diff --git a/package.json b/package.json index 96e4b822..38097328 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "impro", - "version": "0.11.83", + "version": "0.11.84", "type": "module", "scripts": { "start": "rm -rf build && NODE_ENV=development eleventy --serve", diff --git a/src/js/modals.js b/src/js/modals.js index 051771f4..510a23ce 100644 --- a/src/js/modals.js +++ b/src/js/modals.js @@ -1,6 +1,7 @@ import { html, render } from "/js/lib/lit-html.js"; import { getThreadgateAllowSettings } from "/js/dataHelpers.js"; import { linkToProfile, linkToLogin } from "/js/navigation.js"; +import { renderNode, isEmptyNode } from "/js/plugins/pluginRendering.js"; export function showSignInModal() { const dialog = document.createElement("dialog"); @@ -241,3 +242,72 @@ export function showWhoCanReplyModal({ post }) { document.body.appendChild(dialog); dialog.showModal(); } + +const pluginModals = new Map(); + +export function showPluginModal({ + pluginHost, + pluginId, + modalId, + title, + content, + onDismiss = () => {}, +}) { + let modal = pluginModals.get(`${pluginId}:${modalId}`); + if (modal?.isOpen) return; + + if (!modal) { + const dialog = document.createElement("dialog"); + dialog.classList.add("modal-dialog", "plugin-modal"); + dialog.dataset.pluginId = pluginId; + + const content = document.createElement("div"); + content.classList.add("modal-dialog-content"); + dialog.appendChild(content); + + modal = { dialog, content, isOpen: false }; + + function dismiss() { + if (!modal.isOpen) return; + modal.isOpen = false; + dialog.close(); + onDismiss(); + } + + dialog.addEventListener("click", (event) => { + if (event.target.tagName === "DIALOG") dismiss(); + }); + dialog.addEventListener("cancel", (event) => { + event.preventDefault(); + dismiss(); + }); + + pluginModals.set(`${pluginId}:${modalId}`, modal); + document.body.appendChild(dialog); + } + + modal.content.replaceChildren(); + if (!isEmptyNode(title)) { + const titleEl = renderNode(title, pluginId); + titleEl.classList.add("modal-dialog-title"); + modal.content.appendChild(titleEl); + } + if (content?.children?.length) { + for (const childNode of content.children) { + modal.content.appendChild(renderNode(childNode, pluginId)); + } + } else if (!isEmptyNode(content)) { + modal.content.appendChild(renderNode(content, pluginId)); + } + + modal.isOpen = true; + modal.dialog.showModal(); +} + +export function hidePluginModal({ pluginId, modalId }) { + const modal = pluginModals.get(`${pluginId}:${modalId}`); + if (modal && modal.isOpen) { + modal.isOpen = false; + modal.dialog.close(); + } +} diff --git a/src/js/plugins/pluginHost.js b/src/js/plugins/pluginHost.js index ee9a735d..0ce1186a 100644 --- a/src/js/plugins/pluginHost.js +++ b/src/js/plugins/pluginHost.js @@ -203,6 +203,10 @@ export class PluginHost { }; this._loadedPlugins = new Map(); this._hostCallHandlers = new Map(); + this._handlePluginNodeEvent = this._handlePluginNodeEvent.bind(this); + window.addEventListener("plugin-node-event", (e) => + this._handlePluginNodeEvent(e), + ); } async loadPlugins(pluginIds) { @@ -275,10 +279,17 @@ export class PluginHost { } } + _handlePluginNodeEvent(event) { + const { pluginId, handlerId } = event.detail; + this.callPlugin(pluginId, handlerId).catch((error) => { + logger.warn(`[plugins] "${pluginId}" event handler threw:`, error); + }); + } + callPlugin(pluginId, handlerId) { const instance = this._loadedPlugins.get(pluginId); if (!instance) return; - instance.call(handlerId, []); + return instance.call(handlerId, []); } sendNotification(pluginId, notificationType, eventData = {}) { diff --git a/src/js/plugins/pluginModals.js b/src/js/plugins/pluginModals.js deleted file mode 100644 index acb44960..00000000 --- a/src/js/plugins/pluginModals.js +++ /dev/null @@ -1,79 +0,0 @@ -import { PluginRenderer, isEmptyNode } from "./pluginRenderer.js"; - -// key -> { dialog, content, isOpen, dismiss } -const dialogs = new Map(); - -function dialogKey(pluginId, modalId) { - return `${pluginId}:${modalId}`; -} - -export function setupPluginModals(pluginHost) { - const renderer = new PluginRenderer(pluginHost); - - pluginHost.registerHostCall("openModal", ({ pluginId, args }) => { - const [options] = args; - if (!options || options.modalId == null) return; - const key = dialogKey(pluginId, options.modalId); - let entry = dialogs.get(key); - if (entry?.isOpen) return; - - if (!entry) { - const dialog = document.createElement("dialog"); - dialog.classList.add("modal-dialog", "plugin-modal"); - dialog.dataset.pluginId = pluginId; - - const content = document.createElement("div"); - content.classList.add("modal-dialog-content"); - dialog.appendChild(content); - - entry = { dialog, content, isOpen: false, dismiss: null }; - - const dismiss = ({ notify = true } = {}) => { - if (!entry.isOpen) return; - entry.isOpen = false; - dialog.close(); - if (notify) - pluginHost.sendNotification(pluginId, "modalDismissed", { - modalId: options.modalId, - }); - }; - entry.dismiss = dismiss; - - dialog.addEventListener("click", (event) => { - if (event.target.tagName === "DIALOG") dismiss(); - }); - dialog.addEventListener("cancel", (event) => { - event.preventDefault(); - dismiss(); - }); - - dialogs.set(key, entry); - document.body.appendChild(dialog); - } - - entry.content.replaceChildren(); - if (!isEmptyNode(options.title)) { - const title = renderer.renderNode(options.title, pluginId); - title.classList.add("modal-dialog-title"); - entry.content.appendChild(title); - } - if (options.content?.children?.length) { - for (const childNode of options.content.children) { - entry.content.appendChild(renderer.renderNode(childNode, pluginId)); - } - } else if (!isEmptyNode(options.content)) { - entry.content.appendChild(renderer.renderNode(options.content, pluginId)); - } - - entry.isOpen = true; - entry.dialog.showModal(); - }); - - pluginHost.registerHostCall("closeModal", ({ pluginId, args }) => { - const [options] = args; - if (!options || options.modalId == null) return; - const entry = dialogs.get(dialogKey(pluginId, options.modalId)); - // Plugin-initiated, so don't notify - if (entry) entry.dismiss({ notify: false }); - }); -} diff --git a/src/js/plugins/pluginRenderer.js b/src/js/plugins/pluginRenderer.js deleted file mode 100644 index b7a1c273..00000000 --- a/src/js/plugins/pluginRenderer.js +++ /dev/null @@ -1,118 +0,0 @@ -import { lightningBoltIconTemplate } from "/js/templates/icons/lightningBoltIcon.template.js"; - -const ALLOWED_TAGS = [ - "div", - "span", - "p", - "h1", - "h2", - "h3", - "h4", - "ul", - "ol", - "li", - "strong", - "em", - "b", - "i", - "code", - "pre", - "br", - "hr", - "button", -]; - -const ALLOWED_EVENTS = ["click"]; - -function isAllowedTag(tag) { - return ALLOWED_TAGS.includes(tag); -} - -const ALLOWED_ATTRS = ["class", "title", "role", "lang", "dir"]; - -function isAllowedAttr(name) { - return ( - ALLOWED_ATTRS.includes(name) || - name.startsWith("data-") || - name.startsWith("aria-") - ); -} - -const PLUGIN_ICON_TEMPLATES = { - "lightning-bolt": lightningBoltIconTemplate, -}; - -// Render a serialized VirtualEl node ({ tag, attrs, text, children }) into a -// real element. Text and children are mutually exclusive on -// the worker side (setText() clears children). -export class PluginRenderer { - constructor(pluginHost) { - this.pluginHost = pluginHost; - } - - renderNode(node, pluginId) { - let tag = typeof node.tag === "string" ? node.tag.toLowerCase() : "div"; - if (!isAllowedTag(tag)) { - console.warn( - `[plugins] "${pluginId}" tried to render disallowed tag <${tag}>`, - ); - tag = "span"; - } - const element = document.createElement(tag); - if (node.attrs) { - for (const [name, value] of Object.entries(node.attrs)) { - if (!isAllowedAttr(name)) { - console.warn( - `[plugins] "${pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`, - ); - continue; - } - element.setAttribute(name, String(value)); - } - } - if (node.events && typeof node.events === "object") { - for (const [name, handlerId] of Object.entries(node.events)) { - if (!ALLOWED_EVENTS.includes(name)) { - console.warn( - `[plugins] "${pluginId}" tried to bind disallowed event "${name}"`, - ); - continue; - } - element.addEventListener(name, () => { - Promise.resolve( - this.pluginHost.callPlugin(pluginId, handlerId), - ).catch((error) => { - console.warn( - `[plugins] "${pluginId}" ${name} handler threw:`, - error, - ); - }); - }); - } - } - if (node.text != null) { - element.textContent = node.text; - } else if (Array.isArray(node.children)) { - for (const child of node.children) { - element.appendChild(this.renderNode(child, pluginId)); - } - } - return element; - } - - static getPluginIconTemplate(icon) { - const template = PLUGIN_ICON_TEMPLATES[icon]; - if (!template) { - console.warn(`[plugins] requested unknown icon "${icon}"`); - return null; - } - return template; - } -} - -export function isEmptyNode(node) { - if (!node) return true; - if (node.text != null && node.text !== "") return false; - if (Array.isArray(node.children) && node.children.length > 0) return false; - return true; -} diff --git a/src/js/plugins/pluginRendering.js b/src/js/plugins/pluginRendering.js new file mode 100644 index 00000000..2070cc74 --- /dev/null +++ b/src/js/plugins/pluginRendering.js @@ -0,0 +1,109 @@ +import { lightningBoltIconTemplate } from "/js/templates/icons/lightningBoltIcon.template.js"; + +const ALLOWED_TAGS = [ + "div", + "span", + "p", + "h1", + "h2", + "h3", + "h4", + "ul", + "ol", + "li", + "strong", + "em", + "b", + "i", + "code", + "pre", + "br", + "hr", + "button", +]; + +const ALLOWED_EVENTS = ["click"]; + +function isAllowedTag(tag) { + return ALLOWED_TAGS.includes(tag); +} + +const ALLOWED_ATTRS = ["class", "title", "role", "lang", "dir"]; + +function isAllowedAttr(name) { + return ( + ALLOWED_ATTRS.includes(name) || + name.startsWith("data-") || + name.startsWith("aria-") + ); +} + +const PLUGIN_ICON_TEMPLATES = { + "lightning-bolt": lightningBoltIconTemplate, +}; + +// Render a serialized VirtualEl node ({ tag, attrs, text, children }) into a +// real element. Text and children are mutually exclusive on +// the worker side (setText() clears children). +export function renderNode(node, pluginId) { + let tag = typeof node.tag === "string" ? node.tag.toLowerCase() : "div"; + if (!isAllowedTag(tag)) { + console.warn( + `[plugins] "${pluginId}" tried to render disallowed tag <${tag}>`, + ); + tag = "span"; + } + const element = document.createElement(tag); + if (node.attrs) { + for (const [name, value] of Object.entries(node.attrs)) { + if (!isAllowedAttr(name)) { + console.warn( + `[plugins] "${pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`, + ); + continue; + } + element.setAttribute(name, String(value)); + } + } + if (node.events && typeof node.events === "object") { + for (const [name, handlerId] of Object.entries(node.events)) { + if (!ALLOWED_EVENTS.includes(name)) { + console.warn( + `[plugins] "${pluginId}" tried to bind disallowed event "${name}"`, + ); + continue; + } + element.addEventListener(name, () => { + window.dispatchEvent( + new CustomEvent("plugin-node-event", { + detail: { pluginId, handlerId }, + }), + ); + }); + } + } + if (node.text != null) { + element.textContent = node.text; + } else if (Array.isArray(node.children)) { + for (const child of node.children) { + element.appendChild(renderNode(child, pluginId)); + } + } + return element; +} + +export function isEmptyNode(node) { + if (!node) return true; + if (node.text != null && node.text !== "") return false; + if (Array.isArray(node.children) && node.children.length > 0) return false; + return true; +} + +export function getPluginIconTemplate(icon) { + const template = PLUGIN_ICON_TEMPLATES[icon]; + if (!template) { + console.warn(`[plugins] requested unknown icon "${icon}"`); + return null; + } + return template; +} diff --git a/src/js/plugins/pluginService.js b/src/js/plugins/pluginService.js index 3840e66f..3ffbfe6d 100644 --- a/src/js/plugins/pluginService.js +++ b/src/js/plugins/pluginService.js @@ -1,6 +1,6 @@ import { isDev } from "/js/utils.js"; -import { PluginHost } from "./pluginHost.js"; -import { setupPluginModals } from "./pluginModals.js"; +import { PluginHost } from "/js/plugins/pluginHost.js"; +import { showPluginModal, hidePluginModal } from "/js/modals.js"; const ENABLED_PLUGINS_KEY = "enabled-plugins"; @@ -24,7 +24,31 @@ function setEnabledPlugins(ids) { class PluginService { constructor() { this.pluginHost = new PluginHost({ verbose: isDev() }); - setupPluginModals(this.pluginHost); + this._setupModals(); + } + + _setupModals() { + this.pluginHost.registerHostCall("openModal", ({ pluginId, args }) => { + const [options] = args; + const { modalId, title, content } = options; + showPluginModal({ + pluginId, + modalId, + title, + content, + onDismiss: () => { + this.pluginHost.sendNotification(pluginId, "modalDismissed", { + modalId, + }); + }, + }); + }); + + this.pluginHost.registerHostCall("closeModal", ({ pluginId, args }) => { + const [options] = args; + const { modalId } = options; + hidePluginModal({ pluginId, modalId }); + }); } async loadPlugins() { diff --git a/src/js/templates/sidebar.template.js b/src/js/templates/sidebar.template.js index 9db8d827..83f050a1 100644 --- a/src/js/templates/sidebar.template.js +++ b/src/js/templates/sidebar.template.js @@ -22,10 +22,10 @@ import { } from "/js/navigation.js"; import "/js/components/animated-sidebar.js"; import { showInfoModal } from "/js/modals.js"; -import { PluginRenderer } from "/js/plugins/pluginRenderer.js"; +import { getPluginIconTemplate } from "/js/plugins/pluginRendering.js"; function pluginSidebarItemTemplate({ entry }) { - const iconTemplate = PluginRenderer.getPluginIconTemplate(entry.icon); + const iconTemplate = getPluginIconTemplate(entry.icon); return html`