3 DOMConversionMap, DOMConversionOutput,
7 SerializedElementNode, Spread
9 import type {EditorConfig} from "lexical/LexicalEditor";
11 import {el} from "../utils/dom";
13 export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
14 export type MediaNodeSource = {
19 export type SerializedMediaNode = Spread<{
21 attributes: Record<string, string>;
22 sources: MediaNodeSource[];
23 }, SerializedElementNode>
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'
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];
42 function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
43 const node = $createMediaNode(tag);
45 const attributes: Record<string, string> = {};
46 for (const attribute of element.attributes) {
47 attributes[attribute.name] = attribute.value;
49 node.setAttributes(attributes);
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');
58 sources.push({ src, type });
62 node.setSources(sources);
68 export class MediaNode extends ElementNode {
70 __attributes: Record<string, string> = {};
71 __sources: MediaNodeSource[] = [];
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));
84 constructor(tag: MediaNodeTag, key?: string) {
89 setTag(tag: MediaNodeTag) {
90 const self = this.getWritable();
94 getTag(): MediaNodeTag {
95 const self = this.getLatest();
99 setAttributes(attributes: Record<string, string>) {
100 const self = this.getWritable();
101 self.__attributes = filterAttributes(attributes);
104 getAttributes(): Record<string, string> {
105 const self = this.getLatest();
106 return self.__attributes;
109 setSources(sources: MediaNodeSource[]) {
110 const self = this.getWritable();
111 self.__sources = sources;
114 getSources(): MediaNodeSource[] {
115 const self = this.getLatest();
116 return self.__sources;
119 setSrc(src: string): void {
120 const attrs = Object.assign({}, this.getAttributes());
121 if (this.__tag ==='object') {
126 this.setAttributes(attrs);
129 setWidthAndHeight(width: string, height: string): void {
130 const attrs = Object.assign(
132 this.getAttributes(),
135 this.setAttributes(attrs);
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));
142 return el(this.__tag, this.__attributes, sourceEls);
145 updateDOM(prevNode: unknown, dom: HTMLElement) {
149 static importDOM(): DOMConversionMap|null {
151 const buildConverter = (tag: MediaNodeTag) => {
152 return (node: HTMLElement): DOMConversion|null => {
154 conversion: (element: HTMLElement): DOMConversionOutput|null => {
156 node: domElementToNode(tag, element),
165 iframe: buildConverter('iframe'),
166 embed: buildConverter('embed'),
167 object: buildConverter('object'),
168 video: buildConverter('video'),
169 audio: buildConverter('audio'),
173 exportJSON(): SerializedMediaNode {
175 ...super.exportJSON(),
179 attributes: this.__attributes,
180 sources: this.__sources,
184 static importJSON(serializedNode: SerializedMediaNode): MediaNode {
185 return $createMediaNode(serializedNode.tag);
190 export function $createMediaNode(tag: MediaNodeTag) {
191 return new MediaNode(tag);
194 export function $createMediaNodeFromHtml(html: string): MediaNode | null {
195 const parser = new DOMParser();
196 const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
198 const el = doc.body.children[0];
203 const tag = el.tagName.toLowerCase();
204 const validTypes = ['embed', 'iframe', 'video', 'audio', 'object'];
205 if (!validTypes.includes(tag)) {
209 return domElementToNode(tag as MediaNodeTag, el);
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'];
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)) {
222 } else if (audioExtensions.includes(extension)) {
224 } else if (extension && !iframeExtensions.includes(extension)) {
228 return new MediaNode(nodeTag);
231 export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {
232 return node instanceof MediaNode;
235 export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean {
236 return node instanceof MediaNode && (node as MediaNode).getTag() === tag;