From 1c9afcb84ef702412d6a004df1a3d861a8f57f1b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 6 Sep 2024 14:07:10 +0100 Subject: [PATCH] Lexical: Added some level of img/media alignment --- resources/js/wysiwyg/nodes/image.ts | 42 ++++++++++++--- resources/js/wysiwyg/nodes/media.ts | 51 ++++++++++++++++--- resources/js/wysiwyg/todo.md | 5 +- .../wysiwyg/ui/defaults/buttons/alignments.ts | 39 +++++++++----- resources/js/wysiwyg/utils/selection.ts | 19 +++++-- 5 files changed, 124 insertions(+), 32 deletions(-) diff --git a/resources/js/wysiwyg/nodes/image.ts b/resources/js/wysiwyg/nodes/image.ts index ef6bf3572..77c854b41 100644 --- a/resources/js/wysiwyg/nodes/image.ts +++ b/resources/js/wysiwyg/nodes/image.ts @@ -10,6 +10,7 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import {EditorDecoratorAdapter} from "../ui/framework/decorator"; import {el} from "../utils/dom"; +import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common"; export interface ImageNodeOptions { alt?: string; @@ -22,6 +23,7 @@ export type SerializedImageNode = Spread<{ alt: string; width: number; height: number; + alignment: CommonBlockAlignment; }, SerializedLexicalNode> export class ImageNode extends DecoratorNode { @@ -29,7 +31,7 @@ export class ImageNode extends DecoratorNode { __alt: string = ''; __width: number = 0; __height: number = 0; - // TODO - Alignment + __alignment: CommonBlockAlignment = ''; static getType(): string { return 'image'; @@ -97,6 +99,16 @@ export class ImageNode extends DecoratorNode { return self.__width; } + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + isInline(): boolean { return true; } @@ -121,6 +133,11 @@ export class ImageNode extends DecoratorNode { if (this.__alt) { element.setAttribute('alt', this.__alt); } + + if (this.__alignment) { + element.classList.add('align-' + this.__alignment); + } + return el('span', {class: 'editor-image-wrap'}, [ element, ]); @@ -158,6 +175,15 @@ export class ImageNode extends DecoratorNode { } } + if (prevNode.__alignment !== this.__alignment) { + if (prevNode.__alignment) { + image.classList.remove('align-' + prevNode.__alignment); + } + if (this.__alignment) { + image.classList.add('align-' + this.__alignment); + } + } + return false; } @@ -174,9 +200,10 @@ export class ImageNode extends DecoratorNode { width: Number.parseInt(element.getAttribute('width') || '0'), } - return { - node: new ImageNode(src, options), - }; + const node = new ImageNode(src, options); + node.setAlignment(extractAlignmentFromElement(element)); + + return { node }; }, priority: 3, }; @@ -191,16 +218,19 @@ export class ImageNode extends DecoratorNode { src: this.__src, alt: this.__alt, height: this.__height, - width: this.__width + width: this.__width, + alignment: this.__alignment, }; } static importJSON(serializedNode: SerializedImageNode): ImageNode { - return $createImageNode(serializedNode.src, { + const node = $createImageNode(serializedNode.src, { alt: serializedNode.alt, width: serializedNode.width, height: serializedNode.height, }); + node.setAlignment(serializedNode.alignment); + return node; } } diff --git a/resources/js/wysiwyg/nodes/media.ts b/resources/js/wysiwyg/nodes/media.ts index 73208cb2e..4159cd457 100644 --- a/resources/js/wysiwyg/nodes/media.ts +++ b/resources/js/wysiwyg/nodes/media.ts @@ -9,6 +9,13 @@ import { import type {EditorConfig} from "lexical/LexicalEditor"; import {el} from "../utils/dom"; +import { + CommonBlockAlignment, + SerializedCommonBlockNode, + setCommonBlockPropsFromElement, + updateElementWithCommonBlockProps +} from "./_common"; +import {elem} from "../../services/dom"; export type MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio'; export type MediaNodeSource = { @@ -20,10 +27,10 @@ export type SerializedMediaNode = Spread<{ tag: MediaNodeTag; attributes: Record; sources: MediaNodeSource[]; -}, SerializedElementNode> +}, SerializedCommonBlockNode> const attributeAllowList = [ - 'id', 'width', 'height', 'style', 'title', 'name', + 'width', 'height', 'style', 'title', 'name', 'src', 'allow', 'allowfullscreen', 'loading', 'sandbox', 'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop', 'muted', 'playsinline', 'poster', 'preload' @@ -39,7 +46,7 @@ function filterAttributes(attributes: Record): Record = {}; @@ -62,10 +69,14 @@ function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode { node.setSources(sources); } + setCommonBlockPropsFromElement(element, node); + return node; } export class MediaNode extends ElementNode { + __id: string = ''; + __alignment: CommonBlockAlignment = ''; __tag: MediaNodeTag; __attributes: Record = {}; __sources: MediaNodeSource[] = []; @@ -135,11 +146,32 @@ export class MediaNode extends ElementNode { this.setAttributes(attrs); } + setId(id: string) { + const self = this.getWritable(); + self.__id = id; + } + + getId(): string { + const self = this.getLatest(); + return self.__id; + } + + setAlignment(alignment: CommonBlockAlignment) { + const self = this.getWritable(); + self.__alignment = alignment; + } + + getAlignment(): CommonBlockAlignment { + const self = this.getLatest(); + return self.__alignment; + } + 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); + const element = el(this.__tag, this.__attributes, sourceEls); + updateElementWithCommonBlockProps(element, this); + return element; } updateDOM(prevNode: unknown, dom: HTMLElement) { @@ -175,6 +207,8 @@ export class MediaNode extends ElementNode { ...super.exportJSON(), type: 'media', version: 1, + id: this.__id, + alignment: this.__alignment, tag: this.__tag, attributes: this.__attributes, sources: this.__sources, @@ -182,7 +216,10 @@ export class MediaNode extends ElementNode { } static importJSON(serializedNode: SerializedMediaNode): MediaNode { - return $createMediaNode(serializedNode.tag); + const node = $createMediaNode(serializedNode.tag); + node.setId(serializedNode.id); + node.setAlignment(serializedNode.alignment); + return node; } } @@ -196,7 +233,7 @@ export function $createMediaNodeFromHtml(html: string): MediaNode | null { const doc = parser.parseFromString(`${html}`, 'text/html'); const el = doc.body.children[0]; - if (!el) { + if (!(el instanceof HTMLElement)) { return null; } diff --git a/resources/js/wysiwyg/todo.md b/resources/js/wysiwyg/todo.md index 5df26bd8c..795f7ab9c 100644 --- a/resources/js/wysiwyg/todo.md +++ b/resources/js/wysiwyg/todo.md @@ -6,18 +6,19 @@ ## Main Todo -- Alignments: Handle inline block content (image, video) - Support media src conversions (https://github.com/tinymce/tinymce/blob/release/6.6/modules/tinymce/src/plugins/media/main/ts/core/UrlPatterns.ts) - Media resize support (like images) -- Table caption text support - Mac: Shortcut support via command. ## Secondary Todo - Color picker support in table form color fields +- Table caption text support ## Bugs +- Image alignment in editor dodgy due to wrapper. +- Can't select iframe embeds by themselves. (click enters iframe) - Image resizing currently bugged, maybe change to ghost resizer in decorator instead of updating core node. - Removing link around image via button deletes image, not just link - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect. diff --git a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts index 78de3c9a2..75440aed8 100644 --- a/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts +++ b/resources/js/wysiwyg/ui/defaults/buttons/alignments.ts @@ -1,17 +1,32 @@ -import {$getSelection, BaseSelection} from "lexical"; +import {BaseSelection, LexicalEditor} from "lexical"; import {EditorButtonDefinition} from "../../framework/buttons"; import alignLeftIcon from "@icons/editor/align-left.svg"; import {EditorUiContext} from "../../framework/core"; import alignCenterIcon from "@icons/editor/align-center.svg"; import alignRightIcon from "@icons/editor/align-right.svg"; import alignJustifyIcon from "@icons/editor/align-justify.svg"; -import {$getBlockElementNodesInSelection, $selectionContainsElementFormat} from "../../../utils/selection"; +import { + $getBlockElementNodesInSelection, + $getDecoratorNodesInSelection, + $selectionContainsAlignment, getLastSelection +} from "../../../utils/selection"; import {CommonBlockAlignment} from "../../../nodes/_common"; import {nodeHasAlignment} from "../../../utils/nodes"; -function setAlignmentForSection(alignment: CommonBlockAlignment): void { - const selection = $getSelection(); +function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void { + const selection = getLastSelection(editor); + const selectionNodes = selection?.getNodes() || []; + const decorators = $getDecoratorNodesInSelection(selection); + + // Handle decorator node selection alignment + if (selectionNodes.length === 1 && decorators.length === 1 && nodeHasAlignment(decorators[0])) { + decorators[0].setAlignment(alignment); + console.log('setting for decorator!'); + return; + } + + // Handle normal block/range alignment const elements = $getBlockElementNodesInSelection(selection); for (const node of elements) { if (nodeHasAlignment(node)) { @@ -24,10 +39,10 @@ export const alignLeft: EditorButtonDefinition = { label: 'Align left', icon: alignLeftIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('left')); + context.editor.update(() => setAlignmentForSection(context.editor, 'left')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'left'); + return $selectionContainsAlignment(selection, 'left'); } }; @@ -35,10 +50,10 @@ export const alignCenter: EditorButtonDefinition = { label: 'Align center', icon: alignCenterIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('center')); + context.editor.update(() => setAlignmentForSection(context.editor, 'center')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'center'); + return $selectionContainsAlignment(selection, 'center'); } }; @@ -46,10 +61,10 @@ export const alignRight: EditorButtonDefinition = { label: 'Align right', icon: alignRightIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('right')); + context.editor.update(() => setAlignmentForSection(context.editor, 'right')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'right'); + return $selectionContainsAlignment(selection, 'right'); } }; @@ -57,9 +72,9 @@ export const alignJustify: EditorButtonDefinition = { label: 'Align justify', icon: alignJustifyIcon, action(context: EditorUiContext) { - context.editor.update(() => setAlignmentForSection('justify')); + context.editor.update(() => setAlignmentForSection(context.editor, 'justify')); }, isActive(selection: BaseSelection|null) { - return $selectionContainsElementFormat(selection, 'justify'); + return $selectionContainsAlignment(selection, 'justify'); } }; diff --git a/resources/js/wysiwyg/utils/selection.ts b/resources/js/wysiwyg/utils/selection.ts index 74dd94527..791eb7499 100644 --- a/resources/js/wysiwyg/utils/selection.ts +++ b/resources/js/wysiwyg/utils/selection.ts @@ -2,11 +2,11 @@ import { $createNodeSelection, $createParagraphNode, $getRoot, - $getSelection, + $getSelection, $isDecoratorNode, $isElementNode, $isTextNode, $setSelection, - BaseSelection, + BaseSelection, DecoratorNode, ElementFormatType, ElementNode, LexicalEditor, LexicalNode, @@ -16,8 +16,9 @@ import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexi import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {$setBlocksType} from "@lexical/selection"; -import {$getParentOfType} from "./nodes"; +import {$getParentOfType, nodeHasAlignment} from "./nodes"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; +import {CommonBlockAlignment} from "../nodes/_common"; const lastSelectionByEditor = new WeakMap; @@ -120,10 +121,10 @@ export function $selectionContainsNode(selection: BaseSelection | null, node: Le return false; } -export function $selectionContainsElementFormat(selection: BaseSelection | null, format: ElementFormatType): boolean { +export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean { const nodes = $getBlockElementNodesInSelection(selection); for (const node of nodes) { - if (node.getFormatType() === format) { + if (nodeHasAlignment(node) && node.getAlignment() === alignment) { return true; } } @@ -148,4 +149,12 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection | null } return Array.from(blockNodes.values()); +} + +export function $getDecoratorNodesInSelection(selection: BaseSelection | null): DecoratorNode[] { + if (!selection) { + return []; + } + + return selection.getNodes().filter(node => $isDecoratorNode(node)); } \ No newline at end of file