@withl5e/richtext-payload 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/dist/index.js +316 -0
- package/dist/index.js.map +1 -0
- package/dist/stringToLexical.js +36 -0
- package/dist/stringToLexical.js.map +1 -0
- package/index.ts +1 -0
- package/package.json +52 -0
- package/src/richtext-render/converters/blockquote.tsx +12 -0
- package/src/richtext-render/converters/heading.tsx +14 -0
- package/src/richtext-render/converters/horizontalRule.tsx +5 -0
- package/src/richtext-render/converters/linebreak.tsx +6 -0
- package/src/richtext-render/converters/link.tsx +47 -0
- package/src/richtext-render/converters/list.tsx +60 -0
- package/src/richtext-render/converters/paragraph.tsx +24 -0
- package/src/richtext-render/converters/tab.tsx +6 -0
- package/src/richtext-render/converters/table.tsx +60 -0
- package/src/richtext-render/converters/text.tsx +37 -0
- package/src/richtext-render/converters/upload.tsx +83 -0
- package/src/richtext-render/defaultConverters.ts +28 -0
- package/src/richtext-render/index.tsx +190 -0
- package/src/richtext-render/types.ts +70 -0
- package/src/richtext-render/utils/genFragmentIdentifier.ts +12 -0
- package/src/richtext-render/utils/getTableOfContents.ts +93 -0
- package/src/richtext-render/utils/getTextOfLexical.ts +11 -0
- package/src/richtext-render/utils/getTimeRead.ts +12 -0
- package/src/richtext-render/utils/stringToLexical.ts +49 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { Fragment } from '@withl5e/l5e/jsx-runtime';
|
|
3
|
+
|
|
4
|
+
import type { SerializedListItemNode, SerializedListNode } from '@payloadcms/richtext-lexical';
|
|
5
|
+
import type { JSXConverters } from '../types.js';
|
|
6
|
+
|
|
7
|
+
export const ListJSXConverter: JSXConverters<SerializedListItemNode | SerializedListNode> = {
|
|
8
|
+
list: ({ node, nodesToJSX }) => {
|
|
9
|
+
const children = nodesToJSX({
|
|
10
|
+
nodes: node.children,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const NodeTag = node.tag;
|
|
14
|
+
|
|
15
|
+
return <NodeTag class={`list-${node?.listType}`}>{children}</NodeTag>;
|
|
16
|
+
},
|
|
17
|
+
listitem: ({ node, nodesToJSX, parent }) => {
|
|
18
|
+
const hasSubLists = node.children.some((child) => child.type === 'list');
|
|
19
|
+
|
|
20
|
+
const children = nodesToJSX({
|
|
21
|
+
nodes: node.children,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if ('listType' in parent && parent?.listType === 'check') {
|
|
25
|
+
const uuid = uuidv4();
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<li
|
|
29
|
+
aria-checked={node.checked ? 'true' : 'false'}
|
|
30
|
+
class={`list-item-checkbox${node.checked ? ' list-item-checkbox-checked' : ' list-item-checkbox-unchecked'}${hasSubLists ? ' nestedListItem' : ''}`}
|
|
31
|
+
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
|
32
|
+
role="checkbox"
|
|
33
|
+
style="list-style-type: none;"
|
|
34
|
+
tabindex={'-1'}
|
|
35
|
+
value={node?.value}
|
|
36
|
+
>
|
|
37
|
+
{hasSubLists ? (
|
|
38
|
+
children
|
|
39
|
+
) : (
|
|
40
|
+
<>
|
|
41
|
+
<input checked={node.checked} id={uuid} readonly={true} type="checkbox" />
|
|
42
|
+
<label for={uuid}>{children}</label>
|
|
43
|
+
<br />
|
|
44
|
+
</>
|
|
45
|
+
)}
|
|
46
|
+
</li>
|
|
47
|
+
);
|
|
48
|
+
} else {
|
|
49
|
+
return (
|
|
50
|
+
<li
|
|
51
|
+
class={`${hasSubLists ? 'nestedListItem' : ''}`}
|
|
52
|
+
style={hasSubLists ? 'list-style-type: none;' : ''}
|
|
53
|
+
value={node?.value}
|
|
54
|
+
>
|
|
55
|
+
{children}
|
|
56
|
+
</li>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SerializedParagraphNode } from 'lexical';
|
|
2
|
+
import type { JSXConverter, JSXConverters } from '../types.js';
|
|
3
|
+
|
|
4
|
+
type ParagraphConverterArgs = Parameters<
|
|
5
|
+
Extract<JSXConverter<SerializedParagraphNode>, (...args: any[]) => JSX.Element>
|
|
6
|
+
>[0];
|
|
7
|
+
|
|
8
|
+
export const ParagraphJSXConverter: JSXConverters<SerializedParagraphNode> = {
|
|
9
|
+
paragraph: ({ node, nodesToJSX }: ParagraphConverterArgs) => {
|
|
10
|
+
const children = nodesToJSX({
|
|
11
|
+
nodes: node.children,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
if (!children?.length) {
|
|
15
|
+
return (
|
|
16
|
+
<p>
|
|
17
|
+
<br />
|
|
18
|
+
</p>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return <p>{children}</p>;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SerializedTableCellNode,
|
|
3
|
+
SerializedTableNode,
|
|
4
|
+
SerializedTableRowNode,
|
|
5
|
+
} from '@payloadcms/richtext-lexical';
|
|
6
|
+
import type { JSXConverters } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export const TableJSXConverter: JSXConverters<
|
|
9
|
+
SerializedTableCellNode | SerializedTableNode | SerializedTableRowNode
|
|
10
|
+
> = {
|
|
11
|
+
table: ({ node, nodesToJSX }) => {
|
|
12
|
+
const children = nodesToJSX({
|
|
13
|
+
nodes: node.children,
|
|
14
|
+
});
|
|
15
|
+
return (
|
|
16
|
+
<div class="lexical-table-container">
|
|
17
|
+
<table class="lexical-table" style="border-collapse: collapse;">
|
|
18
|
+
<tbody>{children}</tbody>
|
|
19
|
+
</table>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
},
|
|
23
|
+
tablecell: ({ node, nodesToJSX }) => {
|
|
24
|
+
const children = nodesToJSX({
|
|
25
|
+
nodes: node.children,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const TagName = node.headerState > 0 ? 'th' : 'td'; // Use capital letter to denote a component
|
|
29
|
+
const headerStateClass = `lexical-table-cell-header-${node.headerState}`;
|
|
30
|
+
let style = '';
|
|
31
|
+
if (node.backgroundColor) {
|
|
32
|
+
style += `background-color: ${node.backgroundColor};`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
style += `border: 1px solid #ccc;`;
|
|
36
|
+
|
|
37
|
+
style += `padding: 8px;`;
|
|
38
|
+
|
|
39
|
+
// Note: JSX does not support setting attributes directly as strings, so you must convert the colSpan and rowSpan to numbers
|
|
40
|
+
const colSpan = node.colSpan && node.colSpan > 1 ? node.colSpan : undefined;
|
|
41
|
+
const rowSpan = node.rowSpan && node.rowSpan > 1 ? node.rowSpan : undefined;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<TagName
|
|
45
|
+
class={`lexical-table-cell ${headerStateClass}`}
|
|
46
|
+
colspan={colSpan} // colSpan and rowSpan will only be added if they are not null
|
|
47
|
+
rowspan={rowSpan}
|
|
48
|
+
style={style}
|
|
49
|
+
>
|
|
50
|
+
{children}
|
|
51
|
+
</TagName>
|
|
52
|
+
);
|
|
53
|
+
},
|
|
54
|
+
tablerow: ({ node, nodesToJSX }) => {
|
|
55
|
+
const children = nodesToJSX({
|
|
56
|
+
nodes: node.children,
|
|
57
|
+
});
|
|
58
|
+
return <tr class="lexical-table-row">{children}</tr>;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NodeFormat } from '@payloadcms/richtext-lexical';
|
|
2
|
+
import type { SerializedTextNode } from 'lexical';
|
|
3
|
+
import type { JSXConverter, JSXConverters } from '../types.js';
|
|
4
|
+
|
|
5
|
+
type TextConverterArgs = Parameters<
|
|
6
|
+
Extract<JSXConverter<SerializedTextNode>, (...args: any[]) => JSX.Element>
|
|
7
|
+
>[0];
|
|
8
|
+
|
|
9
|
+
export const TextJSXConverter: JSXConverters<SerializedTextNode> = {
|
|
10
|
+
text: ({ node }: TextConverterArgs) => {
|
|
11
|
+
let text: JSX.Element = node.text;
|
|
12
|
+
|
|
13
|
+
if (node.format & NodeFormat.IS_BOLD) {
|
|
14
|
+
text = <strong>{text}</strong>;
|
|
15
|
+
}
|
|
16
|
+
if (node.format & NodeFormat.IS_ITALIC) {
|
|
17
|
+
text = <em>{text}</em>;
|
|
18
|
+
}
|
|
19
|
+
if (node.format & NodeFormat.IS_STRIKETHROUGH) {
|
|
20
|
+
text = <span style="text-decoration: line-through;">{text}</span>;
|
|
21
|
+
}
|
|
22
|
+
if (node.format & NodeFormat.IS_UNDERLINE) {
|
|
23
|
+
text = <span style="text-decoration: underline;">{text}</span>;
|
|
24
|
+
}
|
|
25
|
+
if (node.format & NodeFormat.IS_CODE) {
|
|
26
|
+
text = <code>{text}</code>;
|
|
27
|
+
}
|
|
28
|
+
if (node.format & NodeFormat.IS_SUBSCRIPT) {
|
|
29
|
+
text = <sub>{text}</sub>;
|
|
30
|
+
}
|
|
31
|
+
if (node.format & NodeFormat.IS_SUPERSCRIPT) {
|
|
32
|
+
text = <sup>{text}</sup>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return text;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { FileData, FileSizeImproved, TypeWithID } from 'payload';
|
|
2
|
+
|
|
3
|
+
import type { SerializedUploadNode } from '@payloadcms/richtext-lexical';
|
|
4
|
+
import type { JSXConverters } from '../types.js';
|
|
5
|
+
|
|
6
|
+
export const UploadJSXConverter: JSXConverters<SerializedUploadNode> = {
|
|
7
|
+
upload: ({ node }) => {
|
|
8
|
+
// TO-DO (v4): SerializedUploadNode should use UploadData_P4
|
|
9
|
+
const uploadNode = node as any;
|
|
10
|
+
if (typeof uploadNode.value !== 'object') {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const uploadDoc = uploadNode.value as FileData & TypeWithID;
|
|
15
|
+
|
|
16
|
+
const url = uploadDoc.url;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* If the upload is not an image, return a link to the upload
|
|
20
|
+
*/
|
|
21
|
+
if (!uploadDoc.mimeType.startsWith('image')) {
|
|
22
|
+
return (
|
|
23
|
+
<a href={url} rel="noopener noreferrer">
|
|
24
|
+
{uploadDoc.filename}
|
|
25
|
+
</a>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* If the upload is a simple image with no different sizes, return a simple img tag
|
|
31
|
+
*/
|
|
32
|
+
if (!uploadDoc.sizes || !Object.keys(uploadDoc.sizes).length) {
|
|
33
|
+
return (
|
|
34
|
+
<img alt={uploadDoc.filename} height={uploadDoc.height} src={url} width={uploadDoc.width} />
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* If the upload is an image with different sizes, return a picture element
|
|
40
|
+
*/
|
|
41
|
+
const pictureJSX: JSX.Element[] = [];
|
|
42
|
+
|
|
43
|
+
// Iterate through each size in the data.sizes object
|
|
44
|
+
for (const size in uploadDoc.sizes) {
|
|
45
|
+
const imageSize = uploadDoc.sizes[size] as FileSizeImproved;
|
|
46
|
+
|
|
47
|
+
// Skip if any property of the size object is null
|
|
48
|
+
if (
|
|
49
|
+
!imageSize ||
|
|
50
|
+
!imageSize.width ||
|
|
51
|
+
!imageSize.height ||
|
|
52
|
+
!imageSize.mimeType ||
|
|
53
|
+
!imageSize.filesize ||
|
|
54
|
+
!imageSize.filename ||
|
|
55
|
+
!imageSize.url
|
|
56
|
+
) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const imageSizeURL = imageSize?.url;
|
|
60
|
+
|
|
61
|
+
pictureJSX.push(
|
|
62
|
+
<source
|
|
63
|
+
key={size}
|
|
64
|
+
media={`(max-width: ${imageSize.width}px)`}
|
|
65
|
+
srcset={imageSizeURL}
|
|
66
|
+
type={imageSize.mimeType}
|
|
67
|
+
/>,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Add the default img tag
|
|
72
|
+
pictureJSX.push(
|
|
73
|
+
<img
|
|
74
|
+
alt={uploadDoc?.filename}
|
|
75
|
+
height={uploadDoc?.height}
|
|
76
|
+
key={'image'}
|
|
77
|
+
src={url}
|
|
78
|
+
width={uploadDoc?.width}
|
|
79
|
+
/>,
|
|
80
|
+
);
|
|
81
|
+
return <picture>{pictureJSX}</picture>;
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { JSXConverters } from './types.js';
|
|
2
|
+
|
|
3
|
+
import { DefaultNodeTypes } from '@payloadcms/richtext-lexical';
|
|
4
|
+
import { BlockquoteJSXConverter } from './converters/blockquote.js';
|
|
5
|
+
import { HeadingJSXConverter } from './converters/heading.js';
|
|
6
|
+
import { HorizontalRuleJSXConverter } from './converters/horizontalRule.js';
|
|
7
|
+
import { LinebreakJSXConverter } from './converters/linebreak.js';
|
|
8
|
+
import { LinkJSXConverter } from './converters/link.js';
|
|
9
|
+
import { ListJSXConverter } from './converters/list.js';
|
|
10
|
+
import { ParagraphJSXConverter } from './converters/paragraph.js';
|
|
11
|
+
import { TabJSXConverter } from './converters/tab.js';
|
|
12
|
+
import { TableJSXConverter } from './converters/table.js';
|
|
13
|
+
import { TextJSXConverter } from './converters/text.js';
|
|
14
|
+
import { UploadJSXConverter } from './converters/upload.js';
|
|
15
|
+
|
|
16
|
+
export const defaultJSXConverters: JSXConverters<DefaultNodeTypes> = {
|
|
17
|
+
...ParagraphJSXConverter,
|
|
18
|
+
...TextJSXConverter,
|
|
19
|
+
...LinebreakJSXConverter,
|
|
20
|
+
...BlockquoteJSXConverter,
|
|
21
|
+
...TableJSXConverter,
|
|
22
|
+
...HeadingJSXConverter,
|
|
23
|
+
...HorizontalRuleJSXConverter,
|
|
24
|
+
...ListJSXConverter,
|
|
25
|
+
...LinkJSXConverter({}),
|
|
26
|
+
...UploadJSXConverter,
|
|
27
|
+
...TabJSXConverter,
|
|
28
|
+
};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical';
|
|
3
|
+
import { cloneElement, Fragment, isValidElement } from '@withl5e/l5e/jsx-runtime';
|
|
4
|
+
|
|
5
|
+
import type { SerializedBlockNode, SerializedInlineBlockNode } from '@payloadcms/richtext-lexical';
|
|
6
|
+
import { hasText } from '@payloadcms/richtext-lexical/shared';
|
|
7
|
+
import type { JSXConverter, JSXConverters, SerializedLexicalNodeWithParent } from './types.js';
|
|
8
|
+
|
|
9
|
+
export { defaultJSXConverters } from './defaultConverters.js';
|
|
10
|
+
|
|
11
|
+
export { stringToLexical } from './utils/stringToLexical.js';
|
|
12
|
+
export { genFragmentIdentifier } from './utils/genFragmentIdentifier.js';
|
|
13
|
+
export {
|
|
14
|
+
buildTableOfContents,
|
|
15
|
+
getHeaders,
|
|
16
|
+
type TableOfContentsHeading,
|
|
17
|
+
type TableOfContentsHeadingWithChildren,
|
|
18
|
+
} from './utils/getTableOfContents.js';
|
|
19
|
+
export { getTextLexicalNode } from './utils/getTextOfLexical.js';
|
|
20
|
+
export { getTimeRead } from './utils/getTimeRead.js';
|
|
21
|
+
|
|
22
|
+
export type ConvertLexicalToJSXArgs = {
|
|
23
|
+
converters: JSXConverters;
|
|
24
|
+
data: SerializedEditorState;
|
|
25
|
+
disableIndent?: boolean | string[];
|
|
26
|
+
disableTextAlign?: boolean | string[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function convertLexicalToJSX({
|
|
30
|
+
converters,
|
|
31
|
+
data,
|
|
32
|
+
disableIndent,
|
|
33
|
+
disableTextAlign,
|
|
34
|
+
}: ConvertLexicalToJSXArgs): JSX.Element {
|
|
35
|
+
if (hasText(data)) {
|
|
36
|
+
return (
|
|
37
|
+
<Fragment>
|
|
38
|
+
{convertLexicalNodesToJSX({
|
|
39
|
+
converters,
|
|
40
|
+
disableIndent,
|
|
41
|
+
disableTextAlign,
|
|
42
|
+
nodes: data?.root?.children,
|
|
43
|
+
parent: data?.root,
|
|
44
|
+
})}
|
|
45
|
+
</Fragment>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return <Fragment />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function convertLexicalNodesToJSX({
|
|
52
|
+
converters,
|
|
53
|
+
disableIndent,
|
|
54
|
+
disableTextAlign,
|
|
55
|
+
nodes,
|
|
56
|
+
parent,
|
|
57
|
+
}: {
|
|
58
|
+
converters: JSXConverters;
|
|
59
|
+
disableIndent?: boolean | string[];
|
|
60
|
+
disableTextAlign?: boolean | string[];
|
|
61
|
+
nodes: SerializedLexicalNode[];
|
|
62
|
+
parent: SerializedLexicalNodeWithParent;
|
|
63
|
+
}): JSX.Element[] {
|
|
64
|
+
const unknownConverter: JSXConverter<any> = converters.unknown as JSXConverter<any>;
|
|
65
|
+
|
|
66
|
+
const jsxArray: JSX.Element[] = nodes.map((node, i) => {
|
|
67
|
+
let converterForNode: JSXConverter<any> | undefined;
|
|
68
|
+
if (node.type === 'block') {
|
|
69
|
+
converterForNode = converters?.blocks?.[(node as SerializedBlockNode)?.fields?.blockType];
|
|
70
|
+
if (!converterForNode && !unknownConverter) {
|
|
71
|
+
console.error(
|
|
72
|
+
`Lexical => JSX converter: Blocks converter: found ${(node as SerializedBlockNode)?.fields?.blockType} block, but no converter is provided`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
} else if (node.type === 'inlineBlock') {
|
|
76
|
+
converterForNode =
|
|
77
|
+
converters?.inlineBlocks?.[(node as SerializedInlineBlockNode)?.fields?.blockType];
|
|
78
|
+
if (!converterForNode && !unknownConverter) {
|
|
79
|
+
console.error(
|
|
80
|
+
`Lexical => JSX converter: Inline Blocks converter: found ${(node as SerializedInlineBlockNode)?.fields?.blockType} inline block, but no converter is provided`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
converterForNode = converters[node.type] as JSXConverter<any>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
if (!converterForNode && unknownConverter) {
|
|
89
|
+
converterForNode = unknownConverter;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let reactNode: JSX.Element;
|
|
93
|
+
if (converterForNode) {
|
|
94
|
+
const converted =
|
|
95
|
+
typeof converterForNode === 'function'
|
|
96
|
+
? converterForNode({
|
|
97
|
+
childIndex: i,
|
|
98
|
+
converters,
|
|
99
|
+
node,
|
|
100
|
+
nodesToJSX: (args) => {
|
|
101
|
+
return convertLexicalNodesToJSX({
|
|
102
|
+
converters: args.converters ?? converters,
|
|
103
|
+
disableIndent: args.disableIndent ?? disableIndent,
|
|
104
|
+
disableTextAlign: args.disableTextAlign ?? disableTextAlign,
|
|
105
|
+
nodes: args.nodes,
|
|
106
|
+
parent: args.parent ?? {
|
|
107
|
+
...node,
|
|
108
|
+
parent,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
parent,
|
|
113
|
+
})
|
|
114
|
+
: converterForNode;
|
|
115
|
+
reactNode = converted;
|
|
116
|
+
} else {
|
|
117
|
+
reactNode = <span key={i}>unknown node</span>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let style: string = '';
|
|
121
|
+
|
|
122
|
+
// Check if disableTextAlign is not true and does not include node type
|
|
123
|
+
if (
|
|
124
|
+
!disableTextAlign &&
|
|
125
|
+
(!Array.isArray(disableTextAlign) || !disableTextAlign?.includes(node.type))
|
|
126
|
+
) {
|
|
127
|
+
if ('format' in node && node.format) {
|
|
128
|
+
switch (node.format) {
|
|
129
|
+
case 'center':
|
|
130
|
+
style += 'text-align: center;';
|
|
131
|
+
break;
|
|
132
|
+
case 'end':
|
|
133
|
+
style += 'text-align: right;';
|
|
134
|
+
break;
|
|
135
|
+
case 'justify':
|
|
136
|
+
style += 'text-align: justify;';
|
|
137
|
+
break;
|
|
138
|
+
case 'left':
|
|
139
|
+
//style.textAlign = 'left'
|
|
140
|
+
// Do nothing, as left is the default
|
|
141
|
+
break;
|
|
142
|
+
case 'right':
|
|
143
|
+
style += 'text-align: right;';
|
|
144
|
+
break;
|
|
145
|
+
case 'start':
|
|
146
|
+
style += 'text-align: left;';
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
!disableIndent &&
|
|
154
|
+
(!Array.isArray(disableIndent) || !disableIndent?.includes(node.type))
|
|
155
|
+
) {
|
|
156
|
+
if ('indent' in node && node.indent && node.type !== 'listitem') {
|
|
157
|
+
// the unit should be px. Do not change it to rem, em, or something else.
|
|
158
|
+
// The quantity should be 40px. Do not change it either.
|
|
159
|
+
// See rationale in
|
|
160
|
+
// https://github.com/payloadcms/payload/issues/13130#issuecomment-3058348085
|
|
161
|
+
style += `padding-inline-start: ${Number(node.indent) * 40}px;`;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (isValidElement(reactNode)) {
|
|
166
|
+
// Inject style into reactNode
|
|
167
|
+
if (style) {
|
|
168
|
+
const existingStyle =
|
|
169
|
+
typeof reactNode?.props?.style === 'string' ? reactNode.props.style : '';
|
|
170
|
+
const newStyle = `${style} ${existingStyle}`.trim();
|
|
171
|
+
|
|
172
|
+
return cloneElement(reactNode, {
|
|
173
|
+
key: i,
|
|
174
|
+
style: newStyle,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
return cloneElement(reactNode, {
|
|
178
|
+
key: i,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return reactNode;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error('Error converting lexical node to JSX:', error, 'node:', node);
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
return jsxArray.filter(Boolean);
|
|
190
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { SerializedBlockNode, SerializedInlineBlockNode } from '@payloadcms/richtext-lexical';
|
|
2
|
+
import type { DefaultNodeTypes } from '@payloadcms/richtext-lexical';
|
|
3
|
+
import type { SerializedLexicalNode } from '@payloadcms/richtext-lexical/lexical';
|
|
4
|
+
|
|
5
|
+
export type JSXConverter<T extends { [key: string]: any; type?: string } = SerializedLexicalNode> =
|
|
6
|
+
| ((args: {
|
|
7
|
+
childIndex: number;
|
|
8
|
+
converters: JSXConverters;
|
|
9
|
+
node: T;
|
|
10
|
+
nodesToJSX: (args: {
|
|
11
|
+
converters?: JSXConverters;
|
|
12
|
+
disableIndent?: boolean | string[];
|
|
13
|
+
disableTextAlign?: boolean | string[];
|
|
14
|
+
nodes: SerializedLexicalNode[];
|
|
15
|
+
parent?: SerializedLexicalNodeWithParent;
|
|
16
|
+
}) => JSX.Element[];
|
|
17
|
+
parent: SerializedLexicalNodeWithParent;
|
|
18
|
+
}) => JSX.Element)
|
|
19
|
+
| JSX.Element;
|
|
20
|
+
|
|
21
|
+
export type JSXConverters<
|
|
22
|
+
T extends { [key: string]: any; type?: string } =
|
|
23
|
+
| DefaultNodeTypes
|
|
24
|
+
| SerializedBlockNode<{ blockName?: null | string; blockType: string }> // need these to ensure types for blocks and inlineBlocks work if no generics are provided
|
|
25
|
+
| SerializedInlineBlockNode<{ blockName?: null | string; blockType: string }>, // need these to ensure types for blocks and inlineBlocks work if no generics are provided
|
|
26
|
+
> = {
|
|
27
|
+
[key: string]:
|
|
28
|
+
| {
|
|
29
|
+
[blockSlug: string]: JSXConverter<any>;
|
|
30
|
+
}
|
|
31
|
+
| JSXConverter<any>
|
|
32
|
+
| undefined;
|
|
33
|
+
} & {
|
|
34
|
+
[nodeType in Exclude<NonNullable<T['type']>, 'block' | 'inlineBlock'>]?: JSXConverter<
|
|
35
|
+
Extract<T, { type: nodeType }>
|
|
36
|
+
>;
|
|
37
|
+
} & {
|
|
38
|
+
blocks?: {
|
|
39
|
+
[K in Extract<
|
|
40
|
+
Extract<T, { type: 'block' }> extends SerializedBlockNode<infer B>
|
|
41
|
+
? B extends { blockType: string }
|
|
42
|
+
? B['blockType']
|
|
43
|
+
: never
|
|
44
|
+
: never,
|
|
45
|
+
string
|
|
46
|
+
>]?: JSXConverter<
|
|
47
|
+
Extract<T, { type: 'block' }> extends SerializedBlockNode<infer B>
|
|
48
|
+
? SerializedBlockNode<Extract<B, { blockType: K }>>
|
|
49
|
+
: SerializedBlockNode
|
|
50
|
+
>;
|
|
51
|
+
};
|
|
52
|
+
inlineBlocks?: {
|
|
53
|
+
[K in Extract<
|
|
54
|
+
Extract<T, { type: 'inlineBlock' }> extends SerializedInlineBlockNode<infer B>
|
|
55
|
+
? B extends { blockType: string }
|
|
56
|
+
? B['blockType']
|
|
57
|
+
: never
|
|
58
|
+
: never,
|
|
59
|
+
string
|
|
60
|
+
>]?: JSXConverter<
|
|
61
|
+
Extract<T, { type: 'inlineBlock' }> extends SerializedInlineBlockNode<infer B>
|
|
62
|
+
? SerializedInlineBlockNode<Extract<B, { blockType: K }>>
|
|
63
|
+
: SerializedInlineBlockNode
|
|
64
|
+
>;
|
|
65
|
+
};
|
|
66
|
+
unknown?: JSXConverter<SerializedLexicalNode>;
|
|
67
|
+
};
|
|
68
|
+
export type SerializedLexicalNodeWithParent = {
|
|
69
|
+
parent?: SerializedLexicalNode;
|
|
70
|
+
} & SerializedLexicalNode;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function genFragmentIdentifier(text: string): string {
|
|
2
|
+
return text
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.normalize('NFD') // tách dấu ra khỏi ký tự
|
|
5
|
+
.replace(/[\u0300-\u036F]/g, '') // bỏ dấu VN
|
|
6
|
+
.replace(/đ/g, 'd')
|
|
7
|
+
.replace(/[\u2010-\u2015\u2212\uFE58\uFE63\uFF0D]/g, '-') // quy mọi dạng gạch ngang Unicode → '-'
|
|
8
|
+
.replace(/[\s\u00A0\/\\|_.,;:+=@&]+/g, '-') // khoảng trắng & dấu phân tách → '-'
|
|
9
|
+
.replace(/[^a-z0-9-]/g, '') // giữ lại a-z, số, dấu '-'
|
|
10
|
+
.replace(/-+/g, '-') // gộp nhiều dấu - liên tiếp bằng 1 dấu -
|
|
11
|
+
.replace(/^-|-$/g, ''); // bỏ - ở đầu/cuối (nếu có)
|
|
12
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { SerializedHeadingNode } from '@payloadcms/richtext-lexical';
|
|
2
|
+
import type { SerializedLexicalNode } from '@payloadcms/richtext-lexical/lexical';
|
|
3
|
+
import type { SerializedEditorState } from 'lexical';
|
|
4
|
+
import { getTextLexicalNode } from './getTextOfLexical.js';
|
|
5
|
+
|
|
6
|
+
export interface TableOfContentsHeading {
|
|
7
|
+
text: string;
|
|
8
|
+
level: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface TableOfContentsHeadingWithChildren extends TableOfContentsHeading {
|
|
12
|
+
children: TableOfContentsHeadingWithChildren[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Hàm lấy tất cả các heading từ content
|
|
16
|
+
export function getHeaders(
|
|
17
|
+
node: SerializedLexicalNode | SerializedEditorState,
|
|
18
|
+
): TableOfContentsHeading[] {
|
|
19
|
+
const headers: TableOfContentsHeading[] = [];
|
|
20
|
+
|
|
21
|
+
// Handle SerializedEditorState
|
|
22
|
+
if ('root' in node && node.root) {
|
|
23
|
+
return getHeaders(node.root);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// At this point, node must be SerializedLexicalNode
|
|
27
|
+
const lexicalNode = node as SerializedLexicalNode;
|
|
28
|
+
|
|
29
|
+
// Handle heading node
|
|
30
|
+
if (lexicalNode.type === 'heading') {
|
|
31
|
+
const headingNode = lexicalNode as SerializedHeadingNode;
|
|
32
|
+
const tag = headingNode.tag || '';
|
|
33
|
+
const level =
|
|
34
|
+
tag === 'h2'
|
|
35
|
+
? 2
|
|
36
|
+
: tag === 'h3'
|
|
37
|
+
? 3
|
|
38
|
+
: tag === 'h4'
|
|
39
|
+
? 4
|
|
40
|
+
: tag === 'h5'
|
|
41
|
+
? 5
|
|
42
|
+
: tag === 'h6'
|
|
43
|
+
? 6
|
|
44
|
+
: 0;
|
|
45
|
+
if (level > 0) {
|
|
46
|
+
const text = getTextLexicalNode(lexicalNode);
|
|
47
|
+
if (text) {
|
|
48
|
+
headers.push({
|
|
49
|
+
text,
|
|
50
|
+
level,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Recursively process children
|
|
57
|
+
if ('children' in lexicalNode && Array.isArray(lexicalNode.children)) {
|
|
58
|
+
lexicalNode.children.forEach((child) => {
|
|
59
|
+
headers.push(...getHeaders(child));
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return headers;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Xây dựng cấu trúc mục lục
|
|
67
|
+
export function buildTableOfContents(
|
|
68
|
+
headers: TableOfContentsHeading[],
|
|
69
|
+
): TableOfContentsHeadingWithChildren[] {
|
|
70
|
+
const result: TableOfContentsHeadingWithChildren[] = [];
|
|
71
|
+
const stack: TableOfContentsHeadingWithChildren[] = [];
|
|
72
|
+
|
|
73
|
+
headers.forEach((header) => {
|
|
74
|
+
const headingWithChildren: TableOfContentsHeadingWithChildren = {
|
|
75
|
+
...header,
|
|
76
|
+
children: [],
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= header.level) {
|
|
80
|
+
stack.pop();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (stack.length === 0) {
|
|
84
|
+
result.push(headingWithChildren);
|
|
85
|
+
} else {
|
|
86
|
+
stack[stack.length - 1].children.push(headingWithChildren);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
stack.push(headingWithChildren);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SerializedLexicalNode } from '@payloadcms/richtext-lexical/lexical';
|
|
2
|
+
import type { SerializedTextNode } from 'lexical';
|
|
3
|
+
|
|
4
|
+
export function getTextLexicalNode(node: SerializedLexicalNode | null | undefined): string {
|
|
5
|
+
if (!node) return '';
|
|
6
|
+
if (node.type === 'text') {
|
|
7
|
+
return (node as SerializedTextNode).text || '';
|
|
8
|
+
}
|
|
9
|
+
if (!('children' in node) || !Array.isArray(node.children)) return '';
|
|
10
|
+
return node.children.map((child) => getTextLexicalNode(child)).join('');
|
|
11
|
+
}
|