From a20aed99e2f74ed5f32c1191c47188c6cbbe981c Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Mon, 28 Nov 2022 16:42:54 +0100 Subject: [PATCH] Replace (wiki) links with proper link widgets for better click behavior --- web/cm_plugins/clean.ts | 6 +- web/cm_plugins/link.ts | 124 ++++++++++++++++++---------- web/cm_plugins/util.ts | 25 ++++++ web/cm_plugins/wiki_link.ts | 158 ++++++++++++++++++++---------------- web/editor.tsx | 3 + web/styles/theme.scss | 1 + website/CHANGELOG.md | 1 + 7 files changed, 198 insertions(+), 120 deletions(-) diff --git a/web/cm_plugins/clean.ts b/web/cm_plugins/clean.ts index c46bc2b..0e12f2b 100644 --- a/web/cm_plugins/clean.ts +++ b/web/cm_plugins/clean.ts @@ -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[]; } diff --git a/web/cm_plugins/link.ts b/web/cm_plugins/link.ts index a02dd27..08027c7 100644 --- a/web/cm_plugins/link.ts +++ b/web/cm_plugins/link.ts @@ -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,54 +6,91 @@ 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) { - const widgets: any[] = []; +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) { - widgets.push( - ...marks.map(({ from, to }) => invisibleDecoration.range(from, to)), - invisibleDecoration.range(from, to), - ); + 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( + 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); + } + update(update: ViewUpdate) { + if ( + update.docChanged || + update.viewportChanged || + update.selectionSet + ) { + this.decorations = this.calculateDecorations(); } } }, - }); - - return Decoration.set(widgets, true); + { decorations: (v) => v.decorations }, + ); } - -export const goToLinkPlugin = ViewPlugin.fromClass( - class { - decorations: DecorationSet = Decoration.none; - constructor(view: EditorView) { - this.decorations = getLinkAnchor(view); - } - update(update: ViewUpdate) { - if ( - update.docChanged || - update.viewportChanged || - update.selectionSet - ) { - this.decorations = getLinkAnchor(update.view); - } - } - }, - { decorations: (v) => v.decorations }, -); diff --git a/web/cm_plugins/util.ts b/web/cm_plugins/util.ts index 2129a9a..4c7d576 100644 --- a/web/cm_plugins/util.ts +++ b/web/cm_plugins/util.ts @@ -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 diff --git a/web/cm_plugins/wiki_link.ts b/web/cm_plugins/wiki_link.ts index ce642c2..38c6955 100644 --- a/web/cm_plugins/wiki_link.ts +++ b/web/cm_plugins/wiki_link.ts @@ -1,92 +1,106 @@ 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 { - decorations: DecorationSet; - constructor(view: EditorView) { - this.decorations = this.compute(view); - } - update(update: ViewUpdate) { - if (update.docChanged || update.viewportChanged || update.selectionSet) { - this.decorations = this.compute(update.view); - } - } - compute(view: EditorView): DecorationSet { - const widgets: any[] = []; - // let parentRange: [number, number]; - iterateTreeInVisibleRanges(view, { - enter: ({ type, from, to }) => { - if (type.name === "WikiLink") { - // 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 [[ - 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; - - if (!pipePart) { - // No alias, let's check if there's a slash in the page name - if (text.indexOf("/") === -1) { +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 + ) { + this.decorations = this.compute(update.view); + } + } + compute(view: EditorView): DecorationSet { + const widgets: any[] = []; + // let parentRange: [number, number]; + iterateTreeInVisibleRanges(view, { + enter: ({ type, from, to }) => { + 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 a inivisible decoration to hide the path prefix - 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 | - ), - ); - } - } - }, - }); - return Decoration.set(widgets, true); - } -} -export const cleanWikiLinkPlugin = () => [ - ViewPlugin.fromClass(CleanWikiLinkPlugin, { - decorations: (v) => v.decorations, - }), -]; + 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, + to, + ), + ); + + 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()!; + } + + // And replace it with a widget + widgets.push( + 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); + } + }, + { + decorations: (v) => v.decorations, + }, + ); +} diff --git a/web/editor.tsx b/web/editor.tsx index 2f2f667..65a9779 100644 --- a/web/editor.tsx +++ b/web/editor.tsx @@ -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, }); } diff --git a/web/styles/theme.scss b/web/styles/theme.scss index 29904b0..5e9652c 100644 --- a/web/styles/theme.scss +++ b/web/styles/theme.scss @@ -340,6 +340,7 @@ border-radius: 5px; padding: 0 5px; white-space: nowrap; + text-decoration: none; cursor: pointer; } diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 7e87e1a..a7ae3ff 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -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. ---