* Fix legacy support for navigation * Fixes timing issue with document editors If silverbullet was directly loaded into a document editor it would set the darkmode after loading the editor, causing a wrong value to be passed * Add basic `image-viewer` This add adds a basic image viewer using the panzoom library from Timmy Willison * Svgs somehow don't accept 100% width * Make `internal` a property of a message * Add `editor.sendMessage` syscall * Update forgotten internal syscall * Add small info command to image-viewer * Change indent to two spaces * Move zoom in button to the right * Switch rotation direction * Display mime type instead of file extension
239 lines
5.4 KiB
TypeScript
239 lines
5.4 KiB
TypeScript
import type { DocumentMeta } from "@silverbulletmd/silverbullet/types";
|
|
import type { Client } from "./client.ts";
|
|
import { html as skeleton } from "./document_editor_skeleton.ts";
|
|
import { timeout } from "$lib/async.ts";
|
|
|
|
export class DocumentEditor {
|
|
iframe!: HTMLIFrameElement;
|
|
name!: string;
|
|
extension!: string;
|
|
currentPath: string | null = null;
|
|
savePromise: PromiseWithResolvers<void> | null = null;
|
|
|
|
constructor(
|
|
readonly parent: HTMLElement,
|
|
readonly client: Client,
|
|
readonly saveMethod: (path: string, content: Uint8Array) => void,
|
|
) {}
|
|
|
|
async init(extension: string) {
|
|
this.extension = extension;
|
|
|
|
const entry = Array.from(
|
|
this.client.clientSystem.documentEditorHook.documentEditors
|
|
.entries(),
|
|
).find(([_, { extensions }]) => extensions.includes(this.extension));
|
|
|
|
if (!entry) {
|
|
throw new Error("Couldn't find plug for specified extension");
|
|
}
|
|
|
|
const [name, { callback }] = entry;
|
|
this.name = name;
|
|
|
|
const content = await callback();
|
|
|
|
const [iframe, finished] = this.createIframe();
|
|
this.iframe = iframe;
|
|
|
|
this.parent.appendChild(this.iframe);
|
|
|
|
await finished;
|
|
|
|
this.sendMessage({
|
|
type: "init",
|
|
internal: true,
|
|
data: {
|
|
html: content.html,
|
|
script: content.script,
|
|
},
|
|
});
|
|
|
|
this.updateTheme();
|
|
}
|
|
|
|
async destroy() {
|
|
// If name isn't initalized the editor is probably dead
|
|
if (!this.name) return;
|
|
|
|
await this.waitForSave();
|
|
|
|
globalThis.removeEventListener("message", this.messageHandler);
|
|
this.iframe.remove();
|
|
}
|
|
|
|
private async waitForSave() {
|
|
if (this.savePromise) {
|
|
try {
|
|
await Promise.race([
|
|
this.savePromise.promise,
|
|
timeout(2500),
|
|
]);
|
|
} catch {
|
|
this.savePromise.resolve();
|
|
this.savePromise = null;
|
|
|
|
console.log(
|
|
"Unable to save content of document editor in 2.5s. Aborting save",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private sendMessage(
|
|
message: { type: string; internal?: boolean; data?: any },
|
|
) {
|
|
if (!this.iframe?.contentWindow) return;
|
|
message.internal ??= false;
|
|
this.iframe.contentWindow.postMessage(message);
|
|
}
|
|
|
|
sendPublicMessage(message: { type: string; data?: any }) {
|
|
this.sendMessage(message);
|
|
}
|
|
|
|
setContent(data: Uint8Array, meta: DocumentMeta) {
|
|
this.sendMessage({
|
|
type: "file-open",
|
|
data: {
|
|
data,
|
|
meta,
|
|
},
|
|
});
|
|
|
|
this.currentPath = meta.name;
|
|
}
|
|
|
|
async changeContent(data: Uint8Array, meta: DocumentMeta) {
|
|
await this.waitForSave();
|
|
|
|
this.sendMessage({
|
|
type: "file-update",
|
|
data: {
|
|
data,
|
|
meta,
|
|
},
|
|
});
|
|
|
|
this.currentPath = meta.name;
|
|
}
|
|
|
|
requestSave() {
|
|
if (this.savePromise) {
|
|
console.log(
|
|
"Save was already requested from editor, trying again anyways",
|
|
);
|
|
} else {
|
|
this.savePromise = Promise.withResolvers();
|
|
}
|
|
|
|
this.sendMessage({
|
|
type: "request-save",
|
|
});
|
|
}
|
|
|
|
focus() {
|
|
this.sendMessage({
|
|
type: "focus",
|
|
});
|
|
}
|
|
|
|
updateTheme() {
|
|
this.sendMessage({
|
|
type: "set-theme",
|
|
internal: true,
|
|
data: {
|
|
theme: client.ui.viewState.uiOptions.darkMode ? "dark" : "light",
|
|
},
|
|
});
|
|
}
|
|
|
|
private async messageHandler(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({
|
|
type: "document-editor-changed",
|
|
});
|
|
this.client.save().catch((e) => console.error("Couldn't save: ", e));
|
|
}
|
|
break;
|
|
case "file-saved":
|
|
{
|
|
this.savePromise?.resolve();
|
|
this.savePromise = null;
|
|
|
|
if (!this.currentPath) return;
|
|
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 plug: ", data.type);
|
|
}
|
|
}
|
|
|
|
private createIframe(): [HTMLIFrameElement, Promise<void>] {
|
|
// Note: In the future we could maybe cache this iframe, for now it is not nearly necessary
|
|
const iframe = document.createElement("iframe");
|
|
|
|
iframe.src = "about:blank";
|
|
iframe.style.visibility = "hidden";
|
|
|
|
const ready = new Promise<void>((resolve) => {
|
|
iframe.onload = () => {
|
|
iframe.contentDocument!.write(skeleton);
|
|
iframe.style.visibility = "visible";
|
|
resolve();
|
|
};
|
|
});
|
|
|
|
const finished = new Promise<void>((resolve) => {
|
|
ready.then(() => {
|
|
globalThis.addEventListener("message", this.messageHandler.bind(this));
|
|
|
|
iframe.onload = null;
|
|
|
|
if (!iframe.contentWindow) {
|
|
console.warn("Iframe went away or content was not loaded");
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
return [iframe, finished];
|
|
}
|
|
}
|