* Fix extended task rendering in widgets
This patch addresses several issues.
1. Extended tasks like `[PLANNED]` or `[TODO]` were not rendering
correctly when generated via widgets. The HTML renderer was producing
plain text without brackets or proper styling, while simple `[x]`
tasks worked fine. The fix produces the same DOM structure as
CodeMirror producing coherent styling everywhere.
2. Widget checkboxes are not interactive now. Since widget tasks have no
backing state handler clicking them only caused a misleading visual
toggle with no effect. The `disabled` attribute prevents this
confusion and the dimmed appearanceit signals to the user that the
checkbox is read-only in this context which is the truth.
To see it in action try the following Markdown snippet in SilverBullet:
```
- [x] #foo [att: i]
- [PLANNED] bar #bar [att: i]
${'- [x] #foo [att: i]\n- [PLANNED] bar #bar [att: i]'}
```
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
* Revert and just prevent widget checkboxes from toggling visually
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
* Support custom task states in widgets and indexer
Custom task states like `[PLANNED]` or `[IN PROGRESS]` now render
correctly in *widgets*, matching the same DOM structure as CodeMirror
uses in the editor. Widget-rendered tasks that reference a real task
(via `page@pos`) are fully interactive — clicking an extended state
cycles it through the configured states and updates the source task.
Tasks without a reference are disabled to prevent misleading visual
feedback.
The task indexer now respects the `done` flag on custom states defined
via `taskState.define`, so queries like `where not t.done` correctly
exclude tasks in states marked as done. Previously only the built-in
simple tasks (`- [x]` or `- [X]`) states were treated as completed. The
`Task: Remove Completed` command already handled custom done states and
continues to work unchanged.
State cycling now preserves the definition order from `taskState.define`
calls instead of sorting alphabetically. A new `order` field allows
explicit control over the cycle sequence. Without it, states cycle in
definition order. With it, states are sorted by the `order` field value.
This gives users full control over the progression, for example:
PLANNED --> IN PROGRESS --> FINISHED
The `taskState.define` API now validates its input against a schema:
`name` (string, required), `done` (boolean, optional), and `order`
(number, optional).
The `expandMarkdown` no longer injects "fake" `page@pos` references into
tasks rendered via `widget.markdownBlock` or `widget.new` which was
causing broken links and unwanted interactivity on static task content.
The `task:stateChange` event is dispatched when toggling tasks from
widgets, ensuring Lua listeners (such as some custom completed timestamp
management) fire correctly regardless of where the toggle originates.
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
---------
Signed-off-by: Matouš Jan Fialka <mjf@mjf.cz>
430 lines
13 KiB
TypeScript
430 lines
13 KiB
TypeScript
import { WidgetType } from "@codemirror/view";
|
|
import type { Client } from "../client.ts";
|
|
import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts";
|
|
import {
|
|
isLocalURL,
|
|
resolveMarkdownLink,
|
|
} from "@silverbulletmd/silverbullet/lib/resolve";
|
|
import { parse } from "../markdown_parser/parse_tree.ts";
|
|
import { extendedMarkdownLanguage } from "../markdown_parser/parser.ts";
|
|
import { renderToText } from "@silverbulletmd/silverbullet/lib/tree";
|
|
import {
|
|
attachWidgetEventHandlers,
|
|
moveCursorToWidgetStart,
|
|
} from "./widget_util.ts";
|
|
import { expandMarkdown } from "../markdown_renderer/inline.ts";
|
|
import {
|
|
isBlockMarkdown,
|
|
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,
|
|
pageName: string,
|
|
) => Promise<LuaWidgetContent | null>;
|
|
|
|
export type EventPayLoad = {
|
|
name: string;
|
|
data: any;
|
|
};
|
|
|
|
export type LuaWidgetContent =
|
|
| {
|
|
// Magic marker
|
|
_isWidget?: true;
|
|
// Render as HTML
|
|
html?: string | HTMLElement;
|
|
// Render as markdown
|
|
markdown?: string;
|
|
// CSS classes for wrapper
|
|
cssClasses?: string[];
|
|
display?: "block" | "inline";
|
|
// Event handlers
|
|
events?: Record<string, (event: EventPayLoad) => void>;
|
|
}
|
|
| string;
|
|
|
|
export interface LuaWidgetOptions {
|
|
client: Client;
|
|
/** Key to use for caching */
|
|
cacheKey: string;
|
|
/** Body text to send to widget renderer */
|
|
expressionText: string;
|
|
callback: LuaWidgetCallback;
|
|
inPage: boolean;
|
|
/** Code as it appears in the page (used to find when hitting the "edit" button) */
|
|
codeText?: string;
|
|
renderEmpty?: boolean;
|
|
openRef?: Ref | null;
|
|
}
|
|
|
|
export class LuaWidget extends WidgetType {
|
|
public dom?: HTMLElement;
|
|
|
|
constructor(readonly opts: LuaWidgetOptions) {
|
|
super();
|
|
this.opts = {
|
|
codeText: "",
|
|
renderEmpty: false,
|
|
openRef: null,
|
|
...opts,
|
|
};
|
|
}
|
|
|
|
override get estimatedHeight(): number {
|
|
return this.opts.client.widgetCache.getCachedWidgetHeight(
|
|
this.opts.cacheKey,
|
|
);
|
|
}
|
|
|
|
toDOM(): HTMLElement {
|
|
const wrapperSpan = document.createElement("span");
|
|
wrapperSpan.className = "sb-lua-wrapper";
|
|
const innerDiv = document.createElement("div");
|
|
wrapperSpan.appendChild(innerDiv);
|
|
const cacheItem = this.opts.client.widgetCache.getWidgetCache(
|
|
this.opts.cacheKey,
|
|
);
|
|
if (cacheItem) {
|
|
if (cacheItem.block) {
|
|
innerDiv.className += " sb-lua-directive-block";
|
|
} else {
|
|
innerDiv.className += " sb-lua-directive-inline";
|
|
}
|
|
// This is to make the initial render faster, will later be replaced by the actual content
|
|
innerDiv.replaceChildren(
|
|
this.wrapHtml(!!cacheItem.block, cacheItem.html, cacheItem.copyContent),
|
|
);
|
|
attachWidgetEventHandlers(
|
|
innerDiv,
|
|
this.opts.client,
|
|
this.opts.inPage ? this.opts.codeText : undefined,
|
|
);
|
|
}
|
|
|
|
// Async kick-off of content renderer
|
|
this.renderContent(innerDiv).catch(console.error);
|
|
this.dom = wrapperSpan;
|
|
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.opts.client.config.get("shortWikiLinks", false),
|
|
translateUrls: (url) => {
|
|
if (isLocalURL(url)) {
|
|
url = resolveMarkdownLink(
|
|
this.opts.client.currentName(),
|
|
decodeURI(url),
|
|
);
|
|
}
|
|
return url;
|
|
},
|
|
},
|
|
this.opts.client.ui.viewState.allPages,
|
|
);
|
|
};
|
|
}
|
|
|
|
async renderContent(div: HTMLElement) {
|
|
const currentName = this.opts.client.currentName();
|
|
let widgetContent = await this.opts.callback(
|
|
this.opts.expressionText,
|
|
currentName,
|
|
);
|
|
activeWidgets.add(this);
|
|
if (widgetContent === null || widgetContent === undefined) {
|
|
if (!this.opts.renderEmpty) {
|
|
div.innerHTML = "";
|
|
this.opts.client.widgetCache.setWidgetCache(this.opts.cacheKey, {
|
|
html: "",
|
|
block: false,
|
|
});
|
|
this.opts.client.widgetCache.setCachedWidgetHeight(
|
|
this.opts.cacheKey,
|
|
div.clientHeight,
|
|
);
|
|
return;
|
|
}
|
|
widgetContent = { markdown: "nil", _isWidget: true };
|
|
}
|
|
|
|
let html: HTMLElement | undefined;
|
|
let block = false;
|
|
let copyContent: string | undefined;
|
|
|
|
// Normalization (non-widget results go through dual-path rendering)
|
|
if (typeof widgetContent === "string" || !widgetContent._isWidget) {
|
|
// 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);
|
|
|
|
const isBlock =
|
|
dataType === "table" ||
|
|
dataType === "list" ||
|
|
(typeof widgetContent === "string" && isBlockMarkdown(widgetContent));
|
|
|
|
widgetContent = {
|
|
_isWidget: true,
|
|
html: `<span data-type="${dataType}">${displayHtml}</span>`,
|
|
display: isBlock ? "block" : "inline",
|
|
};
|
|
copyContent = markdownCopy;
|
|
}
|
|
|
|
// After normalization `widgetContent` is always the object form
|
|
const wc = widgetContent as Exclude<LuaWidgetContent, string>;
|
|
|
|
if (wc.cssClasses) {
|
|
div.className = wc.cssClasses.join(" ");
|
|
}
|
|
if (wc.html) {
|
|
if (typeof wc.html === "string") {
|
|
html = parseHtmlString(wc.html);
|
|
if (!copyContent) copyContent = wc.html;
|
|
} else {
|
|
html = wc.html;
|
|
if (!copyContent) copyContent = wc.html.outerHTML;
|
|
}
|
|
|
|
block = wc.display === "block";
|
|
if (block) {
|
|
div.className += " sb-lua-directive-block";
|
|
} else {
|
|
div.className += " sb-lua-directive-inline";
|
|
}
|
|
}
|
|
if (wc.markdown) {
|
|
let mdTree = parse(extendedMarkdownLanguage, wc.markdown || "");
|
|
|
|
mdTree = await expandMarkdown(
|
|
this.opts.client.space,
|
|
currentName,
|
|
mdTree,
|
|
this.opts.client.clientSystem.spaceLuaEnv,
|
|
{
|
|
rewriteTasks: false,
|
|
},
|
|
);
|
|
const trimmedMarkdown = renderToText(mdTree).trim();
|
|
|
|
copyContent = trimmedMarkdown;
|
|
|
|
if (!trimmedMarkdown) {
|
|
// Net empty result after expansion
|
|
div.innerHTML = "";
|
|
this.opts.client.widgetCache.setWidgetCache(this.opts.cacheKey, {
|
|
html: "",
|
|
block: false,
|
|
});
|
|
this.opts.client.widgetCache.setCachedWidgetHeight(
|
|
this.opts.cacheKey,
|
|
div.clientHeight,
|
|
);
|
|
return;
|
|
}
|
|
|
|
block =
|
|
(wc._isWidget && wc.display === "block") ||
|
|
isBlockMarkdown(trimmedMarkdown);
|
|
if (block) {
|
|
div.className += " sb-lua-directive-block";
|
|
} else {
|
|
div.className += " sb-lua-directive-inline";
|
|
}
|
|
|
|
// Parse the markdown again after trimming
|
|
mdTree = parse(extendedMarkdownLanguage, trimmedMarkdown);
|
|
|
|
html = parseHtmlString(
|
|
renderMarkdownToHtml(
|
|
mdTree,
|
|
{
|
|
shortWikiLinks: this.opts.client.config.get(
|
|
"shortWikiLinks",
|
|
false,
|
|
),
|
|
translateUrls: (url) => {
|
|
if (isLocalURL(url)) {
|
|
url = resolveMarkdownLink(
|
|
this.opts.client.currentName(),
|
|
decodeURI(url),
|
|
);
|
|
}
|
|
|
|
return url;
|
|
},
|
|
},
|
|
this.opts.client.ui.viewState.allPages,
|
|
),
|
|
);
|
|
}
|
|
if (html) {
|
|
div.replaceChildren(this.wrapHtml(block, html, copyContent));
|
|
attachWidgetEventHandlers(
|
|
div,
|
|
this.opts.client,
|
|
this.opts.inPage ? this.opts.codeText : undefined,
|
|
wc._isWidget && wc.events,
|
|
);
|
|
}
|
|
|
|
// Let's give it a tick, then measure and cache
|
|
setTimeout(() => {
|
|
this.opts.client.widgetCache.setWidgetCache(this.opts.cacheKey, {
|
|
html: html?.outerHTML || "",
|
|
block,
|
|
copyContent: copyContent,
|
|
});
|
|
this.opts.client.widgetCache.setCachedWidgetHeight(
|
|
this.opts.cacheKey,
|
|
div.offsetHeight,
|
|
);
|
|
// Because of the rejiggering of the DOM, we need to do a no-op cursor move to make sure it's positioned correctly
|
|
this.opts.client.editorView.dispatch({
|
|
selection: this.opts.client.editorView.state.selection,
|
|
});
|
|
});
|
|
}
|
|
|
|
wrapHtml(
|
|
isBlock: boolean,
|
|
html: string | HTMLElement,
|
|
copyContent: string | undefined,
|
|
): HTMLElement {
|
|
if (typeof html === "string") {
|
|
html = parseHtmlString(html);
|
|
}
|
|
if (!isBlock) {
|
|
return html;
|
|
}
|
|
const container = document.createElement("div");
|
|
const buttonBar = document.createElement("div");
|
|
buttonBar.className = "button-bar";
|
|
|
|
const createButton = ({
|
|
title,
|
|
icon,
|
|
listener,
|
|
}: {
|
|
title: string;
|
|
icon: string;
|
|
listener: (event: MouseEvent) => void;
|
|
}) => {
|
|
const button = document.createElement("button");
|
|
button.setAttribute("data-button", title.toLowerCase());
|
|
button.setAttribute("title", title);
|
|
button.innerHTML = icon;
|
|
button.addEventListener("click", listener);
|
|
|
|
return button;
|
|
};
|
|
|
|
buttonBar.appendChild(
|
|
createButton({
|
|
title: "Reload",
|
|
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>',
|
|
listener: (e) => {
|
|
e.stopPropagation();
|
|
this.opts.client.clientSystem
|
|
.localSyscall("system.invokeFunction", ["index.refreshWidgets"])
|
|
.catch(console.error);
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (copyContent) {
|
|
buttonBar.appendChild(
|
|
createButton({
|
|
title: "Copy",
|
|
icon: `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
|
|
listener: (e) => {
|
|
e.stopPropagation();
|
|
|
|
this.opts.client.clientSystem
|
|
.localSyscall("editor.copyToClipboard", [copyContent])
|
|
.catch(console.error);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (this.opts.inPage) {
|
|
buttonBar.appendChild(
|
|
createButton({
|
|
title: "Edit",
|
|
icon: '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>',
|
|
listener: (e) => {
|
|
e.stopPropagation();
|
|
moveCursorToWidgetStart(
|
|
this.opts.client,
|
|
this.dom!,
|
|
this.opts.codeText,
|
|
);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (this.opts.openRef) {
|
|
buttonBar.appendChild(
|
|
createButton({
|
|
title: "Open",
|
|
icon: '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
|
|
listener: (e) => {
|
|
e.stopPropagation();
|
|
void this.opts.client.navigate(this.opts.openRef!);
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const content = document.createElement("div");
|
|
content.className = "content";
|
|
content.appendChild(html);
|
|
|
|
container.appendChild(buttonBar);
|
|
container.appendChild(content);
|
|
|
|
return container;
|
|
}
|
|
|
|
override eq(other: WidgetType): boolean {
|
|
return (
|
|
other instanceof LuaWidget &&
|
|
other.opts.expressionText === this.opts.expressionText &&
|
|
other.opts.cacheKey === this.opts.cacheKey
|
|
);
|
|
}
|
|
|
|
override ignoreEvent() {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export function parseHtmlString(html: string): HTMLElement {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, "text/html");
|
|
// Create a wrapper div to hold all elements
|
|
const wrapper = document.createElement("span");
|
|
wrapper.className = "wrapper";
|
|
// Move all body children into the wrapper
|
|
while (doc.body.firstChild) {
|
|
wrapper.appendChild(doc.body.firstChild);
|
|
}
|
|
return wrapper;
|
|
}
|