]> BookStack Code Mirror - bookstack/blob - resources/js/wysiwyg/nodes/media.ts
73208cb2e433dc00a45c663db2b7373e92791807
[bookstack] / resources / js / wysiwyg / nodes / media.ts
1 import {
2     DOMConversion,
3     DOMConversionMap, DOMConversionOutput,
4     ElementNode,
5     LexicalEditor,
6     LexicalNode,
7     SerializedElementNode, Spread
8 } from 'lexical';
9 import type {EditorConfig} from "lexical/LexicalEditor";
10
11 import {el} from "../utils/dom";
12
13 export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
14 export type MediaNodeSource = {
15     src: string;
16     type: string;
17 };
18
19 export type SerializedMediaNode = Spread<{
20     tag: MediaNodeTag;
21     attributes: Record<string, string>;
22     sources: MediaNodeSource[];
23 }, SerializedElementNode>
24
25 const attributeAllowList = [
26     'id', 'width', 'height', 'style', 'title', 'name',
27     'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
28     'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
29     'muted', 'playsinline', 'poster', 'preload'
30 ];
31
32 function filterAttributes(attributes: Record<string, string>): Record<string, string> {
33     const filtered: Record<string, string> = {};
34     for (const key of Object.keys(attributes)) {
35         if (attributeAllowList.includes(key)) {
36             filtered[key] = attributes[key];
37         }
38     }
39     return filtered;
40 }
41
42 function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
43     const node = $createMediaNode(tag);
44
45     const attributes: Record<string, string> = {};
46     for (const attribute of element.attributes) {
47         attributes[attribute.name] = attribute.value;
48     }
49     node.setAttributes(attributes);
50
51     const sources: MediaNodeSource[] = [];
52     if (tag === 'video' || tag === 'audio') {
53         for (const child of element.children) {
54             if (child.tagName === 'SOURCE') {
55                 const src = child.getAttribute('src');
56                 const type = child.getAttribute('type');
57                 if (src && type) {
58                     sources.push({ src, type });
59                 }
60             }
61         }
62         node.setSources(sources);
63     }
64
65     return node;
66 }
67
68 export class MediaNode extends ElementNode {
69     __tag: MediaNodeTag;
70     __attributes: Record<string, string> = {};
71     __sources: MediaNodeSource[] = [];
72
73     static getType() {
74         return 'media';
75     }
76
77     static clone(node: MediaNode) {
78         const newNode = new MediaNode(node.__tag, node.__key);
79         newNode.__attributes = Object.assign({}, node.__attributes);
80         newNode.__sources = node.__sources.map(s => Object.assign({}, s));
81         return newNode;
82     }
83
84     constructor(tag: MediaNodeTag, key?: string) {
85         super(key);
86         this.__tag = tag;
87     }
88
89     setTag(tag: MediaNodeTag) {
90         const self = this.getWritable();
91         self.__tag = tag;
92     }
93
94     getTag(): MediaNodeTag {
95         const self = this.getLatest();
96         return self.__tag;
97     }
98
99     setAttributes(attributes: Record<string, string>) {
100         const self = this.getWritable();
101         self.__attributes = filterAttributes(attributes);
102     }
103
104     getAttributes(): Record<string, string> {
105         const self = this.getLatest();
106         return self.__attributes;
107     }
108
109     setSources(sources: MediaNodeSource[]) {
110         const self = this.getWritable();
111         self.__sources = sources;
112     }
113
114     getSources(): MediaNodeSource[] {
115         const self = this.getLatest();
116         return self.__sources;
117     }
118
119     setSrc(src: string): void {
120         const attrs = Object.assign({}, this.getAttributes());
121         if (this.__tag ==='object') {
122             attrs.data = src;
123         } else {
124             attrs.src = src;
125         }
126         this.setAttributes(attrs);
127     }
128
129     setWidthAndHeight(width: string, height: string): void {
130         const attrs = Object.assign(
131             {},
132             this.getAttributes(),
133             {width, height},
134         );
135         this.setAttributes(attrs);
136     }
137
138     createDOM(_config: EditorConfig, _editor: LexicalEditor) {
139         const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
140         const sourceEls = sources.map(source => el('source', source));
141
142         return el(this.__tag, this.__attributes, sourceEls);
143     }
144
145     updateDOM(prevNode: unknown, dom: HTMLElement) {
146         return true;
147     }
148
149     static importDOM(): DOMConversionMap|null {
150
151         const buildConverter = (tag: MediaNodeTag) => {
152             return (node: HTMLElement): DOMConversion|null => {
153                 return {
154                     conversion: (element: HTMLElement): DOMConversionOutput|null => {
155                         return {
156                             node: domElementToNode(tag, element),
157                         };
158                     },
159                     priority: 3,
160                 };
161             };
162         };
163
164         return {
165             iframe: buildConverter('iframe'),
166             embed: buildConverter('embed'),
167             object: buildConverter('object'),
168             video: buildConverter('video'),
169             audio: buildConverter('audio'),
170         };
171     }
172
173     exportJSON(): SerializedMediaNode {
174         return {
175             ...super.exportJSON(),
176             type: 'media',
177             version: 1,
178             tag: this.__tag,
179             attributes: this.__attributes,
180             sources: this.__sources,
181         };
182     }
183
184     static importJSON(serializedNode: SerializedMediaNode): MediaNode {
185         return $createMediaNode(serializedNode.tag);
186     }
187
188 }
189
190 export function $createMediaNode(tag: MediaNodeTag) {
191     return new MediaNode(tag);
192 }
193
194 export function $createMediaNodeFromHtml(html: string): MediaNode | null {
195     const parser = new DOMParser();
196     const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
197
198     const el = doc.body.children[0];
199     if (!el) {
200         return null;
201     }
202
203     const tag = el.tagName.toLowerCase();
204     const validTypes = ['embed', 'iframe', 'video', 'audio', 'object'];
205     if (!validTypes.includes(tag)) {
206         return null;
207     }
208
209     return domElementToNode(tag as MediaNodeTag, el);
210 }
211
212 const videoExtensions = ['mp4', 'mpeg', 'm4v', 'm4p', 'mov'];
213 const audioExtensions = ['3gp', 'aac', 'flac', 'mp3', 'm4a', 'ogg', 'wav', 'webm'];
214 const iframeExtensions = ['html', 'htm', 'php', 'asp', 'aspx'];
215
216 export function $createMediaNodeFromSrc(src: string): MediaNode {
217     let nodeTag: MediaNodeTag = 'iframe';
218     const srcEnd = src.split('?')[0].split('/').pop() || '';
219     const extension = (srcEnd.split('.').pop() || '').toLowerCase();
220     if (videoExtensions.includes(extension)) {
221         nodeTag = 'video';
222     } else if (audioExtensions.includes(extension)) {
223         nodeTag = 'audio';
224     } else if (extension && !iframeExtensions.includes(extension)) {
225         nodeTag = 'embed';
226     }
227
228     return new MediaNode(nodeTag);
229 }
230
231 export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {
232     return node instanceof MediaNode;
233 }
234
235 export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean {
236     return node instanceof MediaNode && (node as MediaNode).getTag() === tag;
237 }