diff --git a/plugs/query/query.plug.yaml b/plugs/query/query.plug.yaml index c8ac1e73..1c8225f5 100644 --- a/plugs/query/query.plug.yaml +++ b/plugs/query/query.plug.yaml @@ -3,6 +3,7 @@ functions: queryWidget: path: query.ts:widget codeWidget: query + renderMode: markdown lintQuery: path: query.ts:lintQuery @@ -12,6 +13,7 @@ functions: templateWidget: path: template.ts:widget codeWidget: template + renderMode: markdown queryComplete: path: complete.ts:queryComplete diff --git a/web/client.ts b/web/client.ts index 5f40c64d..fd9f01b6 100644 --- a/web/client.ts +++ b/web/client.ts @@ -46,6 +46,7 @@ import { DataStoreSpacePrimitives } from "../common/spaces/datastore_space_primi import { EncryptedSpacePrimitives, } from "../common/spaces/encrypted_space_primitives.ts"; +import { LimitedMap } from "../common/limited_map.ts"; const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; const autoSaveInterval = 1000; @@ -187,6 +188,13 @@ export class Client { // Load settings this.settings = await ensureSettingsAndIndex(localSpacePrimitives); + // Load widget cache + this.widgetCache = new LimitedMap( + 100, + await this.stateDataStore.get(["cache", "widgets"]) || + {}, + ); + // Pinging a remote space to ensure we're authenticated properly, if not will result in a redirect to auth page try { await this.httpSpacePrimitives.ping(); @@ -1004,4 +1012,28 @@ export class Client { } return; } + + private widgetCache = new LimitedMap(100); + + debouncedWidgetCacheFlush = throttle(() => { + this.stateDataStore.set(["cache", "widgets"], this.widgetCache.toJSON()) + .catch( + console.error, + ); + console.log("Flushed widget cache to store"); + }, 5000); + + setWidgetCache(key: string, height: number, html: string) { + this.widgetCache.set(key, { height, html }); + this.debouncedWidgetCacheFlush(); + } + + getWidgetCache(key: string): WidgetCacheItem | undefined { + return this.widgetCache.get(key); + } } + +type WidgetCacheItem = { + height: number; + html: string; +}; diff --git a/web/cm_plugins/fenced_code.ts b/web/cm_plugins/fenced_code.ts index 28737ab8..89c711a3 100644 --- a/web/cm_plugins/fenced_code.ts +++ b/web/cm_plugins/fenced_code.ts @@ -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; diff --git a/web/cm_plugins/iframe_widget.ts b/web/cm_plugins/iframe_widget.ts new file mode 100644 index 00000000..86636c52 --- /dev/null +++ b/web/cm_plugins/iframe_widget.ts @@ -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 + ); + } +} diff --git a/web/cm_plugins/markdown_widget.ts b/web/cm_plugins/markdown_widget.ts new file mode 100644 index 00000000..0b89e5d2 --- /dev/null +++ b/web/cm_plugins/markdown_widget.ts @@ -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 ` +
+ + + +
+ ${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 + ); + } +} diff --git a/web/hooks/code_widget.ts b/web/hooks/code_widget.ts index c4da4194..66e39d85 100644 --- a/web/hooks/code_widget.ts +++ b/web/hooks/code_widget.ts @@ -4,10 +4,12 @@ import { CodeWidgetCallback } from "$sb/types.ts"; export type CodeWidgetT = { codeWidget?: string; + renderMode?: "markdown" | "iframe"; }; export class CodeWidgetHook implements Hook { codeWidgetCallbacks = new Map(); + codeWidgetModes = new Map(); constructor() { } @@ -23,6 +25,10 @@ export class CodeWidgetHook implements Hook { if (!functionDef.codeWidget) { continue; } + this.codeWidgetModes.set( + functionDef.codeWidget, + functionDef.renderMode || "iframe", + ); this.codeWidgetCallbacks.set( functionDef.codeWidget, (bodyText, pageName) => { diff --git a/web/styles/editor.scss b/web/styles/editor.scss index b47ce9f7..359807df 100644 --- a/web/styles/editor.scss +++ b/web/styles/editor.scss @@ -443,6 +443,91 @@ line-height: 0; } + .sb-markdown-widget { + overflow-y: scroll; + margin: 0 0 -4ch 0; + border: 1px solid var(--editor-directive-background-color); + border-radius: 5px; + white-space: nowrap; + position: relative; + + ul, + ol { + margin-top: 0; + margin-bottom: 0; + } + + ul { + list-style: none; + padding-left: 1ch; + } + + ul li::before { + content: "\2022"; + /* Add content: \2022 is the CSS Code/unicode for a bullet */ + color: var(--editor-list-bullet-color); + display: inline-block; + /* Needed to add space between the bullet and the text */ + width: 1em; + /* Also needed for space (tweak if needed) */ + // margin-left: -1em; + } + + h1, + h2, + h3, + h4, + h5 { + margin: 0; + } + + a.wiki-link { + border-radius: 5px; + padding: 0 5px; + color: var(--editor-wiki-link-page-color); + background-color: var(--editor-wiki-link-page-background-color); + text-decoration: none; + } + + span.task-deadline { + background-color: rgba(22, 22, 22, 0.07); + } + + tt { + background-color: var(--editor-code-background-color); + } + + // Button bar + &:hover .button-bar, + &:active .button-bar { + display: block; + } + + .button-bar { + position: absolute; + right: 6px; + top: 6px; + display: none; + background: var(--editor-directive-background-color); + padding-inline: 3px; + padding-bottom: 1px; + border-radius: 5px; + + button { + border: none; + background: none; + cursor: pointer; + color: var(--root-color); + } + } + + .edit-button, + .reload-button { + margin-left: -10px; + } + + } + .sb-fenced-code-iframe { background-color: transparent;