]> BookStack Code Mirror - bookstack/blob - resources/js/services/dom.ts
Comments: Fixed a range of TS errors + other
[bookstack] / resources / js / services / dom.ts
1 import {cyrb53} from "./util";
2
3 /**
4  * Check if the given param is a HTMLElement
5  */
6 export function isHTMLElement(el: any): el is HTMLElement {
7     return el instanceof HTMLElement;
8 }
9
10 /**
11  * Create a new element with the given attrs and children.
12  * Children can be a string for text nodes or other elements.
13  */
14 export function elem(tagName: string, attrs: Record<string, string> = {}, children: Element[]|string[] = []): HTMLElement {
15     const el = document.createElement(tagName);
16
17     for (const [key, val] of Object.entries(attrs)) {
18         if (val === null) {
19             el.removeAttribute(key);
20         } else {
21             el.setAttribute(key, val);
22         }
23     }
24
25     for (const child of children) {
26         if (typeof child === 'string') {
27             el.append(document.createTextNode(child));
28         } else {
29             el.append(child);
30         }
31     }
32
33     return el;
34 }
35
36 /**
37  * Run the given callback against each element that matches the given selector.
38  */
39 export function forEach(selector: string, callback: (el: Element) => any) {
40     const elements = document.querySelectorAll(selector);
41     for (const element of elements) {
42         callback(element);
43     }
44 }
45
46 /**
47  * Helper to listen to multiple DOM events
48  */
49 export function onEvents(listenerElement: Element|null, events: string[], callback: (e: Event) => any): void {
50     if (listenerElement) {
51         for (const eventName of events) {
52             listenerElement.addEventListener(eventName, callback);
53         }
54     }
55 }
56
57 /**
58  * Helper to run an action when an element is selected.
59  * A "select" is made to be accessible, So can be a click, space-press or enter-press.
60  */
61 export function onSelect(elements: HTMLElement|HTMLElement[], callback: (e: Event) => any): void {
62     if (!Array.isArray(elements)) {
63         elements = [elements];
64     }
65
66     for (const listenerElement of elements) {
67         listenerElement.addEventListener('click', callback);
68         listenerElement.addEventListener('keydown', event => {
69             if (event.key === 'Enter' || event.key === ' ') {
70                 event.preventDefault();
71                 callback(event);
72             }
73         });
74     }
75 }
76
77 /**
78  * Listen to key press on the given element(s).
79  */
80 function onKeyPress(key: string, elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
81     if (!Array.isArray(elements)) {
82         elements = [elements];
83     }
84
85     const listener = (event: KeyboardEvent) => {
86         if (event.key === key) {
87             callback(event);
88         }
89     };
90
91     elements.forEach(e => e.addEventListener('keydown', listener));
92 }
93
94 /**
95  * Listen to enter press on the given element(s).
96  */
97 export function onEnterPress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
98     onKeyPress('Enter', elements, callback);
99 }
100
101 /**
102  * Listen to escape press on the given element(s).
103  */
104 export function onEscapePress(elements: HTMLElement|HTMLElement[], callback: (e: KeyboardEvent) => any): void {
105     onKeyPress('Escape', elements, callback);
106 }
107
108 /**
109  * Set a listener on an element for an event emitted by a child
110  * matching the given childSelector param.
111  * Used in a similar fashion to jQuery's $('listener').on('eventName', 'childSelector', callback)
112  */
113 export function onChildEvent(
114     listenerElement: HTMLElement,
115     childSelector: string,
116     eventName: string,
117     callback: (this: HTMLElement, e: Event, child: HTMLElement) => any
118 ): void {
119     listenerElement.addEventListener(eventName, (event: Event) => {
120         const matchingChild = (event.target as HTMLElement|null)?.closest(childSelector) as HTMLElement;
121         if (matchingChild) {
122             callback.call(matchingChild, event, matchingChild);
123         }
124     });
125 }
126
127 /**
128  * Look for elements that match the given selector and contain the given text.
129  * Is case-insensitive and returns the first result or null if nothing is found.
130  */
131 export function findText(selector: string, text: string): Element|null {
132     const elements = document.querySelectorAll(selector);
133     text = text.toLowerCase();
134     for (const element of elements) {
135         if ((element.textContent || '').toLowerCase().includes(text) && isHTMLElement(element)) {
136             return element;
137         }
138     }
139     return null;
140 }
141
142 /**
143  * Show a loading indicator in the given element.
144  * This will effectively clear the element.
145  */
146 export function showLoading(element: HTMLElement): void {
147     element.innerHTML = '<div class="loading-container"><div></div><div></div><div></div></div>';
148 }
149
150 /**
151  * Get a loading element indicator element.
152  */
153 export function getLoading(): HTMLElement {
154     const wrap = document.createElement('div');
155     wrap.classList.add('loading-container');
156     wrap.innerHTML = '<div></div><div></div><div></div>';
157     return wrap;
158 }
159
160 /**
161  * Remove any loading indicators within the given element.
162  */
163 export function removeLoading(element: HTMLElement): void {
164     const loadingEls = element.querySelectorAll('.loading-container');
165     for (const el of loadingEls) {
166         el.remove();
167     }
168 }
169
170 /**
171  * Convert the given html data into a live DOM element.
172  * Initiates any components defined in the data.
173  */
174 export function htmlToDom(html: string): HTMLElement {
175     const wrap = document.createElement('div');
176     wrap.innerHTML = html;
177     window.$components.init(wrap);
178     const firstChild = wrap.children[0];
179     if (!isHTMLElement(firstChild)) {
180         throw new Error('Could not find child HTMLElement when creating DOM element from HTML');
181     }
182
183     return firstChild;
184 }
185
186 /**
187  * For the given node and offset, return an adjusted offset that's relative to the given parent element.
188  */
189 export function normalizeNodeTextOffsetToParent(node: Node, offset: number, parentElement: HTMLElement): number {
190     if (!parentElement.contains(node)) {
191         throw new Error('ParentElement must be a prent of element');
192     }
193
194     let normalizedOffset = offset;
195     let currentNode: Node|null = node.nodeType === Node.TEXT_NODE ?
196         node : node.childNodes[offset];
197
198     while (currentNode !== parentElement && currentNode) {
199         if (currentNode.previousSibling) {
200             currentNode = currentNode.previousSibling;
201             normalizedOffset += (currentNode.textContent?.length || 0);
202         } else {
203             currentNode = currentNode.parentNode;
204         }
205     }
206
207     return normalizedOffset;
208 }
209
210 /**
211  * Find the target child node and adjusted offset based on a parent node and text offset.
212  * Returns null if offset not found within the given parent node.
213  */
214 export function findTargetNodeAndOffset(parentNode: HTMLElement, offset: number): ({node: Node, offset: number}|null) {
215     if (offset === 0) {
216         return { node: parentNode, offset: 0 };
217     }
218
219     let currentOffset = 0;
220     let currentNode = null;
221
222     for (let i = 0; i < parentNode.childNodes.length; i++) {
223         currentNode = parentNode.childNodes[i];
224
225         if (currentNode.nodeType === Node.TEXT_NODE) {
226             // For text nodes, count the length of their content
227             // Returns if within range
228             const textLength = (currentNode.textContent || '').length;
229             if (currentOffset + textLength >= offset) {
230                 return {
231                     node: currentNode,
232                     offset: offset - currentOffset
233                 };
234             }
235
236             currentOffset += textLength;
237         } else if (currentNode.nodeType === Node.ELEMENT_NODE) {
238             // Otherwise, if an element, track the text length and search within
239             // if in range for the target offset
240             const elementTextLength = (currentNode.textContent || '').length;
241             if (currentOffset + elementTextLength >= offset) {
242                 return findTargetNodeAndOffset(currentNode as HTMLElement, offset - currentOffset);
243             }
244
245             currentOffset += elementTextLength;
246         }
247     }
248
249     // Return null if not found within range
250     return null;
251 }
252
253 /**
254  * Create a hash for the given HTML element content.
255  */
256 export function hashElement(element: HTMLElement): string {
257     const normalisedElemText = (element.textContent || '').replace(/\s{2,}/g, '');
258     return cyrb53(normalisedElemText);
259 }