diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 57809624..3815fd3f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -62,7 +62,7 @@ jobs: - name: Build binaries for multiple platforms run: | make build-for-docker - + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4.4.0 diff --git a/client/client.ts b/client/client.ts index dd209690..66eb4fa2 100644 --- a/client/client.ts +++ b/client/client.ts @@ -988,7 +988,10 @@ export class Client { } catch (e: any) { // If there is no document editor we will open the file raw if (e.message.includes("Couldn't find")) { - this.openUrl(`${fsEndpoint}/${path}`, !previousPath); + this.openUrl( + `${document.baseURI.replace(/\/*$/, "") + fsEndpoint}/${path}`, + !previousPath, + ); } throw e; diff --git a/client/codemirror/frontmatter.ts b/client/codemirror/frontmatter.ts index fb098184..5c0d04ca 100644 --- a/client/codemirror/frontmatter.ts +++ b/client/codemirror/frontmatter.ts @@ -9,10 +9,10 @@ import { } from "./util.ts"; import type { Client } from "../client.ts"; import { + frontmatterMailtoRegex, frontmatterQuotesRegex, frontmatterUrlRegex, frontmatterWikiLinkRegex, - frontmatterMailtoRegex, } from "../markdown_parser/constants.ts"; import { processWikiLink, type WikiLinkMatch } from "./wiki_link_processor.ts"; diff --git a/client/codemirror/lua_directive.ts b/client/codemirror/lua_directive.ts index 2a677d28..5c5320d8 100644 --- a/client/codemirror/lua_directive.ts +++ b/client/codemirror/lua_directive.ts @@ -14,6 +14,7 @@ import { LuaEnv, LuaRuntimeError, LuaStackFrame, + LuaTable, luaValueToJS, singleResult, } from "../space_lua/runtime.ts"; @@ -123,6 +124,12 @@ export function luaDirectivePlugin(client: Client) { ) { return rawResult; } + if (rawResult instanceof LuaTable) { + if (rawResult.rawGet("_isWidget")) { + return luaValueToJS(rawResult, sf); + } + return rawResult; + } // everything else needs luaValueToJS for widget support return luaValueToJS(rawResult, sf); } catch (e: any) { diff --git a/client/codemirror/lua_widget.ts b/client/codemirror/lua_widget.ts index c92d3758..cdeb8f6f 100644 --- a/client/codemirror/lua_widget.ts +++ b/client/codemirror/lua_widget.ts @@ -13,15 +13,13 @@ import { moveCursorIntoText, } from "./widget_util.ts"; import { expandMarkdown } from "../markdown_renderer/inline.ts"; -import { luaFormatNumber, LuaTable } from "../space_lua/runtime.ts"; -import { isTaggedFloat } from "../space_lua/numeric.ts"; import { isBlockMarkdown, - jsonToMDTable, - refCellTransformer, + renderExpressionResult, } from "../markdown_renderer/result_render.ts"; import { activeWidgets } from "./code_widget.ts"; import type { Ref } from "@silverbulletmd/silverbullet/lib/ref"; +import { renderResultToHtml } from "../space_lua/render_lua_html.ts"; export type LuaWidgetCallback = ( bodyText: string, @@ -95,6 +93,26 @@ export class LuaWidget extends WidgetType { return wrapperSpan; } + // Build an inline Markdown renderer bound to the current client context + private buildInlineRenderer(): (text: string) => string { + return (text: string): string => { + const mdTree = parse(extendedMarkdownLanguage, text); + return renderMarkdownToHtml(mdTree, { + shortWikiLinks: this.client.config.get("shortWikiLinks", false), + translateUrls: (url) => { + if (isLocalURL(url)) { + url = resolveMarkdownLink( + this.client.currentName(), + decodeURI(url), + ); + } + return url; + }, + preserveAttributes: true, + }, this.client.ui.viewState.allPages); + }; + } + async renderContent( div: HTMLElement, ) { @@ -121,38 +139,53 @@ export class LuaWidget extends WidgetType { let block = false; let copyContent: string | undefined ; - // Normalization + // Normalization (non-widget results go through dual-path rendering) if (typeof widgetContent === "string" || !widgetContent._isWidget) { - // Apply heuristic to render the object as a markdown table + // HTML path for display (with data attributes, nested structures) + const inlineRenderer = this.buildInlineRenderer(); + const { html: displayHtml, dataType } = await renderResultToHtml( + widgetContent, + inlineRenderer, + ); + // Markdown path for copy button (flat tables, `{...}` for nested) + const markdownCopy = await renderExpressionResult(widgetContent); + widgetContent = { _isWidget: true, - markdown: await renderExpressionResult(widgetContent), + html: `${displayHtml}`, + display: (dataType === "table" || dataType === "list") + ? "block" + : "inline", }; + copyContent = markdownCopy; } - if (widgetContent.cssClasses) { - div.className = widgetContent.cssClasses.join(" "); + // After normalization `widgetContent` is always the object form + const wc = widgetContent as Exclude; + + if (wc.cssClasses) { + div.className = wc.cssClasses.join(" "); } - if (widgetContent.html) { - if (typeof widgetContent.html === "string") { - html = parseHtmlString(widgetContent.html); - copyContent = widgetContent.html; + if (wc.html) { + if (typeof wc.html === "string") { + html = parseHtmlString(wc.html); + if (!copyContent) copyContent = wc.html; } else { - html = widgetContent.html; - copyContent = widgetContent.html.outerHTML; + html = wc.html; + if (!copyContent) copyContent = wc.html.outerHTML; } - block = widgetContent.display === "block"; + block = wc.display === "block"; if (block) { div.className += " sb-lua-directive-block"; } else { div.className += " sb-lua-directive-inline"; } } - if (widgetContent.markdown) { + if (wc.markdown) { let mdTree = parse( extendedMarkdownLanguage, - widgetContent.markdown || "", + wc.markdown || "", ); mdTree = await expandMarkdown( @@ -176,7 +209,7 @@ export class LuaWidget extends WidgetType { return; } - block = widgetContent._isWidget && widgetContent.display === "block" || + block = wc._isWidget && wc.display === "block" || isBlockMarkdown(trimmedMarkdown); if (block) { div.className += " sb-lua-directive-block"; @@ -211,7 +244,7 @@ export class LuaWidget extends WidgetType { div, this.client, this.inPage ? this.codeText : undefined, - widgetContent._isWidget && widgetContent.events, + wc._isWidget && wc.events, ); } @@ -343,42 +376,6 @@ export class LuaWidget extends WidgetType { } } -export function renderExpressionResult(result: any): Promise { - if (result instanceof LuaTable) { - result = result.toJS(); - } - // Must check before object/array checks — tagged floats are plain objects - if (isTaggedFloat(result)) { - return Promise.resolve(luaFormatNumber(result.value, "float")); - } - if (typeof result === "number") { - return Promise.resolve(luaFormatNumber(result)); - } - if ( - Array.isArray(result) && result.length > 0 && typeof result[0] === "object" - ) { - // If result is an array of objects, render as a Markdown table - try { - return jsonToMDTable(result, refCellTransformer); - } catch (e: any) { - console.error( - `Error rendering expression directive: ${e.message} for value ${ - JSON.stringify(result) - }`, - ); - return Promise.resolve(JSON.stringify(result)); - } - } else if (typeof result === "object" && result.constructor === Object) { - // If result is a plain object, render as a Markdown table - return jsonToMDTable([result], refCellTransformer); - } else if (Array.isArray(result)) { - // Not-object array, let's render it as a Markdown list - return Promise.resolve(result.map((item) => `- ${item}`).join("\n")); - } else { - return Promise.resolve(`${result}`); - } -} - export function parseHtmlString(html: string): HTMLElement { const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); diff --git a/client/markdown_renderer/result_render.ts b/client/markdown_renderer/result_render.ts index d830ca3a..f23e38f7 100644 --- a/client/markdown_renderer/result_render.ts +++ b/client/markdown_renderer/result_render.ts @@ -13,6 +13,10 @@ export function defaultTransformer(v: any, _k: string): Promise { if (typeof v === "string") { return Promise.resolve(escapeRegularPipes(v.replaceAll("\n", " "))); } + if (v instanceof LuaTable) { + // Render nested `LuaTables` as literal `{...}` for copy path + return v.toStringAsync().then((s) => escapeRegularPipes(s)); + } if (v && typeof v === "object") { return Promise.resolve(luaToString(v)); } @@ -110,12 +114,18 @@ export async function jsonToMDTable( return lines.join("\n"); } -export function renderExpressionResult(result: any): Promise { +// Render an expression result as Markdown (for copy path) +// LuaTables are rendered natively, JS objects/arrays use `jsonToMDTable` +export function renderExpressionResult( + result: any, + cellTransformer: (v: any, k: string) => Promise = refCellTransformer, +): Promise { if (result === undefined || result === null) { return Promise.resolve("nil"); } + // LuaTable: render natively without `.toJS` if (result instanceof LuaTable) { - result = result.toJS(); + return renderLuaTableToMarkdown(result, cellTransformer); } // Must check before object/array checks — tagged floats are plain objects if (isTaggedFloat(result)) { @@ -129,7 +139,7 @@ export function renderExpressionResult(result: any): Promise { ) { // If result is an array of objects, render as a Markdown table try { - return jsonToMDTable(result); + return jsonToMDTable(result, cellTransformer); } catch (e: any) { console.error( `Error rendering expression directive: ${e.message} for value ${ @@ -139,16 +149,164 @@ export function renderExpressionResult(result: any): Promise { return Promise.resolve(JSON.stringify(result)); } } else if (typeof result === "object" && result.constructor === Object) { - // If result is a plain object, render as a Markdown table - return jsonToMDTable([result]); + if (Object.keys(result).length === 0) { + return Promise.resolve("*(empty table)*"); + } + return jsonToMDTable([result], cellTransformer); } else if (Array.isArray(result)) { - // Not-object array, let's render it as a Markdown list - return Promise.resolve(result.map((item) => `- ${item}`).join("\n")); + if (result.length === 0) { + return Promise.resolve("*(empty table)*"); + } + return renderListItems(result, cellTransformer, false); } else { return Promise.resolve(`${result}`); } } +// Render a `LuaTable` to Markdown (table for records, list for arrays) +async function renderLuaTableToMarkdown( + tbl: LuaTable, + cellTransformer: (v: any, k: string) => Promise, + nested = false, +): Promise { + const keys = tbl.keys(); + if (keys.length === 0) return "*(empty table)*"; + + const arrayLen = tbl.length; + const stringKeys: string[] = []; + for (const k of keys) { + if (typeof k === "string") stringKeys.push(k); + } + + const hasArrayPart = arrayLen > 0; + const hasStrKeys = stringKeys.length > 0; + + // Pure array + if (hasArrayPart && !hasStrKeys) { + const elements: any[] = []; + for (let i = 1; i <= arrayLen; i++) elements.push(tbl.rawGet(i)); + // If all elements are `LuaTables` then to Markdown table + if (elements.every((el) => el instanceof LuaTable)) { + return renderLuaTableArrayToMarkdown( + elements as LuaTable[], + cellTransformer, + ); + } + // Flat list + return renderListItems(elements, cellTransformer, nested); + } + + // Record or mixed - use `keys` order directly (preserve order) + const allHeaders: string[] = keys.map(String); + + const lines: string[] = [ + `|${allHeaders.join("|")}|`, + `|${allHeaders.map(() => "--").join("|")}|`, + ]; + const cells: string[] = []; + for (const k of keys) { + const v = tbl.rawGet(k); + cells.push(await cellTransformer(v, String(k))); + } + lines.push(`|${cells.join("|")}|`); + return lines.join("\n"); +} + +// Render a list of items as indented Markdown list +async function renderListItems( + items: any[], + cellTransformer: (v: any, k: string) => Promise, + nested: boolean, +): Promise { + const rendered: string[] = []; + for (const item of items) { + const md = await renderItemToMarkdown(item, cellTransformer, nested); + if (md.includes("\n")) { + const lines = md.split("\n"); + rendered.push( + "-\n" + + lines.map((l) => ` ${l}`).join("\n"), + ); + } else { + rendered.push(`- ${md}`); + } + } + return rendered.join("\n"); +} + +// Render a single item for use inside a list +function renderItemToMarkdown( + item: any, + cellTransformer: (v: any, k: string) => Promise, + nested: boolean, +): Promise { + if (item === undefined || item === null) return Promise.resolve(""); + if (item instanceof LuaTable) { + if (item.empty()) return Promise.resolve("*(empty table)*"); + if (nested) { + return item.toStringAsync(); + } + return renderLuaTableToMarkdown(item, cellTransformer, true); + } + if (Array.isArray(item)) { + if (item.length === 0) return Promise.resolve("*(empty table)*"); + if (isPlainObjectHelper(item)) { + return jsonToMDTable(item, cellTransformer); + } + if (nested) { + return Promise.resolve(JSON.stringify(item)); + } + return renderListItems(item, cellTransformer, true); + } + if (isPlainObjectHelper(item)) { + if (Object.keys(item).length === 0) { + return Promise.resolve("*(empty table)*"); + } + if (nested) { + return Promise.resolve(JSON.stringify(item)); + } + return jsonToMDTable([item], cellTransformer); + } + if (isTaggedFloat(item)) { + return Promise.resolve(luaFormatNumber(item.value, "float")); + } + if (typeof item === "number") return Promise.resolve(luaFormatNumber(item)); + return Promise.resolve(`${item}`); +} + +function isPlainObjectHelper(v: any): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v) && + v.constructor === Object; +} + +// Array of record-like `LuaTables` to multi-row Markdown table +async function renderLuaTableArrayToMarkdown( + tables: LuaTable[], + cellTransformer: (v: any, k: string) => Promise, +): Promise { + const headerSet = new Set(); + for (const tbl of tables) { + for (const k of tbl.keys()) headerSet.add(String(k)); + } + if (headerSet.size === 0) return "*(empty table)*"; + const headers = [...headerSet]; + + const lines: string[] = [ + `|${headers.join("|")}|`, + `|${headers.map(() => "--").join("|")}|`, + ]; + for (const tbl of tables) { + const cells: string[] = []; + for (const h of headers) { + const key = /^\d+$/.test(h) ? Number(h) : h; + const v = tbl.rawGet(key); + cells.push(await cellTransformer(v, h)); + } + lines.push(`|${cells.join("|")}|`); + } + return lines.join("\n"); +} + /** * Applies some heuristics to figure out if a string should be rendered as a markdown block or inline markdown * @param s markdown string diff --git a/client/space_lua/render_lua_html.ts b/client/space_lua/render_lua_html.ts new file mode 100644 index 00000000..ad05b07b --- /dev/null +++ b/client/space_lua/render_lua_html.ts @@ -0,0 +1,335 @@ +import { luaFormatNumber, LuaTable } from "../space_lua/runtime.ts"; +import { isTaggedFloat } from "../space_lua/numeric.ts"; +import { isSqlNull } from "../space_lua/query_collection.ts"; + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +// Render inline Markdown to HTML (caller provides the implementation) +export type InlineRenderer = (text: string) => string; + +function defaultInlineRenderer(text: string): string { + return escapeHtml(text); +} + +function luaTypeName( + v: any, +): "number" | "string" | "boolean" | "table" | "array" | undefined { + if (v === undefined || v === null || isSqlNull(v)) return undefined; + if (typeof v === "number" || isTaggedFloat(v)) return "number"; + if (typeof v === "string") return "string"; + if (typeof v === "boolean") return "boolean"; + if (v instanceof LuaTable) { + return hasStringKeys(v) ? "table" : "array"; + } + if (Array.isArray(v)) return "array"; + if (isPlainObject(v)) return "table"; + return "string"; +} + +function isEmpty(v: any): boolean { + return v === undefined || v === null || isSqlNull(v); +} + +function isPlainObject(v: any): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v) && + v.constructor === Object; +} + +function hasStringKeys(tbl: LuaTable): boolean { + for (const k of tbl.keys()) { + if (typeof k === "string") return true; + } + return false; +} + +function formatScalar(v: any): string { + if (isEmpty(v)) return ""; + if (isTaggedFloat(v)) return luaFormatNumber(v.value, "float"); + if (typeof v === "number") return luaFormatNumber(v); + if (typeof v === "boolean") return v ? "true" : "false"; + return `${v}`; +} + +/** + * Render any Lua/JS value to an HTML string with data attributes. + * Also sets data-type and data-empty on the outermost element when + * used for widget display. + */ +export async function renderResultToHtml( + result: any, + renderInline: InlineRenderer = defaultInlineRenderer, +): Promise<{ + html: string; + dataType: string; +}> { + if (isEmpty(result)) { + return { + html: ``, + dataType: "nil", + }; + } + if (typeof result === "string") { + return { + html: renderInline(result), + dataType: "string", + }; + } + if (isTaggedFloat(result)) { + return { + html: escapeHtml(luaFormatNumber(result.value, "float")), + dataType: "number", + }; + } + if (typeof result === "number") { + return { + html: escapeHtml(luaFormatNumber(result)), + dataType: "number", + }; + } + if (typeof result === "boolean") { + return { + html: result ? "true" : "false", + dataType: "boolean", + }; + } + if (result instanceof LuaTable) { + if (result.empty()) { + return { + html: `
`, + dataType: "table", + }; + } + const html = await renderLuaTableToHtml(result, renderInline); + const type = hasStringKeys(result) ? "table" : "list"; + return { html, dataType: type }; + } + if (Array.isArray(result)) { + if (result.length === 0) { + return { + html: `
`, + dataType: "table", + }; + } + if (result.every(isPlainObject)) { + return { + html: await renderJsObjectArrayToHtml(result, renderInline), + dataType: "table", + }; + } + return { + html: await renderJsArrayToHtml(result, renderInline), + dataType: "list", + }; + } + if (isPlainObject(result)) { + if (Object.keys(result).length === 0) { + return { + html: `
`, + dataType: "table", + }; + } + return { + html: await renderJsObjectArrayToHtml([result], renderInline), + dataType: "table", + }; + } + return { + html: renderInline(`${result}`), + dataType: "string", + }; +} + +async function renderLuaTableToHtml( + tbl: LuaTable, + renderInline: InlineRenderer, +): Promise { + const keys = tbl.keys(); + if (keys.length === 0) return `
`; + + const arrayLen = tbl.length; + const stringKeys: string[] = []; + for (const k of keys) { + if (typeof k === "string") stringKeys.push(k); + } + + const hasArrayPart = arrayLen > 0; + const hasStrKeys = stringKeys.length > 0; + + // Pure array (render as `
    ` or multi-row ``) + if (hasArrayPart && !hasStrKeys) { + const elements: any[] = []; + for (let i = 1; i <= arrayLen; i++) elements.push(tbl.rawGet(i)); + // If all elements are `LuaTables` then render as multi-row table + if (elements.every((el) => el instanceof LuaTable)) { + return renderLuaTableArrayToHtml(elements as LuaTable[], renderInline); + } + return renderArrayToHtml(elements, renderInline); + } + + // Has string keys (record or mixed) - use `keys` order directly + const lines: string[] = []; + lines.push("
    "); + lines.push(""); + for (const k of keys) lines.push(``); + lines.push(""); + lines.push(""); + for (const k of keys) { + const v = tbl.rawGet(k); + lines.push(await renderTdHtml(v, renderInline)); + } + lines.push(""); + lines.push("
    ${escapeHtml(String(k))}
    "); + return lines.join(""); +} + +// Array of record-like LuaTables to multi-row `` +async function renderLuaTableArrayToHtml( + tables: LuaTable[], + renderInline: InlineRenderer, +): Promise { + const headerSet = new Set(); + for (const tbl of tables) { + for (const k of tbl.keys()) headerSet.add(String(k)); + } + if (headerSet.size === 0) return `
    `; + const headers = [...headerSet]; + + const lines: string[] = []; + lines.push(""); + lines.push(""); + for (const h of headers) lines.push(``); + lines.push(""); + lines.push(""); + for (const tbl of tables) { + lines.push(""); + for (const h of headers) { + const key = /^\d+$/.test(h) ? Number(h) : h; + const v = tbl.rawGet(key); + lines.push(await renderTdHtml(v, renderInline)); + } + lines.push(""); + } + lines.push(""); + lines.push("
    ${escapeHtml(h)}
    "); + return lines.join(""); +} + +async function renderJsObjectArrayToHtml( + jsonArray: Record[], + renderInline: InlineRenderer, +): Promise { + const headerSet = new Set(); + for (const entry of jsonArray) { + for (const k of Object.keys(entry)) headerSet.add(k); + } + if (headerSet.size === 0) return `
    `; + const headers = [...headerSet]; + + const lines: string[] = []; + lines.push(""); + lines.push(""); + for (const h of headers) lines.push(``); + lines.push(""); + lines.push(""); + for (const row of jsonArray) { + lines.push(""); + for (const h of headers) { + lines.push(await renderTdHtml(row[h], renderInline)); + } + lines.push(""); + } + lines.push(""); + lines.push("
    ${escapeHtml(h)}
    "); + return lines.join(""); +} + +async function renderArrayToHtml( + items: any[], + renderInline: InlineRenderer, +): Promise { + const lines: string[] = []; + lines.push("
      "); + for (const item of items) { + lines.push(await renderLiHtml(item, renderInline)); + } + lines.push("
    "); + return lines.join(""); +} + +function renderJsArrayToHtml( + items: any[], + renderInline: InlineRenderer, +): Promise { + return renderArrayToHtml(items, renderInline); +} + +async function renderTdHtml( + v: any, + renderInline: InlineRenderer, +): Promise { + const type = luaTypeName(v); + const attrs: string[] = []; + if (type) { + attrs.push(`data-table-cell-type="${type}"`); + } + if (isEmpty(v)) { + attrs.push("data-table-cell-empty"); + } + const attrStr = attrs.length > 0 ? ` ${attrs.join(" ")}` : ""; + const content = await renderCellContent(v, renderInline); + return `${content}`; +} + +async function renderLiHtml( + v: any, + renderInline: InlineRenderer, +): Promise { + const type = luaTypeName(v); + const attrs: string[] = []; + if (type) { + attrs.push(`data-list-item-type="${type}"`); + } + if (isEmpty(v)) { + attrs.push("data-list-item-empty"); + } + const attrStr = attrs.length > 0 ? ` ${attrs.join(" ")}` : ""; + const content = await renderCellContent(v, renderInline); + return `${content}`; +} + +// Render a value content (recurse for nested tables/arrays) +function renderCellContent( + v: any, + renderInline: InlineRenderer, +): Promise { + if (isEmpty(v)) return Promise.resolve(""); + if (isTaggedFloat(v)) { + return Promise.resolve(renderInline(luaFormatNumber(v.value, "float"))); + } + if (v instanceof LuaTable) { + if (v.empty()) return Promise.resolve(`
    `); + return renderLuaTableToHtml(v, renderInline); + } + if (Array.isArray(v)) { + if (v.length === 0) { + return Promise.resolve(`
    `); + } + if (v.every(isPlainObject)) { + return renderJsObjectArrayToHtml(v, renderInline); + } + return renderArrayToHtml(v, renderInline); + } + if (isPlainObject(v)) { + if (Object.keys(v).length === 0) { + return Promise.resolve(`
    `); + } + return renderJsObjectArrayToHtml([v], renderInline); + } + return Promise.resolve(renderInline(formatScalar(v))); +} diff --git a/libraries/Library/Std/Widgets/Widgets.md b/libraries/Library/Std/Widgets/Widgets.md index ef5dac4f..01e81916 100644 --- a/libraries/Library/Std/Widgets/Widgets.md +++ b/libraries/Library/Std/Widgets/Widgets.md @@ -80,6 +80,27 @@ end ``` ## Table of contents +```space-style +.sb-toc-summary { + cursor: pointer; + font-weight: bold; + user-select: none; + padding: 15px 10px; + margin: -10px; + background-color: var(--editor-widget-background-color); +} +.sb-toc-content { + padding-top: 0.5rem; +} +.sb-toc-item { + padding: 0.1rem 0; +} +.sb-toc-link { + cursor: pointer; + text-decoration: none; +} +``` + ```space-lua -- priority: 10 widgets = widgets or {} @@ -102,9 +123,14 @@ config.set("std.widgets.toc", { function widgets.toc(options) options = options or config.get("std.widgets.toc") options.minHeaders = options.minHeaders or 3 + options.minLevel = options.minLevel or 1 + options.header = options.header or "Table of Contents" + local defaultOpen = (options.defaultOpen ~= false) or nil + local text = editor.getText() local pageName = editor.getCurrentPage() local parsedMarkdown = markdown.parseMarkdown(text) + -- Collect all headers local headers = {} for topLevelChild in parsedMarkdown.children do @@ -129,27 +155,57 @@ function widgets.toc(options) end end end + if options.minHeaders and options.minHeaders > #headers then return widget.new{} end + + -- Filter headers to display + local headersToDisplay = {} + for _, header in ipairs(headers) do + if not (options.maxHeader and header.level > options.maxHeader or + header.level < options.minLevel) then + table.insert(headersToDisplay, header) + end + end + -- Find min level local minLevel = 6 - for _, header in ipairs(headers) do - if header.level < minLevel then - minLevel = header.level - end + for _, header in ipairs(headersToDisplay) do + minLevel = math.min(minLevel, header.level) end - -- Build up markdown - local md = (options.header or "# Table of Contents") .. "\n" - for _, header in ipairs(headers) do - if not(options.maxHeader and header.level > options.maxHeader or - options.minLevel and header.level < options.minLevel) then - md = md .. string.rep(" ", (header.level - minLevel) * 2) .. - "* [[" .. pageName .. "@" .. header.pos .. "|" .. header.name .. "]]\n" - end + + -- Build TOC entries as DOM elements + local tocItems = {} + for _, header in ipairs(headersToDisplay) do + local indent = (header.level - minLevel) * 1.5 + table.insert(tocItems, dom.div { + class = "sb-toc-item", + style = "margin-left: " .. indent .. "rem;", + dom.a { + onclick = function() + editor.navigate({ page = pageName, pos = header.pos }) + end, + class = "sb-toc-link", + "- " .. header.name + } + }) end + + -- Wrap in a
    element for native show/hide toggle return widget.new { - markdown = md + html = dom.details { + open = defaultOpen, + dom.summary { + class = "sb-toc-summary", + options.header + }, + dom.div { + class = "sb-toc-content", + table.unpack(tocItems) + } + }, + display = "block" } end ``` diff --git a/plugs/editor/editor.plug.yaml b/plugs/editor/editor.plug.yaml index faa9a520..c5c82594 100644 --- a/plugs/editor/editor.plug.yaml +++ b/plugs/editor/editor.plug.yaml @@ -379,12 +379,6 @@ functions: mac: "Cmd-f" requireEditor: page - # Outline helper functions - determineItemBounds: - path: ./outline.ts:determineItemBounds - ensureNewLine: - path: ./outline.ts:ensureNewLine - # Undo/redo undoCommand: path: ./editor.ts:undoCommand diff --git a/plugs/editor/outline.test.ts b/plugs/editor/outline.test.ts new file mode 100644 index 00000000..08e816db --- /dev/null +++ b/plugs/editor/outline.test.ts @@ -0,0 +1,778 @@ +import { describe, expect, test } from "vitest"; +import { + type CursorContext, + detectContext, + indent, + moveDown, + moveUp, + outdent, + type OutlineResult, +} from "./outline_ops.ts"; +import { parseMarkdown } from "../../client/markdown_parser/parser.ts"; + +const CURSOR = "|^|"; + +function applyOp( + op: (text: string, cursor: number) => OutlineResult, + input: string, +): string { + const pos = input.indexOf(CURSOR); + const clean = input.slice(0, pos) + input.slice(pos + CURSOR.length); + const result = op(clean, pos); + if (result === null || result === "blocked") { + return input; // no-op returns unchanged + } + return result.text.slice(0, result.cursor) + CURSOR + + result.text.slice(result.cursor); +} + +function detect(marked: string): CursorContext | null { + const pos = marked.indexOf(CURSOR); + const text = marked.slice(0, pos) + marked.slice(pos + CURSOR.length); + const tree = parseMarkdown(text); + return detectContext(tree, pos); +} + +// Bullet Lists: Move Up/Down +describe("Bullet list move up/down", () => { + test("swap two flat bullets", () => { + expect(applyOp( + moveUp, + `- first +- sec|^|ond +`, + )).toEqual(`- sec|^|ond +- first +`,); + expect(applyOp( + moveDown, + `- fir|^|st +- second +`, + )).toEqual(`- second +- fir|^|st +`,); + }); + + test("boundary bullet is no-op", () => { + const upInput = `- fir|^|st +- second +`; + expect(applyOp(moveUp, upInput)).toEqual(upInput); + const downInput = `- first +- sec|^|ond +`; + expect(applyOp(moveDown, downInput)).toEqual(downInput); + }); + + test("item with children moves as unit", () => { + expect(applyOp( + moveUp, + `- first +- sec|^|ond + - child +`, + )).toEqual(`- sec|^|ond + - child +- first +`,); + expect(applyOp( + moveDown, + `- fir|^|st + - child +- second +`, + )).toEqual(`- second +- fir|^|st + - child +`,); + }); + + test("nested child: boundary no-op and swap", () => { + const upBoundary = `- parent + - child |^|one + - child two +`; + expect(applyOp(moveUp, upBoundary)).toEqual(upBoundary); + const downBoundary = `- parent + - child one + - child |^|two +`; + expect(applyOp(moveDown, downBoundary)).toEqual(downBoundary); + + expect(applyOp( + moveUp, + `- parent + - child one + - child |^|two +`, + )).toEqual(`- parent + - child |^|two + - child one +`,); + expect(applyOp( + moveDown, + `- parent + - child |^|one + - child two +`, + )).toEqual(`- parent + - child two + - child |^|one +`,); + }); + + test("three items, swap adjacent", () => { + expect(applyOp( + moveUp, + `- first +- second +- th|^|ird +`, + )).toEqual(`- first +- th|^|ird +- second +`,); + expect(applyOp( + moveDown, + `- fir|^|st +- second +- third +`, + )).toEqual(`- second +- fir|^|st +- third +`,); + }); +}); + +// Bullet Lists: Indent/Outdent +describe("Bullet list indent/outdent", () => { + test("indent then outdent flat bullet", () => { + const flat = `- first +- sec|^|ond +`; + const nested = `- first + - sec|^|ond +`; + expect(applyOp(indent, flat)).toEqual(nested); + expect(applyOp(outdent, nested)).toEqual(flat); + }); + + test("boundary no-ops", () => { + const input = `- fir|^|st +- second +`; + expect(applyOp(indent, input)).toEqual(input); + expect(applyOp(outdent, input)).toEqual(input); + }); + + test("with children", () => { + const flat = `- first +- sec|^|ond + - child +`; + const nested = `- first + - sec|^|ond + - child +`; + expect(applyOp(indent, flat)).toEqual(nested); + expect(applyOp(outdent, nested)).toEqual(flat); + }); + + test("already nested item indents further", () => { + expect(applyOp( + indent, + `- first + - sec|^|ond +`, + )).toEqual(`- first + - sec|^|ond +`,); + }); + + test("deeply nested item outdents one level", () => { + expect(applyOp( + outdent, + `- first + - second + - th|^|ird +`, + )).toEqual(`- first + - second + - th|^|ird +`,); + }); +}); + +// Headers: Move Up/Down +describe("Heading move up/down", () => { + test("swap two h2 sections", () => { + expect(applyOp( + moveUp, + `## First +Content one +## Sec|^|ond +Content two +`, + )).toEqual(`## Sec|^|ond +Content two +## First +Content one +`,); + expect(applyOp( + moveDown, + `## Fir|^|st +Content one +## Second +Content two +`, + )).toEqual(`## Second +Content two +## Fir|^|st +Content one +`,); + }); + + test("boundary heading is no-op", () => { + const upInput = `## Fir|^|st +Content +## Second +`; + expect(applyOp(moveUp, upInput)).toEqual(upInput); + const downInput = `## First +## Sec|^|ond +`; + expect(applyOp(moveDown, downInput)).toEqual(downInput); + }); + + test("h2 with sub-headings moves entire section", () => { + expect(applyOp( + moveUp, + `## First +### Sub one +## Sec|^|ond +### Sub two +`, + )).toEqual(`## Sec|^|ond +### Sub two +## First +### Sub one +`,); + expect(applyOp( + moveDown, + `## Fir|^|st +### Sub one +## Second +### Sub two +`, + )).toEqual(`## Second +### Sub two +## Fir|^|st +### Sub one +`,); + }); + + test("h3 swaps only within parent h2 scope", () => { + expect(applyOp( + moveUp, + `## Parent +### Sub one +### Sub |^|two +`, + )).toEqual(`## Parent +### Sub |^|two +### Sub one +`,); + }); + + test("headings with no body text", () => { + expect(applyOp( + moveUp, + `## First +## Sec|^|ond +`, + )).toEqual(`## Sec|^|ond +## First +`,); + }); + + test("no trailing newline preserves content", () => { + expect(applyOp( + moveUp, + `## First +Body +## Sec|^|ond +End`, + )).toEqual(`## Sec|^|ond +End +## First +Body`,); + expect(applyOp( + moveDown, + `## Fir|^|st +Body +## Second +End`, + )).toEqual(`## Second +End +## Fir|^|st +Body`,); + }); + + test("empty body section swap preserves structure", () => { + expect(applyOp( + moveDown, + `## |^|A +## B +Body B +`, + )).toEqual(`## B +Body B +## |^|A +`,); + }); + + test("no trailing newline, first has no body", () => { + expect(applyOp( + moveDown, + `## |^|A +## B +Body`, + )).toEqual(`## B +Body +## |^|A`,); + }); + + test("roundtrip: move down then up returns to original", () => { + const original = `## First +Body 1a +Body 1b +## Second +Body 2a +`; + const after = moveDown(original, 4); + if (after === null || after === "blocked") { + throw new Error(`moveDown returned ${after}`); + } + const back = moveUp(after.text, after.cursor); + if (back === null || back === "blocked") { + throw new Error(`moveUp returned ${back}`); + } + expect(back.text).toEqual(original); + }); +}); + +// Headers: Indent/Outdent +describe("Heading indent/outdent", () => { + test("basic", () => { + const h2 = `## He|^|ading +`; + const h3 = `### He|^|ading +`; + expect(applyOp(indent, h2)).toEqual(h3); + expect(applyOp(outdent, h3)).toEqual(h2); + }); + + test("level limit no-ops", () => { + const h6 = `###### He|^|ading +`; + expect(applyOp(indent, h6)).toEqual(h6); + const h1 = `# He|^|ading +`; + expect(applyOp(outdent, h1)).toEqual(h1); + }); + + test("with sub-headings", () => { + const shallow = `## Ma|^|in +### Sub +`; + const deep = `### Ma|^|in +#### Sub +`; + expect(applyOp(indent, shallow)).toEqual(deep); + expect(applyOp(outdent, deep)).toEqual(shallow); + }); +}); + +// Paragraphs: Move Up/Down +describe("Paragraph operations", () => { + test("swap two consecutive paragraphs", () => { + expect(applyOp( + moveUp, + `First para. + +Second |^|para. +`, + )).toEqual(`Second |^|para. + +First para. +`,); + }); + + test("boundary no-ops", () => { + const upInput = `First |^|para. + +Second para. +`; + expect(applyOp(moveUp, upInput)).toEqual(upInput); + const downInput = `First para. + +Second |^|para. +`; + expect(applyOp(moveDown, downInput)).toEqual(downInput); + }); + + test("moves past an adjacent list block", () => { + expect(applyOp( + moveDown, + `Some |^|text. + +- item one +- item two +`, + )).toEqual(`- item one +- item two + +Some |^|text. +`,); + }); +}); + +// Ordered Lists: all operations +describe("Ordered list operations", () => { + test("move up", () => { + expect(applyOp( + moveUp, + `1. first +2. sec|^|ond +`, + )).toEqual(`1. sec|^|ond +2. first +`,); + }); + + test("move down", () => { + expect(applyOp( + moveDown, + `1. fir|^|st +2. second +`, + )).toEqual(`1. second +2. fir|^|st +`,); + }); + + test("indent", () => { + expect(applyOp( + indent, + `1. first +2. sec|^|ond +`, + )).toEqual(`1. first + 2. sec|^|ond +`,); + }); + + test("outdent", () => { + expect(applyOp( + outdent, + `1. first + 2. sec|^|ond +`, + )).toEqual(`1. first +2. sec|^|ond +`,); + }); +}); + +// Edge Cases +describe("Edge cases", () => { + test("empty and single-item no-ops", () => { + const empty = `|^|`; + expect(applyOp(moveUp, empty)).toEqual(empty); + expect(applyOp(moveDown, empty)).toEqual(empty); + expect(applyOp(indent, empty)).toEqual(empty); + expect(applyOp(outdent, empty)).toEqual(empty); + + const single = `- on|^|ly +`; + expect(applyOp(moveUp, single)).toEqual(single); + expect(applyOp(moveDown, single)).toEqual(single); + }); + + describe("nested boundary no-ops", () => { + test("move down last item with sub-items", () => { + const input = "- a\n- b|^|\n - sub1\n - sub2"; + expect(applyOp(moveDown, input)).toEqual(input); + }); + + test("move down last nested item", () => { + const input = "- a\n- b\n - sub1\n - sub2|^|"; + expect(applyOp(moveDown, input)).toEqual(input); + }); + + test("move up first item with sub-items", () => { + const input = "- a|^|\n - sub\n- b"; + expect(applyOp(moveUp, input)).toEqual(input); + }); + + test("cursor at end of last nested item blocks move down", () => { + const input = "- a\n- b\n - sub1\n - sub2|^|\n- c"; + expect(applyOp(moveDown, input)).toEqual(input); + }); + }); + + test("cursor on leading whitespace of nested item is no-op", () => { + const input = " - a\n - b\n - sub1\n |^| - sub2\n - c"; + expect(applyOp(moveDown, input)).toEqual(input); + }); + + test("cursor at end of nested item moves within nested list", () => { + expect(applyOp(moveDown, "- a\n- b\n - sub1|^|\n - sub2\n- c")).toEqual("- a\n- b\n - sub2\n - sub1|^|\n- c",); + }); +}); + +// Context Detection +describe("Cursor positions in bullet list", () => { + test("on list marker", () => { + const ctx = detect("|^|- one\n- two\n"); + expect(ctx?.type).toEqual("listItem"); + expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("BulletList"); + }); + + test("on space after marker", () => { + expect(detect("-|^| one\n- two\n")?.type).toEqual("listItem"); + }); + + test("on newline between items", () => { + expect(detect("- one|^|\n- two\n")?.type).toEqual("listItem"); + }); + + test("at last char of item text", () => { + const ctx = detect("- on|^|e\n- two\n"); + expect(ctx?.type).toEqual("listItem"); + if (ctx?.type === "listItem") { + expect(ctx.itemIndex).toEqual(0); + } + }); + + test("past end of document", () => { + expect(detect("- one\n- two\n|^|")).toEqual(null); + }); +}); + +describe("Cursor positions on heading", () => { + test("on heading marker", () => { + const ctx = detect("|^|## Heading\nBody\n"); + expect(ctx?.type).toEqual("heading"); + if (ctx?.type === "heading") { + expect(ctx.level).toEqual(2); + } + }); + + test("on newline after heading", () => { + expect(detect("## Heading|^|\nBody\n")?.type).toEqual("heading"); + }); +}); + +describe("Context detection edge cases", () => { + test("code block returns null", () => { + expect(detect("```\n|^|some code\n```\n")).toEqual(null); + }); + + test("frontmatter returns null", () => { + expect(detect("---\n|^|title: Test\n---\nContent\n")).toEqual(null); + }); + + test("between paragraphs resolves to preceding paragraph", () => { + expect(detect("Para one.|^|\n\nPara two.\n")?.type).toEqual("paragraph"); + expect(detect("Para one.\n|^|\nPara two.\n")).toEqual(null); + }); + + test("paragraph inside list item detects list item", () => { + expect(detect("- ite|^|m text\n")?.type).toEqual("listItem"); + }); + + test("heading section boundaries are correct", () => { + const ctx = detect("## |^|H2a\nBody\n### H3\nSub\n## H2b\n"); + expect(ctx?.type).toEqual("heading"); + if (ctx?.type === "heading") { + expect(ctx.level).toEqual(2); + const types = ctx.doc.children!.slice(ctx.sectionStart, ctx.sectionEnd) + .filter((c) => c.type).map((c) => c.type); + expect(types).toEqual([ + "ATXHeading2", + "Paragraph", + "ATXHeading3", + "Paragraph", + ]); + } + }); + + test("deeply nested list item detects innermost list", () => { + const ctx = detect("- parent\n - child\n - gra|^|ndchild\n"); + expect(ctx?.type).toEqual("listItem"); + if (ctx?.type === "listItem") { + expect(ctx.list.type).toEqual("BulletList"); + expect(ctx.item.from).toEqual(23); + } + }); + + test("paragraph after heading detects paragraph", () => { + expect(detect("## Heading\nBody|^| text here.\n")?.type).toEqual("paragraph",); + }); + + test("single heading with no following content", () => { + const ctx = detect("## On|^|ly heading\n"); + expect(ctx?.type).toEqual("heading"); + if (ctx?.type === "heading") { + expect(ctx.sectionStart).toEqual(0); + expect(ctx.sectionEnd).toEqual(ctx.doc.children!.length); + } + }); +}); + +describe("List marker variants", () => { + test("ordered list", () => { + const ctx = detect("|^|1. first\n2. second\n"); + expect(ctx?.type).toEqual("listItem"); + expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("OrderedList"); + }); + + test("* bullet", () => { + const ctx = detect("|^|* item one\n* item two\n"); + expect(ctx?.type).toEqual("listItem"); + expect(ctx && "list" in ctx ? ctx.list.type : null).toEqual("BulletList"); + }); + + test("blockquote list", () => { + expect(detect("> - |^|item one\n> - item two\n")?.type).toEqual("listItem"); + }); +}); + +// Table Rows: Move Up/Down +describe("Table row move up/down", () => { + test("swap two data rows", () => { + expect(applyOp( + moveUp, + `| A | B | +| --- | --- | +| 1 | 2 | +| 3|^| | 4 | +`, + )).toEqual(`| A | B | +| --- | --- | +| 3|^| | 4 | +| 1 | 2 | +`,); + expect(applyOp( + moveDown, + `| A | B | +| --- | --- | +| 1|^| | 2 | +| 3 | 4 | +`, + )).toEqual(`| A | B | +| --- | --- | +| 3 | 4 | +| 1|^| | 2 | +`,); + }); + + test("first data row can't move up", () => { + const input = `| A | B | +| --- | --- | +| 1|^| | 2 | +| 3 | 4 | +`; + expect(applyOp(moveUp, input)).toEqual(input); + }); + + test("last data row can't move down", () => { + const input = `| A | B | +| --- | --- | +| 1 | 2 | +| 3|^| | 4 | +`; + expect(applyOp(moveDown, input)).toEqual(input); + }); + + test("header row is not movable", () => { + const input = `| A|^| | B | +| --- | --- | +| 1 | 2 | +`; + expect(applyOp(moveUp, input)).toEqual(input); + expect(applyOp(moveDown, input)).toEqual(input); + }); + + test("three rows, swap middle", () => { + expect(applyOp( + moveUp, + `| H1 | H2 | +| --- | --- | +| a | b | +| c|^| | d | +| e | f | +`, + )).toEqual(`| H1 | H2 | +| --- | --- | +| c|^| | d | +| a | b | +| e | f | +`,); + expect(applyOp( + moveDown, + `| H1 | H2 | +| --- | --- | +| a | b | +| c|^| | d | +| e | f | +`, + )).toEqual(`| H1 | H2 | +| --- | --- | +| a | b | +| e | f | +| c|^| | d | +`,); + }); + + test("indent/outdent on table row is no-op", () => { + const input = `| A | B | +| --- | --- | +| 1|^| | 2 | +`; + expect(applyOp(indent, input)).toEqual(input); + expect(applyOp(outdent, input)).toEqual(input); + }); +}); + +// Table Rows: Context Detection +describe("Table row context detection", () => { + test("cursor in data row detects tableRow", () => { + const ctx = detect(`| A | B | +| --- | --- | +| 1|^| | 2 | +`); + expect(ctx?.type).toEqual("tableRow"); + }); + + test("cursor in header row detects tableRow with isHeader", () => { + const ctx = detect(`| A|^| | B | +| --- | --- | +| 1 | 2 | +`); + expect(ctx?.type).toEqual("tableRow"); + if (ctx?.type === "tableRow") { + expect(ctx.isHeader).toEqual(true); + } + }); + + test("cursor on delimiter row returns null", () => { + expect(detect(`| A | B | +| --|^|- | --- | +| 1 | 2 | +`)).toEqual(null,); + }); +}); diff --git a/plugs/editor/outline.ts b/plugs/editor/outline.ts index 23811f5c..4a5e8df1 100644 --- a/plugs/editor/outline.ts +++ b/plugs/editor/outline.ts @@ -1,288 +1,50 @@ import { editor } from "@silverbulletmd/silverbullet/syscalls"; +import { + indent as indentOp, + moveDown as moveDownOp, + moveUp as moveUpOp, + outdent as outdentOp, +} from "./outline_ops.ts"; -export async function moveItemUp() { +async function applyOutlineOp( + op: ( + text: string, + cursor: number, + ) => { text: string; cursor: number } | "blocked" | null, + fallback?: () => void, +) { const cursorPos = await editor.getCursor(); const text = await editor.getText(); - - try { - let currentItemBounds: ReturnType | undefined; - try { - currentItemBounds = determineItemBounds(text, cursorPos); - } catch { - // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list, - // so we fall back to `moveLineUp()` - void editor.moveLineUp(); - return; - } - let previousItemBounds: ReturnType | undefined; - - try { - previousItemBounds = determineItemBounds( - text, - currentItemBounds.from - 1, - currentItemBounds.indentLevel, - ); - if (currentItemBounds.from === previousItemBounds.from) { - throw new Error("Already at the top"); - } - } catch { - // Ok, top of the list, let's find the previous item at any other indent level and adapt - previousItemBounds = determineItemBounds( - text, - currentItemBounds.from - 1, - ); - } - - let newPreviousText = text.slice( - previousItemBounds.from, - previousItemBounds.to, - ); - // If the current item is embedded inside the previous item, we need to strip it out - if ( - currentItemBounds.from >= previousItemBounds.from && - currentItemBounds.to <= previousItemBounds.to - ) { - newPreviousText = - text.slice(previousItemBounds.from, currentItemBounds.from) + - text.slice(currentItemBounds.to, previousItemBounds.to); - } - - const newText = - ensureNewLine(text.slice(currentItemBounds.from, currentItemBounds.to)) + - newPreviousText; - const newCursorPos = (cursorPos - currentItemBounds.from) + - previousItemBounds.from; - - // console.log("New replacement text", newText); - - await editor.dispatch({ - changes: [ - { - from: Math.min(previousItemBounds.from, currentItemBounds.from), - to: Math.max(currentItemBounds.to, previousItemBounds.to), - insert: newText, - }, - ], - selection: { - anchor: newCursorPos, - }, - }); - } catch (e: any) { - await editor.flashNotification(e.message, "error"); + const result = op(text, cursorPos); + if (result === "blocked") { + await editor.flashNotification("Cannot move item further", "error"); + return; } + if (result === null) { + if (fallback) { + fallback(); + } + return; + } + + await editor.setText(result.text, true); + await editor.moveCursor(result.cursor); +} + +export async function moveItemUp() { + await applyOutlineOp(moveUpOp, () => editor.moveLineUp()); } export async function moveItemDown() { - const cursorPos = await editor.getCursor(); - const text = await editor.getText(); - - try { - let currentItemBounds: ReturnType | undefined; - try { - currentItemBounds = determineItemBounds(text, cursorPos); - } catch { - // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list, - // so we fall back to `moveLineDown()` - void editor.moveLineDown(); - return; - } - let nextItemBounds: ReturnType | undefined; - try { - nextItemBounds = determineItemBounds( - text, - currentItemBounds.to + 1, - currentItemBounds.indentLevel, - ); - - if (currentItemBounds.from === nextItemBounds.from) { - throw new Error("Already at the bottom"); - } - } catch { - nextItemBounds = determineItemBounds( - text, - currentItemBounds.to + 1, - undefined, - false, - ); - } - - if (currentItemBounds.to === nextItemBounds.to) { - throw new Error("Already at the bottom"); - } - - const nextItemText = ensureNewLine( - text.slice(nextItemBounds.from, nextItemBounds.to), - ); - // console.log("Next item text", nextItemText); - const newText = nextItemText + - text.slice(currentItemBounds.from, currentItemBounds.to); - const newCursorPos = (cursorPos - currentItemBounds.from) + - currentItemBounds.from + nextItemText.length; - await editor.dispatch({ - changes: [ - { - from: Math.min(nextItemBounds.from, currentItemBounds.from), - to: Math.max(nextItemBounds.to, currentItemBounds.to), - insert: newText, - }, - ], - selection: { - anchor: newCursorPos, - }, - }); - } catch (e: any) { - await editor.flashNotification(e.message, "error"); - } + await applyOutlineOp(moveDownOp, () => editor.moveLineDown()); } 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"); - } + await applyOutlineOp(indentOp); } 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"); - } -} - -export function ensureNewLine(s: string) { - if (!s.endsWith("\n")) { - return `${s}\n`; - } else { - return s; - } -} - -export function determineItemBounds( - text: string, - pos: number, - minIndentLevel?: number, - withChildren = true, -): { 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++; - } - if (!withChildren) { - // We're not interested in the children, so let's stop here - break; - } - // 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, - }; + await applyOutlineOp(outdentOp); } export async function foldCommand() { diff --git a/plugs/editor/outline_ops.ts b/plugs/editor/outline_ops.ts new file mode 100644 index 00000000..1e838ca6 --- /dev/null +++ b/plugs/editor/outline_ops.ts @@ -0,0 +1,680 @@ +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!, + ); +} diff --git a/plugs/index/task.test.ts b/plugs/index/task.test.ts new file mode 100644 index 00000000..5e1e9628 --- /dev/null +++ b/plugs/index/task.test.ts @@ -0,0 +1,91 @@ +import { parseMarkdown } from "../../client/markdown_parser/parser.ts"; +import { createMockSystem } from "../../plug-api/system_mock.ts"; +import { expect, test } from "vitest"; +import { addParentPointers, renderToText } from "../../plug-api/lib/tree.ts"; +import { completeStates, removeCompletedTasksFromTree } from "./task.ts"; + +function removeCompleted(md: string): string { + createMockSystem(); + const tree = parseMarkdown(md); + addParentPointers(tree); + removeCompletedTasksFromTree(tree, completeStates); + return renderToText(tree); +} + +test("removeCompletedTasks: flat list", () => { + const input = `- [x] completed +- [ ] incomplete +`; + const result = removeCompleted(input); + expect(result).toEqual("- [ ] incomplete\n"); +}); + +test("removeCompletedTasks: flat list, completed last", () => { + const input = `- [ ] incomplete +- [x] completed +`; + const result = removeCompleted(input); + expect(result).toEqual("- [ ] incomplete\n"); +}); + +test("removeCompletedTasks: flat list, all completed", () => { + const input = `- [x] one +- [x] two +`; + const result = removeCompleted(input); + expect(result).toEqual(""); +}); + +test("removeCompletedTasks: nested list, completed has sibling", () => { + const input = `- [ ] parent item + - [x] completed nested + - [ ] incomplete nested +`; + const result = removeCompleted(input); + expect(result).toEqual("- [ ] parent item\n - [ ] incomplete nested\n"); +}); + +test("removeCompletedTasks: nested list, only nested item completed", () => { + const input = `- [ ] parent item + - [x] only nested item +- [ ] sibling item +`; + const result = removeCompleted(input); + expect(result).toEqual("- [ ] parent item\n- [ ] sibling item\n"); +}); + +test("removeCompletedTasks: nested list, all nested completed", () => { + const input = `- [ ] parent item + - [x] nested completed one + - [x] nested completed two +- [ ] sibling item +`; + const result = removeCompleted(input); + expect(result).toEqual("- [ ] parent item\n- [ ] sibling item\n"); +}); + +test("removeCompletedTasks: mixed flat and nested", () => { + const input = `- [x] flat completed +- [ ] flat incomplete + - [x] nested completed + - [ ] nested incomplete +`; + const result = removeCompleted(input); + expect(result).toEqual("- [ ] flat incomplete\n - [ ] nested incomplete\n"); +}); + +test("removeCompletedTasks: uppercase X treated as completed", () => { + const input = `- [X] completed uppercase +- [ ] incomplete +`; + const result = removeCompleted(input); + expect(result).toEqual("- [ ] incomplete\n"); +}); + +test("removeCompletedTasks: no completed tasks, no change", () => { + const input = `- [ ] one +- [ ] two +`; + const result = removeCompleted(input); + expect(result).toEqual(input); +}); diff --git a/plugs/index/task.ts b/plugs/index/task.ts index ef123dea..5920c6db 100644 --- a/plugs/index/task.ts +++ b/plugs/index/task.ts @@ -295,17 +295,13 @@ export async function taskCycleCommand() { await convertListItemToTask(listItem); } -export async function removeCompletedTasksCommand() { - const tree = await markdown.parseMarkdown(await editor.getText()); - addParentPointers(tree); - +// Core logic extracted for testability. Mutates tree in place. +export function removeCompletedTasksFromTree( + tree: ParseTree, + allCompletedStates: string[], +) { // Taking this ugly approach because the tree is modified in place // Just finding and removing one task at a time and then repeating until nothing changes - const allCompletedStates = completeStates.concat( - Object.values(await config.get("taskStates", {})).filter((ts: any) => - ts.done - ).map((ts: any) => ts.name), - ); while (true) { const completedTaskNode = findNodeMatching(tree, (node) => { return node.type === "Task" && @@ -317,16 +313,65 @@ export async function removeCompletedTasksCommand() { const bulletListNode = listItemNode.parent!; // Remove the list item const listItemIdx = bulletListNode.children!.indexOf(listItemNode); - let removeItems = 1; - if (bulletListNode.children![listItemIdx + 1]?.text === "\n") { - removeItems++; + // Also remove the adjacent whitespace/newline separator text node. + // Prefer the following separator; if none, remove the preceding one. + const nextChild = bulletListNode.children![listItemIdx + 1]; + const prevChild = listItemIdx > 0 + ? bulletListNode.children![listItemIdx - 1] + : undefined; + if (nextChild && !nextChild.type && nextChild.text?.startsWith("\n")) { + // Remove item and following separator + bulletListNode.children!.splice(listItemIdx, 2); + } else if ( + prevChild && !prevChild.type && prevChild.text?.startsWith("\n") + ) { + // Remove preceding separator and item + bulletListNode.children!.splice(listItemIdx - 1, 2); + } else { + // No separator to remove, just remove the item + bulletListNode.children!.splice(listItemIdx, 1); + } + // If the BulletList now has no ListItem children, remove it and any + // adjacent whitespace/newline text node from its parent. This prevents + // blank lines left behind when all items in a nested list are completed. + if ( + bulletListNode.parent && + !bulletListNode.children!.some((c) => c.type === "ListItem") + ) { + const parentChildren = bulletListNode.parent.children!; + const blIdx = parentChildren.indexOf(bulletListNode); + const blNext = parentChildren[blIdx + 1]; + const blPrev = blIdx > 0 ? parentChildren[blIdx - 1] : undefined; + if (blNext && !blNext.type && blNext.text?.startsWith("\n")) { + // Remove BulletList and following separator + parentChildren.splice(blIdx, 2); + } else if ( + blPrev && !blPrev.type && blPrev.text?.startsWith("\n") + ) { + // Remove preceding separator and BulletList + parentChildren.splice(blIdx - 1, 2); + } else { + parentChildren.splice(blIdx, 1); + } } - bulletListNode.children!.splice(listItemIdx, removeItems); } else { // No completed tasks left, we're done break; } } +} + +export async function removeCompletedTasksCommand() { + const tree = await markdown.parseMarkdown(await editor.getText()); + addParentPointers(tree); + + const allCompletedStates = completeStates.concat( + Object.values(await config.get("taskStates", {})).filter((ts: any) => + ts.done + ).map((ts: any) => ts.name), + ); + + removeCompletedTasksFromTree(tree, allCompletedStates); await editor.setText(renderToText(tree)); }