Move live query and template rendering outside of iframe

This commit is contained in:
Zef Hemel
2023-12-27 13:38:38 +01:00
parent a205178ff0
commit 48e147d0b2
7 changed files with 381 additions and 77 deletions
+22 -77
View File
@@ -1,79 +1,12 @@
import { WidgetContent } from "../../plug-api/app_event.ts";
import { Decoration, EditorState, syntaxTree, WidgetType } from "../deps.ts";
import { Decoration, EditorState, syntaxTree } from "../deps.ts";
import type { Client } from "../client.ts";
import {
decoratorStateField,
invisibleDecoration,
isCursorInRange,
} from "./util.ts";
import { createWidgetSandboxIFrame } from "../components/widget_sandbox_iframe.ts";
import type { CodeWidgetCallback } from "$sb/types.ts";
class IFrameWidget extends WidgetType {
iframe?: HTMLIFrameElement;
constructor(
readonly from: number,
readonly to: number,
readonly client: Client,
readonly bodyText: string,
readonly codeWidgetCallback: CodeWidgetCallback,
) {
super();
}
toDOM(): HTMLElement {
const from = this.from;
const iframe = createWidgetSandboxIFrame(
this.client,
this.bodyText,
this.codeWidgetCallback(this.bodyText, this.client.currentPage!),
(message) => {
switch (message.type) {
case "blur":
this.client.editorView.dispatch({
selection: { anchor: from },
});
this.client.focus();
break;
case "reload":
this.codeWidgetCallback(this.bodyText, this.client.currentPage!)
.then(
(widgetContent: WidgetContent) => {
iframe.contentWindow!.postMessage({
type: "html",
html: widgetContent.html,
script: widgetContent.script,
theme:
document.getElementsByTagName("html")[0].dataset.theme,
});
},
);
break;
}
},
);
const estimatedHeight = this.estimatedHeight;
iframe.height = `${estimatedHeight}px`;
return iframe;
}
get estimatedHeight(): number {
const cachedHeight = this.client.space.getCachedWidgetHeight(this.bodyText);
// console.log("Calling estimated height", cachedHeight);
return cachedHeight > 0 ? cachedHeight : 150;
}
eq(other: WidgetType): boolean {
return (
other instanceof IFrameWidget &&
other.bodyText === this.bodyText
);
}
}
import { MarkdownWidget } from "./markdown_widget.ts";
import { IFrameWidget } from "./iframe_widget.ts";
export function fencedCodePlugin(editor: Client) {
return decoratorStateField((state: EditorState) => {
@@ -87,6 +20,9 @@ export function fencedCodePlugin(editor: Client) {
const codeWidgetCallback = editor.system.codeWidgetHook
.codeWidgetCallbacks
.get(lang);
const renderMode = editor.system.codeWidgetHook.codeWidgetModes.get(
lang,
);
if (codeWidgetCallback) {
// We got a custom renderer!
const lineStrings = text.split("\n");
@@ -131,15 +67,24 @@ export function fencedCodePlugin(editor: Client) {
);
});
const widget = renderMode === "markdown"
? new MarkdownWidget(
from + lineStrings[0].length + 1,
to - lineStrings[lineStrings.length - 1].length - 1,
editor,
lineStrings.slice(1, lineStrings.length - 1).join("\n"),
codeWidgetCallback,
)
: new IFrameWidget(
from + lineStrings[0].length + 1,
to - lineStrings[lineStrings.length - 1].length - 1,
editor,
lineStrings.slice(1, lineStrings.length - 1).join("\n"),
codeWidgetCallback,
);
widgets.push(
Decoration.widget({
widget: new IFrameWidget(
from + lineStrings[0].length + 1,
to - lineStrings[lineStrings.length - 1].length - 1,
editor,
lineStrings.slice(1, lineStrings.length - 1).join("\n"),
codeWidgetCallback,
),
widget: widget,
}).range(from),
);
return false;
+71
View File
@@ -0,0 +1,71 @@
import { WidgetContent } from "../../plug-api/app_event.ts";
import { WidgetType } from "../deps.ts";
import type { Client } from "../client.ts";
import { createWidgetSandboxIFrame } from "../components/widget_sandbox_iframe.ts";
import type { CodeWidgetCallback } from "$sb/types.ts";
export class IFrameWidget extends WidgetType {
iframe?: HTMLIFrameElement;
constructor(
readonly from: number,
readonly to: number,
readonly client: Client,
readonly bodyText: string,
readonly codeWidgetCallback: CodeWidgetCallback,
) {
super();
}
toDOM(): HTMLElement {
const from = this.from;
const iframe = createWidgetSandboxIFrame(
this.client,
this.bodyText,
this.codeWidgetCallback(this.bodyText, this.client.currentPage!),
(message) => {
switch (message.type) {
case "blur":
this.client.editorView.dispatch({
selection: { anchor: from },
});
this.client.focus();
break;
case "reload":
this.codeWidgetCallback(this.bodyText, this.client.currentPage!)
.then(
(widgetContent: WidgetContent) => {
iframe.contentWindow!.postMessage({
type: "html",
html: widgetContent.html,
script: widgetContent.script,
theme:
document.getElementsByTagName("html")[0].dataset.theme,
});
},
);
break;
}
},
);
const estimatedHeight = this.estimatedHeight;
iframe.height = `${estimatedHeight}px`;
return iframe;
}
get estimatedHeight(): number {
const cachedHeight = this.client.space.getCachedWidgetHeight(this.bodyText);
// console.log("Calling estimated height", cachedHeight);
return cachedHeight > 0 ? cachedHeight : 150;
}
eq(other: WidgetType): boolean {
return (
other instanceof IFrameWidget &&
other.bodyText === this.bodyText
);
}
}
+163
View File
@@ -0,0 +1,163 @@
import { WidgetType } from "../deps.ts";
import type { Client } from "../client.ts";
import type { 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";
import { renderToText } from "$sb/lib/tree.ts";
export class MarkdownWidget extends WidgetType {
renderedMarkdown?: string;
constructor(
readonly from: number,
readonly to: number,
readonly client: Client,
readonly bodyText: string,
readonly codeWidgetCallback: CodeWidgetCallback,
) {
super();
}
toDOM(): HTMLElement {
const div = document.createElement("div");
div.className = "sb-markdown-widget";
const cacheItem = this.client.getWidgetCache(this.bodyText);
if (cacheItem) {
div.innerHTML = this.wrapHtml(cacheItem.html);
this.attachListeners(div);
}
// Async kick-off of content renderer
this.renderContent(div, cacheItem?.html).catch(console.error);
return div;
}
private async renderContent(
div: HTMLElement,
cachedHtml: string | undefined,
) {
const widgetContent = await this.codeWidgetCallback(
this.bodyText,
this.client.currentPage!,
);
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,
],
);
// Used for the source button
this.renderedMarkdown = renderToText(mdTree);
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);
this.attachListeners(div);
// Let's give it a tick, then measure and cache
setTimeout(() => {
this.client.setWidgetCache(
this.bodyText,
div.clientHeight,
html,
);
});
}
private wrapHtml(html: string) {
return `
<div class="button-bar">
<button class="source-button" title="Show Markdown source"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-code"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg></button>
<button class="reload-button" title="Reload"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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></button>
<button class="edit-button" title="Edit"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" 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></button>
</div>
${html}`;
}
private attachListeners(div: HTMLElement) {
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();
this.client.navigate(el.dataset.ref!);
});
});
// Implement task toggling
div.querySelectorAll("span[data-external-task-ref]").forEach((el: any) => {
const taskRef = el.dataset.externalTaskRef;
el.querySelector("input[type=checkbox]").addEventListener(
"change",
(e: any) => {
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,
);
},
);
});
div.querySelector(".edit-button")!.addEventListener("click", () => {
this.client.editorView.dispatch({
selection: { anchor: this.from },
});
this.client.focus();
});
div.querySelector(".reload-button")!.addEventListener("click", () => {
this.renderContent(div, undefined).catch(console.error);
});
div.querySelector(".source-button")!.addEventListener("click", () => {
div.innerText = this.renderedMarkdown!;
});
}
get estimatedHeight(): number {
const cacheItem = this.client.getWidgetCache(this.bodyText);
// console.log("Calling estimated height", cacheItem);
return cacheItem ? cacheItem.height : -1;
}
eq(other: WidgetType): boolean {
return (
other instanceof MarkdownWidget &&
other.bodyText === this.bodyText
);
}
}