silverbullet/web/cm_plugins/markdown_widget.ts

255 lines
6.8 KiB
TypeScript

import { WidgetType } from "@codemirror/view";
import type { Client } from "../client.ts";
import type {
CodeWidgetButton,
CodeWidgetCallback,
} from "../../plug-api/types.ts";
import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts";
import {
isLocalPath,
resolvePath,
} from "@silverbulletmd/silverbullet/lib/resolve";
import { parse } from "$common/markdown_parser/parse_tree.ts";
import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts";
import { renderToText } from "@silverbulletmd/silverbullet/lib/tree";
import { attachWidgetEventHandlers } from "./widget_util.ts";
export const activeWidgets = new Set<DomWidget>();
export interface DomWidget {
dom?: HTMLElement;
renderContent(
div: HTMLElement,
cachedHtml: string | undefined,
): Promise<void>;
}
export class MarkdownWidget extends WidgetType {
public dom?: HTMLElement;
constructor(
readonly from: number | undefined,
readonly client: Client,
readonly cacheKey: string,
readonly bodyText: string,
readonly codeWidgetCallback: CodeWidgetCallback,
readonly className: string,
private tryInline = false,
) {
super();
}
toDOM(): HTMLElement {
const div = document.createElement("div");
div.className = this.className;
const cacheItem = this.client.getWidgetCache(this.cacheKey);
if (cacheItem) {
div.innerHTML = this.wrapHtml(cacheItem.html, cacheItem.buttons);
if (cacheItem.html) {
this.attachListeners(div, cacheItem.buttons);
}
}
// Async kick-off of content renderer
this.renderContent(div, cacheItem?.html).catch(console.error);
this.dom = div;
return div;
}
async renderContent(
div: HTMLElement,
cachedHtml: string | undefined,
) {
const widgetContent = await this.codeWidgetCallback(
this.bodyText,
this.client.currentPage,
);
activeWidgets.add(this);
if (!widgetContent) {
div.innerHTML = "";
this.client.setWidgetCache(
this.cacheKey,
{ height: div.clientHeight, html: "" },
);
return;
}
let mdTree = parse(
extendedMarkdownLanguage,
widgetContent.markdown!,
);
mdTree = await this.client.clientSystem.localSyscall(
"system.invokeFunction",
[
"markdown.expandCodeWidgets",
mdTree,
this.client.currentPage,
],
);
const trimmedMarkdown = renderToText(mdTree).trim();
if (!trimmedMarkdown) {
// Net empty result after expansion
div.innerHTML = "";
this.client.setWidgetCache(
this.cacheKey,
{ height: div.clientHeight, html: "" },
);
return;
}
if (this.tryInline) {
if (trimmedMarkdown.includes("\n")) {
// Heuristic that this is going to be a multi-line output and we should render this as a HTML block
div.style.display = "block";
} else {
div.style.display = "inline";
}
}
// Parse the markdown again after trimming
mdTree = parse(
extendedMarkdownLanguage,
trimmedMarkdown,
);
const html = renderMarkdownToHtml(mdTree, {
// Annotate every element with its position so we can use it to put
// the cursor there when the user clicks on the table.
annotationPositions: true,
translateUrls: (url) => {
if (isLocalPath(url)) {
url = resolvePath(
this.client.currentPage,
decodeURI(url),
);
}
return url;
},
preserveAttributes: true,
}, this.client.ui.viewState.allPages);
if (cachedHtml === html) {
// HTML still same as in cache, no need to re-render
return;
}
div.innerHTML = this.wrapHtml(
html,
widgetContent.buttons,
);
if (html) {
this.attachListeners(div, widgetContent.buttons);
}
// Let's give it a tick, then measure and cache
setTimeout(() => {
this.client.setWidgetCache(
this.cacheKey,
{
height: div.offsetHeight,
html,
buttons: widgetContent.buttons,
},
);
// Because of the rejiggering of the DOM, we need to do a no-op cursor move to make sure it's positioned correctly
this.client.editorView.dispatch({
selection: {
anchor: this.client.editorView.state.selection.main.anchor,
},
});
});
}
private wrapHtml(
html: string,
buttons: CodeWidgetButton[] = [],
) {
if (!html) {
return "";
}
if (buttons.length === 0) {
return html;
} else {
return `<div class="button-bar">${
buttons.filter((button) => !button.widgetTarget).map((button, idx) =>
`<button data-button="${idx}" title="${button.description}">${button.svg}</button> `
).join("")
}</div><div class="content">${html}</div>`;
}
}
private attachListeners(div: HTMLElement, buttons?: CodeWidgetButton[]) {
attachWidgetEventHandlers(div, this.client, this.from);
if (!buttons) {
buttons = [];
}
for (let i = 0; i < buttons.length; i++) {
const button = buttons[i];
if (button.widgetTarget) {
div.addEventListener("click", () => {
console.log("Widget clicked");
this.client.clientSystem.localSyscall("system.invokeFunction", [
button.invokeFunction[0],
this.from,
]).catch(console.error);
});
} else {
div.querySelector(`button[data-button="${i}"]`)!.addEventListener(
"click",
(e) => {
e.stopPropagation();
this.client.clientSystem.localSyscall(
"system.invokeFunction",
button.invokeFunction,
).then((newContent: string | undefined) => {
if (newContent) {
div.innerText = newContent;
}
this.client.focus();
}).catch(console.error);
},
);
}
}
}
override get estimatedHeight(): number {
const cacheItem = this.client.getWidgetCache(this.cacheKey);
return cacheItem ? cacheItem.height : -1;
}
override eq(other: WidgetType): boolean {
return (
other instanceof MarkdownWidget &&
other.bodyText === this.bodyText && other.cacheKey === this.cacheKey &&
this.from === other.from
);
}
}
export function reloadAllWidgets() {
for (const widget of activeWidgets) {
// Garbage collect as we go
if (!widget.dom || !widget.dom.parentNode) {
activeWidgets.delete(widget);
continue;
}
widget.renderContent(widget.dom!, undefined).catch(console.error);
}
}
function garbageCollectWidgets() {
for (const widget of activeWidgets) {
if (!widget.dom || !widget.dom.parentNode) {
// console.log("Garbage collecting widget", widget.bodyText);
activeWidgets.delete(widget);
}
}
}
setInterval(garbageCollectWidgets, 5000);