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