Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unstable serialization logic for node JSON parsing #2157

Merged
merged 29 commits into from
May 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions libdefs/globals.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ declare module '*.jpg' {
const content: any;
export default content;
}

export type Spread<T1, T2> = {[K in Exclude<keyof T1, keyof T2>]: T1[K]} & T2;
23 changes: 23 additions & 0 deletions packages/lexical-code/LexicalCode.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@ import type {
RangeSelection,
EditorThemeClasses,
LexicalEditor,
SerializedElementNode,
SerializedTextNode,
} from 'lexical';

import {ElementNode, TextNode} from 'lexical';
import {Spread} from 'libdefs/globals';

declare class CodeNode extends ElementNode {
static getType(): string;
Expand All @@ -31,6 +34,8 @@ declare class CodeNode extends ElementNode {
collapseAtStart(): true;
setLanguage(language: string): void;
getLanguage(): string | void;
importJSON(serializedNode: SerializedCodeNode): CodeNode;
exportJSON(): SerializedElementNode;
}
declare function $createCodeNode(language?: string): CodeNode;
declare function $isCodeNode(
Expand Down Expand Up @@ -74,3 +79,21 @@ declare function $isCodeHighlightNode(
): node is CodeHighlightNode;

declare function registerCodeHighlighting(editor: LexicalEditor): () => void;

type SerializedCodeNode = Spread<
{
language: string | null | undefined;
type: 'code';
version: 1;
},
SerializedElementNode
>;

type SerializedCodeHighlightNode = Spread<
{
highlightType: string | null | undefined;
type: 'code-highlight';
version: 1;
},
SerializedTextNode
>;
61 changes: 61 additions & 0 deletions packages/lexical-code/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import type {
NodeKey,
ParagraphNode,
RangeSelection,
SerializedElementNode,
SerializedTextNode,
} from 'lexical';

import * as Prism from 'prismjs';
Expand All @@ -39,6 +41,7 @@ import {
mergeRegister,
removeClassNamesFromElement,
} from '@lexical/utils';
import {Spread} from 'globals';
import {
$createLineBreakNode,
$createParagraphNode,
Expand All @@ -59,6 +62,24 @@ import {

const DEFAULT_CODE_LANGUAGE = 'javascript';

type SerializedCodeNode = Spread<
{
language: string | null | undefined;
type: 'code';
version: 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still need version numbers? AFAIK it was proposed by the Flow team but later you pointed out you still had problems with inheritance (and refinement?)

Copy link
Contributor

@acywatson acywatson May 19, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, I mostly solved the problems with inheritance, but the trade-off is that the argument passed to importJSON will have to be a union of all the serialized types of the superclasses in order to conform to the method signature on the superclass in Flow. See the "try flow" link in the PR description for an example.

As far as refinement goes, versions are actually the solution rather than a problem. If you look at the "try flow" link in the playground, you can see how they form a disjoint union in Flow (this can't really be done in TS) and can be used to refine the type of the serializedNode argument in importJSON.

Version is less important in TypeScript, since you can't use a disjoint union to refine. Instead, you have to use type guards. More info in the PR description.

},
SerializedElementNode
>;

type SerializedCodeHighlightNode = Spread<
{
highlightType: string | null | undefined;
type: 'code-highlight';
version: 1;
},
SerializedTextNode
>;

const mapToPrismLanguage = (
language: string | null | undefined,
): string | null | undefined => {
Expand Down Expand Up @@ -99,6 +120,11 @@ export class CodeHighlightNode extends TextNode {
);
}

getHighlightType(): string | null | undefined {
const self = this.getLatest<CodeHighlightNode>();
return self.__highlightType;
}

createDOM(config: EditorConfig): HTMLElement {
const element = super.createDOM(config);
const className = getHighlightThemeClass(
Expand Down Expand Up @@ -134,6 +160,25 @@ export class CodeHighlightNode extends TextNode {
return update;
}

static importJSON(
serializedNode: SerializedCodeHighlightNode,
): CodeHighlightNode {
const node = $createCodeHighlightNode(serializedNode.highlightType);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}

exportJSON(): SerializedCodeHighlightNode {
return {
...super.exportJSON(),
highlightType: this.getHighlightType(),
type: 'code-highlight',
};
}

// Prevent formatting (bold, underline, etc)
setFormat(format: number): this {
return this;
Expand Down Expand Up @@ -266,6 +311,22 @@ export class CodeNode extends ElementNode {
};
}

static importJSON(serializedNode: SerializedCodeNode): CodeNode {
const node = $createCodeNode(serializedNode.language);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this API solve the problem with new fields/deprecated fields? Is this where you'd add a default in some cases?

return node;
}

exportJSON(): SerializedCodeNode {
return {
...super.exportJSON(),
language: this.getLanguage(),
type: 'code',
};
}

// Mutation
insertNewAfter(
selection: RangeSelection,
Expand Down
8 changes: 7 additions & 1 deletion packages/lexical-hashtag/LexicalHashtag.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
*
*/

import type {EditorConfig, LexicalNode, NodeKey} from 'lexical';
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';
import {TextNode} from 'lexical';

export declare class HashtagNode extends TextNode {
Expand All @@ -16,6 +21,7 @@ export declare class HashtagNode extends TextNode {
createDOM(config: EditorConfig): HTMLElement;
canInsertTextBefore(): boolean;
isTextEntity(): true;
static importJSON(serializedNode: SerializedTextNode): HashtagNode;
}
export function $createHashtagNode(text?: string): TextNode;
export function $isHashtagNode(
Expand Down
9 changes: 8 additions & 1 deletion packages/lexical-hashtag/flow/LexicalHashtag.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,24 @@
* @flow strict
*/

import type {EditorConfig, LexicalNode, NodeKey} from 'lexical';
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';

import {TextNode} from 'lexical';

declare export class HashtagNode extends TextNode {
static getType(): string;
static clone(node: HashtagNode): HashtagNode;
static importJSON(serializedNode: SerializedTextNode): HashtagNode;
constructor(text: string, key?: NodeKey): void;
createDOM(config: EditorConfig): HTMLElement;
canInsertTextBefore(): boolean;
isTextEntity(): true;
exportJSON(): SerializedTextNode;
}
declare export function $createHashtagNode(text?: string): HashtagNode;
declare export function $isHashtagNode(
Expand Down
23 changes: 22 additions & 1 deletion packages/lexical-hashtag/src/LexicalHashtagNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
* @flow strict
*/

import type {EditorConfig, LexicalNode, NodeKey} from 'lexical';
import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';

import {addClassNamesToElement} from '@lexical/utils';
import {TextNode} from 'lexical';
Expand All @@ -31,6 +36,22 @@ export class HashtagNode extends TextNode {
return element;
}

static importJSON(serializedNode: SerializedTextNode): HashtagNode {
const node = $createHashtagNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
node.setStyle(serializedNode.style);
return node;
}

exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: 'hashtag',
};
}

canInsertTextBefore(): boolean {
return false;
}
Expand Down
55 changes: 55 additions & 0 deletions packages/lexical-link/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,20 @@ import type {
LexicalNode,
NodeKey,
RangeSelection,
SerializedElementNode,
} from 'lexical';

import {addClassNamesToElement} from '@lexical/utils';
import {$isElementNode, createCommand, ElementNode} from 'lexical';
import invariant from 'shared/invariant';

export type SerializedLinkNode = {
...SerializedElementNode,
type: 'link',
url: string,
version: 1,
...
};

export class LinkNode extends ElementNode {
__url: string;
Expand Down Expand Up @@ -67,6 +77,22 @@ export class LinkNode extends ElementNode {
};
}

static importJSON(serializedNode: SerializedLinkNode): LinkNode {
const node = $createLinkNode(serializedNode.url);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}

exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: 'link',
Copy link
Contributor

@tylerjbainbridge tylerjbainbridge May 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Random question, but I've noticed that in some places we use type to distinguish between nodes and in others we use the node's constructor/class. We should probably try to move towards only using type in the future if this will be serialized and used to find the node's constructor at import time.

Also another random question that's related, do we have anything in place to prevent you from loading two nodes with the same type? If not, we should throw or you'd get some funky behavior here if one of the plugins you load in adds a node that happens to collide with another.

Copy link
Contributor

@tylerjbainbridge tylerjbainbridge May 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or maybe you'd want to be able to "override" a node by calling another plugin after? Not sure, but feels potentially confusing and hard to debug.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably try to move towards only using type in the future

Great point! The reason why $isXNode works via inheritance right now is mostly due to Flow types (refines when checking prototypes). I'm not sure it's easy to switch now as it would be a major breaking change but we can discuss it offline to see what's the best way forward

url: this.getURL(),
};
}

getURL(): string {
return this.getLatest().__url;
}
Expand Down Expand Up @@ -119,6 +145,13 @@ export function $isLinkNode(node: ?LexicalNode): boolean %checks {
return node instanceof LinkNode;
}

export type SerializedAutoLinkNode = {
...SerializedLinkNode,
type: 'autolink',
version: 1,
...
};

// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
export class AutoLinkNode extends LinkNode {
Expand All @@ -131,6 +164,28 @@ export class AutoLinkNode extends LinkNode {
return new AutoLinkNode(node.__url, node.__key);
}

static importJSON(
serializedNode: SerializedLinkNode | SerializedAutoLinkNode,
): AutoLinkNode {
invariant(
serializedNode.type !== 'autolink',
'Incorrect node type received in importJSON for %s',
this.getType(),
);
const node = $createAutoLinkNode(serializedNode.url);
node.setFormat(serializedNode.format);
node.setIndent(serializedNode.indent);
node.setDirection(serializedNode.direction);
return node;
}

exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: 'autolink',
};
}

insertNewAfter(selection: RangeSelection): null | ElementNode {
const element = this.getParentOrThrow().insertNewAfter(selection);
if ($isElementNode(element)) {
Expand Down
26 changes: 26 additions & 0 deletions packages/lexical-list/LexicalList.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
*/

import {ListNodeTagType} from './src/LexicalListNode';
import {Spread} from 'globals';
import {
ElementNode,
LexicalNode,
LexicalEditor,
ParagraphNode,
RangeSelection,
LexicalCommand,
SerializedElementNode,
} from 'lexical';

export type ListType = 'number' | 'bullet' | 'check';
Expand All @@ -40,17 +42,41 @@ export declare class ListItemNode extends ElementNode {
getChecked(): boolean | void;
setChecked(boolean): this;
toggleChecked(): void;
static importJSON(serializedNode: SerializedListItemNode): ListItemNode;
exportJSON(): SerializedListItemNode;
}
export declare class ListNode extends ElementNode {
canBeEmpty(): false;
append(...nodesToAppend: LexicalNode[]): ListNode;
getTag(): ListNodeTagType;
getListType(): ListType;
static importJSON(serializedNode: SerializedListNode): ListNode;
exportJSON(): SerializedListNode;
}

export function outdentList(): void;
export function removeList(editor: LexicalEditor): boolean;

export var INSERT_UNORDERED_LIST_COMMAND: LexicalCommand<void>;
export var INSERT_ORDERED_LIST_COMMAND: LexicalCommand<void>;
export var INSERT_CHECK_LIST_COMMAND: LexicalCommand<void>;
export var REMOVE_LIST_COMMAND: LexicalCommand<void>;

export type SerializedListItemNode = Spread<
{
checked: boolean | void;
value: number;
type: 'listitem';
},
SerializedElementNode
>;

export type SerializedListNode = Spread<
{
listType: ListType;
start: number;
tag: ListNodeTagType;
type: 'list';
},
SerializedElementNode
>;
Loading