@valbuild/ui 0.12.0 → 0.13.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/package.json +1 -1
- package/src/assets/icons/Bold.tsx +23 -0
- package/src/assets/icons/Chevron.tsx +28 -0
- package/src/assets/icons/FontColor.tsx +30 -0
- package/src/assets/icons/ImageIcon.tsx +21 -0
- package/src/assets/icons/Italic.tsx +24 -0
- package/src/assets/icons/Strikethrough.tsx +22 -0
- package/src/assets/icons/Underline.tsx +22 -0
- package/src/assets/icons/Undo.tsx +20 -0
- package/src/components/Button.tsx +58 -0
- package/src/components/Checkbox.tsx +51 -0
- package/src/components/Dropdown.tsx +92 -0
- package/src/components/EditButton.tsx +10 -0
- package/src/components/ErrorText.tsx +3 -0
- package/src/components/RichTextEditor/ContentEditable.tsx +9 -0
- package/src/components/RichTextEditor/Nodes/ImageNode.tsx +117 -0
- package/src/components/RichTextEditor/Plugins/AutoFocus.tsx +12 -0
- package/src/components/RichTextEditor/Plugins/ImagePlugin.tsx +46 -0
- package/src/components/RichTextEditor/Plugins/Toolbar.tsx +381 -0
- package/src/components/RichTextEditor/RichTextEditor.tsx +176 -0
- package/src/components/UploadModal.tsx +109 -0
- package/src/components/ValOverlay.tsx +41 -0
- package/src/components/ValWindow.stories.tsx +182 -0
- package/src/components/ValWindow.tsx +137 -0
- package/src/components/forms/Form.tsx +122 -0
- package/src/components/forms/FormContainer.tsx +24 -0
- package/src/components/forms/ImageForm.tsx +195 -0
- package/src/components/forms/TextForm.tsx +22 -0
- package/src/exports.ts +3 -0
- package/src/index.css +79 -0
- package/src/index.tsx +14 -0
- package/src/server.ts +41 -0
- package/src/stories/Button.stories.tsx +20 -0
- package/src/stories/Checkbox.stories.tsx +14 -0
- package/src/stories/Dropdown.stories.tsx +23 -0
- package/src/stories/Introduction.mdx +221 -0
- package/src/stories/RichTextEditor.stories.tsx +314 -0
- package/src/stories/assets/code-brackets.svg +1 -0
- package/src/stories/assets/colors.svg +1 -0
- package/src/stories/assets/comments.svg +1 -0
- package/src/stories/assets/direction.svg +1 -0
- package/src/stories/assets/flow.svg +1 -0
- package/src/stories/assets/plugin.svg +1 -0
- package/src/stories/assets/repo.svg +1 -0
- package/src/stories/assets/stackalt.svg +1 -0
- package/src/vite-env.d.ts +1 -0
- package/src/vite-index.tsx +7 -0
- package/src/vite-server.ts +8 -0
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
import {
|
|
3
|
+
INSERT_CHECK_LIST_COMMAND,
|
|
4
|
+
INSERT_ORDERED_LIST_COMMAND,
|
|
5
|
+
INSERT_UNORDERED_LIST_COMMAND,
|
|
6
|
+
REMOVE_LIST_COMMAND,
|
|
7
|
+
$isListNode,
|
|
8
|
+
ListNode,
|
|
9
|
+
} from "@lexical/list";
|
|
10
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
11
|
+
import {
|
|
12
|
+
$createHeadingNode,
|
|
13
|
+
$isHeadingNode,
|
|
14
|
+
HeadingTagType,
|
|
15
|
+
} from "@lexical/rich-text";
|
|
16
|
+
import {
|
|
17
|
+
$getSelectionStyleValueForProperty,
|
|
18
|
+
$patchStyleText,
|
|
19
|
+
$setBlocksType,
|
|
20
|
+
} from "@lexical/selection";
|
|
21
|
+
import {
|
|
22
|
+
$findMatchingParent,
|
|
23
|
+
$getNearestBlockElementAncestorOrThrow,
|
|
24
|
+
mergeRegister,
|
|
25
|
+
$getNearestNodeOfType,
|
|
26
|
+
} from "@lexical/utils";
|
|
27
|
+
import {
|
|
28
|
+
$createParagraphNode,
|
|
29
|
+
$getSelection,
|
|
30
|
+
$isRangeSelection,
|
|
31
|
+
$isRootOrShadowRoot,
|
|
32
|
+
$isTextNode,
|
|
33
|
+
COMMAND_PRIORITY_CRITICAL,
|
|
34
|
+
DEPRECATED_$isGridSelection,
|
|
35
|
+
FORMAT_TEXT_COMMAND,
|
|
36
|
+
LexicalEditor,
|
|
37
|
+
NodeKey,
|
|
38
|
+
REDO_COMMAND,
|
|
39
|
+
SELECTION_CHANGE_COMMAND,
|
|
40
|
+
SerializedLexicalNode,
|
|
41
|
+
UNDO_COMMAND,
|
|
42
|
+
} from "lexical";
|
|
43
|
+
import { SerializedEditorState } from "lexical/LexicalEditorState";
|
|
44
|
+
import { FC, useCallback, useEffect, useState } from "react";
|
|
45
|
+
import Bold from "../../../assets/icons/Bold";
|
|
46
|
+
import ImageIcon from "../../../assets/icons/ImageIcon";
|
|
47
|
+
import Italic from "../../../assets/icons/Italic";
|
|
48
|
+
import Strikethrough from "../../../assets/icons/Strikethrough";
|
|
49
|
+
import Underline from "../../../assets/icons/Underline";
|
|
50
|
+
import Undo from "../../../assets/icons/Undo";
|
|
51
|
+
import Button from "../../Button";
|
|
52
|
+
import Dropdown from "../../Dropdown";
|
|
53
|
+
import UploadModal from "../../UploadModal";
|
|
54
|
+
import { INSERT_IMAGE_COMMAND } from "./ImagePlugin";
|
|
55
|
+
|
|
56
|
+
export interface ToolbarSettingsProps {
|
|
57
|
+
fontsFamilies?: string[];
|
|
58
|
+
fontSizes?: string[];
|
|
59
|
+
colors?: string[];
|
|
60
|
+
onEditor?: (editor: LexicalEditor) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const Toolbar: FC<ToolbarSettingsProps> = ({
|
|
64
|
+
fontSizes,
|
|
65
|
+
fontsFamilies,
|
|
66
|
+
onEditor,
|
|
67
|
+
colors,
|
|
68
|
+
}) => {
|
|
69
|
+
const [editor] = useLexicalComposerContext();
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (onEditor) {
|
|
72
|
+
onEditor(editor);
|
|
73
|
+
}
|
|
74
|
+
}, [editor]);
|
|
75
|
+
const [activeEditor, setActiveEditor] = useState(editor);
|
|
76
|
+
const [selectedElementKey, setSelectedElementKey] = useState<NodeKey | null>(
|
|
77
|
+
null
|
|
78
|
+
);
|
|
79
|
+
const [isBold, setIsBold] = useState(false);
|
|
80
|
+
const [isItalic, setIsItalic] = useState(false);
|
|
81
|
+
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
|
82
|
+
const [isUnderline, setIsUnderline] = useState(false);
|
|
83
|
+
const [fontSize, setFontSize] = useState<string>("15px");
|
|
84
|
+
const [fontColor, setFontColor] = useState<string>("#000");
|
|
85
|
+
const [fontFamily, setFontFamily] = useState<string>("Sans");
|
|
86
|
+
const [blockType, setBlockType] =
|
|
87
|
+
useState<keyof typeof blockTypes>("paragraph");
|
|
88
|
+
|
|
89
|
+
const [showModal, setShowModal] = useState<boolean>(false);
|
|
90
|
+
const [inputUrl, setInputUrl] = useState<boolean>(false);
|
|
91
|
+
const [uploadMode, setUploadMode] = useState<"url" | "file">("url");
|
|
92
|
+
const [file, setFile] = useState<File | null>(null);
|
|
93
|
+
const [url, setUrl] = useState<string>("");
|
|
94
|
+
|
|
95
|
+
const blockTypes: { [key: string]: string } = {
|
|
96
|
+
paragraph: "Normal",
|
|
97
|
+
h1: "Heading 1",
|
|
98
|
+
h2: "Heading 2",
|
|
99
|
+
h3: "Heading 3",
|
|
100
|
+
h4: "Heading 4",
|
|
101
|
+
h5: "Heading 5",
|
|
102
|
+
h6: "Heading 6",
|
|
103
|
+
number: "Numbered List",
|
|
104
|
+
bullet: "Bulleted List",
|
|
105
|
+
};
|
|
106
|
+
const blockTypesLookup: { [key: string]: string } = {};
|
|
107
|
+
|
|
108
|
+
for (const key in blockTypes) {
|
|
109
|
+
blockTypesLookup[blockTypes[key]] = key;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const updateToolbar = useCallback(() => {
|
|
113
|
+
const selection = $getSelection();
|
|
114
|
+
if ($isRangeSelection(selection)) {
|
|
115
|
+
const anchorNode = selection.anchor.getNode();
|
|
116
|
+
let element =
|
|
117
|
+
anchorNode.getKey() === "root"
|
|
118
|
+
? anchorNode
|
|
119
|
+
: $findMatchingParent(anchorNode, (e) => {
|
|
120
|
+
const parent = e.getParent();
|
|
121
|
+
return parent !== null && $isRootOrShadowRoot(parent);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (element === null) {
|
|
125
|
+
element = anchorNode.getTopLevelElementOrThrow();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const elementKey = element.getKey();
|
|
129
|
+
const elementDOM = activeEditor.getElementByKey(elementKey);
|
|
130
|
+
setIsBold(selection.hasFormat("bold"));
|
|
131
|
+
setIsItalic(selection.hasFormat("italic"));
|
|
132
|
+
setIsStrikethrough(selection.hasFormat("strikethrough"));
|
|
133
|
+
setIsUnderline(selection.hasFormat("underline"));
|
|
134
|
+
if (elementDOM !== null) {
|
|
135
|
+
setSelectedElementKey(elementKey);
|
|
136
|
+
if ($isListNode(element)) {
|
|
137
|
+
const parentList = $getNearestNodeOfType<ListNode>(
|
|
138
|
+
anchorNode,
|
|
139
|
+
ListNode
|
|
140
|
+
);
|
|
141
|
+
const type = parentList
|
|
142
|
+
? parentList.getListType()
|
|
143
|
+
: element.getListType();
|
|
144
|
+
setBlockType(type);
|
|
145
|
+
} else {
|
|
146
|
+
const type = $isHeadingNode(element)
|
|
147
|
+
? element.getTag()
|
|
148
|
+
: element.getType();
|
|
149
|
+
if (type in blockTypes) {
|
|
150
|
+
setBlockType(type as keyof typeof blockTypes);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
setFontSize(
|
|
156
|
+
$getSelectionStyleValueForProperty(selection, "font-size", "15px")
|
|
157
|
+
);
|
|
158
|
+
setFontColor(
|
|
159
|
+
$getSelectionStyleValueForProperty(selection, "color", "#000")
|
|
160
|
+
);
|
|
161
|
+
setFontFamily(
|
|
162
|
+
$getSelectionStyleValueForProperty(selection, "font-family", "Arial")
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
}, [activeEditor]);
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
return mergeRegister(
|
|
169
|
+
editor.registerUpdateListener(({ editorState }) => {
|
|
170
|
+
editorState.read(() => {
|
|
171
|
+
updateToolbar();
|
|
172
|
+
});
|
|
173
|
+
})
|
|
174
|
+
);
|
|
175
|
+
}, [updateToolbar, activeEditor, editor]);
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
return editor.registerCommand(
|
|
179
|
+
SELECTION_CHANGE_COMMAND,
|
|
180
|
+
(_payload, newEditor) => {
|
|
181
|
+
updateToolbar();
|
|
182
|
+
setActiveEditor(newEditor);
|
|
183
|
+
return false;
|
|
184
|
+
},
|
|
185
|
+
COMMAND_PRIORITY_CRITICAL
|
|
186
|
+
);
|
|
187
|
+
}, [editor, updateToolbar]);
|
|
188
|
+
|
|
189
|
+
const formatText = (format: keyof typeof blockTypes) => {
|
|
190
|
+
if (["h1", "h2", "h3", "h4", "h5", "h6"].includes(format as string)) {
|
|
191
|
+
if (blockType !== format) {
|
|
192
|
+
editor.update(() => {
|
|
193
|
+
const selection = $getSelection();
|
|
194
|
+
if ($isRangeSelection(selection)) {
|
|
195
|
+
$setBlocksType(selection, () => {
|
|
196
|
+
return $createHeadingNode(format as HeadingTagType);
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
} else if (format === "paragraph" && blockType !== "paragraph") {
|
|
202
|
+
editor.update(() => {
|
|
203
|
+
const selection = $getSelection();
|
|
204
|
+
if (
|
|
205
|
+
$isRangeSelection(selection) ||
|
|
206
|
+
DEPRECATED_$isGridSelection(selection)
|
|
207
|
+
) {
|
|
208
|
+
$setBlocksType(selection, () => $createParagraphNode());
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
} else {
|
|
212
|
+
if (format === "number" && blockType !== "number") {
|
|
213
|
+
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
214
|
+
} else if (format === "bullet" && blockType !== "bullet") {
|
|
215
|
+
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
|
216
|
+
} else {
|
|
217
|
+
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const clearFormatting = useCallback(() => {
|
|
223
|
+
editor.update(() => {
|
|
224
|
+
const selection = $getSelection();
|
|
225
|
+
if ($isRangeSelection(selection)) {
|
|
226
|
+
const anchor = selection.anchor;
|
|
227
|
+
const focus = selection.focus;
|
|
228
|
+
const nodes = selection.getNodes();
|
|
229
|
+
|
|
230
|
+
if (anchor.key === focus.key && anchor.offset === focus.offset) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
nodes.forEach((node, idx) => {
|
|
234
|
+
if ($isTextNode(node)) {
|
|
235
|
+
if (idx === 0 && anchor.offset !== 0) {
|
|
236
|
+
node = node.splitText(anchor.offset)[1] || node;
|
|
237
|
+
}
|
|
238
|
+
if (idx === nodes.length - 1) {
|
|
239
|
+
node = node.splitText(focus.offset)[0] || node;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (node.__style !== "") {
|
|
243
|
+
node.setStyle("");
|
|
244
|
+
}
|
|
245
|
+
if (node.__format !== 0) {
|
|
246
|
+
node.setFormat(0);
|
|
247
|
+
$getNearestBlockElementAncestorOrThrow(node).setFormat("");
|
|
248
|
+
}
|
|
249
|
+
} else if ($isHeadingNode(node)) {
|
|
250
|
+
node.replace($createParagraphNode(), true);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}, [activeEditor]);
|
|
256
|
+
|
|
257
|
+
const changeFontFamily = (fontFamily: string) => {
|
|
258
|
+
editor.update(() => {
|
|
259
|
+
const selection = $getSelection();
|
|
260
|
+
if ($isRangeSelection(selection)) {
|
|
261
|
+
$patchStyleText(selection, {
|
|
262
|
+
["font-family"]: fontFamily,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const changeFontSize = (fontSize: string) => {
|
|
269
|
+
editor.update(() => {
|
|
270
|
+
const selection = $getSelection();
|
|
271
|
+
if ($isRangeSelection(selection)) {
|
|
272
|
+
$patchStyleText(selection, {
|
|
273
|
+
["font-size"]: fontSize,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
});
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const uploadImage = (url: string, alt?: string) => {
|
|
280
|
+
editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
|
|
281
|
+
altText: "URL image",
|
|
282
|
+
src: url,
|
|
283
|
+
});
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div className="flex flex-row items-center gap-6 p-2 overflow-scroll">
|
|
288
|
+
{/* <div className="flex flex-row gap-2">
|
|
289
|
+
<button className="hidden w-0 h-0 " disabled></button>
|
|
290
|
+
<Button
|
|
291
|
+
icon={<Undo />}
|
|
292
|
+
onClick={() => {
|
|
293
|
+
editor.dispatchCommand(UNDO_COMMAND, undefined);
|
|
294
|
+
}}
|
|
295
|
+
></Button>
|
|
296
|
+
<Button
|
|
297
|
+
icon={<Undo className="transform -scale-x-100" />}
|
|
298
|
+
onClick={() => {
|
|
299
|
+
editor.dispatchCommand(REDO_COMMAND, undefined);
|
|
300
|
+
}}
|
|
301
|
+
/>
|
|
302
|
+
</div> */}
|
|
303
|
+
<div className="flex flex-row gap-2">
|
|
304
|
+
<Dropdown
|
|
305
|
+
options={Object.values(blockTypes)}
|
|
306
|
+
label={
|
|
307
|
+
blockTypes[blockType as string] ?? blockType + " (not supported)"
|
|
308
|
+
}
|
|
309
|
+
onChange={(selectedOption) => {
|
|
310
|
+
formatText(blockTypesLookup[selectedOption]);
|
|
311
|
+
}}
|
|
312
|
+
/>
|
|
313
|
+
<Dropdown
|
|
314
|
+
onChange={changeFontFamily}
|
|
315
|
+
options={fontsFamilies ?? ["sans", "serif", "solina"]}
|
|
316
|
+
label={fontFamily}
|
|
317
|
+
/>
|
|
318
|
+
<Dropdown
|
|
319
|
+
onChange={changeFontSize}
|
|
320
|
+
options={
|
|
321
|
+
fontSizes ??
|
|
322
|
+
[11, 12, 13, 14, 15, 16, 17, 18, 19, 20].map((size) => `${size}px`)
|
|
323
|
+
}
|
|
324
|
+
label={fontSize}
|
|
325
|
+
/>
|
|
326
|
+
</div>
|
|
327
|
+
<div className="flex flex-row gap-2">
|
|
328
|
+
<Button
|
|
329
|
+
variant="primary"
|
|
330
|
+
onClick={(ev) => {
|
|
331
|
+
ev.preventDefault();
|
|
332
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
|
333
|
+
}}
|
|
334
|
+
tooltip="Format text as bold"
|
|
335
|
+
active={isBold}
|
|
336
|
+
icon={<Bold className={`${isBold && "stroke-[3px]"}`} />}
|
|
337
|
+
/>
|
|
338
|
+
<Button
|
|
339
|
+
active={isStrikethrough}
|
|
340
|
+
onClick={(ev) => {
|
|
341
|
+
ev.preventDefault();
|
|
342
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough");
|
|
343
|
+
}}
|
|
344
|
+
icon={
|
|
345
|
+
<Strikethrough className={`${isStrikethrough && "stroke-[2px]"}`} />
|
|
346
|
+
}
|
|
347
|
+
/>
|
|
348
|
+
<Button
|
|
349
|
+
active={isItalic}
|
|
350
|
+
onClick={(ev) => {
|
|
351
|
+
ev.preventDefault();
|
|
352
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
|
353
|
+
}}
|
|
354
|
+
icon={<Italic className={`${isItalic && "stroke-[3px]"}`} />}
|
|
355
|
+
/>
|
|
356
|
+
<Button
|
|
357
|
+
active={isUnderline}
|
|
358
|
+
onClick={(ev) => {
|
|
359
|
+
ev.preventDefault();
|
|
360
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline");
|
|
361
|
+
}}
|
|
362
|
+
icon={<Underline className={`${isUnderline && "stroke-[3px]"}`} />}
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
<Button
|
|
366
|
+
icon={<ImageIcon />}
|
|
367
|
+
onClick={(ev) => {
|
|
368
|
+
ev.preventDefault();
|
|
369
|
+
setShowModal(true);
|
|
370
|
+
}}
|
|
371
|
+
></Button>
|
|
372
|
+
<UploadModal
|
|
373
|
+
setShowModal={setShowModal}
|
|
374
|
+
showModal={showModal}
|
|
375
|
+
uploadImage={uploadImage}
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
export default Toolbar;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
2
|
+
import {
|
|
3
|
+
$createParagraphNode,
|
|
4
|
+
$createTextNode,
|
|
5
|
+
$getRoot,
|
|
6
|
+
LexicalEditor,
|
|
7
|
+
LexicalNode,
|
|
8
|
+
} from "lexical";
|
|
9
|
+
import {
|
|
10
|
+
ListItemNode,
|
|
11
|
+
ListNode,
|
|
12
|
+
$createListNode,
|
|
13
|
+
$createListItemNode,
|
|
14
|
+
} from "@lexical/list";
|
|
15
|
+
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
|
16
|
+
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
|
|
17
|
+
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
|
18
|
+
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
|
19
|
+
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
|
|
20
|
+
import { FC } from "react";
|
|
21
|
+
import LexicalContentEditable from "./ContentEditable";
|
|
22
|
+
import { ImageNode } from "./Nodes/ImageNode";
|
|
23
|
+
import { AutoFocus } from "./Plugins/AutoFocus";
|
|
24
|
+
import ImagesPlugin from "./Plugins/ImagePlugin";
|
|
25
|
+
import Toolbar from "./Plugins/Toolbar";
|
|
26
|
+
import {
|
|
27
|
+
RichText,
|
|
28
|
+
TextNode as ValTextNode,
|
|
29
|
+
HeadingNode as ValHeadingNode,
|
|
30
|
+
ListItemNode as ValListItemNode,
|
|
31
|
+
ParagraphNode as ValParagraphNode,
|
|
32
|
+
ListNode as ValListNode,
|
|
33
|
+
} from "@valbuild/core";
|
|
34
|
+
import { $createHeadingNode, HeadingNode } from "@lexical/rich-text";
|
|
35
|
+
|
|
36
|
+
export interface RichTextEditorProps {
|
|
37
|
+
richtext: RichText;
|
|
38
|
+
onEditor?: (editor: LexicalEditor) => void; // Not the ideal way of passing the editor to the upper context, we need it to be able to save
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function onError(error: any) {
|
|
42
|
+
console.error(error);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ValNode =
|
|
46
|
+
| ValTextNode
|
|
47
|
+
| ValHeadingNode
|
|
48
|
+
| ValListItemNode
|
|
49
|
+
| ValParagraphNode
|
|
50
|
+
| ValListNode;
|
|
51
|
+
function toLexicalNode(node: ValNode): LexicalNode {
|
|
52
|
+
switch (node.type) {
|
|
53
|
+
case "heading":
|
|
54
|
+
return toLexicalHeadingNode(node);
|
|
55
|
+
case "listitem":
|
|
56
|
+
return toLexicalListItemNode(node);
|
|
57
|
+
case "paragraph":
|
|
58
|
+
return toLexicalParagraphNode(node);
|
|
59
|
+
case "list":
|
|
60
|
+
return toLexicalListNode(node);
|
|
61
|
+
case "text":
|
|
62
|
+
return toLexicalTextNode(node);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function toLexicalHeadingNode(heading: ValHeadingNode): LexicalNode {
|
|
67
|
+
const node = $createHeadingNode(heading.tag);
|
|
68
|
+
node.setFormat(heading.format);
|
|
69
|
+
node.setIndent(heading.indent);
|
|
70
|
+
node.setDirection(heading.direction);
|
|
71
|
+
node.append(...heading.children.map((child) => toLexicalNode(child)));
|
|
72
|
+
return node;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function toLexicalParagraphNode(paragraph: ValParagraphNode): LexicalNode {
|
|
76
|
+
const node = $createParagraphNode();
|
|
77
|
+
node.setFormat(paragraph.format);
|
|
78
|
+
node.setIndent(paragraph.indent);
|
|
79
|
+
node.setDirection(paragraph.direction);
|
|
80
|
+
node.append(...paragraph.children.map((child) => toLexicalNode(child)));
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toLexicalListItemNode(listItem: ValListItemNode): LexicalNode {
|
|
85
|
+
const node = $createListItemNode();
|
|
86
|
+
node.setFormat(listItem.format);
|
|
87
|
+
node.setIndent(listItem.indent);
|
|
88
|
+
node.setDirection(listItem.direction);
|
|
89
|
+
node.setValue(listItem.value);
|
|
90
|
+
node.setChecked(listItem.checked);
|
|
91
|
+
node.append(...listItem.children.map((child) => toLexicalNode(child)));
|
|
92
|
+
return node;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toLexicalListNode(list: ValListNode): LexicalNode {
|
|
96
|
+
const node = $createListNode(list.listType, list.start);
|
|
97
|
+
node.setFormat(list.format);
|
|
98
|
+
node.setIndent(list.indent);
|
|
99
|
+
node.setDirection(list.direction);
|
|
100
|
+
node.append(...list.children.map((child) => toLexicalNode(child)));
|
|
101
|
+
return node;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toLexicalTextNode(text: ValTextNode): LexicalNode {
|
|
105
|
+
const node = $createTextNode(text.text);
|
|
106
|
+
node.setFormat(text.format as any); // TODO: why is text.format numbers when we are trying it out?
|
|
107
|
+
text.indent && node.setIndent(text.indent);
|
|
108
|
+
text.direction && node.setDirection(text.direction);
|
|
109
|
+
node.setStyle(text.style);
|
|
110
|
+
node.setDetail(text.detail);
|
|
111
|
+
return node;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const RichTextEditor: FC<RichTextEditorProps> = ({
|
|
115
|
+
richtext,
|
|
116
|
+
onEditor,
|
|
117
|
+
}) => {
|
|
118
|
+
const prePopulatedState = () => {
|
|
119
|
+
const root = $getRoot();
|
|
120
|
+
$getRoot().append(
|
|
121
|
+
...richtext.children.map((child) => toLexicalNode(child))
|
|
122
|
+
);
|
|
123
|
+
root.selectEnd();
|
|
124
|
+
};
|
|
125
|
+
const initialConfig = {
|
|
126
|
+
namespace: "val",
|
|
127
|
+
editorState: prePopulatedState,
|
|
128
|
+
nodes: [HeadingNode, ImageNode, ListNode, ListItemNode],
|
|
129
|
+
theme: {
|
|
130
|
+
root: "relative p-4 bg-base min-h-[200px] text-white font-roboto",
|
|
131
|
+
text: {
|
|
132
|
+
bold: "font-semibold",
|
|
133
|
+
underline: "underline",
|
|
134
|
+
italic: "italic",
|
|
135
|
+
strikethrough: "line-through",
|
|
136
|
+
underlineStrikethrough: "underlined-line-through",
|
|
137
|
+
},
|
|
138
|
+
list: {
|
|
139
|
+
listitem: "ml-[20px]",
|
|
140
|
+
ol: "list-decimal",
|
|
141
|
+
ul: "list-disc",
|
|
142
|
+
},
|
|
143
|
+
heading: {
|
|
144
|
+
h1: "text-4xl font-bold",
|
|
145
|
+
h2: "text-3xl font-bold",
|
|
146
|
+
h3: "text-2xl font-bold",
|
|
147
|
+
h4: "text-xl font-bold",
|
|
148
|
+
h5: "text-lg font-bold",
|
|
149
|
+
h6: "text-base font-bold",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
onError,
|
|
153
|
+
};
|
|
154
|
+
return (
|
|
155
|
+
<div className=" relative bg-base min-h-[200px] mt-2 border border-highlight rounded">
|
|
156
|
+
<LexicalComposer initialConfig={initialConfig}>
|
|
157
|
+
<Toolbar onEditor={onEditor} />
|
|
158
|
+
<ImagesPlugin />
|
|
159
|
+
<RichTextPlugin
|
|
160
|
+
contentEditable={
|
|
161
|
+
<LexicalContentEditable className="relative bg-fill flex flex-col h-full w-full min-h-[200px] text-primary outline-none" />
|
|
162
|
+
}
|
|
163
|
+
placeholder={
|
|
164
|
+
<div className="absolute top-[calc(58px+1rem)] left-4 text-base/25 ">
|
|
165
|
+
Enter some text...
|
|
166
|
+
</div>
|
|
167
|
+
}
|
|
168
|
+
ErrorBoundary={LexicalErrorBoundary}
|
|
169
|
+
/>
|
|
170
|
+
<ListPlugin />
|
|
171
|
+
<AutoFocus />
|
|
172
|
+
<HistoryPlugin />
|
|
173
|
+
</LexicalComposer>
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { FC, useEffect, useState } from "react";
|
|
2
|
+
import Button from "./Button";
|
|
3
|
+
|
|
4
|
+
interface UploadModalProps {
|
|
5
|
+
showModal: boolean;
|
|
6
|
+
setShowModal: React.Dispatch<React.SetStateAction<boolean>>;
|
|
7
|
+
uploadImage: (url: string, alt?: string) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const UploadModal: FC<UploadModalProps> = ({
|
|
11
|
+
showModal,
|
|
12
|
+
setShowModal,
|
|
13
|
+
uploadImage,
|
|
14
|
+
}) => {
|
|
15
|
+
const [uploadUrl, setUploadUrl] = useState<boolean>(true);
|
|
16
|
+
const [url, setUrl] = useState<string>("");
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setUrl("");
|
|
20
|
+
}, [uploadUrl]);
|
|
21
|
+
|
|
22
|
+
const loadImage = (files: FileList | null) => {
|
|
23
|
+
const reader = new FileReader();
|
|
24
|
+
reader.onload = function () {
|
|
25
|
+
if (typeof reader.result === "string") {
|
|
26
|
+
setUrl(reader.result);
|
|
27
|
+
}
|
|
28
|
+
return "";
|
|
29
|
+
};
|
|
30
|
+
if (files !== null) {
|
|
31
|
+
reader.readAsDataURL(files[0]);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const onSubmit = () => {
|
|
36
|
+
if (url) {
|
|
37
|
+
uploadImage(url);
|
|
38
|
+
setUrl("");
|
|
39
|
+
setShowModal(false);
|
|
40
|
+
setUploadUrl(true);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="absolute z-10 flex flex-col justify-center items-center top-[50%] left-[50%] font-mono">
|
|
46
|
+
{showModal && (
|
|
47
|
+
<div className="flex flex-col items-center justify-center">
|
|
48
|
+
<div className="flex flex-col items-start justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
|
49
|
+
<div className="fixed inset-0 transition-opacity">
|
|
50
|
+
<div className="absolute inset-0 bg-gray-500 opacity-75" />
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="flex flex-col items-center justify-between bg-fill rounded-lg transform transition-all min-h-[300px] min-w-[500px] h-full px-5 py-7">
|
|
54
|
+
<div className="flex flex-col items-center w-full gap-5">
|
|
55
|
+
<div className="mb-4">
|
|
56
|
+
<Button
|
|
57
|
+
variant={uploadUrl ? "primary" : "secondary"}
|
|
58
|
+
onClick={() => setUploadUrl(true)}
|
|
59
|
+
>
|
|
60
|
+
Paste URL
|
|
61
|
+
</Button>
|
|
62
|
+
<Button
|
|
63
|
+
variant={uploadUrl ? "secondary" : "primary"}
|
|
64
|
+
onClick={() => setUploadUrl(false)}
|
|
65
|
+
>
|
|
66
|
+
Upload file
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
{uploadUrl ? (
|
|
70
|
+
<div className="flex flex-col items-center justify-center w-full gap-5">
|
|
71
|
+
<label className="text-primary">Upload URL</label>
|
|
72
|
+
<input
|
|
73
|
+
className="w-full h-10 rounded-lg bg-border"
|
|
74
|
+
value={url}
|
|
75
|
+
onChange={(event) => setUrl(event.target.value)}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
) : (
|
|
79
|
+
<div className="flex flex-col items-center justify-center w-full gap-5">
|
|
80
|
+
<label className="text-primary">Choose File</label>
|
|
81
|
+
<input
|
|
82
|
+
className="h-10 rounded-lg w-fit"
|
|
83
|
+
type="file"
|
|
84
|
+
onChange={(e) => loadImage(e.target.files)}
|
|
85
|
+
/>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex flex-row items-center justify-center gap-5 ">
|
|
90
|
+
<Button variant="secondary" onClick={() => setShowModal(false)}>
|
|
91
|
+
Cancel
|
|
92
|
+
</Button>
|
|
93
|
+
<Button
|
|
94
|
+
variant="primary"
|
|
95
|
+
disabled={url === ""}
|
|
96
|
+
onClick={() => onSubmit()}
|
|
97
|
+
>
|
|
98
|
+
Upload
|
|
99
|
+
</Button>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export default UploadModal;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { EditButton } from "./EditButton";
|
|
2
|
+
import { Form, FormProps } from "./forms/Form";
|
|
3
|
+
import { ValWindow } from "./ValWindow";
|
|
4
|
+
|
|
5
|
+
type ValWindow = {
|
|
6
|
+
position: {
|
|
7
|
+
left: number;
|
|
8
|
+
top: number;
|
|
9
|
+
};
|
|
10
|
+
} & FormProps;
|
|
11
|
+
|
|
12
|
+
export type ValOverlayProps = {
|
|
13
|
+
editMode: boolean;
|
|
14
|
+
setEditMode: (editMode: boolean) => void;
|
|
15
|
+
valWindow?: ValWindow;
|
|
16
|
+
closeValWindow: () => void;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function ValOverlay({
|
|
20
|
+
editMode,
|
|
21
|
+
setEditMode,
|
|
22
|
+
valWindow,
|
|
23
|
+
closeValWindow,
|
|
24
|
+
}: ValOverlayProps) {
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<div className="fixed -translate-x-1/2 left-1/2 bottom-4">
|
|
28
|
+
<EditButton
|
|
29
|
+
onClick={() => {
|
|
30
|
+
setEditMode(!editMode);
|
|
31
|
+
}}
|
|
32
|
+
/>
|
|
33
|
+
</div>
|
|
34
|
+
{editMode && valWindow && (
|
|
35
|
+
<ValWindow onClose={closeValWindow} position={valWindow.position}>
|
|
36
|
+
<Form onSubmit={valWindow.onSubmit} inputs={valWindow.inputs} />
|
|
37
|
+
</ValWindow>
|
|
38
|
+
)}
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|