@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.
@@ -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,6 @@
1
+ import type { SerializedTabNode } from 'lexical';
2
+ import type { JSXConverters } from '../types.js';
3
+
4
+ export const TabJSXConverter: JSXConverters<SerializedTabNode> = {
5
+ tab: '\t',
6
+ };
@@ -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
+ }