Support linking to and moving to line number in pages (#988)
Support linking to and moving to line number in pagespull/995/head
parent
86bee00e5e
commit
a0f3f7ef41
|
@ -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) {
|
||||
|
|
|
@ -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");
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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?",
|
||||
|
|
|
@ -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!, {
|
||||
|
|
|
@ -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": () => {
|
||||
|
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue