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 { 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<string> {
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) {

View File

@ -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");

View File

@ -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;
}

View File

@ -141,6 +141,14 @@ export function moveCursor(pos: number, center = false): Promise<void> {
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> {
return syscall("editor.insertAtCursor", text);
}

View File

@ -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:

View File

@ -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) {

View File

@ -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?",

View File

@ -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!, {

View File

@ -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": () => {

View File

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

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$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.

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: 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)