diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml index 6273ec18..198e62d1 100644 --- a/plugs/editor/editor.plug.yaml +++ b/plugs/editor/editor.plug.yaml @@ -227,6 +227,30 @@ functions: command: name: "Upload: File" + outlineMoveUp: + path: ./outline.ts:moveItemUp + command: + name: "Outline: Move Up" + key: "Alt-ArrowUp" + + outlineMoveDown: + path: ./outline.ts:moveItemDown + command: + name: "Outline: Move Down" + key: "Alt-ArrowDown" + + outlineIndent: + path: ./outline.ts:indentItem + command: + name: "Outline: Move Right" + key: "Alt-ArrowRight" + + outlineOutdent: + path: ./outline.ts:outdentItem + command: + name: "Outline: Move Left" + key: "Alt-ArrowLeft" + customFlashMessage: path: editor.ts:customFlashMessage command: diff --git a/plugs/editor/outline.ts b/plugs/editor/outline.ts new file mode 100644 index 00000000..ad42f74a --- /dev/null +++ b/plugs/editor/outline.ts @@ -0,0 +1,225 @@ +import { editor } from "$sb/syscalls.ts"; + +export async function moveItemUp() { + const cursorPos = await editor.getCursor(); + const text = await editor.getText(); + + try { + const currentItemBounds = determineItemBounds(text, cursorPos); + const previousItemBounds = determineItemBounds( + text, + currentItemBounds.from - 1, + currentItemBounds.indentLevel, + ); + + if (currentItemBounds.from === previousItemBounds.from) { + throw new Error("Already at the top"); + } + + const newText = + ensureNewLine(text.slice(currentItemBounds.from, currentItemBounds.to)) + + text.slice(previousItemBounds.from, previousItemBounds.to); + const newCursorPos = (cursorPos - currentItemBounds.from) + + previousItemBounds.from; + + await editor.dispatch({ + changes: [ + { + from: previousItemBounds.from, + to: currentItemBounds.to, + insert: newText, + }, + ], + selection: { + anchor: newCursorPos, + }, + }); + } catch (e: any) { + await editor.flashNotification(e.message, "error"); + } +} + +export async function moveItemDown() { + const cursorPos = await editor.getCursor(); + const text = await editor.getText(); + + try { + const currentItemBounds = determineItemBounds(text, cursorPos); + const nextItemBounds = determineItemBounds( + text, + currentItemBounds.to + 1, + currentItemBounds.indentLevel, + ); + + if (currentItemBounds.from === nextItemBounds.from) { + throw new Error("Already at the bottom"); + } + + const nextItemText = ensureNewLine( + text.slice(nextItemBounds.from, nextItemBounds.to), + ); + const newText = nextItemText + + text.slice(currentItemBounds.from, currentItemBounds.to); + const newCursorPos = (cursorPos - currentItemBounds.from) + + currentItemBounds.from + nextItemText.length; + await editor.dispatch({ + changes: [ + { + from: currentItemBounds.from, + to: nextItemBounds.to, + insert: newText, + }, + ], + selection: { + anchor: newCursorPos, + }, + }); + } catch (e: any) { + await editor.flashNotification(e.message, "error"); + } +} + +export async function indentItem() { + const cursorPos = await editor.getCursor(); + const text = await editor.getText(); + + try { + const currentItemBounds = determineItemBounds(text, cursorPos); + const itemText = text.slice(currentItemBounds.from, currentItemBounds.to); + const newText = itemText.split("\n").map((line) => + line ? " " + line : line + ).join("\n"); + const preText = text.slice(currentItemBounds.from, cursorPos); + const newCursorPos = cursorPos + preText.split("\n").length * 2; + await editor.dispatch({ + changes: [ + { + from: currentItemBounds.from, + to: currentItemBounds.to, + insert: newText, + }, + ], + selection: { + anchor: newCursorPos, + }, + }); + } catch (e: any) { + await editor.flashNotification(e.message, "error"); + } +} + +export async function outdentItem() { + const cursorPos = await editor.getCursor(); + const text = await editor.getText(); + + try { + const currentItemBounds = determineItemBounds(text, cursorPos); + const itemText = text.slice(currentItemBounds.from, currentItemBounds.to); + if (!itemText.startsWith(" ")) { + throw new Error("Cannot outdent further"); + } + const newText = itemText.split("\n").map((line) => + line.startsWith(" ") ? line.substring(2) : line + ).join("\n"); + const preText = text.slice(currentItemBounds.from, cursorPos); + const newCursorPos = cursorPos - preText.split("\n").length * 2; + await editor.dispatch({ + changes: [ + { + from: currentItemBounds.from, + to: currentItemBounds.to, + insert: newText, + }, + ], + selection: { + anchor: newCursorPos, + }, + }); + } catch (e: any) { + await editor.flashNotification(e.message, "error"); + } +} + +function ensureNewLine(s: string) { + if (!s.endsWith("\n")) { + return s + "\n"; + } else { + return s; + } +} + +function determineItemBounds( + text: string, + pos: number, + minIndentLevel?: number, +): { from: number; to: number; indentLevel: number } { + // Find the start of the item marked with a bullet + let currentItemStart = pos; + let indentLevel = 0; + while (true) { + while (currentItemStart > 0 && text[currentItemStart - 1] !== "\n") { + currentItemStart--; + } + // Check if the line is a bullet and determine the indent level + indentLevel = 0; + while (text[currentItemStart + indentLevel] === " ") { + indentLevel++; + } + if (minIndentLevel !== undefined && indentLevel < minIndentLevel) { + throw new Error("No item found at minimum indent level"); + } + if (minIndentLevel !== undefined && indentLevel > minIndentLevel) { + // Not at the desired indent level yet, let's go up another line + currentItemStart--; + if (currentItemStart <= 0) { + // We've reached the top of the document, no bullet found + throw new Error("No item found"); + } + continue; + } + if (["-", "*"].includes(text[currentItemStart + indentLevel])) { + // This is a bullet line, found it, let's break out of this loop + break; + } else { + // Not a bullet line, let's go up another line + currentItemStart--; + if (currentItemStart <= 0) { + // We've reached the top of the document, no bullet found + throw new Error("No item found"); + } + } + } + + // Ok, so at this point we have determine the starting point of our item + // Relevant variables are currentItemStart and indentLevel + // Now let's find the end point + let currentItemEnd = currentItemStart + 1; + while (true) { + // Let's traverse to the end of the line + while (currentItemEnd < text.length && text[currentItemEnd - 1] !== "\n") { + currentItemEnd++; + } + // Check the indent level of the next line + let nextIndentLevel = 0; + while (text[currentItemEnd + nextIndentLevel] === " ") { + nextIndentLevel++; + } + if (nextIndentLevel <= indentLevel) { + // This is a line indentend less than the current item, found it, let's break out of this loop + break; + } else { + // Not a bullet line, let's go up another line + currentItemEnd++; + if (currentItemEnd >= text.length) { + // End of the document, mark this as the end of the item + currentItemEnd = text.length - 1; + break; + } + } + } + return { + from: currentItemStart, + to: currentItemEnd, + indentLevel, + }; +}