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