import Format from "@supersoniks/concorde/core/utils/Format"; import HTML from "@supersoniks/concorde/core/utils/HTML"; import {PublisherManager} from "@supersoniks/concorde/core/utils/PublisherProxy"; import {ConcordeWindow, PublisherInterface, PublisherContentType} from "@supersoniks/concorde/core/_types/types"; import {SearchableDomElement} from "@supersoniks/concorde/core/utils/HTML"; declare const window: ConcordeWindow; type BindedVariablesDescriptor = { expression: string; variables: Array>; }; type DataBindItem = { propertyToUpdate: string; bindedVariablesDescriptor: BindedVariablesDescriptor; }; type PublisherListenerConfig = { publisher: PublisherInterface; onAssign: (value?: ValueType) => void; }; type FormatFuncName = keyof Format; /** * * En appelant DataBindObserver.observe(HTMLElement) sun un élément html, tout les éléments peuvent être liés à au publisher a l'adresse déterminée parl'attribut dataProvider de l'un de ses ancêtres. * Pour cela un MutationObserver est créé pour observer les changements d'attributs de l'élément. * On peut alors ecrire ce genre de choses de manière a lier dynamiquement les données du publisher à l'élément html. * * * Voir la doc de subscriber à ce sujet car il l'utilise par défaut. */ export default class DataBindObserver { /** * Maintient la liste des éléments observés de manière à pouvoir les désinscrire quand ils sont supprimés. */ static observedElements: Map = new Map(); /** * Commencer à observer un élément html. */ static enabled = true; static disable() { if (!this.enabled) return; this.enabled = false; Array.from(DataBindObserver.observedElements.keys()).forEach((k: SearchableDomElement) => DataBindObserver.unObserve(k) ); } static observe(element: SearchableDomElement) { if (!element) return; if (!DataBindObserver.enabled) return; if (DataBindObserver.observedElements.has(element)) return; const obs = new MutationObserver(DataBindObserver.onMutation); const opt: MutationObserverInit = {}; opt.childList = true; opt.subtree = true; opt.attributes = true; opt.attributeFilter = ["data-bind"]; obs.observe(element, opt); element .querySelectorAll("[data-bind]") .forEach((e) => DataBindObserver.addPublisherListeners(e as SearchableDomElement)); DataBindObserver.observedElements.set(element, obs); } /** * Arrêter à observer un élément html. */ static unObserve(element: SearchableDomElement) { if (!element) return; const observer = this.observedElements.get(element); if (!observer) return; observer.disconnect(); element.querySelectorAll("[data-bind]").forEach((e) => DataBindObserver.removePublisherListeners(e as HTMLElement)); } static onAdded(elt: HTMLElement) { if (elt.hasAttribute && elt.hasAttribute("data-bind")) DataBindObserver.addPublisherListeners(elt); if (elt.querySelectorAll) elt.querySelectorAll("[data-bind]").forEach((e) => DataBindObserver.addPublisherListeners(e as HTMLElement)); else elt.childNodes.forEach((elt) => DataBindObserver.onAdded(elt as HTMLElement)); } static onRemoved(elt: HTMLElement) { if (elt.hasAttribute && elt.hasAttribute("data-bind")) DataBindObserver.removePublisherListeners(elt as HTMLElement); if (elt.querySelectorAll) elt.querySelectorAll("[data-bind]").forEach((e) => DataBindObserver.removePublisherListeners(e as HTMLElement)); else elt.childNodes.forEach((elt) => DataBindObserver.onRemoved(elt as HTMLElement)); } /** * Callback appelé par le MutationObserver */ static onMutation(list: MutationRecord[]) { for (const l of list) { switch (l.type) { case "attributes": DataBindObserver.addPublisherListeners(l.target as HTMLElement); break; case "childList": l.addedNodes.forEach((elt: Node) => { DataBindObserver.onAdded(elt as HTMLElement); }); l.removedNodes.forEach((elt: Node) => { DataBindObserver.onRemoved(elt as HTMLElement); }); break; } } } static publisherListeners: Map = new Map(); /** * La liaison avec le publisher supprimée ici. */ static removePublisherListeners(target: SearchableDomElement) { const conf = DataBindObserver.publisherListeners.get(target); if (!conf) return; DataBindObserver.publisherListeners.delete(target); conf.forEach((currentConf) => { currentConf.publisher?.offAssign(currentConf.onAssign); }); } /** * * Cette fonction prend l'expression fournie et trouves toutes les occurences du type $.clef1.clef2.clef3 ou $a.b par exemple. * Les occurences sont ensuite mises dans un table de la forme [["clef1", "clef2", "clef3"],["a", "b"],...] * Note : si une clef contien un point, on peut l'échapper en écrivant par exemple "$a.b.c\.d\.e" pour cibler value dans {a:{b:{"c.d.e":value}}} * Propriétés du retour (du type BindedVariablesDescriptor) : * * expression : l'expression initiale sans les échappements * * variables : le tableau d'"adresses" de variables décrit ci-dessus */ static getVariablesDescriptor(expression: string): BindedVariablesDescriptor { let variables: string[] = expression.match(/(\$(?:\w+\\?\.?)+)/g) as string[]; if (!variables) variables = [expression]; else variables = variables.map((v: string) => v.replace("$", "")); variables = variables.filter((v: string) => v.length > 0); return { expression: expression.replace("\\", ""), //TODO Supression des échappements uniquement suivis de "." variables: variables.map((v: string) => v.split(/\b\.\b/).map((e) => e.replace("\\", ""))), }; } /** * Extrait des "DataBindItems" a partir d'un élément html. * Pour chaque attribut dont le nom commence par ::, un dataBindItem est créé. * * Proriété "propertyToUpdate" du DataBindItem : * Le nom de l'attribut modifié en transformant La notation en hyphen ("-") en KamelCase. * un cas spécial est créé pour *-html qui retourne *HTML par exemple inner-html devient innerHTML * Cela représente la propriété à mettre à jour sur l'élément lors de la modification d'une des variables liées dans le publicheur. * * Propriété "bindedVariablesDescriptor" du DataBindItem : voir la fonction getVariablesDescriptor */ static getDataBindItems(element: SearchableDomElement): DataBindItem[] { if (!("attributes" in element)) return []; return Array.from(element.attributes) .filter((attribute) => attribute.name.indexOf("::") == 0) .map((e) => { const name = e.name.substring(2); return { propertyToUpdate: name.replace(/-((html)|\w)/g, (match) => match.substring(1).toUpperCase()), bindedVariablesDescriptor: DataBindObserver.getVariablesDescriptor(e.value), }; }); } /** * Cette fonction récuperer le (sous) publisher a l'adresse donnée. * Si l'une des clef de l'adresse est _self_, on garde le publisher courant et on passe à la suite. * Ceci est un cas spécial, c'est pour ça qu'on utilisa pes Objects.traverse. * Il y a toujours un publisher quelque soit l'adresse ce qui permet de cibler des valeurs qui n'existent pas encore */ static getSubPublisher( pub: PublisherInterface, pathArray?: string[] ): PublisherInterface { if (!pathArray) return pub; for (const key of pathArray) { if (key == "_self_") continue; if (!pub) return null; pub = pub[key]; } return pub as PublisherInterface; } /** * La liaison avec le publisher est faite ici. * TODO Sans doute factoriser */ static addPublisherListeners(target: SearchableDomElement) { DataBindObserver.removePublisherListeners(target); /** * On récupère le publisher viea le dataProvider d'un ancêtre de l'élément. */ const dataProviderId: string | null = HTML.getAncestorAttributeValue( (target.parentNode || (target as ShadowRoot).host || target) as SearchableDomElement, "dataProvider" ); if (!dataProviderId) return; const publisher = PublisherManager.getInstance().get(dataProviderId) as PublisherInterface; const dataBindItems: Array = DataBindObserver.getDataBindItems(target); const conf: Array = []; /** * Pour chaque attribut => dataBindItems on fait la liaison avec les (sous) publishers associés aux variables extraites * Lorsqu'une assignation est faite sur un des publishers liés, on met à jour la propriété de target dont le nom est renseigne dans l'attribut "propertyToUpdate du databindItem". * Cette mise à jour est effectuée dans la fonction onAssign. * On peut utiliser les fonctions présentes dans Format.ts notamment Format.js qui permet par exemple d'interpréter des expressions js simples. * Attentions, les Objets/tableaus sont rendus en chaine avant l'interprétation dans ce cas. */ dataBindItems.forEach((dataBindItem: DataBindItem) => { const bindedVariablesDescriptor = dataBindItem.bindedVariablesDescriptor; const propertyToUpdate = dataBindItem.propertyToUpdate; for (const value of bindedVariablesDescriptor.variables) { const publisherPathArray: string[] = value; let pub = publisher; pub = DataBindObserver.getSubPublisher(publisher, publisherPathArray); const rec = target as Record; const currentConf = { publisher: pub, onAssign: () => { const values = bindedVariablesDescriptor.variables.map((dataPath: string[]) => { return DataBindObserver.getSubPublisher(publisher, dataPath)?.get(); }); let expression = bindedVariablesDescriptor.expression; let hasUndeterminatedValue = false; /* * Si il n'y a qu'une variable on injecte la variable brute ce qui permet d'utiliser des objets composites * Dans le cas des des expressions complexes (plus bas, avec plusieurs variables) les objets deviennent des chaines de caractères.) */ if (values.length == 1 && bindedVariablesDescriptor.variables[0].join(".") == expression.substring(1)) { let value = values[0]; if ( value === null // || // ( Objects.isObject(value) && // value.hasOwnProperty("__value") && // (Objects.isUndefindOrNull(value.__value) || value.__value === "") ) ) { value = ""; } rec[propertyToUpdate] = value; return; } /** * Expressions avec plusieurs variables */ for (let i = 0; i < values.length; i++) { let value = values[i]; const variable = bindedVariablesDescriptor.variables[i]; if (value === null) { hasUndeterminatedValue = true; value = undefined; } expression = expression.replace("$" + variable.join("."), value); } if (expression.indexOf("|") != -1) { const funcDelimiterIdx = expression.indexOf("|"); if (funcDelimiterIdx == 0) { expression = Format.js(expression.substring(1)); } else { const funcName: FormatFuncName = expression.substring(0, funcDelimiterIdx) as FormatFuncName; const funcArgs = expression.substring(funcDelimiterIdx + 1); const fmtFunc: CallableFunction = Format[funcName]; expression = hasUndeterminatedValue ? "" : fmtFunc ? fmtFunc(funcArgs) : expression; } } else { expression = hasUndeterminatedValue ? "" : expression; } rec[propertyToUpdate] = expression; }, }; pub?.onAssign(currentConf.onAssign); conf.push(currentConf); } }); DataBindObserver.publisherListeners.set(target, conf); } } DataBindObserver.observe(document.documentElement); if (!window.SonicDataBindObserver) window.SonicDataBindObserver = DataBindObserver;