From f284d31861057dfdb575400b965e99e02cbf8cf7 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 25 Jul 2024 16:25:08 +0100 Subject: [PATCH] Lexical: Started media node support --- resources/icons/editor/media.svg | 1 + resources/js/wysiwyg/nodes/index.ts | 5 +- resources/js/wysiwyg/nodes/media.ts | 215 ++++++++++++++++++ resources/js/wysiwyg/todo.md | 13 +- .../js/wysiwyg/ui/defaults/buttons/objects.ts | 55 +++-- .../wysiwyg/ui/defaults/form-definitions.ts | 50 ++++ resources/js/wysiwyg/ui/index.ts | 6 +- resources/js/wysiwyg/ui/toolbars.ts | 5 +- 8 files changed, 320 insertions(+), 30 deletions(-) create mode 100644 resources/icons/editor/media.svg create mode 100644 resources/js/wysiwyg/nodes/media.ts diff --git a/resources/icons/editor/media.svg b/resources/icons/editor/media.svg new file mode 100644 index 000000000..0c4feea4c --- /dev/null +++ b/resources/icons/editor/media.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/js/wysiwyg/nodes/index.ts b/resources/js/wysiwyg/nodes/index.ts index a2c739576..669ffe6dd 100644 --- a/resources/js/wysiwyg/nodes/index.ts +++ b/resources/js/wysiwyg/nodes/index.ts @@ -1,10 +1,8 @@ import {HeadingNode, QuoteNode} from '@lexical/rich-text'; import {CalloutNode} from './callout'; import { - $getNodeByKey, ElementNode, KlassConstructor, - LexicalEditor, LexicalNode, LexicalNodeReplacement, NodeMutation, ParagraphNode @@ -19,8 +17,8 @@ import {CustomTableNode} from "./custom-table"; 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. @@ -40,6 +38,7 @@ export function getNodesForPageEditor(): (KlassConstructor | DetailsNode, SummaryNode, CodeBlockNode, DiagramNode, + MediaNode, CustomParagraphNode, LinkNode, { diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts new file mode 100644 index 000000000..e0c1b3141 --- /dev/null +++ b/resources/js/wysiwyg/nodes/media.ts @@ -0,0 +1,215 @@ +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; + 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): Record { + const filtered: Record = {}; + 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 = {}; + 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 = {}; + __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) { + const self = this.getWritable(); + self.__attributes = filterAttributes(attributes); + } + + getAttributes(): Record { + 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(`${html}`, '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 diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index c62a6e524..cd36f359e 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -2,17 +2,7 @@ ## 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 @@ -31,6 +21,7 @@ - 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 diff --git a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts index 88241e926..3c14052ba 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/objects.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/objects.ts @@ -23,7 +23,9 @@ import editIcon from "@icons/edit.svg"; 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', @@ -32,7 +34,7 @@ export const link: EditorButtonDefinition = { 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) { @@ -53,7 +55,7 @@ export const link: EditorButtonDefinition = { linkModal.show(formDefaults); }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isLinkNode); } }; @@ -64,7 +66,7 @@ export const unlink: EditorButtonDefinition = { 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) { @@ -78,20 +80,19 @@ export const unlink: EditorButtonDefinition = { } }); }, - 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 = {}; @@ -113,7 +114,7 @@ export const image: EditorButtonDefinition = { imageModal.show(formDefaults); }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isImageNode); } }; @@ -126,7 +127,7 @@ export const horizontalRule: EditorButtonDefinition = { $insertNewBlockNodeAtSelection($createHorizontalRuleNode(), false); }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isHorizontalRuleNode); } }; @@ -137,7 +138,7 @@ export const codeBlock: EditorButtonDefinition = { 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(); @@ -151,7 +152,7 @@ export const codeBlock: EditorButtonDefinition = { } }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isCodeBlockNode); } }; @@ -167,7 +168,7 @@ export const diagram: EditorButtonDefinition = { 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(); @@ -180,11 +181,39 @@ export const diagram: EditorButtonDefinition = { } }); }, - 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', @@ -209,7 +238,7 @@ export const details: EditorButtonDefinition = { } }); }, - isActive(selection: BaseSelection|null): boolean { + isActive(selection: BaseSelection | null): boolean { return $selectionContainsNodeType(selection, $isDetailsNode); } } \ No newline at end of file diff --git a/resources/js/wysiwyg/ui/defaults/form-definitions.ts b/resources/js/wysiwyg/ui/defaults/form-definitions.ts index 04147a4f0..e0459b5c5 100644 --- a/resources/js/wysiwyg/ui/defaults/form-definitions.ts +++ b/resources/js/wysiwyg/ui/defaults/form-definitions.ts @@ -4,6 +4,7 @@ import {$createLinkNode} from "@lexical/link"; import {$createTextNode, $getSelection} from "lexical"; import {$createImageNode} from "../../nodes/image"; import {setEditorContentFromHtml} from "../../actions"; +import {$createMediaNodeFromHtml} from "../../nodes/media"; export const link: EditorFormDefinition = { @@ -89,6 +90,55 @@ export const image: 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) { diff --git a/resources/js/wysiwyg/ui/index.ts b/resources/js/wysiwyg/ui/index.ts index 5dee6b62b..a3f150e52 100644 --- a/resources/js/wysiwyg/ui/index.ts +++ b/resources/js/wysiwyg/ui/index.ts @@ -6,7 +6,7 @@ import { 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"; @@ -38,6 +38,10 @@ export function buildEditorUI(container: HTMLElement, element: HTMLElement, scro title: 'Insert/Edit Image', form: imageFormDefinition }); + manager.registerModal('media', { + title: 'Insert/Edit Media', + form: mediaFormDefinition, + }); manager.registerModal('source', { title: 'Source code', form: sourceFormDefinition, diff --git a/resources/js/wysiwyg/ui/toolbars.ts b/resources/js/wysiwyg/ui/toolbars.ts index 5d40578a5..ae6a292a2 100644 --- a/resources/js/wysiwyg/ui/toolbars.ts +++ b/resources/js/wysiwyg/ui/toolbars.ts @@ -47,7 +47,7 @@ import { editCodeBlock, horizontalRule, image, - link, + link, media, unlink } from "./defaults/buttons/objects"; @@ -110,7 +110,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { ]), // Insert types - new EditorOverflowContainer(6, [ + new EditorOverflowContainer(8, [ new EditorButton(link), new EditorDropdownButton(table, false, [ new EditorTableCreator(), @@ -119,6 +119,7 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement { new EditorButton(horizontalRule), new EditorButton(codeBlock), new EditorButton(diagram), + new EditorButton(media), new EditorButton(details), ]),