Lexical: Added some level of img/media alignment

This commit is contained in:
Dan Brown 2024-09-06 14:07:10 +01:00
parent 1ebb0f8c93
commit 1c9afcb84e
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
5 changed files with 124 additions and 32 deletions

View File

@ -10,6 +10,7 @@ import {
import type {EditorConfig} from "lexical/LexicalEditor"; import type {EditorConfig} from "lexical/LexicalEditor";
import {EditorDecoratorAdapter} from "../ui/framework/decorator"; import {EditorDecoratorAdapter} from "../ui/framework/decorator";
import {el} from "../utils/dom"; import {el} from "../utils/dom";
import {CommonBlockAlignment, extractAlignmentFromElement} from "./_common";
export interface ImageNodeOptions { export interface ImageNodeOptions {
alt?: string; alt?: string;
@ -22,6 +23,7 @@ export type SerializedImageNode = Spread<{
alt: string; alt: string;
width: number; width: number;
height: number; height: number;
alignment: CommonBlockAlignment;
}, SerializedLexicalNode> }, SerializedLexicalNode>
export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> { export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
@ -29,7 +31,7 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
__alt: string = ''; __alt: string = '';
__width: number = 0; __width: number = 0;
__height: number = 0; __height: number = 0;
// TODO - Alignment __alignment: CommonBlockAlignment = '';
static getType(): string { static getType(): string {
return 'image'; return 'image';
@ -97,6 +99,16 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
return self.__width; return self.__width;
} }
setAlignment(alignment: CommonBlockAlignment) {
const self = this.getWritable();
self.__alignment = alignment;
}
getAlignment(): CommonBlockAlignment {
const self = this.getLatest();
return self.__alignment;
}
isInline(): boolean { isInline(): boolean {
return true; return true;
} }
@ -121,6 +133,11 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
if (this.__alt) { if (this.__alt) {
element.setAttribute('alt', this.__alt); element.setAttribute('alt', this.__alt);
} }
if (this.__alignment) {
element.classList.add('align-' + this.__alignment);
}
return el('span', {class: 'editor-image-wrap'}, [ return el('span', {class: 'editor-image-wrap'}, [
element, 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; return false;
} }
@ -174,9 +200,10 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
width: Number.parseInt(element.getAttribute('width') || '0'), width: Number.parseInt(element.getAttribute('width') || '0'),
} }
return { const node = new ImageNode(src, options);
node: new ImageNode(src, options), node.setAlignment(extractAlignmentFromElement(element));
};
return { node };
}, },
priority: 3, priority: 3,
}; };
@ -191,16 +218,19 @@ export class ImageNode extends DecoratorNode<EditorDecoratorAdapter> {
src: this.__src, src: this.__src,
alt: this.__alt, alt: this.__alt,
height: this.__height, height: this.__height,
width: this.__width width: this.__width,
alignment: this.__alignment,
}; };
} }
static importJSON(serializedNode: SerializedImageNode): ImageNode { static importJSON(serializedNode: SerializedImageNode): ImageNode {
return $createImageNode(serializedNode.src, { const node = $createImageNode(serializedNode.src, {
alt: serializedNode.alt, alt: serializedNode.alt,
width: serializedNode.width, width: serializedNode.width,
height: serializedNode.height, height: serializedNode.height,
}); });
node.setAlignment(serializedNode.alignment);
return node;
} }
} }

View File

