]> BookStack Code Mirror - bookstack/commitdiff
Lexical: Added some level of img/media alignment
authorDan Brown <redacted>
Fri, 6 Sep 2024 13:07:10 +0000 (14:07 +0100)
committerDan Brown <redacted>
Fri, 6 Sep 2024 13:07:10 +0000 (14:07 +0100)
resources/js/wysiwyg/nodes/image.ts
resources/js/wysiwyg/nodes/media.ts
resources/js/wysiwyg/todo.md
resources/js/wysiwyg/ui/defaults/buttons/alignments.ts
resources/js/wysiwyg/utils/selection.ts

index ef6bf35724a6f98e827febfdedd38a2ec3cb99ba..77c854b419d62cd13cee8215d4c2f3de3bc33f5c 100644 (file)
@@ -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<EditorDecoratorAdapter> {
@@ -29,7 +31,7 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
     __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<EditorDecoratorAdapter> {
         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<EditorDecoratorAdapter> {
         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<EditorDecoratorAdapter> {
             }
         }
 
+        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<EditorDecoratorAdapter> {
                             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<EditorDecoratorAdapter> {
             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;
     }
 }
 
index 73208cb2e433dc00a45c663db2b7373e92791807..4159cd457e5f8508231a73c740f9d746d92c69f0 100644 (file)
@@ -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<string, string>;
     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<string, string>): Record<string, st
     return filtered;
 }
 
-function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
+function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
     const node = $createMediaNode(tag);
 
     const attributes: Record<string, string> = {};
@@ -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<string, string> = {};
     __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(`<body>${html}</body>`, 'text/html');
 
     const el = doc.body.children[0];
-    if (!el) {
+    if (!(el instanceof HTMLElement)) {
         return null;
     }
 
index 5df26bd8cfd84391bf09ab0ac2b63bb729427369..795f7ab9c04b61d799bb1f5ea9b941e383744ec2 100644 (file)
@@ -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.
index 78de3c9a243590de8d83ddf4f483003731b2df5d..75440aed819c4d5ed484d306ec0f97a661232617 100644 (file)
@@ -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');
     }
 };
index 74dd94527433c155a2620107a7995f7ead508365..791eb74990440a1db8bfe48ee911c187036201c8 100644 (file)
@@ -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<LexicalEditor, BaseSelection|null>;
 
@@ -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<any>[] {
+    if (!selection) {
+        return [];
+    }
+
+    return selection.getNodes().filter(node => $isDecoratorNode(node));
 }
\ No newline at end of file