diff --git a/client/components/panel.tsx b/client/components/panel.tsx index 2e006f6e..932d9680 100644 --- a/client/components/panel.tsx +++ b/client/components/panel.tsx @@ -49,9 +49,6 @@ export function Panel({ return; } switch (data.type) { - case "event": - editor.dispatchAppEvent(data.name, ...data.args); - break; case "syscall": { const { id, name, args } = data; editor.clientSystem.localSyscall(name, args).then( diff --git a/client/components/panel_html.ts b/client/components/panel_html.ts index 108268fc..f1ce64bd 100644 --- a/client/components/panel_html.ts +++ b/client/components/panel_html.ts @@ -8,11 +8,11 @@ export const panelHtml = ` const pendingRequests = new Map(); let syscallReqId = 0; -self.syscall = async (name, ...args) => { +globalThis.syscall = async (name, ...args) => { return await new Promise((resolve, reject) => { syscallReqId++; pendingRequests.set(syscallReqId, { resolve, reject }); - window.parent.postMessage({ + globalThis.parent.postMessage({ type: "syscall", id: syscallReqId, name, @@ -24,7 +24,7 @@ self.syscall = async (name, ...args) => { let oldHeight = undefined; let heightChecks = 0; -window.addEventListener("message", (message) => { +globalThis.addEventListener("message", (message) => { const data = message.data; switch (data.type) { case "html": @@ -70,17 +70,13 @@ window.addEventListener("message", (message) => { } }); -function api(obj) { - window.parent.postMessage(obj, "*"); -} - function updateHeight() { const body = document.body, html = document.documentElement; let height = Math.max(body.offsetHeight, html.offsetHeight); heightChecks++; if(height !== oldHeight) { oldHeight = height; - api({ + globalThis.parent.postMessage({ type: "setHeight", height: height, }); @@ -97,7 +93,6 @@ function loadJsByUrl(url,integrity=null) { script.integrity=integrity; script.crossOrigin="anonymous"; //for some weird reason this attribute is case sensitive when used in JS } - return new Promise((resolve) => { script.onload = resolve; diff --git a/client/document_editor.ts b/client/document_editor.ts index 69278acc..12eb70b2 100644 --- a/client/document_editor.ts +++ b/client/document_editor.ts @@ -2,13 +2,8 @@ import type { Client } from "./client.ts"; import { timeout } from "@silverbulletmd/silverbullet/lib/async"; import type { DocumentMeta } from "@silverbulletmd/silverbullet/type/index"; import type { Ref } from "@silverbulletmd/silverbullet/lib/ref"; - -type DocumentEditorIframeWindow = Window & { - silverbullet: EventTarget & { - syscall: (name: string, ...args: any[]) => any; - sendMessage: (type: string, data?: any) => void; - }; -}; +import documentEditorJS from "./document_editor_js.ts"; +import type { DocumentEditorContent } from "@silverbulletmd/silverbullet/type/client"; export class DocumentEditor { iframe!: HTMLIFrameElement; @@ -41,39 +36,14 @@ export class DocumentEditor { const content = await callback(); - const [iframe, ready] = DocumentEditor.createIframe(content.html); - - // These two have to happen in quick succession - this.parent.appendChild(iframe); - this.setupIframe(iframe); + globalThis.addEventListener("message", this.handleMessage.bind(this)); + const { iframe, ready } = DocumentEditor.createIframe(content); this.iframe = iframe; + + this.parent.appendChild(this.iframe); + await ready; - - // This is only for legacy support - if ((content as any).script) { - console.warn( - "Providing a `script` property with your document editor is deprecated. Inline the script into your HTML instead.", - ); - - if (this.iframe.contentDocument) { - // Load the legacy script - const script = this.iframe.contentDocument.createElement("script"); - script.type = "text/javascript"; - script.text = (content as any).script; - this.iframe.contentDocument.head.appendChild(script); - - // Because now an html and body tag will be auto created, it can happen - // that the content is not properly stretching those elements and the - // content will stay small, let's try to mitgate this - const style = this.iframe.contentDocument.createElement("style"); - style.textContent = - "html, body { width: 100%; height: 100%; margin: 0; }"; - this.iframe.contentDocument.head.appendChild(style); - } - } - - this.updateTheme(); } async destroy() { @@ -86,7 +56,7 @@ export class DocumentEditor { } sendPublicMessage(message: { type: string; data?: any }) { - this.dispatchEvent(message); + this.sendMessage(message); } openFile( @@ -94,7 +64,7 @@ export class DocumentEditor { meta: DocumentMeta, details: Ref["details"], ) { - this.dispatchEvent({ + this.sendMessage({ type: "file-open", data: { data, @@ -115,30 +85,17 @@ export class DocumentEditor { this.savePromise = Promise.withResolvers(); } - this.dispatchEvent({ + this.sendMessage({ type: "request-save", }); } focus() { - this.dispatchEvent({ + this.sendMessage({ type: "focus", }); } - updateTheme() { - if (!this.iframe?.contentDocument) return; - - const html = this.iframe.contentDocument.querySelector("html"); - if (!html) return; - - const theme = this.client.ui.viewState.uiOptions.darkMode - ? "dark" - : "light"; - - html.setAttribute("data-theme", theme); - } - private async waitForSave() { if (this.savePromise) { try { @@ -157,19 +114,22 @@ export class DocumentEditor { } } - private dispatchEvent(message: { type: string; data?: any }) { - if (!this.iframe.contentWindow) return; - - const iframeWindow = this.iframe - .contentWindow as DocumentEditorIframeWindow; - - iframeWindow.silverbullet.dispatchEvent( - new CustomEvent(message.type, { detail: message.data }), - ); + private sendMessage( + message: { type: string; internal?: boolean; data?: any }, + ) { + if (!this.iframe?.contentWindow) return; + message.internal ??= false; + this.iframe.contentWindow.postMessage(message); } - private handleMessage(type: string, data?: any) { - switch (type) { + private async handleMessage(event: any) { + if (event.source !== this.iframe.contentWindow) return; + const response = event.data; + if (!response) return; + + const data = response.data; + + switch (response.type) { case "file-changed": { this.client.ui.viewDispatch({ @@ -187,69 +147,89 @@ export class DocumentEditor { this.saveMethod(this.currentPath, data.data); } break; + case "syscall": + { + let result: any; + + try { + const response = await this.client.clientSystem.localSyscall( + data.name, + data.args, + ); + + result = { result: response }; + } catch (e: any) { + result = { error: e.message }; + } + + this.sendMessage({ + type: "syscall-response", + internal: true, + data: { + id: data.id, + ...result, + }, + }); + } + break; default: - console.warn("Unknown event sent from document editor: ", type); + console.warn("Unknown event sent from plug: ", data.type); } } - private setupIframe(iframe: HTMLIFrameElement) { - if (!iframe.contentWindow || !iframe.contentDocument) { - // This will bubble up to the navigation and be catched there - throw new Error("Something went wrong while setting up the iframe"); - } - - const iframeWindow = iframe.contentWindow as DocumentEditorIframeWindow; - - iframeWindow.silverbullet = - new EventTarget() as DocumentEditorIframeWindow["silverbullet"]; - - iframeWindow.silverbullet.syscall = (name: string, ...args: any[]) => { - return this.client.clientSystem.localSyscall(name, args); - }; - - iframeWindow.silverbullet.sendMessage = (type: string, data?: any) => { - this.handleMessage(type, data); - }; - - // When an iframe is focused, all keyboard events will be captured by the - // iframe. This is bad because we obviously want stuff like the command - // picker to keep working. This is done by listening to the event **in the - // capture phase** (so we get it first), sending it to the actual SB DOM, - // and only if it doesn't have defaultPrevented set, we send it further - // down. - iframeWindow.addEventListener("keydown", (event) => { - const keyEvent = new KeyboardEvent("keydown", event); - - Object.defineProperty(keyEvent, "target", { - value: globalThis.document.body, - }); - - globalThis.document.dispatchEvent(keyEvent); - - if (keyEvent.defaultPrevented) { - event.preventDefault(); - event.stopPropagation(); - } - }, true); + updateTheme() { + this.sendMessage({ + type: "set-theme", + internal: true, + data: { + theme: this.client.ui.viewState.uiOptions.darkMode ? "dark" : "light", + }, + }); } private static createIframe( - srcdoc: string, - ): [HTMLIFrameElement, Promise] { - const iframe = document.createElement("iframe"); - iframe.srcdoc = srcdoc; + content: DocumentEditorContent, + ): { iframe: HTMLIFrameElement; ready: Promise } { + const doc = new DOMParser().parseFromString(content.html, "text/html"); - // Avoid possible loading artifcats - iframe.style.visibility = "hidden"; + // This is only for legacy support + if ((content as any).script) { + console.warn( + "Providing a `script` property with your document editor is deprecated. Inline the script into your HTML instead.", + ); + + // Load the legacy script + const script = doc.createElement("script"); + script.type = "text/javascript"; + script.text = (content as any).script; + doc.body.appendChild(script); + + // Because now an html and body tag will be auto created, it can happen + // that the content is not properly stretching those elements and the + // content will stay small, let's try to mitgate this + const style = doc.createElement("style"); + style.textContent = + "html, body { width: 100%; height: 100%; margin: 0; }"; + doc.head.appendChild(style); + } + + const script = doc.createElement("script"); + script.type = "text/javascript"; + script.text = documentEditorJS; + doc.head.prepend(script); + + // This isn't great, but the only way to get the up-to-date theme without + // using media queries + const theme = document.querySelector("html")?.getAttribute("data-theme"); + doc.querySelector("html")!.setAttribute("data-theme", theme ?? "light"); + + const iframe = document.createElement("iframe"); + iframe.srcdoc = doc.documentElement.outerHTML; const ready = new Promise((resolve) => { - iframe.addEventListener("load", () => { - iframe.style.visibility = ""; - - resolve(); - }, { once: true }); + iframe.addEventListener("load", () => resolve(), { once: true }); }); - return [iframe, ready]; + return { iframe, ready }; } } diff --git a/client/document_editor_js.ts b/client/document_editor_js.ts new file mode 100644 index 00000000..979ef300 --- /dev/null +++ b/client/document_editor_js.ts @@ -0,0 +1,81 @@ +export default ` +const pendingRequests = new Map(); +let requestID = 0; + +globalThis.silverbullet = new EventTarget(); + +globalThis.silverbullet.syscall = (name, ...args) => { + const promise = Promise.withResolvers(); + + pendingRequests.set(++requestID, { ...promise }); + + globalThis.parent.postMessage({ + type: "syscall", + data: { + id: requestID, + name, + args, + }, + }, "*"); + + return promise.promise; +}; + +globalThis.addEventListener("message", (message) => { + const response = message.data, data = message.data.data; + + // Passthrough non internal events + if (!response.internal) { + globalThis.silverbullet.dispatchEvent(new CustomEvent(response.type, { detail: data })); + return; + } + + switch (response.type) { + case "syscall-response": { + const id = data.id; + const request = pendingRequests.get(id); + if (!request) { + console.error(\`Failed to lookup request with id \${id}\`); + throw Error("Invalid request id"); + } + + pendingRequests.delete(id); + + if (data.error) { + request.reject(new Error(data.error)); + } else { + request.resolve(data.result); + } + } break; + case "set-theme": { + document.querySelector("html").setAttribute("data-theme", data.theme); + } break; + } +}); + +globalThis.silverbullet.sendMessage = (type, data) => { + globalThis.parent.postMessage({ type, data }, "*"); +}; + +// When an iframe is focused, all keyboard events will be captured by the +// iframe. This is bad because we obviously want stuff like the command +// picker to keep working. This is done by listening to the event **in the +// capture phase** (so we get it first), sending it to the actual SB DOM, +// and only if it doesn't have defaultPrevented set, we send it further +// down. +globalThis.addEventListener("keydown", (event) => { + // This is really hacky... open to other solutions + const keyEvent = new KeyboardEvent("keydown", event); + + Object.defineProperty(keyEvent, "target", { + value: globalThis.parent.document.body, + }); + + globalThis.parent.document.dispatchEvent(keyEvent); + + if (keyEvent.defaultPrevented) { + event.preventDefault(); + event.stopPropagation(); + } +}, true); +`;