import { WidgetType } from "@codemirror/view"; import type { Ref } from "@silverbulletmd/silverbullet/lib/ref"; import { type ParseTree, renderToText, } from "@silverbulletmd/silverbullet/lib/tree"; import type { Client } from "../client.ts"; import { parse } from "../markdown_parser/parse_tree.ts"; import { buildExtendedMarkdownLanguage } from "../markdown_parser/parser.ts"; import { expandMarkdown } from "../markdown_renderer/inline.ts"; import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts"; import { classifyResult, isBlockMarkdown, renderResultToCleanMarkdown, renderResultToMarkdown, } from "../space_lua/render_lua_markdown.ts"; import { activeWidgets } from "./code_widget.ts"; import { attachWidgetEventHandlers, buildTranslateUrls, findWidgetSourceRange, moveCursorToWidgetStart, } from "./widget_util.ts"; import { escapeBakedBody } from "../baked_sections/regions.ts"; import { createWidgetSandboxIFrame } from "../components/widget_sandbox_iframe.ts"; export type LuaWidgetCallback = ( bodyText: string, pageName: string, ) => Promise; 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 void>; // When true, html+script render inside a sandbox iframe (see renderContent). sandbox?: boolean; // Script to run inside the sandbox iframe (only used when `sandbox` is true). script?: string; } | 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; /** * Whether this widget can be "baked" into a `` region. Only * `${…}` Lua directives qualify, because baking rewrites the source to * `` where `EXPR` is re-evaluated as a Lua expression. The * body of a fenced code block (e.g. ```mermaid) is not a Lua expression, so * those widgets never get a Bake button even though they render through the * same pipeline. */ bakeable?: boolean; 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, }; if (this.opts.inPage) { this.opts.client.widgetCache .prewarmResult(this.opts.cacheKey, () => this.opts.callback( this.opts.expressionText, this.opts.client.currentName(), ), ) .catch(() => { // Ignore: renderContent re-awaits the same promise and handles // errors via its own catch path. }); } } override get estimatedHeight(): number { return this.opts.client.widgetCache.getCachedWidgetHeight( this.opts.cacheKey, ); } invalidatePrewarm() { this.opts.client.widgetCache.invalidatePrewarm(this.opts.cacheKey); } toDOM(): HTMLElement { const wrapperSpan = document.createElement("span"); wrapperSpan.className = "sb-lua-wrapper"; const innerDiv = document.createElement("div"); wrapperSpan.appendChild(innerDiv); // On a cache hit, apply the correct block/inline class and reserve // vertical space via min-height. We deliberately do NOT insert any // cached HTML — re-parsing a cached HTML string produces a subtly // different measured height than the fresh render, causing a brief // border-position glitch on first paint. const cachedMeta = this.opts.client.widgetCache.getCachedWidgetMeta( this.opts.cacheKey, ); if (cachedMeta) { innerDiv.className += cachedMeta.block ? " sb-lua-directive-block" : " sb-lua-directive-inline"; if (cachedMeta.height > 0) { innerDiv.style.minHeight = `${cachedMeta.height}px`; } } // Async kick-off of content renderer this.renderContent(innerDiv).catch(console.error); this.dom = wrapperSpan; return wrapperSpan; } private get syntaxExtensions() { return this.opts.client.config.get("syntaxExtensions", {}); } // Parse and expand custom syntax in a markdown string (no transclusions/directives) private async parseAndExpandCustomSyntax( text: string, pageName: string, ): Promise { const syntaxExtensions = this.syntaxExtensions; const mdTree = parse(buildExtendedMarkdownLanguage(syntaxExtensions), text); return expandMarkdown( this.opts.client.space, pageName, mdTree, this.opts.client.clientSystem.spaceLuaEnv, { expandTransclusions: false, expandLuaDirectives: false, rewriteTasks: false, syntaxExtensions, }, ); } async renderContent(div: HTMLElement) { const currentName = this.opts.client.currentName(); let widgetContent = this.opts.inPage ? await this.opts.client.widgetCache.prewarmResult( this.opts.cacheKey, () => this.opts.callback(this.opts.expressionText, currentName), ) : await this.opts.callback(this.opts.expressionText, currentName); activeWidgets.add(this); if (widgetContent === null || widgetContent === undefined) { if (!this.opts.renderEmpty) { div.innerHTML = ""; div.style.minHeight = ""; this.opts.client.widgetCache.removeCachedWidgetMeta(this.opts.cacheKey); return; } widgetContent = { markdown: "nil", _isWidget: true }; } let html: HTMLElement | undefined; let block = false; let copyContent: string | undefined; // Normalization (non-widget results go through markdown rendering) if (typeof widgetContent === "string" || !widgetContent._isWidget) { const rawResult = widgetContent; // Classify once, share the result between the display and copy paths. const classified = classifyResult(rawResult); const { markdown, dataType } = renderResultToMarkdown( rawResult, classified, ); const isBlock = dataType === "table" || dataType === "list" || (typeof rawResult === "string" && isBlockMarkdown(rawResult)); widgetContent = { _isWidget: true, markdown: markdown, display: isBlock ? "block" : "inline", }; // Copy button gets a clean GFM-style rendering, not the display markdown. copyContent = await renderResultToCleanMarkdown(rawResult, classified); } // After normalization `widgetContent` is always the object form const wc = widgetContent as Exclude; if (wc.cssClasses) { div.className = wc.cssClasses.join(" "); } // Explicit opt-in: when `sandbox` is true, render html+script inside a // sandbox iframe (the script, if any, runs there). if (wc.sandbox) { div.className += " sb-lua-directive-block"; const iframeContent = { html: typeof wc.html === "string" ? wc.html : "", script: typeof wc.script === "string" ? wc.script : "", }; const iframe = createWidgetSandboxIFrame( this.opts.client, this.opts.cacheKey, iframeContent, (message) => { switch (message.type) { case "blur": { const pos = this.opts.client.editorView.posAtDOM(iframe, 0); this.opts.client.editorView.dispatch({ selection: { anchor: pos }, }); this.opts.client.focus(); break; } } }, ); const cachedHeight = this.opts.client.widgetCache.getCachedWidgetHeight( this.opts.cacheKey, ); iframe.style.height = `${cachedHeight > 0 ? cachedHeight : 150}px`; iframe.style.display = "block"; const wrapped = this.wrapHtml( true, iframe, wc.markdown, typeof wc.markdown === "string" ? wc.markdown.trim() : undefined, ); div.replaceChildren(wrapped); div.style.minHeight = ""; this.opts.client.widgetCache.setCachedWidgetMeta(this.opts.cacheKey, { height: cachedHeight > 0 ? cachedHeight : 150, block: true, }); return; } if (wc.html) { if (typeof wc.html === "string") { html = parseHtmlString(wc.html); } else { html = wc.html; } // When a widget provides both `html` and `markdown`, the `html` is the // display and the `markdown` is what the Copy button should copy (e.g. a // diagram renders to an SVG via `html` while exposing its source block via // `markdown`). Only fall back to the html itself when no markdown is given. if (!copyContent) { copyContent = typeof wc.html === "string" ? (wc.markdown ?? wc.html) : (wc.markdown ?? wc.html.outerHTML); } block = wc.display === "block"; if (block) { div.className += " sb-lua-directive-block"; } else { div.className += " sb-lua-directive-inline"; } } // `markdown` is only used for display when there is no `html` to show; when // both are present `markdown` is reserved for the Copy button (handled above). if (!html && wc.markdown) { const syntaxExtensions = this.syntaxExtensions; let mdTree = parse( buildExtendedMarkdownLanguage(syntaxExtensions), wc.markdown || "", ); mdTree = await expandMarkdown( this.opts.client.space, currentName, mdTree, this.opts.client.clientSystem.spaceLuaEnv, { rewriteTasks: false, syntaxExtensions, }, ); const trimmedMarkdown = renderToText(mdTree).trim(); // Fall back to the rendered markdown only if the raw-result path // didn't already produce a clean copy string. if (!copyContent) { copyContent = trimmedMarkdown; } if (!trimmedMarkdown) { // Net empty result after expansion div.innerHTML = ""; div.style.minHeight = ""; this.opts.client.widgetCache.removeCachedWidgetMeta(this.opts.cacheKey); return; } block = (wc._isWidget && wc.display === "block") || isBlockMarkdown(trimmedMarkdown); if (block) { div.className += " sb-lua-directive-block"; } else { div.className += " sb-lua-directive-inline"; } mdTree = await this.parseAndExpandCustomSyntax( trimmedMarkdown, currentName, ); html = parseHtmlString( renderMarkdownToHtml( mdTree, { shortWikiLinks: this.opts.client.config.get("shortWikiLinks", true), translateUrls: buildTranslateUrls(this.opts.client), }, this.opts.client.ui.viewState.allPages, ), ); } if (html) { const bakeBody = wc.html ? typeof wc.markdown === "string" ? wc.markdown.trim() : undefined : copyContent; div.replaceChildren(this.wrapHtml(block, html, copyContent, bakeBody)); div.style.minHeight = ""; 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.setCachedWidgetMeta(this.opts.cacheKey, { height: div.offsetHeight, block, }); // Skip during IME composition to avoid caret jumps if (!this.opts.client.editorView.composing) { // 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, bakeBody?: 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: '', listener: (e) => { e.stopPropagation(); this.opts.client.clientSystem .localSyscall("system.invokeFunction", ["index.refreshWidgets"]) .catch(console.error); }, }), ); if (copyContent) { buttonBar.appendChild( createButton({ title: "Copy", icon: ``, listener: (e) => { e.stopPropagation(); this.opts.client.clientSystem .localSyscall("editor.copyToClipboard", [copyContent]) .catch(console.error); }, }), ); } if ( this.opts.bakeable && bakeBody !== undefined && this.opts.codeText && !this.opts.client.isReadOnlyMode() ) { buttonBar.appendChild( createButton({ title: "Bake", icon: '', listener: (e) => { e.stopPropagation(); const range = findWidgetSourceRange( this.opts.client, this.dom!, this.opts.codeText!, ); if (!range) { this.opts.client.ui.flashNotification( "Could not locate the directive to bake", "error", ); return; } const replacement = `\n${escapeBakedBody( bakeBody, ).trim()}\n`; this.opts.client.editorView.dispatch({ changes: { from: range.from, to: range.to, insert: replacement }, }); this.opts.client.focus(); }, }), ); } if (this.opts.inPage) { buttonBar.appendChild( createButton({ title: "Edit", icon: '', listener: (e) => { e.stopPropagation(); moveCursorToWidgetStart( this.opts.client, this.dom!, this.opts.codeText, ); }, }), ); } if (this.opts.openRef) { buttonBar.appendChild( createButton({ title: "Open", icon: '', 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; }