@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,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