1
0
silverbullet/web/cm_plugins/markdown_widget.ts
Zef Hemel 91027af5fe
Awesome frontmatter (#617)
Live Frontmatter Templates
2024-01-04 20:08:12 +01:00

265 lines
7.5 KiB
TypeScript

import { WidgetType } from "../deps.ts";
import type { Client } from "../client.ts";
import type { CodeWidgetButton, CodeWidgetCallback } from "$sb/types.ts";
import { renderMarkdownToHtml } from "../../plugs/markdown/markdown_render.ts";
import { resolveAttachmentPath } from "$sb/lib/resolve.ts";
import { parse } from "../../common/markdown_parser/parse_tree.ts";
import buildMarkdown from "../../common/markdown_parser/parser.ts";
const activeWidgets = new Set<MarkdownWidget>();
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,
) {
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 || [],
cacheItem.banner,
);
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;
}
const lang = buildMarkdown(this.client.system.mdExtensions);
let mdTree = parse(
lang,
widgetContent.markdown!,
);
mdTree = await this.client.system.localSyscall(
"system.invokeFunction",
[
"markdown.expandCodeWidgets",
mdTree,
this.client.currentPage,
],
);
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 (!url.includes("://")) {
url = resolveAttachmentPath(
this.client.currentPage!,
decodeURI(url),
);
}
return url;
},
preserveAttributes: true,
});
if (cachedHtml === html) {
// HTML still same as in cache, no need to re-render
return;
}
div.innerHTML = this.wrapHtml(
html,
widgetContent.buttons || [],
widgetContent.banner,
);
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,
banner: widgetContent.banner,
},
);
// 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[],
banner?: string,
) {
if (!html) {
return "";
}
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>${
banner ? `<div class="sb-banner">${escapeHtml(banner)}</div>` : ""
}${html}`;
}
private attachListeners(div: HTMLElement, buttons?: CodeWidgetButton[]) {
div.querySelectorAll("a[data-ref]").forEach((el_) => {
const el = el_ as HTMLElement;
// Override default click behavior with a local navigate (faster)
el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const [pageName, pos] = el.dataset.ref!.split(/[$@]/);
if (pos && pos.match(/^\d+$/)) {
this.client.navigate(pageName, +pos);
} else {
this.client.navigate(pageName, pos);
}
});
});
// Implement task toggling
div.querySelectorAll("span[data-external-task-ref]").forEach((el: any) => {
const taskRef = el.dataset.externalTaskRef;
const input = el.querySelector("input[type=checkbox]")!;
input.addEventListener(
"click",
(e: any) => {
// Avoid triggering the click on the parent
e.stopPropagation();
},
);
input.addEventListener(
"change",
(e: any) => {
e.stopPropagation();
const oldState = e.target.dataset.state;
const newState = oldState === " " ? "x" : " ";
// Update state in DOM as well for future toggles
e.target.dataset.state = newState;
console.log("Toggling task", taskRef);
this.client.system.localSyscall(
"system.invokeFunction",
["tasks.updateTaskState", taskRef, oldState, newState],
).catch(
console.error,
);
},
);
});
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.system.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).catch(console.error);
});
} else {
div.querySelector(`button[data-button="${i}"]`)!.addEventListener(
"click",
(e) => {
e.stopPropagation();
console.log("Button clicked:", button.description);
this.client.system.localSyscall("system.invokeFunction", [
button.invokeFunction,
this.from,
]).then((newContent: string | undefined) => {
if (newContent) {
div.innerText = newContent;
}
this.client.focus();
}).catch(console.error);
},
);
}
}
}
get estimatedHeight(): number {
const cacheItem = this.client.getWidgetCache(this.cacheKey);
// console.log("Calling estimated height", this.bodyText, cacheItem);
return cacheItem ? cacheItem.height : -1;
}
eq(other: WidgetType): boolean {
return (
other instanceof MarkdownWidget &&
other.bodyText === this.bodyText && other.cacheKey === this.cacheKey
);
}
}
export function reloadAllMarkdownWidgets() {
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);
function escapeHtml(text: string) {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(
/>/g,
"&gt;",
);
}