From a0f3f7ef414d64a779b812e544d74365ce46d67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20S=2E=20=C5=81ukasiewicz?= Date: Sun, 28 Jul 2024 20:31:37 +0200 Subject: [PATCH] Support linking to and moving to line number in pages (#988) Support linking to and moving to line number in pages --- common/query_functions.ts | 11 ++++++-- plug-api/lib/page_ref.test.ts | 20 +++++++++++++++ plug-api/lib/page_ref.ts | 48 ++++++++++++++++++++++++++++++++--- plug-api/syscalls/editor.ts | 8 ++++++ plugs/editor/editor.plug.yaml | 8 ++++-- plugs/editor/editor.ts | 27 +++++++++++++++++++- plugs/tasks/task.ts | 18 ++++++++----- web/client.ts | 12 ++++++++- web/syscalls/editor.ts | 18 ++++++++++++- website/CHANGELOG.md | 3 +-- website/Links.md | 5 +++- website/Plugs/Editor.md | 1 + 12 files changed, 159 insertions(+), 20 deletions(-) diff --git a/common/query_functions.ts b/common/query_functions.ts index a9aa7edc..a2728a9c 100644 --- a/common/query_functions.ts +++ b/common/query_functions.ts @@ -2,7 +2,7 @@ import { FunctionMap, Query } from "$sb/types.ts"; import { builtinFunctions } from "$lib/builtin_query_functions.ts"; import { System } from "$lib/plugos/system.ts"; import { LimitedMap } from "$lib/limited_map.ts"; -import { parsePageRef } from "$sb/lib/page_ref.ts"; +import { parsePageRef, positionOfLine } from "$sb/lib/page_ref.ts"; import { parse } from "$common/markdown_parser/parse_tree.ts"; import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts"; import { traverseTree } from "$sb/lib/tree.ts"; @@ -86,7 +86,7 @@ export function buildQueryFunctions( return renderToText(tree); }, - // INTERNAL: Used to implement resolving [[links]] in expressions, also supports [[link#header]] and [[link$pos]] as well as [[link$anchor]] + // INTERNAL: Used to implement resolving [[links]] in expressions, also supports [[link#header]] and [[link@pos]] as well as [[link$anchor]] async readPage(name: string): Promise { const cachedPage = pageCache.get(name); if (cachedPage) { @@ -100,6 +100,13 @@ export function buildQueryFunctions( // Extract page section if pos, anchor, or header are included if (pageRef.pos) { + if (pageRef.pos instanceof Object) { + pageRef.pos = positionOfLine( + page, + pageRef.pos.line, + pageRef.pos.column, + ); + } // If the page link includes a position, slice the page from that position page = page.slice(pageRef.pos); } else if (pageRef.anchor) { diff --git a/plug-api/lib/page_ref.test.ts b/plug-api/lib/page_ref.test.ts index d58393f9..7348a4de 100644 --- a/plug-api/lib/page_ref.test.ts +++ b/plug-api/lib/page_ref.test.ts @@ -10,6 +10,18 @@ Deno.test("Page utility functions", () => { assertEquals(parsePageRef("foo"), { page: "foo" }); assertEquals(parsePageRef("[[foo]]"), { page: "foo" }); assertEquals(parsePageRef("foo@1"), { page: "foo", pos: 1 }); + assertEquals(parsePageRef("foo@L1"), { + page: "foo", + pos: { line: 1, column: 1 }, + }); + assertEquals(parsePageRef("foo@L2C3"), { + page: "foo", + pos: { line: 2, column: 3 }, + }); + assertEquals(parsePageRef("foo@l2c3"), { + page: "foo", + pos: { line: 2, column: 3 }, + }); assertEquals(parsePageRef("foo$bar"), { page: "foo", anchor: "bar" }); assertEquals(parsePageRef("foo#My header"), { page: "foo", @@ -31,6 +43,14 @@ Deno.test("Page utility functions", () => { // Encoding assertEquals(encodePageRef({ page: "foo" }), "foo"); assertEquals(encodePageRef({ page: "foo", pos: 10 }), "foo@10"); + assertEquals( + encodePageRef({ page: "foo", pos: { line: 10, column: 1 } }), + "foo@L10", + ); + assertEquals( + encodePageRef({ page: "foo", pos: { line: 10, column: 5 } }), + "foo@L10C5", + ); assertEquals(encodePageRef({ page: "foo", anchor: "bar" }), "foo$bar"); assertEquals(encodePageRef({ page: "foo", header: "bar" }), "foo#bar"); diff --git a/plug-api/lib/page_ref.ts b/plug-api/lib/page_ref.ts index 3bf16b94..5ed74c37 100644 --- a/plug-api/lib/page_ref.ts +++ b/plug-api/lib/page_ref.ts @@ -21,14 +21,14 @@ export function validatePageName(name: string) { export type PageRef = { page: string; - pos?: number; + pos?: number | { line: number; column: number }; anchor?: string; header?: string; meta?: boolean; }; const posRegex = /@(\d+)$/; -// Should be kept in sync with the regex in index.plug.yaml +const linePosRegex = /@[Ll](\d+)(?:[Cc](\d+))?$/; // column is optional, implicit 1 const anchorRegex = /\$([a-zA-Z\.\-\/]+[\w\.\-\/]*)$/; const headerRegex = /#([^#]*)$/; @@ -39,7 +39,7 @@ export function parsePageRef(name: string): PageRef { } const pageRef: PageRef = { page: name }; if (pageRef.page.startsWith("^")) { - // A carrot prefix means we're looking for a meta page, but that doesn't matter for most use cases + // A caret prefix means we're looking for a meta page, but that doesn't matter for most use cases pageRef.page = pageRef.page.slice(1); pageRef.meta = true; } @@ -48,6 +48,15 @@ export function parsePageRef(name: string): PageRef { pageRef.pos = parseInt(posMatch[1]); pageRef.page = pageRef.page.replace(posRegex, ""); } + const linePosMatch = pageRef.page.match(linePosRegex); + if (linePosMatch) { + pageRef.pos = { line: parseInt(linePosMatch[1]), column: 1 }; + if (linePosMatch[2]) { + pageRef.pos.column = parseInt(linePosMatch[2]); + } + pageRef.page = pageRef.page.replace(linePosRegex, ""); + } + const anchorMatch = pageRef.page.match(anchorRegex); if (anchorMatch) { pageRef.anchor = anchorMatch[1]; @@ -58,13 +67,21 @@ export function parsePageRef(name: string): PageRef { pageRef.header = headerMatch[1]; pageRef.page = pageRef.page.replace(headerRegex, ""); } + return pageRef; } export function encodePageRef(pageRef: PageRef): string { let name = pageRef.page; if (pageRef.pos) { - name += `@${pageRef.pos}`; + if (pageRef.pos instanceof Object) { + name += `@L${pageRef.pos.line}`; + if (pageRef.pos.column !== 1) { + name += `C${pageRef.pos.column}`; + } + } else { // just a number + name += `@${pageRef.pos}`; + } } if (pageRef.anchor) { name += `$${pageRef.anchor}`; @@ -74,3 +91,26 @@ export function encodePageRef(pageRef: PageRef): string { } return name; } + +/** + * Translate line and column number (counting from 1) to position in text (counting from 0) + */ +export function positionOfLine( + text: string, + line: number, + column: number, +): number { + const lines = text.split("\n"); + let targetLine = ""; + let targetPos = 0; + for (let i = 0; i < line && lines.length; i++) { + targetLine = lines[i]; + targetPos += targetLine.length; + } + // How much to move inside the line, column number starts from 1 + const columnOffset = Math.max( + 0, + Math.min(targetLine.length, column - 1), + ); + return targetPos - targetLine.length + columnOffset; +} diff --git a/plug-api/syscalls/editor.ts b/plug-api/syscalls/editor.ts index 17e3d413..c270df78 100644 --- a/plug-api/syscalls/editor.ts +++ b/plug-api/syscalls/editor.ts @@ -141,6 +141,14 @@ export function moveCursor(pos: number, center = false): Promise { return syscall("editor.moveCursor", pos, center); } +export function moveCursorToLine( + line: number, + column = 1, + center = false, +): Promise { + return syscall("editor.moveCursorToLine", line, column, center); +} + export function insertAtCursor(text: string): Promise { return syscall("editor.insertAtCursor", text); } diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml index dd29c952..89f220f2 100644 --- a/plugs/editor/editor.plug.yaml +++ b/plugs/editor/editor.plug.yaml @@ -92,7 +92,11 @@ functions: moveToPos: path: "./editor.ts:moveToPosCommand" command: - name: "Navigate: Move Cursor to Position" + name: "Navigate: To Position" + moveToLine: + path: "./editor.ts:moveToLineCommand" + command: + name: "Navigate: To Line" navigateToPage: path: "./navigate.ts:navigateToPage" command: @@ -329,7 +333,7 @@ functions: name: "Editor: Find in Page" key: "Ctrl-f" mac: "Cmd-f" - + # Outline helper functions determineItemBounds: path: ./outline.ts:determineItemBounds diff --git a/plugs/editor/editor.ts b/plugs/editor/editor.ts index 50dd5a92..c51ad666 100644 --- a/plugs/editor/editor.ts +++ b/plugs/editor/editor.ts @@ -44,7 +44,32 @@ export async function moveToPosCommand() { return; } const pos = +posString; - await editor.moveCursor(pos); + await editor.moveCursor(pos, true); // showing the movement for better UX +} + +export async function moveToLineCommand() { + const lineString = await editor.prompt( + "Move to line (and optionally column):", + ); + if (!lineString) { + return; + } + // Match sequence of digits at the start, optionally another sequence + const numberRegex = /^(\d+)(?:[^\d]+(\d+))?/; + const match = lineString.match(numberRegex); + if (!match) { + await editor.flashNotification( + "Could not parse line number in prompt", + "error", + ); + return; + } + let column = 1; + const line = parseInt(match[1]); + if (match[2]) { + column = parseInt(match[2]); + } + await editor.moveCursorToLine(line, column, true); // showing the movement for better UX } export async function customFlashMessage(_def: any, message: string) { diff --git a/plugs/tasks/task.ts b/plugs/tasks/task.ts index 16bb82be..3edf29c0 100644 --- a/plugs/tasks/task.ts +++ b/plugs/tasks/task.ts @@ -21,7 +21,7 @@ import { ObjectValue } from "../../plug-api/types.ts"; import { indexObjects, queryObjects } from "../index/plug_api.ts"; import { updateITags } from "$sb/lib/tags.ts"; import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; -import { parsePageRef } from "$sb/lib/page_ref.ts"; +import { parsePageRef, positionOfLine } from "$sb/lib/page_ref.ts"; export type TaskObject = ObjectValue< { @@ -219,10 +219,13 @@ export async function updateTaskState( if (page === currentPage) { // In current page, just update the task marker with dispatch const editorText = await editor.getText(); + const targetPos = pos instanceof Object + ? positionOfLine(editorText, pos.line, pos.column) + : pos; // Check if the task state marker is still there const targetText = editorText.substring( - pos + 1, - pos + 1 + oldState.length, + targetPos + 1, + targetPos + 1 + oldState.length, ); if (targetText !== oldState) { console.error( @@ -233,8 +236,8 @@ export async function updateTaskState( } await editor.dispatch({ changes: { - from: pos + 1, - to: pos + 1 + oldState.length, + from: targetPos + 1, + to: targetPos + 1 + oldState.length, insert: newState, }, }); @@ -242,8 +245,11 @@ export async function updateTaskState( let text = await space.readPage(page); const referenceMdTree = await markdown.parseMarkdown(text); + const targetPos = pos instanceof Object + ? positionOfLine(text, pos.line, pos.column) + : pos; // Adding +1 to immediately hit the task state node - const taskStateNode = nodeAtPos(referenceMdTree, pos + 1); + const taskStateNode = nodeAtPos(referenceMdTree, targetPos + 1); if (!taskStateNode || taskStateNode.type !== "TaskState") { console.error( "Reference not a task marker, out of date?", diff --git a/web/client.ts b/web/client.ts index ad9c012e..679797cc 100644 --- a/web/client.ts +++ b/web/client.ts @@ -358,7 +358,8 @@ export class Client { } // Was there a pos or anchor set? - let pos: number | undefined = pageState.pos; + let pos: number | { line: number; column: number } | undefined = + pageState.pos; if (pageState.anchor) { console.log("Navigating to anchor", pageState.anchor); const pageText = this.editorView.state.sliceDoc(); @@ -404,6 +405,15 @@ export class Client { adjustedPosition = true; } if (pos !== undefined) { + // Translate line and column number to position in text + if (pos instanceof Object) { + // CodeMirror already keeps information about lines + const cmLine = this.editorView.state.doc.line(pos.line); + // How much to move inside the line, column number starts from 1 + const offset = Math.max(0, Math.min(cmLine.length, pos.column - 1)); + pos = cmLine.from + offset; + } + this.editorView.dispatch({ selection: { anchor: pos! }, effects: EditorView.scrollIntoView(pos!, { diff --git a/web/syscalls/editor.ts b/web/syscalls/editor.ts index 21327afd..b5406adf 100644 --- a/web/syscalls/editor.ts +++ b/web/syscalls/editor.ts @@ -214,6 +214,19 @@ export function editorSyscalls(client: Client): SysCallMapping { } client.editorView.focus(); }, + "editor.moveCursorToLine": ( + _ctx, + line: number, + column = 1, + center = false, + ) => { + // CodeMirror already keeps information about lines + const cmLine = client.editorView.state.doc.line(line); + // How much to move inside the line, column number starts from 1 + const offset = Math.max(0, Math.min(cmLine.length, column - 1)); + // Just reuse the implementation above + syscalls["editor.moveCursor"](_ctx, cmLine.from + offset, center); + }, "editor.setSelection": (_ctx, from: number, to: number) => { client.editorView.dispatch({ selection: { @@ -263,7 +276,10 @@ export function editorSyscalls(client: Client): SysCallMapping { const cm = vimGetCm(client.editorView)!; return Vim.handleEx(cm, exCommand); }, - "editor.openPageNavigator": (_ctx, mode: "page" | "meta" | "all" = "page") => { + "editor.openPageNavigator": ( + _ctx, + mode: "page" | "meta" | "all" = "page", + ) => { client.startPageNavigate(mode); }, "editor.openCommandPalette": () => { diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 4ec2dc21..1c9dfe38 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -1,5 +1,4 @@ -An attempt at documenting the changes/new features introduced in each -release. +An attempt at documenting the changes/new features introduced in each release. --- diff --git a/website/Links.md b/website/Links.md index 0d757ab9..8177e7f7 100644 --- a/website/Links.md +++ b/website/Links.md @@ -11,7 +11,10 @@ Internal links can have various formats: * `[[CHANGELOG|The Change Log]]`: a link with an alias that appears like this: [[CHANGELOG|The Change Log]]. * `[[CHANGELOG$edge]]`: a link referencing a particular [[Markdown/Anchors|anchor]]: [[CHANGELOG$edge]]. When the page name is omitted, the anchor is expected to be local to the current page. * `[[CHANGELOG#Edge]]`: a link referencing a particular header: [[CHANGELOG#Edge]]. When the page name is omitted, the header is expected to be local to the current page. -* `[[CHANGELOG@1234]]`: a link referencing a particular position in a page (characters from the start of the document). This notation is generally automatically generated through templates. +* `[[CHANGELOG@...]]`: a link referencing a particular position in a page. This notation is generally automatically generated through templates. + * `[[CHANGELOG@1234]]`: character in text (starting from 0): [[CHANGELOG@1234]] + * `[[CHANGELOG@L3]]`: line of text (starting from 1): [[CHANGELOG@L3]]. When column number is omitted it is assumed to be start of line. This starts from one to match the convention widely used in other text editors. + * `[[CHANGELOG@L1C3]]`: line and column: [[CHANGELOG@L1C3]]. This also starts from 1 for the start of line. The cursor will be placed at the end of line if the passed number is larger than the line # Caret page links [[Meta Pages]] are excluded from link auto complete in many contexts. However, you may still want to reference a meta page outside of a “meta context.” To make it easier to reference, you can use the caret syntax: `[[^SETTINGS]]`. Semantically this has the same meaning as `[[SETTINGS]]`. The only difference is that auto complete will _only_ complete meta pages. diff --git a/website/Plugs/Editor.md b/website/Plugs/Editor.md index cd064a40..b729d792 100644 --- a/website/Plugs/Editor.md +++ b/website/Plugs/Editor.md @@ -20,6 +20,7 @@ The `editor` plug implements foundational editor functionality for SilverBullet. * {[Navigate: To This Page]}: navigate to the page under the cursor * {[Navigate: Center Cursor]}: center the cursor at the center of the screen * {[Navigate: Move Cursor to Position]}: move cursor to a specific (numeric) cursor position (# of characters from the start of the document) +* {[Navigate: Move Cursor to Line]}: move cursor to a specific line, counting from 1; write two numbers (separated by any non-digit) to also move to a column, counting from 1. ## Text editing * {[Text: Quote Selection]}: turns the selection into a blockquote (`>` prefix)