]> BookStack Code Mirror - bookstack/blob - resources/js/services/components.js
Replaced el.components mapping with component service weakmap
[bookstack] / resources / js / services / components.js
1 /**
2  * A mapping of active components keyed by name, with values being arrays of component
3  * instances since there can be multiple components of the same type.
4  * @type {Object<String, Component[]>}
5  */
6 const components = {};
7
8 /**
9  * A mapping of component class models, keyed by name.
10  * @type {Object<String, Constructor<Component>>}
11  */
12 const componentModelMap = {};
13
14 /**
15  * A mapping of active component maps, keyed by the element components are assigned to.
16  * @type {WeakMap<Element, Object<String, Component>>}
17  */
18 const elementComponentMap = new WeakMap();
19
20 /**
21  * Initialize a component instance on the given dom element.
22  * @param {String} name
23  * @param {Element} element
24  */
25 function initComponent(name, element) {
26     /** @type {Function<Component>|undefined} **/
27     const componentModel = componentModelMap[name];
28     if (componentModel === undefined) return;
29
30     // Create our component instance
31     /** @type {Component} **/
32     let instance;
33     try {
34         instance = new componentModel();
35         instance.$name = name;
36         instance.$el = element;
37         const allRefs = parseRefs(name, element);
38         instance.$refs = allRefs.refs;
39         instance.$manyRefs = allRefs.manyRefs;
40         instance.$opts = parseOpts(name, element);
41         instance.setup();
42     } catch (e) {
43         console.error('Failed to create component', e, name, element);
44     }
45
46     // Add to global listing
47     if (typeof components[name] === "undefined") {
48         components[name] = [];
49     }
50     components[name].push(instance);
51
52     // Add to element mapping
53     const elComponents = elementComponentMap.get(element) || {};
54     elComponents[name] = instance;
55     elementComponentMap.set(element, elComponents);
56 }
57
58 /**
59  * Parse out the element references within the given element
60  * for the given component name.
61  * @param {String} name
62  * @param {Element} element
63  */
64 function parseRefs(name, element) {
65     const refs = {};
66     const manyRefs = {};
67
68     const prefix = `${name}@`
69     const selector = `[refs*="${prefix}"]`;
70     const refElems = [...element.querySelectorAll(selector)];
71     if (element.matches(selector)) {
72         refElems.push(element);
73     }
74
75     for (const el of refElems) {
76         const refNames = el.getAttribute('refs')
77             .split(' ')
78             .filter(str => str.startsWith(prefix))
79             .map(str => str.replace(prefix, ''))
80             .map(kebabToCamel);
81         for (const ref of refNames) {
82             refs[ref] = el;
83             if (typeof manyRefs[ref] === 'undefined') {
84                 manyRefs[ref] = [];
85             }
86             manyRefs[ref].push(el);
87         }
88     }
89     return {refs, manyRefs};
90 }
91
92 /**
93  * Parse out the element component options.
94  * @param {String} name
95  * @param {Element} element
96  * @return {Object<String, String>}
97  */
98 function parseOpts(name, element) {
99     const opts = {};
100     const prefix = `option:${name}:`;
101     for (const {name, value} of element.attributes) {
102         if (name.startsWith(prefix)) {
103             const optName = name.replace(prefix, '');
104             opts[kebabToCamel(optName)] = value || '';
105         }
106     }
107     return opts;
108 }
109
110 /**
111  * Convert a kebab-case string to camelCase
112  * @param {String} kebab
113  * @returns {string}
114  */
115 function kebabToCamel(kebab) {
116     const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
117     const words = kebab.split('-');
118     return words[0] + words.slice(1).map(ucFirst).join('');
119 }
120
121 /**
122  * Initialize all components found within the given element.
123  * @param {Element|Document} parentElement
124  */
125 export function init(parentElement = document) {
126     const componentElems = parentElement.querySelectorAll(`[component],[components]`);
127
128     for (const el of componentElems) {
129         const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
130         for (const name of componentNames) {
131             initComponent(name, el);
132         }
133     }
134 }
135
136 /**
137  * Register the given component mapping into the component system.
138  * @param {Object<String, ObjectConstructor<Component>>} mapping
139  */
140 export function register(mapping) {
141     const keys = Object.keys(mapping);
142     for (const key of keys) {
143         componentModelMap[camelToKebab(key)] = mapping[key];
144     }
145 }
146
147 /**
148  * Get the first component of the given name.
149  * @param {String} name
150  * @returns {Component|null}
151  */
152 export function first(name) {
153     return (components[name] || [null])[0];
154 }
155
156 /**
157  * Get all the components of the given name.
158  * @param {String} name
159  * @returns {Component[]}
160  */
161 export function get(name = '') {
162     return components[name] || [];
163 }
164
165 /**
166  * Get the first component, of the given name, that's assigned to the given element.
167  * @param {Element} element
168  * @param {String} name
169  * @returns {Component|null}
170  */
171 export function firstOnElement(element, name) {
172     const elComponents = elementComponentMap.get(element) || {};
173     return elComponents[name] || null;
174 }
175
176 function camelToKebab(camelStr) {
177     return camelStr.replace(/[A-Z]/g, (str, offset) =>  (offset > 0 ? '-' : '') + str.toLowerCase());
178 }