UNPKG

@ou-imdt/utils

Version:

Utility library for interactive media development

246 lines (206 loc) 8.01 kB
import { default as Base, defaultState } from '../class/Base.js'; import Subber from './SubberModule'; import setObjectProperty from '../setObjectProperty.js'; import getObjectProperty from '../getObjectProperty.js'; export const subber = Symbol('SubberModule'); export default class TemplateEngineModule extends Base { static get [defaultState]() { return { templateOptions: {}, templateData: {}, subberOptions: null }; }; #renderedContent = []; constructor() { super(); this[subber] = new Subber(); } get subberOptions() { return this[subber].state; } set subberOptions(options = null) { if (options === null) return; this[subber].state = options; } get _templatesAsData() { // return this.templateOptions.reduce((result, { name, content }) => Object.assign(result, { [name]: content })); return Object.fromEntries(this.templateOptions.map(({ name, content }) => [name, content])); } update() { // console.log('update template engine..............', this.templateOptions); const rendered = this.#renderedContent.slice(0); this._validateTemplateOptions(); for (let options of this.templateOptions) { const template = this._renderTemplate(options); if (rendered.includes(template)) rendered.splice(rendered.indexOf(template), 1); if (!this.#renderedContent.includes(template)) this.#renderedContent.push(template); } // remove remaining rendered.forEach(el => this._removeTemplate(el)); // console.log('...................templates updated'); } _validateTemplateOptions() { // console.log('validate template options', this.templateOptions); this.templateOptions.forEach((options, index, array) => { const { name, target } = options; if (typeof name !== 'string' || name.length === 0) { throw new Error(`invalid template name [${name}]: templates must have a unique name`); } if (array.indexOf(array.find(el => el.name === name)) !== index) { throw new Error(`duplicate template name [${name}]: templates must have a unique name`); } if (!(target instanceof Element)) { throw new Error(`invalid template target [${name}]: templates must have a target Element`); } }); } /** * * @param {object} options - gets * @param {string} name - template name * @returns {object} - template if found or null */ _getTemplate(name) { // console.log('get template:', name, this.#renderedContent); return this.#renderedContent.find(el => el.name === name) ?? null; } /** * gets the data for the template with name, or all data if name undefined * templateData.root assigned to returned data, with remaining values assigned to data.root * aliases are resolved within remaining values, i.e. value.alias * @param {string} name - template name * @returns {object} */ _getTemplateData(name) { const root = this.templateData; const { alias, templates, [`${name}Overrides`]: overrides = {}, ...rest } = root; const { alias: aliasOverrides, templates: templatesOverrides, ...overridesRest } = overrides; const aliased = this.resolveAliases({ alias: { ...alias, ...aliasOverrides }, ...rest, ...overridesRest }); return { root, templates: { ...this._templatesAsData, ...templates, ...templatesOverrides }, ...aliased }; } /** * renders a template with options, creating new or using existing if found * existing templates only updated if content has changed * @param {object} options * @returns */ _renderTemplate(options) { const template = this._getTemplate(options.name); // console.log('render template:', options.name, template); // if active append/remove from dom but retain template if (template) { this._updateTemplate(template, options); return template; } return this._createTemplate(options); } /** * creates a template instance with the given options and appends result to target * @param {Object} optionList - options for template * @param {string} optionList.name - the template name * @param {string} optionList.content - the template content * @param {Element} optionList.target - element to append result * @param {string} [optionList.valueTarget] - element to append values (e.g. shadow host) */ _createTemplate(options) { // console.log(`create template:', options.name, options); const { name, content, target, valueTarget = null } = options; const values = []; const data = this._getTemplateData(name); const callback = (context) => { const { value: content } = context; const fragment = this._createFragment(content || '<template></template>'); // ensure target for updates const nodes = [...fragment.childNodes]; if (valueTarget) { valueTarget.append(...nodes); return ''; } values.push({ context, content, nodes }); return fragment; } const fragment = this[subber].dom(content, data, callback); const nodes = [...fragment.childNodes]; const template = { ...options, values, nodes }; target.append(fragment); return template; } /** * updates template values, initially refreshs template content if updated * @param {object} template - template instance * @param {object} options - template options */ _updateTemplate(template, options) { // console.log('update template:', options); const { name } = template; const data = this._getTemplateData(name); const hasChanged = (JSON.stringify(options.content) !== JSON.stringify(template?.content)); if (hasChanged) { template.nodes.forEach(el => el.remove()); // refresh template with options - new content, context etc. Object.assign(template, this._createTemplate(options)); } template.values.forEach(options => { const { context } = options; const resolved = this[subber].resolveValue(context.path, data); const content = (typeof resolved === 'function') ? resolved({ ...context, data }) : resolved; const unChanged = (content === options.content); if (unChanged) return; // move to value.update? const fragment = this._createFragment(content); const nodes = [...fragment.childNodes]; const [target, ...rest] = options.nodes; // replace existing target node with updated target.replaceWith(...nodes); // remove remaining nodes rest.forEach(el => el.remove()); // update options Object.assign(options, { content, nodes }); }); } /** * removes stored template and all asssociated elements from DOM * @param {object} template */ _removeTemplate(template) { // console.log('remove template', options); const { values } = template; const rendered = this.#renderedContent; const nodes = [...template.nodes, ...values.flatMap(({ nodes }) => nodes)]; nodes.forEach(el => el.remove()); rendered.splice(rendered.indexOf(template), 1); } _createFragment(content) { return document.createRange().createContextualFragment(content); } resolveAliases(data) { return Object.entries(data).reduce((result, [key, value]) => { if (key === 'alias') { const expanded = Object.entries(value).flatMap(([name, path]) => { const destructure = name.includes('|'); return name.replace(/\s+/g, '').split('|').map(prop => { return [prop, (destructure ? `${path}.${prop}` : path)] }); }); for (const [name, path] of expanded) { setObjectProperty(result, name, getObjectProperty(data, path) ?? null); } } else { result[key] = value; } return result; }, {}); } reset() { const rendered = this.#renderedContent.splice(0); rendered.forEach(el => this._removeTemplate(el)); } }