No longer cache widget HTML content just widget heights, either way the widgets will briefly flash as the widget is recalculated and this saves storage space and reduces issues in Webkit
402 lines
12 KiB
TypeScript
402 lines
12 KiB
TypeScript
import { WidgetType } from "@codemirror/view";
|
|
import type { Client } from "../client.ts";
|
|
import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts";
|
|
import { parse } from "../markdown_parser/parse_tree.ts";
|
|
import { buildExtendedMarkdownLanguage } from "../markdown_parser/parser.ts";
|
|
import { type ParseTree, renderToText } from "@silverbulletmd/silverbullet/lib/tree";
|
|
import {
|
|
attachWidgetEventHandlers,
|
|
buildTranslateUrls,
|
|
moveCursorToWidgetStart,
|
|
} from "./widget_util.ts";
|
|
import { expandMarkdown } from "../markdown_renderer/inline.ts";
|
|
import { isBlockMarkdown } from "../space_lua/render_lua_markdown.ts";
|
|
import { activeWidgets } from "./code_widget.ts";
|
|
import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";
|
|
import { renderResultToMarkdown } from "../space_lua/render_lua_markdown.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);
|
|
// 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<ParseTree> {
|
|
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 = 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 { markdown, dataType } = renderResultToMarkdown(widgetContent);
|
|
|
|
const isBlock =
|
|
dataType === "table" ||
|
|
dataType === "list" ||
|
|
(typeof widgetContent === "string" && isBlockMarkdown(widgetContent));
|
|
|
|
widgetContent = {
|
|
_isWidget: true,
|
|
markdown: markdown,
|
|
display: isBlock ? "block" : "inline",
|
|
};
|
|
copyContent = markdown;
|
|
}
|
|
|
|
// 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) {
|
|
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();
|
|
|
|
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",
|
|
false,
|
|
),
|
|
translateUrls: buildTranslateUrls(this.opts.client),
|
|
},
|
|
this.opts.client.ui.viewState.allPages,
|
|
),
|
|
);
|
|
}
|
|
if (html) {
|
|
div.replaceChildren(this.wrapHtml(block, html, copyContent));
|
|
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,
|
|
): 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;
|
|
}
|