--- /dev/null
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960"><path d="m380-300 280-180-280-180v360ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm0-80h560v-560H200v560Zm0-560v560-560Z"/></svg>
\ No newline at end of file
import {HeadingNode, QuoteNode} from '@lexical/rich-text';
import {CalloutNode} from './callout';
import {
- $getNodeByKey,
ElementNode,
KlassConstructor,
- LexicalEditor,
LexicalNode,
LexicalNodeReplacement, NodeMutation,
ParagraphNode
import {HorizontalRuleNode} from "./horizontal-rule";
import {CodeBlockNode} from "./code-block";
import {DiagramNode} from "./diagram";
-import {EditorUIManager} from "../ui/framework/manager";
import {EditorUiContext} from "../ui/framework/core";
+import {MediaNode} from "./media";
/**
* Load the nodes for lexical.
DetailsNode, SummaryNode,
CodeBlockNode,
DiagramNode,
+ MediaNode,
CustomParagraphNode,
LinkNode,
{
--- /dev/null
+import {
+ DOMConversion,
+ DOMConversionMap, DOMConversionOutput,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ SerializedElementNode, Spread
+} from 'lexical';
+import type {EditorConfig} from "lexical/LexicalEditor";
+import {el} from "../helpers";
+
+export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
+export type MediaNodeSource = {
+ src: string;
+ type: string;
+};
+
+export type SerializedMediaNode = Spread<{
+ tag: MediaNodeTag;
+ attributes: Record<string, string>;
+ sources: MediaNodeSource[];
+}, SerializedElementNode>
+
+const attributeAllowList = [
+ 'id', 'width', 'height', 'style', 'title', 'name',
+ 'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
+ 'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
+ 'muted', 'playsinline', 'poster', 'preload'
+];
+
+function filterAttributes(attributes: Record<string, string>): Record<string, string> {
+ const filtered: Record<string, string> = {};
+ for (const key in Object.keys(attributes)) {
+ if (attributeAllowList.includes(key)) {
+ filtered[key] = attributes[key];
+ }
+ }
+ return filtered;
+}
+
+function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
+ const node = $createMediaNode(tag);
+
+ const attributes: Record<string, string> = {};
+ for (const attribute of element.attributes) {
+ attributes[attribute.name] = attribute.value;
+ }
+ node.setAttributes(attributes);
+
+ const sources: MediaNodeSource[] = [];
+ if (tag === 'video' || tag === 'audio') {
+ for (const child of element.children) {
+ if (child.tagName === 'SOURCE') {
+ const src = child.getAttribute('src');
+ const type = child.getAttribute('type');
+ if (src && type) {
+ sources.push({ src, type });
+ }
+ }
+ }
+ node.setSources(sources);
+ }
+
+ return node;
+}
+
+export class MediaNode extends ElementNode {
+
+ __tag: MediaNodeTag;
+ __attributes: Record<string, string> = {};
+ __sources: MediaNodeSource[] = [];
+
+ static getType() {
+ return 'media';
+ }
+
+ static clone(node: MediaNode) {
+ return new MediaNode(node.__tag, node.__key);
+ }
+
+ constructor(tag: MediaNodeTag, key?: string) {
+ super(key);
+ this.__tag = tag;
+ }
+
+ setTag(tag: MediaNodeTag) {
+ const self = this.getWritable();
+ self.__tag = tag;
+ }
+
+ getTag(): MediaNodeTag {
+ const self = this.getLatest();
+ return self.__tag;
+ }
+
+ setAttributes(attributes: Record<string, string>) {
+ const self = this.getWritable();
+ self.__attributes = filterAttributes(attributes);
+ }
+
+ getAttributes(): Record<string, string> {
+ const self = this.getLatest();
+ return self.__attributes;
+ }
+
+ setSources(sources: MediaNodeSource[]) {
+ const self = this.getWritable();
+ self.__sources = sources;
+ }
+
+ getSources(): MediaNodeSource[] {
+ const self = this.getLatest();
+ return self.__sources;
+ }
+
+ setSrc(src: string): void {
+ const attrs = Object.assign({}, this.getAttributes());
+ if (this.__tag ==='object') {
+ attrs.data = src;
+ } else {
+ attrs.src = src;
+ }
+ this.setAttributes(attrs);
+ }
+
+ setWidthAndHeight(width: string, height: string): void {
+ const attrs = Object.assign(
+ {},
+ this.getAttributes(),
+ {width, height},
+ );
+ this.setAttributes(attrs);
+ }
+
+ createDOM(_config: EditorConfig, _editor: LexicalEditor) {
+ const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
+ const sourceEls = sources.map(source => el('source', source));
+
+ return el(this.__tag, this.__attributes, sourceEls);
+ }
+
+ updateDOM(prevNode: unknown, dom: HTMLElement) {
+ return true;
+ }
+
+ static importDOM(): DOMConversionMap|null {
+
+ const buildConverter = (tag: MediaNodeTag) => {
+ return (node: HTMLElement): DOMConversion|null => {
+ return {
+ conversion: (element: HTMLElement): DOMConversionOutput|null => {
+ return {
+ node: domElementToNode(tag, element),
+ };
+ },
+ priority: 3,
+ };
+ };
+ };
+
+ return {
+ iframe: buildConverter('iframe'),
+ embed: buildConverter('embed'),
+ object: buildConverter('object'),
+ video: buildConverter('video'),
+ audio: buildConverter('audio'),
+ };
+ }
+
+ exportJSON(): SerializedMediaNode {
+ return {
+ ...super.exportJSON(),
+ type: 'callout',
+ version: 1,
+ tag: this.__tag,
+ attributes: this.__attributes,
+ sources: this.__sources,
+ };
+ }
+
+ static importJSON(serializedNode: SerializedMediaNode): MediaNode {
+ return $createMediaNode(serializedNode.tag);
+ }
+
+}
+
+export function $createMediaNode(tag: MediaNodeTag) {
+ return new MediaNode(tag);
+}
+
+export function $createMediaNodeFromHtml(html: string): MediaNode | null {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
+
+ const el = doc.body.children[0];
+ if (!el) {
+ return null;
+ }
+
+ const tag = el.tagName.toLowerCase();
+ const validTypes = ['embed', 'iframe', 'video', 'audio', 'object'];
+ if (!validTypes.includes(tag)) {
+ return null;
+ }
+
+ return domElementToNode(tag as MediaNodeTag, el);
+}
+
+export function $isMediaNode(node: LexicalNode | null | undefined) {
+ return node instanceof MediaNode;
+}
+
+export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag) {
+ return node instanceof MediaNode && (node as MediaNode).getTag() === tag;
+}
\ No newline at end of file
## In progress
-- Add Type: Video/media/embed
- - TinyMce media embed supported:
- - iframe
- - embed
- - object
- - video - Can take sources
- - audio - Can take sources
- - Pretty much all attributes look like they were supported.
- - Core old logic seen here: https://github.com/tinymce/tinymce/blob/main/modules/tinymce/src/plugins/media/main/ts/core/DataToHtml.ts
- - Copy/store attributes on node based on allow list?
- - width, height, src, controls, etc... Take valid values from MDN
+- Finish initial media node & form integration
## Main Todo
- Image gallery integration for insert
- Image gallery integration for form
- Drawing gallery integration
+- Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts)
## Bugs
import diagramIcon from "@icons/editor/diagram.svg";
import {$createDiagramNode, $isDiagramNode, $openDrawingEditorForNode, DiagramNode} from "../../../nodes/diagram";
import detailsIcon from "@icons/editor/details.svg";
+import mediaIcon from "@icons/editor/media.svg";
import {$createDetailsNode, $isDetailsNode} from "../../../nodes/details";
+import {$isMediaNode, MediaNode} from "../../../nodes/media";
export const link: EditorButtonDefinition = {
label: 'Insert/edit link',
const linkModal = context.manager.createModal('link');
context.editor.getEditorState().read(() => {
const selection = $getSelection();
- const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null;
+ const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null;
let formDefaults = {};
if (selectedLink) {
linkModal.show(formDefaults);
});
},
- isActive(selection: BaseSelection|null): boolean {
+ isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isLinkNode);
}
};
action(context: EditorUiContext) {
context.editor.update(() => {
const selection = context.lastSelection;
- const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode|null;
+ const selectedLink = $getNodeFromSelection(selection, $isLinkNode) as LinkNode | null;
const selectionPoints = selection?.getStartEndPoints();
if (selectedLink) {
}
});
},
- isActive(selection: BaseSelection|null): boolean {
+ isActive(selection: BaseSelection | null): boolean {
return false;
}
};
-
export const image: EditorButtonDefinition = {
label: 'Insert/Edit Image',
icon: imageIcon,
action(context: EditorUiContext) {
const imageModal = context.manager.createModal('image');
const selection = context.lastSelection;
- const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode|null;
+ const selectedImage = $getNodeFromSelection(selection, $isImageNode) as ImageNode | null;
context.editor.getEditorState().read(() => {
let formDefaults = {};
imageModal.show(formDefaults);
});
},
- isActive(selection: BaseSelection|null): boolean {
+ isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isImageNode);
}
};
$insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false);
});
},
- isActive(selection: BaseSelection|null): boolean {
+ isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isHorizontalRuleNode);
}
};
action(context: EditorUiContext) {
context.editor.getEditorState().read(() => {
const selection = $getSelection();
- const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode|null);
+ const codeBlock = $getNodeFromSelection(context.lastSelection, $isCodeBlockNode) as (CodeBlockNode | null);
if (codeBlock === null) {
context.editor.update(() => {
const codeBlock = $createCodeBlockNode();
}
});
},
- isActive(selection: BaseSelection|null): boolean {
+ isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isCodeBlockNode);
}
};
action(context: EditorUiContext) {
context.editor.getEditorState().read(() => {
const selection = $getSelection();
- const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode|null);
+ const diagramNode = $getNodeFromSelection(context.lastSelection, $isDiagramNode) as (DiagramNode | null);
if (diagramNode === null) {
context.editor.update(() => {
const diagram = $createDiagramNode();
}
});
},
- isActive(selection: BaseSelection|null): boolean {
+ isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isDiagramNode);
}
};
+export const media: EditorButtonDefinition = {
+ label: 'Insert/edit Media',
+ icon: mediaIcon,
+ action(context: EditorUiContext) {
+ const mediaModal = context.manager.createModal('media');
+
+ context.editor.getEditorState().read(() => {
+ const selection = $getSelection();
+ const selectedNode = $getNodeFromSelection(selection, $isMediaNode) as MediaNode | null;
+
+ let formDefaults = {};
+ if (selectedNode) {
+ const nodeAttrs = selectedNode.getAttributes();
+ formDefaults = {
+ src: nodeAttrs.src || nodeAttrs.data || '',
+ width: nodeAttrs.width,
+ height: nodeAttrs.height,
+ embed: '',
+ }
+ }
+
+ mediaModal.show(formDefaults);
+ });
+ },
+ isActive(selection: BaseSelection | null): boolean {
+ return $selectionContainsNodeType(selection, $isMediaNode);
+ }
+};
export const details: EditorButtonDefinition = {
label: 'Insert collapsible block',
}
});
},
- isActive(selection: BaseSelection|null): boolean {
+ isActive(selection: BaseSelection | null): boolean {
return $selectionContainsNodeType(selection, $isDetailsNode);
}
}
\ No newline at end of file
import {$createTextNode, $getSelection} from "lexical";
import {$createImageNode} from "../../nodes/image";
import {setEditorContentFromHtml} from "../../actions";
+import {$createMediaNodeFromHtml} from "../../nodes/media";
export const link: EditorFormDefinition = {
],
};
+export const media: EditorFormDefinition = {
+ submitText: 'Save',
+ action(formData, context: EditorUiContext) {
+
+ // TODO - Get media from selection
+
+ const embedCode = (formData.get('embed') || '').toString().trim();
+ if (embedCode) {
+ context.editor.update(() => {
+ const node = $createMediaNodeFromHtml(embedCode);
+ // TODO - Replace existing or insert new
+ });
+
+ return true;
+ }
+
+ const src = (formData.get('src') || '').toString().trim();
+ const height = (formData.get('height') || '').toString().trim();
+ const width = (formData.get('width') || '').toString().trim();
+
+ // TODO - Update existing or insert new
+
+ return true;
+ },
+ fields: [
+ {
+ label: 'Source',
+ name: 'src',
+ type: 'text',
+ },
+ {
+ label: 'Width',
+ name: 'width',
+ type: 'text',
+ },
+ {
+ label: 'Height',
+ name: 'height',
+ type: 'text',
+ },
+ // TODO - Tabbed interface to separate this option
+ {
+ label: 'Paste your embed code below:',
+ name: 'embed',
+ type: 'textarea',
+ },
+ ],
+};
+
export const source: EditorFormDefinition = {
submitText: 'Save',
action(formData, context: EditorUiContext) {
getMainEditorFullToolbar, getTableToolbarContent
} from "./toolbars";
import {EditorUIManager} from "./framework/manager";
-import {image as imageFormDefinition, link as linkFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions";
+import {image as imageFormDefinition, link as linkFormDefinition, media as mediaFormDefinition, source as sourceFormDefinition} from "./defaults/form-definitions";
import {ImageDecorator} from "./decorators/image";
import {EditorUiContext} from "./framework/core";
import {CodeBlockDecorator} from "./decorators/code-block";
title: 'Insert/Edit Image',
form: imageFormDefinition
});
+ manager.registerModal('media', {
+ title: 'Insert/Edit Media',
+ form: mediaFormDefinition,
+ });
manager.registerModal('source', {
title: 'Source code',
form: sourceFormDefinition,
editCodeBlock,
horizontalRule,
image,
- link,
+ link, media,
unlink
} from "./defaults/buttons/objects";
]),
// Insert types
- new EditorOverflowContainer(6, [
+ new EditorOverflowContainer(8, [
new EditorButton(link),
new EditorDropdownButton(table, false, [
new EditorTableCreator(),
new EditorButton(horizontalRule),
new EditorButton(codeBlock),
new EditorButton(diagram),
+ new EditorButton(media),
new EditorButton(details),
]),