Lexical: Added block indenting capability

Needed a custom implementation due to hardcoded defaults for Lexical
default indenting.
This commit is contained in:
Dan Brown 2024-09-10 15:55:46 +01:00
parent 2036438203
commit 5083188ed8
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
11 changed files with 193 additions and 25 deletions

View File

@ -1,5 +1,6 @@
import {LexicalNode, Spread} from "lexical";
import type {SerializedElementNode} from "lexical/nodes/LexicalElementNode";
import {sizeToPixels} from "../utils/dom";
export type CommonBlockAlignment = 'left' | 'right' | 'center' | 'justify' | '';
const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'justify'];
@ -7,6 +8,7 @@ const validAlignments: CommonBlockAlignment[] = ['left', 'right', 'center', 'jus
export type SerializedCommonBlockNode = Spread<{
id: string;
alignment: CommonBlockAlignment;
inset: number;
}, SerializedElementNode>
export interface NodeHasAlignment {
@ -21,7 +23,13 @@ export interface NodeHasId {
getId(): string;
}
interface CommonBlockInterface extends NodeHasId, NodeHasAlignment {}
export interface NodeHasInset {
readonly __inset: number;
setInset(inset: number): void;
getInset(): number;
}
interface CommonBlockInterface extends NodeHasId, NodeHasAlignment, NodeHasInset {}
export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAlignment {
const textAlignStyle: string = element.style.textAlign || '';
@ -42,17 +50,24 @@ export function extractAlignmentFromElement(element: HTMLElement): CommonBlockAl
return '';
}
export function extractInsetFromElement(element: HTMLElement): number {
const elemPadding: string = element.style.paddingLeft || '0';
return sizeToPixels(elemPadding);
}
export function setCommonBlockPropsFromElement(element: HTMLElement, node: CommonBlockInterface): void {
if (element.id) {
node.setId(element.id);
}
node.setAlignment(extractAlignmentFromElement(element));
node.setInset(extractInsetFromElement(element));
}
export function commonPropertiesDifferent(nodeA: CommonBlockInterface, nodeB: CommonBlockInterface): boolean {
return nodeA.__id !== nodeB.__id ||
nodeA.__alignment !== nodeB.__alignment;
nodeA.__alignment !== nodeB.__alignment ||
nodeA.__inset !== nodeB.__inset;
}
export function updateElementWithCommonBlockProps(element: HTMLElement, node: CommonBlockInterface): void {
@ -63,6 +78,16 @@ export function updateElementWithCommonBlockProps(element: HTMLElement, node: Co
if (node.__alignment) {
element.classList.add('align-' + node.__alignment);
}
if (node.__inset) {
element.style.paddingLeft = `${node.__inset}px`;
}
}
export function deserializeCommonBlockNode(serializedNode: SerializedCommonBlockNode, node: CommonBlockInterface): void {
node.setId(serializedNode.id);
node.setAlignment(serializedNode.alignment);
node.setInset(serializedNode.inset);
}
export interface NodeHasSize {

View File

@ -10,7 +10,7 @@ import {
import type {EditorConfig} from "lexical/LexicalEditor";
import type {RangeSelection} from "lexical/LexicalSelection";
import {
CommonBlockAlignment, commonPropertiesDifferent,
CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode,
SerializedCommonBlockNode,
setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps
@ -26,6 +26,7 @@ export class CalloutNode extends ElementNode {
__id: string = '';
__category: CalloutCategory = 'info';
__alignment: CommonBlockAlignment = '';
__inset: number = 0;
static getType() {
return 'callout';
@ -35,6 +36,7 @@ export class CalloutNode extends ElementNode {
const newNode = new CalloutNode(node.__category, node.__key);
newNode.__id = node.__id;
newNode.__alignment = node.__alignment;
newNode.__inset = node.__inset;
return newNode;
}
@ -73,6 +75,16 @@ export class CalloutNode extends ElementNode {
return self.__alignment;
}
setInset(size: number) {
const self = this.getWritable();
self.__inset = size;
}
getInset(): number {
const self = this.getLatest();
return self.__inset;
}
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
const element = document.createElement('p');
element.classList.add('callout', this.__category || '');
@ -141,13 +153,13 @@ export class CalloutNode extends ElementNode {
category: this.__category,
id: this.__id,
alignment: this.__alignment,
inset: this.__inset,
};
}
static importJSON(serializedNode: SerializedCalloutNode): CalloutNode {
const node = $createCalloutNode(serializedNode.category);
node.setId(serializedNode.id);
node.setAlignment(serializedNode.alignment);
deserializeCommonBlockNode(serializedNode, node);
return node;
}

View File

@ -7,7 +7,7 @@ import {
import {EditorConfig} from "lexical/LexicalEditor";
import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text";
import {
CommonBlockAlignment, commonPropertiesDifferent,
CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode,
SerializedCommonBlockNode,
setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps
@ -19,6 +19,7 @@ export type SerializedCustomHeadingNode = Spread<SerializedCommonBlockNode, Seri
export class CustomHeadingNode extends HeadingNode {
__id: string = '';
__alignment: CommonBlockAlignment = '';
__inset: number = 0;
static getType() {
return 'custom-heading';
@ -44,9 +45,20 @@ export class CustomHeadingNode extends HeadingNode {
return self.__alignment;
}
setInset(size: number) {
const self = this.getWritable();
self.__inset = size;
}
getInset(): number {
const self = this.getLatest();
return self.__inset;
}
static clone(node: CustomHeadingNode) {
const newNode = new CustomHeadingNode(node.__tag, node.__key);
newNode.__alignment = node.__alignment;
newNode.__inset = node.__inset;
return newNode;
}
@ -68,13 +80,13 @@ export class CustomHeadingNode extends HeadingNode {
version: 1,
id: this.__id,
alignment: this.__alignment,
inset: this.__inset,
};
}
static importJSON(serializedNode: SerializedCustomHeadingNode): CustomHeadingNode {
const node = $createCustomHeadingNode(serializedNode.tag);
node.setId(serializedNode.id);
node.setAlignment(serializedNode.alignment);
deserializeCommonBlockNode(serializedNode, node);
return node;
}

View File

@ -7,7 +7,7 @@ import {
} from "lexical";
import {EditorConfig} from "lexical/LexicalEditor";
import {
CommonBlockAlignment, commonPropertiesDifferent,
CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode,
SerializedCommonBlockNode,
setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps
@ -18,6 +18,7 @@ export type SerializedCustomParagraphNode = Spread<SerializedCommonBlockNode, Se
export class CustomParagraphNode extends ParagraphNode {
__id: string = '';
__alignment: CommonBlockAlignment = '';
__inset: number = 0;
static getType() {
return 'custom-paragraph';
@ -43,10 +44,21 @@ export class CustomParagraphNode extends ParagraphNode {
return self.__alignment;
}
setInset(size: number) {
const self = this.getWritable();
self.__inset = size;
}
getInset(): number {
const self = this.getLatest();
return self.__inset;
}
static clone(node: CustomParagraphNode): CustomParagraphNode {
const newNode = new CustomParagraphNode(node.__key);
newNode.__id = node.__id;
newNode.__alignment = node.__alignment;
newNode.__inset = node.__inset;
return newNode;
}
@ -68,13 +80,13 @@ export class CustomParagraphNode extends ParagraphNode {
version: 1,
id: this.__id,
alignment: this.__alignment,
inset: this.__inset,
};
}
static importJSON(serializedNode: SerializedCustomParagraphNode): CustomParagraphNode {
const node = $createCustomParagraphNode();
node.setId(serializedNode.id);
node.setAlignment(serializedNode.alignment);
deserializeCommonBlockNode(serializedNode, node);
return node;
}

View File

@ -7,7 +7,7 @@ import {
import {EditorConfig} from "lexical/LexicalEditor";
import {QuoteNode, SerializedQuoteNode} from "@lexical/rich-text";
import {
CommonBlockAlignment, commonPropertiesDifferent,
CommonBlockAlignment, commonPropertiesDifferent, deserializeCommonBlockNode,
SerializedCommonBlockNode,
setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps
@ -19,6 +19,7 @@ export type SerializedCustomQuoteNode = Spread<SerializedCommonBlockNode, Serial
export class CustomQuoteNode extends QuoteNode {
__id: string = '';
__alignment: CommonBlockAlignment = '';
__inset: number = 0;
static getType() {
return 'custom-quote';
@ -44,10 +45,21 @@ export class CustomQuoteNode extends QuoteNode {
return self.__alignment;
}
setInset(size: number) {
const self = this.getWritable();
self.__inset = size;
}
getInset(): number {
const self = this.getLatest();
return self.__inset;
}
static clone(node: CustomQuoteNode) {
const newNode = new CustomQuoteNode(node.__key);
newNode.__id = node.__id;
newNode.__alignment = node.__alignment;
newNode.__inset = node.__inset;
return newNode;
}
@ -68,13 +80,13 @@ export class CustomQuoteNode extends QuoteNode {
version: 1,
id: this.__id,
alignment: this.__alignment,
inset: this.__inset,
};
}
static importJSON(serializedNode: SerializedCustomQuoteNode): CustomQuoteNode {
const node = $createCustomQuoteNode();
node.setId(serializedNode.id);
node.setAlignment(serializedNode.alignment);
deserializeCommonBlockNode(serializedNode, node);
return node;
}

View File

@ -5,7 +5,7 @@ import {EditorConfig} from "lexical/LexicalEditor";
import {el, extractStyleMapFromElement, StyleMap} from "../utils/dom";
import {getTableColumnWidths} from "../utils/tables";
import {
CommonBlockAlignment,
CommonBlockAlignment, deserializeCommonBlockNode,
SerializedCommonBlockNode,
setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps
@ -21,6 +21,7 @@ export class CustomTableNode extends TableNode {
__colWidths: string[] = [];
__styles: StyleMap = new Map;
__alignment: CommonBlockAlignment = '';
__inset: number = 0;
static getType() {
return 'custom-table';
@ -46,6 +47,16 @@ export class CustomTableNode extends TableNode {
return self.__alignment;
}
setInset(size: number) {
const self = this.getWritable();
self.__inset = size;
}
getInset(): number {
const self = this.getLatest();
return self.__inset;
}
setColWidths(widths: string[]) {
const self = this.getWritable();
self.__colWidths = widths;
@ -72,6 +83,7 @@ export class CustomTableNode extends TableNode {
newNode.__colWidths = node.__colWidths;
newNode.__styles = new Map(node.__styles);
newNode.__alignment = node.__alignment;
newNode.__inset = node.__inset;
return newNode;
}
@ -112,15 +124,15 @@ export class CustomTableNode extends TableNode {
colWidths: this.__colWidths,
styles: Object.fromEntries(this.__styles),
alignment: this.__alignment,
inset: this.__inset,
};
}
static importJSON(serializedNode: SerializedCustomTableNode): CustomTableNode {
const node = $createCustomTableNode();
node.setId(serializedNode.id);
deserializeCommonBlockNode(serializedNode, node);
node.setColWidths(serializedNode.colWidths);
node.setStyles(new Map(Object.entries(serializedNode.styles)));
node.setAlignment(serializedNode.alignment);
return node;
}

View File

@ -10,7 +10,7 @@ import type {EditorConfig} from "lexical/LexicalEditor";
import {el, setOrRemoveAttribute, sizeToPixels} from "../utils/dom";
import {
CommonBlockAlignment,
CommonBlockAlignment, deserializeCommonBlockNode,
SerializedCommonBlockNode,
setCommonBlockPropsFromElement,
updateElementWithCommonBlockProps
@ -80,6 +80,7 @@ export class MediaNode extends ElementNode {
__tag: MediaNodeTag;
__attributes: Record<string, string> = {};
__sources: MediaNodeSource[] = [];
__inset: number = 0;
static getType() {
return 'media';
@ -91,6 +92,7 @@ export class MediaNode extends ElementNode {
newNode.__sources = node.__sources.map(s => Object.assign({}, s));
newNode.__id = node.__id;
newNode.__alignment = node.__alignment;
newNode.__inset = node.__inset;
return newNode;
}
@ -168,6 +170,16 @@ export class MediaNode extends ElementNode {
return self.__alignment;
}
setInset(size: number) {
const self = this.getWritable();
self.__inset = size;
}
getInset(): number {
const self = this.getLatest();
return self.__inset;
}
setHeight(height: number): void {
if (!height) {
return;
@ -251,6 +263,10 @@ export class MediaNode extends ElementNode {
}
}
if (prevNode.__inset !== this.__inset) {
dom.style.paddingLeft = `${this.__inset}px`;
}
return false;
}
@ -290,6 +306,7 @@ export class MediaNode extends ElementNode {
version: 1,
id: this.__id,
alignment: this.__alignment,
inset: this.__inset,
tag: this.__tag,
attributes: this.__attributes,
sources: this.__sources,
@ -298,8 +315,7 @@ export class MediaNode extends ElementNode {
static importJSON(serializedNode: SerializedMediaNode): MediaNode {
const node = $createMediaNode(serializedNode.tag);
node.setId(serializedNode.id);
node.setAlignment(serializedNode.alignment);
deserializeCommonBlockNode(serializedNode, node);
return node;
}

View File

@ -6,6 +6,7 @@
## Main Todo
- Align list nesting with old editor
- Mac: Shortcut support via command.
## Secondary Todo

View File

@ -1,12 +1,24 @@
import {$isListNode, ListNode, ListType} from "@lexical/list";
import {EditorButtonDefinition} from "../../framework/buttons";
import {EditorUiContext} from "../../framework/core";
import {BaseSelection, LexicalNode} from "lexical";
import {
BaseSelection,
LexicalEditor,
LexicalNode,
} from "lexical";
import listBulletIcon from "@icons/editor/list-bullet.svg";
import listNumberedIcon from "@icons/editor/list-numbered.svg";
import listCheckIcon from "@icons/editor/list-check.svg";
import {$selectionContainsNodeType} from "../../../utils/selection";
import indentIncreaseIcon from "@icons/editor/indent-increase.svg";
import indentDecreaseIcon from "@icons/editor/indent-decrease.svg";
import {
$getBlockElementNodesInSelection,
$selectionContainsNodeType,
$toggleSelection,
getLastSelection
} from "../../../utils/selection";
import {toggleSelectionAsList} from "../../../utils/formats";
import {nodeHasInset} from "../../../utils/nodes";
function buildListButton(label: string, type: ListType, icon: string): EditorButtonDefinition {
@ -27,3 +39,45 @@ function buildListButton(label: string, type: ListType, icon: string): EditorBut
export const bulletList: EditorButtonDefinition = buildListButton('Bullet list', 'bullet', listBulletIcon);
export const numberList: EditorButtonDefinition = buildListButton('Numbered list', 'number', listNumberedIcon);
export const taskList: EditorButtonDefinition = buildListButton('Task list', 'check', listCheckIcon);
function setInsetForSelection(editor: LexicalEditor, change: number): void {
const selection = getLastSelection(editor);
const elements = $getBlockElementNodesInSelection(selection);
for (const node of elements) {
if (nodeHasInset(node)) {
const currentInset = node.getInset();
const newInset = Math.min(Math.max(currentInset + change, 0), 500);
node.setInset(newInset)
}
}
$toggleSelection(editor);
}
export const indentIncrease: EditorButtonDefinition = {
label: 'Increase indent',
icon: indentIncreaseIcon,
action(context: EditorUiContext) {
context.editor.update(() => {
setInsetForSelection(context.editor, 40);
});
},
isActive() {
return false;
}
};
export const indentDecrease: EditorButtonDefinition = {
label: 'Decrease indent',
icon: indentDecreaseIcon,
action(context: EditorUiContext) {
context.editor.update(() => {
setInsetForSelection(context.editor, -40);
});
},
isActive() {
return false;
}
};

View File

@ -52,7 +52,13 @@ import {
underline
} from "./defaults/buttons/inline-formats";
import {alignCenter, alignJustify, alignLeft, alignRight} from "./defaults/buttons/alignments";
import {bulletList, numberList, taskList} from "./defaults/buttons/lists";
import {
bulletList,
indentDecrease,
indentIncrease,
numberList,
taskList
} from "./defaults/buttons/lists";
import {
codeBlock,
details,
@ -119,10 +125,12 @@ export function getMainEditorFullToolbar(): EditorContainerUiElement {
]),
// Lists
new EditorOverflowContainer(3, [
new EditorOverflowContainer(5, [
new EditorButton(bulletList),
new EditorButton(numberList),
new EditorButton(taskList),
new EditorButton(indentDecrease),
new EditorButton(indentIncrease),
]),
// Insert types

View File

@ -11,7 +11,7 @@ import {LexicalNodeMatcher} from "../nodes";
import {$createCustomParagraphNode} from "../nodes/custom-paragraph";
import {$generateNodesFromDOM} from "@lexical/html";
import {htmlToDom} from "./dom";
import {NodeHasAlignment} from "../nodes/_common";
import {NodeHasAlignment, NodeHasInset} from "../nodes/_common";
import {$findMatchingParent} from "@lexical/utils";
function wrapTextNodes(nodes: LexicalNode[]): LexicalNode[] {
@ -96,4 +96,8 @@ export function $getNearestNodeBlockParent(node: LexicalNode): LexicalNode|null
export function nodeHasAlignment(node: object): node is NodeHasAlignment {
return '__alignment' in node;
}
export function nodeHasInset(node: object): node is NodeHasInset {
return '__inset' in node;
}