]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/nodes/media.ts
Lexical: Further fixes
[bookstack] / resources / js / wysiwyg / nodes / media.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,
14     SerializedCommonBlockNode,
15     setCommonBlockPropsFromElement,
16     updateElementWithCommonBlockProps
17 } from "./_common";
18 import {$selectSingleNode} from "../utils/selection";
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
84     static getType() {
85         return 'media';
86     }
87
88     static clone(node: MediaNode) {
89         const newNode = new MediaNode(node.__tag, node.__key);
90         newNode.__attributes = Object.assign({}, node.__attributes);
91         newNode.__sources = node.__sources.map(s => Object.assign({}, s));
92         newNode.__id = node.__id;
93         newNode.__alignment = node.__alignment;
94         return newNode;
95     }
96
97     constructor(tag: MediaNodeTag, key?: string) {
98         super(key);
99         this.__tag = tag;
100     }
101
102     setTag(tag: MediaNodeTag) {
103         const self = this.getWritable();
104         self.__tag = tag;
105     }
106
107     getTag(): MediaNodeTag {
108         const self = this.getLatest();
109         return self.__tag;
110     }
111
112     setAttributes(attributes: Record<string, string>) {
113         const self = this.getWritable();
114         self.__attributes = filterAttributes(attributes);
115     }
116
117     getAttributes(): Record<string, string> {
118         const self = this.getLatest();
119         return self.__attributes;
120     }
121
122     setSources(sources: MediaNodeSource[]) {
123         const self = this.getWritable();
124         self.__sources = sources;
125     }
126
127     getSources(): MediaNodeSource[] {
128         const self = this.getLatest();
129         return self.__sources;
130     }
131
132     setSrc(src: string): void {
133         const attrs = Object.assign({}, this.getAttributes());
134         if (this.__tag ==='object') {
135             attrs.data = src;
136         } else {
137             attrs.src = src;
138         }
139         this.setAttributes(attrs);
140     }
141
142     setWidthAndHeight(width: string, height: string): void {
143         const attrs = Object.assign(
144             {},
145             this.getAttributes(),
146             {width, height},
147         );
148         this.setAttributes(attrs);
149     }
150
151     setId(id: string) {
152         const self = this.getWritable();
153         self.__id = id;
154     }
155
156     getId(): string {
157         const self = this.getLatest();
158         return self.__id;
159     }
160
161     setAlignment(alignment: CommonBlockAlignment) {
162         const self = this.getWritable();
163         self.__alignment = alignment;
164     }
165
166     getAlignment(): CommonBlockAlignment {
167         const self = this.getLatest();
168         return self.__alignment;
169     }
170
171     setHeight(height: number): void {
172         if (!height) {
173             return;
174         }
175
176         const attrs = Object.assign({}, this.getAttributes(), {height});
177         this.setAttributes(attrs);
178     }
179
180     getHeight(): number {
181         const self = this.getLatest();
182         return sizeToPixels(self.__attributes.height || '0');
183     }
184
185     setWidth(width: number): void {
186         const attrs = Object.assign({}, this.getAttributes(), {width});
187         this.setAttributes(attrs);
188     }
189
190     getWidth(): number {
191         const self = this.getLatest();
192         return sizeToPixels(self.__attributes.width || '0');
193     }
194
195     isInline(): boolean {
196         return true;
197     }
198
199     createInnerDOM() {
200         const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
201         const sourceEls = sources.map(source => el('source', source));
202         const element = el(this.__tag, this.__attributes, sourceEls);
203         updateElementWithCommonBlockProps(element, this);
204         return element;
205     }
206
207     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
208         const media = this.createInnerDOM();
209         const wrap = el('span', {
210             class: media.className + ' editor-media-wrap',
211         }, [media]);
212
213         wrap.addEventListener('click', e => {
214             _editor.update(() => $selectSingleNode(this));
215         });
216
217         return wrap;
218     }
219
220     updateDOM(prevNode: MediaNode, dom: HTMLElement): boolean {
221         if (prevNode.__tag !== this.__tag) {
222             return true;
223         }
224
225         if (JSON.stringify(prevNode.__sources) !== JSON.stringify(this.__sources)) {
226             return true;
227         }
228
229         if (JSON.stringify(prevNode.__attributes) !== JSON.stringify(this.__attributes)) {
230             return true;
231         }
232
233         const mediaEl = dom.firstElementChild as HTMLElement;
234
235         if (prevNode.__id !== this.__id) {
236             setOrRemoveAttribute(mediaEl, 'id', this.__id);
237         }
238
239         if (prevNode.__alignment !== this.__alignment) {
240             if (prevNode.__alignment) {
241                 dom.classList.remove(`align-${prevNode.__alignment}`);
242                 mediaEl.classList.remove(`align-${prevNode.__alignment}`);
243             }
244             if (this.__alignment) {
245                 dom.classList.add(`align-${this.__alignment}`);
246                 mediaEl.classList.add(`align-${this.__alignment}`);
247             }
248         }
249
250         return false;
251     }
252
253     static importDOM(): DOMConversionMap|null {
254
255         const buildConverter = (tag: MediaNodeTag) => {
256             return (node: HTMLElement): DOMConversion|null => {
257                 return {
258                     conversion: (element: HTMLElement): DOMConversionOutput|null => {
259                         return {
260                             node: domElementToNode(tag, element),
261                         };
262                     },
263                     priority: 3,
264                 };
265             };
266         };
267
268         return {
269             iframe: buildConverter('iframe'),
270             embed: buildConverter('embed'),
271             object: buildConverter('object'),
272             video: buildConverter('video'),
273             audio: buildConverter('audio'),
274         };
275     }
276
277     exportDOM(editor: LexicalEditor): DOMExportOutput {
278         const element = this.createInnerDOM();
279         return { element };
280     }
281
282     exportJSON(): SerializedMediaNode {
283         return {
284             ...super.exportJSON(),
285             type: 'media',
286             version: 1,
287             id: this.__id,
288             alignment: this.__alignment,
289             tag: this.__tag,
290             attributes: this.__attributes,
291             sources: this.__sources,
292         };
293     }
294
295     static importJSON(serializedNode: SerializedMediaNode): MediaNode {
296         const node = $createMediaNode(serializedNode.tag);
297         node.setId(serializedNode.id);
298         node.setAlignment(serializedNode.alignment);
299         return node;
300     }
301
302 }
303
304 export function $createMediaNode(tag: MediaNodeTag) {
305     return new MediaNode(tag);
306 }
307
308 export function $createMediaNodeFromHtml(html: string): MediaNode | null {
309     const parser = new DOMParser();
310     const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
311
312     const el = doc.body.children[0];
313     if (!(el instanceof HTMLElement)) {
314         return null;
315     }
316
317     const tag = el.tagName.toLowerCase();
318     const validTypes = ['embed', 'iframe', 'video', 'audio', 'object'];
319     if (!validTypes.includes(tag)) {
320         return null;
321     }
322
323     return domElementToNode(tag as MediaNodeTag, el);
324 }
325
326 const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov'];
327 const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm'];
328 const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx'];
329
330 export function $createMediaNodeFromSrc(src: string): MediaNode {
331     let nodeTag: MediaNodeTag = 'iframe';
332     const srcEnd = src.split('?')[0].split('/').pop() || '';
333     const extension = (srcEnd.split('.').pop() || '').toLowerCase();
334     if (videoExtensions.includes(extension)) {
335         nodeTag = 'video';
336     } else if (audioExtensions.includes(extension)) {
337         nodeTag = 'audio';
338     } else if (extension && !iframeExtensions.includes(extension)) {
339         nodeTag = 'embed';
340     }
341
342     return new MediaNode(nodeTag);
343 }
344
345 export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {
346     return node instanceof MediaNode;
347 }
348
349 export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean {
350     return node instanceof MediaNode && (node as MediaNode).getTag() === tag;
351 }