@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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/assets/icons/Bold.tsx +23 -0
  3. package/src/assets/icons/Chevron.tsx +28 -0
  4. package/src/assets/icons/FontColor.tsx +30 -0
  5. package/src/assets/icons/ImageIcon.tsx +21 -0
  6. package/src/assets/icons/Italic.tsx +24 -0
  7. package/src/assets/icons/Strikethrough.tsx +22 -0
  8. package/src/assets/icons/Underline.tsx +22 -0
  9. package/src/assets/icons/Undo.tsx +20 -0
  10. package/src/components/Button.tsx +58 -0
  11. package/src/components/Checkbox.tsx +51 -0
  12. package/src/components/Dropdown.tsx +92 -0
  13. package/src/components/EditButton.tsx +10 -0
  14. package/src/components/ErrorText.tsx +3 -0
  15. package/src/components/RichTextEditor/ContentEditable.tsx +9 -0
  16. package/src/components/RichTextEditor/Nodes/ImageNode.tsx +117 -0
  17. package/src/components/RichTextEditor/Plugins/AutoFocus.tsx +12 -0
  18. package/src/components/RichTextEditor/Plugins/ImagePlugin.tsx +46 -0
  19. package/src/components/RichTextEditor/Plugins/Toolbar.tsx +381 -0
  20. package/src/components/RichTextEditor/RichTextEditor.tsx +176 -0
  21. package/src/components/UploadModal.tsx +109 -0
  22. package/src/components/ValOverlay.tsx +41 -0
  23. package/src/components/ValWindow.stories.tsx +182 -0
  24. package/src/components/ValWindow.tsx +137 -0
  25. package/src/components/forms/Form.tsx +122 -0
  26. package/src/components/forms/FormContainer.tsx +24 -0
  27. package/src/components/forms/ImageForm.tsx +195 -0
  28. package/src/components/forms/TextForm.tsx +22 -0
  29. package/src/exports.ts +3 -0
  30. package/src/index.css +79 -0
  31. package/src/index.tsx +14 -0
  32. package/src/server.ts +41 -0
  33. package/src/stories/Button.stories.tsx +20 -0
  34. package/src/stories/Checkbox.stories.tsx +14 -0
  35. package/src/stories/Dropdown.stories.tsx +23 -0
  36. package/src/stories/Introduction.mdx +221 -0
  37. package/src/stories/RichTextEditor.stories.tsx +314 -0
  38. package/src/stories/assets/code-brackets.svg +1 -0
  39. package/src/stories/assets/colors.svg +1 -0
  40. package/src/stories/assets/comments.svg +1 -0
  41. package/src/stories/assets/direction.svg +1 -0
  42. package/src/stories/assets/flow.svg +1 -0
  43. package/src/stories/assets/plugin.svg +1 -0
  44. package/src/stories/assets/repo.svg +1 -0
  45. package/src/stories/assets/stackalt.svg +1 -0
  46. package/src/vite-env.d.ts +1 -0
  47. package/src/vite-index.tsx +7 -0
  48. package/src/vite-server.ts +8 -0
