UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

1,051 lines (960 loc) 27.4 kB
/** * Copyright © Volker Schukai and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact Volker Schukai. * * SPDX-License-Identifier: AGPL-3.0 */ import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import "../notify/notify.mjs"; import { ViewerStyleSheet } from "./stylesheet/viewer.mjs"; import { instanceSymbol } from "../../constants.mjs"; import { isString } from "../../types/is.mjs"; import { getGlobal } from "../../types/global.mjs"; import { MediaType, parseMediaType } from "../../types/mediatype.mjs"; import { MarkdownToHTML } from "../../text/markdown-parser.mjs"; import "../layout/tabs.mjs"; import "./viewer/message.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; import { Button } from "../form/button.mjs"; import { findTargetElementFromEvent } from "../../dom/events.mjs"; export { Viewer }; /** * @private * @type {symbol} */ const viewerElementSymbol = Symbol("viewerElement"); /** * @private * @type {symbol} */ const downloadHandlerSymbol = Symbol("downloadHandler"); /** * @private * @type {symbol} */ const downloadRevokeSymbol = Symbol("downloadRevoke"); /** * The Viewer component is used to show a PDF, HTML, or Image. * * @fragments /fragments/components/content/viewer * * @example /examples/components/content/pdf-viewer with a PDF * @example /examples/components/content/image-viewer with an image * @example /examples/components/content/html-viewer with HTML content * * @copyright Volker Schukai * @summary A simple viewer component for PDF, HTML, and images. */ class Viewer extends CustomElement { /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/content/viewer@@instance"); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {string} content Content to be displayed in the viewer * @property {Object} classes Css classes * @property {string} classes.viewer Css class for the viewer * @property {Object} renderers Renderers for different media types * @property {function} renderers.image Function to render image content * @property {function} renderers.html Function to render HTML content * @property {function} renderers.pdf Function to render PDF content * @property {function} renderers.plaintext Function to render plain text content * @property {function} renderers.markdown Function to render Markdown content */ get defaults() { return Object.assign({}, super.defaults, { templates: { main: getTemplate(), }, content: "<slot></slot>", classes: { viewer: "", }, labels: getLabels(), renderers: { image: this.setImage, html: this.setHTML, pdf: this.setPDF, download: this.setDownload, plaintext: this.setPlainText, markdown: this.setMarkdown, audio: this.setAudio, video: this.setVideo, message: this.setMessage, }, }); } /** * @private * @type {symbol} * @memberof Viewer * @static * @description downloadHandlerSymbol is used to store the download handler function. * This is necessary to remove the event listener when the element is disconnected. * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/removeEventListener} */ disconnectedCallback() { super.disconnectedCallback?.(); if (this[downloadHandlerSymbol]) { this.removeEventListener("click", this[downloadHandlerSymbol]); this[downloadHandlerSymbol] = null; } if (this[downloadRevokeSymbol]) { try { this[downloadRevokeSymbol](); } catch {} this[downloadRevokeSymbol] = null; } } /** * Sets the content of an element based on the provided content and media type. * * Supported forms: * - setContent(contentString, mediaType) * - setContent({ mediaType, data, encoding }) * * The declarative form requires an explicit `encoding` when `data` is a string. * Use `encoding: "url"` for URLs and `encoding: "base64"` for base64 payloads. * * @param {string|object} content - The content to be set or a declarative payload. * @param {string} [mediaType="text/plain"] - The media type of the content. Defaults to "text/plain" if not specified. * @return {void} This method does not return a value. * @throws {Error} Throws an error if shadowRoot is not defined. */ setContent(content, mediaType = "text/plain") { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } let declarativePayload = null; // Declarative form: { mediaType, data, encoding } if ( content && typeof content === "object" && !isBlob(content) && !(content instanceof URL) ) { const { mediaType: mt, data, encoding } = content; mediaType = mt ?? mediaType ?? "text/plain"; declarativePayload = { data, encoding }; if (isString(data)) { if (!encoding) { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "String content requires explicit encoding", }), ); return; } if (encoding === "base64") { content = data; } else if (encoding === "url") { content = data; } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Invalid encoding", }), ); return; } } else { content = data; } } const renderers = this.getOption("renderers"); const isDataURL = (value) => { return ( (typeof value === "string" && value.startsWith("data:")) || (value instanceof URL && value.protocol === "data:") ); }; if (isDataURL(content)) { try { const dataUrl = content.toString(); const [header] = dataUrl.split(","); const [typeSegment] = header.split(";"); mediaType = typeSegment.replace("data:", "") || "text/plain"; } catch (error) { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Invalid data URL format", }), ); return; } } if (mediaType === undefined || mediaType === null || mediaType === "") { mediaType = "text/plain"; } let mediaTypeObject; try { mediaTypeObject = new parseMediaType(mediaType); if (!(mediaTypeObject instanceof MediaType)) { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Invalid MediaType" }), ); return; } } catch (error) { this.dispatchEvent(new CustomEvent("viewer-error", { detail: error })); return; } const checkRenderer = (renderer, contentType) => { if (renderers && typeof renderers[renderer] === "function") { return true; } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: `Renderer for ${contentType} not found`, }), ); return false; } }; switch (mediaTypeObject.type) { case "text": switch (mediaTypeObject.subtype) { case "html": if (checkRenderer("html", mediaTypeObject.toString())) { renderers.html.call(this, content); } break; case "plain": if (checkRenderer("plaintext", mediaTypeObject.toString())) { renderers.plaintext.call(this, content); } break; case "markdown": if (checkRenderer("markdown", mediaTypeObject.toString())) { this.setMarkdown(content); } break; default: if (checkRenderer("plaintext", mediaTypeObject.toString())) { renderers.plaintext.call(this, content); } break; } break; case "application": switch (mediaTypeObject.subtype) { case "pdf": if (checkRenderer("pdf", mediaTypeObject.toString())) { renderers.pdf.call( this, declarativePayload ? declarativePayload : content, ); } break; default: // Handle octet-stream as a generic binary data type if (checkRenderer("download", mediaTypeObject.toString())) { renderers.download.call(this, content); break; } this.setOption("content", content); break; } break; case "message": switch (mediaTypeObject.subtype) { case "rfc822": if (checkRenderer("message", mediaTypeObject.toString())) { renderers.message.call(this, content); } break; default: this.setOption("content", content); break; } break; case "audio": if (checkRenderer(mediaTypeObject.type, mediaTypeObject.toString())) { renderers[mediaTypeObject.type].call(this, content); } break; case "video": if (checkRenderer(mediaTypeObject.type, mediaTypeObject.toString())) { renderers[mediaTypeObject.type].call(this, content); } break; case "image": if (checkRenderer("image", mediaTypeObject.toString())) { renderers.image.call(this, content); } break; default: this.setOption("content", content); this.dispatchEvent( new CustomEvent("viewer-error", { detail: `Unsupported media type: ${mediaTypeObject.toString()}`, }), ); // Notify about unsupported media type return; } } /** * Sets the audio content for the viewer. Accepts a Blob, URL, or string and processes it * to configure audio playback within the viewer. Throws an error if the input type is invalid. * * @param {Blob|string} data - The audio content. This can be a Blob, a URL, or a string. * @return {void} No return value. */ setAudio(data) { if (isBlob(data)) { data = URL.createObjectURL(data); } else if (isURL(data)) { // nothing to do } else if (isString(data)) { // nothing to do } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Blob or URL expected" }), ); throw new Error("Blob or URL expected"); } this.setOption( "content", ` <audio controls part="audio" style="max-width: 100%"> <source src="${data}"> </audio>`, ); } /** * Sets the video content for the viewer. The method accepts a Blob, URL, or string, * verifies its type, and updates the viewer's content accordingly. * * @param {Blob|string} data - The video data to set. It can be a Blob, URL, or string. * @return {void} This method does not return a value. It updates the viewer's state. * @throws {Error} Throws an error if the provided data is not a Blob or URL. */ setVideo(data) { if (isBlob(data)) { data = URL.createObjectURL(data); } else if (isURL(data)) { // nothing to do } else if (isString(data)) { // nothing to do } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Blob or URL expected" }), ); throw new Error("Blob or URL expected"); } this.setOption( "content", ` <video controls part="video" style="max-width: 100%"> <source src="${data}"> </video>`, ); } /** * Renders Markdown content using built-in or custom Markdown parser. * Overrideable via `customRenderers['text/markdown']`. * * @param {string|Blob} data */ setMarkdown(data) { if (isBlob(data)) { blobToText(data) .then((markdownText) => { try { const html = MarkdownToHTML.convert(markdownText); this.setHTML(html); } catch (error) { this.setPlainText(markdownText); // Fallback to plain text if conversion fails this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); } }) .catch((error) => { this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); throw new Error(error); }); return; } else if (isURL(data)) { getGlobal() .fetch(data) .then((response) => { return response.text(); }) .then((markdownText) => { try { const html = MarkdownToHTML.convert(markdownText); this.setHTML(html); } catch (error) { this.setPlainText(markdownText); // Fallback to plain text if conversion fails this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); } }) .catch((error) => { this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); throw new Error(error); }); return; } else if (isString(data)) { try { const html = MarkdownToHTML.convert(data); this.setHTML(html); } catch (error) { this.setPlainText(data); // Fallback to plain text if conversion fails this.dispatchEvent(new CustomEvent("viewer-error", { detail: error })); } return; } this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Blob or string expected" }), ); throw new Error("Blob or string expected"); } /** * Configures and embeds a PDF document into the application with customizable display settings. * * Supported forms: * - setPDF(blobOrUrlOrBase64String, navigation, toolbar, scrollbar) * - setPDF({ data, encoding, navigation, toolbar, scrollbar }) * * The declarative form requires an explicit `encoding` when `data` is a string. * Use `encoding: "url"` for URLs and `encoding: "base64"` for base64 payloads. * * @param {Blob|URL|string|object} data The PDF data to be embedded. Can be provided as a Blob, URL, or base64 string, * or a declarative payload: { data, encoding, navigation, toolbar, scrollbar }. * @param {boolean} [navigation=true] Determines whether the navigation pane is displayed in the PDF viewer. * @param {boolean} [toolbar=true] Controls the visibility of the toolbar in the PDF viewer. * @param {boolean} [scrollbar=false] Configures the display of the scrollbar in the PDF viewer. * @return {void} This method returns nothing but sets the embedded PDF as the content. */ setPDF(data, navigation = true, toolbar = true, scrollbar = false) { let stringEncoding = null; // Declarative form: { data, encoding, navigation, toolbar, scrollbar } if ( data && typeof data === "object" && !isBlob(data) && !(data instanceof URL) ) { const payload = data; data = payload.data; stringEncoding = payload.encoding ?? null; if (typeof payload.navigation === "boolean") { navigation = payload.navigation; } if (typeof payload.toolbar === "boolean") { toolbar = payload.toolbar; } if (typeof payload.scrollbar === "boolean") { scrollbar = payload.scrollbar; } if (isString(data)) { if (!payload.encoding) { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "String PDF data requires explicit encoding", }), ); return; } if (payload.encoding === "base64") { // handled below as string/base64 } else if (payload.encoding === "url") { // handled below as string/url } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Invalid encoding" }), ); return; } } } const hashes = "#toolbar=" + (toolbar ? "1" : "0") + "&navpanes=" + (navigation ? "1" : "0") + "&scrollbar=" + (scrollbar ? "1" : "0"); let pdfURL = ""; if (isBlob(data)) { pdfURL = URL.createObjectURL(data); pdfURL += hashes; } else if (isURL(data)) { // check if the url already contains the hashes if (data?.hash?.indexOf("#") === -1) { pdfURL = data.toString() + hashes; } else { pdfURL = data.toString(); } } else if (isString(data)) { if (stringEncoding === "url") { if (data.indexOf("#") === -1) { pdfURL = data + hashes; } else { pdfURL = data; } } else { // Default legacy behavior: treat string as base64 const blobObj = new Blob([atob(data)], { type: "application/pdf" }); const url = window.URL.createObjectURL(blobObj); pdfURL = url + hashes; } } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Blob or URL expected" }), ); throw new Error("Blob or URL expected"); } const html = '<object part="pdf" data="' + pdfURL + '" width="100%" height="100%" type="application/pdf"></object>'; this.setOption("content", html); } /** * Sets the download functionality for the viewer. * @param data * @param filename */ setDownload(data, filename = "download") { let href = ""; let revoke = null; let suggestedName = filename; if (data instanceof File) { href = URL.createObjectURL(data); suggestedName = data.name || filename; revoke = () => URL.revokeObjectURL(href); } else if (isBlob(data)) { href = URL.createObjectURL(data); revoke = () => URL.revokeObjectURL(href); } else if ( isURL(data) || (isString(data) && /^(data:|blob:|https?:)/.test(data)) ) { href = data.toString(); try { const u = new URL(href, location.href); const last = u.pathname.split("/").pop(); if ((!filename || filename === "download") && last) { suggestedName = decodeURIComponent(last); } } catch {} } else if (isString(data)) { const blob = new Blob([data], { type: "application/octet-stream" }); href = URL.createObjectURL(blob); revoke = () => URL.revokeObjectURL(href); } else { const msg = "Blob, URL oder String erwartet"; this.dispatchEvent(new CustomEvent("viewer-error", { detail: msg })); throw new Error(msg); } const button = `<monster-button data-monster-role="download">` + this.getOption("labels.download") + `</monster-button>`; this.setOption("content", button); if (this[downloadHandlerSymbol]) { this.removeEventListener("click", this[downloadHandlerSymbol]); } if (this[downloadRevokeSymbol]) { try { this[downloadRevokeSymbol](); } catch {} } this[downloadHandlerSymbol] = (event) => { const el = findTargetElementFromEvent( event, "data-monster-role", "download", ); if (el instanceof Button) { const a = document.createElement("a"); a.href = href; a.download = suggestedName; a.rel = "noopener"; a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } }; this[downloadRevokeSymbol] = revoke; this.addEventListener("click", this[downloadHandlerSymbol]); } /** * Sets the content for displaying an email message. * The data is expected to be an object with a structure containing * 'to', 'from', 'subject', 'parts', and 'headers'. * The parts are processed to display plain text and HTML in separate tabs, * and attachments are listed. * * @param {object} emailData - The structured email data. */ setMessage(emailData) { if (!emailData || typeof emailData !== "object") { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Invalid email data provided", }), ); return; } this.setOption( "content", '<monster-message-content part="message"></monster-message-content>', ); setTimeout(() => { const messageContent = this.shadowRoot.querySelector( "monster-message-content", ); if (!messageContent) { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Message content element not found", }), ); return; } messageContent.setMessage(emailData); }, 100); } /** * Sets an image for the target by accepting a blob, URL, or string representation of the image. * * @param {(Blob|string)} data - The image data, which can be a Blob, a valid URL, or a string representation of the image. * @return {void} Does not return a value. */ setImage(data) { if (isBlob(data)) { data = URL.createObjectURL(data); } else if (isURL(data)) { // nothing to do } else if (isString(data)) { // nothing to do } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "Blob or URL expected" }), ); throw new Error("Blob or URL expected"); } this.setOption( "content", `<img style="max-width: 100%" src="${data}" alt="image" part="image" onerror="this.dispatchEvent(new CustomEvent('viewer-error', {detail: 'Image loading error'}));">`, ); } /** * * if the data is a string, it is interpreted as HTML. * if the data is a URL, the HTML is loaded from the url and set as content. * if the data is an HTMLElement, the outerHTML is used as content. * * @param {HTMLElement|URL|string|Blob} data */ setHTML(data) { if (data instanceof Blob) { blobToText(data) .then((html) => { this.setOption("content", html); }) .catch((error) => { this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); throw new Error(error); }); return; } else if (data instanceof HTMLElement) { data = data.outerHTML; } else if (isString(data)) { // nothing to do } else if (isURL(data)) { // fetch element getGlobal() .fetch(data) .then((response) => { return response.text(); }) .then((html) => { this.setOption("content", html); }) .catch((error) => { this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); throw new Error(error); }); } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "HTMLElement or string expected", }), ); throw new Error("HTMLElement or string expected"); } this.setOption("content", data); } /** * Sets the plain text content by processing the input data, which can be of various types, including Blob, * HTMLElement, string, or a valid URL. The method extracts and sets the raw text content into a predefined option. * * @param {Blob|HTMLElement|string} data - The input data to be processed. It can be a Blob object, an HTMLElement, * a plain string, or a string formatted as a valid URL. The method determines * the data type and processes it accordingly. * @return {void} - This method does not return any value. It processes the content and updates the relevant option * property. */ setPlainText(data) { const mkPreSpan = (text) => { const pre = document.createElement("pre"); pre.innerText = text; pre.setAttribute("part", "text"); return pre.outerHTML; }; if (data instanceof Blob) { blobToText(data) .then((text) => { const div = document.createElement("div"); div.innerHTML = text; text = div.innerText; this.setOption("content", mkPreSpan(text)); }) .catch((error) => { this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); throw new Error(error); }); return; } else if (data instanceof HTMLElement) { data = data.outerText; } else if (isString(data)) { const div = document.createElement("div"); div.innerHTML = data; data = div.innerText; } else if (isURL(data)) { getGlobal() .fetch(data) .then((response) => { return response.text(); }) .then((text) => { const div = document.createElement("div"); div.innerHTML = text; text = div.innerText; this.setOption("content", mkPreSpan(text)); }) .catch((error) => { this.dispatchEvent( new CustomEvent("viewer-error", { detail: error }), ); throw new Error(error); }); } else { this.dispatchEvent( new CustomEvent("viewer-error", { detail: "HTMLElement or string expected", }), ); throw new Error("HTMLElement or string expected"); } this.setOption("content", mkPreSpan(data)); } /** * * @return {Viewer} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); } /** * * @return {string} */ static getTag() { return "monster-viewer"; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [ViewerStyleSheet]; } } /** * @private * @param variable * @return {boolean} */ function isURL(variable) { try { new URL(variable); return true; } catch (error) { return false; } } /** * @private * @param variable * @return {boolean} */ function isBlob(variable) { return variable instanceof Blob; } /** * @private * @param blob * @return {Promise<unknown>} */ function blobToText(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.onerror = reject; reader.readAsText(blob); }); } /** * @private * @return {Select} * @throws {Error} no shadow-root is defined */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[viewerElementSymbol] = this.shadowRoot.getElementById("viewer"); } /** * @private */ function initEventHandler() { return this; } function getLabels() { switch (getLocaleOfDocument().language) { case "de": // German return { download: "Herunterladen", }; case "es": // Spanish return { download: "Descargar", }; case "zh": // Mandarin return { download: "下载", }; case "hi": // Hindi return { download: "下载", }; case "bn": // Bengali return { download: "ডাউনলোড", }; case "pt": // Portuguese return { download: "Baixar", }; case "ru": // Russian return { download: "Скачать", }; case "ja": // Japanese return { download: "ダウンロード", }; case "pa": // Western Punjabi return { download: "ਡਾਊਨਲੋਡ", }; case "mr": // Marathi return { download: "डाउनलोड", }; case "fr": // French return { download: "Télécharger", }; case "it": // Italian return { download: "Scarica", }; case "nl": // Dutch return { download: "Downloaden", }; case "sv": // Swedish return { download: "Ladda ner", }; case "pl": // Polish return { download: "Ściągnij", }; case "da": // Danish return { download: "Lad ned", }; case "fi": // Finnish return { download: "Lataa", }; case "no": // Norwegian return { download: "Laste ned", }; case "cs": // Czech return { download: "Stáhnout", }; default: return { download: "Download", }; } } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <div id="viewer" data-monster-role="viewer" part="viewer" data-monster-replace="path:content" data-monster-attributes="class path:classes.viewer"> </div>`; } registerCustomElement(Viewer);