@ -9,6 +9,13 @@ import {
import type {EditorConfig} from "lexical/LexicalEditor"; import type {EditorConfig} from "lexical/LexicalEditor";
import {el} from "../utils/dom"; 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 MediaNodeTag = 'iframe' | 'embed' | 'object' | 'video' | 'audio';
export type MediaNodeSource = { export type MediaNodeSource = {
@ -20,10 +27,10 @@ export type SerializedMediaNode = Spread<{
tag: MediaNodeTag; tag: MediaNodeTag;
attributes: Record<string, string>; attributes: Record<string, string>;
sources: MediaNodeSource[]; sources: MediaNodeSource[];
}, SerializedElementNode> }, SerializedCommonBlockNode>
const attributeAllowList = [ const attributeAllowList = [
'id', 'width', 'height', 'style', 'title', 'name', 'width', 'height', 'style', 'title', 'name',
'src', 'allow', 'allowfullscreen', 'loading', 'sandbox', 'src', 'allow', 'allowfullscreen', 'loading', 'sandbox',
'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop', 'type', 'data', 'controls', 'autoplay', 'controlslist', 'loop',
'muted', 'playsinline', 'poster', 'preload' 'muted', 'playsinline', 'poster', 'preload'
@ -39,7 +46,7 @@ function filterAttributes(attributes: Record<string, string>): Record<string, st
return filtered; return filtered;
} }
function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode { function domElementToNode(tag: MediaNodeTag, element: HTMLElement): MediaNode {
const node = $createMediaNode(tag); const node = $createMediaNode(tag);
const attributes: Record<string, string> = {}; const attributes: Record<string, string> = {};
@ -62,10 +69,14 @@ function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
node.setSources(sources); node.setSources(sources);
} }
setCommonBlockPropsFromElement(element, node);
return node; return node;
} }
export class MediaNode extends ElementNode { export class MediaNode extends ElementNode {
__id: string = '';
__alignment: CommonBlockAlignment = '';
__tag: MediaNodeTag; __tag: MediaNodeTag;
__attributes: Record<string, string> = {}; __attributes: Record<string, string> = {};
__sources: MediaNodeSource[] = []; __sources: MediaNodeSource[] = [];
@ -135,11 +146,32 @@ export class MediaNode extends ElementNode {
this.setAttributes(attrs); 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) { createDOM(_config: EditorConfig, _editor: LexicalEditor) {
const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : []; const sources = (this.__tag === 'video' || this.__tag === 'audio') ? this.__sources : [];
const sourceEls = sources.map(source => el('source', source)); const sourceEls = sources.map(source => el('source', source));
const element = el(this.__tag, this.__attributes, sourceEls);
return el(this.__tag, this.__attributes, sourceEls); updateElementWithCommonBlockProps(element, this);
return element;
} }
updateDOM(prevNode: unknown, dom: HTMLElement) { updateDOM(prevNode: unknown, dom: HTMLElement) {
@ -175,6 +207,8 @@ export class MediaNode extends ElementNode {
...super.exportJSON(), ...super.exportJSON(),
type: 'media', type: 'media',
version: 1, version: 1,
id: this.__id,
alignment: this.__alignment,
tag: this.__tag, tag: this.__tag,
attributes: this.__attributes, attributes: this.__attributes,
sources: this.__sources, sources: this.__sources,
@ -182,7 +216,10 @@ export class MediaNode extends ElementNode {
} }
static importJSON(serializedNode: SerializedMediaNode): MediaNode { 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 doc = parser.parseFromString(`<body>${html}</body>`, 'text/html');
const el = doc.body.children[0]; const el = doc.body.children[0];
if (!el) { if (!(el instanceof HTMLElement)) {
return null; return null;
} }

View File

@ -6,18 +6,19 @@
## Main Todo ## 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) - 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) - Media resize support (like images)
- Table caption text support
- Mac: Shortcut support via command. - Mac: Shortcut support via command.
## Secondary Todo ## Secondary Todo
- Color picker support in table form color fields - Color picker support in table form color fields
- Table caption text support
## Bugs ## 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. - 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 - 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. - `SELECTION_CHANGE_COMMAND` not fired when clicking out of a table cell. Prevents toolbar hiding on table unselect.

View File

@ -1,17 +1,32 @@
import {$getSelection, BaseSelection} from "lexical"; import {BaseSelection, LexicalEditor} from "lexical";
import {EditorButtonDefinition} from "../../framework/buttons"; import {EditorButtonDefinition} from "../../framework/buttons";
import alignLeftIcon from "@icons/editor/align-left.svg"; import alignLeftIcon from "@icons/editor/align-left.svg";
import {EditorUiContext} from "../../framework/core"; import {EditorUiContext} from "../../framework/core";
import alignCenterIcon from "@icons/editor/align-center.svg"; import alignCenterIcon from "@icons/editor/align-center.svg";
import alignRightIcon from "@icons/editor/align-right.svg"; import alignRightIcon from "@icons/editor/align-right.svg";
import alignJustifyIcon from "@icons/editor/align-justify.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 {CommonBlockAlignment} from "../../../nodes/_common";
import {nodeHasAlignment} from "../../../utils/nodes"; import {nodeHasAlignment} from "../../../utils/nodes";
function setAlignmentForSection(alignment: CommonBlockAlignment): void { function setAlignmentForSection(editor: LexicalEditor, alignment: CommonBlockAlignment): void {
const selection = $getSelection(); 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); const elements = $getBlockElementNodesInSelection(selection);
for (const node of elements) { for (const node of elements) {
if (nodeHasAlignment(node)) { if (nodeHasAlignment(node)) {
@ -24,10 +39,10 @@ export const alignLeft: EditorButtonDefinition = {
label: 'Align left', label: 'Align left',
icon: alignLeftIcon, icon: alignLeftIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.update(() => setAlignmentForSection('left')); context.editor.update(() => setAlignmentForSection(context.editor, 'left'));
}, },
isActive(selection: BaseSelection|null) { isActive(selection: BaseSelection|null) {
return $selectionContainsElementFormat(selection, 'left'); return $selectionContainsAlignment(selection, 'left');
} }
}; };
@ -35,10 +50,10 @@ export const alignCenter: EditorButtonDefinition = {
label: 'Align center', label: 'Align center',
icon: alignCenterIcon, icon: alignCenterIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.update(() => setAlignmentForSection('center')); context.editor.update(() => setAlignmentForSection(context.editor, 'center'));
}, },
isActive(selection: BaseSelection|null) { isActive(selection: BaseSelection|null) {
return $selectionContainsElementFormat(selection, 'center'); return $selectionContainsAlignment(selection, 'center');
} }
}; };
@ -46,10 +61,10 @@ export const alignRight: EditorButtonDefinition = {
label: 'Align right', label: 'Align right',
icon: alignRightIcon, icon: alignRightIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.update(() => setAlignmentForSection('right')); context.editor.update(() => setAlignmentForSection(context.editor, 'right'));
}, },
isActive(selection: BaseSelection|null) { isActive(selection: BaseSelection|null) {
return $selectionContainsElementFormat(selection, 'right'); return $selectionContainsAlignment(selection, 'right');
} }
}; };
@ -57,9 +72,9 @@ export const alignJustify: EditorButtonDefinition = {
label: 'Align justify', label: 'Align justify',
icon: alignJustifyIcon, icon: alignJustifyIcon,
action(context: EditorUiContext) { action(context: EditorUiContext) {
context.editor.update(() => setAlignmentForSection('justify')); context.editor.update(() => setAlignmentForSection(context.editor, 'justify'));
}, },
isActive(selection: BaseSelection|null) { isActive(selection: BaseSelection|null) {
return $selectionContainsElementFormat(selection, 'justify'); return $selectionContainsAlignment(selection, 'justify');
} }
}; };

