├── .eslintrc.cjs ├── .gitignore ├── CHANGELOG.md ├── README.md ├── components.json ├── images └── preview.png ├── index.html ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── public └── vite.svg ├── src ├── App.css ├── App.tsx ├── assets │ ├── logo-dark-full.png │ ├── logo-dark.png │ └── react.svg ├── components │ ├── canvas │ │ ├── actions.ts │ │ ├── canvas.tsx │ │ ├── events.ts │ │ └── fabric │ │ │ ├── artboard.ts │ │ │ ├── canvas.ts │ │ │ └── utils │ │ │ ├── controls.ts │ │ │ ├── create-item.ts │ │ │ ├── drawer.ts │ │ │ └── get-object-details.ts │ ├── editor │ │ ├── control-item │ │ │ ├── basic-audio.tsx │ │ │ ├── basic-image.tsx │ │ │ ├── basic-text.tsx │ │ │ ├── common │ │ │ │ ├── opacity.tsx │ │ │ │ └── transform.tsx │ │ │ ├── control-item.tsx │ │ │ ├── index.tsx │ │ │ ├── presets.tsx │ │ │ └── smart.tsx │ │ ├── control-list.tsx │ │ ├── editor.tsx │ │ ├── footer.tsx │ │ ├── index.ts │ │ ├── menu-item │ │ │ ├── elements.tsx │ │ │ ├── images.tsx │ │ │ ├── index.tsx │ │ │ ├── menu-item.tsx │ │ │ ├── texts.tsx │ │ │ └── uploads.tsx │ │ ├── menu-list.tsx │ │ ├── navbar.tsx │ │ ├── resize-video.tsx │ │ └── use-hotkeys.ts │ ├── shared │ │ └── icons.tsx │ ├── theme-provider.tsx │ └── ui │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── command.tsx │ │ ├── dialog.tsx │ │ ├── form.tsx │ │ ├── hover-card.tsx │ │ ├── input-color.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── slider.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toggle-group.tsx │ │ └── toggle.tsx ├── data.ts ├── data │ ├── audio.ts │ ├── fonts.ts │ ├── images.ts │ ├── shapes.ts │ ├── uploads.ts │ └── video.ts ├── global │ ├── dispatcher.ts │ ├── events.ts │ └── index.ts ├── globals.css ├── index.css ├── interfaces │ ├── editor.ts │ ├── layout.ts │ └── rxjs.ts ├── lib │ └── utils.ts ├── main.tsx ├── store │ ├── use-data-state.ts │ └── use-layout-store.ts ├── types.d.ts ├── utils │ └── fonts.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .vercel 26 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # video-editor 2 | 3 | ## 0.0.13 4 | 5 | ### Patch Changes 6 | 7 | - Updated dependencies 8 | - @designcombo/core@0.4.8 9 | 10 | ## 0.0.12 11 | 12 | ### Patch Changes 13 | 14 | - Updated dependencies 15 | - @designcombo/core@0.4.7 16 | 17 | ## 0.0.11 18 | 19 | ### Patch Changes 20 | 21 | - Updated dependencies 22 | - @designcombo/core@0.4.6 23 | 24 | ## 0.0.10 25 | 26 | ### Patch Changes 27 | 28 | - Updated dependencies 29 | - @designcombo/core@0.4.5 30 | 31 | ## 0.0.9 32 | 33 | ### Patch Changes 34 | 35 | - Updated dependencies 36 | - @designcombo/core@0.4.4 37 | 38 | ## 0.0.8 39 | 40 | ### Patch Changes 41 | 42 | - Updated dependencies 43 | - @designcombo/core@0.4.3 44 | 45 | ## 0.0.7 46 | 47 | ### Patch Changes 48 | 49 | - Updated dependencies 50 | - @designcombo/core@0.4.2 51 | 52 | ## 0.0.6 53 | 54 | ### Patch Changes 55 | 56 | - Updated dependencies 57 | - @designcombo/core@0.4.1 58 | 59 | ## 0.0.5 60 | 61 | ### Patch Changes 62 | 63 | - Updated dependencies 64 | - @designcombo/core@0.3.1 65 | 66 | ## 0.0.4 67 | 68 | ### Patch Changes 69 | 70 | - Updated dependencies 71 | - @designcombo/core@0.3.0 72 | 73 | ## 0.0.3 74 | 75 | ### Patch Changes 76 | 77 | - Updated dependencies 78 | - @designcombo/core@0.2.1 79 | 80 | ## 0.0.2 81 | 82 | ### Patch Changes 83 | 84 | - Updated dependencies 85 | - @designcombo/core@0.2.0 86 | 87 | ## 0.0.1 88 | 89 | ### Patch Changes 90 | 91 | - Updated dependencies [9439fb3] 92 | - Updated dependencies [9439fb3] 93 | - @designcombo/scene@0.0.1 94 | - @designcombo/timeline@0.0.1 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

6 |

React Graphic Editor

7 | 8 |
9 | 10 | Graphic Editor application using React and TypeScript. 11 | 12 |

13 | DesignCombo 14 | · 15 | Discord 16 | · 17 | X 18 |

