Support linking to and moving to line number in pages (#988)

Support linking to and moving to line number in pages
pull/995/head
Marek S. Łukasiewicz 2024-07-28 20:31:37 +02:00 committed by GitHub
parent 86bee00e5e
commit a0f3f7ef41
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 159 additions and 20 deletions

View File

@ -2,7 +2,7 @@ import { FunctionMap, Query } from "$sb/types.ts";
import { builtinFunctions } from "$lib/builtin_query_functions.ts"; import { builtinFunctions } from "$lib/builtin_query_functions.ts";
import { System } from "$lib/plugos/system.ts"; import { System } from "$lib/plugos/system.ts";
import { LimitedMap } from "$lib/limited_map.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 { parse } from "$common/markdown_parser/parse_tree.ts";
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts"; import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
import { traverseTree } from "$sb/lib/tree.ts"; import { traverseTree } from "$sb/lib/tree.ts";
@ -86,7 +86,7 @@ export function buildQueryFunctions(
return renderToText(tree); 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<string> { async readPage(name: string): Promise<string> {
const cachedPage = pageCache.get(name); const cachedPage = pageCache.get(name);
if (cachedPage) { if (cachedPage) {
@ -100,6 +100,13 @@ export function buildQueryFunctions(
// Extract page section if pos, anchor, or header are included // Extract page section if pos, anchor, or header are included
if (pageRef.pos) { 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 // If the page link includes a position, slice the page from that position
page = page.slice(pageRef.pos); page = page.slice(pageRef.pos);
} else if (pageRef.anchor) { } else if (pageRef.anchor) {

View File

@ -10,6 +10,18 @@ Deno.test("Page utility functions", () => {
assertEquals(parsePageRef("foo"), { page: "foo" }); assertEquals(parsePageRef("foo"), { page: "foo" });
assertEquals(parsePageRef("[[foo]]"), { page: "foo" }); assertEquals(parsePageRef("[[foo]]"), { page: "foo" });
assertEquals(parsePageRef("foo@1"), { page: "foo", pos: 1 }); 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$bar"), { page: "foo", anchor: "bar" });
assertEquals(parsePageRef("foo#My header"), { assertEquals(parsePageRef("foo#My header"), {
page: "foo", page: "foo",
@ -31,6 +43,14 @@ Deno.test("Page utility functions", () => {
// Encoding // Encoding
assertEquals(encodePageRef({ page: "foo" }), "foo"); assertEquals(encodePageRef({ page: "foo" }), "foo");
assertEquals(encodePageRef({ page: "foo", pos: 10 }), "foo@10"); 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", anchor: "bar" }), "foo$bar");
assertEquals(encodePageRef({ page: "foo", header: "bar" }), "foo#bar"); assertEquals(encodePageRef({ page: "foo", header: "bar" }), "foo#bar");

View File

@ -21,14 +21,14 @@ export function validatePageName(name: string) {
export type PageRef = { export type PageRef = {
page: string; page: string;
pos?: number; pos?: number | { line: number; column: number };
anchor?: string; anchor?: string;
header?: string; header?: string;
meta?: boolean; meta?: boolean;
}; };
const posRegex = /@(\d+)$/; 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 anchorRegex = /\$([a-zA-Z\.\-\/]+[\w\.\-\/]*)$/;
const headerRegex = /#([^#]*)$/; const headerRegex = /#([^#]*)$/;
@ -39,7 +39,7 @@ export function parsePageRef(name: string): PageRef {
} }
const pageRef: PageRef = { page: name }; const pageRef: PageRef = { page: name };
if (pageRef.page.startsWith("^")) { 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.page = pageRef.page.slice(1);
pageRef.meta = true; pageRef.meta = true;
} }
@ -48,6 +48,15 @@ export function parsePageRef(name: string): PageRef {
pageRef.pos = parseInt(posMatch[1]); pageRef.pos = parseInt(posMatch[1]);
pageRef.page = pageRef.page.replace(posRegex, ""); 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); const anchorMatch = pageRef.page.match(anchorRegex);
if (anchorMatch) { if (anchorMatch) {
pageRef.anchor = anchorMatch[1]; pageRef.anchor = anchorMatch[1];
@ -58,13 +67,21 @@ export function parsePageRef(name: string): PageRef {
pageRef.header = headerMatch[1]; pageRef.header = headerMatch[1];
pageRef.page = pageRef.page.replace(headerRegex, ""); pageRef.page = pageRef.page.replace(headerRegex, "");
} }
return pageRef; return pageRef;
} }
export function encodePageRef(pageRef: PageRef): string { export function encodePageRef(pageRef: PageRef): string {
let name = pageRef.page; let name = pageRef.page;
if (pageRef.pos) { 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) { if (pageRef.anchor) {
name += `$${pageRef.anchor}`; name += `$${pageRef.anchor}`;
@ -74,3 +91,26 @@ export function encodePageRef(pageRef: PageRef): string {
} }
return name; 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;
}

View File

@ -141,6 +141,14 @@ export function moveCursor(pos: number, center = false): Promise<void> {
return syscall("editor.moveCursor", pos, center); return syscall("editor.moveCursor", pos, center);
} }
export function moveCursorToLine(
line: number,
column = 1,
center = false,
): Promise<void> {
return syscall("editor.moveCursorToLine", line, column, center);
}
export function insertAtCursor(text: string): Promise<void> { export function insertAtCursor(text: string): Promise<void> {
return syscall("editor.insertAtCursor", text); return syscall("editor.insertAtCursor", text);
} }

View File

@ -92,7 +92,11 @@ functions:
moveToPos: moveToPos:
path: "./editor.ts:moveToPosCommand" path: "./editor.ts:moveToPosCommand"
command: command:
name: "Navigate: Move Cursor to Position" name: "Navigate: To Position"
moveToLine:
path: "./editor.ts:moveToLineCommand"
command:
name: "Navigate: To Line"
navigateToPage: navigateToPage:
path: "./navigate.ts:navigateToPage" path: "./navigate.ts:navigateToPage"
command: command:

View File

@ -44,7 +44,32 @@ export async function moveToPosCommand() {
return; return;
} }
const pos = +posString; 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) { export async function customFlashMessage(_def: any, message: string) {

View File

@ -21,7 +21,7 @@ import { ObjectValue } from "../../plug-api/types.ts";
import { indexObjects, queryObjects } from "../index/plug_api.ts"; import { indexObjects, queryObjects } from "../index/plug_api.ts";
import { updateITags } from "$sb/lib/tags.ts"; import { updateITags } from "$sb/lib/tags.ts";
import { extractFrontmatter } from "$sb/lib/frontmatter.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< export type TaskObject = ObjectValue<
{ {
@ -219,10 +219,13 @@ export async function updateTaskState(
if (page === currentPage) { if (page === currentPage) {
// In current page, just update the task marker with dispatch // In current page, just update the task marker with dispatch
const editorText = await editor.getText(); 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 // Check if the task state marker is still there
const targetText = editorText.substring( const targetText = editorText.substring(
pos + 1, targetPos + 1,
pos + 1 + oldState.length, targetPos + 1 + oldState.length,
); );
if (targetText !== oldState) { if (targetText !== oldState) {
console.error( console.error(
@ -233,8 +236,8 @@ export async function updateTaskState(
} }
await editor.dispatch({ await editor.dispatch({
changes: { changes: {
from: pos + 1, from: targetPos + 1,
to: pos + 1 + oldState.length, to: targetPos + 1 + oldState.length,
insert: newState, insert: newState,
}, },
}); });
@ -242,8 +245,11 @@ export async function updateTaskState(
let text = await space.readPage(page); let text = await space.readPage(page);
const referenceMdTree = await markdown.parseMarkdown(text); 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 // 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") { if (!taskStateNode || taskStateNode.type !== "TaskState") {
console.error( console.error(
"Reference not a task marker, out of date?", "Reference not a task marker, out of date?",

View File

@ -358,7 +358,8 @@ export class Client {
} }
// Was there a pos or anchor set? // 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) { if (pageState.anchor) {
console.log("Navigating to anchor", pageState.anchor); console.log("Navigating to anchor", pageState.anchor);
const pageText = this.editorView.state.sliceDoc(); const pageText = this.editorView.state.sliceDoc();
@ -404,6 +405,15 @@ export class Client {
adjustedPosition = true; adjustedPosition = true;
} }
if (pos !== undefined) { 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({ this.editorView.dispatch({
selection: { anchor: pos! }, selection: { anchor: pos! },
effects: EditorView.scrollIntoView(pos!, { effects: EditorView.scrollIntoView(pos!, {

View File

@ -214,6 +214,19 @@ export function editorSyscalls(client: Client): SysCallMapping {
} }
client.editorView.focus(); 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) => { "editor.setSelection": (_ctx, from: number, to: number) => {
client.editorView.dispatch({ client.editorView.dispatch({
selection: { selection: {
@ -263,7 +276,10 @@ export function editorSyscalls(client: Client): SysCallMapping {
const cm = vimGetCm(client.editorView)!; const cm = vimGetCm(client.editorView)!;
return Vim.handleEx(cm, exCommand); return Vim.handleEx(cm, exCommand);
}, },
"editor.openPageNavigator": (_ctx, mode: "page" | "meta" | "all" = "page") => { "editor.openPageNavigator": (
_ctx,
mode: "page" | "meta" | "all" = "page",
) => {
client.startPageNavigate(mode); client.startPageNavigate(mode);
}, },
"editor.openCommandPalette": () => { "editor.openCommandPalette": () => {

View File

@ -1,5 +1,4 @@
An attempt at documenting the changes/new features introduced in each An attempt at documenting the changes/new features introduced in each release.
release.
--- ---

View File

@ -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|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 [[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#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 # 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. [[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.

View File

@ -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: To This Page]}: navigate to the page under the cursor
* {[Navigate: Center Cursor]}: center the cursor at the center of the screen * {[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 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 editing
* {[Text: Quote Selection]}: turns the selection into a blockquote (`>` prefix) * {[Text: Quote Selection]}: turns the selection into a blockquote (`>` prefix)