import {LitElement, html, css} from 'lit'; import {customElement, property} from 'lit/decorators.js'; import {unsafeHTML} from 'lit-html/directives/unsafe-html'; import {define, whenDefined, status} from './ce'; type Elements = {[tagName: string]: string}; const mathRenderingModuleUrlDefault = 'https://cdn.jsdelivr.net/npm/@pie-lib/math-rendering-module@4.1.0/module/index.js'; const importMathRendering = (mathRenderingModuleUrl?: string) => { if (!(window as any).renderMath) { import(mathRenderingModuleUrl || mathRenderingModuleUrlDefault).then( ({_dll_pie_lib__math_rendering: mathRendering}) => { (window as any).renderMath = mathRendering.renderMath; } ); } }; interface Model { id: string; element: string; [key: string]: any; } type Item = {markup: string; elements: Elements; models: Model[]}; type Config = { item: Item; options?: {role: 'student' | 'instructor'}; }; type PkgResolution = { tagName: string; printTagName?: string; pkg: string; url: string; module: boolean; }; type ResolverFn = (tagName: string, pkg: string) => Promise; type LoadResolutionFn = (r: PkgResolution) => Promise; const defaultResolve: ResolverFn = (tagName: string, pkg: string) => { return Promise.resolve({ tagName, pkg, url: `https://cdn.jsdelivr.net/npm/${pkg}/module/print.js`, module: true, }); }; type LoadResolutionResult = { success: boolean; pkg: PkgResolution; message?: string; }; const verifyCdnExists = async (url: string) => { try { const response = await fetch(url, {method: 'HEAD'}); return response.ok; } catch { return false; } }; const defaultLoadResolution = async ( r: PkgResolution ): Promise => { if (!r.printTagName) { throw new Error(`printTagName must be defined`); } const s = status(r.printTagName); if (s === 'inProgress' || s === 'inRegistry') { console.log('tag already defined - skip'); return whenDefined(r.printTagName).then(() => ({success: true, pkg: r})); } const existPrintModule = await verifyCdnExists(r.url); if (!existPrintModule) { return { success: false, pkg: r, message: 'Print module is not configured for this item type', }; } if (r.module) { try { const mod = await import(r.url); define(r.printTagName, mod.default || mod); return whenDefined(r.printTagName).then(() => ({success: true, pkg: r})); } catch (e: any) { console.log('failed to load module'); return {success: false, pkg: r, message: e.message}; } } if (!r.module) { throw new Error('only loading modules!'); } return {success: false, pkg: r}; }; const hashCode = (s: string): number => { let hash = 0; let i, chr; if (s.length === 0) return hash; for (i = 0; i < s.length; i++) { chr = s.charCodeAt(i); hash = (hash << 5) - hash + chr; hash |= 0; // Convert to 32bit integer } return hash; }; type NodeResult = {id: string; [key: string]: string | null}; const mkItem = ( models: Model[], resolutions: PkgResolution[], elements: Elements ): Item => { const f = document.createDocumentFragment(); models.forEach((o) => { const res = resolutions.find( (r) => r.tagName === o.element || r.printTagName === o.element ); if (res) { o.element = res.printTagName!; const node = document.createElement(res.printTagName!); node.setAttribute('id', o.id); f.appendChild(node); } }); const root = document.createElement('div'); root.appendChild(f); return {markup: root.innerHTML, models, elements}; }; /** * Parse the markup and replace the default element with the print element * @param item * @param resolutions * @returns */ const processMarkup = ( markup: string, resolutions: PkgResolution[] ): {html: string; nodes: NodeResult[]} => { const p = new DOMParser(); try { const doc = p.parseFromString(markup, 'text/html'); const results: NodeResult[] = []; resolutions.forEach((r) => { const nl = doc.body.querySelectorAll(r.tagName); nl.forEach((n) => { if (!r.printTagName) { throw new Error('Missing a printTagName'); } const id = n.getAttribute('id'); const pieId = n.getAttribute('pie-id') || id; const originalTag = n.tagName.toLowerCase(); if (id) { const newEl = document.createElement(r.printTagName); newEl.setAttribute('id', id || ''); newEl.setAttribute('pie-id', pieId || ''); newEl.setAttribute('data-original-tag', originalTag); n.parentNode?.replaceChild(newEl, n); results.push({id, pieId, originalTag}); } }); }); return {html: doc.body.innerHTML, nodes: results}; } catch (e) { throw new Error(`Failed to parse the markup - is it valid html: ${markup}`); } }; export class FailedEl extends HTMLElement { connectedCallback() { this.innerHTML = `Failed!`; } } /** * Create a duplicate of the item, with the print element being used instead of the regular element. * @param item * @param resolutions * @returns */ const printItemAndFloaters = ( item: Item, resolutions: PkgResolution[] ): {item: Item; floaters: Model[]} => { const r = processMarkup(item.markup, resolutions); const {embedded, floaters} = item.models.reduce( (acc, m) => { const inMarkup = r.nodes.some((n) => n.id === m.id); if (inMarkup) { acc.embedded.push(m); } else { acc.floaters.push(m); } return acc; }, {embedded: [] as any[], floaters: [] as any[]} ); return { item: { markup: r.html, elements: Object.entries(item.elements).reduce( (acc, [key, value]) => { const res = resolutions.find((r) => r.tagName === key); if (!res || !res.printTagName) { throw new Error(`cant find resolution for element: ${key}`); } acc[res.printTagName] = value; return acc; }, {} ), models: embedded.map((m) => { const res = resolutions.find((r) => r.tagName === m.element); if (!res || !res.printTagName) { throw new Error(`cant find resolution for element: ${m.element}`); } return {...m, element: res?.printTagName}; }), }, floaters, }; }; type MissingElFn = (pkg: PkgResolution, message?: string) => HTMLElement; const defaultMissingElement = (pkg: PkgResolution, message?: string): any => class extends HTMLElement { connectedCallback() { this.innerHTML = `
cant load ${ pkg.tagName }

${ message || '?' }
`; } }; @customElement('pie-print') export class PiePrint extends LitElement { static styles = css` :host { display: block; border: solid 1px gray; padding: 16px; max-width: 800px; } `; constructor() { super(); this._resolve = defaultResolve; this._loadResolutions = defaultLoadResolution; this._missingElement = defaultMissingElement; } set missingElement(c: any) { this._missingElement = c; } get missingElement() { return this._missingElement; } // no shadow dom createRenderRoot() { return this; } private _resolve: ResolverFn; private _loadResolutions: LoadResolutionFn; private _missingElement: MissingElFn; private mathRenderingModuleUrlImported = false; private _applyData(item: Item) { item.models.forEach((m) => { const el: any = this.querySelector(`${m.element}[id="${m.id}"]`); if (!el) { throw new Error(`missing el: ${m.element}[id="${m.id}"]`); } // set el mode from role to prevent breakages el.options = {...this.config.options, mode: this.config.options?.role}; el.model = m; }); } public set resolve(fn: ResolverFn) { this._resolve = fn; } private _config: Config = {item: {markup: '', elements: {}, models: []}}; private _resolutions: PkgResolution[] = []; private _printItem: Item = {markup: '', elements: {}, models: []}; private _floatItem: Item = {markup: '', elements: {}, models: []}; @property({type: Object}) get config(): Config { return this._config; } /** * TODO: I'm not sure if this is the best path, as it may be nice to * provide some ui feedback when setting up the resolutions etc. * Will get us over the hump for now. */ set config(value: Config) { const oldValue = this._config; this._config = value; Promise.all( Object.entries(this.config.item.elements).map(([tagName, pkg]) => { console.log('tagName:', tagName, 'pkg', pkg); return this._resolve(tagName, pkg).then((res) => { if (!res.printTagName) { res.printTagName = `${res.tagName}-${hashCode(res.url)}`; } return res; }); }) ).then((resolutions) => { this._resolutions = resolutions; const pif = printItemAndFloaters(this._config.item, this._resolutions); this._printItem = pif.item; this._floatItem = mkItem( pif.floaters, this._resolutions, pif.item.elements ); this.requestUpdate('config', oldValue); }); } private renderMath() { setTimeout(() => { if ((window as any).renderMath) { (window as any).renderMath([this]); } }, 50); } connectedCallback() { super.connectedCallback(); this.renderMath(); } async updated(changedProperties: Map) { if (!this.mathRenderingModuleUrlImported) { importMathRendering(); this.mathRenderingModuleUrlImported = true; } if (changedProperties.has('config') && this.config.item.elements) { try { const results = await Promise.all( this._resolutions.map((r) => this._loadResolutions(r)) ); const failed = results.filter((r) => !r.success); await Promise.all( failed.map((f) => { define(f.pkg.printTagName!, this.missingElement(f.pkg, f.message)); return whenDefined(f.pkg.printTagName!); }) ); this._applyData(this._printItem); if (this._floatItem && this._floatItem.markup) { this._applyData(this._floatItem); } } catch (e) { console.error(e); } } } render() { return html`
${unsafeHTML(this._printItem.markup)}
${unsafeHTML(this._floatItem.markup)}
`; } } declare global { interface HTMLElementTagNameMap { 'pie-print': PiePrint; } }