diff --git a/apps/core/app/test/page.tsx b/apps/core/app/test/page.tsx
index 4f20deba..9587074d 100644
--- a/apps/core/app/test/page.tsx
+++ b/apps/core/app/test/page.tsx
@@ -1,11 +1,19 @@
"use client";
-import React from "react";
-import { FileFormatField } from "../dashboard/products/create/_components/file-formate-field";
-import { FileFormatSection } from "../dashboard/products/create/_components/file-formate-section";
+import { RichTextEditor } from "@repo/ui/components/editor/editor";
const Page = () => {
- return ;
+ return (
+
+ console.log(html, text)}
+ defaultValue="hello"
+ value="salam"
+ label="badry"
+ helperText="ali"
+ />
+
+ );
};
export default Page;
diff --git a/apps/storybook/src/stories/editor.stories.tsx b/apps/storybook/src/stories/editor.stories.tsx
new file mode 100644
index 00000000..a7d36e42
--- /dev/null
+++ b/apps/storybook/src/stories/editor.stories.tsx
@@ -0,0 +1,47 @@
+import { RichTextEditor } from "@repo/ui/components/editor/editor";
+import type { Meta, StoryObj } from "@storybook/react";
+
+const meta: Meta = {
+ title: "Components/RichTextEditor",
+ component: RichTextEditor,
+ tags: ["autodocs"],
+ argTypes: {
+ value: { control: "text" },
+ defaultValue: { control: "text" },
+ onChange: { action: "onChange" },
+ className: { control: "text" },
+ label: { control: "text" },
+ helperText: { control: "text" },
+ error: { control: "text" },
+ id: { control: "text" },
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ label: "Rich Text Editor",
+ helperText: "You can enter rich text here.",
+ defaultValue: "Initial content
",
+ },
+};
+
+export const WithError: Story = {
+ args: {
+ label: "Rich Text Editor",
+ helperText: "This editor has an error.",
+ defaultValue: "Initial content with error
",
+ error: "This is an error message.",
+ },
+};
+
+export const CustomClass: Story = {
+ args: {
+ label: "Rich Text Editor",
+ helperText: "This editor has a custom class.",
+ defaultValue: "Initial content with custom class
",
+ className: "custom-editor-class",
+ },
+};
diff --git a/bun.lockb b/bun.lockb
index a60456c8..9a631ef4 100755
Binary files a/bun.lockb and b/bun.lockb differ
diff --git a/package.json b/package.json
index 7a8d754e..06a19cde 100644
--- a/package.json
+++ b/package.json
@@ -1,8 +1,19 @@
{
"name": "turborepo-shadcn-ui",
"version": "1.3.0",
- "private": true,
+ "devDependencies": {
+ "@repo/eslint-config": "*",
+ "@repo/typescript-config": "*",
+ "husky": "^9.1.7",
+ "prettier": "^3.3.2",
+ "turbo": "2.0.6"
+ },
+ "engines": {
+ "node": ">=18"
+ },
"license": "MIT",
+ "packageManager": "bun@1.1.29",
+ "private": true,
"scripts": {
"build": "turbo build",
"type-checks": "turbo build check-types",
@@ -17,17 +28,6 @@
"deploy": "git pull origin main && git push vercel main",
"rebase:main": "git fetch origin main && git rebase main"
},
- "devDependencies": {
- "@repo/eslint-config": "*",
- "@repo/typescript-config": "*",
- "husky": "^9.1.7",
- "prettier": "^3.3.2",
- "turbo": "2.0.6"
- },
- "packageManager": "bun@1.1.29",
- "engines": {
- "node": ">=18"
- },
"workspaces": [
"apps/*",
"packages/*"
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 91a5171c..8868160a 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -24,8 +24,6 @@
"typescript": "^5.4.5"
},
"dependencies": {
- "@repo/design-system": "*",
- "@repo/icons": "*",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-accordion": "^1.2.0",
"@radix-ui/react-alert-dialog": "^1.1.1",
@@ -51,8 +49,23 @@
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.0",
-
+ "@repo/design-system": "*",
+ "@repo/icons": "*",
"@tabler/icons-react": "^3.12.0",
+ "@tailwindcss/typography": "^0.5.16",
+ "@tiptap/extension-code": "^2.11.7",
+ "@tiptap/extension-code-block": "^2.11.7",
+ "@tiptap/extension-color": "^2.11.7",
+ "@tiptap/extension-highlight": "^2.11.5",
+ "@tiptap/extension-image": "^2.11.5",
+ "@tiptap/extension-link": "^2.11.5",
+ "@tiptap/extension-list-item": "^2.11.7",
+ "@tiptap/extension-ordered-list": "^2.11.7",
+ "@tiptap/extension-text-align": "^2.11.5",
+ "@tiptap/extension-text-style": "^2.11.7",
+ "@tiptap/extension-underline": "^2.11.7",
+ "@tiptap/react": "^2.11.5",
+ "@tiptap/starter-kit": "^2.11.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
diff --git a/packages/ui/src/components/atoms/command.tsx b/packages/ui/src/components/atoms/command.tsx
index fe211d38..061f4ffa 100644
--- a/packages/ui/src/components/atoms/command.tsx
+++ b/packages/ui/src/components/atoms/command.tsx
@@ -1,12 +1,12 @@
"use client"
import * as React from "react"
-import { type DialogProps } from "@radix-ui/react-dialog"
+
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@repo/ui/lib/utils"
-import { Dialog, DialogContent } from "@repo/ui/components/ui/dialog"
+
const Command = React.forwardRef<
React.ElementRef,
diff --git a/packages/ui/src/components/atoms/rich-text-style-provider.tsx b/packages/ui/src/components/atoms/rich-text-style-provider.tsx
new file mode 100644
index 00000000..8d71640b
--- /dev/null
+++ b/packages/ui/src/components/atoms/rich-text-style-provider.tsx
@@ -0,0 +1,10 @@
+import { PropsWithChildren } from "react";
+
+export const RichTextStylesProvider = ({ children }: PropsWithChildren) => {
+ return (
+ // INFO: "prose" class has max-width and we should remove it by max-w-none
+
+ {children}
+
+ );
+};
diff --git a/packages/ui/src/components/atoms/textarea.tsx b/packages/ui/src/components/atoms/textarea.tsx
index 2f33e297..782f2d83 100644
--- a/packages/ui/src/components/atoms/textarea.tsx
+++ b/packages/ui/src/components/atoms/textarea.tsx
@@ -1,6 +1,6 @@
-import * as React from "react"
+import * as React from "react";
-import { cn } from "@repo/ui/lib/utils"
+import { cn } from "@repo/ui/lib/utils";
export interface TextareaProps
extends React.TextareaHTMLAttributes {}
@@ -11,14 +11,14 @@ const Textarea = React.forwardRef(
- )
- }
-)
-Textarea.displayName = "Textarea"
+ );
+ },
+);
+Textarea.displayName = "Textarea";
-export { Textarea }
+export { Textarea };
diff --git a/packages/ui/src/components/molecules/editor/editor-toolbar-button.tsx b/packages/ui/src/components/molecules/editor/editor-toolbar-button.tsx
new file mode 100644
index 00000000..1dbeca0b
--- /dev/null
+++ b/packages/ui/src/components/molecules/editor/editor-toolbar-button.tsx
@@ -0,0 +1,26 @@
+export const EditorToolbarButton = ({
+ onClick,
+ isActive,
+ icon: Icon,
+ label,
+ disabled,
+}: {
+ onClick: () => void;
+ isActive: boolean;
+ icon: any;
+ label: string;
+ disabled?: boolean;
+}) => (
+
+);
diff --git a/packages/ui/src/components/molecules/editor/editor-toolbar-image.tsx b/packages/ui/src/components/molecules/editor/editor-toolbar-image.tsx
new file mode 100644
index 00000000..2d900df5
--- /dev/null
+++ b/packages/ui/src/components/molecules/editor/editor-toolbar-image.tsx
@@ -0,0 +1,61 @@
+/* eslint-disable no-restricted-imports */
+import { useMutation } from '@tanstack/react-query';
+import axios from 'axios';
+import { useRef } from 'react';
+import { EditorToolbarButton } from './editor-toolbar-button';
+import { ImageIcon } from 'lucide-react';
+import { ToolbarProps } from './editor-toolbar';
+import { toast } from 'sonner';
+
+const useUploadImage = (editor: any) => {
+ return useMutation({
+ mutationFn: async (file: File) => {
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const response = await axios.post('/api/upload', formData, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ });
+
+ return response.data; // Returns { imageUrl }
+ },
+ onSuccess: (data) => {
+ if (data.imageUrl) {
+ editor?.chain().focus().setImage({ src: data.imageUrl }).run();
+ }
+ },
+ onError: (error) => {
+ toast.error(error.message ?? "Can't upload image");
+ }
+ });
+};
+
+export const EditorToolbarImage = ({
+ editor,
+ showHtml
+}: Pick) => {
+ const fileInputRef = useRef(null);
+
+ const { mutate: uploadImage } = useUploadImage(editor);
+
+ return (
+
+ fileInputRef.current?.click()}
+ isActive={false}
+ icon={ImageIcon}
+ label="Image"
+ />
+ {
+ if (e.target.files?.[0]) uploadImage(e.target.files[0]);
+ }}
+ />
+
+ );
+};
diff --git a/packages/ui/src/components/molecules/editor/editor-toolbar-view-html.tsx b/packages/ui/src/components/molecules/editor/editor-toolbar-view-html.tsx
new file mode 100644
index 00000000..28fe45a8
--- /dev/null
+++ b/packages/ui/src/components/molecules/editor/editor-toolbar-view-html.tsx
@@ -0,0 +1,27 @@
+import { Eye } from 'lucide-react';
+import { ToolbarProps } from './editor-toolbar';
+import { EditorToolbarButton } from './editor-toolbar-button';
+
+export const EditorToolbarViewHtml = ({
+ editor,
+ showHtml,
+ setShowHtml,
+ setHtmlContent
+}: ToolbarProps) => {
+ const toggleHtmlView = () => {
+ if (!editor) return;
+ setShowHtml((prev) => !prev);
+ if (!showHtml) {
+ setHtmlContent(editor.getHTML()); // Get latest HTML before switching
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/packages/ui/src/components/molecules/editor/editor-toolbar-view-set-link.tsx b/packages/ui/src/components/molecules/editor/editor-toolbar-view-set-link.tsx
new file mode 100644
index 00000000..bdab52bc
--- /dev/null
+++ b/packages/ui/src/components/molecules/editor/editor-toolbar-view-set-link.tsx
@@ -0,0 +1,47 @@
+import { useState } from "react";
+import { Link2 } from "lucide-react";
+import { EditorToolbarButton } from "./editor-toolbar-button";
+import { ToolbarProps } from "./editor-toolbar";
+import { Popover, PopoverTrigger, PopoverContent } from "./../../atoms/popover";
+import { Input } from "./../input";
+import { Button } from "./../../atoms/button";
+
+export const EditorToolbarSetLink = ({
+ editor,
+ showHtml,
+}: Pick) => {
+ const [url, setUrl] = useState("");
+ const [open, setOpen] = useState(false);
+
+ const onSubmit = () => {
+ if (!editor || !url) return;
+ editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
+ setOpen(false);
+ setUrl("");
+ };
+
+ return (
+
+
+ setOpen(true)}
+ disabled={showHtml}
+ isActive={false}
+ icon={Link2}
+ label="Set Link"
+ />
+
+
+ setUrl(e.target.value)}
+ />
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/molecules/editor/editor-toolbar.tsx b/packages/ui/src/components/molecules/editor/editor-toolbar.tsx
new file mode 100644
index 00000000..cb08e8c5
--- /dev/null
+++ b/packages/ui/src/components/molecules/editor/editor-toolbar.tsx
@@ -0,0 +1,148 @@
+import { Editor } from "@tiptap/react";
+
+import {
+ Bold,
+ Italic,
+ Link2Off,
+ List,
+ ListOrdered,
+ Quote,
+ Redo,
+ Strikethrough,
+ Underline,
+ Undo,
+ Heading1,
+ Heading2,
+ Heading3,
+ Heading4,
+ Heading6,
+ Heading5,
+ ChevronsLeftRightEllipsis,
+ Code,
+} from "lucide-react";
+import { Dispatch, SetStateAction } from "react";
+import { Separator } from "./../../atoms/separator";
+import { EditorToolbarButton } from "./editor-toolbar-button";
+import { EditorToolbarImage } from "./editor-toolbar-image";
+import { EditorToolbarViewHtml } from "./editor-toolbar-view-html";
+import { EditorToolbarSetLink } from "./editor-toolbar-view-set-link";
+
+export interface ToolbarProps {
+ editor: Editor | null;
+ showHtml: boolean;
+ setShowHtml: Dispatch>;
+ setHtmlContent: Dispatch>;
+}
+
+export const EditorToolbar = ({
+ editor,
+ setShowHtml,
+ showHtml,
+ setHtmlContent,
+}: ToolbarProps) => {
+ if (!editor) return null;
+
+ return (
+
+ {/* Typography */}
+ editor.chain().focus().undo().run()}
+ isActive={false}
+ icon={Undo}
+ label="Undo"
+ />
+ editor.chain().focus().redo().run()}
+ isActive={false}
+ icon={Redo}
+ label="Redo"
+ />
+
+ {/* to do normal text */}
+
+ editor.chain().focus().toggleHeading({ level: 2 }).run()}
+ isActive={editor.isActive("heading2")}
+ icon={Heading2}
+ label="heading 2"
+ />
+ editor.chain().focus().toggleHeading({ level: 3 }).run()}
+ isActive={editor.isActive("heading3")}
+ icon={Heading3}
+ label="heading 3"
+ />
+ editor.chain().focus().toggleHeading({ level: 4 }).run()}
+ isActive={editor.isActive("heading4")}
+ icon={Heading4}
+ label="heading 4"
+ />
+ {/* editor.chain().focus().toggleHeading({ level: 5 }).run()}
+ isActive={editor.isActive("heading5")}
+ icon={Heading5}
+ label="heading 5"
+ />
+ editor.chain().focus().toggleHeading({ level: 6 }).run()}
+ isActive={editor.isActive("heading6")}
+ icon={Heading6}
+ label="heading 5"
+ /> */}
+
+ editor.chain().focus().toggleBold().run()}
+ isActive={editor.isActive("bold")}
+ icon={Bold}
+ label="Bold"
+ />
+ editor.chain().focus().toggleItalic().run()}
+ isActive={editor.isActive("italic")}
+ icon={Italic}
+ label="Italic"
+ />
+
+ editor.chain().focus().toggleStrike().run()}
+ isActive={editor.isActive("strike")}
+ icon={Strikethrough}
+ label="Strike"
+ />
+ {/* to do code text */}
+ editor.chain().focus().toggleCodeBlock().run()}
+ isActive={editor.isActive("codeBlock")}
+ icon={ChevronsLeftRightEllipsis}
+ label="Strike"
+ />
+
+ editor.chain().focus().toggleOrderedList().run()}
+ isActive={editor.isActive("orderedList")}
+ icon={ListOrdered}
+ label="Ordered List"
+ />
+ editor.chain().focus().toggleCode().run()}
+ isActive={editor.isActive("code")}
+ icon={Code}
+ label="Code"
+ />
+
+ );
+};
diff --git a/packages/ui/src/components/molecules/editor/editor.tsx b/packages/ui/src/components/molecules/editor/editor.tsx
new file mode 100644
index 00000000..abc228b2
--- /dev/null
+++ b/packages/ui/src/components/molecules/editor/editor.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { cn } from "../../../lib/utils";
+import { Textarea } from "../../atoms/textarea";
+
+import { RichTextStylesProvider } from "@repo/ui/components/rich-text-style-provider";
+import Image from "@tiptap/extension-image";
+import Link from "@tiptap/extension-link";
+import TextAlign from "@tiptap/extension-text-align";
+import {
+ // BubbleMenu,
+ EditorContent,
+ // FloatingMenu,
+ useEditor,
+} from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import { cva } from "class-variance-authority";
+import { LabelWraper } from "../label-wrapper";
+import { EditorToolbar } from "./editor-toolbar";
+// import { RichTextStylesProvider } from "@/components/providers/rich-text-style-provider";
+
+interface RichTextEditorProps {
+ value?: string;
+ defaultValue?: string;
+ // eslint-disable-next-line no-unused-vars
+ onChange?: (value: string, text: string) => void;
+ className?: string;
+ label?: React.ReactNode;
+ helperText?: React.ReactNode;
+ error?: string;
+ id?: string;
+}
+export const baseEditorInputVariants = cva(
+ " pt-4 w-full bg-card rounded-md border border-border ring-offset-background file:border-0 file:bg-transparent transition-shadow duration-500 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 text-sm file:text-sm placeholder:text-muted-foreground font-normal file:font-medium focus:ring-2 focus:ring-primary focus:ring-offset-2",
+ {
+ variants: {
+ error: {
+ true: "ring-2 ring-error focus-visible:ring-error focus-visible:ring-offset-2",
+ },
+ },
+ },
+);
+export const RichTextEditor = ({
+ value,
+ defaultValue,
+ onChange,
+ className,
+ label,
+ helperText,
+ error,
+ id,
+}: RichTextEditorProps) => {
+ const [showHtml, setShowHtml] = useState(false);
+ const [htmlContent, setHtmlContent] = useState("");
+
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
+ // Highlight,
+ Image,
+ Link,
+ ],
+ content: value || defaultValue || "",
+
+ onUpdate: ({ editor }) => {
+ const html = editor.getHTML();
+ const text = editor.getText();
+
+ if (text.trim() === "") {
+ onChange?.("", "");
+ } else {
+ onChange?.(html, text);
+ }
+ },
+ });
+
+ const updateEditorFromHtml = () => {
+ if (editor && showHtml) {
+ editor.commands.setContent(htmlContent, false);
+ }
+ };
+
+ useEffect(() => {
+ if (editor && value !== undefined && value !== editor.getHTML()) {
+ editor.commands.setContent(value);
+ }
+ }, [value, editor]);
+
+ return (
+
+
+ {/* Toolbar */}
+
+ {/* Editor */}
+
+
+
+
+ {showHtml ? (
+
+
+
+
+
+ );
+};
diff --git a/packages/ui/src/components/molecules/editor/rich-text-styles-provider.tsx b/packages/ui/src/components/molecules/editor/rich-text-styles-provider.tsx
new file mode 100644
index 00000000..e03b1a89
--- /dev/null
+++ b/packages/ui/src/components/molecules/editor/rich-text-styles-provider.tsx
@@ -0,0 +1,6 @@
+import { PropsWithChildren } from "react";
+import { cn } from "~/lib/utils";
+
+export const RichTextStylesProvider = ({ children }: PropsWithChildren) => {
+ return {children}
;
+};
diff --git a/packages/ui/src/styles/globals.scss b/packages/ui/src/styles/globals.scss
index eb4437cb..773532f0 100644
--- a/packages/ui/src/styles/globals.scss
+++ b/packages/ui/src/styles/globals.scss
@@ -230,3 +230,12 @@
scrollbar-width: none; /* Firefox */
}
}
+
+/* Tip Tap Editor */
+.ProseMirror:focus-visible {
+ outline: none !important;
+}
+
+img.ProseMirror-selectednode {
+ outline: 2px solid hsl(var(--c1-400));
+}
diff --git a/packages/ui/tailwind.config.ts b/packages/ui/tailwind.config.ts
index a34d2c02..e616d034 100644
--- a/packages/ui/tailwind.config.ts
+++ b/packages/ui/tailwind.config.ts
@@ -186,6 +186,7 @@ const config = {
tailwindcssAnimate,
require("tailwind-scrollbar"),
require("tailwind-scrollbar-hide"),
+ require("@tailwindcss/typography"),
],
} satisfies Config;