@valbuild/ui 0.18.0 → 0.20.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.
@@ -3,6 +3,7 @@ import {
3
3
  SetStateAction,
4
4
  useCallback,
5
5
  useEffect,
6
+ useRef,
6
7
  useState,
7
8
  } from "react";
8
9
  import { Session } from "../dto/Session";
@@ -11,16 +12,34 @@ import { EditMode, Theme, ValOverlayContext } from "./ValOverlayContext";
11
12
  import { Remote } from "../utils/Remote";
12
13
  import { ValWindow } from "./ValWindow";
13
14
  import { result } from "@valbuild/core/fp";
14
- import { TextArea } from "./forms/TextArea";
15
- import { Internal, SerializedSchema, SourcePath } from "@valbuild/core";
15
+ import {
16
+ AnyRichTextOptions,
17
+ FileSource,
18
+ Internal,
19
+ RichText,
20
+ SerializedSchema,
21
+ SourcePath,
22
+ VAL_EXTENSION,
23
+ } from "@valbuild/core";
16
24
  import { Modules, resolvePath } from "../utils/resolvePath";
17
25
  import { ValApi } from "@valbuild/core";
26
+ import { RichTextEditor } from "../exports";
27
+ import { LexicalEditor } from "lexical";
28
+ import { LexicalRootNode, fromLexical } from "./RichTextEditor/conversion";
29
+ import { PatchJSON } from "@valbuild/core/patch";
30
+ import { readImage } from "../utils/readImage";
18
31
 
19
32
  export type ValOverlayProps = {
20
33
  defaultTheme?: "dark" | "light";
21
34
  api: ValApi;
22
35
  };
23
36
 
37
+ type ImageSource = FileSource<{
38
+ height: number;
39
+ width: number;
40
+ sha256: string;
41
+ }>;
42
+
24
43
  export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
25
44
  const [theme, setTheme] = useTheme(defaultTheme);
26
45
  const session = useSession(api);
@@ -34,6 +53,36 @@ export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
34
53
  windowTarget?.path
35
54
  );
36
55
 
56
+ const [state, setState] = useState<{
57
+ [path: SourcePath]: () => PatchJSON;
58
+ }>({});
59
+ const initPatchCallback = useCallback((currentPath: SourcePath | null) => {
60
+ return (callback: PatchCallback) => {
61
+ // TODO: revaluate this logic when we have multiple paths
62
+ // NOTE: see cleanup of state in useEffect below
63
+ if (!currentPath) {
64
+ setState({});
65
+ } else {
66
+ const patchPath = Internal.createPatchJSONPath(
67
+ Internal.splitModuleIdAndModulePath(currentPath)[1]
68
+ );
69
+ setState((prev) => {
70
+ return {
71
+ ...prev,
72
+ [currentPath]: () => callback(patchPath),
73
+ };
74
+ });
75
+ }
76
+ };
77
+ }, []);
78
+ useEffect(() => {
79
+ setState((prev) => {
80
+ return Object.fromEntries(
81
+ Object.entries(prev).filter(([path]) => path === windowTarget?.path)
82
+ );
83
+ });
84
+ }, [windowTarget?.path]);
85
+
37
86
  return (
38
87
  <ValOverlayContext.Provider
39
88
  value={{
@@ -66,7 +115,7 @@ export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
66
115
  setEditMode("hover");
67
116
  }}
68
117
  >
69
- <div className="px-4 text-sm">
118
+ <div className="px-4 py-2 text-sm border-b border-highlight">
70
119
  <WindowHeader
71
120
  path={windowTarget.path}
72
121
  type={selectedSchema?.type}
@@ -76,12 +125,52 @@ export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
76
125
  {error && <div className="text-red">{error}</div>}
77
126
  {typeof selectedSource === "string" &&
78
127
  selectedSchema?.type === "string" && (
79
- <TextForm
80
- api={api}
81
- path={windowTarget.path}
128
+ <TextField
82
129
  defaultValue={selectedSource}
130
+ isLoading={loading}
131
+ registerPatchCallback={initPatchCallback(windowTarget.path)}
132
+ />
133
+ )}
134
+ {selectedSource &&
135
+ typeof selectedSource === "object" &&
136
+ VAL_EXTENSION in selectedSource &&
137
+ selectedSource[VAL_EXTENSION] === "richtext" && (
138
+ <RichTextField
139
+ registerPatchCallback={initPatchCallback(windowTarget.path)}
140
+ defaultValue={selectedSource as RichText<AnyRichTextOptions>}
141
+ />
142
+ )}
143
+ {selectedSource &&
144
+ typeof selectedSource === "object" &&
145
+ VAL_EXTENSION in selectedSource &&
146
+ selectedSource[VAL_EXTENSION] === "file" && (
147
+ <ImageField
148
+ registerPatchCallback={initPatchCallback(windowTarget.path)}
149
+ defaultValue={selectedSource as ImageSource}
83
150
  />
84
151
  )}
152
+ <div className="flex items-end justify-end py-2">
153
+ <SubmitButton
154
+ disabled={false}
155
+ onClick={() => {
156
+ if (state[windowTarget.path]) {
157
+ const [moduleId] = Internal.splitModuleIdAndModulePath(
158
+ windowTarget.path
159
+ );
160
+ const patch = state[windowTarget.path]();
161
+ console.log("Submitting", patch);
162
+ api
163
+ .postPatches(moduleId, patch)
164
+ .then((res) => {
165
+ console.log(res);
166
+ })
167
+ .finally(() => {
168
+ console.log("done");
169
+ });
170
+ }
171
+ }}
172
+ />
173
+ </div>
85
174
  </ValWindow>
86
175
  )}
