]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/lexical/rich-text/LexicalMediaNode.ts
Updated translations with latest Crowdin changes (#5695)
[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, styleMapToStyleString, styleStringToStyleMap} from "../../utils/dom";
12 import {
13     CommonBlockAlignment, deserializeCommonBlockNode,
14     setCommonBlockPropsFromElement,
15     updateElementWithCommonBlockProps
16 } from "lexical/nodes/common";
17 import {SerializedCommonBlockNode} from "lexical/nodes/CommonBlockNode";
18
19 export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
20 export type MediaNodeSource = {
21     src: string;
22     type: string;
23 };
24
25 export type SerializedMediaNode = Spread<{
26     tag: MediaNodeTag;
27     attributes: Record<string, string>;
28     sources: MediaNodeSource[];
29 }, SerializedCommonBlockNode>
30
31 const attributeAllowList = [
32     'width', 'height', 'style', 'title', 'name',
33     'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
34     'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
35     'muted', 'playsinline', 'poster', 'preload'
36 ];
37
38 function filterAttributes(attributes: Record<string, string>): Record<string, string> {
39     const filtered: Record<string, string> = {};
40     for (const key of Object.keys(attributes)) {
41         if (attributeAllowList.includes(key)) {
42             filtered[key] = attributes[key];
43         }
44     }
45     return filtered;
46 }
47
48 function removeStyleFromAttributes(attributes: Record<string, string>, styleName: string): Record<string, string> {
49     const attrCopy = Object.assign({}, attributes);
50     if (!attributes.style) {
51         return attrCopy;
52     }
53
54     const map = styleStringToStyleMap(attributes.style);
55     map.delete(styleName);
56
57     attrCopy.style = styleMapToStyleString(map);
58     return attrCopy;
59 }
60
61 function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
62     const node = $createMediaNode(tag);
63
64     const attributes: Record<string, string> = {};
65     for (const attribute of element.attributes) {
66         attributes[attribute.name] = attribute.value;
67     }
68     node.setAttributes(attributes);
69
70     const sources: MediaNodeSource[] = [];
71     if (tag === 'video' || tag === 'audio') {
72         for (const child of element.children) {
73             if (child.tagName === 'SOURCE') {
74                 const src = child.getAttribute('src');
75                 const type = child.getAttribute('type');
76                 if (src && type) {
77                     sources.push({ src, type });
78                 }
79             }
80         }
81         node.setSources(sources);
82     }
83
84     setCommonBlockPropsFromElement(element, node);
85
86     return node;
87 }
88
89 export class MediaNode extends ElementNode {
90     __id: string = '';
91     __alignment: CommonBlockAlignment = '';
92     __tag: MediaNodeTag;
93     __attributes: Record<string, string> = {};
94     __sources: MediaNodeSource[] = [];
95     __inset: number = 0;
96
97     static getType() {
98         return 'media';
99     }
100
101     static clone(node: MediaNode) {
102         const newNode = new MediaNode(node.__tag, node.__key);
103         newNode.__attributes = Object.assign({}, node.__attributes);
104         newNode.__sources = node.__sources.map(s => Object.assign({}, s));
105         newNode.__id = node.__id;
106         newNode.__alignment = node.__alignment;
107         newNode.__inset = node.__inset;
108         return newNode;
109     }
110
111     constructor(tag: MediaNodeTag, key?: string) {
112         super(key);
113         this.__tag = tag;
114     }
115
116     setTag(tag: MediaNodeTag) {
117         const self = this.getWritable();
118         self.__tag = tag;
119     }
120
121     getTag(): MediaNodeTag {
122         const self = this.getLatest();
123         return self.__tag;
124     }
125
126     setAttributes(attributes: Record<string, string>) {
127         const self = this.getWritable();
128         self.__attributes = filterAttributes(attributes);
129     }
130
131     getAttributes(): Record<string, string> {
132         const self = this.getLatest();
133         return Object.assign({}, self.__attributes);
134     }
135
136     setSources(sources: MediaNodeSource[]) {
137         const self = this.getWritable();
138         self.__sources = sources;
139     }
140
141     getSources(): MediaNodeSource[] {
142         const self = this.getLatest();
143         return self.__sources.map(s => Object.assign({}, s))
144     }
145
146     setSrc(src: string): void {
147         const attrs = this.getAttributes();
148         const sources = this.getSources();
149
150         if (this.__tag ==='object') {
151             attrs.data = src;
152         } if (this.__tag === 'video' && sources.length > 0) {
153             sources[0].src = src;
154             delete attrs.src;
155             if (sources.length > 1) {
156                 sources.splice(1, sources.length - 1);
157             }
158             this.setSources(sources);
159         } else {
160             attrs.src = src;
161         }
162
163         this.setAttributes(attrs);
164     }
165
166     setWidthAndHeight(width: string, height: string): void {
167         let attrs: Record<string, string> = Object.assign(
168             this.getAttributes(),
169             {width, height},
170         );
171
172         attrs = removeStyleFromAttributes(attrs, 'width');
173         attrs = removeStyleFromAttributes(attrs, 'height');
174         this.setAttributes(attrs);
175     }
176
177     setId(id: string) {
178         const self = this.getWritable();
179         self.__id = id;
180     }
181
182     getId(): string {
183         const self = this.getLatest();
184         return self.__id;
185     }
186
187     setAlignment(alignment: CommonBlockAlignment) {
188         const self = this.getWritable();
189         self.__alignment = alignment;
190     }
191
192     getAlignment(): CommonBlockAlignment {
193         const self = this.getLatest();
194         return self.__alignment;
195     }
196
197     setInset(size: number) {
198         const self = this.getWritable();
199         self.__inset = size;
200     }
201
202     getInset(): number {
203         const self = this.getLatest();
204         return self.__inset;
205     }
206
207     setHeight(height: number): void {
208         if (!height) {
209             return;
210         }
211
212         const attrs = Object.assign(this.getAttributes(), {height});
213         this.setAttributes(removeStyleFromAttributes(attrs, 'height'));
214     }
215
216     getHeight(): number {
217         const self = this.getLatest();
218         return sizeToPixels(self.__attributes.height || '0');
219     }
220
221     setWidth(width: number): void {
222         const existingAttrs = this.getAttributes();
223         const attrs: Record<string, string> = Object.assign(existingAttrs, {width});
224         this.setAttributes(removeStyleFromAttributes(attrs, 'width'));
225     }
226
227     getWidth(): number {
228         const self = this.getLatest();
229         return sizeToPixels(self.__attributes.width || '0');
230     }
231
232     isInline(): boolean {
233         return true;
234     }
235
236     isParentRequired(): boolean {
237         return true;
238     }
239
240     createInnerDOM() {
241         const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
242         const sourceEls = sources.map(source => el('source', source));
243         const element = el(this.__tag, this.__attributes, sourceEls);
244         updateElementWithCommonBlockProps(element, this);
245         return element;
246     }
247
248     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
249         const media = this.createInnerDOM();
250         return el('span', {
251             class: media.className + ' editor-media-wrap',
252         }, [media]);
253     }
254
255     updateDOM(prevNode: MediaNode, dom: HTMLElement): boolean {
256         if (prevNode.__tag !== this.__tag) {
257             return true;
258         }
259
260         if (JSON.stringify(prevNode.__sources) !== JSON.stringify(this.__sources)) {
261             return true;
262         }
263
264         if (JSON.stringify(prevNode.__attributes) !== JSON.stringify(this.__attributes)) {
265             return true;
266         }
267
268         const mediaEl = dom.firstElementChild as HTMLElement;
269
270         if (prevNode.__id !== this.__id) {
271             setOrRemoveAttribute(mediaEl, 'id', this.__id);
272         }
273
274         if (prevNode.__alignment !== this.__alignment) {
275             if (prevNode.__alignment) {
276                 dom.classList.remove(`align-${prevNode.__alignment}`);
277                 mediaEl.classList.remove(`align-${prevNode.__alignment}`);
278             }
279             if (this.__alignment) {
280                 dom.classList.add(`align-${this.__alignment}`);
281                 mediaEl.classList.add(`align-${this.__alignment}`);
282             }
283         }
284
285         if (prevNode.__inset !== this.__inset) {
286             dom.style.paddingLeft = `${this.__inset}px`;
287         }
288
289         return false;
290     }
291
292     static importDOM(): DOMConversionMap|null {
293
294         const buildConverter = (tag: MediaNodeTag) => {
295             return (node: HTMLElement): DOMConversion|null => {
296                 return {
297                     conversion: (element: HTMLElement): DOMConversionOutput|null => {
298                         return {
299                             node: domElementToNode(tag, element),
300                         };
301                     },
302                     priority: 3,
303                 };
304             };
305         };
306
307         return {
308             iframe: buildConverter('iframe'),
309             embed: buildConverter('embed'),
310             object: buildConverter('object'),
311             video: buildConverter('video'),
312             audio: buildConverter('audio'),
313         };
314     }
315
316     exportDOM(editor: LexicalEditor): DOMExportOutput {
317         const element = this.createInnerDOM();
318         return { element };
319     }
320
321     exportJSON(): SerializedMediaNode {
322         return {
323             ...super.exportJSON(),
324             type: 'media',
325             version: 1,
326             id: this.__id,
327             alignment: this.__alignment,
328             inset: this.__inset,
329             tag: this.__tag,
330             attributes: this.__attributes,
331             sources: this.__sources,
332         };
333     }
334
335     static importJSON(serializedNode: SerializedMediaNode): MediaNode {
336         const node = $createMediaNode(serializedNode.tag);
337         deserializeCommonBlockNode(serializedNode, node);
338         return node;
339     }
340
341 }
342
343 export function $createMediaNode(tag: MediaNodeTag) {
344     return new MediaNode(tag);
345 }
346
347 export function $createMediaNodeFromHtml(html: string): MediaNode | null {
348     const parser = new DOMParser();
349     const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
350
351     const el = doc.body.children[0];
352     if (!(el instanceof HTMLElement)) {
353         return null;
354     }
355
356     const tag = el.tagName.toLowerCase();
357     const validTypes = ['embed', 'iframe', 'video', 'audio', 'object'];
358     if (!validTypes.includes(tag)) {
359         return null;
360     }
361
362     return domElementToNode(tag as MediaNodeTag, el);
363 }
364
365 interface UrlPattern {
366     readonly regex: RegExp;
367     readonly w: number;
368     readonly h: number;
369     readonly url: string;
370 }
371
372 /**
373  * These patterns originate from the tinymce/tinymce project.
374  * https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts
375  * License: MIT Copyright (c) 2022 Ephox Corporation DBA Tiny Technologies, Inc.
376  * License Link: https://github.com/tinymce/tinymce/blob/584a150679669859a528828e5d2910a083b1d911/LICENSE.TXT
377  */
378 const urlPatterns: UrlPattern[] = [
379     {
380         regex: /.*?youtu\.be\/([\w\-_\?&=.]+)/i,
381         w: 560, h: 314,
382         url: 'https://www.youtube.com/embed/$1',
383     },
384     {
385         regex: /.*youtube\.com(.+)v=([^&]+)(&([a-z0-9&=\-_]+))?.*/i,
386         w: 560, h: 314,
387         url: 'https://www.youtube.com/embed/$2?$4',
388     },
389     {
390         regex: /.*youtube.com\/embed\/([a-z0-9\?&=\-_]+).*/i,
391         w: 560, h: 314,
392         url: 'https://www.youtube.com/embed/$1',
393     },
394 ];
395
396 const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov'];
397 const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm'];
398 const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx', ''];
399
400 export function $createMediaNodeFromSrc(src: string): MediaNode {
401
402     for (const pattern of urlPatterns) {
403         const match = src.match(pattern.regex);
404         if (match) {
405             const newSrc = src.replace(pattern.regex, pattern.url);
406             const node = new MediaNode('iframe');
407             node.setSrc(newSrc);
408             node.setHeight(pattern.h);
409             node.setWidth(pattern.w);
410             return node;
411         }
412     }
413
414     let nodeTag: MediaNodeTag = 'iframe';
415     const srcEnd = src.split('?')[0].split('/').pop() || '';
416     const srcEndSplit = srcEnd.split('.');
417     const extension = (srcEndSplit.length > 1 ? srcEndSplit[srcEndSplit.length - 1] : '').toLowerCase();
418     if (videoExtensions.includes(extension)) {
419         nodeTag = 'video';
420     } else if (audioExtensions.includes(extension)) {
421         nodeTag = 'audio';
422     } else if (extension && !iframeExtensions.includes(extension)) {
423         nodeTag = 'embed';
424     }
425
426     const node = new MediaNode(nodeTag);
427     node.setSrc(src);
428     return node;
429 }
430
431 export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {
432     return node instanceof MediaNode;
433 }
434
435 export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean {
436     return node instanceof MediaNode && (node as MediaNode).getTag() === tag;
437 }