@@ -0,0 +1,182 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { RichTextEditor } from "../exports";
3
+ import { FormContainer } from "./forms/FormContainer";
4
+ import { ImageForm } from "./forms/ImageForm";
5
+ import { TextForm } from "./forms/TextForm";
6
+
7
+ import { ValWindow } from "./ValWindow";
8
+
9
+ const meta: Meta<typeof ValWindow> = { component: ValWindow };
10
+
11
+ export default meta;
12
+ type Story = StoryObj<typeof ValWindow>;
13
+
14
+ const EXAMPLE_IMAGE =
15
+ "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAFgAWAAD/4QDkRXhpZgAASUkqAAgAAAAJABIBAwABAAAAAQAAABoBBQABAAAAegAAABsBBQABAAAAggAAACgBAwABAAAAAgAAADEBAgANAAAAigAAADIBAgAUAAAAmAAAAGmHBAABAAAAygAAAHySAgAHAAAArAAAAIaSAgAWAAAAtAAAAAAAAAAWAAAAAQAAABYAAAABAAAAR0lNUCAyLjEwLjM0AAAyMDIzOjA1OjI0IDE0OjQ3OjA1AE9wZW5BSQAATWFkZSB3aXRoIE9wZW5BSSBMYWJzAAEAAaADAAEAAAABAAAAAAAAAP/hDM9odHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0RXZ0PSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VFdmVudCMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6R0lNUD0iaHR0cDovL3d3dy5naW1wLm9yZy94bXAvIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOkRvY3VtZW50SUQ9ImdpbXA6ZG9jaWQ6Z2ltcDo1NDk5MWU4Yy01YjkxLTQwYWYtYjk4ZC00ZjEwMWYwY2QxZWQiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Y2JmMjFlNjMtNGZiYi00MjI5LThlM2UtN2MzMWI0ZDNiNWFhIiB4bXBNTTpPcmlnaW5hbERvY3VtZW50SUQ9InhtcC5kaWQ6YzQ3ZWZmNDktNzg4Yy00NzdjLWJkNTEtZGM5YzJjYTY4NzBjIiBkYzpGb3JtYXQ9ImltYWdlL2pwZWciIEdJTVA6QVBJPSIyLjAiIEdJTVA6UGxhdGZvcm09IkxpbnV4IiBHSU1QOlRpbWVTdGFtcD0iMTY4NDkzMjQyNzk4NDA2MCIgR0lNUDpWZXJzaW9uPSIyLjEwLjM0IiB4bXA6Q3JlYXRvclRvb2w9IkdJTVAgMi4xMCIgeG1wOk1ldGFkYXRhRGF0ZT0iMjAyMzowNToyNFQxNDo0NzowNSswMjowMCIgeG1wOk1vZGlmeURhdGU9IjIwMjM6MDU6MjRUMTQ6NDc6MDUrMDI6MDAiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJzYXZlZCIgc3RFdnQ6Y2hhbmdlZD0iLyIgc3RFdnQ6aW5zdGFuY2VJRD0ieG1wLmlpZDoxNjIyMWEyMS0wZmMzLTQ1NmEtOWZlOC1hOTAyMmY0NjBhOTEiIHN0RXZ0OnNvZnR3YXJlQWdlbnQ9IkdpbXAgMi4xMCAoTGludXgpIiBzdEV2dDp3aGVuPSIyMDIzLTA1LTI0VDE0OjQ3OjA3KzAyOjAwIi8+IDwvcmRmOlNlcT4gPC94bXBNTTpIaXN0b3J5PiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICA8P3hwYWNrZXQgZW5kPSJ3Ij8+/+ICsElDQ19QUk9GSUxFAAEBAAACoGxjbXMEQAAAbW50clJHQiBYWVogB+cABQAYAAwALQAJYWNzcEFQUEwAAAAAAAAA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";
16
+ const EXAMPLE_TEXT = `
17
+ Vi gjør mange ting sammen i Blank, men det vi lever av er å designe og utvikle digitale tjenester for kundene våre.
18
+
19
+ Noen av selskapene vi jobber med er små, andre er store. Alle har de høye ambisjoner for sine digitale løsninger, og stiller høye krav til hvem de jobber med.
20
+
21
+ Noen ganger starter vi nye, egne, selskaper også, mest fordi det er gøy (og fordi vi liker å bygge ting), men også fordi smarte folk har gode idéer som fortjener å bli realisert.
22
+ Ting vi har bygd for kundene våre
23
+ `;
24
+ export const ShortText: Story = {
25
+ args: {
26
+ isInitialized: true,
27
+ children: (
28
+ <FormContainer
29
+ onSubmit={() => {
30
+ /* */
31
+ }}
32
+ >
33
+ <TextForm
34
+ name="/apps/blogs.0.title"
35
+ text="Hva skjer'a, Bagera?"
36
+ onChange={() => {
37
+ console.log("onChange");
38
+ }}
39
+ />
40
+ </FormContainer>
41
+ ),
42
+ },
43
+ };
44
+ export const LongText: Story = {
45
+ args: {
46
+ isInitialized: true,
47
+ children: (
48
+ <FormContainer
49
+ onSubmit={() => {
50
+ /* */
51
+ }}
52
+ >
53
+ <TextForm
54
+ name="/apps/blogs.0.title"
55
+ text={EXAMPLE_TEXT}
56
+ onChange={() => {
57
+ console.log("onChange");
58
+ }}
59
+ />
60
+ </FormContainer>
61
+ ),
62
+ },
63
+ };
64
+
65
+ export const RichText: Story = {
66
+ args: {
67
+ isInitialized: true,
68
+ children: (
69
+ <FormContainer
70
+ onSubmit={() => {
71
+ /* */
72
+ }}
73
+ >
74
+ <RichTextEditor
75
+ richtext={{
76
+ children: [
77
+ {
78
+ children: [
79
+ {
80
+ detail: 0,
81
+ format: 0,
82
+ mode: "normal",
83
+ style: "",
84
+ text: "Heading 1",
85
+ type: "text",
86
+ version: 1,
87
+ },
88
+ ],
89
+ direction: "ltr",
90
+ format: "",
91
+ indent: 0,
92
+ type: "heading",
93
+ version: 1,
94
+ tag: "h1",
95
+ },
96
+ ],
97
+ direction: "ltr",
98
+ format: "",
99
+ indent: 0,
100
+ type: "root",
101
+ version: 1,
102
+ }}
103
+ />
104
+ </FormContainer>
105
+ ),
106
+ },
107
+ };
108
+
109
+ export const EmptyImage: Story = {
110
+ args: {
111
+ isInitialized: true,
112
+ children: (
113
+ <FormContainer
114
+ onSubmit={() => {
115
+ /* */
116
+ }}
117
+ >
118
+ <ImageForm
119
+ name="/apps/blogs.0.image"
120
+ error={null}
121
+ data={null}
122
+ onChange={() => {
123
+ console.log("onChange");
124
+ }}
125
+ />
126
+ </FormContainer>
127
+ ),
128
+ },
129
+ };
130
+
131
+ export const Image: Story = {
132
+ args: {
133
+ isInitialized: true,
134
+ children: (
135
+ <FormContainer
136
+ onSubmit={() => {
137
+ /* */
138
+ }}
139
+ >
140
+ <ImageForm
141
+ name="/apps/blogs.0.image"
142
+ error={null}
143
+ data={{
144
+ url: EXAMPLE_IMAGE,
145
+ metadata: {
146
+ width: 32,
147
+ height: 32,
148
+ sha256: "123",
149
+ },
150
+ }}
151
+ onChange={() => {
152
+ console.log("onChange");
153
+ }}
154
+ />
155
+ </FormContainer>
156
+ ),
157
+ },
158
+ };
159
+
160
+ export const ImageError: Story = {
161
+ args: {
162
+ isInitialized: true,
163
+ children: (
164
+ <FormContainer
165
+ onSubmit={() => {
166
+ /* */
167
+ }}
168
+ >
169
+ <ImageForm
170
+ name="/apps/blogs.0.image"
171
+ error={"invalid-file"}
172
+ data={{
173
+ url: EXAMPLE_IMAGE,
174
+ }}
175
+ onChange={() => {
176
+ console.log("onChange");
177
+ }}
178
+ />
179
+ </FormContainer>
180
+ ),
181
+ },
182
+ };
@@ -0,0 +1,137 @@
1
+ import React, { useEffect, useRef, useState } from "react";
2
+ import { AlignJustify, X } from "react-feather";
3
+ import classNames from "classnames";
4
+
5
+ export type ValWindowProps = {
6
+ children: React.ReactNode;
7
+ onClose: () => void;
8
+ position?: { left: number; top: number };
9
+ isInitialized?: true;
10
+ };
11
+
12
+ export function ValWindow({
13
+ position,
14
+ isInitialized: isInitializedProp,
15
+ onClose,
16
+ children,
17
+ }: ValWindowProps): React.ReactElement {
18
+ const [draggedPosition, isInitialized, ref, onMouseDown] = useDrag({
19
+ position,
20
+ });
21
+ useEffect(() => {
22
+ const closeOnEscape = (e: KeyboardEvent) => {
23
+ if (e.key === "Escape") {
24
+ onClose();
25
+ }
26
+ };
27
+ document.addEventListener("keyup", closeOnEscape);
28
+ return () => {
29
+ document.removeEventListener("keyup", closeOnEscape);
30
+ };
31
+ }, []);
32
+ return (
33
+ <div
34
+ className={classNames(
35
+ "absolute h-[100svh] w-full tablet:w-auto tablet:h-auto tablet:rounded bg-base drop-shadow-2xl min-w-[320px] transition-opacity duration-300 delay-75 tablet:max-w-[50ch] max-w-full",
36
+ {
37
+ "opacity-0": !(isInitialized || isInitializedProp),
38
+ "opacity-100": isInitialized || isInitializedProp,
39
+ }
40
+ )}
41
+ style={{
42
+ left: draggedPosition.left,
43
+ top: draggedPosition.top,
44
+ }}
45
+ >
46
+ <div
47
+ ref={ref}
48
+ className="relative flex justify-center px-2 py-2 text-primary"
49
+ >
50
+ <AlignJustify
51
+ size={16}
52
+ className="hidden w-full cursor-grab tablet:block"
53
+ onMouseDown={(e) => {
54
+ e.preventDefault();
55
+ e.stopPropagation();
56
+ onMouseDown();
57
+ }}
58
+ />
59
+ <button
60
+ className="absolute top-0 right-0 px-4 py-2 focus:outline-none focus-visible:outline-highlight"
61
+ onClick={onClose}
62
+ >
63
+ <X size={16} />
64
+ </button>
65
+ </div>
66
+ {children}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ function useDrag({
72
+ position: initPosition,
73
+ }: {
74
+ position?: { left: number; top: number };
75
+ }) {
76
+ const [position, setPosition] = useState({ left: 0, top: 0 });
77
+ useEffect(() => {
78
+ if (initPosition) {
79
+ setPosition({
80
+ left:
81
+ initPosition.left -
82
+ (ref?.current?.getBoundingClientRect()?.width || 0) / 2,
83
+ top: initPosition.top - 16,
84
+ });
85
+ }
86
+ }, [initPosition]);
87
+
88
+ const [mouseDown, setMouseDown] = useState(false);
89
+ const ref = useRef<HTMLDivElement>(null);
90
+ useEffect(() => {
91
+ const onMouseUp = () => {
92
+ setMouseDown(false);
93
+ };
94
+ const onMouseMove = (e: MouseEvent) => {
95
+ if (mouseDown) {
96
+ e.preventDefault();
97
+ e.stopPropagation();
98
+ const top =
99
+ -((ref?.current?.getBoundingClientRect()?.height || 0) / 2) +
100
+ +e.pageY;
101
+
102
+ setPosition({
103
+ left:
104
+ -((ref?.current?.getBoundingClientRect()?.width || 0) / 2) +
105
+ e.pageX,
106
+ top: top < 0 ? 0 : top,
107
+ });
108
+ }
109
+ };
110
+
111
+ document.addEventListener("mouseup", onMouseUp);
112
+ document.addEventListener("mousemove", onMouseMove);
113
+ return () => {
114
+ document.removeEventListener("mouseup", onMouseUp);
115
+ document.removeEventListener("mousemove", onMouseMove);
116
+ };
117
+ }, [mouseDown]);
118
+
119
+ // TODO: rename hook from useDrag to usePosition or something since we also check for screen width here?
120
+ useEffect(() => {
121
+ const onResize = () => {
122
+ if (window.screen.width < 640) {
123
+ setPosition({
124
+ left: 0,
125
+ top: 0,
126
+ });
127
+ }
128
+ };
129
+ window.addEventListener("resize", onResize);
130
+ return () => {
131
+ window.removeEventListener("resize", onResize);
132
+ };
133
+ }, []);
134
+ const handleMouseDown = () => setMouseDown(true);
135
+ const isInitialized = !!ref?.current?.getBoundingClientRect()?.width;
136
+ return [position, isInitialized, ref, handleMouseDown] as const;
137
+ }
@@ -0,0 +1,122 @@
1
+ import { RichText } from "@valbuild/core";
2
+ import { LexicalEditor } from "lexical";
3
+ import { useEffect, useState } from "react";
4
+ import { RichTextEditor } from "../RichTextEditor/RichTextEditor";
5
+ import { FormContainer } from "./FormContainer";
6
+ import { ImageForm, ImageData } from "./ImageForm";
7
+ import { TextData, TextForm } from "./TextForm";
8
+
9
+ export type Inputs = {
10
+ [path: string]:
11
+ | { status: "requested" }
12
+ | {
13
+ status: "completed";
14
+ type: "text";
15
+ data: TextData;
16
+ }
17
+ | { status: "completed"; type: "image"; data: ImageData }
18
+ | { status: "completed"; type: "richtext"; data: RichText };
19
+ };
20
+
21
+ export type FormProps = {
22
+ onSubmit: (nextInputs: Inputs) => void;
23
+ inputs: Inputs;
24
+ };
25
+
26
+ export function Form({ onSubmit, inputs }: FormProps): React.ReactElement {
27
+ const [currentInputs, setCurrentInputs] = useState<Inputs>();
28
+ const [richTextEditor, setRichTextEditor] = useState<{
29
+ [path: string]: LexicalEditor;
30
+ }>();
31
+
32
+ useEffect(() => {
33
+ setCurrentInputs(inputs);
34
+ }, [inputs]);
35
+
36
+ return (
37
+ <FormContainer
38
+ onSubmit={() => {
39
+ if (currentInputs) {
40
+ onSubmit(
41
+ Object.fromEntries(
42
+ Object.entries(currentInputs).map(([path, input]) => {
43
+ if (input.status === "completed" && input.type === "richtext") {
44
+ if (!richTextEditor) {
45
+ throw Error(
46
+ "Cannot save rich text - editor not initialized"
47
+ );
48
+ }
49
+ return [
50
+ path,
51
+ {
52
+ status: "completed",
53
+ type: "richtext",
54
+ data: richTextEditor[path].getEditorState().toJSON()
55
+ ?.root,
56
+ },
57
+ ];
58
+ }
59
+ return [path, input];
60
+ })
61
+ )
62
+ );
63
+ }
64
+ }}
65
+ >
66
+ {currentInputs &&
67
+ Object.entries(currentInputs).map(([path, input]) => (
68
+ <div key={path}>
69
+ {input.status === "requested" && (
70
+ <div className="p-2 text-center text-primary">Loading...</div>
71
+ )}
72
+ {input.status === "completed" && input.type === "image" && (
73
+ <ImageForm
74
+ name={path}
75
+ data={input.data}
76
+ onChange={(data) => {
77
+ if (data.value) {
78
+ setCurrentInputs({
79
+ ...currentInputs,
80
+ [path]: {
81
+ status: "completed",
82
+ type: "image",
83
+ data: data.value,
84
+ },
85
+ });
86
+ }
87
+ }}
88
+ error={null}
89
+ />
90
+ )}
91
+ {input.status === "completed" && input.type === "text" && (
92
+ <TextForm
93
+ name={path}
94
+ text={input.data}
95
+ onChange={(data) => {
96
+ setCurrentInputs({
97
+ ...currentInputs,
98
+ [path]: {
99
+ status: "completed",
100
+ type: "text",
101
+ data: data,
102
+ },
103
+ });
104
+ }}
105
+ />
106
+ )}
107
+ {input.status === "completed" && input.type === "richtext" && (
108
+ <RichTextEditor
109
+ richtext={input.data}
110
+ onEditor={(editor) => {
111
+ setRichTextEditor({
112
+ ...richTextEditor,
113
+ [path]: editor,
114
+ });
115
+ }}
116
+ />
117
+ )}
118
+ </div>
119
+ ))}
120
+ </FormContainer>
121
+ );
122
+ }
@@ -0,0 +1,24 @@
1
+ import { PrimaryButton } from "../Button";
2
+
3
+ export function FormContainer({
4
+ children,
5
+ onSubmit,
6
+ }: {
7
+ children: React.ReactNode;
8
+ onSubmit: () => void;
9
+ }) {
10
+ return (
11
+ <form
12
+ className="flex flex-col justify-between w-full px-4 py-2"
13
+ onSubmit={(ev) => {
14
+ ev.preventDefault();
15
+ onSubmit();
16
+ }}
17
+ >
18
+ {children}
19
+ <div className="flex justify-end">
20
+ <PrimaryButton>Save</PrimaryButton>
21
+ </div>
22
+ </form>
23
+ );
24
+ }
@@ -0,0 +1,195 @@
1
+ import { useEffect, useState } from "react";
2
+ import { UploadCloud } from "react-feather";
3
+ import { ErrorText } from "../ErrorText";
4
+
5
+ type Error = "invalid-file" | "file-too-large";
6
+ export type ImageData =
7
+ | {
8
+ src: string;
9
+ addMetadata: boolean;
10
+ metadata?: { width: number; height: number; sha256: string };
11
+ }
12
+ | {
13
+ url: string;
14
+ metadata?: { width: number; height: number; sha256: string };
15
+ }
16
+ | null;
17
+ export type ImageInputProps = {
18
+ name: string;
19
+ data: ImageData;
20
+ maxSize?: number;
21
+ error: Error | null;
22
+ onChange: (
23
+ result: { error: null; value: ImageData } | { error: Error; value: null }
24
+ ) => void;
25
+ };
26
+
27
+ const textEncoder = new TextEncoder();
28
+ // TODO: handle hashes some other way
29
+ const getSHA256Hash = async (bits: Uint8Array) => {
30
+ const hashBuffer = await window.crypto.subtle.digest("SHA-256", bits);
31
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
32
+ const hash = hashArray
33
+ .map((item) => item.toString(16).padStart(2, "0"))
34
+ .join("");
35
+ return hash;
36
+ };
37
+
38
+ export function ImageForm({
39
+ name,
40
+ data: data,
41
+ maxSize,
42
+ error,
43
+ onChange,
44
+ }: ImageInputProps) {
45
+ const [currentData, setCurrentData] = useState<ImageData>(null);
46
+ useEffect(() => {
47
+ setCurrentData(data);
48
+ }, [data]);
49
+ const [currentError, setCurrentError] = useState<Error | null>(null);
50
+ useEffect(() => {
51
+ setCurrentError(error);
52
+ }, [data]);
53
+
54
+ // TODO: we should update the Input type - we should never have a url here?
55
+ const src =
56
+ (currentData &&
57
+ (("src" in currentData && currentData.src) ||
58
+ ("url" in currentData && currentData.url))) ||
59
+ undefined;
60
+
61
+ return (
62
+ <div className="w-full py-2 max-w-[90vw] object-contain">
63
+ <label htmlFor={name}>
64
+ <div className="flex items-center justify-center w-full h-full min-h-[200px] cursor-pointer">
65
+ {currentData !== null && (
66
+ <img className="object-contain max-h-[300px]" src={src} />
67
+ )}
68
+ {!src && <UploadCloud size={24} className="text-primary" />}
69
+ </div>
70
+ <input
71
+ hidden
72
+ type="file"
73
+ id={name}
74
+ name={name}
75
+ onChange={(e) => {
76
+ const file = e.target.files?.[0];
77
+ if (file) {
78
+ if (maxSize && file.size > maxSize) {
79
+ onChange({ error: "file-too-large", value: null });
80
+ return;
81
+ }
82
+ const reader = new FileReader();
83
+ reader.addEventListener("load", () => {
84
+ const result = reader.result;
85
+ if (typeof result === "string") {
86
+ const image = new Image();
87
+ image.onload = async () => {
88
+ const nextSource = {
89
+ src: result,
90
+ metadata: {
91
+ width: image.naturalWidth,
92
+ height: image.naturalHeight,
93
+ sha256: await getSHA256Hash(textEncoder.encode(result)),
94
+ },
95
+ addMetadata: !currentData?.metadata,
96
+ };
97
+ setCurrentData(nextSource);
98
+ setCurrentError(null);
99
+ onChange({
100
+ error: null,
101
+ value: nextSource,
102
+ });
103
+ };
104
+ image.src = result;
105
+ } else {
106
+ onChange({ error: "invalid-file", value: null });
107
+ }
108
+ });
109
+ reader.readAsDataURL(file);
110
+ }
111
+ }}
112
+ />
113
+ </label>
114
+ {currentData &&
115
+ (!currentData.metadata ||
116
+ typeof currentData.metadata.height === "undefined" ||
117
+ typeof currentData.metadata.width === "undefined" ||
118
+ typeof currentData.metadata.sha256 === "undefined") && (
119
+ <ErrorText>
120
+ <div className="">
121
+ <div>Validation failed: missing metadata</div>
122
+ <button
123
+ className="px-4 py-2 border border-dark-gray"
124
+ onClick={async (ev) => {
125
+ ev.preventDefault();
126
+
127
+ const image = new Image();
128
+ image.onload = async () => {
129
+ const nextSource = {
130
+ src: image.src,
131
+ metadata: {
132
+ width: image.naturalWidth,
133
+ height: image.naturalHeight,
134
+ sha256: await getSHA256Hash(
135
+ textEncoder.encode(image.src)
136
+ ),
137
+ },
138
+ addMetadata: !currentData?.metadata,
139
+ };
140
+ setCurrentData(nextSource);
141
+ setCurrentError(null);
142
+ onChange({
143
+ error: null,
144
+ value: nextSource,
145
+ });
146
+ };
147
+
148
+ if ("url" in currentData) {
149
+ const src = await fetch(currentData.url)
150
+ .then((response) => response.blob())
151
+ .then((blob) => {
152
+ return new Promise<string>((resolve, reject) => {
153
+ const reader = new FileReader();
154
+ reader.onload = function () {
155
+ if (typeof this.result !== "string") {
156
+ return reject(
157
+ Error(
158
+ `Could not read file from url: ${currentData.url}`
159
+ )
160
+ );
161
+ }
162
+ resolve(this.result);
163
+ }; // <--- `this.result` contains a base64 data URI
164
+ reader.readAsDataURL(blob);
165
+ });
166
+ });
167
+ image.src = src;
168
+ } else {
169
+ image.src = currentData.src;
170
+ }
171
+ }}
172
+ >
173
+ Fix
174
+ </button>
175
+ </div>
176
+ </ErrorText>
177
+ )}
178
+ {currentData?.metadata && (
179
+ <div className="ml-auto text-primary">
180
+ Dimensions: {currentData.metadata.width}x{currentData.metadata.height}
181
+ </div>
182
+ )}
183
+ {currentError && <ImageError error={currentError} />}
184
+ </div>
185
+ );
186
+ }
187
+
188
+ function ImageError({ error }: { error: Error }): React.ReactElement | null {
189
+ if (error === "invalid-file") {
190
+ return <ErrorText>Invalid file</ErrorText>;
191
+ } else if (error === "file-too-large") {
192
+ return <ErrorText>File is too large</ErrorText>;
193
+ }
194
+ return null;
195
+ }
@@ -0,0 +1,22 @@
1
+ export type TextData = string;
2
+ export type TextInputProps = {
3
+ name: string;
4
+ text: TextData;
5
+ onChange: (value: string) => void;
6
+ };
7
+
8
+ export function TextForm({ name, text, onChange }: TextInputProps) {
9
+ return (
10
+ <div
11
+ className="w-full py-2 grow-wrap"
12
+ data-replicated-value={text} /* see grow-wrap */
13
+ >
14
+ <textarea
15
+ name={name}
16
+ className="w-full p-2 border outline-none bg-fill text-primary border-border focus-visible:border-highlight"
17
+ defaultValue={text}
18
+ onChange={(e) => onChange(e.target.value)}
19
+ />
20
+ </div>
21
+ );
22
+ }
package/src/exports.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { RichTextEditor } from "./components/RichTextEditor/RichTextEditor";
2
+ export { ValOverlay } from "./components/ValOverlay";
3
+ export { type Inputs } from "./components/forms/Form";