1
0

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

This commit is contained in:
Zef Hemel 2022-11-28 16:42:54 +01:00
parent cab0c5aa23
commit a20aed99e2
7 changed files with 198 additions and 120 deletions

View File

@ -5,7 +5,7 @@ import { blockquotePlugin } from "./block_quote.ts";
import { directivePlugin } from "./directive.ts";
import { hideHeaderMarkPlugin, hideMarks } from "./hide_mark.ts";
import { cleanBlockPlugin } from "./block.ts";
import { goToLinkPlugin } from "./link.ts";
import { linkPlugin } from "./link.ts";
import { listBulletPlugin } from "./list.ts";
import { tablePlugin } from "./table.ts";
import { taskListPlugin } from "./task.ts";
@ -13,7 +13,7 @@ import { cleanWikiLinkPlugin } from "./wiki_link.ts";
export function cleanModePlugins(editor: Editor) {
return [
goToLinkPlugin,
linkPlugin(editor),
directivePlugin,
blockquotePlugin,
hideMarks(),
@ -35,6 +35,6 @@ export function cleanModePlugins(editor: Editor) {
}),
listBulletPlugin,
tablePlugin,
cleanWikiLinkPlugin(),
cleanWikiLinkPlugin(editor),
] as Extension[];
}

View File

@ -1,7 +1,4 @@
// Forked from https://codeberg.org/retronav/ixora
// Original author: Pranav Karawale
// License: Apache License 2.0.
import { ClickEvent } from "../../plug-api/app_event.ts";
import {
Decoration,
DecorationSet,
@ -9,44 +6,80 @@ import {
ViewPlugin,
ViewUpdate,
} from "../deps.ts";
import { Editor } from "../editor.tsx";
import {
checkRangeOverlap,
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
} 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 view = this.view;
iterateTreeInVisibleRanges(view, {
enter: ({ type, from, to, node }) => {
if (type.name !== "URL") return;
const parent = node.parent;
const blackListedParents = ["Image"];
if (parent && !blackListedParents.includes(parent.name)) {
const marks = parent.getChildren("LinkMark");
const ranges = view.state.selection.ranges;
const cursorOverlaps = ranges.some(({ from, to }) =>
checkRangeOverlap([from, to], [parent.from, parent.to])
);
if (!cursorOverlaps) {
iterateTreeInVisibleRanges(this.view, {
enter: ({ type, from, to }) => {
if (type.name !== "Link") {
return;
}
// Adding 2 on each side due to [[ and ]] that are outside the WikiLinkPage node
if (isCursorInRange(view.state, [from, to])) {
return;
}
// Hide the whole thing
widgets.push(
...marks.map(({ from, to }) => invisibleDecoration.range(from, to)),
invisibleDecoration.range(from, to),
invisibleDecoration.range(
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);
}
export const goToLinkPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet = Decoration.none;
constructor(view: EditorView) {
this.decorations = getLinkAnchor(view);
}
update(update: ViewUpdate) {
if (
@ -54,9 +87,10 @@ export const goToLinkPlugin = ViewPlugin.fromClass(
update.viewportChanged ||
update.selectionSet
) {
this.decorations = getLinkAnchor(update.view);
this.decorations = this.calculateDecorations();
}
}
},
{ decorations: (v) => v.decorations },
);
);
}

View File

@ -8,8 +8,33 @@ import {
foldedRanges,
SyntaxNodeRef,
syntaxTree,
WidgetType,
} 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
* Based on the visual diagram on https://stackoverflow.com/a/25369187

View File

@ -1,27 +1,35 @@
import { pageLinkRegex } from "../../common/parser.ts";
import { ClickEvent } from "../../plug-api/app_event.ts";
import {
Decoration,
DecorationSet,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "../deps.ts";
import { Editor } from "../editor.tsx";
import {
invisibleDecoration,
isCursorInRange,
iterateTreeInVisibleRanges,
LinkWidget,
} from "./util.ts";
/**
* Plugin to hide path prefix when the cursor is not inside.
*/
class CleanWikiLinkPlugin {
export function cleanWikiLinkPlugin(editor: Editor) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = this.compute(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged || update.selectionSet) {
if (
update.docChanged || update.viewportChanged || update.selectionSet
) {
this.decorations = this.compute(update.view);
}
}
@ -30,63 +38,69 @@ class CleanWikiLinkPlugin {
// let parentRange: [number, number];
iterateTreeInVisibleRanges(view, {
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
if (isCursorInRange(view.state, [from, to])) {
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(
invisibleDecoration.range(
from,
from + 2,
),
);
// Add decoration to hide the postfix [[
widgets.push(
invisibleDecoration.range(
to - 2,
to,
),
);
// Now check if this page has an alias
const text = view.state.sliceDoc(from, to);
const match = pageLinkRegex.exec(text);
if (!match) return;
const [_fullMatch, page, pipePart] = match;
let linkText = alias || page;
if (!pipePart && text.indexOf("/") !== -1) {
// Let's use the last part of the path as the link text
linkText = page.split("/").pop()!;
}
if (!pipePart) {
// 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
// And replace it with a widget
widgets.push(
invisibleDecoration.range(
from + 2, // +2 to skip the [[
from + text.lastIndexOf("/") + 1,
),
);
} else {
// Alias is present, so we hide the part before the pipe
widgets.push(
invisibleDecoration.range(
from + 2,
from + page.length + 3, // 3 is for the [[ and the |
),
);
}
Decoration.widget({
widget: new LinkWidget(
linkText,
page,
"sb-wiki-link-page",
(e) => {
if (e.altKey) {
// Move cursor into the link
return view.dispatch({
selection: { anchor: from + 2 },
});
}
// 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);
}
}
export const cleanWikiLinkPlugin = () => [
ViewPlugin.fromClass(CleanWikiLinkPlugin, {
},
{
decorations: (v) => v.decorations,
}),
];
},
);
}

View File

@ -307,8 +307,11 @@ export class Editor {
// Frotnmatter found, put cursor after it
initialCursorPos = match[0].length;
}
// By default scroll to the top
this.editorView.scrollDOM.scrollTop = 0;
this.editorView.dispatch({
selection: { anchor: initialCursorPos },
// And then scroll down if required
scrollIntoView: true,
});
}

View File

@ -340,6 +340,7 @@
border-radius: 5px;
padding: 0 5px;
white-space: nowrap;
text-decoration: none;
cursor: pointer;
}

View File

@ -6,6 +6,7 @@ release.
## 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]].
* 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.
---