19 |
20 | 21 | [![](https://ik.imagekit.io/snapmotion/graphic-preview.png)](https://github.com/designcombo/react-design-editor) 22 | 23 | ## ⌨️ Development 24 | 25 | Clone locally: 26 | 27 | ```bash 28 | $ git clone git@github.com:designcombo/react-design-editor.git 29 | $ cd react-design-editor 30 | $ pnpm install 31 | $ pnpm dev 32 | ``` 33 | 34 | Open your browser and visit http://127.0.0.1:5173 , see more at [Development](https://github.com/designcombo/designcombo/react-design-editor). 35 | 36 | ## 📝 License 37 | 38 | Copyright © 2024 [DesignCombo](https://github.com/designcombo/react-design-editor). 39 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /images/preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/designcombo/react-design-editor/00f8720299fd8878e4127e74a3fade6731fb1e0d/images/preview.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | 26 |
27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphic-editor", 3 | "private": true, 4 | "version": "0.0.13", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^3.6.0", 14 | "@radix-ui/react-avatar": "^1.0.4", 15 | "@radix-ui/react-dialog": "^1.1.1", 16 | "@radix-ui/react-hover-card": "^1.1.1", 17 | "@radix-ui/react-label": "^2.0.2", 18 | "@radix-ui/react-popover": "^1.0.7", 19 | "@radix-ui/react-scroll-area": "^1.0.5", 20 | "@radix-ui/react-slider": "^1.1.2", 21 | "@radix-ui/react-slot": "^1.0.2", 22 | "@radix-ui/react-tabs": "^1.1.0", 23 | "@radix-ui/react-toggle": "^1.1.0", 24 | "@radix-ui/react-toggle-group": "^1.1.0", 25 | "class-variance-authority": "^0.7.0", 26 | "clsx": "^2.1.1", 27 | "cmdk": "^1.0.0", 28 | "fabric": "^6.3.0", 29 | "hotkeys-js": "^3.13.7", 30 | "lodash": "^4.17.21", 31 | "lucide-react": "^0.378.0", 32 | "nanoid": "^5.0.7", 33 | "react": "^18.2.0", 34 | "react-colorful": "^5.6.1", 35 | "react-dom": "^18.2.0", 36 | "react-hook-form": "^7.51.5", 37 | "rxjs": "^7.8.1", 38 | "tailwind-merge": "^2.3.0", 39 | "tailwindcss-animate": "^1.0.7", 40 | "zod": "^3.23.8", 41 | "zustand": "^4.5.2" 42 | }, 43 | "devDependencies": { 44 | "@types/lodash": "^4.17.5", 45 | "@types/node": "^20.12.12", 46 | "@types/react": "^18.2.66", 47 | "@types/react-dom": "^18.2.22", 48 | "@typescript-eslint/eslint-plugin": "^7.2.0", 49 | "@typescript-eslint/parser": "^7.2.0", 50 | "@vitejs/plugin-react": "^4.2.1", 51 | "autoprefixer": "^10.4.19", 52 | "eslint": "^8.57.0", 53 | "eslint-plugin-react-hooks": "^4.6.0", 54 | "eslint-plugin-react-refresh": "^0.4.6", 55 | "postcss": "^8.4.38", 56 | "tailwindcss": "^3.4.3", 57 | "typescript": "^5.2.2", 58 | "vite": "^5.2.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | .logo { 9 | height: 6em; 10 | padding: 1.5em; 11 | will-change: filter; 12 | transition: filter 300ms; 13 | } 14 | .logo:hover { 15 | filter: drop-shadow(0 0 2em #646cffaa); 16 | } 17 | .logo.react:hover { 18 | filter: drop-shadow(0 0 2em #61dafbaa); 19 | } 20 | 21 | @keyframes logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | 30 | @media (prefers-reduced-motion: no-preference) { 31 | a:nth-of-type(2) .logo { 32 | animation: logo-spin infinite 20s linear; 33 | } 34 | } 35 | 36 | .card { 37 | padding: 2em; 38 | } 39 | 40 | .read-the-docs { 41 | color: #888; 42 | } 43 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import useDataState from "./store/use-data-state"; 3 | import { getCompactFontData } from "./utils/fonts"; 4 | import { FONTS } from "./data/fonts"; 5 | import { Editor } from "./components/editor"; 6 | 7 | function App() { 8 | const { setCompactFonts, setFonts } = useDataState(); 9 | 10 | useEffect(() => { 11 | setCompactFonts(getCompactFontData(FONTS)); 12 | setFonts(FONTS); 13 | }, []); 14 | 15 | return ; 16 | } 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /src/assets/logo-dark-full.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/designcombo/react-design-editor/00f8720299fd8878e4127e74a3fade6731fb1e0d/src/assets/logo-dark-full.png -------------------------------------------------------------------------------- /src/assets/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/designcombo/react-design-editor/00f8720299fd8878e4127e74a3fade6731fb1e0d/src/assets/logo-dark.png -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/canvas/actions.ts: -------------------------------------------------------------------------------- 1 | export const actions = () => {}; 2 | -------------------------------------------------------------------------------- /src/components/canvas/canvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import FabricCanvas from "./fabric/canvas"; 3 | import Artboard from "./fabric/artboard"; 4 | import { ADD_PREFIX, dispatcher, filter } from "@/global"; 5 | import { addEventListeners, removeEventListeners } from "./events"; 6 | import { createItem } from "./fabric/utils/create-item"; 7 | import { EventBusData } from "@/interfaces/rxjs"; 8 | import { FabricObject } from "fabric"; 9 | 10 | const Canvas = () => { 11 | const canvasElRef = React.useRef(null); 12 | const containerRef = React.useRef(null); 13 | const canvasRef = React.useRef(null); 14 | 15 | useEffect(() => { 16 | const canvasWrapper = containerRef.current as HTMLDivElement; 17 | 18 | const canvasWrapperWidth = canvasWrapper.offsetWidth; 19 | const canvasWrapperHeight = canvasWrapper.offsetHeight; 20 | 21 | FabricObject.ownDefaults.borderColor = "blue"; 22 | FabricObject.ownDefaults.cornerColor = "white"; 23 | FabricObject.ownDefaults.cornerStrokeColor = "#c0c0c0"; 24 | FabricObject.ownDefaults.borderOpacityWhenMoving = 1; 25 | FabricObject.ownDefaults.borderScaleFactor = 1; 26 | FabricObject.ownDefaults.cornerSize = 8; 27 | FabricObject.ownDefaults.cornerStyle = "rect"; 28 | FabricObject.ownDefaults.centeredScaling = false; 29 | FabricObject.ownDefaults.centeredRotation = true; 30 | FabricObject.ownDefaults.transparentCorners = false; 31 | 32 | const canvas = new FabricCanvas(canvasElRef.current, { 33 | width: canvasWrapperWidth, 34 | height: canvasWrapperHeight, 35 | preserveObjectStacking: true, 36 | selectionColor: "rgba(52, 152, 219,0.1)", 37 | selectionBorderColor: "rgba(52, 152, 219,1.0)", 38 | }); 39 | 40 | addEventListeners(canvas); 41 | 42 | canvasRef.current = canvas; 43 | 44 | const size = { 45 | width: 600, 46 | height: 600, 47 | }; 48 | 49 | const artboard = new Artboard({ 50 | width: size.width, 51 | height: size.height, 52 | }); 53 | 54 | canvas.add(artboard); 55 | 56 | canvas.centerArtboards(); 57 | 58 | return () => { 59 | removeEventListeners(canvas); 60 | canvas.dispose(); 61 | }; 62 | }, []); 63 | 64 | const handleAddRemoveEvents = async (object: EventBusData) => { 65 | const item = await createItem(object); 66 | canvasRef.current?.add(item); 67 | }; 68 | 69 | useEffect(() => { 70 | const stateEvents = dispatcher.bus.pipe( 71 | filter(({ key }) => key.startsWith(ADD_PREFIX)) 72 | ); 73 | const subscription = stateEvents.subscribe((object) => { 74 | handleAddRemoveEvents(object); 75 | }); 76 | return () => subscription.unsubscribe(); 77 | }, [canvasRef]); 78 | 79 | return ( 80 |
81 | 82 |
83 | ); 84 | }; 85 | 86 | export default Canvas; 87 | -------------------------------------------------------------------------------- /src/components/canvas/events.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ActiveSelection, 3 | BasicTransformEvent, 4 | FabricObject, 5 | FabricObjectProps, 6 | ObjectEvents, 7 | SerializedObjectProps, 8 | TPointerEvent, 9 | } from "fabric"; 10 | import FabricCanvas from "./fabric/canvas"; 11 | import { createTextControls } from "./fabric/utils/controls"; 12 | import { getObjectsDetail } from "./fabric/utils/get-object-details"; 13 | import { SELECTION_UPDATED, dispatcher } from "@/global"; 14 | 15 | type FabricMovingEvent = BasicTransformEvent & { 16 | target: FabricObject< 17 | Partial, 18 | SerializedObjectProps, 19 | ObjectEvents 20 | >; 21 | }; 22 | 23 | function onObjectMoving(this: FabricCanvas, e: FabricMovingEvent) {} 24 | 25 | function onSelectionCreated(this: FabricCanvas) { 26 | const objects = this.getActiveObjects(); 27 | const selection = this.getActiveObject(); 28 | 29 | const layers = getObjectsDetail(objects); 30 | 31 | if (objects.length > 1 && selection instanceof ActiveSelection) { 32 | selection.setControlsVisibility({ 33 | mt: false, 34 | mb: false, 35 | mr: false, 36 | ml: false, 37 | }); 38 | selection.set({ 39 | cornerColor: "#FFFFFF", 40 | cornerStyle: "circle", 41 | borderColor: "rgba(60, 64, 198,1.0)", 42 | cornerStrokeColor: "rgba(60, 64, 198,1.0)", 43 | transparentCorners: false, 44 | borderScaleFactor: 1, 45 | touchCornerSize: 40, 46 | cornerSize: 10, 47 | }); 48 | } 49 | 50 | if (objects.length === 1) { 51 | const object = objects[0]; 52 | 53 | object.controls = createTextControls(); 54 | object.setCoords(); 55 | } 56 | 57 | dispatcher.dispatch(SELECTION_UPDATED, { 58 | payload: { 59 | layers, 60 | }, 61 | }); 62 | } 63 | 64 | export function addEventListeners(canvas: FabricCanvas) { 65 | canvas.on("object:moving", onObjectMoving); 66 | canvas.on("selection:created", onSelectionCreated); 67 | canvas.on("selection:updated", onSelectionCreated); 68 | } 69 | 70 | export function removeEventListeners(canvas: FabricCanvas) { 71 | canvas.off("object:moving", onObjectMoving); 72 | canvas.off("selection:created", onSelectionCreated); 73 | canvas.off("selection:updated", onSelectionCreated); 74 | } 75 | -------------------------------------------------------------------------------- /src/components/canvas/fabric/artboard.ts: -------------------------------------------------------------------------------- 1 | import { Rect, RectProps } from "fabric"; 2 | 3 | class Artboard extends Rect { 4 | static type = "Artboard"; 5 | static ownDefaults = { 6 | lockMovementX: true, 7 | lockMovementY: true, 8 | hasControls: false, 9 | fill: "#ffffff", 10 | selectable: false, 11 | absolutePositioned: true, 12 | objectCaching: false, 13 | evented: false, 14 | }; 15 | 16 | static getDefaults(): Record { 17 | return { 18 | ...super.getDefaults(), 19 | ...Artboard.ownDefaults, 20 | }; 21 | } 22 | 23 | constructor(options: Partial) { 24 | super({ 25 | ...options, 26 | ...Artboard.ownDefaults, 27 | }); 28 | } 29 | } 30 | 31 | export default Artboard; 32 | -------------------------------------------------------------------------------- /src/components/canvas/fabric/canvas.ts: -------------------------------------------------------------------------------- 1 | import { Canvas as FabricCanvas } from "fabric"; 2 | 3 | class Canvas extends FabricCanvas { 4 | public isFlipCanvas: boolean; 5 | constructor(canvasEl: HTMLCanvasElement, options: any) { 6 | super(canvasEl, options); 7 | } 8 | 9 | public centerArtboards() { 10 | const canvasWidth = this.width; 11 | const canvasHeight = this.height; 12 | const vt = this.viewportTransform; 13 | const requestedSize = this.getRequestedSize(); 14 | vt[4] = (canvasWidth - requestedSize.width) / 2; 15 | vt[5] = (canvasHeight - requestedSize.height) / 2; 16 | this.requestRenderAll(); 17 | } 18 | 19 | public getRequestedSize() { 20 | const artboards = this.getObjects("Artboard"); 21 | const artboardsWidth = artboards[0].width; 22 | let artboardsHeight = 0; 23 | artboards.forEach((artboard) => { 24 | artboardsHeight += artboard.height; 25 | }); 26 | return { 27 | width: artboardsWidth, 28 | height: artboardsHeight, 29 | }; 30 | } 31 | } 32 | 33 | export default Canvas; 34 | -------------------------------------------------------------------------------- /src/components/canvas/fabric/utils/controls.ts: -------------------------------------------------------------------------------- 1 | import { Control, controlsUtils } from "fabric"; 2 | import { drawCircleIcon, drawRotateIcon, drawVerticalIcon } from "./drawer"; 3 | const scaleSkewCursorStyleHandler = controlsUtils.scaleSkewCursorStyleHandler; 4 | const scaleCursorStyleHandler = controlsUtils.scaleCursorStyleHandler; 5 | const scalingEqually = controlsUtils.scalingEqually; 6 | const rotationWithSnapping = controlsUtils.rotationWithSnapping; 7 | const rotationStyleHandler = controlsUtils.rotationStyleHandler; 8 | const changeWidth = controlsUtils.changeWidth; 9 | 10 | interface ObjectControls { 11 | ml: Control; 12 | mr: Control; 13 | mb: Control; 14 | mt: Control; 15 | tl: Control; 16 | tr: Control; 17 | bl: Control; 18 | br: Control; 19 | trr: Control; 20 | brr: Control; 21 | blr: Control; 22 | tlr: Control; 23 | mtr: Control; 24 | } 25 | 26 | export const rotateControl = new Control({ 27 | x: 0, 28 | y: 0.5, 29 | actionHandler: rotationWithSnapping, 30 | cursorStyleHandler: rotationStyleHandler, 31 | offsetY: 25, 32 | withConnection: false, 33 | actionName: "rotate", 34 | render: drawRotateIcon, 35 | }); 36 | 37 | const rotateIcon = (angle: number) => { 38 | return `url("data:image/svg+xml,") 12 12,auto`; 39 | }; 40 | 41 | function noop(...args: T[]): void { 42 | // This function does nothing with the arguments 43 | } 44 | 45 | const getRotateControl = (angle: number): Partial => ({ 46 | sizeX: 16, 47 | sizeY: 16, 48 | actionHandler: (eventData, transformData, x, y) => { 49 | transformData.target.canvas?.setCursor( 50 | rotateIcon(transformData.target.angle + angle) 51 | ); 52 | return rotationWithSnapping(eventData, transformData, x, y); 53 | }, 54 | cursorStyleHandler: (eventData, control, fabricObject) => { 55 | return rotateIcon(fabricObject.angle + angle); 56 | }, 57 | render: noop, 58 | actionName: "rotate", 59 | }); 60 | export const createTextControls = (): Partial => { 61 | return { 62 | tlr: new Control({ 63 | x: -0.5, 64 | y: -0.5, 65 | offsetX: -4, 66 | offsetY: -4, 67 | ...getRotateControl(0), 68 | }), 69 | 70 | trr: new Control({ 71 | x: 0.5, 72 | y: -0.5, 73 | offsetX: 4, 74 | offsetY: -4, 75 | ...getRotateControl(90), 76 | }), 77 | 78 | brr: new Control({ 79 | x: 0.5, 80 | y: 0.5, 81 | offsetX: 4, 82 | offsetY: 4, 83 | ...getRotateControl(180), 84 | }), 85 | 86 | blr: new Control({ 87 | x: -0.5, 88 | y: 0.5, 89 | offsetX: -4, 90 | offsetY: 4, 91 | ...getRotateControl(270), 92 | }), 93 | 94 | mr: new Control({ 95 | x: 0.5, 96 | y: 0, 97 | actionHandler: changeWidth, 98 | cursorStyleHandler: scaleSkewCursorStyleHandler, 99 | actionName: "resizing", 100 | render: drawVerticalIcon, 101 | }), 102 | ml: new Control({ 103 | x: -0.5, 104 | y: 0, 105 | actionHandler: changeWidth, 106 | cursorStyleHandler: scaleSkewCursorStyleHandler, 107 | actionName: "resizing", 108 | render: drawVerticalIcon, 109 | }), 110 | 111 | tl: new Control({ 112 | x: -0.5, 113 | y: -0.5, 114 | cursorStyleHandler: scaleCursorStyleHandler, 115 | actionHandler: scalingEqually, 116 | render: drawCircleIcon, 117 | }), 118 | 119 | tr: new Control({ 120 | x: 0.5, 121 | y: -0.5, 122 | cursorStyleHandler: scaleCursorStyleHandler, 123 | actionHandler: scalingEqually, 124 | render: drawCircleIcon, 125 | }), 126 | 127 | bl: new Control({ 128 | x: -0.5, 129 | y: 0.5, 130 | cursorStyleHandler: scaleCursorStyleHandler, 131 | actionHandler: scalingEqually, 132 | render: drawCircleIcon, 133 | }), 134 | 135 | br: new Control({ 136 | x: 0.5, 137 | y: 0.5, 138 | cursorStyleHandler: scaleCursorStyleHandler, 139 | actionHandler: scalingEqually, 140 | render: drawCircleIcon, 141 | }), 142 | }; 143 | }; 144 | -------------------------------------------------------------------------------- /src/components/canvas/fabric/utils/create-item.ts: -------------------------------------------------------------------------------- 1 | import { ADD_IMAGE, ADD_TEXT } from "@/global"; 2 | import { IImage, ILayer } from "@/interfaces/editor"; 3 | import { EventBusData } from "@/interfaces/rxjs"; 4 | import { loadFonts } from "@/utils/fonts"; 5 | import { FabricImage, ITextProps, Textbox } from "fabric"; 6 | 7 | export const createItem = async (event: EventBusData) => { 8 | if (event.key === ADD_TEXT) { 9 | const layer = event.val.payload as ILayer; 10 | await loadFonts([ 11 | { 12 | name: layer.details.fontFamily, 13 | url: layer.details.fontUrl, 14 | }, 15 | ]); 16 | const { text, ...options } = layer.details; 17 | 18 | const textbox = new Textbox(text, options as ITextProps); 19 | return textbox; 20 | } 21 | 22 | if (event.key === ADD_IMAGE) { 23 | const layer = event.val.payload as IImage; 24 | const image = await FabricImage.fromURL(layer.details.src); 25 | image.set({ 26 | scaleX: 0.1, 27 | scaleY: 0.1, 28 | }); 29 | return image; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/components/canvas/fabric/utils/drawer.ts: -------------------------------------------------------------------------------- 1 | import * as fabric from "fabric"; 2 | const horizontalIcon = document.createElement("img"); 3 | const verticalIcon = document.createElement("img"); 4 | horizontalIcon.src = 5 | "https://ik.imagekit.io/uonadbo34e6/icons/horizontal_7M4-rXo2E.svg"; 6 | verticalIcon.src = 7 | "https://ik.imagekit.io/uonadbo34e6/icons/vertical_hEUb9e0-3.svg"; 8 | 9 | const largeSide = 45; 10 | const smallSide = 10; 11 | 12 | export function drawVerticalIcon( 13 | ctx: CanvasRenderingContext2D, 14 | left: number, 15 | top: number, 16 | styleOverride: any, 17 | fabricObject: any 18 | ) { 19 | ctx.save(); 20 | ctx.translate(left, top); 21 | ctx.rotate(fabric.util.degreesToRadians(90 + fabricObject.angle)); 22 | ctx.restore(); 23 | } 24 | 25 | export function drawHorizontalIcon( 26 | ctx: CanvasRenderingContext2D, 27 | left: number, 28 | top: number, 29 | styleOverride: any, 30 | fabricObject: any 31 | ) { 32 | // @ts-ignore 33 | const size = this.cornerSize; 34 | ctx.save(); 35 | ctx.translate(left, top); 36 | ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle)); 37 | // icon, x, y, width, height 38 | 39 | ctx.drawImage( 40 | horizontalIcon, 41 | false ? -smallSide / 2 : -size / 2, //x 42 | false ? -size / 2 : -smallSide / 2, //y 43 | false ? smallSide : size, // width 44 | false ? size : smallSide // height 45 | ); 46 | ctx.restore(); 47 | } 48 | 49 | const rotateControlSize = 26; 50 | const img = document.createElement("img"); 51 | img.src = "https://ik.imagekit.io/uonadbo34e6/icons/Rotate_qCgLn7Jao.svg"; 52 | 53 | export function drawRotateIcon( 54 | ctx: CanvasRenderingContext2D, 55 | left: number, 56 | top: number, 57 | styleOverride: any, 58 | fabricObject: any 59 | ) { 60 | ctx.save(); 61 | ctx.translate(left, top); 62 | ctx.shadowBlur = 15; 63 | ctx.shadowOffsetY = 8; 64 | ctx.shadowColor = "rgba(0,0,0,0.08)"; 65 | ctx.drawImage( 66 | img, 67 | -rotateControlSize / 2, 68 | -rotateControlSize / 2, 69 | rotateControlSize, 70 | rotateControlSize 71 | ); 72 | ctx.restore(); 73 | } 74 | 75 | export function drawCircleIcon( 76 | ctx: CanvasRenderingContext2D, 77 | left: number, 78 | top: number, 79 | styleOverride: any, 80 | fabricObject: any 81 | ) { 82 | ctx.save(); 83 | ctx.translate(left, top); 84 | ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle)); 85 | ctx.beginPath(); 86 | ctx.lineCap = "round"; 87 | ctx.lineWidth = 3; 88 | ctx.shadowBlur = 2; 89 | ctx.shadowColor = "black"; 90 | ctx.arc(0, 0, 5.5, 0, 2 * Math.PI); 91 | ctx.fillStyle = "#ffffff"; 92 | ctx.fill(); 93 | ctx.restore(); 94 | } 95 | -------------------------------------------------------------------------------- /src/components/canvas/fabric/utils/get-object-details.ts: -------------------------------------------------------------------------------- 1 | import { FabricObject } from "fabric"; 2 | 3 | export const getObjectsDetail = (objects: FabricObject[]) => { 4 | const objectsDetails = []; 5 | objects.forEach((object) => { 6 | objectsDetails.push(getObjectDetails(object)); 7 | }); 8 | return objectsDetails; 9 | }; 10 | 11 | const getObjectDetails = (object: FabricObject) => { 12 | const commonProps = { 13 | type: object.type, 14 | left: object.left, 15 | top: object.top, 16 | width: object.width, 17 | height: object.height, 18 | }; 19 | if (object.type === "textbox") { 20 | return { 21 | id: object.id, 22 | type: object.type, 23 | details: { 24 | ...commonProps, 25 | text: object.text, 26 | fontUrl: object.fontUrl, 27 | }, 28 | }; 29 | } 30 | return { 31 | id: object.id, 32 | type: object.type, 33 | details: { 34 | ...commonProps, 35 | }, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/editor/control-item/basic-audio.tsx: -------------------------------------------------------------------------------- 1 | const BasicAudio = () => { 2 | return ( 3 |
4 |
5 | BasicAudio 6 |
7 |
8 | ); 9 | }; 10 | 11 | export default BasicAudio; 12 | -------------------------------------------------------------------------------- /src/components/editor/control-item/basic-image.tsx: -------------------------------------------------------------------------------- 1 | const BasicImage = () => { 2 | return ( 3 |
4 |
5 | BasicImage 6 |
7 |
8 | ); 9 | }; 10 | 11 | export default BasicImage; 12 | -------------------------------------------------------------------------------- /src/components/editor/control-item/basic-text.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Popover, 4 | PopoverContent, 5 | PopoverTrigger, 6 | } from "@/components/ui/popover"; 7 | import { ScrollArea } from "@/components/ui/scroll-area"; 8 | import { DEFAULT_FONT } from "@/data/fonts"; 9 | import { ICompactFont, IFont, ILayer } from "@/interfaces/editor"; 10 | import useDataState from "@/store/use-data-state"; 11 | import { loadFonts } from "@/utils/fonts"; 12 | import { EDIT_OBJECT, dispatcher } from "@/global"; 13 | import { ChevronDown, Ellipsis, Strikethrough, Underline } from "lucide-react"; 14 | import { useEffect, useState } from "react"; 15 | import Opacity from "./common/opacity"; 16 | import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; 17 | import { AlignCenter, AlignLeft, AlignRight } from "lucide-react"; 18 | import { Input } from "@/components/ui/input"; 19 | import Transform from "./common/transform"; 20 | 21 | interface ITextControlProps { 22 | color: string; 23 | colorDisplay: string; 24 | fontSize: number; 25 | fontSizeDisplay: string; 26 | fontFamily: string; 27 | fontFamilyDisplay: string; 28 | opacity: number; 29 | opacityDisplay: string; 30 | textAlign: string; 31 | textDecoration: string; 32 | } 33 | 34 | const getStyleNameFromFontName = (fontName: string) => { 35 | const fontFamilyEnd = fontName.lastIndexOf("-"); 36 | const styleName = fontName 37 | .substring(fontFamilyEnd + 1) 38 | .replace("Italic", " Italic"); 39 | return styleName; 40 | }; 41 | 42 | const BasicText = ({ item }: { item: ILayer }) => { 43 | const [properties, setProperties] = useState({ 44 | color: "#000000", 45 | colorDisplay: "#000000", 46 | fontSize: 12, 47 | fontSizeDisplay: "12px", 48 | fontFamily: "Open Sans", 49 | fontFamilyDisplay: "Open Sans", 50 | opacity: 1, 51 | opacityDisplay: "100%", 52 | textAlign: "left", 53 | textDecoration: "none", 54 | }); 55 | 56 | const [selectedFont, setSelectedFont] = useState({ 57 | family: "Open Sans", 58 | styles: [], 59 | default: DEFAULT_FONT, 60 | name: "Regular", 61 | }); 62 | const { compactFonts, fonts } = useDataState(); 63 | 64 | useEffect(() => { 65 | const fontFamily = item.details.fontFamily || DEFAULT_FONT.postScriptName; 66 | const currentFont = fonts.find( 67 | (font) => font.postScriptName === fontFamily 68 | ); 69 | const selectedFont = compactFonts.find( 70 | (font) => font.family === currentFont?.family 71 | ); 72 | 73 | setSelectedFont({ 74 | ...selectedFont, 75 | name: getStyleNameFromFontName(currentFont.postScriptName), 76 | }); 77 | 78 | if (item.details.opacityDisplay == undefined) { 79 | item.details.opacityDisplay = "100"; 80 | } 81 | 82 | if (item.details.fontSizeDisplay == undefined) { 83 | item.details.fontSizeDisplay = "62"; 84 | } 85 | setProperties({ 86 | color: item.details.color || "#ffffff", 87 | colorDisplay: item.details.color || "#ffffff", 88 | fontSize: item.details.fontSize || 62, 89 | fontSizeDisplay: (item.details.fontSize || 62) + "px", 90 | fontFamily: selectedFont?.family || "Open Sans", 91 | fontFamilyDisplay: selectedFont?.family || "Open Sans", 92 | opacity: item.details.opacity || 1, 93 | opacityDisplay: (item.details.opacityDisplay || "100") + "%", 94 | textAlign: item.details.textAlign || "left", 95 | textDecoration: item.details.textDecoration || "none", 96 | }); 97 | }, [item.id]); 98 | 99 | const handleChangeFont = async (font: ICompactFont) => { 100 | const fontName = font.default.postScriptName; 101 | const fontUrl = font.default.url; 102 | 103 | await loadFonts([ 104 | { 105 | name: fontName, 106 | url: fontUrl, 107 | }, 108 | ]); 109 | setSelectedFont({ ...font, name: getStyleNameFromFontName(fontName) }); 110 | setProperties({ 111 | ...properties, 112 | fontFamily: font.default.family, 113 | fontFamilyDisplay: font.default.family, 114 | }); 115 | 116 | dispatcher.dispatch(EDIT_OBJECT, { 117 | payload: { 118 | details: { 119 | fontFamily: fontName, 120 | fontUrl: fontUrl, 121 | }, 122 | }, 123 | }); 124 | }; 125 | 126 | const handleChangeFontStyle = async (font: IFont) => { 127 | const fontName = font.postScriptName; 128 | const fontUrl = font.url; 129 | const styleName = getStyleNameFromFontName(fontName); 130 | await loadFonts([ 131 | { 132 | name: fontName, 133 | url: fontUrl, 134 | }, 135 | ]); 136 | setSelectedFont({ ...selectedFont, name: styleName }); 137 | dispatcher.dispatch(EDIT_OBJECT, { 138 | payload: { 139 | details: { 140 | fontFamily: fontName, 141 | fontUrl: fontUrl, 142 | }, 143 | }, 144 | }); 145 | }; 146 | 147 | return ( 148 |
149 |
150 | Text 151 |
152 | 153 |
154 |
155 |
156 | 160 |
161 |
162 | 166 |
167 | 168 |
169 | px 170 |
171 |
172 |
173 |
174 |
175 | 176 | 177 |
178 |
179 |
180 |
Style
181 | 182 | 183 | 184 | 185 |
186 | 187 |
188 | 189 |
190 |
191 | 192 |
193 |
194 |
195 | ); 196 | }; 197 | 198 | const Fill = () => { 199 | return ( 200 |
207 |
Fill
208 |
209 |
210 |
211 |
212 | 215 |
216 |
217 | ); 218 | }; 219 | 220 | const Stroke = () => { 221 | return ( 222 |
229 |
Stroke
230 |
231 |
232 |
233 |
234 | 237 |
238 |
239 | ); 240 | }; 241 | const Shadow = () => { 242 | return ( 243 |
250 |
Shadow
251 |
252 |
253 |
254 |
255 | 258 |
259 |
260 | ); 261 | }; 262 | const Background = () => { 263 | return ( 264 |
271 |
Background
272 |
273 |
274 |
275 |
276 | 279 |
280 |
281 | ); 282 | }; 283 | 284 | const FontFamily = ({ 285 | handleChangeFont, 286 | fontFamilyDisplay, 287 | }: { 288 | handleChangeFont: (font: ICompactFont) => void; 289 | fontFamilyDisplay: string; 290 | }) => { 291 | const { compactFonts } = useDataState(); 292 | 293 | return ( 294 | 295 | 296 | 306 | 307 | 308 | 309 | {compactFonts.map((font, index) => ( 310 |
handleChangeFont(font)} 312 | className="hover:bg-zinc-800/50 cursor-pointer px-2 py-1" 313 | key={index} 314 | > 315 | {font.family} 322 |
323 | ))} 324 |
325 |
326 |
327 | ); 328 | }; 329 | 330 | const FontStyle = ({ 331 | selectedFont, 332 | handleChangeFontStyle, 333 | }: { 334 | selectedFont: ICompactFont; 335 | handleChangeFontStyle: (font: IFont) => void; 336 | }) => { 337 | return ( 338 | 339 | 340 | 350 | 351 | 352 | 353 | {selectedFont.styles.map((style, index) => { 354 | const fontFamilyEnd = style.postScriptName.lastIndexOf("-"); 355 | const styleName = style.postScriptName 356 | .substring(fontFamilyEnd + 1) 357 | .replace("Italic", " Italic"); 358 | return ( 359 |
handleChangeFontStyle(style)} 363 | > 364 | {styleName} 365 |
366 | ); 367 | })} 368 |
369 |
370 | ); 371 | }; 372 | const TextDecoration = () => { 373 | const [value, setValue] = useState(["left"]); 374 | const onChangeAligment = (value: string[]) => { 375 | setValue(value); 376 | }; 377 | return ( 378 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 |
393 | 399 | 405 | 406 |
407 |
408 |
409 | ); 410 | }; 411 | 412 | const Alignment = () => { 413 | const [value, setValue] = useState("left"); 414 | const onChangeAligment = (value: string) => { 415 | setValue(value); 416 | }; 417 | return ( 418 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | ); 436 | }; 437 | 438 | export default BasicText; 439 | -------------------------------------------------------------------------------- /src/components/editor/control-item/common/opacity.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { Input } from '@/components/ui/input'; 3 | import { Label } from '@/components/ui/label'; 4 | 5 | import { Slider } from '@/components/ui/slider'; 6 | import { Plus, RefreshCcw, RotateCw } from 'lucide-react'; 7 | import { useState } from 'react'; 8 | 9 | const Opacity = () => { 10 | const [value, setValue] = useState([10]); 11 | 12 | return ( 13 |
14 |
15 |
16 | 17 |
18 |
25 | 32 | 33 |
34 | 41 |
42 |
43 |
44 |
45 | ); 46 | }; 47 | 48 | export default Opacity; 49 | -------------------------------------------------------------------------------- /src/components/editor/control-item/common/transform.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { Input } from '@/components/ui/input'; 3 | import { Slider } from '@/components/ui/slider'; 4 | import { RotateCw } from 'lucide-react'; 5 | import { useState } from 'react'; 6 | 7 | const Transform = () => { 8 | const [value, setValue] = useState([10]); 9 | 10 | return ( 11 |
12 |
Transform
13 |
14 |
Scale
15 |
22 | 29 | 30 |
31 | 38 |
39 |
40 |
41 | 42 |
43 |
Position
44 |
51 |
52 | 53 |
54 | x 55 |
56 |
57 |
58 | 59 |
60 | y 61 |
62 |
63 | 64 |
65 | 72 |
73 |
74 |
75 | 76 |
77 |
Rotate
78 |
85 | 86 |
87 |
88 | 95 |
96 |
97 |
98 |
99 | ); 100 | }; 101 | 102 | export default Transform; 103 | -------------------------------------------------------------------------------- /src/components/editor/control-item/control-item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useLayoutStore from "@/store/use-layout-store"; 3 | import { useEffect, useState } from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { X } from "lucide-react"; 6 | import Presets from "./presets"; 7 | import Smart from "./smart"; 8 | import BasicText from "./basic-text"; 9 | import BasicImage from "./basic-image"; 10 | import { ILayer } from "@/interfaces/editor"; 11 | import { 12 | SELECTION_PREFIX, 13 | SELECTION_UPDATED, 14 | dispatcher, 15 | filter, 16 | } from "@/global"; 17 | 18 | const Container = ({ children }: { children: React.ReactNode }) => { 19 | const { activeToolboxItem } = useLayoutStore(); 20 | const [item, setItem] = useState(null); 21 | const [displayToolbox, setDisplayToolbox] = useState(false); 22 | 23 | useEffect(() => { 24 | const stateEvents = dispatcher.bus.pipe( 25 | filter(({ key }) => key.startsWith(SELECTION_PREFIX)) 26 | ); 27 | const subscription = stateEvents.subscribe((obj) => { 28 | if (obj.key === SELECTION_UPDATED) { 29 | const layers = obj.val.payload.layers; 30 | if (layers.length === 1) { 31 | const [layer] = layers; 32 | setItem(layer); 33 | } else { 34 | setItem(null); 35 | setDisplayToolbox(false); 36 | } 37 | } 38 | }); 39 | return () => subscription.unsubscribe(); 40 | }, []); 41 | 42 | useEffect(() => { 43 | if (activeToolboxItem) { 44 | setDisplayToolbox(true); 45 | } else { 46 | setDisplayToolbox(false); 47 | } 48 | }, [activeToolboxItem]); 49 | 50 | if (!item) { 51 | return null; 52 | } 53 | 54 | return ( 55 |
63 |
64 | 71 | {React.cloneElement(children as React.ReactElement, { 72 | item, 73 | activeToolboxItem, 74 | })} 75 |
76 |
77 |
78 | ); 79 | }; 80 | 81 | const ActiveControlItem = ({ 82 | item, 83 | activeToolboxItem, 84 | }: { 85 | item?: ILayer; 86 | activeToolboxItem?: string; 87 | }) => { 88 | if (!item || !activeToolboxItem) { 89 | return null; 90 | } 91 | return ( 92 | <> 93 | { 94 | { 95 | "basic-textbox": , 96 | "basic-image": , 97 | "preset-textbox": , 98 | smart: , 99 | }[activeToolboxItem] 100 | } 101 | 102 | ); 103 | }; 104 | 105 | export const ControlItem = () => { 106 | return ( 107 | 108 | 109 | 110 | ); 111 | }; 112 | -------------------------------------------------------------------------------- /src/components/editor/control-item/index.tsx: -------------------------------------------------------------------------------- 1 | export { ControlItem } from './control-item'; 2 | -------------------------------------------------------------------------------- /src/components/editor/control-item/presets.tsx: -------------------------------------------------------------------------------- 1 | const Presets = () => { 2 | return ( 3 |
4 |
5 | Presets 6 |
7 |
8 | ); 9 | }; 10 | 11 | export default Presets; 12 | -------------------------------------------------------------------------------- /src/components/editor/control-item/smart.tsx: -------------------------------------------------------------------------------- 1 | const Smart = () => { 2 | return ( 3 |
4 |
5 | Ai things 6 |
7 |
8 | ); 9 | }; 10 | 11 | export default Smart; 12 | -------------------------------------------------------------------------------- /src/components/editor/control-list.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useState } from "react"; 2 | import { Icons } from "@/components/shared/icons"; 3 | import { Button } from "@/components/ui/button"; 4 | import useLayoutStore from "@/store/use-layout-store"; 5 | import { 6 | SELECTION_PREFIX, 7 | SELECTION_UPDATED, 8 | dispatcher, 9 | filter, 10 | } from "@/global"; 11 | 12 | type ItemType = any; 13 | export default function ControlList() { 14 | const [controlType, setControlType] = useState(null); 15 | 16 | // useEffect(() => { 17 | // if (activeIds.length === 1) { 18 | // const [id] = activeIds; 19 | // const item = itemsMap[id]; 20 | // setControlType(item.type); 21 | // } else { 22 | // setControlType(null); 23 | // } 24 | // }, [activeIds, itemsMap]); 25 | 26 | useEffect(() => { 27 | const stateEvents = dispatcher.bus.pipe( 28 | filter(({ key }) => key.startsWith(SELECTION_PREFIX)) 29 | ); 30 | const subscription = stateEvents.subscribe((obj) => { 31 | if (obj.key === SELECTION_UPDATED) { 32 | const layers = obj.val.payload.layers; 33 | if (layers.length === 1) { 34 | const [layer] = layers; 35 | setControlType(layer.type); 36 | } else { 37 | setControlType(null); 38 | } 39 | } 40 | }); 41 | return () => subscription.unsubscribe(); 42 | }, []); 43 | 44 | return <>{controlType && }; 45 | } 46 | 47 | function ControlMenu({ controlType }: { controlType: ItemType }) { 48 | const { setShowToolboxItem, setActiveToolboxItem, activeToolboxItem } = 49 | useLayoutStore(); 50 | 51 | const openToolboxItem = useCallback( 52 | (type: string) => { 53 | if (type === activeToolboxItem) { 54 | setShowToolboxItem(false); 55 | setActiveToolboxItem(null); 56 | } else { 57 | setShowToolboxItem(true); 58 | setActiveToolboxItem(type); 59 | } 60 | }, 61 | [activeToolboxItem] 62 | ); 63 | 64 | return ( 65 |
69 | { 70 | { 71 | image: ( 72 | 76 | ), 77 | textbox: ( 78 | 82 | ), 83 | }[controlType] 84 | } 85 |
86 | ); 87 | } 88 | 89 | const ImageMenuList = ({ 90 | openToolboxItem, 91 | type, 92 | }: { 93 | openToolboxItem: (type: string) => void; 94 | type: ItemType; 95 | }) => { 96 | return ( 97 |
98 | 99 | 100 |
101 | ); 102 | }; 103 | 104 | const TextMenuList = ({ 105 | openToolboxItem, 106 | type, 107 | }: { 108 | openToolboxItem: (type: string) => void; 109 | type: ItemType; 110 | }) => { 111 | return ( 112 |
113 | 114 | 115 | 116 |
117 | ); 118 | }; 119 | 120 | const PresetsMenuListItem = ({ 121 | openToolboxItem, 122 | type, 123 | }: { 124 | openToolboxItem: (type: string) => void; 125 | type: ItemType; 126 | }) => { 127 | return ( 128 | 135 | ); 136 | }; 137 | 138 | const BasicMenuListItem = ({ 139 | openToolboxItem, 140 | type, 141 | }: { 142 | openToolboxItem: (type: string) => void; 143 | type: string; 144 | }) => { 145 | const Icon = Icons[type]; 146 | return ( 147 | 154 | ); 155 | }; 156 | 157 | const SmartMenuListItem = ({ 158 | openToolboxItem, 159 | }: { 160 | openToolboxItem: (type: string) => void; 161 | }) => { 162 | return ( 163 | 170 | ); 171 | }; 172 | -------------------------------------------------------------------------------- /src/components/editor/editor.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "./navbar"; 2 | import MenuList from "./menu-list"; 3 | import { MenuItem } from "./menu-item"; 4 | import ControlList from "./control-list"; 5 | import { ControlItem } from "./control-item"; 6 | 7 | import useHotkeys from "./use-hotkeys"; 8 | import { useEffect } from "react"; 9 | import { getCompactFontData } from "@/utils/fonts"; 10 | import { FONTS } from "@/data/fonts"; 11 | import useDataState from "@/store/use-data-state"; 12 | import Canvas from "../canvas/canvas"; 13 | import Footer from "./footer"; 14 | 15 | const Editor = () => { 16 | const { setCompactFonts, setFonts } = useDataState(); 17 | 18 | useHotkeys(); 19 | 20 | useEffect(() => { 21 | setCompactFonts(getCompactFontData(FONTS)); 22 | setFonts(FONTS); 23 | }, []); 24 | 25 | return ( 26 |
27 | 28 |
29 | 30 | 31 | 32 | 33 | 34 |
35 |
36 |
37 | ); 38 | }; 39 | 40 | export default Editor; 41 | -------------------------------------------------------------------------------- /src/components/editor/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | 3 | import { Command, Info, MinusCircle, PlusCircle } from "lucide-react"; 4 | import { Input } from "../ui/input"; 5 | import { Slider } from "../ui/slider"; 6 | 7 | export default function Footer() { 8 | return ( 9 |
16 |
17 |
18 | 21 |
22 |
23 | 24 |
25 |
26 | 29 | 30 | 33 | 34 |
35 |
36 | 37 |
38 |
39 | 42 |
43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/components/editor/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Editor } from './editor'; 2 | -------------------------------------------------------------------------------- /src/components/editor/menu-item/elements.tsx: -------------------------------------------------------------------------------- 1 | export const Elements = () => { 2 | return ( 3 |
4 |
5 | Shapes 6 |
7 |
8 | ); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/editor/menu-item/images.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { ScrollArea } from "@/components/ui/scroll-area"; 3 | import { IMAGES } from "@/data/images"; 4 | import { ADD_IMAGE, dispatcher } from "@/global"; 5 | import { nanoid } from "nanoid"; 6 | 7 | export const Images = () => { 8 | const handleAddImage = (src: string) => { 9 | dispatcher?.dispatch(ADD_IMAGE, { 10 | payload: { 11 | id: nanoid(), 12 | details: { 13 | src: src, 14 | }, 15 | }, 16 | options: { 17 | trackId: "main", 18 | }, 19 | }); 20 | }; 21 | 22 | return ( 23 |
24 |
25 | Photos 26 |
27 | 28 |
29 | {IMAGES.map((image, index) => { 30 | return ( 31 |
handleAddImage(image.src)} 33 | key={index} 34 | className="flex items-center justify-center w-full bg-zinc-950 pb-2 overflow-hidden cursor-pointer" 35 | > 36 | image 41 |
42 | ); 43 | })} 44 |
45 |
46 |
47 | ); 48 | }; 49 | 50 | function modifyImageUrl(url: string): string { 51 | const uploadIndex = url.indexOf("/upload"); 52 | if (uploadIndex === -1) { 53 | throw new Error("Invalid URL: /upload not found"); 54 | } 55 | 56 | const modifiedUrl = 57 | url.slice(0, uploadIndex + 7) + 58 | "/w_0.05,c_scale" + 59 | url.slice(uploadIndex + 7); 60 | return modifiedUrl; 61 | } 62 | -------------------------------------------------------------------------------- /src/components/editor/menu-item/index.tsx: -------------------------------------------------------------------------------- 1 | export { MenuItem } from './menu-item'; 2 | -------------------------------------------------------------------------------- /src/components/editor/menu-item/menu-item.tsx: -------------------------------------------------------------------------------- 1 | import useLayoutStore from "@/store/use-layout-store"; 2 | import { Texts } from "./texts"; 3 | import { Uploads } from "./uploads"; 4 | import { Elements } from "./elements"; 5 | import { Images } from "./images"; 6 | import { X } from "lucide-react"; 7 | import { Button } from "@/components/ui/button"; 8 | 9 | const Container = ({ children }: { children: React.ReactNode }) => { 10 | const { showMenuItem, setShowMenuItem } = useLayoutStore(); 11 | return ( 12 |
20 |
21 |
22 | 29 | {children} 30 |
31 |
32 | ); 33 | }; 34 | 35 | const ActiveMenuItem = () => { 36 | const { activeMenuItem } = useLayoutStore(); 37 | 38 | if (activeMenuItem === "texts") { 39 | return ; 40 | } 41 | if (activeMenuItem === "shapes") { 42 | return ; 43 | } 44 | 45 | if (activeMenuItem === "images") { 46 | return ; 47 | } 48 | if (activeMenuItem === "uploads") { 49 | return ; 50 | } 51 | return null; 52 | }; 53 | 54 | export const MenuItem = () => { 55 | return ( 56 | 57 | 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/editor/menu-item/texts.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { DEFAULT_FONT } from "@/data/fonts"; 3 | import { ADD_TEXT, dispatcher } from "@/global"; 4 | import { nanoid } from "nanoid"; 5 | 6 | export const Texts = () => { 7 | const handleAddText = () => { 8 | dispatcher?.dispatch(ADD_TEXT, { 9 | payload: { 10 | id: nanoid(), 11 | details: { 12 | text: "Add text", 13 | fontSize: 32, 14 | fontFamily: DEFAULT_FONT.postScriptName, 15 | fontUrl: DEFAULT_FONT.url, 16 | width: 180, 17 | textAlign: "center", 18 | fill: "#111111", 19 | }, 20 | }, 21 | options: {}, 22 | }); 23 | }; 24 | 25 | return ( 26 |
27 |
28 | Text 29 |
30 |
31 | 39 |
40 |
41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /src/components/editor/menu-item/uploads.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | ADD_AUDIO, 4 | ADD_IMAGE, 5 | ADD_TEXT, 6 | ADD_VIDEO, 7 | dispatcher, 8 | } from "@/global"; 9 | import { nanoid } from "nanoid"; 10 | import { IMAGES } from "@/data/images"; 11 | import { DEFAULT_FONT } from "@/data/fonts"; 12 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 13 | import { UploadIcon } from "lucide-react"; 14 | 15 | export const Uploads = () => { 16 | const handleAddImage = () => { 17 | dispatcher?.dispatch(ADD_IMAGE, { 18 | payload: { 19 | id: nanoid(), 20 | details: { 21 | src: IMAGES[4].src, 22 | }, 23 | }, 24 | options: { 25 | trackId: "main", 26 | }, 27 | }); 28 | }; 29 | 30 | const handleAddText = () => { 31 | dispatcher?.dispatch(ADD_TEXT, { 32 | payload: { 33 | id: nanoid(), 34 | details: { 35 | text: "Heading", 36 | fontSize: 200, 37 | width: 900, 38 | fontUrl: DEFAULT_FONT.url, 39 | fontFamily: DEFAULT_FONT.postScriptName, 40 | color: "#ffffff", 41 | WebkitTextStrokeColor: "green", 42 | WebkitTextStrokeWidth: "20px", 43 | textShadow: "30px 30px 100px rgba(255, 255, 0, 1)", 44 | wordWrap: "break-word", 45 | wordBreak: "break-all", 46 | }, 47 | }, 48 | options: {}, 49 | }); 50 | }; 51 | 52 | const handleAddAudio = () => { 53 | dispatcher?.dispatch(ADD_AUDIO, { 54 | payload: { 55 | id: nanoid(), 56 | details: { 57 | src: "https://ik.imagekit.io/snapmotion/timer-voice.mp3", 58 | }, 59 | }, 60 | options: {}, 61 | }); 62 | }; 63 | 64 | const handleAddVideo = () => { 65 | dispatcher?.dispatch(ADD_VIDEO, { 66 | payload: { 67 | id: nanoid(), 68 | details: { 69 | src: "https://ik.imagekit.io/snapmotion/75475-556034323_medium.mp4", 70 | }, 71 | metadata: { 72 | resourceId: "7415538a-5d61-4a81-ad79-c00689b6cc10", 73 | }, 74 | }, 75 | options: { 76 | trackId: "main", 77 | }, 78 | }); 79 | }; 80 | 81 | const handleAddVideo2 = () => { 82 | dispatcher?.dispatch(ADD_VIDEO, { 83 | payload: { 84 | id: nanoid(), 85 | details: { 86 | src: "https://ik.imagekit.io/snapmotion/flat.mp4", 87 | }, 88 | metadata: { 89 | resourceId: "7415538a-5do1-4m81-a279-c00689b6cc10", 90 | }, 91 | }, 92 | }); 93 | }; 94 | return ( 95 |
96 |
97 | Your media 98 |
99 |
100 |
101 | 102 | 103 | Project 104 | Workspace 105 | 106 | 107 | 115 |
116 |
117 | 118 | 125 |
Some assets
126 |
127 |
128 |
129 |
130 |
131 | ); 132 | }; 133 | -------------------------------------------------------------------------------- /src/components/editor/menu-list.tsx: -------------------------------------------------------------------------------- 1 | import useLayoutStore from "@/store/use-layout-store"; 2 | import { Icons } from "@/components/shared/icons"; 3 | import { Button } from "@/components/ui/button"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | export default function MenuList() { 7 | const { setActiveMenuItem, setShowMenuItem, activeMenuItem, showMenuItem } = 8 | useLayoutStore(); 9 | return ( 10 |
14 | 29 | 44 | 45 | 60 | 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/components/editor/navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | DESIGN_RESIZE, 4 | HISTORY_UNDO, 5 | HISTORY_REDO, 6 | dispatcher, 7 | } from "@/global"; 8 | import logoDark from "@/assets/logo-dark.png"; 9 | import { Icons } from "../shared/icons"; 10 | import { 11 | Popover, 12 | PopoverContent, 13 | PopoverTrigger, 14 | } from "@/components/ui/popover"; 15 | import { Download } from "lucide-react"; 16 | 17 | export default function Navbar() { 18 | const handleUndo = () => { 19 | dispatcher.dispatch(HISTORY_UNDO); 20 | }; 21 | 22 | const handleRedo = () => { 23 | dispatcher.dispatch(HISTORY_REDO); 24 | }; 25 | 26 | const handleExport = () => {}; 27 | const openLink = (url: string) => { 28 | window.open(url, "_blank"); // '_blank' will open the link in a new tab 29 | }; 30 | 31 | return ( 32 |
39 |
40 |
41 | logo 42 |
43 |
44 | 52 | 60 |
61 |
62 | 63 |
64 |
65 |
Untitled graphic
66 | 67 |
68 |
69 | 70 |
71 |
72 | 90 | 98 |
99 |
100 |
101 | ); 102 | } 103 | 104 | interface ResizeOptionProps { 105 | label: string; 106 | icon: string; 107 | value: ResizeValue; 108 | } 109 | interface ResizeValue { 110 | width: number; 111 | height: number; 112 | name: string; 113 | } 114 | const RESIZE_OPTIONS: ResizeOptionProps[] = [ 115 | { 116 | label: "16:9", 117 | icon: "landscape", 118 | value: { 119 | width: 1920, 120 | height: 1080, 121 | name: "16:9", 122 | }, 123 | }, 124 | { 125 | label: "9:16", 126 | icon: "portrait", 127 | value: { 128 | width: 1080, 129 | height: 1920, 130 | name: "9:16", 131 | }, 132 | }, 133 | { 134 | label: "1:1", 135 | icon: "square", 136 | value: { 137 | width: 1080, 138 | height: 1080, 139 | name: "1:1", 140 | }, 141 | }, 142 | ]; 143 | 144 | const ResizeVideo = () => { 145 | const handleResize = (payload: ResizeValue) => { 146 | dispatcher.dispatch(DESIGN_RESIZE, { 147 | payload, 148 | }); 149 | }; 150 | return ( 151 | 152 | 153 | 160 | 161 | 162 |
163 | {RESIZE_OPTIONS.map((option, index) => ( 164 | 171 | ))} 172 |
173 |
174 |
175 | ); 176 | }; 177 | 178 | const ResizeOption = ({ 179 | label, 180 | icon, 181 | value, 182 | handleResize, 183 | }: ResizeOptionProps & { handleResize: (payload: ResizeValue) => void }) => { 184 | const Icon = Icons[icon]; 185 | return ( 186 |
handleResize(value)} 188 | className="flex items-center gap-4 hover:bg-zinc-50/10 cursor-pointer" 189 | > 190 |
191 | 192 |
193 |
194 |
{label}
195 |
Tiktok, Instagram
196 |
197 |
198 | ); 199 | }; 200 | -------------------------------------------------------------------------------- /src/components/editor/resize-video.tsx: -------------------------------------------------------------------------------- 1 | const ResizeVideo = () => { 2 | return
ResizeVideo
; 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/editor/use-hotkeys.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_FONT } from "@/data/fonts"; 2 | import { 3 | ACTIVE_CLONE, 4 | ACTIVE_DELETE, 5 | ADD_TEXT, 6 | HISTORY_REDO, 7 | HISTORY_UNDO, 8 | LAYER_SELECT, 9 | PLAYER_SEEK_BY, 10 | PLAYER_TOGGLE_PLAY, 11 | dispatcher, 12 | } from "@/global"; 13 | import hotkeys from "hotkeys-js"; 14 | import { nanoid } from "nanoid"; 15 | import { useEffect } from "react"; 16 | 17 | const useHotkeys = () => { 18 | useEffect(() => { 19 | const dispatch = dispatcher.dispatch; 20 | // handle undo 21 | hotkeys("ctrl+z,command+z", (event) => { 22 | event.preventDefault(); // Prevent the default action 23 | dispatch(HISTORY_UNDO); 24 | // dispatch(UNDO); 25 | }); 26 | 27 | // handle redo: ctrl+shift+z 28 | hotkeys("ctrl+shift+z,command+shift+z", (event) => { 29 | event.preventDefault(); // Prevent the default action 30 | // redo(); 31 | dispatch(HISTORY_REDO); 32 | }); 33 | 34 | // Define the shortcut and corresponding action 35 | hotkeys("ctrl+s,command+s", (event) => { 36 | event.preventDefault(); // Prevent the default action 37 | console.log("split action"); 38 | // dispatch(ACTIVE_SPLIT); 39 | }); 40 | 41 | // duplicate item 42 | hotkeys("ctrl+d,command+d", (event) => { 43 | event.preventDefault(); // Prevent the default action 44 | dispatch(ACTIVE_CLONE); 45 | }); 46 | 47 | hotkeys("backspace,delete", (event) => { 48 | event.preventDefault(); // Prevent the default action 49 | dispatch(ACTIVE_DELETE); 50 | }); 51 | 52 | hotkeys("esc", (event) => { 53 | event.preventDefault(); // Prevent the default action 54 | dispatcher.dispatch(LAYER_SELECT, { payload: { activeIds: [] } }); 55 | }); 56 | 57 | hotkeys("space", (event) => { 58 | event.preventDefault(); 59 | dispatch(PLAYER_TOGGLE_PLAY); 60 | }); 61 | 62 | hotkeys("down", (event) => { 63 | event.preventDefault(); 64 | dispatch(PLAYER_SEEK_BY, { payload: { frames: 1 } }); 65 | }); 66 | 67 | hotkeys("up", (event) => { 68 | event.preventDefault(); 69 | dispatch(PLAYER_SEEK_BY, { payload: { frames: -1 } }); 70 | }); 71 | 72 | // New shortcut for the 'T' key 73 | hotkeys("t", (event) => { 74 | dispatcher.dispatch(ADD_TEXT, { 75 | payload: { 76 | id: nanoid(), 77 | details: { 78 | text: "Add text", 79 | fontSize: 32, 80 | fontFamily: DEFAULT_FONT.postScriptName, 81 | fontUrl: DEFAULT_FONT.url, 82 | width: 180, 83 | textAlign: "center", 84 | fill: "#111111", 85 | }, 86 | }, 87 | }); 88 | }); 89 | 90 | return () => { 91 | hotkeys.unbind("ctrl+shift+z,command+shift+z"); 92 | hotkeys.unbind("ctrl+z,command+z"); 93 | hotkeys.unbind("ctrl+s,command+s"); 94 | hotkeys.unbind("ctrl+d,command+d"); 95 | hotkeys.unbind("backspace,delete"); 96 | hotkeys.unbind("escape"); 97 | hotkeys.unbind("down"); 98 | hotkeys.unbind("up"); 99 | hotkeys.unbind("space"); 100 | hotkeys.unbind("t"); 101 | }; 102 | }, []); 103 | }; 104 | 105 | export default useHotkeys; 106 | -------------------------------------------------------------------------------- /src/components/shared/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertTriangle, 3 | ArrowRight, 4 | ArrowUpRight, 5 | BookOpen, 6 | Check, 7 | ChevronLeft, 8 | ChevronRight, 9 | Copy, 10 | CreditCard, 11 | File, 12 | FileText, 13 | FolderClosed, 14 | HelpCircle, 15 | Home, 16 | Image, 17 | Laptop, 18 | LayoutPanelLeft, 19 | LineChart, 20 | Loader2, 21 | LucideIcon, 22 | LucideProps, 23 | MessagesSquare, 24 | Moon, 25 | MoreVertical, 26 | Package, 27 | Plus, 28 | Puzzle, 29 | Search, 30 | Settings, 31 | SunMedium, 32 | Trash, 33 | Text, 34 | Type, 35 | User, 36 | X, 37 | Square, 38 | RectangleVertical, 39 | RectangleHorizontal, 40 | WandSparkles, 41 | Zap, 42 | Music, 43 | VideoIcon, 44 | } from "lucide-react"; 45 | 46 | export type Icon = LucideIcon; 47 | 48 | export const Icons = { 49 | add: Plus, 50 | audio: Music, 51 | arrowRight: ArrowRight, 52 | arrowUpRight: ArrowUpRight, 53 | billing: CreditCard, 54 | bookOpen: BookOpen, 55 | chevronLeft: ChevronLeft, 56 | chevronRight: ChevronRight, 57 | check: Check, 58 | close: X, 59 | copy: Copy, 60 | dashboard: LayoutPanelLeft, 61 | ellipsis: MoreVertical, 62 | folder: FolderClosed, 63 | gitHub: ({ ...props }: LucideProps) => ( 64 | 79 | ), 80 | google: ({ ...props }: LucideProps) => ( 81 | 96 | ), 97 | nextjs: ({ ...props }: LucideProps) => ( 98 | 113 | ), 114 | help: HelpCircle, 115 | home: Home, 116 | image: Image, 117 | landscape: RectangleHorizontal, 118 | laptop: Laptop, 119 | lineChart: LineChart, 120 | logo: Puzzle, 121 | media: Image, 122 | messages: MessagesSquare, 123 | moon: Moon, 124 | package: Package, 125 | page: File, 126 | portrait: RectangleVertical, 127 | post: FileText, 128 | preset: Zap, 129 | search: Search, 130 | square: Square, 131 | redo: ({ ...props }: LucideProps) => ( 132 | 138 | 144 | 145 | ), 146 | shapes: ({ ...props }: LucideProps) => ( 147 | 153 | 154 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | ), 166 | settings: Settings, 167 | smart: WandSparkles, 168 | spinner: Loader2, 169 | sun: SunMedium, 170 | templates: ({ ...props }: LucideProps) => ( 171 | 177 | 178 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | ), 190 | text: Type, 191 | textbox: Type, 192 | 193 | trash: Trash, 194 | twitter: ({ ...props }: LucideProps) => ( 195 | 210 | ), 211 | type: Type, 212 | undo: ({ ...props }: LucideProps) => ( 213 | 219 | 225 | 226 | ), 227 | upload: ({ ...props }: LucideProps) => ( 228 | 234 | 235 | 239 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | ), 251 | user: User, 252 | video: VideoIcon, 253 | warning: AlertTriangle, 254 | }; 255 | -------------------------------------------------------------------------------- /src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from 'react'; 2 | 3 | type Theme = 'dark' | 'light' | 'system'; 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode; 7 | defaultTheme?: Theme; 8 | storageKey?: string; 9 | }; 10 | 11 | type ThemeProviderState = { 12 | theme: Theme; 13 | setTheme: (theme: Theme) => void; 14 | }; 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: 'system', 18 | setTheme: () => null, 19 | }; 20 | 21 | const ThemeProviderContext = createContext(initialState); 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = 'system', 26 | storageKey = 'vite-ui-theme', 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, 31 | ); 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement; 35 | 36 | root.classList.remove('light', 'dark'); 37 | 38 | if (theme === 'system') { 39 | const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') 40 | .matches 41 | ? 'dark' 42 | : 'light'; 43 | 44 | root.classList.add(systemTheme); 45 | return; 46 | } 47 | 48 | root.classList.add(theme); 49 | }, [theme]); 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme); 55 | setTheme(theme); 56 | }, 57 | }; 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ); 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext); 68 | 69 | if (context === undefined) 70 | throw new Error('useTheme must be used within a ThemeProvider'); 71 | 72 | return context; 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Avatar = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | )) 19 | Avatar.displayName = AvatarPrimitive.Root.displayName 20 | 21 | const AvatarImage = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, ...props }, ref) => ( 25 | 30 | )) 31 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 32 | 33 | const AvatarFallback = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | )) 46 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 47 | 48 | export { Avatar, AvatarImage, AvatarFallback } 49 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 'bg-primary text-primary-foreground hover:bg-primary/90', 13 | destructive: 14 | 'bg-destructive text-destructive-foreground hover:bg-destructive/90', 15 | outline: 16 | 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', 17 | secondary: 18 | 'bg-secondary text-secondary-foreground hover:bg-secondary/80', 19 | ghost: 'hover:bg-accent hover:text-accent-foreground', 20 | link: 'text-primary underline-offset-4 hover:underline', 21 | }, 22 | size: { 23 | default: 'h-10 px-4 py-2', 24 | xs: 'h-8 rounded-md px-3', 25 | sm: 'h-9 rounded-md px-3', 26 | lg: 'h-11 rounded-md px-8', 27 | icon: 'h-10 w-10', 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default', 33 | }, 34 | }, 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button'; 46 | return ( 47 | 52 | ); 53 | }, 54 | ); 55 | Button.displayName = 'Button'; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { type DialogProps } from "@radix-ui/react-dialog" 3 | import { Command as CommandPrimitive } from "cmdk" 4 | import { Search } from "lucide-react" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Dialog, DialogContent } from "@/components/ui/dialog" 8 | 9 | const Command = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | )) 22 | Command.displayName = CommandPrimitive.displayName 23 | 24 | interface CommandDialogProps extends DialogProps {} 25 | 26 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { X } from "lucide-react" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogClose, 114 | DialogTrigger, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /src/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@/lib/utils" 14 | import { Label } from "@/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |