From adb5c8a4e92e16a59aaa8c309f0fa8aef9fffe6f Mon Sep 17 00:00:00 2001 From: "yongen.loong" Date: Thu, 15 Aug 2024 22:57:34 +0800 Subject: [PATCH] feat: visual editor --- app/visual-editor/page.tsx | 27 ++++ components/top-menu.tsx | 1 + components/ui/tabs.tsx | 55 ++++++++ components/visual/context.tsx | 52 +++++++ components/visual/input-output-options.tsx | 31 ++++ components/visual/preview.tsx | 71 ++++++++++ components/visual/send-method-editor.tsx | 133 ++++++++++++++++++ components/visual/side-panel.tsx | 73 ++++++++++ components/visual/view-method-editor.tsx | 156 +++++++++++++++++++++ package-lock.json | 31 ++++ package.json | 1 + 11 files changed, 631 insertions(+) create mode 100644 app/visual-editor/page.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/visual/context.tsx create mode 100644 components/visual/input-output-options.tsx create mode 100644 components/visual/preview.tsx create mode 100644 components/visual/send-method-editor.tsx create mode 100644 components/visual/side-panel.tsx create mode 100644 components/visual/view-method-editor.tsx diff --git a/app/visual-editor/page.tsx b/app/visual-editor/page.tsx new file mode 100644 index 0000000..a33f867 --- /dev/null +++ b/app/visual-editor/page.tsx @@ -0,0 +1,27 @@ +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { VisualEditorProvider } from "@/components/visual/context"; +import { Preview } from "@/components/visual/preview"; +import { SidePanel } from "@/components/visual/side-panel"; + +export default function Page() { + return ( +
+

Visual Editor

+ + + + + + + + + + + +
+ ); +} diff --git a/components/top-menu.tsx b/components/top-menu.tsx index 922e64b..dc2a2cb 100644 --- a/components/top-menu.tsx +++ b/components/top-menu.tsx @@ -20,6 +20,7 @@ export default function TopMenu() { { href: "/workspaces", children: "Workspaces" }, { href: "/tutorials", children: "Tutorials" }, { href: "/deployments", children: "Deployments" }, + { href: "/visual-editor", children: "Visual Editor" }, { href: "https://github.com/AElfProject", children: "GitHub" }, ]; diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx new file mode 100644 index 0000000..26eb109 --- /dev/null +++ b/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "@/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/components/visual/context.tsx b/components/visual/context.tsx new file mode 100644 index 0000000..50335c2 --- /dev/null +++ b/components/visual/context.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useState, +} from "react"; + +interface IMethods { + name: string; + type: string; +} + +interface IViewMethods extends IMethods { + outputType: string; +} + +interface IVisualEditorState { + name: string; + viewMethods: IViewMethods[]; + sendMethods: IMethods[]; +} + +const defaultState: IVisualEditorState = { + name: "Hello World", + viewMethods: [], + sendMethods: [], +}; + +const VisualEditorContext = createContext<{ + state: IVisualEditorState; + setState: Dispatch>; +}>({ state: defaultState, setState: () => {} }); + +const VisualEditorProvider = ({ children }: PropsWithChildren) => { + const [state, setState] = useState(defaultState); + + return ( + + {children} + + ); +}; + +const useVisualEditorContext = () => { + return useContext(VisualEditorContext); +}; + +export { VisualEditorProvider, useVisualEditorContext }; diff --git a/components/visual/input-output-options.tsx b/components/visual/input-output-options.tsx new file mode 100644 index 0000000..cf4d70a --- /dev/null +++ b/components/visual/input-output-options.tsx @@ -0,0 +1,31 @@ +import { + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, +} from "@/components/ui/select"; + +export function InputOutputOptions({ options = [] }: { options?: string[] }) { + return ( + + {options.length > 0 ? ( + + Custom + {options.map((i) => ( + + {i} + + ))} + + ) : null} + + Google Protobuf + {["empty", "timestamp"].map((i) => ( + + {i} + + ))} + + + ); +} diff --git a/components/visual/preview.tsx b/components/visual/preview.tsx new file mode 100644 index 0000000..1519da2 --- /dev/null +++ b/components/visual/preview.tsx @@ -0,0 +1,71 @@ +"use client"; + +import CodeMirror from "@uiw/react-codemirror"; +import { useVisualEditorContext } from "./context"; +import { useTheme } from "next-themes"; +import { githubLight, githubDark } from "@uiw/codemirror-theme-github"; +import { StreamLanguage } from "@codemirror/language"; +import { protobuf } from "@codemirror/legacy-modes/mode/protobuf"; +import { useMemo } from "react"; + +const getType = (value: string) => { + switch (value) { + case "empty": + return "google.protobuf.Empty"; + case "timestamp": + return "google.protobuf.Timestamp"; + } + + return value; +}; + +export function Preview() { + const { state } = useVisualEditorContext(); + const { theme, systemTheme } = useTheme(); + + const currentTheme = theme !== "system" ? theme : systemTheme; + const editorTheme = currentTheme === "light" ? githubLight : githubDark; + + const value = useMemo(() => { + const name = state.name.split(" ").join("") || "HelloWorld"; + + return `syntax = "proto3"; + +import "aelf/core.proto"; +import "aelf/options.proto"; +import "google/protobuf/empty.proto"; +import "Protobuf/reference/acs12.proto"; +import public "google/protobuf/timestamp.proto"; + +option csharp_namespace = "AElf.Contracts.${name}"; + +service ${name} { + option (aelf.csharp_state) = "AElf.Contracts.${name}.${name}State"; + option (aelf.base) = "Protobuf/reference/acs12.proto"; + ${state.sendMethods + .map( + (i) => ` + rpc ${i.name}(${getType(i.type)}) returns (google.protobuf.Empty);` + ) + .join("\n")} + ${state.viewMethods + .map( + (i) => ` + rpc ${i.name} (${getType(i.type)}) returns (${getType(i.outputType)}) { + option (aelf.is_view) = true; + }` + ) + .join("\n")} +}`; + }, [state]); + + return ( + + ); +} diff --git a/components/visual/send-method-editor.tsx b/components/visual/send-method-editor.tsx new file mode 100644 index 0000000..7fd2a02 --- /dev/null +++ b/components/visual/send-method-editor.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useForm, useFieldArray, useWatch } from "react-hook-form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useEffect } from "react"; +import { z } from "zod"; +import { Plus, Trash2 } from "lucide-react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Select, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { InputOutputOptions } from "./input-output-options"; +import clsx from "clsx"; + +const schema = z.object({ + method: z.array( + z.object({ + name: z + .string({ required_error: "Name is required." }) + .trim() + .min(1, "Name is required."), + type: z.string(), + }) + ), +}); + +type FormValues = z.infer; + +export function SendMethodEditor({ + value = [], + onChange, +}: { + value?: FormValues["method"]; + onChange?: (val: FormValues["method"]) => void; +}) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + method: value, + }, + mode: "onBlur", + }); + const { control } = form; + + const { fields, append, remove } = useFieldArray({ + name: "method", + control, + }); + + const { method } = useWatch({ control }); + + useEffect(() => { + try { + const value = schema.parse({ method }); + if (!!onChange) onChange(value?.method || []); + } catch (err) { + // console.log(err); + } + }, [method]); + + return ( +
+ {fields.map((field, index) => { + return ( +
+ ( + + {index === 0 ? Name : null} + + + + + + )} + /> + ( + + {index === 0 ? Input : null} + + + + + + )} + /> + +
+ ); + })} + + +
+ ); +} diff --git a/components/visual/side-panel.tsx b/components/visual/side-panel.tsx new file mode 100644 index 0000000..04e22f4 --- /dev/null +++ b/components/visual/side-panel.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useVisualEditorContext } from "./context"; +import { SendMethodEditor } from "./send-method-editor"; +import { ViewMethodEditor } from "./view-method-editor"; + +export function SidePanel() { + const { state, setState } = useVisualEditorContext(); + + return ( + + + State + Methods + + + + + State + Make changes to your state here. + + +
+ + + setState((p) => ({ ...p, name: e.target.value })) + } + /> +
+
+
+
+ + + + Methods + Change your methods here. + + +
+ + setState((p) => ({ ...p, viewMethods: e }))} + /> +
+
+ + setState((p) => ({ ...p, sendMethods: e }))} + /> +
+
+
+
+
+ ); +} diff --git a/components/visual/view-method-editor.tsx b/components/visual/view-method-editor.tsx new file mode 100644 index 0000000..e2e91f4 --- /dev/null +++ b/components/visual/view-method-editor.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useForm, useFieldArray, useWatch } from "react-hook-form"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useEffect } from "react"; +import { z } from "zod"; +import { Plus, Trash2 } from "lucide-react"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Select, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { InputOutputOptions } from "./input-output-options"; +import clsx from "clsx"; + +const schema = z.object({ + method: z.array( + z.object({ + name: z + .string({ required_error: "Name is required." }) + .trim() + .min(1, "Name is required."), + type: z.string(), + outputType: z.string(), + }) + ), +}); + +type FormValues = z.infer; + +export function ViewMethodEditor({ + value = [], + onChange, +}: { + value?: FormValues["method"]; + onChange?: (val: FormValues["method"]) => void; +}) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + method: value, + }, + mode: "onBlur", + }); + const { control } = form; + + const { fields, append, remove } = useFieldArray({ + name: "method", + control, + }); + + const { method } = useWatch({ control }); + + useEffect(() => { + try { + const value = schema.parse({ method }); + if (!!onChange) onChange(value?.method || []); + } catch (err) { + // console.log(err); + } + }, [method]); + + return ( +
+ {fields.map((field, index) => { + return ( +
+ ( + + {index === 0 ? Name : null} + + + + + + )} + /> + ( + + {index === 0 ? Input : null} + + + + + + )} + /> + ( + + {index === 0 ? Output : null} + + + + + + )} + /> + +
+ ); + })} + + +
+ ); +} diff --git a/package-lock.json b/package-lock.json index d8f5cb0..99ea450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", @@ -2028,6 +2029,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.0.tgz", + "integrity": "sha512-bZgOKB/LtZIij75FSuPzyEti/XBhJH52ExgtdVqjCIh+Nx/FW+LhnbXtbCzIi34ccyMsyOja8T0thCzoHFXNKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-toast": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.1.tgz", diff --git a/package.json b/package.json index c1827dc..ee2c542 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",