Lexical: Added id support for all main block types

This commit is contained in:
Dan Brown 2024-08-11 16:08:51 +01:00
parent ebf95f637a
commit ec965f28c0
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
13 changed files with 486 additions and 59 deletions

View File

@ -82,6 +82,11 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st
}
});
// @ts-ignore
window.debugEditorState = () => {
console.log(editor.getEditorState().toJSON());
};
const context: EditorUiContext = buildEditorUI(container, editArea, editWrap, editor, options);
registerCommonNodeMutationListeners(context);

View File

@ -9,15 +9,17 @@ import {
} from 'lexical';
import type {EditorConfig} from "lexical/LexicalEditor";
import type {RangeSelection} from "lexical/LexicalSelection";
import {el} from "../utils/dom";
export type CalloutCategory = 'info' | 'danger' | 'warning' | 'success';
export type SerializedCalloutNode = Spread<{
category: CalloutCategory;
id: string;
}, SerializedElementNode>
export class CalloutNode extends ElementNode {
__id: string = '';
__category: CalloutCategory = 'info';
static getType() {
@ -25,7 +27,9 @@ export class CalloutNode extends ElementNode {
}
static clone(node: CalloutNode) {
return new CalloutNode(node.__category, node.__key);
const newNode = new CalloutNode(node.__category, node.__key);
newNode.__id = node.__id;
return newNode;
}
constructor(category: CalloutCategory, key?: string) {
@ -43,9 +47,22 @@ export class CalloutNode extends ElementNode {
return self.__category;
}
setId(id: string) {
const self = this.getWritable();
self.__id = id;
}
getId(): string {
const self = this.getLatest();
return self.__id;
}
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
const element = document.createElement('p');
element.classList.add('callout', this.__category || '');
if (this.__id) {
element.setAttribute('id', this.__id);
}
return element;
}
@ -88,8 +105,13 @@ export class CalloutNode extends ElementNode {
}
}
const node = new CalloutNode(category);
if (element.id) {
node.setId(element.id);
}
return {
node: new CalloutNode(category),
node,
};
},
priority: 3,
@ -106,11 +128,14 @@ export class CalloutNode extends ElementNode {
type: 'callout',
version: 1,
category: this.__category,
id: this.__id,
};
}
static importJSON(serializedNode: SerializedCalloutNode): CalloutNode {
return $createCalloutNode(serializedNode.category);
const node = $createCalloutNode(serializedNode.category);
node.setId(serializedNode.id);
return node;
}
}
@ -119,7 +144,7 @@ export function $createCalloutNode(category: CalloutCategory = 'info') {
return new CalloutNode(category);
}
export function $isCalloutNode(node: LexicalNode | null | undefined) {
export function $isCalloutNode(node: LexicalNode | null | undefined): node is CalloutNode {
return node instanceof CalloutNode;
}

View File

@ -2,7 +2,7 @@ import {
DecoratorNode,
DOMConversion,
DOMConversionMap,
DOMConversionOutput,
DOMConversionOutput, DOMExportOutput,
LexicalEditor, LexicalNode,
SerializedLexicalNode,
Spread
@ -33,7 +33,9 @@ export class CodeBlockNode extends DecoratorNode<EditorDecoratorAdapter> {
}
static clone(node: CodeBlockNode): CodeBlockNode {
return new CodeBlockNode(node.__language, node.__code);
const newNode = new CodeBlockNode(node.__language, node.__code);
newNode.__id = node.__id;
return newNode;
}
constructor(language: string = '', code: string = '', key?: string) {
@ -118,6 +120,13 @@ export class CodeBlockNode extends DecoratorNode<EditorDecoratorAdapter> {
return false;
}
exportDOM(editor: LexicalEditor): DOMExportOutput {
const dom = this.createDOM(editor._config, editor);
return {
element: dom.querySelector('pre') as HTMLElement,
};
}
static importDOM(): DOMConversionMap|null {
return {
pre(node: HTMLElement): DOMConversion|null {
@ -130,10 +139,13 @@ export class CodeBlockNode extends DecoratorNode<EditorDecoratorAdapter> {
|| '';
const code = codeEl ? (codeEl.textContent || '').trim() : (element.textContent || '').trim();
const node = $createCodeBlockNode(language, code);
return {
node: $createCodeBlockNode(language, code),
};
if (element.id) {
node.setId(element.id);
}
return { node };
},
priority: 3,
};

View File

@ -0,0 +1,120 @@
import {
DOMConversionMap,
DOMConversionOutput, ElementFormatType,
LexicalNode,
Spread
} from "lexical";
import {EditorConfig} from "lexical/LexicalEditor";
import {HeadingNode, HeadingTagType, SerializedHeadingNode} from "@lexical/rich-text";
export type SerializedCustomHeadingNode = Spread<{
id: string;
}, SerializedHeadingNode>
export class CustomHeadingNode extends HeadingNode {
__id: string = '';
static getType() {
return 'custom-heading';
}
setId(id: string) {
const self = this.getWritable();
self.__id = id;
}
getId(): string {
const self = this.getLatest();
return self.__id;
}
static clone(node: CustomHeadingNode) {
const newNode = new CustomHeadingNode(node.__tag, node.__key);
newNode.__id = node.__id;
return newNode;
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
if (this.__id) {
dom.setAttribute('id', this.__id);
}
return dom;
}
exportJSON(): SerializedCustomHeadingNode {
return {
...super.exportJSON(),
type: 'custom-heading',
version: 1,
id: this.__id,
};
}
static importJSON(serializedNode: SerializedCustomHeadingNode): CustomHeadingNode {
const node = $createCustomHeadingNode(serializedNode.tag);
node.setId(serializedNode.id);
return node;
}
static importDOM(): DOMConversionMap | null {
return {
h1: (node: Node) => ({
conversion: $convertHeadingElement,
priority: 0,
}),
h2: (node: Node) => ({
conversion: $convertHeadingElement,
priority: 0,
}),
h3: (node: Node) => ({
conversion: $convertHeadingElement,
priority: 0,
}),
h4: (node: Node) => ({
conversion: $convertHeadingElement,
priority: 0,
}),
h5: (node: Node) => ({
conversion: $convertHeadingElement,
priority: 0,
}),
h6: (node: Node) => ({
conversion: $convertHeadingElement,
priority: 0,
}),
};
}
}
function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
const nodeName = element.nodeName.toLowerCase();
let node = null;
if (
nodeName === 'h1' ||
nodeName === 'h2' ||
nodeName === 'h3' ||
nodeName === 'h4' ||
nodeName === 'h5' ||
nodeName === 'h6'
) {
node = $createCustomHeadingNode(nodeName);
if (element.style !== null) {
node.setFormat(element.style.textAlign as ElementFormatType);
}
if (element.id) {
node.setId(element.id);
}
}
return {node};
}
export function $createCustomHeadingNode(tag: HeadingTagType) {
return new CustomHeadingNode(tag);
}
export function $isCustomHeadingNode(node: LexicalNode | null | undefined): node is CustomHeadingNode {
return node instanceof CustomHeadingNode;
}

View File

@ -0,0 +1,92 @@
import {
DOMConversionFn,
DOMConversionMap,
LexicalNode,
Spread
} from "lexical";
import {EditorConfig} from "lexical/LexicalEditor";
import {ListNode, ListType, SerializedListNode} from "@lexical/list";
export type SerializedCustomListNode = Spread<{
id: string;
}, SerializedListNode>
export class CustomListNode extends ListNode {
__id: string = '';
static getType() {
return 'custom-list';
}
setId(id: string) {
const self = this.getWritable();
self.__id = id;
}
getId(): string {
const self = this.getLatest();
return self.__id;
}
static clone(node: CustomListNode) {
const newNode = new CustomListNode(node.__listType, 0, node.__key);
newNode.__id = node.__id;
return newNode;
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
if (this.__id) {
dom.setAttribute('id', this.__id);
}
return dom;
}
exportJSON(): SerializedCustomListNode {
return {
...super.exportJSON(),
type: 'custom-list',
version: 1,
id: this.__id,
};
}
static importJSON(serializedNode: SerializedCustomListNode): CustomListNode {
const node = $createCustomListNode(serializedNode.listType);
node.setId(serializedNode.id);
return node;
}
static importDOM(): DOMConversionMap | null {
// @ts-ignore
const converter = super.importDOM().ol().conversion as DOMConversionFn<HTMLElement>;
const customConvertFunction = (element: HTMLElement) => {
const baseResult = converter(element);
if (element.id && baseResult?.node) {
(baseResult.node as CustomListNode).setId(element.id);
}
return baseResult;
};
return {
ol: () => ({
conversion: customConvertFunction,
priority: 0,
}),
ul: () => ({
conversion: customConvertFunction,
priority: 0,
}),
};
}
}
export function $createCustomListNode(type: ListType): CustomListNode {
return new CustomListNode(type, 0);
}
export function $isCustomListNode(node: LexicalNode | null | undefined): node is CustomListNode {
return node instanceof CustomListNode;
}

View File

@ -31,7 +31,7 @@ export class CustomParagraphNode extends ParagraphNode {
return self.__id;
}
static clone(node: CustomParagraphNode) {
static clone(node: CustomParagraphNode): CustomParagraphNode {
const newNode = new CustomParagraphNode(node.__key);
newNode.__id = node.__id;
return newNode;
@ -39,9 +39,8 @@ export class CustomParagraphNode extends ParagraphNode {
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
const id = this.getId();
if (id) {
dom.setAttribute('id', id);
if (this.__id) {
dom.setAttribute('id', this.__id);
}
return dom;
@ -89,7 +88,7 @@ export class CustomParagraphNode extends ParagraphNode {
}
}
export function $createCustomParagraphNode() {
export function $createCustomParagraphNode(): CustomParagraphNode {
return new CustomParagraphNode();
}

View File

@ -0,0 +1,89 @@
import {
DOMConversionMap,
DOMConversionOutput, ElementFormatType,
LexicalNode,
Spread
} from "lexical";
import {EditorConfig} from "lexical/LexicalEditor";
import {QuoteNode, SerializedQuoteNode} from "@lexical/rich-text";
export type SerializedCustomQuoteNode = Spread<{
id: string;
}, SerializedQuoteNode>
export class CustomQuoteNode extends QuoteNode {
__id: string = '';
static getType() {
return 'custom-quote';
}
setId(id: string) {
const self = this.getWritable();
self.__id = id;
}
getId(): string {
const self = this.getLatest();
return self.__id;
}
static clone(node: CustomQuoteNode) {
const newNode = new CustomQuoteNode(node.__key);
newNode.__id = node.__id;
return newNode;
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
if (this.__id) {
dom.setAttribute('id', this.__id);
}
return dom;
}
exportJSON(): SerializedCustomQuoteNode {
return {
...super.exportJSON(),
type: 'custom-quote',
version: 1,
id: this.__id,
};
}
static importJSON(serializedNode: SerializedCustomQuoteNode): CustomQuoteNode {
const node = $createCustomQuoteNode();
node.setId(serializedNode.id);
return node;
}
static importDOM(): DOMConversionMap | null {
return {
blockquote: (node: Node) => ({
conversion: $convertBlockquoteElement,
priority: 0,
}),
};
}
}
function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
const node = $createCustomQuoteNode();
if (element.style !== null) {
node.setFormat(element.style.textAlign as ElementFormatType);
}
if (element.id) {
node.setId(element.id);
}
return {node};
}
export function $createCustomQuoteNode() {
return new CustomQuoteNode();
}
export function $isCustomQuoteNode(node: LexicalNode | null | undefined): node is CustomQuoteNode {
return node instanceof CustomQuoteNode;
}

View File

@ -4,28 +4,50 @@ import {
ElementNode,
LexicalEditor,
LexicalNode,
SerializedElementNode,
SerializedElementNode, Spread,
} from 'lexical';
import type {EditorConfig} from "lexical/LexicalEditor";
import {el} from "../utils/dom";
export type SerializedDetailsNode = Spread<{
id: string;
}, SerializedElementNode>
export class DetailsNode extends ElementNode {
__id: string = '';
static getType() {
return 'details';
}
static clone(node: DetailsNode) {
return new DetailsNode(node.__key);
setId(id: string) {
const self = this.getWritable();
self.__id = id;
}
getId(): string {
const self = this.getLatest();
return self.__id;
}
static clone(node: DetailsNode): DetailsNode {
const newNode = new DetailsNode(node.__key);
newNode.__id = node.__id;
return newNode;
}
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
return el('details');
const el = document.createElement('details');
if (this.__id) {
el.setAttribute('id', this.__id);
}
return el;
}
updateDOM(prevNode: DetailsNode, dom: HTMLElement) {
return false;
return prevNode.__id !== this.__id;
}
static importDOM(): DOMConversionMap|null {
@ -33,9 +55,12 @@ export class DetailsNode extends ElementNode {
details(node: HTMLElement): DOMConversion|null {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
return {
node: new DetailsNode(),
};
const node = new DetailsNode();
if (element.id) {
node.setId(element.id);
}
return {node};
},
priority: 3,
};
@ -43,16 +68,19 @@ export class DetailsNode extends ElementNode {
};
}
exportJSON(): SerializedElementNode {
exportJSON(): SerializedDetailsNode {
return {
...super.exportJSON(),
type: 'details',
version: 1,
id: this.__id,
};
}
static importJSON(serializedNode: SerializedElementNode): DetailsNode {
return $createDetailsNode();
static importJSON(serializedNode: SerializedDetailsNode): DetailsNode {
const node = $createDetailsNode();
node.setId(serializedNode.id);
return node;
}
}
@ -61,7 +89,7 @@ export function $createDetailsNode() {
return new DetailsNode();
}
export function $isDetailsNode(node: LexicalNode | null | undefined) {
export function $isDetailsNode(node: LexicalNode | null | undefined): node is DetailsNode {
return node instanceof DetailsNode;
}
@ -106,16 +134,16 @@ export class SummaryNode extends ElementNode {
};
}
static importJSON(serializedNode: SerializedElementNode): DetailsNode {
static importJSON(serializedNode: SerializedElementNode): SummaryNode {
return $createSummaryNode();
}
}
export function $createSummaryNode() {
export function $createSummaryNode(): SummaryNode {
return new SummaryNode();
}
export function $isSummaryNode(node: LexicalNode | null | undefined) {
export function $isSummaryNode(node: LexicalNode | null | undefined): node is SummaryNode {
return node instanceof SummaryNode;
}

View File

@ -30,7 +30,9 @@ export class DiagramNode extends DecoratorNode<EditorDecoratorAdapter> {
}
static clone(node: DiagramNode): DiagramNode {
return new DiagramNode(node.__drawingId, node.__drawingUrl);
const newNode = new DiagramNode(node.__drawingId, node.__drawingUrl);
newNode.__id = node.__id;
return newNode;
}
constructor(drawingId: string, drawingUrl: string, key?: string) {
@ -120,10 +122,13 @@ export class DiagramNode extends DecoratorNode<EditorDecoratorAdapter> {
const img = element.querySelector('img');
const drawingUrl = img?.getAttribute('src') || '';
const drawingId = element.getAttribute('drawio-diagram') || '';
const node = $createDiagramNode(drawingId, drawingUrl);
return {
node: $createDiagramNode(drawingId, drawingUrl),
};
if (element.id) {
node.setId(element.id);
}
return { node };
},
priority: 3,
};
@ -152,7 +157,7 @@ export function $createDiagramNode(drawingId: string = '', drawingUrl: string =
return new DiagramNode(drawingId, drawingUrl);
}
export function $isDiagramNode(node: LexicalNode | null | undefined) {
export function $isDiagramNode(node: LexicalNode | null | undefined): node is DiagramNode {
return node instanceof DiagramNode;
}

View File

@ -4,26 +4,48 @@ import {
ElementNode,
LexicalEditor,
LexicalNode,
SerializedElementNode,
SerializedElementNode, Spread,
} from 'lexical';
import type {EditorConfig} from "lexical/LexicalEditor";
export type SerializedHorizontalRuleNode = Spread<{
id: string;
}, SerializedElementNode>
export class HorizontalRuleNode extends ElementNode {
__id: string = '';
static getType() {
return 'horizontal-rule';
}
setId(id: string) {
const self = this.getWritable();
self.__id = id;
}
getId(): string {
const self = this.getLatest();
return self.__id;
}
static clone(node: HorizontalRuleNode): HorizontalRuleNode {
return new HorizontalRuleNode(node.__key);
const newNode = new HorizontalRuleNode(node.__key);
newNode.__id = node.__id;
return newNode;
}
createDOM(_config: EditorConfig, _editor: LexicalEditor) {
return document.createElement('hr');
createDOM(_config: EditorConfig, _editor: LexicalEditor): HTMLElement {
const el = document.createElement('hr');
if (this.__id) {
el.setAttribute('id', this.__id);
}
return el;
}
updateDOM(prevNode: unknown, dom: HTMLElement) {
return false;
updateDOM(prevNode: HorizontalRuleNode, dom: HTMLElement) {
return prevNode.__id !== this.__id;
}
static importDOM(): DOMConversionMap|null {
@ -31,9 +53,12 @@ export class HorizontalRuleNode extends ElementNode {
hr(node: HTMLElement): DOMConversion|null {
return {
conversion: (element: HTMLElement): DOMConversionOutput|null => {
return {
node: new HorizontalRuleNode(),
};
const node = new HorizontalRuleNode();
if (element.id) {
node.setId(element.id);
}
return {node};
},
priority: 3,
};
@ -41,24 +66,27 @@ export class HorizontalRuleNode extends ElementNode {
};
}
exportJSON(): SerializedElementNode {
exportJSON(): SerializedHorizontalRuleNode {
return {
...super.exportJSON(),
type: 'horizontal-rule',
version: 1,
id: this.__id,
};
}
static importJSON(serializedNode: SerializedElementNode): HorizontalRuleNode {
return $createHorizontalRuleNode();
static importJSON(serializedNode: SerializedHorizontalRuleNode): HorizontalRuleNode {
const node = $createHorizontalRuleNode();
node.setId(serializedNode.id);
return node;
}
}
export function $createHorizontalRuleNode() {
export function $createHorizontalRuleNode(): HorizontalRuleNode {
return new HorizontalRuleNode();
}
export function $isHorizontalRuleNode(node: LexicalNode | null | undefined) {
export function $isHorizontalRuleNode(node: LexicalNode | null | undefined): node is HorizontalRuleNode {
return node instanceof HorizontalRuleNode;
}

View File

@ -22,16 +22,19 @@ import {MediaNode} from "./media";
import {CustomListItemNode} from "./custom-list-item";
import {CustomTableCellNode} from "./custom-table-cell";
import {CustomTableRowNode} from "./custom-table-row";
import {CustomHeadingNode} from "./custom-heading";
import {CustomQuoteNode} from "./custom-quote";
import {CustomListNode} from "./custom-list";
/**
* Load the nodes for lexical.
*/
export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> | LexicalNodeReplacement)[] {
return [
CalloutNode, // Todo - Create custom
HeadingNode, // Todo - Create custom
QuoteNode, // Todo - Create custom
ListNode, // Todo - Create custom
CalloutNode,
CustomHeadingNode,
CustomQuoteNode,
CustomListNode,
CustomListItemNode,
CustomTableNode,
CustomTableRowNode,
@ -42,7 +45,7 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
CodeBlockNode,
DiagramNode,
MediaNode,
CustomParagraphNode,
CustomParagraphNode, // TODO - ID
LinkNode,
{
replace: ParagraphNode,
@ -50,6 +53,24 @@ export function getNodesForPageEditor(): (KlassConstructor<typeof LexicalNode> |
return new CustomParagraphNode();
}
},
{
replace: HeadingNode,
with: (node: HeadingNode) => {
return new CustomHeadingNode(node.__tag);
}
},
{
replace: QuoteNode,
with: (node: QuoteNode) => {
return new CustomQuoteNode();
}
},
{
replace: ListNode,
with: (node: ListNode) => {
return new CustomListNode(node.getListType(), node.getStart());
}
},
{
replace: ListItemNode,
with: (node: ListItemNode) => {

View File

@ -66,7 +66,6 @@ function domElementToNode(tag: MediaNodeTag, element: Element): MediaNode {
}
export class MediaNode extends ElementNode {
__tag: MediaNodeTag;
__attributes: Record<string, string> = {};
__sources: MediaNodeSource[] = [];
@ -76,7 +75,10 @@ export class MediaNode extends ElementNode {
}
static clone(node: MediaNode) {
return new MediaNode(node.__tag, node.__key);
const newNode = new MediaNode(node.__tag, node.__key);
newNode.__attributes = Object.assign({}, node.__attributes);
newNode.__sources = node.__sources.map(s => Object.assign({}, s));
return newNode;
}
constructor(tag: MediaNodeTag, key?: string) {
@ -226,10 +228,10 @@ export function $createMediaNodeFromSrc(src: string): MediaNode {
return new MediaNode(nodeTag);
}
export function $isMediaNode(node: LexicalNode | null | undefined) {
export function $isMediaNode(node: LexicalNode | null | undefined): node is MediaNode {
return node instanceof MediaNode;
}
export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag) {
export function $isMediaNodeOfTag(node: LexicalNode | null | undefined, tag: MediaNodeTag): boolean {
return node instanceof MediaNode && (node as MediaNode).getTag() === tag;
}

View File

@ -2,13 +2,14 @@
## In progress
//
## Main Todo
- Alignments: Use existing classes for blocks (including table cells)
- Alignments: Handle inline block content (image, video)
- Image paste upload
- Keyboard shortcuts support
- Add ID support to all block types
- Link popup menu for cross-content reference
- Link heading-based ID reference menu
- Image gallery integration for insert