@schukai/monster
Version:
Monster is a simple library for creating fast, robust and lightweight websites.
1,051 lines (960 loc) • 27.4 kB
JavaScript
/**
* 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);