5htp-core 0.4.9-5 → 0.4.9-6
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/package.json +1 -1
- package/src/client/components/inputv3/Rte/ToolbarPlugin/index.tsx +2 -11
- package/src/client/components/inputv3/Rte/nodes/HeadingNode.ts +55 -0
- package/src/client/components/inputv3/Rte/plugins/ComponentPickerPlugin/index.tsx +9 -6
- package/src/client/components/inputv3/Rte/themes/PlaygroundEditorTheme.css +0 -11
- package/src/client/components/inputv3/Rte/themes/PlaygroundEditorTheme.ts +1 -1
- package/src/common/data/rte/nodes.ts +9 -1
- package/src/server/utils/rte.ts +234 -122
- package/src/server/utils/slug.ts +2 -2
- package/src/client/components/inputv3/Rte/nodes/PlaygroundNodes.ts +0 -76
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "5htp-core",
|
|
3
3
|
"description": "Convenient TypeScript framework designed for Performance and Productivity.",
|
|
4
|
-
"version": "0.4.9-
|
|
4
|
+
"version": "0.4.9-6",
|
|
5
5
|
"author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
|
|
6
6
|
"repository": "git://github.com/gaetanlegac/5htp-core.git",
|
|
7
7
|
"license": "MIT",
|
|
@@ -16,7 +16,6 @@ import ElementFormatDropdown from './ElementFormat';
|
|
|
16
16
|
import BlockFormatDropDown, { blockTypeNames, rootTypeToRootName } from './BlockFormat';
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
|
-
$createCodeNode,
|
|
20
19
|
$isCodeNode,
|
|
21
20
|
CODE_LANGUAGE_FRIENDLY_NAME_MAP,
|
|
22
21
|
CODE_LANGUAGE_MAP,
|
|
@@ -25,9 +24,6 @@ import {
|
|
|
25
24
|
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
|
|
26
25
|
import {
|
|
27
26
|
$isListNode,
|
|
28
|
-
INSERT_CHECK_LIST_COMMAND,
|
|
29
|
-
INSERT_ORDERED_LIST_COMMAND,
|
|
30
|
-
INSERT_UNORDERED_LIST_COMMAND,
|
|
31
27
|
ListNode,
|
|
32
28
|
} from '@lexical/list';
|
|
33
29
|
import { INSERT_EMBED_COMMAND } from '@lexical/react/LexicalAutoEmbedPlugin';
|
|
@@ -35,17 +31,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
|
|
35
31
|
import { $isDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode';
|
|
36
32
|
import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode';
|
|
37
33
|
import {
|
|
38
|
-
$createHeadingNode,
|
|
39
|
-
$createQuoteNode,
|
|
40
34
|
$isHeadingNode,
|
|
41
35
|
$isQuoteNode,
|
|
42
|
-
HeadingTagType,
|
|
43
36
|
} from '@lexical/rich-text';
|
|
44
37
|
import {
|
|
45
38
|
$getSelectionStyleValueForProperty,
|
|
46
39
|
$isParentElementRTL,
|
|
47
40
|
$patchStyleText,
|
|
48
|
-
$setBlocksType,
|
|
49
41
|
} from '@lexical/selection';
|
|
50
42
|
import { $isTableNode, $isTableSelection } from '@lexical/table';
|
|
51
43
|
import {
|
|
@@ -126,7 +118,7 @@ function dropDownActiveClass(active: boolean) {
|
|
|
126
118
|
|
|
127
119
|
|
|
128
120
|
|
|
129
|
-
function Divider(): JSX.Element {
|
|
121
|
+
function Divider(): React.JSX.Element {
|
|
130
122
|
return <div className="divider" />;
|
|
131
123
|
}
|
|
132
124
|
|
|
@@ -134,7 +126,7 @@ export default function ToolbarPlugin({
|
|
|
134
126
|
setIsLinkEditMode,
|
|
135
127
|
}: {
|
|
136
128
|
setIsLinkEditMode: Dispatch<boolean>;
|
|
137
|
-
}): JSX.Element {
|
|
129
|
+
}): React.JSX.Element {
|
|
138
130
|
|
|
139
131
|
const { modal } = useContext();
|
|
140
132
|
|
|
@@ -603,7 +595,6 @@ export default function ToolbarPlugin({
|
|
|
603
595
|
<DropDown popover={{ tag: 'li' }} icon="font" size="s"
|
|
604
596
|
disabled={!isEditable}
|
|
605
597
|
title="Formatting options for additional text styles"
|
|
606
|
-
buttonIconClassName="icon dropdown-more"
|
|
607
598
|
>
|
|
608
599
|
|
|
609
600
|
<Button icon="strikethrough" size="s"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
|
|
2
|
+
import { HeadingNode, HeadingTagType } from '@lexical/rich-text';
|
|
3
|
+
|
|
4
|
+
export default class HeadingWithAnchorNode extends HeadingNode {
|
|
5
|
+
|
|
6
|
+
public anchor?: string;
|
|
7
|
+
|
|
8
|
+
constructor( tag: HeadingTagType, key?: string ) {
|
|
9
|
+
super(tag, key);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Adding a static method to register the custom node
|
|
13
|
+
static getType() {
|
|
14
|
+
return 'anchored-heading';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static clone(node) {
|
|
18
|
+
return new HeadingWithAnchorNode(node.getTag(), node.__key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Add a `anchor` attribute to the serialized JSON
|
|
22
|
+
static importJSON(serializedNode) {
|
|
23
|
+
const node = new HeadingWithAnchorNode(serializedNode.tag);
|
|
24
|
+
node.anchor = serializedNode.anchor;
|
|
25
|
+
return node;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Ensure the `anchor` attribute is serialized in JSON
|
|
29
|
+
exportJSON() {
|
|
30
|
+
return {
|
|
31
|
+
...super.exportJSON(),
|
|
32
|
+
type: HeadingWithAnchorNode.getType(),
|
|
33
|
+
anchor: this.anchor,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Override createDOM to set the `anchor` attribute
|
|
38
|
+
createDOM(config) {
|
|
39
|
+
const dom = super.createDOM(config);
|
|
40
|
+
if (this.anchor) {
|
|
41
|
+
dom.setAttribute('id', this.anchor);
|
|
42
|
+
}
|
|
43
|
+
return dom;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Update the DOM to reflect changes in the `anchor` attribute
|
|
47
|
+
updateDOM(prevNode, dom) {
|
|
48
|
+
const updated = super.updateDOM(prevNode, dom);
|
|
49
|
+
if (this.anchor !== prevNode.anchor) {
|
|
50
|
+
dom.setAttribute('id', this.anchor);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return updated;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -36,7 +36,10 @@ import * as React from 'react';
|
|
|
36
36
|
import * as ReactDOM from 'react-dom';
|
|
37
37
|
|
|
38
38
|
// Core
|
|
39
|
-
import useContext from '@/client/context';
|
|
39
|
+
import useContext, { ClientContext } from '@/client/context';
|
|
40
|
+
|
|
41
|
+
// Custom Nodes
|
|
42
|
+
import AnchoredHeadingNode from '@client/components/inputv3/Rte/nodes/HeadingNode';
|
|
40
43
|
|
|
41
44
|
import { EmbedConfigs } from '../AutoEmbedPlugin';
|
|
42
45
|
import { INSERT_COLLAPSIBLE_COMMAND } from '../CollapsiblePlugin';
|
|
@@ -50,7 +53,7 @@ class ComponentPickerOption extends MenuOption {
|
|
|
50
53
|
// What shows up in the editor
|
|
51
54
|
title: string;
|
|
52
55
|
// Icon for display
|
|
53
|
-
icon?: JSX.Element;
|
|
56
|
+
icon?: React.JSX.Element;
|
|
54
57
|
// For extra searching.
|
|
55
58
|
keywords: Array<string>;
|
|
56
59
|
// TBD
|
|
@@ -61,7 +64,7 @@ class ComponentPickerOption extends MenuOption {
|
|
|
61
64
|
constructor(
|
|
62
65
|
title: string,
|
|
63
66
|
options: {
|
|
64
|
-
icon?: JSX.Element;
|
|
67
|
+
icon?: React.JSX.Element;
|
|
65
68
|
keywords?: Array<string>;
|
|
66
69
|
keyboardShortcut?: string;
|
|
67
70
|
onSelect: (queryString: string) => void;
|
|
@@ -141,7 +144,7 @@ function getDynamicOptions(editor: LexicalEditor, queryString: string) {
|
|
|
141
144
|
return options;
|
|
142
145
|
}
|
|
143
146
|
|
|
144
|
-
function getBaseOptions(editor: LexicalEditor, { modal }:
|
|
147
|
+
function getBaseOptions(editor: LexicalEditor, { modal }: ClientContext) {
|
|
145
148
|
return [
|
|
146
149
|
new ComponentPickerOption('Paragraph', {
|
|
147
150
|
icon: <i className="icon paragraph" />,
|
|
@@ -163,7 +166,7 @@ function getBaseOptions(editor: LexicalEditor, { modal }: TContext) {
|
|
|
163
166
|
editor.update(() => {
|
|
164
167
|
const selection = $getSelection();
|
|
165
168
|
if ($isRangeSelection(selection)) {
|
|
166
|
-
$setBlocksType(selection, () =>
|
|
169
|
+
$setBlocksType(selection, () => new AnchoredHeadingNode(`h${n}`));
|
|
167
170
|
}
|
|
168
171
|
}),
|
|
169
172
|
}),
|
|
@@ -281,7 +284,7 @@ function getBaseOptions(editor: LexicalEditor, { modal }: TContext) {
|
|
|
281
284
|
];
|
|
282
285
|
}
|
|
283
286
|
|
|
284
|
-
export default function ComponentPickerMenuPlugin(): JSX.Element {
|
|
287
|
+
export default function ComponentPickerMenuPlugin(): React.JSX.Element {
|
|
285
288
|
|
|
286
289
|
const [editor] = useLexicalComposerContext();
|
|
287
290
|
const { modal, context } = useContext();
|
|
@@ -16,17 +16,6 @@
|
|
|
16
16
|
margin: 0;
|
|
17
17
|
position: relative;
|
|
18
18
|
}
|
|
19
|
-
.PlaygroundEditorTheme__quote {
|
|
20
|
-
margin: 0;
|
|
21
|
-
margin-left: 20px;
|
|
22
|
-
margin-bottom: 10px;
|
|
23
|
-
font-size: 15px;
|
|
24
|
-
color: rgb(101, 103, 107);
|
|
25
|
-
border-left-color: rgb(206, 208, 212);
|
|
26
|
-
border-left-width: 4px;
|
|
27
|
-
border-left-style: solid;
|
|
28
|
-
padding-left: 16px;
|
|
29
|
-
}
|
|
30
19
|
.PlaygroundEditorTheme__h1 {
|
|
31
20
|
font-size: 24px;
|
|
32
21
|
color: rgb(5, 5, 5);
|
|
@@ -88,7 +88,7 @@ const theme: EditorThemeClasses = {
|
|
|
88
88
|
mark: 'PlaygroundEditorTheme__mark',
|
|
89
89
|
markOverlap: 'PlaygroundEditorTheme__markOverlap',
|
|
90
90
|
paragraph: 'PlaygroundEditorTheme__paragraph',
|
|
91
|
-
quote: '
|
|
91
|
+
quote: '',
|
|
92
92
|
rtl: 'PlaygroundEditorTheme__rtl',
|
|
93
93
|
table: 'PlaygroundEditorTheme__table',
|
|
94
94
|
tableCell: 'PlaygroundEditorTheme__tableCell',
|
|
@@ -35,8 +35,16 @@ import { StickyNode } from '@client/components/inputv3/Rte/nodes/StickyNode';
|
|
|
35
35
|
import { TweetNode } from '@client/components/inputv3/Rte/nodes/TweetNode';
|
|
36
36
|
import { YouTubeNode } from '@client/components/inputv3/Rte/nodes/YouTubeNode';
|
|
37
37
|
|
|
38
|
+
import HeadingWithAnchorNode from '@client/components/inputv3/Rte/nodes/HeadingNode';
|
|
39
|
+
|
|
38
40
|
const PlaygroundNodes: Array<Klass<LexicalNode>> = [
|
|
39
|
-
HeadingNode,
|
|
41
|
+
/*HeadingNode, */HeadingWithAnchorNode,
|
|
42
|
+
{
|
|
43
|
+
replace: HeadingNode,
|
|
44
|
+
with: (node) => {
|
|
45
|
+
return new HeadingWithAnchorNode( node.getTag(), node.__key );
|
|
46
|
+
}
|
|
47
|
+
},
|
|
40
48
|
ListNode,
|
|
41
49
|
ListItemNode,
|
|
42
50
|
QuoteNode,
|
package/src/server/utils/rte.ts
CHANGED
|
@@ -10,7 +10,7 @@ import md5 from 'md5';
|
|
|
10
10
|
import { fromBuffer } from 'file-type';
|
|
11
11
|
import { JSDOM } from 'jsdom';
|
|
12
12
|
// Lexical
|
|
13
|
-
import { $getRoot
|
|
13
|
+
import { $getRoot } from 'lexical';
|
|
14
14
|
import { createHeadlessEditor } from '@lexical/headless';
|
|
15
15
|
import { $generateNodesFromDOM, $generateHtmlFromNodes } from '@lexical/html';
|
|
16
16
|
|
|
@@ -19,17 +19,46 @@ import { Anomaly } from '@common/errors';
|
|
|
19
19
|
import editorNodes from '@common/data/rte/nodes';
|
|
20
20
|
import ExampleTheme from '@client/components/inputv3/Rte/themes/PlaygroundEditorTheme';
|
|
21
21
|
import type Driver from '@server/services/disks/driver';
|
|
22
|
+
import Slug from '@server/utils/slug';
|
|
22
23
|
|
|
23
24
|
/*----------------------------------
|
|
24
25
|
- TYPES
|
|
25
26
|
----------------------------------*/
|
|
26
27
|
|
|
27
|
-
type
|
|
28
|
+
type LexicalState = {
|
|
29
|
+
root: LexicalNode
|
|
30
|
+
}
|
|
28
31
|
|
|
29
|
-
type
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
type LexicalNode = {
|
|
33
|
+
version: number,
|
|
34
|
+
type: string,
|
|
35
|
+
children?: LexicalNode[],
|
|
36
|
+
// Attachement
|
|
37
|
+
src?: string;
|
|
38
|
+
// Headhing
|
|
39
|
+
text?: string;
|
|
40
|
+
anchor?: string;
|
|
41
|
+
tag?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type TRenderOptions = {
|
|
45
|
+
attachements?: {
|
|
46
|
+
disk: Driver,
|
|
47
|
+
directory: string,
|
|
48
|
+
prevVersion?: string | LexicalState | null,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type TSkeleton = {
|
|
53
|
+
id: string,
|
|
54
|
+
title: string,
|
|
55
|
+
level: number,
|
|
56
|
+
childrens: TSkeleton
|
|
57
|
+
}[];
|
|
58
|
+
|
|
59
|
+
type TContentAssets = {
|
|
60
|
+
attachements: string[],
|
|
61
|
+
skeleton: TSkeleton
|
|
33
62
|
}
|
|
34
63
|
|
|
35
64
|
/*----------------------------------
|
|
@@ -39,70 +68,198 @@ type TAttachmentsOptions = {
|
|
|
39
68
|
export class RteUtils {
|
|
40
69
|
|
|
41
70
|
public async render(
|
|
42
|
-
content: string |
|
|
43
|
-
|
|
44
|
-
) {
|
|
71
|
+
content: string | LexicalState,
|
|
72
|
+
options: TRenderOptions = {}
|
|
73
|
+
): Promise<TContentAssets & {
|
|
74
|
+
html: string,
|
|
75
|
+
json: string | LexicalState,
|
|
76
|
+
}> {
|
|
45
77
|
|
|
46
78
|
// Parse content if string
|
|
79
|
+
let json: LexicalState;
|
|
47
80
|
if (typeof content === 'string') {
|
|
48
81
|
try {
|
|
49
|
-
|
|
82
|
+
json = JSON.parse(content) as LexicalState;
|
|
50
83
|
} catch (error) {
|
|
51
84
|
throw new Anomaly("Invalid JSON format for the given JSON RTE content.");
|
|
52
85
|
}
|
|
86
|
+
} else
|
|
87
|
+
json = content;
|
|
88
|
+
|
|
89
|
+
// Parse prev version if string
|
|
90
|
+
if (typeof options?.attachements?.prevVersion === 'string') {
|
|
91
|
+
try {
|
|
92
|
+
options.attachements.prevVersion = JSON.parse(options.attachements.prevVersion) as LexicalState;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
throw new Anomaly("Invalid JSON format for the given JSON RTE prev version.");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Transform content
|
|
99
|
+
const assets: TContentAssets = {
|
|
100
|
+
attachements: [],
|
|
101
|
+
skeleton: []
|
|
53
102
|
}
|
|
54
103
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
104
|
+
const root = await this.processContent(json.root, async (node) => {
|
|
105
|
+
return await this.transformNode(node, assets, options);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
json = { ...json, root };
|
|
109
|
+
|
|
110
|
+
// Delete unused attachements
|
|
111
|
+
const attachementOptions = options?.attachements;
|
|
112
|
+
if (attachementOptions && attachementOptions.prevVersion !== undefined) {
|
|
113
|
+
|
|
114
|
+
await this.processContent(root, async (node) => {
|
|
115
|
+
return await this.deleteUnusedFile(node, assets, attachementOptions);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Convert json to HTML
|
|
120
|
+
const html = await this.jsonToHtml( json );
|
|
121
|
+
|
|
122
|
+
return { html, json: content, ...assets };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async processContent(
|
|
126
|
+
node: LexicalNode,
|
|
127
|
+
callback: (node: LexicalNode) => Promise<LexicalNode>
|
|
128
|
+
) {
|
|
129
|
+
|
|
130
|
+
node = await callback(node);
|
|
131
|
+
|
|
132
|
+
// Recursion
|
|
133
|
+
if (node.children) {
|
|
134
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
135
|
+
|
|
136
|
+
node.children[ i ] = await this.processContent( node.children[ i ], callback );
|
|
58
137
|
|
|
59
|
-
// Parse prev version if string
|
|
60
|
-
if (typeof attachementsOpts.prevVersion === 'string') {
|
|
61
|
-
try {
|
|
62
|
-
attachementsOpts.prevVersion = JSON.parse(attachementsOpts.prevVersion) as SerializedEditorState;
|
|
63
|
-
} catch (error) {
|
|
64
|
-
throw new Anomaly("Invalid JSON format for the given JSON RTE prev version.");
|
|
65
|
-
}
|
|
66
138
|
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return node;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async transformNode(node: LexicalNode, assets: TContentAssets, options: TRenderOptions) {
|
|
67
145
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
146
|
+
// Attachment: Upload attachments and replace blobs by URLs
|
|
147
|
+
if (node.type === 'image' || node.type === 'file') {
|
|
148
|
+
|
|
149
|
+
await this.processAttachement(
|
|
150
|
+
node as With<LexicalNode, 'src'>,
|
|
151
|
+
assets,
|
|
152
|
+
options,
|
|
73
153
|
);
|
|
154
|
+
|
|
155
|
+
} else if (node.type === 'anchored-heading') {
|
|
156
|
+
|
|
157
|
+
await this.processHeading(node, assets);
|
|
158
|
+
|
|
74
159
|
}
|
|
75
160
|
|
|
76
|
-
|
|
77
|
-
const html = await this.jsonToHtml( content );
|
|
78
|
-
|
|
79
|
-
return { html, json: content, attachements };
|
|
161
|
+
return node;
|
|
80
162
|
}
|
|
81
163
|
|
|
82
|
-
public async
|
|
164
|
+
public async processAttachement(
|
|
165
|
+
node: LexicalNode,
|
|
166
|
+
assets: TContentAssets,
|
|
167
|
+
options: TRenderOptions
|
|
168
|
+
) {
|
|
83
169
|
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
170
|
+
const attachementOptions = options?.attachements;
|
|
171
|
+
if (typeof node.src !== 'string' || !attachementOptions)
|
|
172
|
+
return;
|
|
173
|
+
|
|
174
|
+
// Already uploaded
|
|
175
|
+
if (!node.src.startsWith('data:')) {
|
|
176
|
+
assets.attachements.push(node.src);
|
|
177
|
+
return node.src;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Transform into buffer
|
|
181
|
+
node.src = node.src.replace(/^data:\w+\/\w+;base64,/, '');
|
|
182
|
+
const fileData = Buffer.from(node.src, 'base64');
|
|
183
|
+
|
|
184
|
+
// Parse file type from buffer
|
|
185
|
+
const fileType = await fromBuffer(fileData);
|
|
186
|
+
if (!fileType)
|
|
187
|
+
throw new Error('Failed to detect file type');
|
|
188
|
+
|
|
189
|
+
// Upload file to disk
|
|
190
|
+
const fileName = md5(node.src) + '.' + fileType.ext;
|
|
191
|
+
const filePath = path.join(attachementOptions.directory, fileName);
|
|
192
|
+
const upoadedFile = await attachementOptions.disk.outputFile('data', filePath, fileData, {
|
|
193
|
+
encoding: 'binary'
|
|
87
194
|
});
|
|
88
195
|
|
|
89
|
-
|
|
196
|
+
// Replace node.src with url
|
|
197
|
+
node.src = upoadedFile.path;
|
|
198
|
+
assets.attachements.push(node.src);
|
|
199
|
+
}
|
|
90
200
|
|
|
91
|
-
|
|
201
|
+
private async processHeading( node: LexicalNode, assets: TContentAssets ) {
|
|
92
202
|
|
|
93
|
-
|
|
203
|
+
const title = this.jsonToText(node);
|
|
204
|
+
const titleLevel = parseInt(node.tag?.substring(1) || '1');
|
|
205
|
+
const titleSlug = await Slug.generate(title);
|
|
206
|
+
node.anchor = titleSlug;
|
|
94
207
|
|
|
95
|
-
|
|
96
|
-
|
|
208
|
+
const findParentContainer = (skeleton: TSkeleton): TSkeleton => {
|
|
209
|
+
for (let i = skeleton.length - 1; i >= 0; i--) {
|
|
210
|
+
const child = skeleton[i];
|
|
97
211
|
|
|
98
|
-
|
|
212
|
+
if (child.level === titleLevel - 1) {
|
|
213
|
+
return child.childrens;
|
|
214
|
+
} else if (child.level < titleLevel) {
|
|
215
|
+
return findParentContainer(child.childrens);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return skeleton;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const parentContainer = (assets.skeleton.length === 0 || titleLevel === 1)
|
|
223
|
+
? assets.skeleton
|
|
224
|
+
: findParentContainer(assets.skeleton);
|
|
225
|
+
|
|
226
|
+
parentContainer.push({
|
|
227
|
+
title,
|
|
228
|
+
id: titleSlug,
|
|
229
|
+
level: titleLevel,
|
|
230
|
+
childrens: []
|
|
99
231
|
});
|
|
232
|
+
}
|
|
100
233
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
234
|
+
private async deleteUnusedFile(
|
|
235
|
+
node: LexicalNode,
|
|
236
|
+
assets: TContentAssets,
|
|
237
|
+
options: NonNullable<TRenderOptions["attachements"]>
|
|
238
|
+
) {
|
|
239
|
+
|
|
240
|
+
if (!node.src)
|
|
241
|
+
return node;
|
|
104
242
|
|
|
105
|
-
|
|
243
|
+
// Should be a URL
|
|
244
|
+
if (node.src.startsWith('data:') || !node.src.startsWith('http'))
|
|
245
|
+
return node;
|
|
246
|
+
|
|
247
|
+
// This file is used
|
|
248
|
+
if (assets.attachements.includes(node.src))
|
|
249
|
+
return node;
|
|
250
|
+
|
|
251
|
+
// Extract file name
|
|
252
|
+
const fileName = path.basename(node.src);
|
|
253
|
+
const filePath = path.join(options.directory, fileName);
|
|
254
|
+
|
|
255
|
+
// Delete file from disk
|
|
256
|
+
await options.disk.delete('data', filePath);
|
|
257
|
+
console.log('Deleted file:', filePath);
|
|
258
|
+
|
|
259
|
+
return node;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
public async jsonToHtml( json: LexicalState ) {
|
|
106
263
|
|
|
107
264
|
// Server side: simulate DOM environment
|
|
108
265
|
const dom = new JSDOM(`<!DOCTYPE html><body></body>`);
|
|
@@ -120,9 +277,9 @@ export class RteUtils {
|
|
|
120
277
|
});
|
|
121
278
|
|
|
122
279
|
// Set the editor state from JSON
|
|
123
|
-
const state = editor.parseEditorState(
|
|
280
|
+
const state = editor.parseEditorState(json);
|
|
124
281
|
if (state.isEmpty())
|
|
125
|
-
return
|
|
282
|
+
return '';
|
|
126
283
|
|
|
127
284
|
editor.setEditorState(state);
|
|
128
285
|
|
|
@@ -131,105 +288,60 @@ export class RteUtils {
|
|
|
131
288
|
return $generateHtmlFromNodes(editor);
|
|
132
289
|
});
|
|
133
290
|
|
|
134
|
-
|
|
291
|
+
// Clean up global variables set for JSDOM to avoid memory leaks
|
|
292
|
+
// @ts-ignore
|
|
135
293
|
delete global.window;
|
|
294
|
+
// @ts-ignore
|
|
136
295
|
delete global.document;
|
|
296
|
+
// @ts-ignore
|
|
137
297
|
delete global.DOMParser;
|
|
298
|
+
// @ts-ignore
|
|
138
299
|
delete global.MutationObserver;
|
|
139
300
|
|
|
140
301
|
return html;
|
|
141
302
|
}
|
|
142
303
|
|
|
143
|
-
|
|
144
|
-
value: SerializedEditorState,
|
|
145
|
-
disk: Driver,
|
|
146
|
-
destination: string,
|
|
147
|
-
oldState?: SerializedEditorState
|
|
148
|
-
) {
|
|
149
|
-
|
|
150
|
-
const usedFilesUrl: string[] = [];
|
|
304
|
+
private jsonToText( node: LexicalNode ) {
|
|
151
305
|
|
|
152
|
-
|
|
153
|
-
const findFiles = async (
|
|
154
|
-
node: SerializedLexicalNode,
|
|
155
|
-
callback: (node: SerializedLexicalNodeWithSrc) => Promise<void>
|
|
156
|
-
) => {
|
|
306
|
+
let text = '';
|
|
157
307
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
await callback(node as SerializedLexicalNodeWithSrc);
|
|
162
|
-
}
|
|
163
|
-
// Recursion
|
|
164
|
-
} else if (node.children) {
|
|
165
|
-
for (const child of node.children) {
|
|
166
|
-
await findFiles(child, callback);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
308
|
+
// Check if the node has text content
|
|
309
|
+
if (node.type === 'text' && node.text) {
|
|
310
|
+
text += node.text;
|
|
169
311
|
}
|
|
170
312
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
usedFilesUrl.push(node.src);
|
|
176
|
-
return node.src;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// Transform into buffer
|
|
180
|
-
node.src = node.src.replace(/^data:\w+\/\w+;base64,/, '');
|
|
181
|
-
const fileData = Buffer.from(node.src, 'base64');
|
|
182
|
-
|
|
183
|
-
// Parse file type from buffer
|
|
184
|
-
const fileType = await fromBuffer(fileData);
|
|
185
|
-
if (!fileType)
|
|
186
|
-
throw new Error('Failed to detect file type');
|
|
187
|
-
|
|
188
|
-
// Upload file to disk
|
|
189
|
-
const fileName = md5(node.src) + '.' + fileType.ext;
|
|
190
|
-
const filePath = path.join(destination, fileName);
|
|
191
|
-
const upoadedFile = await disk.outputFile('data', filePath, fileData, {
|
|
192
|
-
encoding: 'binary'
|
|
313
|
+
// Recursively process children nodes
|
|
314
|
+
if (node.children && Array.isArray(node.children)) {
|
|
315
|
+
node.children.forEach(childNode => {
|
|
316
|
+
text += this.jsonToText(childNode);
|
|
193
317
|
});
|
|
194
|
-
|
|
195
|
-
// Replace node.src with url
|
|
196
|
-
node.src = upoadedFile.path;
|
|
197
|
-
usedFilesUrl.push(node.src);
|
|
198
318
|
}
|
|
199
319
|
|
|
200
|
-
|
|
320
|
+
return text;
|
|
321
|
+
}
|
|
201
322
|
|
|
202
|
-
|
|
203
|
-
if (node.src.startsWith('data:') || !node.src.startsWith('http'))
|
|
204
|
-
return;
|
|
323
|
+
public async htmlToJson(htmlString: string): Promise<LexicalState> {
|
|
205
324
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
325
|
+
const editor = createHeadlessEditor({
|
|
326
|
+
nodes: editorNodes,
|
|
327
|
+
theme: ExampleTheme,
|
|
328
|
+
});
|
|
209
329
|
|
|
210
|
-
|
|
211
|
-
const fileName = path.basename(node.src);
|
|
212
|
-
const filePath = path.join(destination, fileName);
|
|
330
|
+
await editor.update(() => {
|
|
213
331
|
|
|
214
|
-
|
|
215
|
-
await disk.delete('data', filePath);
|
|
216
|
-
console.log('Deleted file:', filePath);
|
|
217
|
-
}
|
|
332
|
+
const root = $getRoot();
|
|
218
333
|
|
|
219
|
-
|
|
220
|
-
for (const child of value.root.children) {
|
|
221
|
-
await findFiles(child, uploadFile);
|
|
222
|
-
}
|
|
334
|
+
const dom = new JSDOM(htmlString);
|
|
223
335
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
for (const child of oldState.root.children) {
|
|
227
|
-
await findFiles(child, deleteUnusedFile);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
336
|
+
// Once you have the DOM instance it's easy to generate LexicalNodes.
|
|
337
|
+
const lexicalNodes = $generateNodesFromDOM(editor, dom ? dom.window.document : window.document);
|
|
230
338
|
|
|
231
|
-
|
|
232
|
-
|
|
339
|
+
lexicalNodes.forEach((node) => root.append(node));
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const state = editor.getEditorState();
|
|
343
|
+
return state.toJSON();
|
|
344
|
+
};
|
|
233
345
|
}
|
|
234
346
|
|
|
235
347
|
export default new RteUtils;
|
package/src/server/utils/slug.ts
CHANGED
|
@@ -26,9 +26,9 @@ export class Slug {
|
|
|
26
26
|
// Generate slug
|
|
27
27
|
let slug = slugify(label, {
|
|
28
28
|
replacement: '-', // replace spaces with replacement character, defaults to `-`
|
|
29
|
-
remove:
|
|
29
|
+
remove: /[^a-z\s]/ig, // remove characters that match regex, defaults to `undefined`
|
|
30
30
|
lower: true, // convert to lower case, defaults to `false`
|
|
31
|
-
strict:
|
|
31
|
+
strict: true, // strip special characters except replacement, defaults to `false`
|
|
32
32
|
locale: 'vi', // language code of the locale to use
|
|
33
33
|
trim: true // trim leading and trailing replacement chars, defaults to `true`
|
|
34
34
|
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
3
|
-
*
|
|
4
|
-
* This source code is licensed under the MIT license found in the
|
|
5
|
-
* LICENSE file in the root directory of this source tree.
|
|
6
|
-
*
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type {Klass, LexicalNode} from 'lexical';
|
|
10
|
-
|
|
11
|
-
import {CodeHighlightNode, CodeNode} from '@lexical/code';
|
|
12
|
-
import {HashtagNode} from '@lexical/hashtag';
|
|
13
|
-
import {AutoLinkNode, LinkNode} from '@lexical/link';
|
|
14
|
-
import {ListItemNode, ListNode} from '@lexical/list';
|
|
15
|
-
import {MarkNode} from '@lexical/mark';
|
|
16
|
-
import {OverflowNode} from '@lexical/overflow';
|
|
17
|
-
import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode';
|
|
18
|
-
import {HeadingNode, QuoteNode} from '@lexical/rich-text';
|
|
19
|
-
import {TableCellNode, TableNode, TableRowNode} from '@lexical/table';
|
|
20
|
-
|
|
21
|
-
import {CollapsibleContainerNode} from '../plugins/CollapsiblePlugin/CollapsibleContainerNode';
|
|
22
|
-
import {CollapsibleContentNode} from '../plugins/CollapsiblePlugin/CollapsibleContentNode';
|
|
23
|
-
import {CollapsibleTitleNode} from '../plugins/CollapsiblePlugin/CollapsibleTitleNode';
|
|
24
|
-
import {AutocompleteNode} from './AutocompleteNode';
|
|
25
|
-
import {EmojiNode} from './EmojiNode';
|
|
26
|
-
import {EquationNode} from './EquationNode';
|
|
27
|
-
import {FigmaNode} from './FigmaNode';
|
|
28
|
-
import {ImageNode} from './ImageNode';
|
|
29
|
-
import {InlineImageNode} from './InlineImageNode/InlineImageNode';
|
|
30
|
-
import {KeywordNode} from './KeywordNode';
|
|
31
|
-
import {LayoutContainerNode} from './LayoutContainerNode';
|
|
32
|
-
import {LayoutItemNode} from './LayoutItemNode';
|
|
33
|
-
import {MentionNode} from './MentionNode';
|
|
34
|
-
import {PageBreakNode} from './PageBreakNode';
|
|
35
|
-
import {PollNode} from './PollNode';
|
|
36
|
-
import {StickyNode} from './StickyNode';
|
|
37
|
-
import {TweetNode} from './TweetNode';
|
|
38
|
-
import {YouTubeNode} from './YouTubeNode';
|
|
39
|
-
|
|
40
|
-
const PlaygroundNodes: Array<Klass<LexicalNode>> = [
|
|
41
|
-
HeadingNode,
|
|
42
|
-
ListNode,
|
|
43
|
-
ListItemNode,
|
|
44
|
-
QuoteNode,
|
|
45
|
-
CodeNode,
|
|
46
|
-
TableNode,
|
|
47
|
-
TableCellNode,
|
|
48
|
-
TableRowNode,
|
|
49
|
-
HashtagNode,
|
|
50
|
-
CodeHighlightNode,
|
|
51
|
-
AutoLinkNode,
|
|
52
|
-
LinkNode,
|
|
53
|
-
OverflowNode,
|
|
54
|
-
PollNode,
|
|
55
|
-
StickyNode,
|
|
56
|
-
ImageNode,
|
|
57
|
-
InlineImageNode,
|
|
58
|
-
MentionNode,
|
|
59
|
-
EmojiNode,
|
|
60
|
-
EquationNode,
|
|
61
|
-
AutocompleteNode,
|
|
62
|
-
KeywordNode,
|
|
63
|
-
HorizontalRuleNode,
|
|
64
|
-
TweetNode,
|
|
65
|
-
YouTubeNode,
|
|
66
|
-
FigmaNode,
|
|
67
|
-
MarkNode,
|
|
68
|
-
CollapsibleContainerNode,
|
|
69
|
-
CollapsibleContentNode,
|
|
70
|
-
CollapsibleTitleNode,
|
|
71
|
-
PageBreakNode,
|
|
72
|
-
LayoutContainerNode,
|
|
73
|
-
LayoutItemNode,
|
|
74
|
-
];
|
|
75
|
-
|
|
76
|
-
export default PlaygroundNodes;
|