@valbuild/ui 0.20.0 → 0.21.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/dist/valbuild-ui.cjs.d.ts +6 -4
- package/dist/valbuild-ui.cjs.js +13528 -12255
- package/dist/valbuild-ui.esm.js +8313 -7040
- package/package.json +4 -2
- package/src/components/Button.tsx +0 -9
- package/src/components/Dropdown.tsx +32 -25
- package/src/components/RichTextEditor/Nodes/ImageNode.tsx +0 -8
- package/src/components/RichTextEditor/Plugins/LinkEditorPlugin.tsx +58 -0
- package/src/components/RichTextEditor/Plugins/Toolbar.tsx +70 -2
- package/src/components/RichTextEditor/RichTextEditor.tsx +33 -8
- package/src/components/ValOverlay.tsx +55 -31
- package/src/components/ValOverlayContext.tsx +17 -0
- package/src/components/ValWindow.stories.tsx +5 -51
- package/src/components/ValWindow.tsx +27 -12
- package/src/components/dashboard/ValDashboard.tsx +2 -0
- package/src/components/forms/Form.tsx +2 -2
- package/src/exports.ts +1 -0
- package/src/richtext/conversion/conversion.test.ts +146 -0
- package/src/richtext/conversion/lexicalToRichTextSource.test.ts +89 -0
- package/src/richtext/conversion/lexicalToRichTextSource.ts +286 -0
- package/src/richtext/conversion/parseRichTextSource.test.ts +424 -0
- package/src/richtext/conversion/parseRichTextSource.ts +228 -0
- package/src/richtext/conversion/richTextSourceToLexical.test.ts +381 -0
- package/src/richtext/conversion/richTextSourceToLexical.ts +293 -0
- package/src/stories/RichTextEditor.stories.tsx +3 -47
- package/src/utils/imageMimeType.ts +23 -0
- package/src/utils/readImage.ts +1 -25
- package/src/components/RichTextEditor/conversion.test.ts +0 -132
- package/src/components/RichTextEditor/conversion.ts +0 -389
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
|
-
import { AnyRichTextOptions, RichText as RichTextType } from "@valbuild/core";
|
|
3
2
|
import { RichTextEditor } from "../exports";
|
|
4
3
|
import { FormContainer } from "./forms/FormContainer";
|
|
5
4
|
import { ImageForm } from "./forms/ImageForm";
|
|
@@ -75,56 +74,11 @@ export const RichText: Story = {
|
|
|
75
74
|
}}
|
|
76
75
|
>
|
|
77
76
|
<RichTextEditor
|
|
78
|
-
richtext={
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
{ tag: "h2", children: ["Title 2"] },
|
|
84
|
-
{ tag: "h3", children: ["Title 3"] },
|
|
85
|
-
{ tag: "h4", children: ["Title 4"] },
|
|
86
|
-
{ tag: "h5", children: ["Title 5"] },
|
|
87
|
-
{ tag: "h6", children: ["Title 6"] },
|
|
88
|
-
{
|
|
89
|
-
tag: "p",
|
|
90
|
-
children: [
|
|
91
|
-
{
|
|
92
|
-
tag: "span",
|
|
93
|
-
classes: ["bold", "italic", "line-through"],
|
|
94
|
-
children: ["Formatted span"],
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
},
|
|
98
|
-
{
|
|
99
|
-
tag: "ul",
|
|
100
|
-
children: [
|
|
101
|
-
{
|
|
102
|
-
tag: "li",
|
|
103
|
-
children: [
|
|
104
|
-
{
|
|
105
|
-
tag: "ol",
|
|
106
|
-
dir: "rtl",
|
|
107
|
-
children: [
|
|
108
|
-
{
|
|
109
|
-
tag: "li",
|
|
110
|
-
children: [
|
|
111
|
-
{
|
|
112
|
-
tag: "span",
|
|
113
|
-
classes: ["italic"],
|
|
114
|
-
children: ["number 1.1"],
|
|
115
|
-
},
|
|
116
|
-
],
|
|
117
|
-
},
|
|
118
|
-
{ tag: "li", children: ["number 1.2"] },
|
|
119
|
-
],
|
|
120
|
-
},
|
|
121
|
-
],
|
|
122
|
-
},
|
|
123
|
-
],
|
|
124
|
-
},
|
|
125
|
-
],
|
|
126
|
-
} as RichTextType<AnyRichTextOptions>
|
|
127
|
-
}
|
|
77
|
+
richtext={{
|
|
78
|
+
_type: "richtext",
|
|
79
|
+
templateStrings: ["# Title 1"],
|
|
80
|
+
exprs: [],
|
|
81
|
+
}}
|
|
128
82
|
/>
|
|
129
83
|
</FormContainer>
|
|
130
84
|
),
|
|
@@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from "react";
|
|
|
2
2
|
import { AlignJustify, X } from "react-feather";
|
|
3
3
|
import classNames from "classnames";
|
|
4
4
|
import { Resizable } from "react-resizable";
|
|
5
|
+
import { useValOverlayContext } from "./ValOverlayContext";
|
|
5
6
|
|
|
6
7
|
export type ValWindowProps = {
|
|
7
8
|
children:
|
|
@@ -35,17 +36,34 @@ export function ValWindow({
|
|
|
35
36
|
document.removeEventListener("keyup", closeOnEscape);
|
|
36
37
|
};
|
|
37
38
|
}, []);
|
|
38
|
-
|
|
39
39
|
//
|
|
40
|
-
const
|
|
40
|
+
const { windowSize, setWindowSize } = useValOverlayContext();
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!windowSize) {
|
|
43
|
+
setWindowSize({
|
|
44
|
+
height: MIN_HEIGHT,
|
|
45
|
+
width: MIN_WIDTH,
|
|
46
|
+
innerHeight:
|
|
47
|
+
MIN_HEIGHT -
|
|
48
|
+
(64 + (bottomRef.current?.getBoundingClientRect()?.height || 0)),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}, [windowSize]);
|
|
41
52
|
//
|
|
42
53
|
const bottomRef = useRef<HTMLDivElement>(null);
|
|
43
54
|
|
|
44
55
|
return (
|
|
45
56
|
<Resizable
|
|
46
|
-
width={
|
|
47
|
-
height={
|
|
48
|
-
onResize={(_, { size }) =>
|
|
57
|
+
width={windowSize?.width || MIN_WIDTH}
|
|
58
|
+
height={windowSize?.height || MIN_HEIGHT}
|
|
59
|
+
onResize={(_, { size }) =>
|
|
60
|
+
setWindowSize({
|
|
61
|
+
...size,
|
|
62
|
+
innerHeight:
|
|
63
|
+
(windowSize?.height || MIN_HEIGHT) -
|
|
64
|
+
(64 + (bottomRef.current?.getBoundingClientRect()?.height || 0)),
|
|
65
|
+
})
|
|
66
|
+
}
|
|
49
67
|
handle={
|
|
50
68
|
<div className="fixed bottom-0 right-0 cursor-se-resize">
|
|
51
69
|
<svg
|
|
@@ -63,7 +81,7 @@ export function ValWindow({
|
|
|
63
81
|
}
|
|
64
82
|
draggableOpts={{}}
|
|
65
83
|
className={classNames(
|
|
66
|
-
"absolute inset-0
|
|
84
|
+
"absolute inset-0 tablet:w-auto tablet:h-auto tablet:min-h-fit tablet:rounded bg-base text-primary drop-shadow-2xl min-w-[320px] transition-opacity duration-300 delay-75",
|
|
67
85
|
{
|
|
68
86
|
"opacity-0": !isInitialized,
|
|
69
87
|
"opacity-100": isInitialized,
|
|
@@ -72,8 +90,8 @@ export function ValWindow({
|
|
|
72
90
|
>
|
|
73
91
|
<div
|
|
74
92
|
style={{
|
|
75
|
-
width:
|
|
76
|
-
height:
|
|
93
|
+
width: windowSize?.width || MIN_WIDTH,
|
|
94
|
+
height: windowSize?.height || MIN_HEIGHT,
|
|
77
95
|
left: draggedPosition.left,
|
|
78
96
|
top: draggedPosition.top,
|
|
79
97
|
}}
|
|
@@ -108,10 +126,7 @@ export function ValWindow({
|
|
|
108
126
|
<div
|
|
109
127
|
className="relative overflow-scroll"
|
|
110
128
|
style={{
|
|
111
|
-
height:
|
|
112
|
-
(size?.height || MIN_HEIGHT) -
|
|
113
|
-
(64 +
|
|
114
|
-
(bottomRef.current?.getBoundingClientRect()?.height || 0)),
|
|
129
|
+
height: windowSize?.innerHeight,
|
|
115
130
|
}}
|
|
116
131
|
>
|
|
117
132
|
{Array.isArray(children) ? children.slice(1, -1) : children}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AnyRichTextOptions,
|
|
1
|
+
import { AnyRichTextOptions, RichTextSource } from "@valbuild/core";
|
|
2
2
|
import { LexicalEditor } from "lexical";
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { RichTextEditor } from "../RichTextEditor/RichTextEditor";
|
|
@@ -18,7 +18,7 @@ export type Inputs = {
|
|
|
18
18
|
| {
|
|
19
19
|
status: "completed";
|
|
20
20
|
type: "richtext";
|
|
21
|
-
data:
|
|
21
|
+
data: RichTextSource<AnyRichTextOptions>;
|
|
22
22
|
};
|
|
23
23
|
};
|
|
24
24
|
|
package/src/exports.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { RichTextEditor } from "./components/RichTextEditor/RichTextEditor";
|
|
|
2
2
|
export { ValOverlay } from "./components/ValOverlay";
|
|
3
3
|
export { type Inputs } from "./components/forms/Form";
|
|
4
4
|
export { ValDashboard } from "./components/dashboard/ValDashboard";
|
|
5
|
+
export { parseRichTextSource } from "./richtext/conversion/parseRichTextSource";
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
initVal,
|
|
3
|
+
RichTextSource,
|
|
4
|
+
AnyRichTextOptions,
|
|
5
|
+
FILE_REF_PROP,
|
|
6
|
+
} from "@valbuild/core";
|
|
7
|
+
import { richTextSourceToLexical } from "./richTextSourceToLexical";
|
|
8
|
+
import { lexicalToRichTextSource } from "./lexicalToRichTextSource";
|
|
9
|
+
import { parseRichTextSource } from "./parseRichTextSource";
|
|
10
|
+
|
|
11
|
+
const { val } = initVal();
|
|
12
|
+
const cases: {
|
|
13
|
+
description: string;
|
|
14
|
+
input: RichTextSource<AnyRichTextOptions>;
|
|
15
|
+
}[] = [
|
|
16
|
+
{
|
|
17
|
+
description: "basic",
|
|
18
|
+
input: val.richtext`
|
|
19
|
+
# Title 1
|
|
20
|
+
|
|
21
|
+
## Title 2
|
|
22
|
+
|
|
23
|
+
### Title 3
|
|
24
|
+
|
|
25
|
+
#### Title 4
|
|
26
|
+
|
|
27
|
+
##### Title 5
|
|
28
|
+
|
|
29
|
+
###### Title 6
|
|
30
|
+
|
|
31
|
+
Some paragraph. Another sentence.
|
|
32
|
+
|
|
33
|
+
Another paragraph.
|
|
34
|
+
|
|
35
|
+
Formatting: **bold**, _italic_, ~~line-through~~, ***bold and italic***.
|
|
36
|
+
|
|
37
|
+
- List 1
|
|
38
|
+
1. List 1.1
|
|
39
|
+
1. List 1.2
|
|
40
|
+
`,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
description: "all features",
|
|
44
|
+
input: val.richtext`
|
|
45
|
+
# Title 1
|
|
46
|
+
|
|
47
|
+
Title 1 content.
|
|
48
|
+
|
|
49
|
+
## Title 2
|
|
50
|
+
|
|
51
|
+
Title 2 content.
|
|
52
|
+
|
|
53
|
+
### Title 3
|
|
54
|
+
|
|
55
|
+
Title 3 content.
|
|
56
|
+
|
|
57
|
+
#### Title 4
|
|
58
|
+
|
|
59
|
+
Title 4 content.
|
|
60
|
+
|
|
61
|
+
##### Title 5
|
|
62
|
+
|
|
63
|
+
###### Title 6
|
|
64
|
+
|
|
65
|
+
Some paragraph. Another sentence.
|
|
66
|
+
|
|
67
|
+
Another paragraph.
|
|
68
|
+
|
|
69
|
+
Formatting: **bold**, _italic_, ~~line-through~~, ***bold and italic***.
|
|
70
|
+
|
|
71
|
+
- List 1
|
|
72
|
+
1. List 1.1
|
|
73
|
+
1. List 1.2
|
|
74
|
+
|
|
75
|
+
Inline link: ${val.link("**link**", { href: "https://link.com" })}
|
|
76
|
+
|
|
77
|
+
<br />
|
|
78
|
+
|
|
79
|
+
Block link:
|
|
80
|
+
|
|
81
|
+
${val.link("**link**", { href: "https://link.com" })}
|
|
82
|
+
|
|
83
|
+
<br />
|
|
84
|
+
|
|
85
|
+
Block Image:
|
|
86
|
+
|
|
87
|
+
${val.file("/public/test.jpg", {
|
|
88
|
+
width: 100,
|
|
89
|
+
height: 100,
|
|
90
|
+
sha256: "123",
|
|
91
|
+
})}
|
|
92
|
+
|
|
93
|
+
<br />
|
|
94
|
+
|
|
95
|
+
<br />
|
|
96
|
+
|
|
97
|
+
- List 1
|
|
98
|
+
1. List 1.1
|
|
99
|
+
1. List 1.2
|
|
100
|
+
- List 2
|
|
101
|
+
- List 3
|
|
102
|
+
1. Formatted **list**
|
|
103
|
+
Test 123
|
|
104
|
+
`,
|
|
105
|
+
},
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
describe("isomorphic richtext <-> conversion", () => {
|
|
109
|
+
test.each(cases)("$description", async ({ input }) => {
|
|
110
|
+
const inputSource = input;
|
|
111
|
+
|
|
112
|
+
const res = await lexicalToRichTextSource(
|
|
113
|
+
richTextSourceToLexical(parseRichTextSource(inputSource))
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const output = stringifyRichTextSource(res);
|
|
117
|
+
// console.log("EOF>>" + output + "<<EOF");
|
|
118
|
+
expect(stringifyRichTextSource(inputSource)).toStrictEqual(output);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function stringifyRichTextSource({
|
|
123
|
+
templateStrings,
|
|
124
|
+
exprs,
|
|
125
|
+
}: RichTextSource<AnyRichTextOptions>): string {
|
|
126
|
+
let lines = "";
|
|
127
|
+
for (let i = 0; i < templateStrings.length; i++) {
|
|
128
|
+
const line = templateStrings[i];
|
|
129
|
+
const expr = exprs[i];
|
|
130
|
+
lines += line;
|
|
131
|
+
if (expr) {
|
|
132
|
+
if (expr._type === "file") {
|
|
133
|
+
lines += `\${val.file("${expr[FILE_REF_PROP]}", ${JSON.stringify(
|
|
134
|
+
expr.metadata
|
|
135
|
+
)})}`;
|
|
136
|
+
} else if (expr._type === "link") {
|
|
137
|
+
lines += `\${val.link("${expr.children[0]}", ${JSON.stringify({
|
|
138
|
+
href: expr.href,
|
|
139
|
+
})})}`;
|
|
140
|
+
} else {
|
|
141
|
+
throw Error("Unknown expr: " + JSON.stringify(expr, null, 2));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return lines;
|
|
146
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { initVal } from "@valbuild/core";
|
|
2
|
+
import { parseRichTextSource } from "./parseRichTextSource";
|
|
3
|
+
|
|
4
|
+
const { val } = initVal();
|
|
5
|
+
|
|
6
|
+
describe("lexical to RichTextSource", () => {
|
|
7
|
+
test("parse empty richtext source", () => {
|
|
8
|
+
const output = parseRichTextSource({
|
|
9
|
+
_type: "richtext",
|
|
10
|
+
templateStrings: [""],
|
|
11
|
+
exprs: [],
|
|
12
|
+
});
|
|
13
|
+
expect(output).toStrictEqual({ _type: "richtext", children: [] });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("parse basic richtext source", () => {
|
|
17
|
+
const output = parseRichTextSource<{
|
|
18
|
+
a: true;
|
|
19
|
+
li: true;
|
|
20
|
+
ul: true;
|
|
21
|
+
heading: ["h1"];
|
|
22
|
+
}>(val.richtext`
|
|
23
|
+
# Title 1
|
|
24
|
+
|
|
25
|
+
${val.link("**link**", { href: "https://link.com" })}
|
|
26
|
+
|
|
27
|
+
<br>
|
|
28
|
+
|
|
29
|
+
- List 1
|
|
30
|
+
1. List 1.1
|
|
31
|
+
1. List 1.2
|
|
32
|
+
Test 123
|
|
33
|
+
`);
|
|
34
|
+
expect(output).toStrictEqual({
|
|
35
|
+
_type: "richtext",
|
|
36
|
+
children: [
|
|
37
|
+
{
|
|
38
|
+
tag: "h1",
|
|
39
|
+
children: ["Title 1"],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
tag: "p",
|
|
43
|
+
children: [
|
|
44
|
+
{
|
|
45
|
+
tag: "a",
|
|
46
|
+
href: "https://link.com",
|
|
47
|
+
children: [
|
|
48
|
+
{
|
|
49
|
+
tag: "span",
|
|
50
|
+
classes: ["bold"],
|
|
51
|
+
children: ["link"],
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{ tag: "br", children: [] },
|
|
58
|
+
{
|
|
59
|
+
tag: "ul",
|
|
60
|
+
children: [
|
|
61
|
+
{
|
|
62
|
+
tag: "li",
|
|
63
|
+
children: [
|
|
64
|
+
"List 1",
|
|
65
|
+
{
|
|
66
|
+
tag: "ol",
|
|
67
|
+
children: [
|
|
68
|
+
{
|
|
69
|
+
tag: "li",
|
|
70
|
+
children: ["List 1.1"],
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
tag: "li",
|
|
74
|
+
children: [
|
|
75
|
+
"List 1.2",
|
|
76
|
+
{ tag: "br", children: [] },
|
|
77
|
+
"Test 123",
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LinkSource,
|
|
3
|
+
FILE_REF_PROP,
|
|
4
|
+
Internal,
|
|
5
|
+
VAL_EXTENSION,
|
|
6
|
+
RichTextSource,
|
|
7
|
+
AnyRichTextOptions,
|
|
8
|
+
} from "@valbuild/core";
|
|
9
|
+
import { getMimeType, mimeTypeToFileExt } from "../../utils/imageMimeType";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
LexicalImageNode,
|
|
13
|
+
LexicalLineBreakNode,
|
|
14
|
+
LexicalLinkNode,
|
|
15
|
+
LexicalListItemNode,
|
|
16
|
+
LexicalListNode,
|
|
17
|
+
LexicalRootNode,
|
|
18
|
+
LexicalTextNode,
|
|
19
|
+
} from "./richTextSourceToLexical";
|
|
20
|
+
|
|
21
|
+
const HeaderRegEx = /^h([\d+])$/;
|
|
22
|
+
// Promise<
|
|
23
|
+
// RichTextSource<AnyRichTextOptions> & { files: Record<string, string> }
|
|
24
|
+
// >
|
|
25
|
+
|
|
26
|
+
type MarkdownIR = {
|
|
27
|
+
type: "block";
|
|
28
|
+
children: (string | LexicalImageNode | LexicalLinkNode)[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MAX_LINE_LENGTH = 80;
|
|
32
|
+
export function lexicalToRichTextSource(
|
|
33
|
+
node: LexicalRootNode
|
|
34
|
+
): Promise<
|
|
35
|
+
RichTextSource<AnyRichTextOptions> & { files: Record<string, string> }
|
|
36
|
+
> {
|
|
37
|
+
const markdownIRBlocks: MarkdownIR[] = node.children.map(createBlock);
|
|
38
|
+
return fromIRToRichTextSource(markdownIRBlocks);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createBlock(node: LexicalRootNode["children"][number]): MarkdownIR {
|
|
42
|
+
if (node.type === "heading") {
|
|
43
|
+
let headingTag = "";
|
|
44
|
+
const depth = Number(node.tag.match(HeaderRegEx)?.[1]);
|
|
45
|
+
if (Number.isNaN(depth)) {
|
|
46
|
+
throw new Error("Invalid header depth");
|
|
47
|
+
}
|
|
48
|
+
for (let i = 0; i < Number(depth); i++) {
|
|
49
|
+
headingTag += "#";
|
|
50
|
+
}
|
|
51
|
+
const headingText: MarkdownIR["children"] = [`${headingTag} `];
|
|
52
|
+
return {
|
|
53
|
+
type: "block",
|
|
54
|
+
children: headingText.concat(...node.children.map(transformLeafNode)),
|
|
55
|
+
};
|
|
56
|
+
} else if (node.type === "paragraph") {
|
|
57
|
+
if (node.children.length === 0) {
|
|
58
|
+
return {
|
|
59
|
+
type: "block",
|
|
60
|
+
children: ["<br />"],
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
type: "block",
|
|
65
|
+
children: node.children.map((child) => transformLeafNode(child)),
|
|
66
|
+
};
|
|
67
|
+
} else if (node.type === "list") {
|
|
68
|
+
return {
|
|
69
|
+
type: "block",
|
|
70
|
+
children: node.children.flatMap((child, i) =>
|
|
71
|
+
formatListItemNode(getListPrefix(node), child, 0, i === 0)
|
|
72
|
+
),
|
|
73
|
+
};
|
|
74
|
+
} else {
|
|
75
|
+
const exhaustiveCheck: never = node;
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Unhandled node type: ${
|
|
78
|
+
"type" in exhaustiveCheck ? "exhaustiveCheck.type" : "unknown"
|
|
79
|
+
}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function fromIRToRichTextSource(
|
|
85
|
+
markdownIRBlocks: MarkdownIR[]
|
|
86
|
+
): Promise<
|
|
87
|
+
RichTextSource<AnyRichTextOptions> & { files: Record<string, string> }
|
|
88
|
+
> {
|
|
89
|
+
const templateStrings = ["\n"];
|
|
90
|
+
const exprs = [];
|
|
91
|
+
const files: Record<string, string> = {};
|
|
92
|
+
for (let blockIdx = 0; blockIdx < markdownIRBlocks.length; blockIdx++) {
|
|
93
|
+
const block = markdownIRBlocks[blockIdx];
|
|
94
|
+
for (const child of block.children) {
|
|
95
|
+
if (typeof child === "string") {
|
|
96
|
+
templateStrings[templateStrings.length - 1] += child;
|
|
97
|
+
} else {
|
|
98
|
+
if (child.type === "image") {
|
|
99
|
+
exprs.push(await fromLexicalImageNode(child, files));
|
|
100
|
+
} else if (child.type === "link") {
|
|
101
|
+
exprs.push(fromLexicalLinkNode(child));
|
|
102
|
+
} else {
|
|
103
|
+
const exhaustiveCheck: never = child;
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Unexpected node type: ${JSON.stringify(exhaustiveCheck, null, 2)}`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
templateStrings.push("");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (blockIdx === markdownIRBlocks.length - 1) {
|
|
112
|
+
templateStrings[templateStrings.length - 1] += "\n";
|
|
113
|
+
} else {
|
|
114
|
+
templateStrings[templateStrings.length - 1] += "\n\n";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return { [VAL_EXTENSION]: "richtext", templateStrings, exprs: exprs, files };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatText(node: LexicalTextNode): string {
|
|
121
|
+
const classes =
|
|
122
|
+
typeof node.format === "number" ? fromLexicalFormat(node.format) : [];
|
|
123
|
+
let text = node.text.trimStart();
|
|
124
|
+
const prefixWS = node.text.length - text.length;
|
|
125
|
+
text = text.trimEnd();
|
|
126
|
+
const suffixWS = node.text.length - text.length - prefixWS;
|
|
127
|
+
if (classes.includes("bold") && classes.includes("italic")) {
|
|
128
|
+
text = `***${text}***`;
|
|
129
|
+
} else if (classes.includes("bold")) {
|
|
130
|
+
text = `**${text}**`;
|
|
131
|
+
} else if (classes.includes("italic")) {
|
|
132
|
+
text = `_${text}_`;
|
|
133
|
+
}
|
|
134
|
+
if (classes.includes("line-through")) {
|
|
135
|
+
text = `~~${text}~~`;
|
|
136
|
+
}
|
|
137
|
+
// TODO:
|
|
138
|
+
// text = splitIntoChunks(text);
|
|
139
|
+
return `${" ".repeat(prefixWS)}${text}${" ".repeat(suffixWS)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function transformLeafNode(
|
|
143
|
+
node:
|
|
144
|
+
| LexicalTextNode
|
|
145
|
+
| LexicalImageNode
|
|
146
|
+
| LexicalLinkNode
|
|
147
|
+
| LexicalLineBreakNode
|
|
148
|
+
): string | LexicalImageNode | LexicalLinkNode {
|
|
149
|
+
if (node.type === "text") {
|
|
150
|
+
return formatText(node);
|
|
151
|
+
} else if (node.type === "linebreak") {
|
|
152
|
+
return "\n";
|
|
153
|
+
} else {
|
|
154
|
+
return node;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatListItemNode(
|
|
159
|
+
listPrefix: string,
|
|
160
|
+
node: LexicalListItemNode,
|
|
161
|
+
indent: number,
|
|
162
|
+
isFirstTopLevelListItem = false
|
|
163
|
+
): (string | LexicalImageNode | LexicalLinkNode)[] {
|
|
164
|
+
const newLine = isFirstTopLevelListItem ? "" : "\n";
|
|
165
|
+
const prefix: (string | LexicalImageNode | LexicalLinkNode)[] = [
|
|
166
|
+
`${newLine}${" ".repeat(indent)}${listPrefix}`,
|
|
167
|
+
];
|
|
168
|
+
if (node.children?.[0]?.type !== "list") {
|
|
169
|
+
prefix.push(" ");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return prefix.concat(
|
|
173
|
+
node.children.flatMap((child) => {
|
|
174
|
+
if (child.type === "list") {
|
|
175
|
+
return child.children.flatMap((subChild) =>
|
|
176
|
+
formatListItemNode(getListPrefix(child), subChild, indent + 4)
|
|
177
|
+
);
|
|
178
|
+
} else {
|
|
179
|
+
return [transformLeafNode(child)];
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function getListPrefix(node: LexicalListNode): string {
|
|
186
|
+
if (node.listType === "bullet") {
|
|
187
|
+
return "-";
|
|
188
|
+
} else if (node.listType === "number") {
|
|
189
|
+
return "1.";
|
|
190
|
+
} else {
|
|
191
|
+
throw new Error(`Unhandled list type: ${node.listType}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const FORMAT_MAPPING = {
|
|
196
|
+
bold: 1, // 0001
|
|
197
|
+
italic: 2, // 0010
|
|
198
|
+
"line-through": 4, // 0100
|
|
199
|
+
// underline: 8, // 1000
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export function fromLexicalFormat(
|
|
203
|
+
format: number
|
|
204
|
+
): (keyof typeof FORMAT_MAPPING)[] {
|
|
205
|
+
return Object.entries(FORMAT_MAPPING).flatMap(([key, value]) => {
|
|
206
|
+
if ((value & /* bitwise and */ format) === value) {
|
|
207
|
+
return [key as keyof typeof FORMAT_MAPPING];
|
|
208
|
+
}
|
|
209
|
+
return [];
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
214
|
+
function splitIntoChunks(str: string) {
|
|
215
|
+
let line = "";
|
|
216
|
+
for (let i = 0; i < str.length; i += 80) {
|
|
217
|
+
const chunk = str.substring(i, i + MAX_LINE_LENGTH);
|
|
218
|
+
line += chunk;
|
|
219
|
+
if (i !== str.length - 1 && chunk.length >= 80) {
|
|
220
|
+
line += "\n";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return line;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const textEncoder = new TextEncoder();
|
|
227
|
+
async function fromLexicalImageNode(
|
|
228
|
+
node: LexicalImageNode,
|
|
229
|
+
files: Record<string, string>
|
|
230
|
+
) {
|
|
231
|
+
if (node.src.startsWith("data:")) {
|
|
232
|
+
console.log("node", node);
|
|
233
|
+
const sha256 = await Internal.getSHA256Hash(textEncoder.encode(node.src));
|
|
234
|
+
const mimeType = getMimeType(node.src);
|
|
235
|
+
if (mimeType === undefined) {
|
|
236
|
+
throw new Error(`Could not detect Mime Type for image: ${node.src}`);
|
|
237
|
+
}
|
|
238
|
+
const fileExt = mimeTypeToFileExt(mimeType);
|
|
239
|
+
const filePath = `/public/${sha256}.${fileExt}`;
|
|
240
|
+
files[filePath] = node.src;
|
|
241
|
+
return {
|
|
242
|
+
[VAL_EXTENSION]: "file" as const,
|
|
243
|
+
[FILE_REF_PROP]: filePath,
|
|
244
|
+
metadata: {
|
|
245
|
+
width: node.width || 0,
|
|
246
|
+
height: node.width || 0,
|
|
247
|
+
sha256: sha256 || "",
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
} else {
|
|
251
|
+
const sha256 = getParam("sha256", node.src);
|
|
252
|
+
return {
|
|
253
|
+
[VAL_EXTENSION]: "file" as const,
|
|
254
|
+
[FILE_REF_PROP]: `/public${node.src.split("?")[0]}`,
|
|
255
|
+
metadata: {
|
|
256
|
+
width: node.width || 0,
|
|
257
|
+
height: node.width || 0,
|
|
258
|
+
sha256: sha256 || "",
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function getParam(param: string, url: string) {
|
|
265
|
+
const urlParts = url.split("?");
|
|
266
|
+
if (urlParts.length < 2) {
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const queryString = urlParts[1];
|
|
271
|
+
const params = new URLSearchParams(queryString);
|
|
272
|
+
|
|
273
|
+
if (params.has(param)) {
|
|
274
|
+
return params.get(param);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function fromLexicalLinkNode(node: LexicalLinkNode): LinkSource {
|
|
281
|
+
return {
|
|
282
|
+
[VAL_EXTENSION]: "link",
|
|
283
|
+
href: node.url,
|
|
284
|
+
children: node.children.map(formatText) as LinkSource["children"],
|
|
285
|
+
};
|
|
286
|
+
}
|