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