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 {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;
}
}

View 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;
}

View 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.

View 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');
}
};

View 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));
}