87
176
  </div>
@@ -89,50 +178,194 @@ export function ValOverlay({ defaultTheme, api }: ValOverlayProps) {
89
178
  );
90
179
  }
91
180
 
92
- function TextForm({
93
- path,
181
+ type PatchCallback = (modulePath: string) => PatchJSON;
182
+
183
+ function ImageField({
94
184
  defaultValue,
95
- api,
185
+ registerPatchCallback,
96
186
  }: {
97
- path: SourcePath;
98
- defaultValue?: string;
99
- api: ValApi;
187
+ registerPatchCallback: (callback: PatchCallback) => void;
188
+ defaultValue?: ImageSource;
100
189
  }) {
101
- const [text, setText] = useState(defaultValue || "");
102
- const [moduleId, modulePath] = Internal.splitModuleIdAndModulePath(path);
103
- const [isPatching, setIsPatching] = useState(false);
190
+ const [data, setData] = useState<string | null>(null);
191
+ const [metadata, setMetadata] = useState<{
192
+ width?: number;
193
+ height?: number;
194
+ sha256: string;
195
+ } | null>(null);
196
+ const url = defaultValue && Internal.convertFileSource(defaultValue).url;
197
+ useEffect(() => {
198
+ registerPatchCallback((path) => {
199
+ const pathParts = path.split("/");
200
+ if (!data) {
201
+ return [];
202
+ }
203
+ return [
204
+ {
205
+ value: {
206
+ ...defaultValue,
207
+ metadata,
208
+ },
209
+ op: "replace",
210
+ path,
211
+ },
212
+ // update the contents of the file:
213
+ {
214
+ value: data,
215
+ op: "replace",
216
+ path: `${pathParts.slice(0, -1).join("/")}/$${
217
+ pathParts[pathParts.length - 1]
218
+ }`,
219
+ },
220
+ ];
221
+ });
222
+ }, [data]);
223
+
104
224
  return (
105
- <form
106
- className="flex flex-col justify-between h-full px-4"
107
- onSubmit={(ev) => {
108
- ev.preventDefault();
109
- setIsPatching(true);
110
- api
111
- .postPatches(moduleId, [
112
- {
113
- op: "replace",
114
- path: Internal.createPatchJSONPath(modulePath),
115
- value: text,
225
+ <div>
226
+ <label htmlFor="img_input" className="">
227
+ <img src={data || url} />
228
+ <input
229
+ id="img_input"
230
+ type="file"
231
+ hidden
232
+ onChange={(ev) => {
233
+ readImage(ev)
234
+ .then((res) => {
235
+ setData(res.src);
236
+ setMetadata({
237
+ sha256: res.sha256,
238
+ width: res.width,
239
+ height: res.height,
240
+ });
241
+ })
242
+ .catch((err) => {
243
+ console.error(err.message);
244
+ setData(null);
245
+ setMetadata(null);
246
+ });
247
+ }}
248
+ />
249
+ </label>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ function RichTextField({
255
+ defaultValue,
256
+ registerPatchCallback,
257
+ }: {
258
+ registerPatchCallback: (callback: PatchCallback) => void;
259
+ defaultValue?: RichText<AnyRichTextOptions>;
260
+ }) {
261
+ const [editor, setEditor] = useState<LexicalEditor | null>(null);
262
+ useEffect(() => {
263
+ if (editor) {
264
+ registerPatchCallback((path) => {
265
+ const { node, files } = editor?.toJSON()?.editorState
266
+ ? fromLexical(editor?.toJSON()?.editorState.root as LexicalRootNode)
267
+ : {
268
+ node: {
269
+ [VAL_EXTENSION]: "richtext",
270
+ children: [],
271
+ } as RichText<AnyRichTextOptions>,
272
+ files: {},
273
+ };
274
+ return [
275
+ {
276
+ op: "replace",
277
+ path,
278
+ value: {
279
+ ...node,
280
+ [VAL_EXTENSION]: "richtext",
116
281
  },
117
- ])
118
- .finally(() => {
119
- setIsPatching(false);
120
- });
282
+ },
283
+ ...Object.entries(files).map(([path, value]) => {
284
+ return {
285
+ op: "file" as const,
286
+ path,
287
+ value,
288
+ };
289
+ }),
290
+ ];
291
+ });
292
+ }
293
+ }, [editor]);
294
+ return (
295
+ <RichTextEditor
296
+ onEditor={(editor) => {
297
+ setEditor(editor);
121
298
  }}
122
- >
123
- <TextArea
124
- name={path}
125
- text={text}
126
- disabled={isPatching}
127
- onChange={setText}
128
- />
129
- <button
130
- className="px-4 py-2 border border-highlight disabled:border-border"
131
- disabled={isPatching}
299
+ richtext={
300
+ defaultValue ||
301
+ ({
302
+ children: [],
303
+ [VAL_EXTENSION]: "root",
304
+ } as unknown as RichText<AnyRichTextOptions>)
305
+ }
306
+ />
307
+ );
308
+ }
309
+
310
+ function TextField({
311
+ isLoading,
312
+ defaultValue,
313
+ registerPatchCallback,
314
+ }: {
315
+ registerPatchCallback: (callback: PatchCallback) => void;
316
+ isLoading: boolean;
317
+ defaultValue?: string;
318
+ }) {
319
+ const [value, setValue] = useState(defaultValue || "");
320
+
321
+ // ref is used to get the value of the textarea without closing over the value field
322
+ // to avoid registering a new callback every time the value changes
323
+ const ref = useRef<HTMLTextAreaElement>(null);
324
+ useEffect(() => {
325
+ registerPatchCallback((path) => {
326
+ return [
327
+ {
328
+ op: "replace",
329
+ path,
330
+ value: ref.current?.value || "",
331
+ },
332
+ ];
333
+ });
334
+ }, []);
335
+
336
+ return (
337
+ <div className="flex flex-col justify-between h-full px-4">
338
+ <div
339
+ className="w-full h-full py-2 overflow-y-scroll grow-wrap"
340
+ data-replicated-value={value} /* see grow-wrap */
132
341
  >
133
- Submit
134
- </button>
135
- </form>
342
+ <textarea
343
+ ref={ref}
344
+ disabled={isLoading}
345
+ className="p-2 border outline-none resize-none bg-fill text-primary border-border focus-visible:border-highlight"
346
+ defaultValue={value}
347
+ onChange={(e) => setValue(e.target.value)}
348
+ />
349
+ </div>
350
+ </div>
351
+ );
352
+ }
353
+
354
+ function SubmitButton({
355
+ disabled,
356
+ onClick,
357
+ }: {
358
+ disabled?: boolean;
359
+ onClick?: () => void;
360
+ }) {
361
+ return (
362
+ <button
363
+ className="px-4 py-2 border border-highlight disabled:border-border"
364
+ disabled={disabled}
365
+ onClick={onClick}
366
+ >
367
+ Submit
368
+ </button>
136
369
  );
137
370
  }
138
371
 
@@ -278,6 +511,63 @@ function ValHover({
278
511
  );
279
512
  }
280
513
 
514
+ function useHoverTarget(editMode: EditMode) {
515
+ const [targetElement, setTargetElement] = useState<HTMLElement>();
516
+ const [targetPath, setTargetPath] = useState<SourcePath>();
517
+ const [targetRect, setTargetRect] = useState<DOMRect>();
518
+ useEffect(() => {
519
+ if (editMode === "hover") {
520
+ let curr: HTMLElement | null = null;
521
+ const mouseOverListener = (e: MouseEvent) => {
522
+ const target = e.target as HTMLElement | null;
523
+ curr = target;
524
+ // TODO: use .contains?
525
+ do {
526
+ if (curr?.dataset.valPath) {
527
+ console.log("setter target");
528
+ setTargetElement(curr);
529
+ setTargetPath(curr.dataset.valPath as SourcePath);
530
+ setTargetRect(curr.getBoundingClientRect());
531
+ break;
532
+ }
533
+ } while ((curr = curr?.parentElement || null));
534
+ };
535
+
536
+ document.addEventListener("mouseover", mouseOverListener);
537
+
538
+ return () => {
539
+ setTargetElement(undefined);
540
+ setTargetPath(undefined);
541
+ document.removeEventListener("mouseover", mouseOverListener);
542
+ };
543
+ }
544
+ }, [editMode]);
545
+ useEffect(() => {
546
+ const scrollListener = () => {
547
+ if (targetElement) {
548
+ setTargetRect(targetElement.getBoundingClientRect());
549
+ }
550
+ };
551
+ document.addEventListener("scroll", scrollListener, { passive: true });
552
+ return () => {
553
+ document.removeEventListener("scroll", scrollListener);
554
+ };
555
+ }, [targetElement]);
556
+
557
+ return [
558
+ {
559
+ path: targetPath,
560
+ element: targetElement,
561
+ rect: targetRect,
562
+ } as HoverTarget,
563
+ (target: HoverTarget | null) => {
564
+ setTargetElement(target?.element);
565
+ setTargetPath(target?.path);
566
+ setTargetRect(target?.element?.getBoundingClientRect());
567
+ },
568
+ ] as const;
569
+ }
570
+
281
571
  // TODO: do something fun on highlight?
282
572
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
283
573
  function useHighlight(
@@ -354,51 +644,6 @@ function useInitEditMode() {
354
644
  return [editMode, setEditMode] as const;
355
645
  }
356
646
 
357
- function useHoverTarget(editMode: EditMode) {
358
- const [target, setTarget] = useState<{
359
- element?: HTMLElement;
360
- rect?: DOMRect;
361
- path: SourcePath;
362
- } | null>(null);
363
- useEffect(() => {
364
- if (editMode === "hover") {
365
- let curr: HTMLElement | null = null;
366
- const mouseOverListener = (e: MouseEvent) => {
367
- const target = e.target as HTMLElement | null;
368
- curr = target;
369
- // TODO: use .contains?
370
- do {
371
- if (curr?.dataset.valPath) {
372
- setTarget({
373
- element: curr,
374
- path: curr.dataset.valPath as SourcePath,
375
- });
376
- break;
377
- }
378
- } while ((curr = curr?.parentElement || null));
379
- };
380
- const scrollListener = () => {
381
- if (target?.element) {
382
- setTarget({
383
- ...target,
384
- });
385
- }
386
- };
387
-
388
- document.addEventListener("mouseover", mouseOverListener);
389
- document.addEventListener("scroll", scrollListener, { passive: true });
390
-
391
- return () => {
392
- setTarget(null);
393
- document.removeEventListener("mouseover", mouseOverListener);
394
- document.removeEventListener("scroll", scrollListener);
395
- };
396
- }
397
- }, [editMode]);
398
-
399
- return [target, setTarget] as const;
400
- }
401
-
402
647
  function useTheme(defaultTheme: Theme = "dark") {
403
648
  const [theme, setTheme] = useState<Theme>(defaultTheme);
404
649
 
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
- import { RichText as RichTextType, SourcePath } from "@valbuild/core";
2
+ import { AnyRichTextOptions, RichText as RichTextType } from "@valbuild/core";
3
3
  import { RichTextEditor } from "../exports";
4
4
  import { FormContainer } from "./forms/FormContainer";
5
5
  import { ImageForm } from "./forms/ImageForm";
@@ -51,13 +51,15 @@ export const LongText: Story = {
51
51
  /* */
52
52
  }}
53
53
  >
54
- <TextArea
55
- name="/apps/blogs.0.title"
56
- text={EXAMPLE_TEXT}
57
- onChange={() => {
58
- console.log("onChange");
59
- }}
60
- />
54
+ <div className="h-full grid grid-rows-[1fr,_min-content] ">
55
+ <TextArea
56
+ name="/apps/blogs.0.title"
57
+ text={EXAMPLE_TEXT}
58
+ onChange={() => {
59
+ console.log("onChange");
60
+ }}
61
+ />
62
+ </div>
61
63
  </FormContainer>
62
64
  ),
63
65
  },
@@ -75,34 +77,53 @@ export const RichText: Story = {
75
77
  <RichTextEditor
76
78
  richtext={
77
79
  {
78
- valPath: "/foo" as SourcePath,
80
+ _type: "richtext",
79
81
  children: [
82
+ { tag: "h1", children: ["Title 1"] },
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
+ },
80
98
  {
99
+ tag: "ul",
81
100
  children: [
82
101
  {
83
- detail: 0,
84
- format: 0,
85
- mode: "normal",
86
- style: "",
87
- text: "Heading 1",
88
- type: "text",
89
- version: 1,
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
+ ],
90
122
  },
91
123
  ],
92
- direction: "ltr",
93
- format: "",
94
- indent: 0,
95
- type: "heading",
96
- version: 1,
97
- tag: "h1",
98
124
  },
99
125
  ],
100
- direction: "ltr",
101
- format: "",
102
- indent: 0,
103
- type: "root",
104
- version: 1,
105
- } as RichTextType
126
+ } as RichTextType<AnyRichTextOptions>
106
127
  }
107
128
  />
108
129
  </FormContainer>
@@ -136,27 +157,12 @@ export const Image: Story = {
136
157
  args: {
137
158
  isInitialized: true,
138
159
  children: (
139
- <FormContainer
140
- onSubmit={() => {
141
- /* */
142
- }}
143
- >
144
- <ImageForm
145
- name="/apps/blogs.0.image"
146
- error={null}
147
- data={{
148
- url: EXAMPLE_IMAGE,
149
- metadata: {
150
- width: 32,
151
- height: 32,
152
- sha256: "123",
153
- },
154
- }}
155
- onChange={() => {
156
- console.log("onChange");
157
- }}
158
- />
159
- </FormContainer>
160
+ <div className="h-full grid grid-rows-[1fr,_min-content] overflow-scroll">
161
+ <img src={EXAMPLE_IMAGE} />
162
+ <div className="flex justify-end">
163
+ <button className="px-4 py-2 border border-highlight">Submit</button>
164
+ </div>
165
+ </div>
160
166
  ),
161
167
  },
162
168
  };