685 lines
18 KiB
TypeScript
685 lines
18 KiB
TypeScript
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
|
|
import {
|
|
addParentPointers,
|
|
findParentMatching,
|
|
nodeAtPos,
|
|
type ParseTree,
|
|
renderToText,
|
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
|
|
|
export type OutlineResult = { text: string; cursor: number } | "blocked" | null;
|
|
|
|
export type ListContext = {
|
|
type: "listItem";
|
|
item: ParseTree;
|
|
itemIndex: number; // index in parent.children (includes separator text nodes)
|
|
list: ParseTree; // BulletList or OrderedList
|
|
};
|
|
|
|
export type HeadingContext = {
|
|
type: "heading";
|
|
level: number;
|
|
// Section: range of Document.children indices [start, end) for this heading section
|
|
sectionStart: number; // index in Document.children
|
|
sectionEnd: number; // exclusive index in Document.children
|
|
doc: ParseTree;
|
|
};
|
|
|
|
export type ParagraphContext = {
|
|
type: "paragraph";
|
|
blockIndex: number; // index in Document.children
|
|
doc: ParseTree;
|
|
};
|
|
|
|
export type TableRowContext = {
|
|
type: "tableRow";
|
|
row: ParseTree; // TableRow or TableHeader node
|
|
rowIndex: number; // index in Table.children
|
|
isHeader: boolean; // true if cursor is on the TableHeader
|
|
table: ParseTree;
|
|
};
|
|
|
|
export type CursorContext =
|
|
| ListContext
|
|
| HeadingContext
|
|
| ParagraphContext
|
|
| TableRowContext;
|
|
|
|
/**
|
|
* Classifies the cursor position as a listItem, heading, tableRow, or
|
|
* paragraph context for outline operations. At node boundaries, resolves to
|
|
* the preceding typed node (drilling into nested children). Returns null for
|
|
* positions where no outline operation applies (code blocks, frontmatter,
|
|
* past end of document).
|
|
*/
|
|
export function detectContext(
|
|
tree: ParseTree,
|
|
cursor: number,
|
|
): CursorContext | null {
|
|
addParentPointers(tree);
|
|
const initial = nodeAtPos(tree, cursor);
|
|
if (!initial) {
|
|
return null;
|
|
}
|
|
|
|
// When cursor is on a separator between typed children (nodeAtPos returns
|
|
// a container like Document, BulletList, or ListItem), check if it's right
|
|
// at the end of a preceding typed child — if so, drill into that child.
|
|
// Repeat recursively so we reach the deepest matching node (e.g. a nested
|
|
// ListItem rather than its parent). This handles cursor-at-end-of-line.
|
|
let node: ParseTree = initial;
|
|
while (node.children) {
|
|
let found: ParseTree | null = null;
|
|
for (let i = node.children.length - 1; i >= 0; i--) {
|
|
const child = node.children[i];
|
|
if (child.type && child.to === cursor) {
|
|
found = child;
|
|
break;
|
|
}
|
|
}
|
|
if (!found) {
|
|
break;
|
|
}
|
|
node = found;
|
|
}
|
|
|
|
// Check if we're in a list item
|
|
// If nodeAtPos landed on a BulletList/OrderedList itself (i.e. on separator
|
|
// whitespace between items), don't walk up — cursor isn't on any item.
|
|
const listItem =
|
|
node.type === "ListItem"
|
|
? node
|
|
: node.type === "BulletList" || node.type === "OrderedList"
|
|
? null
|
|
: findParentMatching(node, (n) => n.type === "ListItem");
|
|
if (listItem) {
|
|
const list = listItem.parent;
|
|
if (list && (list.type === "BulletList" || list.type === "OrderedList")) {
|
|
const itemIndex = list.children!.indexOf(listItem);
|
|
return { type: "listItem", item: listItem, itemIndex, list };
|
|
}
|
|
}
|
|
|
|
// Check if we're in a heading
|
|
const heading = node.type?.startsWith("ATXHeading")
|
|
? node
|
|
: findParentMatching(
|
|
node,
|
|
(n) => n.type?.startsWith("ATXHeading") ?? false,
|
|
);
|
|
if (heading?.type) {
|
|
const level = parseInt(heading.type.replace("ATXHeading", ""), 10);
|
|
const doc = heading.parent!;
|
|
if (doc.type === "Document" && doc.children) {
|
|
const headingIndex = doc.children.indexOf(heading);
|
|
const sectionStart = headingIndex;
|
|
let sectionEnd = doc.children.length;
|
|
for (let i = headingIndex + 1; i < doc.children.length; i++) {
|
|
const child = doc.children[i];
|
|
if (child.type?.startsWith("ATXHeading")) {
|
|
const childLevel = parseInt(child.type.replace("ATXHeading", ""), 10);
|
|
if (childLevel <= level) {
|
|
sectionEnd = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return { type: "heading", level, sectionStart, sectionEnd, doc };
|
|
}
|
|
}
|
|
|
|
// Check if we're in a table row or header
|
|
const tableRow =
|
|
node.type === "TableRow" || node.type === "TableHeader"
|
|
? node
|
|
: findParentMatching(
|
|
node,
|
|
(n) => n.type === "TableRow" || n.type === "TableHeader",
|
|
);
|
|
if (tableRow && tableRow.parent?.type === "Table") {
|
|
const table = tableRow.parent;
|
|
const rowIndex = table.children!.indexOf(tableRow);
|
|
const isHeader = tableRow.type === "TableHeader";
|
|
return { type: "tableRow", row: tableRow, rowIndex, isHeader, table };
|
|
}
|
|
|
|
// Check if we're in a paragraph at the Document level
|
|
const para =
|
|
node.type === "Paragraph"
|
|
? node
|
|
: findParentMatching(node, (n) => n.type === "Paragraph");
|
|
if (para && para.parent?.type === "Document") {
|
|
const doc = para.parent;
|
|
const blockIndex = doc.children!.indexOf(para);
|
|
return { type: "paragraph", blockIndex, doc };
|
|
}
|
|
|
|
// No context we can do something with
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Returns indices of ListItem children in list.children, skipping separator text nodes.
|
|
*/
|
|
function listItemIndices(list: ParseTree): number[] {
|
|
const indices: number[] = [];
|
|
if (!list.children) {
|
|
return indices;
|
|
}
|
|
for (let i = 0; i < list.children.length; i++) {
|
|
if (list.children[i].type === "ListItem") {
|
|
indices.push(i);
|
|
}
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
/**
|
|
* Swaps two non-overlapping text regions, preserving the separator between
|
|
* them and adjusting the cursor position based on move direction.
|
|
*/
|
|
function swapRegions(
|
|
text: string,
|
|
cursor: number,
|
|
currFrom: number,
|
|
direction: "up" | "down",
|
|
firstFrom: number,
|
|
firstTo: number,
|
|
secondFrom: number,
|
|
secondTo: number,
|
|
): { text: string; cursor: number } {
|
|
const firstContent = text.slice(firstFrom, firstTo);
|
|
const separator = text.slice(firstTo, secondFrom);
|
|
const secondContent = text.slice(secondFrom, secondTo);
|
|
const newText =
|
|
text.slice(0, firstFrom) +
|
|
secondContent +
|
|
separator +
|
|
firstContent +
|
|
text.slice(secondTo);
|
|
const offsetInCurr = cursor - currFrom;
|
|
const newCursor =
|
|
direction === "up"
|
|
? firstFrom + offsetInCurr
|
|
: firstFrom + secondContent.length + separator.length + offsetInCurr;
|
|
return { text: newText, cursor: newCursor };
|
|
}
|
|
|
|
/**
|
|
* Renumbers all ordered list markers (1., 2., ...) sequentially within the
|
|
* given range.
|
|
*/
|
|
function renumberOrderedList(
|
|
text: string,
|
|
listFrom: number,
|
|
listTo: number,
|
|
): string {
|
|
const listText = text.slice(listFrom, listTo);
|
|
let num = 1;
|
|
const renumbered = listText.replace(/^(\s*)(\d+)\./gm, (_match, indent) => {
|
|
return `${indent}${num++}.`;
|
|
});
|
|
return text.slice(0, listFrom) + renumbered + text.slice(listTo);
|
|
}
|
|
|
|
/**
|
|
* Moves the outline element at cursor up or down, dispatching to the
|
|
* appropriate handler.
|
|
*/
|
|
function move(
|
|
text: string,
|
|
cursor: number,
|
|
direction: "up" | "down",
|
|
): OutlineResult {
|
|
const tree = parseMarkdown(text);
|
|
const ctx = detectContext(tree, cursor);
|
|
if (!ctx) {
|
|
return null;
|
|
}
|
|
switch (ctx.type) {
|
|
case "listItem":
|
|
return moveListItem(text, cursor, ctx, direction) ?? "blocked";
|
|
case "heading":
|
|
return moveHeading(text, cursor, ctx, direction) ?? "blocked";
|
|
case "paragraph":
|
|
return moveParagraph(text, cursor, ctx, direction) ?? "blocked";
|
|
case "tableRow":
|
|
return moveTableRow(text, cursor, ctx, direction) ?? "blocked";
|
|
}
|
|
}
|
|
|
|
export const moveUp = (text: string, cursor: number) =>
|
|
move(text, cursor, "up");
|
|
export const moveDown = (text: string, cursor: number) =>
|
|
move(text, cursor, "down");
|
|
|
|
/**
|
|
* Indents or outdents the outline element at cursor, dispatching to the
|
|
* appropriate handler.
|
|
*/
|
|
function adjustLevel(
|
|
text: string,
|
|
cursor: number,
|
|
delta: 1 | -1,
|
|
): OutlineResult {
|
|
const tree = parseMarkdown(text);
|
|
const ctx = detectContext(tree, cursor);
|
|
if (!ctx) {
|
|
return null;
|
|
}
|
|
switch (ctx.type) {
|
|
case "listItem":
|
|
return (
|
|
(delta === 1
|
|
? indentListItem(text, cursor, ctx)
|
|
: outdentListItem(text, cursor, ctx)) ?? "blocked"
|
|
);
|
|
case "heading":
|
|
return adjustHeadingLevel(text, cursor, ctx, delta) ?? "blocked";
|
|
case "paragraph":
|
|
case "tableRow":
|
|
return "blocked";
|
|
}
|
|
}
|
|
|
|
export const indent = (text: string, cursor: number) =>
|
|
adjustLevel(text, cursor, 1);
|
|
export const outdent = (text: string, cursor: number) =>
|
|
adjustLevel(text, cursor, -1);
|
|
|
|
/**
|
|
* Swaps a list item with its adjacent sibling, renumbering if in an ordered
|
|
* list.
|
|
*/
|
|
function moveListItem(
|
|
text: string,
|
|
cursor: number,
|
|
ctx: ListContext,
|
|
direction: "up" | "down",
|
|
): OutlineResult {
|
|
const { item, list } = ctx;
|
|
const indices = listItemIndices(list);
|
|
const itemPos = indices.indexOf(ctx.itemIndex);
|
|
|
|
if (direction === "up" && itemPos <= 0) {
|
|
return null;
|
|
}
|
|
if (direction === "down" && itemPos >= indices.length - 1) {
|
|
return null;
|
|
}
|
|
|
|
const otherPos = direction === "up" ? itemPos - 1 : itemPos + 1;
|
|
const otherIndex = indices[otherPos];
|
|
const otherItem = list.children![otherIndex];
|
|
|
|
const first = direction === "up" ? otherItem : item;
|
|
const second = direction === "up" ? item : otherItem;
|
|
|
|
const result = swapRegions(
|
|
text,
|
|
cursor,
|
|
item.from!,
|
|
direction,
|
|
first.from!,
|
|
first.to!,
|
|
second.from!,
|
|
second.to!,
|
|
);
|
|
|
|
if (list.type === "OrderedList") {
|
|
return {
|
|
text: renumberOrderedList(result.text, list.from!, list.to!),
|
|
cursor: result.cursor,
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Returns the start of the line containing position `pos`.
|
|
*/
|
|
function lineStartPos(text: string, pos: number): number {
|
|
let start = pos;
|
|
while (start > 0 && text[start - 1] !== "\n") {
|
|
start--;
|
|
}
|
|
return start;
|
|
}
|
|
|
|
/**
|
|
* Returns the indent width for a list (2 for bullet, marker length + 1 for
|
|
* ordered).
|
|
*/
|
|
function listIndentWidth(list: ParseTree): number {
|
|
if (list.type === "BulletList") {
|
|
return 2;
|
|
}
|
|
const firstItem = list.children?.find((c) => c.type === "ListItem");
|
|
if (firstItem) {
|
|
const mark = firstItem.children?.find((c) => c.type === "ListMark");
|
|
if (mark) {
|
|
return renderToText(mark).length + 1;
|
|
}
|
|
}
|
|
return 3;
|
|
}
|
|
|
|
/**
|
|
* Adds one level of indentation to a list item and its children.
|
|
*/
|
|
function indentListItem(
|
|
text: string,
|
|
cursor: number,
|
|
ctx: ListContext,
|
|
): OutlineResult {
|
|
const { item, list } = ctx;
|
|
const indices = listItemIndices(list);
|
|
const itemPos = indices.indexOf(ctx.itemIndex);
|
|
|
|
// Can indent if: has preceding sibling, or list is already nested
|
|
const isNested = list.parent?.type === "ListItem";
|
|
if (itemPos <= 0 && !isNested) {
|
|
return null;
|
|
}
|
|
|
|
const indentWidth = listIndentWidth(list);
|
|
const indentStr = " ".repeat(indentWidth);
|
|
|
|
// Work with full lines including leading whitespace
|
|
const lineFrom = lineStartPos(text, item.from!);
|
|
const lineTo = item.to!;
|
|
const itemLines = text.slice(lineFrom, lineTo);
|
|
|
|
const indented = itemLines
|
|
.split("\n")
|
|
.map((line) => (line ? indentStr + line : line))
|
|
.join("\n");
|
|
|
|
const newText = text.slice(0, lineFrom) + indented + text.slice(lineTo);
|
|
|
|
const preText = text.slice(lineFrom, cursor);
|
|
const linesBeforeCursor = preText.split("\n").length;
|
|
const newCursor = cursor + linesBeforeCursor * indentWidth;
|
|
|
|
return { text: newText, cursor: newCursor };
|
|
}
|
|
|
|
/**
|
|
* Removes one level of indentation from a list item and its children.
|
|
*/
|
|
function outdentListItem(
|
|
text: string,
|
|
cursor: number,
|
|
ctx: ListContext,
|
|
): OutlineResult {
|
|
const { item, list } = ctx;
|
|
|
|
const lineFrom = lineStartPos(text, item.from!);
|
|
const lineTo = item.to!;
|
|
const itemLines = text.slice(lineFrom, lineTo);
|
|
|
|
if (!itemLines.startsWith(" ")) {
|
|
return null;
|
|
}
|
|
|
|
const indentWidth = listIndentWidth(list);
|
|
|
|
const outdented = itemLines
|
|
.split("\n")
|
|
.map((line) => {
|
|
if (line.startsWith(" ".repeat(indentWidth))) {
|
|
return line.substring(indentWidth);
|
|
}
|
|
let removed = 0;
|
|
while (removed < indentWidth && line[removed] === " ") {
|
|
removed++;
|
|
}
|
|
return line.substring(removed);
|
|
})
|
|
.join("\n");
|
|
|
|
const newText = text.slice(0, lineFrom) + outdented + text.slice(lineTo);
|
|
|
|
const preText = text.slice(lineFrom, cursor);
|
|
const linesBeforeCursor = preText.split("\n").length;
|
|
const newCursor = cursor - linesBeforeCursor * indentWidth;
|
|
|
|
return { text: newText, cursor: newCursor };
|
|
}
|
|
|
|
/**
|
|
* Returns the end position of the last typed child in a range, excluding
|
|
* trailing separator newlines.
|
|
*/
|
|
function lastTypedChildEnd(
|
|
children: ParseTree[],
|
|
start: number,
|
|
end: number,
|
|
): number {
|
|
for (let i = end - 1; i >= start; i--) {
|
|
if (children[i].type) {
|
|
return children[i].to!;
|
|
}
|
|
}
|
|
return children[start].from!;
|
|
}
|
|
|
|
/**
|
|
* Swaps a heading section (including sub-headings and body) with its adjacent
|
|
* sibling section.
|
|
*/
|
|
function moveHeading(
|
|
text: string,
|
|
cursor: number,
|
|
ctx: HeadingContext,
|
|
direction: "up" | "down",
|
|
): OutlineResult {
|
|
const { level, sectionStart, sectionEnd, doc } = ctx;
|
|
const children = doc.children!;
|
|
|
|
// Find adjacent section at the same level
|
|
const searchFrom = direction === "up" ? sectionStart - 1 : sectionEnd;
|
|
const searchTo = direction === "up" ? -1 : children.length;
|
|
const step = direction === "up" ? -1 : 1;
|
|
|
|
let adjSectionStart = -1;
|
|
for (let i = searchFrom; i !== searchTo; i += step) {
|
|
const child = children[i];
|
|
if (child.type?.startsWith("ATXHeading")) {
|
|
const childLevel = parseInt(child.type.replace("ATXHeading", ""), 10);
|
|
if (childLevel === level) {
|
|
adjSectionStart = i;
|
|
break;
|
|
}
|
|
if (childLevel < level) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
if (adjSectionStart < 0) {
|
|
return null;
|
|
}
|
|
|
|
// Find end of adjacent section
|
|
let adjSectionEnd: number;
|
|
if (direction === "up") {
|
|
adjSectionEnd = sectionStart;
|
|
} else {
|
|
adjSectionEnd = children.length;
|
|
for (let i = adjSectionStart + 1; i < children.length; i++) {
|
|
const child = children[i];
|
|
if (child.type?.startsWith("ATXHeading")) {
|
|
const childLevel = parseInt(child.type.replace("ATXHeading", ""), 10);
|
|
if (childLevel <= level) {
|
|
adjSectionEnd = i;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normalize to [first, second] order
|
|
const [firstStart, firstEnd, secondStart, secondEnd] =
|
|
direction === "up"
|
|
? [adjSectionStart, adjSectionEnd, sectionStart, sectionEnd]
|
|
: [sectionStart, sectionEnd, adjSectionStart, adjSectionEnd];
|
|
|
|
return swapRegions(
|
|
text,
|
|
cursor,
|
|
children[sectionStart].from!,
|
|
direction,
|
|
children[firstStart].from!,
|
|
lastTypedChildEnd(children, firstStart, firstEnd),
|
|
children[secondStart].from!,
|
|
lastTypedChildEnd(children, secondStart, secondEnd),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Adds or removes a '#' from all headings in a section, clamping at h1/h6.
|
|
*/
|
|
function adjustHeadingLevel(
|
|
text: string,
|
|
cursor: number,
|
|
ctx: HeadingContext,
|
|
delta: 1 | -1,
|
|
): OutlineResult {
|
|
const { level, sectionStart, sectionEnd, doc } = ctx;
|
|
if (delta === 1 && level >= 6) {
|
|
return null;
|
|
}
|
|
if (delta === -1 && level <= 1) {
|
|
return null;
|
|
}
|
|
|
|
const limitLevel = delta === 1 ? 6 : 1;
|
|
const children = doc.children!;
|
|
const sectionFrom = children[sectionStart].from!;
|
|
const sectionTo = children[sectionEnd - 1].to!;
|
|
const sectionText = text.slice(sectionFrom, sectionTo);
|
|
|
|
let cursorAdjust = 0;
|
|
let newSectionText = "";
|
|
let pos = 0;
|
|
for (let i = sectionStart; i < sectionEnd; i++) {
|
|
const child = children[i];
|
|
if (child.type?.startsWith("ATXHeading")) {
|
|
const childLevel = parseInt(child.type.replace("ATXHeading", ""), 10);
|
|
if (childLevel === limitLevel) {
|
|
continue;
|
|
}
|
|
|
|
const childFrom = child.from! - sectionFrom;
|
|
const childTo = child.to! - sectionFrom;
|
|
|
|
newSectionText += sectionText.slice(pos, childFrom);
|
|
if (delta === 1) {
|
|
newSectionText += `#${sectionText.slice(childFrom, childTo)}`;
|
|
} else {
|
|
newSectionText += sectionText.slice(childFrom + 1, childTo);
|
|
}
|
|
pos = childTo;
|
|
|
|
if (cursor > child.from!) {
|
|
cursorAdjust += delta;
|
|
}
|
|
}
|
|
}
|
|
newSectionText += sectionText.slice(pos);
|
|
|
|
const newText =
|
|
text.slice(0, sectionFrom) + newSectionText + text.slice(sectionTo);
|
|
return { text: newText, cursor: cursor + cursorAdjust };
|
|
}
|
|
|
|
/**
|
|
* Swaps a table data row with the adjacent data row. Header rows are blocked.
|
|
*/
|
|
function moveTableRow(
|
|
text: string,
|
|
cursor: number,
|
|
ctx: TableRowContext,
|
|
direction: "up" | "down",
|
|
): OutlineResult {
|
|
if (ctx.isHeader) {
|
|
return null;
|
|
}
|
|
|
|
const { row, table } = ctx;
|
|
const children = table.children!;
|
|
|
|
// Collect indices of TableRow children (not TableHeader or TableDelimiter)
|
|
const rowIndices: number[] = [];
|
|
for (let i = 0; i < children.length; i++) {
|
|
if (children[i].type === "TableRow") {
|
|
rowIndices.push(i);
|
|
}
|
|
}
|
|
|
|
const rowPos = rowIndices.indexOf(ctx.rowIndex);
|
|
if (direction === "up" && rowPos <= 0) {
|
|
return null;
|
|
}
|
|
if (direction === "down" && rowPos >= rowIndices.length - 1) {
|
|
return null;
|
|
}
|
|
|
|
const otherPos = direction === "up" ? rowPos - 1 : rowPos + 1;
|
|
const otherRow = children[rowIndices[otherPos]];
|
|
|
|
const first = direction === "up" ? otherRow : row;
|
|
const second = direction === "up" ? row : otherRow;
|
|
|
|
return swapRegions(
|
|
text,
|
|
cursor,
|
|
row.from!,
|
|
direction,
|
|
first.from!,
|
|
first.to!,
|
|
second.from!,
|
|
second.to!,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Swaps a top-level paragraph with the adjacent document-level block.
|
|
*/
|
|
function moveParagraph(
|
|
text: string,
|
|
cursor: number,
|
|
ctx: ParagraphContext,
|
|
direction: "up" | "down",
|
|
): OutlineResult {
|
|
const { blockIndex, doc } = ctx;
|
|
const children = doc.children!;
|
|
const currNode = children[blockIndex];
|
|
|
|
// Find adjacent typed sibling
|
|
let adjIdx = blockIndex + (direction === "up" ? -1 : 1);
|
|
const step = direction === "up" ? -1 : 1;
|
|
while (adjIdx >= 0 && adjIdx < children.length && !children[adjIdx].type) {
|
|
adjIdx += step;
|
|
}
|
|
if (adjIdx < 0 || adjIdx >= children.length) {
|
|
return null;
|
|
}
|
|
|
|
const adjNode = children[adjIdx];
|
|
const first = direction === "up" ? adjNode : currNode;
|
|
const second = direction === "up" ? currNode : adjNode;
|
|
|
|
return swapRegions(
|
|
text,
|
|
cursor,
|
|
currNode.from!,
|
|
direction,
|
|
first.from!,
|
|
first.to!,
|
|
second.from!,
|
|
second.to!,
|
|
);
|
|
}
|