View File

@ -2,11 +2,11 @@ import {
$createNodeSelection, $createNodeSelection,
$createParagraphNode, $createParagraphNode,
$getRoot, $getRoot,
$getSelection, $getSelection, $isDecoratorNode,
$isElementNode, $isElementNode,
$isTextNode, $isTextNode,
$setSelection, $setSelection,
BaseSelection, BaseSelection, DecoratorNode,
ElementFormatType, ElementFormatType,
ElementNode, LexicalEditor, ElementNode, LexicalEditor,
LexicalNode, LexicalNode,
@ -16,8 +16,9 @@ import {$findMatchingParent, $getNearestBlockElementAncestorOrThrow} from "@lexi
import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes"; import {LexicalElementNodeCreator, LexicalNodeMatcher} from "../nodes";
import {$setBlocksType} from "@lexical/selection"; import {$setBlocksType} from "@lexical/selection";
import {$getParentOfType} from "./nodes"; import {$getParentOfType, nodeHasAlignment} from "./nodes";
import {$createCustomParagraphNode} from "../nodes/custom-paragraph"; import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
import {CommonBlockAlignment} from "../nodes/_common";
const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>; const lastSelectionByEditor = new WeakMap<LexicalEditor, BaseSelection|null>;
@ -120,10 +121,10 @@ export function $selectionContainsNode(selection: BaseSelection | null, node: Le
return false; return false;
} }
export function $selectionContainsElementFormat(selection: BaseSelection | null, format: ElementFormatType): boolean { export function $selectionContainsAlignment(selection: BaseSelection | null, alignment: CommonBlockAlignment): boolean {
const nodes = $getBlockElementNodesInSelection(selection); const nodes = $getBlockElementNodesInSelection(selection);
for (const node of nodes) { for (const node of nodes) {
if (node.getFormatType() === format) { if (nodeHasAlignment(node) && node.getAlignment() === alignment) {
return true; return true;
} }
} }
@ -149,3 +150,11 @@ export function $getBlockElementNodesInSelection(selection: BaseSelection | null
return Array.from(blockNodes.values()); return Array.from(blockNodes.values());
} }
export function $getDecoratorNodesInSelection(selection: BaseSelection | null): DecoratorNode<any>[] {
if (!selection) {
return [];
}
return selection.getNodes().filter(node => $isDecoratorNode(node));
}