]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts
Lexical: Reorganised custom node code into lexical codebase
[bookstack] / resources / js / wysiwyg / lexical / rich-text / LexicalMediaNode.ts
1 import {
2     DOMConversion,
3     DOMConversionMap, DOMConversionOutput, DOMExportOutput,
4     ElementNode,
5     LexicalEditor,
6     LexicalNode,
7     Spread
8 } from 'lexical';
9 import type {EditorConfig} from "lexical/LexicalEditor";
10
11 import {el, setOrRemoveAttribute, sizeToPixels} from "../../utils/dom";
12 import {
13     CommonBlockAlignment, deserializeCommonBlockNode,
14     setCommonBlockPropsFromElement,
15     updateElementWithCommonBlockProps
16 } from "lexical/nodes/common";
17 import {$selectSingleNode} from "../../utils/selection";
18 import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
19
20 export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
21 export type MediaNodeSource = {
22     src: string;
23     type: string;
24 };
25
26 export type SerializedMediaNode = Spread<{
27     tag: MediaNodeTag;
28     attributes: Record<string, string>;
29     sources: MediaNodeSource[];
30 }, SerializedCommonBlockNode>
31
32 const attributeAllowList = [
33     'width', 'height', 'style', 'title', 'name',
34     'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
35     'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
36     'muted', 'playsinline', 'poster', 'preload'
37 ];
38
39 function filterAttributes(attributes: Record<string, string>): Record<string, string> {
40     const filtered: Record<string, string> = {};
41     for (const key of Object.keys(attributes)) {
42         if (attributeAllowList.includes(key)) {
43             filtered[key] = attributes[key];
44         }
45     }
46     return filtered;
47 }
48
49 function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
50     const node = $createMediaNode(tag);
51
52     const attributes: Record<string, string> = {};
53     for (const attribute of element.attributes) {
54         attributes[attribute.name] = attribute.value;
55     }
56     node.setAttributes(attributes);
57
58     const sources: MediaNodeSource[] = [];
59     if (tag === 'video' || tag === 'audio') {
60         for (const child of element.children) {
61             if (child.tagName === 'SOURCE') {
62                 const src = child.getAttribute('src');
63                 const type = child.getAttribute('type');
64                 if (src && type) {
65                     sources.push({ src, type });
66                 }
67             }
68         }
69         node.setSources(sources);
70     }
71
72     setCommonBlockPropsFromElement(element, node);
73
74     return node;
75 }
76
77 export class MediaNode extends ElementNode {
78     __id: string = '';
79     __alignment: CommonBlockAlignment = '';
80     __tag: MediaNodeTag;
81     __attributes: Record<string, string> = {};
82     __sources: MediaNodeSource[] = [];
83     __inset: number = 0;
84
85     static getType() {
86         return 'media';
87     }
88
89     static clone(node: MediaNode) {
90         const newNode = new MediaNode(node.__tag, node.__key);
91         newNode.__attributes = Object.assign({}, node.__attributes);
92         newNode.__sources = node.__sources.map(s => Object.assign({}, s));
93         newNode.__id = node.__id;
94         newNode.__alignment = node.__alignment;
95         newNode.__inset = node.__inset;
96         return newNode;
97     }
98
99     constructor(tag: MediaNodeTag, key?: string) {
100         super(key);
101         this.__tag = tag;
102     }
103
104     setTag(tag: MediaNodeTag) {
105         const self = this.getWritable();
106         self.__tag = tag;
107     }
108
109     getTag(): MediaNodeTag {
110         const self = this.getLatest();
111         return self.__tag;
112     }
113
114     setAttributes(attributes: Record<string, string>) {
115         const self = this.getWritable();
116         self.__attributes = filterAttributes(attributes);
117     }
118
119     getAttributes(): Record<string, string> {
120         const self = this.getLatest();
121         return self.__attributes;
122     }
123
124     setSources(sources: MediaNodeSource[]) {
125         const self = this.getWritable();
126         self.__sources = sources;
127     }
128
129     getSources(): MediaNodeSource[] {
130         const self = this.getLatest();
131         return self.__sources;
132     }
133
134     setSrc(src: string): void {
135         const attrs = Object.assign({}, this.getAttributes());
136         if (this.__tag ==='object') {
137             attrs.data = src;
138         } else {
139             attrs.src = src;
140         }
141         this.setAttributes(attrs);
142     }
143
144     setWidthAndHeight(width: string, height: string): void {
145         const attrs = Object.assign(
146             {},
147             this.getAttributes(),
148             {width, height},
149         );
150         this.setAttributes(attrs);
151     }
152
153     setId(id: string) {
154         const self = this.getWritable();
155         self.__id = id;
156     }
157
158     getId(): string {
159         const self = this.getLatest();
160         return self.__id;
161     }
162
163     setAlignment(alignment: CommonBlockAlignment) {
164         const self = this.getWritable();
165         self.__alignment = alignment;
166     }
167
168     getAlignment(): CommonBlockAlignment {
169         const self = this.getLatest();
170         return self.__alignment;
171     }
172
173     setInset(size: number) {
174         const self = this.getWritable();
175         self.__inset = size;
176     }
177
178     getInset(): number {
179         const self = this.getLatest();
180         return self.__inset;
181     }
182
183     setHeight(height: number): void {
184         if (!height) {
185             return;
186         }
187
188         const attrs = Object.assign({}, this.getAttributes(), {height});
189         this.setAttributes(attrs);
190     }
191
192     getHeight(): number {
193         const self = this.getLatest();
194         return sizeToPixels(self.__attributes.height || '0');
195     }
196
197     setWidth(width: number): void {
198         const attrs = Object.assign({}, this.getAttributes(), {width});
199         this.setAttributes(attrs);
200     }
201
202     getWidth(): number {
203         const self = this.getLatest();
204         return sizeToPixels(self.__attributes.width || '0');
205     }
206
207     isInline(): boolean {
208         return true;
209     }
210
211     isParentRequired(): boolean {
212         return true;
213     }
214
215     createInnerDOM() {
216         const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
217         const sourceEls = sources.map(source => el('source', source));
218         const element = el(this.__tag, this.__attributes, sourceEls);
219         updateElementWithCommonBlockProps(element, this);
220         return element;
221     }
222
223     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
224         const media = this.createInnerDOM();
225         const wrap = el('span', {
226             class: media.className + ' editor-media-wrap',
227         }, [media]);
228
229         wrap.addEventListener('click', e => {
230             _editor.update(() => $selectSingleNode(this));
231         });
232
233         return wrap;
234     }
235
236     updateDOM(prevNode: MediaNode, dom: HTMLElement): boolean {
237         if (prevNode.__tag !== this.__tag) {
238             return true;
239         }
240
241         if (JSON.stringify(prevNode.__sources) !== JSON.stringify(this.__sources)) {
242             return true;
243         }
244
245         if (JSON.stringify(prevNode.__attributes) !== JSON.stringify(this.__attributes)) {
246             return true;
247         }
248
249         const mediaEl = dom.firstElementChild as HTMLElement;
250
251         if (prevNode.__id !== this.__id) {
252             setOrRemoveAttribute(mediaEl, 'id', this.__id);
253         }
254
255         if (prevNode.__alignment !== this.__alignment) {
256             if (prevNode.__alignment) {
257                 dom.classList.remove(`align-${prevNode.__alignment}`);
258                 mediaEl.classList.remove(`align-${prevNode.__alignment}`);
259             }
260             if (this.__alignment) {
261                 dom.classList.add(`align-${this.__alignment}`);
262                 mediaEl.classList.add(`align-${this.__alignment}`);
263             }
264         }
265
266         if (prevNode.__inset !== this.__inset) {
267             dom.style.paddingLeft = `${this.__inset}px`;
268         }
269
270         return false;
271     }
272
273     static importDOM(): DOMConversionMap|null {
274
275         const buildConverter = (tag: MediaNodeTag) => {
276             return (node: HTMLElement): DOMConversion|null => {
277                 return {
278                     conversion: (element: HTMLElement): DOMConversionOutput|null => {
279                         return {
280                             node: domElementToNode(tag, element),
281                         };
282                     },
283                     priority: 3,
284                 };
285             };
286         };
287
288         return {
289             iframe: buildConverter('iframe'),
290             embed: buildConverter('embed'),
291             object: buildConverter('object'),
292             video: buildConverter('video'),
293             audio: buildConverter('audio'),
294         };
295     }
296
297     exportDOM(editor: LexicalEditor): DOMExportOutput {
298         const element = this.createInnerDOM();
299         return { element };
300     }
301
302     exportJSON(): SerializedMediaNode {
303         return {
304             ...super.exportJSON(),
305             type: 'media',
306             version: 1,
307             id: this.__id,
308             alignment: this.__alignment,
309             inset: this.__inset,
310             tag: this.__tag,
311             attributes: this.__attributes,
312             sources: this.__sources,
313         };
314     }
315
316     static importJSON(serializedNode: SerializedMediaNode): MediaNode {
317         const node = $createMediaNode(serializedNode.tag);
318         deserializeCommonBlockNode(serializedNode, node);
319         return node;
320     }
321
322 }
323
324 export function $createMediaNode(tag: MediaNodeTag) {
325     return new MediaNode(tag);
326 }
327
328 export function $createMediaNodeFromHtml(html: string): MediaNode | null {
329     const parser = new DOMParser();
330     const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
331
332     const el = doc.body.children[0];
333     if (!(el instanceof HTMLElement)) {
334         return null;
335     }
336
337     const tag = el.tagName.toLowerCase();
338     const validTypes = ['embed', 'iframe', 'video', 'audio', 'object'];
339     if (!validTypes.includes(tag)) {
340         return null;
341     }
342
343     return domElementToNode(tag as MediaNodeTag, el);
344 }
345
346 const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov'];
347 const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm'];
348 const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', ''];
349
350 export function $createMediaNodeFromSrc(src: string): MediaNode {
351     let nodeTag: MediaNodeTag = 'iframe';
352     const srcEnd = src.split('?')[0].split('/').pop() || '';
353     const srcEndSplit = srcEnd.split('.');
354     const extension = (srcEndSplit.length > 1 ? srcEndSplit[srcEndSplit.length - 1] : '').toLowerCase();
355     if (videoExtensions.includes(extension)) {
356         nodeTag = 'video';
357     } else if (audioExtensions.includes(extension)) {
358         nodeTag = 'audio';
359     } else if (extension && !iframeExtensions.includes(extension)) {
360         nodeTag = 'embed';
361     }
362
363     return new MediaNode(nodeTag);
364 }
365
366 export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {
367     return node instanceof MediaNode;
368 }
369
370 export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean {
371     return node instanceof MediaNode && (node as MediaNode).getTag() === tag;
372 }