refact: email sending editor

This commit is contained in:
oiov
2025-10-20 11:34:05 +08:00
parent 349a91cd0c
commit 7e5c0d9de6
149 changed files with 13678 additions and 213 deletions

View File

@@ -3,7 +3,7 @@ import { allPages } from "contentlayer/generated";
import { Mdx } from "@/components/content/mdx-components";
import "@/styles/mdx.css";
import "@/styles/mdx.scss";
import { Metadata } from "next";

View File

@@ -1,4 +1,4 @@
import "@/styles/globals.css";
import "@/styles/globals.scss";
import { fontHeading, fontSans, fontSatoshi } from "@/assets/fonts";
import { SessionProvider } from "next-auth/react";

View File

@@ -0,0 +1,142 @@
import * as React from "react";
// --- Lib ---
import { Highlight } from "@tiptap/extension-highlight";
import { Image } from "@tiptap/extension-image";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import { Subscript } from "@tiptap/extension-subscript";
import { Superscript } from "@tiptap/extension-superscript";
import { TextAlign } from "@tiptap/extension-text-align";
import { Typography } from "@tiptap/extension-typography";
import { Selection } from "@tiptap/extensions";
import { EditorContent, EditorContext, useEditor } from "@tiptap/react";
// --- Tiptap Core Extensions ---
import { StarterKit } from "@tiptap/starter-kit";
import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils";
import { useCursorVisibility } from "@/hooks/use-cursor-visibility";
// --- Hooks ---
import { useIsMobile } from "@/hooks/use-mobile";
import { useWindowSize } from "@/hooks/use-window-size";
import { HorizontalRule } from "@/components/shared/tiptap/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension";
// --- Tiptap Node ---
import { ImageUploadNode } from "@/components/shared/tiptap/tiptap-node/image-upload-node/image-upload-node-extension";
// import content from "@/components/shared/tiptap/tiptap-templates/simple/data/content.json";
import { Toolbar } from "@/components/shared/tiptap/tiptap-ui-primitive/toolbar";
import { Icons } from "../shared/icons";
import {
MainToolbarContent,
MobileToolbarContent,
} from "../shared/tiptap/tiptap-templates/simple/simple-editor";
export function EmailEditor({
onGetEditorValue,
}: {
onGetEditorValue: (html: string, text: string) => void;
}) {
const isMobile = useIsMobile();
const { height } = useWindowSize();
const [mobileView, setMobileView] = React.useState<
"main" | "highlighter" | "link"
>("main");
const toolbarRef = React.useRef<HTMLDivElement>(null);
const editor = useEditor({
immediatelyRender: false,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
autocomplete: "off",
autocorrect: "off",
autocapitalize: "off",
"aria-label": "Main content area, start typing to enter text.",
class: "simple-editor",
},
},
extensions: [
StarterKit.configure({
horizontalRule: false,
link: {
openOnClick: false,
enableClickSelection: true,
},
}),
HorizontalRule,
TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList,
TaskItem.configure({ nested: true }),
Highlight.configure({ multicolor: true }),
Image,
Typography,
Superscript,
Subscript,
Selection,
ImageUploadNode.configure({
accept: "image/*",
maxSize: MAX_FILE_SIZE,
limit: 3,
upload: handleImageUpload,
onError: (error) => console.error("Upload failed:", error),
}),
],
content: "Hi",
onUpdate: ({ editor }) => {
const html = editor.getHTML();
const text = editor.getText();
onGetEditorValue(html, text);
},
});
const rect = useCursorVisibility({
editor,
overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0,
});
React.useEffect(() => {
if (!isMobile && mobileView !== "main") {
setMobileView("main");
}
}, [isMobile, mobileView]);
editor?.getHTML();
return (
<div className="simple-editor-wrapper">
<EditorContext.Provider value={{ editor }}>
<Toolbar
ref={toolbarRef}
style={{
...(isMobile
? {
bottom: `calc(100% - ${height - rect.y}px)`,
}
: {}),
}}
>
{mobileView === "main" ? (
<MainToolbarContent
onHighlighterClick={() => setMobileView("highlighter")}
onLinkClick={() => setMobileView("link")}
isMobile={isMobile}
/>
) : (
<MobileToolbarContent
type={mobileView === "highlighter" ? "highlighter" : "link"}
onBack={() => setMobileView("main")}
/>
)}
<button
className="ml-2"
onClick={() => editor?.chain().clearContent().run()}
>
<Icons.eraser className="size-4 hover:text-red-500" />
</button>
</Toolbar>
<EditorContent
editor={editor}
role="presentation"
className="simple-editor-content"
/>
</EditorContext.Provider>
</div>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useTransition } from "react";
import dynamic from "next/dynamic";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { Icons } from "../shared/icons";
@@ -10,17 +10,11 @@ import {
Drawer,
DrawerClose,
DrawerContent,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from "../ui/drawer";
import { Input } from "../ui/input";
import "react-quill/dist/quill.snow.css";
import { useTranslations } from "next-intl";
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
import { EmailEditor } from "./EmailEditor";
interface SendEmailModalProps {
className?: string;
@@ -36,7 +30,12 @@ export function SendEmailModal({
onSuccess,
}: SendEmailModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [sendForm, setSendForm] = useState({ to: "", subject: "", html: "" });
const [sendForm, setSendForm] = useState({
to: "",
subject: "",
html: "",
text: "",
});
const [isPending, startTransition] = useTransition();
const t = useTranslations("Email");
@@ -46,8 +45,10 @@ export function SendEmailModal({
toast.error("No email address selected");
return;
}
console.log("sendForm", sendForm);
// return;
if (!sendForm.to || !sendForm.subject || !sendForm.html) {
toast.error("Please fill in all fields");
toast.error("Please fill in all required fields");
return;
}
@@ -60,13 +61,14 @@ export function SendEmailModal({
to: sendForm.to,
subject: sendForm.subject,
html: sendForm.html,
text: sendForm.text,
}),
});
if (response.ok) {
toast.success("Email sent successfully");
setIsOpen(false);
setSendForm({ to: "", subject: "", html: "" });
setSendForm({ to: "", subject: "", html: "", text: "" });
onSuccess?.();
} else {
toast.error("Failed to send email", {
@@ -94,28 +96,62 @@ export function SendEmailModal({
</Button>
)}
<Drawer open={isOpen} direction="right" onOpenChange={setIsOpen}>
<DrawerContent className="fixed bottom-0 right-0 top-0 w-full rounded-none sm:max-w-xl">
<Drawer
handleOnly
open={isOpen}
direction="right"
onOpenChange={setIsOpen}
>
<DrawerContent className="fixed bottom-0 right-0 top-0 w-full rounded-none sm:max-w-5xl">
<DrawerHeader>
<DrawerTitle className="flex items-center gap-1">
{t("Send Email")}{" "}
<Icons.help className="size-5 text-neutral-600 hover:text-neutral-400" />
</DrawerTitle>
{/* <Icons.help className="size-5 text-neutral-600 hover:text-neutral-400" /> */}
<Button
className="ml-auto"
onClick={handleSendEmail}
disabled={isPending}
variant="ghost"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<Icons.send className="size-4 text-blue-600" />
)}
</Button>
<DrawerClose asChild>
<Button variant="ghost" className="absolute right-4 top-4">
<Icons.close className="h-4 w-4" />
<Button variant="ghost">
<Icons.close className="size-5" />
</Button>
</DrawerClose>
</DrawerTitle>
</DrawerHeader>
<div className="scrollbar-hidden h-[calc(100vh)] space-y-4 overflow-y-auto p-6">
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
<div className="scrollbar-hidden h-[calc(100vh)] space-y-1 overflow-y-auto px-4 pb-4">
<div className="flex items-center justify-between border-b">
<label className="text-nowrap text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("Subject")}
</label>
<Input
value={sendForm.subject}
onChange={(e) =>
setSendForm({ ...sendForm, subject: e.target.value })
}
placeholder="Your subject"
className="border-none"
/>
</div>
<div className="flex items-center justify-between border-b">
<label className="text-nowrap text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("From")}
</label>
<Input value={emailAddress || ""} disabled className="mt-1" />
<Input
value={emailAddress || ""}
disabled
className="border-none"
/>
</div>
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
<div className="flex items-center justify-between border-b">
<label className="text-nowrap text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("To")}
</label>
<Input
@@ -124,49 +160,17 @@ export function SendEmailModal({
setSendForm({ ...sendForm, to: e.target.value })
}
placeholder="recipient@example.com"
className="mt-1"
className="border-none"
/>
</div>
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("Subject")}
</label>
<Input
value={sendForm.subject}
onChange={(e) =>
setSendForm({ ...sendForm, subject: e.target.value })
<EmailEditor
onGetEditorValue={(e, t) =>
setSendForm({ ...sendForm, html: e, text: t })
}
placeholder="Enter subject"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t("Content")}
</label>
<ReactQuill
value={sendForm.html}
onChange={(value) => setSendForm({ ...sendForm, html: value })}
className="mt-1 h-40 rounded-lg"
theme="snow"
placeholder="Enter your message"
/>
</div>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline" disabled={isPending}>
{t("Cancel")}
</Button>
</DrawerClose>
<Button
onClick={handleSendEmail}
disabled={isPending}
variant="default"
>
{isPending ? t("Sending") : t("Send")}
</Button>
</DrawerFooter>
</DrawerContent>
</Drawer>
</>

View File

@@ -26,6 +26,7 @@ import {
DatabaseZap,
Download,
EllipsisVertical,
Eraser,
Eye,
File,
FileText,
@@ -108,6 +109,7 @@ export const Icons = {
camera: Camera,
calendar: Calendar,
crown: Crown,
eraser: Eraser,
puzzle: Puzzle,
hand: Hand,
scanQrCode: ScanQrCode,

View File

@@ -0,0 +1,38 @@
import * as React from "react"
export const AlignCenterIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 6C2 5.44772 2.44772 5 3 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H3C2.44772 7 2 6.55228 2 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 12C6 11.4477 6.44772 11 7 11H17C17.5523 11 18 11.4477 18 12C18 12.5523 17.5523 13 17 13H7C6.44772 13 6 12.5523 6 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 18C4 17.4477 4.44772 17 5 17H19C19.5523 17 20 17.4477 20 18C20 18.5523 19.5523 19 19 19H5C4.44772 19 4 18.5523 4 18Z"
fill="currentColor"
/>
</svg>
)
}
)
AlignCenterIcon.displayName = "AlignCenterIcon"

View File

@@ -0,0 +1,38 @@
import * as React from "react"
export const AlignJustifyIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 6C2 5.44772 2.44772 5 3 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H3C2.44772 7 2 6.55228 2 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 11.4477 2.44772 11 3 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H3C2.44772 13 2 12.5523 2 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 18C2 17.4477 2.44772 17 3 17H21C21.5523 17 22 17.4477 22 18C22 18.5523 21.5523 19 21 19H3C2.44772 19 2 18.5523 2 18Z"
fill="currentColor"
/>
</svg>
)
}
)
AlignJustifyIcon.displayName = "AlignJustifyIcon"

View File

@@ -0,0 +1,38 @@
import * as React from "react"
export const AlignLeftIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 6C2 5.44772 2.44772 5 3 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H3C2.44772 7 2 6.55228 2 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 11.4477 2.44772 11 3 11H15C15.5523 11 16 11.4477 16 12C16 12.5523 15.5523 13 15 13H3C2.44772 13 2 12.5523 2 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 18C2 17.4477 2.44772 17 3 17H17C17.5523 17 18 17.4477 18 18C18 18.5523 17.5523 19 17 19H3C2.44772 19 2 18.5523 2 18Z"
fill="currentColor"
/>
</svg>
)
}
)
AlignLeftIcon.displayName = "AlignLeftIcon"

View File

@@ -0,0 +1,38 @@
import * as React from "react"
export const AlignRightIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 6C2 5.44772 2.44772 5 3 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H3C2.44772 7 2 6.55228 2 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 12C8 11.4477 8.44772 11 9 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H9C8.44772 13 8 12.5523 8 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 18C6 17.4477 6.44772 17 7 17H21C21.5523 17 22 17.4477 22 18C22 18.5523 21.5523 19 21 19H7C6.44772 19 6 18.5523 6 18Z"
fill="currentColor"
/>
</svg>
)
}
)
AlignRightIcon.displayName = "AlignRightIcon"

View File

@@ -0,0 +1,24 @@
import * as React from "react"
export const ArrowLeftIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12.7071 5.70711C13.0976 5.31658 13.0976 4.68342 12.7071 4.29289C12.3166 3.90237 11.6834 3.90237 11.2929 4.29289L4.29289 11.2929C3.90237 11.6834 3.90237 12.3166 4.29289 12.7071L11.2929 19.7071C11.6834 20.0976 12.3166 20.0976 12.7071 19.7071C13.0976 19.3166 13.0976 18.6834 12.7071 18.2929L7.41421 13L19 13C19.5523 13 20 12.5523 20 12C20 11.4477 19.5523 11 19 11L7.41421 11L12.7071 5.70711Z"
fill="currentColor"
/>
</svg>
)
}
)
ArrowLeftIcon.displayName = "ArrowLeftIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const BanIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.43471 4.01458C4.34773 4.06032 4.26607 4.11977 4.19292 4.19292C4.11977 4.26607 4.06032 4.34773 4.01458 4.43471C2.14611 6.40628 1 9.0693 1 12C1 18.0751 5.92487 23 12 23C14.9306 23 17.5936 21.854 19.5651 19.9856C19.6522 19.9398 19.7339 19.8803 19.8071 19.8071C19.8803 19.7339 19.9398 19.6522 19.9856 19.5651C21.854 17.5936 23 14.9306 23 12C23 5.92487 18.0751 1 12 1C9.0693 1 6.40628 2.14611 4.43471 4.01458ZM6.38231 4.9681C7.92199 3.73647 9.87499 3 12 3C16.9706 3 21 7.02944 21 12C21 14.125 20.2635 16.078 19.0319 17.6177L6.38231 4.9681ZM17.6177 19.0319C16.078 20.2635 14.125 21 12 21C7.02944 21 3 16.9706 3 12C3 9.87499 3.73647 7.92199 4.9681 6.38231L17.6177 19.0319Z"
fill="currentColor"
/>
</svg>
)
}
)
BanIcon.displayName = "BanIcon"

View File

@@ -0,0 +1,44 @@
import * as React from "react"
export const BlockquoteIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 6C8 5.44772 8.44772 5 9 5H16C16.5523 5 17 5.44772 17 6C17 6.55228 16.5523 7 16 7H9C8.44772 7 8 6.55228 8 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4 3C4.55228 3 5 3.44772 5 4L5 20C5 20.5523 4.55229 21 4 21C3.44772 21 3 20.5523 3 20L3 4C3 3.44772 3.44772 3 4 3Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 12C8 11.4477 8.44772 11 9 11H20C20.5523 11 21 11.4477 21 12C21 12.5523 20.5523 13 20 13H9C8.44772 13 8 12.5523 8 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 18C8 17.4477 8.44772 17 9 17H16C16.5523 17 17 17.4477 17 18C17 18.5523 16.5523 19 16 19H9C8.44772 19 8 18.5523 8 18Z"
fill="currentColor"
/>
</svg>
)
}
)
BlockquoteIcon.displayName = "BlockquoteIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const BoldIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6 2.5C5.17157 2.5 4.5 3.17157 4.5 4V20C4.5 20.8284 5.17157 21.5 6 21.5H15C16.4587 21.5 17.8576 20.9205 18.8891 19.8891C19.9205 18.8576 20.5 17.4587 20.5 16C20.5 14.5413 19.9205 13.1424 18.8891 12.1109C18.6781 11.9 18.4518 11.7079 18.2128 11.5359C19.041 10.5492 19.5 9.29829 19.5 8C19.5 6.54131 18.9205 5.14236 17.8891 4.11091C16.8576 3.07946 15.4587 2.5 14 2.5H6ZM14 10.5C14.663 10.5 15.2989 10.2366 15.7678 9.76777C16.2366 9.29893 16.5 8.66304 16.5 8C16.5 7.33696 16.2366 6.70107 15.7678 6.23223C15.2989 5.76339 14.663 5.5 14 5.5H7.5V10.5H14ZM7.5 18.5V13.5H15C15.663 13.5 16.2989 13.7634 16.7678 14.2322C17.2366 14.7011 17.5 15.337 17.5 16C17.5 16.663 17.2366 17.2989 16.7678 17.7678C16.2989 18.2366 15.663 18.5 15 18.5H7.5Z"
fill="currentColor"
/>
</svg>
)
}
)
BoldIcon.displayName = "BoldIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const ChevronDownIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.29289 8.29289C5.68342 7.90237 6.31658 7.90237 6.70711 8.29289L12 13.5858L17.2929 8.29289C17.6834 7.90237 18.3166 7.90237 18.7071 8.29289C19.0976 8.68342 19.0976 9.31658 18.7071 9.70711L12.7071 15.7071C12.3166 16.0976 11.6834 16.0976 11.2929 15.7071L5.29289 9.70711C4.90237 9.31658 4.90237 8.68342 5.29289 8.29289Z"
fill="currentColor"
/>
</svg>
)
}
)
ChevronDownIcon.displayName = "ChevronDownIcon"

View File

@@ -0,0 +1,24 @@
import * as React from "react"
export const CloseIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.7071 6.70711C19.0976 6.31658 19.0976 5.68342 18.7071 5.29289C18.3166 4.90237 17.6834 4.90237 17.2929 5.29289L12 10.5858L6.70711 5.29289C6.31658 4.90237 5.68342 4.90237 5.29289 5.29289C4.90237 5.68342 4.90237 6.31658 5.29289 6.70711L10.5858 12L5.29289 17.2929C4.90237 17.6834 4.90237 18.3166 5.29289 18.7071C5.68342 19.0976 6.31658 19.0976 6.70711 18.7071L12 13.4142L17.2929 18.7071C17.6834 19.0976 18.3166 19.0976 18.7071 18.7071C19.0976 18.3166 19.0976 17.6834 18.7071 17.2929L13.4142 12L18.7071 6.70711Z"
fill="currentColor"
/>
</svg>
)
}
)
CloseIcon.displayName = "CloseIcon"

View File

@@ -0,0 +1,38 @@
import * as React from "react"
export const CodeBlockIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.70711 2.29289C7.09763 2.68342 7.09763 3.31658 6.70711 3.70711L4.41421 6L6.70711 8.29289C7.09763 8.68342 7.09763 9.31658 6.70711 9.70711C6.31658 10.0976 5.68342 10.0976 5.29289 9.70711L2.29289 6.70711C1.90237 6.31658 1.90237 5.68342 2.29289 5.29289L5.29289 2.29289C5.68342 1.90237 6.31658 1.90237 6.70711 2.29289Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M10.2929 2.29289C10.6834 1.90237 11.3166 1.90237 11.7071 2.29289L14.7071 5.29289C15.0976 5.68342 15.0976 6.31658 14.7071 6.70711L11.7071 9.70711C11.3166 10.0976 10.6834 10.0976 10.2929 9.70711C9.90237 9.31658 9.90237 8.68342 10.2929 8.29289L12.5858 6L10.2929 3.70711C9.90237 3.31658 9.90237 2.68342 10.2929 2.29289Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17 4C17 3.44772 17.4477 3 18 3H19C20.6569 3 22 4.34315 22 6V18C22 19.6569 20.6569 21 19 21H5C3.34315 21 2 19.6569 2 18V12C2 11.4477 2.44772 11 3 11C3.55228 11 4 11.4477 4 12V18C4 18.5523 4.44772 19 5 19H19C19.5523 19 20 18.5523 20 18V6C20 5.44772 19.5523 5 19 5H18C17.4477 5 17 4.55228 17 4Z"
fill="currentColor"
/>
</svg>
)
}
)
CodeBlockIcon.displayName = "CodeBlockIcon"

View File

@@ -0,0 +1,32 @@
import * as React from "react"
export const Code2Icon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15.4545 4.2983C15.6192 3.77115 15.3254 3.21028 14.7983 3.04554C14.2712 2.88081 13.7103 3.1746 13.5455 3.70175L8.54554 19.7017C8.38081 20.2289 8.6746 20.7898 9.20175 20.9545C9.72889 21.1192 10.2898 20.8254 10.4545 20.2983L15.4545 4.2983Z"
fill="currentColor"
/>
<path
d="M6.70711 7.29289C7.09763 7.68342 7.09763 8.31658 6.70711 8.70711L3.41421 12L6.70711 15.2929C7.09763 15.6834 7.09763 16.3166 6.70711 16.7071C6.31658 17.0976 5.68342 17.0976 5.29289 16.7071L1.29289 12.7071C0.902369 12.3166 0.902369 11.6834 1.29289 11.2929L5.29289 7.29289C5.68342 6.90237 6.31658 6.90237 6.70711 7.29289Z"
fill="currentColor"
/>
<path
d="M17.2929 7.29289C17.6834 6.90237 18.3166 6.90237 18.7071 7.29289L22.7071 11.2929C23.0976 11.6834 23.0976 12.3166 22.7071 12.7071L18.7071 16.7071C18.3166 17.0976 17.6834 17.0976 17.2929 16.7071C16.9024 16.3166 16.9024 15.6834 17.2929 15.2929L20.5858 12L17.2929 8.70711C16.9024 8.31658 16.9024 7.68342 17.2929 7.29289Z"
fill="currentColor"
/>
</svg>
)
}
)
Code2Icon.displayName = "Code2Icon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const CornerDownLeftIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21 4C21 3.44772 20.5523 3 20 3C19.4477 3 19 3.44772 19 4V11C19 11.7956 18.6839 12.5587 18.1213 13.1213C17.5587 13.6839 16.7956 14 16 14H6.41421L9.70711 10.7071C10.0976 10.3166 10.0976 9.68342 9.70711 9.29289C9.31658 8.90237 8.68342 8.90237 8.29289 9.29289L3.29289 14.2929C2.90237 14.6834 2.90237 15.3166 3.29289 15.7071L8.29289 20.7071C8.68342 21.0976 9.31658 21.0976 9.70711 20.7071C10.0976 20.3166 10.0976 19.6834 9.70711 19.2929L6.41421 16H16C17.3261 16 18.5979 15.4732 19.5355 14.5355C20.4732 13.5979 21 12.3261 21 11V4Z"
fill="currentColor"
/>
</svg>
)
}
)
CornerDownLeftIcon.displayName = "CornerDownLeftIcon"

View File

@@ -0,0 +1,28 @@
import * as React from "react"
export const ExternalLinkIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M14 3C14 2.44772 14.4477 2 15 2H21C21.5523 2 22 2.44772 22 3V9C22 9.55228 21.5523 10 21 10C20.4477 10 20 9.55228 20 9V5.41421L10.7071 14.7071C10.3166 15.0976 9.68342 15.0976 9.29289 14.7071C8.90237 14.3166 8.90237 13.6834 9.29289 13.2929L18.5858 4H15C14.4477 4 14 3.55228 14 3Z"
fill="currentColor"
/>
<path
d="M4.29289 7.29289C4.48043 7.10536 4.73478 7 5 7H11C11.5523 7 12 6.55228 12 6C12 5.44772 11.5523 5 11 5H5C4.20435 5 3.44129 5.31607 2.87868 5.87868C2.31607 6.44129 2 7.20435 2 8V19C2 19.7957 2.31607 20.5587 2.87868 21.1213C3.44129 21.6839 4.20435 22 5 22H16C16.7957 22 17.5587 21.6839 18.1213 21.1213C18.6839 20.5587 19 19.7957 19 19V13C19 12.4477 18.5523 12 18 12C17.4477 12 17 12.4477 17 13V19C17 19.2652 16.8946 19.5196 16.7071 19.7071C16.5196 19.8946 16.2652 20 16 20H5C4.73478 20 4.48043 19.8946 4.29289 19.7071C4.10536 19.5196 4 19.2652 4 19V8C4 7.73478 4.10536 7.48043 4.29289 7.29289Z"
fill="currentColor"
/>
</svg>
)
}
)
ExternalLinkIcon.displayName = "ExternalLinkIcon"

View File

@@ -0,0 +1,28 @@
import * as React from "react"
export const HeadingFiveIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 6C5 5.44772 4.55228 5 4 5C3.44772 5 3 5.44772 3 6V18C3 18.5523 3.44772 19 4 19C4.55228 19 5 18.5523 5 18V13H11V18C11 18.5523 11.4477 19 12 19C12.5523 19 13 18.5523 13 18V6C13 5.44772 12.5523 5 12 5C11.4477 5 11 5.44772 11 6V11H5V6Z"
fill="currentColor"
/>
<path
d="M16 10C16 9.44772 16.4477 9 17 9H21C21.5523 9 22 9.44772 22 10C22 10.5523 21.5523 11 21 11H18V12H18.3C20.2754 12 22 13.4739 22 15.5C22 17.5261 20.2754 19 18.3 19C17.6457 19 17.0925 18.8643 16.5528 18.5944C16.0588 18.3474 15.8586 17.7468 16.1055 17.2528C16.3525 16.7588 16.9532 16.5586 17.4472 16.8056C17.7074 16.9357 17.9542 17 18.3 17C19.3246 17 20 16.2739 20 15.5C20 14.7261 19.3246 14 18.3 14H17C16.4477 14 16 13.5523 16 13L16 12.9928V10Z"
fill="currentColor"
/>
</svg>
)
}
)
HeadingFiveIcon.displayName = "HeadingFiveIcon"

View File

@@ -0,0 +1,28 @@
import * as React from "react"
export const HeadingFourIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M4 5C4.55228 5 5 5.44772 5 6V11H11V6C11 5.44772 11.4477 5 12 5C12.5523 5 13 5.44772 13 6V18C13 18.5523 12.5523 19 12 19C11.4477 19 11 18.5523 11 18V13H5V18C5 18.5523 4.55228 19 4 19C3.44772 19 3 18.5523 3 18V6C3 5.44772 3.44772 5 4 5Z"
fill="currentColor"
/>
<path
d="M17 9C17.5523 9 18 9.44772 18 10V13H20V10C20 9.44772 20.4477 9 21 9C21.5523 9 22 9.44772 22 10V18C22 18.5523 21.5523 19 21 19C20.4477 19 20 18.5523 20 18V15H17C16.4477 15 16 14.5523 16 14V10C16 9.44772 16.4477 9 17 9Z"
fill="currentColor"
/>
</svg>
)
}
)
HeadingFourIcon.displayName = "HeadingFourIcon"

View File

@@ -0,0 +1,24 @@
import * as React from "react"
export const HeadingIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M6 3C6.55228 3 7 3.44772 7 4V11H17V4C17 3.44772 17.4477 3 18 3C18.5523 3 19 3.44772 19 4V20C19 20.5523 18.5523 21 18 21C17.4477 21 17 20.5523 17 20V13H7V20C7 20.5523 6.55228 21 6 21C5.44772 21 5 20.5523 5 20V4C5 3.44772 5.44772 3 6 3Z"
fill="currentColor"
/>
</svg>
)
}
)
HeadingIcon.displayName = "HeadingIcon"

View File

@@ -0,0 +1,28 @@
import * as React from "react"
export const HeadingOneIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 6C5 5.44772 4.55228 5 4 5C3.44772 5 3 5.44772 3 6V18C3 18.5523 3.44772 19 4 19C4.55228 19 5 18.5523 5 18V13H11V18C11 18.5523 11.4477 19 12 19C12.5523 19 13 18.5523 13 18V6C13 5.44772 12.5523 5 12 5C11.4477 5 11 5.44772 11 6V11H5V6Z"
fill="currentColor"
/>
<path
d="M21.0001 10C21.0001 9.63121 20.7971 9.29235 20.472 9.11833C20.1468 8.94431 19.7523 8.96338 19.4454 9.16795L16.4454 11.168C15.9859 11.4743 15.8617 12.0952 16.1681 12.5547C16.4744 13.0142 17.0953 13.1384 17.5548 12.8321L19.0001 11.8685V18C19.0001 18.5523 19.4478 19 20.0001 19C20.5524 19 21.0001 18.5523 21.0001 18V10Z"
fill="currentColor"
/>
</svg>
)
}
)
HeadingOneIcon.displayName = "HeadingOneIcon"

View File

@@ -0,0 +1,30 @@
import * as React from "react"
export const HeadingSixIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 6C5 5.44772 4.55228 5 4 5C3.44772 5 3 5.44772 3 6V18C3 18.5523 3.44772 19 4 19C4.55228 19 5 18.5523 5 18V13H11V18C11 18.5523 11.4477 19 12 19C12.5523 19 13 18.5523 13 18V6C13 5.44772 12.5523 5 12 5C11.4477 5 11 5.44772 11 6V11H5V6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20.7071 9.29289C21.0976 9.68342 21.0976 10.3166 20.7071 10.7071C19.8392 11.575 19.2179 12.2949 18.7889 13.0073C18.8587 13.0025 18.929 13 19 13C20.6569 13 22 14.3431 22 16C22 17.6569 20.6569 19 19 19C17.3431 19 16 17.6569 16 16C16 14.6007 16.2837 13.4368 16.8676 12.3419C17.4384 11.2717 18.2728 10.3129 19.2929 9.29289C19.6834 8.90237 20.3166 8.90237 20.7071 9.29289ZM19 17C18.4477 17 18 16.5523 18 16C18 15.4477 18.4477 15 19 15C19.5523 15 20 15.4477 20 16C20 16.5523 19.5523 17 19 17Z"
fill="currentColor"
/>
</svg>
)
}
)
HeadingSixIcon.displayName = "HeadingSixIcon"

View File

@@ -0,0 +1,36 @@
import * as React from "react"
export const HeadingThreeIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M4 5C4.55228 5 5 5.44772 5 6V11H11V6C11 5.44772 11.4477 5 12 5C12.5523 5 13 5.44772 13 6V18C13 18.5523 12.5523 19 12 19C11.4477 19 11 18.5523 11 18V13H5V18C5 18.5523 4.55228 19 4 19C3.44772 19 3 18.5523 3 18V6C3 5.44772 3.44772 5 4 5Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M19.4608 11.2169C19.1135 11.0531 18.5876 11.0204 18.0069 11.3619C17.5309 11.642 16.918 11.4831 16.638 11.007C16.358 10.531 16.5169 9.91809 16.9929 9.63807C18.1123 8.97962 19.3364 8.94691 20.314 9.40808C21.2839 9.86558 21.9999 10.818 21.9999 12C21.9999 12.7957 21.6838 13.5587 21.1212 14.1213C20.5586 14.6839 19.7956 15 18.9999 15C18.4476 15 17.9999 14.5523 17.9999 14C17.9999 13.4477 18.4476 13 18.9999 13C19.2651 13 19.5195 12.8947 19.707 12.7071C19.8946 12.5196 19.9999 12.2652 19.9999 12C19.9999 11.6821 19.8159 11.3844 19.4608 11.2169Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18.0001 14C18.0001 13.4477 18.4478 13 19.0001 13C19.7957 13 20.5588 13.3161 21.1214 13.8787C21.684 14.4413 22.0001 15.2043 22.0001 16C22.0001 17.2853 21.2767 18.3971 20.1604 18.8994C19.0257 19.41 17.642 19.2315 16.4001 18.3C15.9582 17.9686 15.8687 17.3418 16.2001 16.9C16.5314 16.4582 17.1582 16.3686 17.6001 16.7C18.3581 17.2685 18.9744 17.24 19.3397 17.0756C19.7234 16.9029 20.0001 16.5147 20.0001 16C20.0001 15.7348 19.8947 15.4804 19.7072 15.2929C19.5196 15.1054 19.2653 15 19.0001 15C18.4478 15 18.0001 14.5523 18.0001 14Z"
fill="currentColor"
/>
</svg>
)
}
)
HeadingThreeIcon.displayName = "HeadingThreeIcon"

View File

@@ -0,0 +1,28 @@
import * as React from "react"
export const HeadingTwoIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M5 6C5 5.44772 4.55228 5 4 5C3.44772 5 3 5.44772 3 6V18C3 18.5523 3.44772 19 4 19C4.55228 19 5 18.5523 5 18V13H11V18C11 18.5523 11.4477 19 12 19C12.5523 19 13 18.5523 13 18V6C13 5.44772 12.5523 5 12 5C11.4477 5 11 5.44772 11 6V11H5V6Z"
fill="currentColor"
/>
<path
d="M22.0001 12C22.0001 10.7611 21.1663 9.79297 20.0663 9.42632C18.9547 9.05578 17.6171 9.28724 16.4001 10.2C15.9582 10.5314 15.8687 11.1582 16.2001 11.6C16.5314 12.0418 17.1582 12.1314 17.6001 11.8C18.383 11.2128 19.0455 11.1942 19.4338 11.3237C19.8339 11.457 20.0001 11.7389 20.0001 12C20.0001 12.4839 19.8554 12.7379 19.6537 12.9481C19.4275 13.1837 19.1378 13.363 18.7055 13.6307C18.6313 13.6767 18.553 13.7252 18.4701 13.777C17.9572 14.0975 17.3128 14.5261 16.8163 15.2087C16.3007 15.9177 16.0001 16.8183 16.0001 18C16.0001 18.5523 16.4478 19 17.0001 19H21.0001C21.5523 19 22.0001 18.5523 22.0001 18C22.0001 17.4477 21.5523 17 21.0001 17H18.131C18.21 16.742 18.3176 16.5448 18.4338 16.385C18.6873 16.0364 19.0429 15.7775 19.5301 15.473C19.5898 15.4357 19.6536 15.3966 19.7205 15.3556C20.139 15.0992 20.6783 14.7687 21.0964 14.3332C21.6447 13.7621 22.0001 13.0161 22.0001 12Z"
fill="currentColor"
/>
</svg>
)
}
)
HeadingTwoIcon.displayName = "HeadingTwoIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const HighlighterIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.7072 4.70711C15.0977 4.31658 15.0977 3.68342 14.7072 3.29289C14.3167 2.90237 13.6835 2.90237 13.293 3.29289L8.69294 7.89286L8.68594 7.9C8.13626 8.46079 7.82837 9.21474 7.82837 10C7.82837 10.2306 7.85491 10.4584 7.90631 10.6795L2.29289 16.2929C2.10536 16.4804 2 16.7348 2 17V20C2 20.5523 2.44772 21 3 21H12C12.2652 21 12.5196 20.8946 12.7071 20.7071L15.3205 18.0937C15.5416 18.1452 15.7695 18.1717 16.0001 18.1717C16.7853 18.1717 17.5393 17.8639 18.1001 17.3142L22.7072 12.7071C23.0977 12.3166 23.0977 11.6834 22.7072 11.2929C22.3167 10.9024 21.6835 10.9024 21.293 11.2929L16.6971 15.8887C16.5105 16.0702 16.2605 16.1717 16.0001 16.1717C15.7397 16.1717 15.4897 16.0702 15.303 15.8887L10.1113 10.697C9.92992 10.5104 9.82837 10.2604 9.82837 10C9.82837 9.73963 9.92992 9.48958 10.1113 9.30297L14.7072 4.70711ZM13.5858 17L9.00004 12.4142L4 17.4142V19H11.5858L13.5858 17Z"
fill="currentColor"
/>
</svg>
)
}
)
HighlighterIcon.displayName = "HighlighterIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const ImagePlusIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M20 2C20 1.44772 19.5523 1 19 1C18.4477 1 18 1.44772 18 2V4H16C15.4477 4 15 4.44772 15 5C15 5.55228 15.4477 6 16 6H18V8C18 8.55228 18.4477 9 19 9C19.5523 9 20 8.55228 20 8V6H22C22.5523 6 23 5.55228 23 5C23 4.44772 22.5523 4 22 4H20V2ZM5 4C4.73478 4 4.48043 4.10536 4.29289 4.29289C4.10536 4.48043 4 4.73478 4 5V19C4 19.2652 4.10536 19.5196 4.29289 19.7071C4.48043 19.8946 4.73478 20 5 20H5.58579L14.379 11.2068C14.9416 10.6444 15.7045 10.3284 16.5 10.3284C17.2955 10.3284 18.0584 10.6444 18.621 11.2068L20 12.5858V12C20 11.4477 20.4477 11 21 11C21.5523 11 22 11.4477 22 12V14.998C22 14.9994 22 15.0007 22 15.002V19C22 19.7957 21.6839 20.5587 21.1213 21.1213C20.5587 21.6839 19.7957 22 19 22H6.00219C6.00073 22 5.99927 22 5.99781 22H5C4.20435 22 3.44129 21.6839 2.87868 21.1213C2.31607 20.5587 2 19.7957 2 19V5C2 4.20435 2.31607 3.44129 2.87868 2.87868C3.44129 2.31607 4.20435 2 5 2H12C12.5523 2 13 2.44772 13 3C13 3.55228 12.5523 4 12 4H5ZM8.41422 20H19C19.2652 20 19.5196 19.8946 19.7071 19.7071C19.8946 19.5196 20 19.2652 20 19V15.4142L17.207 12.6212C17.0195 12.4338 16.7651 12.3284 16.5 12.3284C16.2349 12.3284 15.9806 12.4337 15.7931 12.6211L8.41422 20ZM6.87868 6.87868C7.44129 6.31607 8.20435 6 9 6C9.79565 6 10.5587 6.31607 11.1213 6.87868C11.6839 7.44129 12 8.20435 12 9C12 9.79565 11.6839 10.5587 11.1213 11.1213C10.5587 11.6839 9.79565 12 9 12C8.20435 12 7.44129 11.6839 6.87868 11.1213C6.31607 10.5587 6 9.79565 6 9C6 8.20435 6.31607 7.44129 6.87868 6.87868ZM9 8C8.73478 8 8.48043 8.10536 8.29289 8.29289C8.10536 8.48043 8 8.73478 8 9C8 9.26522 8.10536 9.51957 8.29289 9.70711C8.48043 9.89464 8.73478 10 9 10C9.26522 10 9.51957 9.89464 9.70711 9.70711C9.89464 9.51957 10 9.26522 10 9C10 8.73478 9.89464 8.48043 9.70711 8.29289C9.51957 8.10536 9.26522 8 9 8Z"
fill="currentColor"
/>
</svg>
)
}
)
ImagePlusIcon.displayName = "ImagePlusIcon"

View File

@@ -0,0 +1,24 @@
import * as React from "react"
export const ItalicIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M15.0222 3H19C19.5523 3 20 3.44772 20 4C20 4.55228 19.5523 5 19 5H15.693L10.443 19H14C14.5523 19 15 19.4477 15 20C15 20.5523 14.5523 21 14 21H9.02418C9.00802 21.0004 8.99181 21.0004 8.97557 21H5C4.44772 21 4 20.5523 4 20C4 19.4477 4.44772 19 5 19H8.30704L13.557 5H10C9.44772 5 9 4.55228 9 4C9 3.44772 9.44772 3 10 3H14.9782C14.9928 2.99968 15.0075 2.99967 15.0222 3Z"
fill="currentColor"
/>
</svg>
)
}
)
ItalicIcon.displayName = "ItalicIcon"

View File

@@ -0,0 +1,28 @@
import * as React from "react"
export const LinkIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M16.9958 1.06669C15.4226 1.05302 13.907 1.65779 12.7753 2.75074L12.765 2.76086L11.045 4.47086C10.6534 4.86024 10.6515 5.49341 11.0409 5.88507C11.4303 6.27673 12.0634 6.27858 12.4551 5.88919L14.1697 4.18456C14.9236 3.45893 15.9319 3.05752 16.9784 3.06662C18.0272 3.07573 19.0304 3.49641 19.772 4.23804C20.5137 4.97967 20.9344 5.98292 20.9435 7.03171C20.9526 8.07776 20.5515 9.08563 19.8265 9.83941L16.833 12.8329C16.4274 13.2386 15.9393 13.5524 15.4019 13.7529C14.8645 13.9533 14.2903 14.0359 13.7181 13.9949C13.146 13.9539 12.5894 13.7904 12.0861 13.5154C11.5827 13.2404 11.1444 12.8604 10.8008 12.401C10.47 11.9588 9.84333 11.8685 9.40108 12.1993C8.95883 12.5301 8.86849 13.1568 9.1993 13.599C9.71464 14.288 10.3721 14.858 11.1272 15.2705C11.8822 15.683 12.7171 15.9283 13.5753 15.9898C14.4334 16.0513 15.2948 15.9274 16.1009 15.6267C16.907 15.326 17.639 14.8555 18.2473 14.247L21.2472 11.2471L21.2593 11.2347C22.3523 10.1031 22.9571 8.58751 22.9434 7.01433C22.9297 5.44115 22.2987 3.93628 21.1863 2.82383C20.0738 1.71138 18.5689 1.08036 16.9958 1.06669Z"
fill="currentColor"
/>
<path
d="M10.4247 8.0102C9.56657 7.94874 8.70522 8.07256 7.89911 8.37326C7.09305 8.67395 6.36096 9.14458 5.75272 9.753L2.75285 12.7529L2.74067 12.7653C1.64772 13.8969 1.04295 15.4125 1.05662 16.9857C1.07029 18.5589 1.70131 20.0637 2.81376 21.1762C3.9262 22.2886 5.43108 22.9196 7.00426 22.9333C8.57744 22.947 10.0931 22.3422 11.2247 21.2493L11.2371 21.2371L12.9471 19.5271C13.3376 19.1366 13.3376 18.5034 12.9471 18.1129C12.5565 17.7223 11.9234 17.7223 11.5328 18.1129L9.82932 19.8164C9.07555 20.5414 8.06768 20.9425 7.02164 20.9334C5.97285 20.9243 4.9696 20.5036 4.22797 19.762C3.48634 19.0203 3.06566 18.0171 3.05655 16.9683C3.04746 15.9222 3.44851 14.9144 4.17355 14.1606L7.16719 11.167C7.5727 10.7613 8.06071 10.4476 8.59811 10.2471C9.13552 10.0467 9.70976 9.96412 10.2819 10.0051C10.854 10.0461 11.4106 10.2096 11.9139 10.4846C12.4173 10.7596 12.8556 11.1397 13.1992 11.599C13.53 12.0412 14.1567 12.1316 14.5989 11.8007C15.0412 11.4699 15.1315 10.8433 14.8007 10.401C14.2854 9.71205 13.6279 9.14198 12.8729 8.72948C12.1178 8.31697 11.2829 8.07166 10.4247 8.0102Z"
fill="currentColor"
/>
</svg>
)
}
)
LinkIcon.displayName = "LinkIcon"

View File

@@ -0,0 +1,56 @@
import * as React from "react"
export const ListIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 6C7 5.44772 7.44772 5 8 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H8C7.44772 7 7 6.55228 7 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 12C7 11.4477 7.44772 11 8 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H8C7.44772 13 7 12.5523 7 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 18C7 17.4477 7.44772 17 8 17H21C21.5523 17 22 17.4477 22 18C22 18.5523 21.5523 19 21 19H8C7.44772 19 7 18.5523 7 18Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 6C2 5.44772 2.44772 5 3 5H3.01C3.56228 5 4.01 5.44772 4.01 6C4.01 6.55228 3.56228 7 3.01 7H3C2.44772 7 2 6.55228 2 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 11.4477 2.44772 11 3 11H3.01C3.56228 11 4.01 11.4477 4.01 12C4.01 12.5523 3.56228 13 3.01 13H3C2.44772 13 2 12.5523 2 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 18C2 17.4477 2.44772 17 3 17H3.01C3.56228 17 4.01 17.4477 4.01 18C4.01 18.5523 3.56228 19 3.01 19H3C2.44772 19 2 18.5523 2 18Z"
fill="currentColor"
/>
</svg>
)
}
)
ListIcon.displayName = "ListIcon"

View File

@@ -0,0 +1,56 @@
import * as React from "react"
export const ListOrderedIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 6C9 5.44772 9.44772 5 10 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H10C9.44772 7 9 6.55228 9 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 12C9 11.4477 9.44772 11 10 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H10C9.44772 13 9 12.5523 9 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 18C9 17.4477 9.44772 17 10 17H21C21.5523 17 22 17.4477 22 18C22 18.5523 21.5523 19 21 19H10C9.44772 19 9 18.5523 9 18Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 6C3 5.44772 3.44772 5 4 5H5C5.55228 5 6 5.44772 6 6V10C6 10.5523 5.55228 11 5 11C4.44772 11 4 10.5523 4 10V7C3.44772 7 3 6.55228 3 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 10C3 9.44772 3.44772 9 4 9H6C6.55228 9 7 9.44772 7 10C7 10.5523 6.55228 11 6 11H4C3.44772 11 3 10.5523 3 10Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.82219 13.0431C6.54543 13.4047 6.99997 14.1319 6.99997 15C6.99997 15.5763 6.71806 16.0426 6.48747 16.35C6.31395 16.5814 6.1052 16.8044 5.91309 17H5.99997C6.55226 17 6.99997 17.4477 6.99997 18C6.99997 18.5523 6.55226 19 5.99997 19H3.99997C3.44769 19 2.99997 18.5523 2.99997 18C2.99997 17.4237 3.28189 16.9575 3.51247 16.65C3.74323 16.3424 4.03626 16.0494 4.26965 15.8161C4.27745 15.8083 4.2852 15.8006 4.29287 15.7929C4.55594 15.5298 4.75095 15.3321 4.88748 15.15C4.96287 15.0495 4.99021 14.9922 4.99911 14.9714C4.99535 14.9112 4.9803 14.882 4.9739 14.8715C4.96613 14.8588 4.95382 14.845 4.92776 14.8319C4.87723 14.8067 4.71156 14.7623 4.44719 14.8944C3.95321 15.1414 3.35254 14.9412 3.10555 14.4472C2.85856 13.9533 3.05878 13.3526 3.55276 13.1056C4.28839 12.7378 5.12272 12.6934 5.82219 13.0431Z"
fill="currentColor"
/>
</svg>
)
}
)
ListOrderedIcon.displayName = "ListOrderedIcon"

View File

@@ -0,0 +1,50 @@
import * as React from "react"
export const ListTodoIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 6C2 4.89543 2.89543 4 4 4H8C9.10457 4 10 4.89543 10 6V10C10 11.1046 9.10457 12 8 12H4C2.89543 12 2 11.1046 2 10V6ZM8 6H4V10H8V6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.70711 14.2929C10.0976 14.6834 10.0976 15.3166 9.70711 15.7071L5.70711 19.7071C5.31658 20.0976 4.68342 20.0976 4.29289 19.7071L2.29289 17.7071C1.90237 17.3166 1.90237 16.6834 2.29289 16.2929C2.68342 15.9024 3.31658 15.9024 3.70711 16.2929L5 17.5858L8.29289 14.2929C8.68342 13.9024 9.31658 13.9024 9.70711 14.2929Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 6C12 5.44772 12.4477 5 13 5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H13C12.4477 7 12 6.55228 12 6Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 12C12 11.4477 12.4477 11 13 11H21C21.5523 11 22 11.4477 22 12C22 12.5523 21.5523 13 21 13H13C12.4477 13 12 12.5523 12 12Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 18C12 17.4477 12.4477 17 13 17H21C21.5523 17 22 17.4477 22 18C22 18.5523 21.5523 19 21 19H13C12.4477 19 12 18.5523 12 18Z"
fill="currentColor"
/>
</svg>
)
}
)
ListTodoIcon.displayName = "ListTodoIcon"

View File

@@ -0,0 +1,30 @@
import * as React from "react"
export const MoonStarIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 2C10.0222 2 8.08879 2.58649 6.4443 3.6853C4.79981 4.78412 3.51809 6.3459 2.76121 8.17317C2.00433 10.0004 1.8063 12.0111 2.19215 13.9509C2.578 15.8907 3.53041 17.6725 4.92894 19.0711C6.32746 20.4696 8.10929 21.422 10.0491 21.8079C11.9889 22.1937 13.9996 21.9957 15.8268 21.2388C17.6541 20.4819 19.2159 19.2002 20.3147 17.5557C21.4135 15.9112 22 13.9778 22 12C22 11.5955 21.7564 11.2309 21.3827 11.0761C21.009 10.9213 20.5789 11.0069 20.2929 11.2929C19.287 12.2988 17.9226 12.864 16.5 12.864C15.0774 12.864 13.713 12.2988 12.7071 11.2929C11.7012 10.287 11.136 8.92261 11.136 7.5C11.136 6.07739 11.7012 4.71304 12.7071 3.70711C12.9931 3.42111 13.0787 2.99099 12.9239 2.61732C12.7691 2.24364 12.4045 2 12 2ZM7.55544 5.34824C8.27036 4.87055 9.05353 4.51389 9.87357 4.28778C9.39271 5.27979 9.13604 6.37666 9.13604 7.5C9.13604 9.45304 9.91189 11.3261 11.2929 12.7071C12.6739 14.0881 14.547 14.864 16.5 14.864C17.6233 14.864 18.7202 14.6073 19.7122 14.1264C19.4861 14.9465 19.1295 15.7296 18.6518 16.4446C17.7727 17.7602 16.5233 18.7855 15.0615 19.391C13.5997 19.9965 11.9911 20.155 10.4393 19.8463C8.88743 19.5376 7.46197 18.7757 6.34315 17.6569C5.22433 16.538 4.4624 15.1126 4.15372 13.5607C3.84504 12.0089 4.00347 10.4003 4.60897 8.93853C5.21447 7.47672 6.23985 6.22729 7.55544 5.34824Z"
fill="currentColor"
/>
<path
d="M19 2C19.5523 2 20 2.44772 20 3V4H21C21.5523 4 22 4.44772 22 5C22 5.55228 21.5523 6 21 6H20V7C20 7.55228 19.5523 8 19 8C18.4477 8 18 7.55228 18 7V6H17C16.4477 6 16 5.55228 16 5C16 4.44772 16.4477 4 17 4H18V3C18 2.44772 18.4477 2 19 2Z"
fill="currentColor"
/>
</svg>
)
}
)
MoonStarIcon.displayName = "MoonStarIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const Redo2Icon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M15.7071 2.29289C15.3166 1.90237 14.6834 1.90237 14.2929 2.29289C13.9024 2.68342 13.9024 3.31658 14.2929 3.70711L17.5858 7H9.5C7.77609 7 6.12279 7.68482 4.90381 8.90381C3.68482 10.1228 3 11.7761 3 13.5C3 14.3536 3.16813 15.1988 3.49478 15.9874C3.82144 16.7761 4.30023 17.4926 4.90381 18.0962C6.12279 19.3152 7.77609 20 9.5 20H13C13.5523 20 14 19.5523 14 19C14 18.4477 13.5523 18 13 18H9.5C8.30653 18 7.16193 17.5259 6.31802 16.682C5.90016 16.2641 5.56869 15.768 5.34254 15.2221C5.1164 14.6761 5 14.0909 5 13.5C5 12.3065 5.47411 11.1619 6.31802 10.318C7.16193 9.47411 8.30653 9 9.5 9H17.5858L14.2929 12.2929C13.9024 12.6834 13.9024 13.3166 14.2929 13.7071C14.6834 14.0976 15.3166 14.0976 15.7071 13.7071L20.7071 8.70711C21.0976 8.31658 21.0976 7.68342 20.7071 7.29289L15.7071 2.29289Z"
fill="currentColor"
/>
</svg>
)
}
)
Redo2Icon.displayName = "Redo2Icon"

View File

@@ -0,0 +1,28 @@
import * as React from "react"
export const StrikeIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M9.00039 3H16.0001C16.5524 3 17.0001 3.44772 17.0001 4C17.0001 4.55229 16.5524 5 16.0001 5H9.00011C8.68006 4.99983 8.36412 5.07648 8.07983 5.22349C7.79555 5.37051 7.55069 5.5836 7.36585 5.84487C7.181 6.10614 7.06155 6.40796 7.01754 6.72497C6.97352 7.04198 7.00623 7.36492 7.11292 7.66667C7.29701 8.18737 7.02414 8.75872 6.50344 8.94281C5.98274 9.1269 5.4114 8.85403 5.2273 8.33333C5.01393 7.72984 4.94851 7.08396 5.03654 6.44994C5.12456 5.81592 5.36346 5.21229 5.73316 4.68974C6.10285 4.1672 6.59256 3.74101 7.16113 3.44698C7.72955 3.15303 8.36047 2.99975 9.00039 3Z"
fill="currentColor"
/>
<path
d="M18 13H20C20.5523 13 21 12.5523 21 12C21 11.4477 20.5523 11 20 11H4C3.44772 11 3 11.4477 3 12C3 12.5523 3.44772 13 4 13H14C14.7956 13 15.5587 13.3161 16.1213 13.8787C16.6839 14.4413 17 15.2044 17 16C17 16.7956 16.6839 17.5587 16.1213 18.1213C15.5587 18.6839 14.7956 19 14 19H6C5.44772 19 5 19.4477 5 20C5 20.5523 5.44772 21 6 21H14C15.3261 21 16.5979 20.4732 17.5355 19.5355C18.4732 18.5979 19 17.3261 19 16C19 14.9119 18.6453 13.8604 18 13Z"
fill="currentColor"
/>
</svg>
)
}
)
StrikeIcon.displayName = "StrikeIcon"

View File

@@ -0,0 +1,38 @@
import * as React from "react"
export const SubscriptIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.29289 7.29289C3.68342 6.90237 4.31658 6.90237 4.70711 7.29289L12.7071 15.2929C13.0976 15.6834 13.0976 16.3166 12.7071 16.7071C12.3166 17.0976 11.6834 17.0976 11.2929 16.7071L3.29289 8.70711C2.90237 8.31658 2.90237 7.68342 3.29289 7.29289Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.7071 7.29289C13.0976 7.68342 13.0976 8.31658 12.7071 8.70711L4.70711 16.7071C4.31658 17.0976 3.68342 17.0976 3.29289 16.7071C2.90237 16.3166 2.90237 15.6834 3.29289 15.2929L11.2929 7.29289C11.6834 6.90237 12.3166 6.90237 12.7071 7.29289Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.4079 14.3995C18.0284 14.0487 18.7506 13.9217 19.4536 14.0397C20.1566 14.1578 20.7977 14.5138 21.2696 15.0481L21.2779 15.0574L21.2778 15.0575C21.7439 15.5988 22 16.2903 22 17C22 18.0823 21.3962 18.8401 20.7744 19.3404C20.194 19.8073 19.4858 20.141 18.9828 20.378C18.9638 20.387 18.9451 20.3958 18.9266 20.4045C18.4473 20.6306 18.2804 20.7817 18.1922 20.918C18.1773 20.9412 18.1619 20.9681 18.1467 21H21C21.5523 21 22 21.4477 22 22C22 22.5523 21.5523 23 21 23H17C16.4477 23 16 22.5523 16 22C16 21.1708 16.1176 20.4431 16.5128 19.832C16.9096 19.2184 17.4928 18.8695 18.0734 18.5956C18.6279 18.334 19.138 18.0901 19.5207 17.7821C19.8838 17.49 20 17.2477 20 17C20 16.7718 19.9176 16.5452 19.7663 16.3672C19.5983 16.1792 19.3712 16.0539 19.1224 16.0121C18.8722 15.9701 18.6152 16.015 18.3942 16.1394C18.1794 16.2628 18.0205 16.4549 17.9422 16.675C17.7572 17.1954 17.1854 17.4673 16.665 17.2822C16.1446 17.0972 15.8728 16.5254 16.0578 16.005C16.2993 15.3259 16.7797 14.7584 17.4039 14.4018L17.4079 14.3995L17.4079 14.3995Z"
fill="currentColor"
/>
</svg>
)
}
)
SubscriptIcon.displayName = "SubscriptIcon"

View File

@@ -0,0 +1,58 @@
import * as React from "react"
export const SunIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M12 1C12.5523 1 13 1.44772 13 2V4C13 4.55228 12.5523 5 12 5C11.4477 5 11 4.55228 11 4V2C11 1.44772 11.4477 1 12 1Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 12C7 9.23858 9.23858 7 12 7C14.7614 7 17 9.23858 17 12C17 14.7614 14.7614 17 12 17C9.23858 17 7 14.7614 7 12ZM12 9C10.3431 9 9 10.3431 9 12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12C15 10.3431 13.6569 9 12 9Z"
fill="currentColor"
/>
<path
d="M13 20C13 19.4477 12.5523 19 12 19C11.4477 19 11 19.4477 11 20V22C11 22.5523 11.4477 23 12 23C12.5523 23 13 22.5523 13 22V20Z"
fill="currentColor"
/>
<path
d="M4.22282 4.22289C4.61335 3.83236 5.24651 3.83236 5.63704 4.22289L7.04704 5.63289C7.43756 6.02341 7.43756 6.65658 7.04704 7.0471C6.65651 7.43762 6.02335 7.43762 5.63283 7.0471L4.22282 5.6371C3.8323 5.24658 3.8323 4.61341 4.22282 4.22289Z"
fill="currentColor"
/>
<path
d="M18.367 16.9529C17.9765 16.5623 17.3433 16.5623 16.9528 16.9529C16.5623 17.3434 16.5623 17.9766 16.9528 18.3671L18.3628 19.7771C18.7533 20.1676 19.3865 20.1676 19.777 19.7771C20.1675 19.3866 20.1675 18.7534 19.777 18.3629L18.367 16.9529Z"
fill="currentColor"
/>
<path
d="M1 12C1 11.4477 1.44772 11 2 11H4C4.55228 11 5 11.4477 5 12C5 12.5523 4.55228 13 4 13H2C1.44772 13 1 12.5523 1 12Z"
fill="currentColor"
/>
<path
d="M20 11C19.4477 11 19 11.4477 19 12C19 12.5523 19.4477 13 20 13H22C22.5523 13 23 12.5523 23 12C23 11.4477 22.5523 11 22 11H20Z"
fill="currentColor"
/>
<path
d="M7.04704 16.9529C7.43756 17.3434 7.43756 17.9766 7.04704 18.3671L5.63704 19.7771C5.24651 20.1676 4.61335 20.1676 4.22282 19.7771C3.8323 19.3866 3.8323 18.7534 4.22283 18.3629L5.63283 16.9529C6.02335 16.5623 6.65651 16.5623 7.04704 16.9529Z"
fill="currentColor"
/>
<path
d="M19.777 5.6371C20.1675 5.24657 20.1675 4.61341 19.777 4.22289C19.3865 3.83236 18.7533 3.83236 18.3628 4.22289L16.9528 5.63289C16.5623 6.02341 16.5623 6.65658 16.9528 7.0471C17.3433 7.43762 17.9765 7.43762 18.367 7.0471L19.777 5.6371Z"
fill="currentColor"
/>
</svg>
)
}
)
SunIcon.displayName = "SunIcon"

View File

@@ -0,0 +1,38 @@
import * as React from "react"
export const SuperscriptIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.7071 7.29289C13.0976 7.68342 13.0976 8.31658 12.7071 8.70711L4.70711 16.7071C4.31658 17.0976 3.68342 17.0976 3.29289 16.7071C2.90237 16.3166 2.90237 15.6834 3.29289 15.2929L11.2929 7.29289C11.6834 6.90237 12.3166 6.90237 12.7071 7.29289Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.29289 7.29289C3.68342 6.90237 4.31658 6.90237 4.70711 7.29289L12.7071 15.2929C13.0976 15.6834 13.0976 16.3166 12.7071 16.7071C12.3166 17.0976 11.6834 17.0976 11.2929 16.7071L3.29289 8.70711C2.90237 8.31658 2.90237 7.68342 3.29289 7.29289Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.405 1.40657C18.0246 1.05456 18.7463 0.92634 19.4492 1.04344C20.1521 1.16054 20.7933 1.51583 21.2652 2.0497L21.2697 2.05469L21.2696 2.05471C21.7431 2.5975 22 3.28922 22 4.00203C22 5.08579 21.3952 5.84326 20.7727 6.34289C20.1966 6.80531 19.4941 7.13675 18.9941 7.37261C18.9714 7.38332 18.9491 7.39383 18.9273 7.40415C18.4487 7.63034 18.2814 7.78152 18.1927 7.91844C18.1778 7.94155 18.1625 7.96834 18.1473 8.00003H21C21.5523 8.00003 22 8.44774 22 9.00003C22 9.55231 21.5523 10 21 10H17C16.4477 10 16 9.55231 16 9.00003C16 8.17007 16.1183 7.44255 16.5138 6.83161C16.9107 6.21854 17.4934 5.86971 18.0728 5.59591C18.6281 5.33347 19.1376 5.09075 19.5208 4.78316C19.8838 4.49179 20 4.25026 20 4.00203C20 3.77192 19.9178 3.54865 19.7646 3.37182C19.5968 3.18324 19.3696 3.05774 19.1205 3.01625C18.8705 2.97459 18.6137 3.02017 18.3933 3.14533C18.1762 3.26898 18.0191 3.45826 17.9406 3.67557C17.7531 4.19504 17.18 4.46414 16.6605 4.27662C16.141 4.0891 15.8719 3.51596 16.0594 2.99649C16.303 2.3219 16.7817 1.76125 17.4045 1.40689L17.405 1.40657Z"
fill="currentColor"
/>
</svg>
)
}
)
SuperscriptIcon.displayName = "SuperscriptIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const TrashIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 5V4C7 3.17477 7.40255 2.43324 7.91789 1.91789C8.43324 1.40255 9.17477 1 10 1H14C14.8252 1 15.5668 1.40255 16.0821 1.91789C16.5975 2.43324 17 3.17477 17 4V5H21C21.5523 5 22 5.44772 22 6C22 6.55228 21.5523 7 21 7H20V20C20 20.8252 19.5975 21.5668 19.0821 22.0821C18.5668 22.5975 17.8252 23 17 23H7C6.17477 23 5.43324 22.5975 4.91789 22.0821C4.40255 21.5668 4 20.8252 4 20V7H3C2.44772 7 2 6.55228 2 6C2 5.44772 2.44772 5 3 5H7ZM9 4C9 3.82523 9.09745 3.56676 9.33211 3.33211C9.56676 3.09745 9.82523 3 10 3H14C14.1748 3 14.4332 3.09745 14.6679 3.33211C14.9025 3.56676 15 3.82523 15 4V5H9V4ZM6 7V20C6 20.1748 6.09745 20.4332 6.33211 20.6679C6.56676 20.9025 6.82523 21 7 21H17C17.1748 21 17.4332 20.9025 17.6679 20.6679C17.9025 20.4332 18 20.1748 18 20V7H6Z"
fill="currentColor"
/>
</svg>
)
}
)
TrashIcon.displayName = "TrashIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const UnderlineIcon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M7 4C7 3.44772 6.55228 3 6 3C5.44772 3 5 3.44772 5 4V10C5 11.8565 5.7375 13.637 7.05025 14.9497C8.36301 16.2625 10.1435 17 12 17C13.8565 17 15.637 16.2625 16.9497 14.9497C18.2625 13.637 19 11.8565 19 10V4C19 3.44772 18.5523 3 18 3C17.4477 3 17 3.44772 17 4V10C17 11.3261 16.4732 12.5979 15.5355 13.5355C14.5979 14.4732 13.3261 15 12 15C10.6739 15 9.40215 14.4732 8.46447 13.5355C7.52678 12.5979 7 11.3261 7 10V4ZM4 19C3.44772 19 3 19.4477 3 20C3 20.5523 3.44772 21 4 21H20C20.5523 21 21 20.5523 21 20C21 19.4477 20.5523 19 20 19H4Z"
fill="currentColor"
/>
</svg>
)
}
)
UnderlineIcon.displayName = "UnderlineIcon"

View File

@@ -0,0 +1,26 @@
import * as React from "react"
export const Undo2Icon = React.memo(
({ className, ...props }: React.SVGProps<SVGSVGElement>) => {
return (
<svg
width="24"
height="24"
className={className}
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.70711 3.70711C10.0976 3.31658 10.0976 2.68342 9.70711 2.29289C9.31658 1.90237 8.68342 1.90237 8.29289 2.29289L3.29289 7.29289C2.90237 7.68342 2.90237 8.31658 3.29289 8.70711L8.29289 13.7071C8.68342 14.0976 9.31658 14.0976 9.70711 13.7071C10.0976 13.3166 10.0976 12.6834 9.70711 12.2929L6.41421 9H14.5C15.0909 9 15.6761 9.1164 16.2221 9.34254C16.768 9.56869 17.2641 9.90016 17.682 10.318C18.0998 10.7359 18.4313 11.232 18.6575 11.7779C18.8836 12.3239 19 12.9091 19 13.5C19 14.0909 18.8836 14.6761 18.6575 15.2221C18.4313 15.768 18.0998 16.2641 17.682 16.682C17.2641 17.0998 16.768 17.4313 16.2221 17.6575C15.6761 17.8836 15.0909 18 14.5 18H11C10.4477 18 10 18.4477 10 19C10 19.5523 10.4477 20 11 20H14.5C15.3536 20 16.1988 19.8319 16.9874 19.5052C17.7761 19.1786 18.4926 18.6998 19.0962 18.0962C19.6998 17.4926 20.1786 16.7761 20.5052 15.9874C20.8319 15.1988 21 14.3536 21 13.5C21 12.6464 20.8319 11.8012 20.5052 11.0126C20.1786 10.2239 19.6998 9.50739 19.0962 8.90381C18.4926 8.30022 17.7761 7.82144 16.9874 7.49478C16.1988 7.16813 15.3536 7 14.5 7H6.41421L9.70711 3.70711Z"
fill="currentColor"
/>
</svg>
)
}
)
Undo2Icon.displayName = "Undo2Icon"

View File

@@ -0,0 +1,37 @@
.tiptap.ProseMirror {
--blockquote-bg-color: var(--tt-gray-light-900);
.dark & {
--blockquote-bg-color: var(--tt-gray-dark-900);
}
}
/* =====================
BLOCKQUOTE
===================== */
.tiptap.ProseMirror {
blockquote {
position: relative;
padding-left: 1em;
padding-top: 0.375em;
padding-bottom: 0.375em;
margin: 1.5rem 0;
p {
margin-top: 0;
}
&::before,
&.is-empty::before {
position: absolute;
bottom: 0;
left: 0;
top: 0;
height: 100%;
width: 0.25em;
background-color: var(--blockquote-bg-color);
content: "";
border-radius: 0;
}
}
}

View File

@@ -0,0 +1,54 @@
.tiptap.ProseMirror {
--tt-inline-code-bg-color: var(--tt-gray-light-a-100);
--tt-inline-code-text-color: var(--tt-gray-light-a-700);
--tt-inline-code-border-color: var(--tt-gray-light-a-200);
--tt-codeblock-bg: var(--tt-gray-light-a-50);
--tt-codeblock-text: var(--tt-gray-light-a-800);
--tt-codeblock-border: var(--tt-gray-light-a-200);
.dark & {
--tt-inline-code-bg-color: var(--tt-gray-dark-a-100);
--tt-inline-code-text-color: var(--tt-gray-dark-a-700);
--tt-inline-code-border-color: var(--tt-gray-dark-a-200);
--tt-codeblock-bg: var(--tt-gray-dark-a-50);
--tt-codeblock-text: var(--tt-gray-dark-a-800);
--tt-codeblock-border: var(--tt-gray-dark-a-200);
}
}
/* =====================
CODE FORMATTING
===================== */
.tiptap.ProseMirror {
// Inline code
code {
background-color: var(--tt-inline-code-bg-color);
color: var(--tt-inline-code-text-color);
border: 1px solid var(--tt-inline-code-border-color);
font-family: "JetBrains Mono NL", monospace;
font-size: 0.875em;
line-height: 1.4;
border-radius: 6px/0.375rem;
padding: 0.1em 0.2em;
}
// Code blocks
pre {
background-color: var(--tt-codeblock-bg);
color: var(--tt-codeblock-text);
border: 1px solid var(--tt-codeblock-border);
margin-top: 1.5em;
margin-bottom: 1.5em;
padding: 1em;
font-size: 1rem;
border-radius: 6px/0.375rem;
code {
background-color: transparent;
border: none;
border-radius: 0;
-webkit-text-fill-color: inherit;
color: inherit;
}
}
}

View File

@@ -0,0 +1,38 @@
.tiptap.ProseMirror {
h1,
h2,
h3,
h4 {
position: relative;
color: inherit;
font-style: inherit;
&:first-child {
margin-top: 0;
}
}
h1 {
font-size: 1.5em;
font-weight: 700;
margin-top: 3em;
}
h2 {
font-size: 1.25em;
font-weight: 700;
margin-top: 2.5em;
}
h3 {
font-size: 1.125em;
font-weight: 600;
margin-top: 2em;
}
h4 {
font-size: 1em;
font-weight: 600;
margin-top: 2em;
}
}

View File

@@ -0,0 +1,14 @@
import { mergeAttributes } from "@tiptap/react"
import TiptapHorizontalRule from "@tiptap/extension-horizontal-rule"
export const HorizontalRule = TiptapHorizontalRule.extend({
renderHTML() {
return [
"div",
mergeAttributes(this.options.HTMLAttributes, { "data-type": this.name }),
["hr"],
]
},
})
export default HorizontalRule

View File

@@ -0,0 +1,25 @@
.tiptap.ProseMirror {
--horizontal-rule-color: var(--tt-gray-light-a-200);
.dark & {
--horizontal-rule-color: var(--tt-gray-dark-a-200);
}
}
/* =====================
HORIZONTAL RULE
===================== */
.tiptap.ProseMirror {
hr {
border: none;
height: 1px;
background-color: var(--horizontal-rule-color);
}
[data-type="horizontalRule"] {
margin-top: 2.25em;
margin-bottom: 2.25em;
padding-top: 0.75rem;
padding-bottom: 0.75rem;
}
}

View File

@@ -0,0 +1,31 @@
.tiptap.ProseMirror {
img {
max-width: 100%;
height: auto;
display: block;
}
> img:not([data-type="emoji"] img) {
margin: 2rem 0;
outline: 0.125rem solid transparent;
border-radius: var(--tt-radius-xs, 0.25rem);
}
img:not([data-type="emoji"] img).ProseMirror-selectednode {
outline-color: var(--tt-brand-color-500);
}
// Thread image handling
.tiptap-thread:has(> img) {
margin: 2rem 0;
img {
outline: 0.125rem solid transparent;
border-radius: var(--tt-radius-xs, 0.25rem);
}
}
.tiptap-thread img {
margin: 0;
}
}

View File

@@ -0,0 +1,162 @@
import type { NodeType } from "@tiptap/pm/model";
import { mergeAttributes, Node, ReactNodeViewRenderer } from "@tiptap/react";
import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/shared/tiptap/tiptap-node/image-upload-node/image-upload-node";
export type UploadFunction = (
file: File,
onProgress?: (event: { progress: number }) => void,
abortSignal?: AbortSignal,
) => Promise<string>;
export interface ImageUploadNodeOptions {
/**
* The type of the node.
* @default 'image'
*/
type?: string | NodeType | undefined;
/**
* Acceptable file types for upload.
* @default 'image/*'
*/
accept?: string;
/**
* Maximum number of files that can be uploaded.
* @default 1
*/
limit?: number;
/**
* Maximum file size in bytes (0 for unlimited).
* @default 0
*/
maxSize?: number;
/**
* Function to handle the upload process.
*/
upload?: UploadFunction;
/**
* Callback for upload errors.
*/
onError?: (error: Error) => void;
/**
* Callback for successful uploads.
*/
onSuccess?: (url: string) => void;
/**
* HTML attributes to add to the image element.
* @default {}
* @example { class: 'foo' }
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HTMLAttributes: Record<string, any>;
}
declare module "@tiptap/react" {
interface Commands<ReturnType> {
imageUpload: {
setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType;
};
}
}
/**
* A Tiptap node extension that creates an image upload component.
* @see registry/tiptap-node/image-upload-node/image-upload-node
*/
export const ImageUploadNode = Node.create<ImageUploadNodeOptions>({
name: "imageUpload",
group: "block",
draggable: true,
selectable: true,
atom: true,
addOptions() {
return {
type: "image",
accept: "image/*",
limit: 1,
maxSize: 0,
upload: undefined,
onError: undefined,
onSuccess: undefined,
HTMLAttributes: {},
};
},
addAttributes() {
return {
accept: {
default: this.options.accept,
},
limit: {
default: this.options.limit,
},
maxSize: {
default: this.options.maxSize,
},
};
},
parseHTML() {
return [{ tag: 'div[data-type="image-upload"]' }];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes({ "data-type": "image-upload" }, HTMLAttributes),
];
},
addNodeView() {
return ReactNodeViewRenderer(ImageUploadNodeComponent);
},
addCommands() {
return {
setImageUploadNode:
(options) =>
({ commands }) => {
return commands.insertContent({
type: this.name,
attrs: options,
});
},
};
},
/**
* Adds Enter key handler to trigger the upload component when it's selected.
*/
addKeyboardShortcuts() {
return {
Enter: ({ editor }) => {
const { selection } = editor.state;
const { nodeAfter } = selection.$from;
if (
nodeAfter &&
nodeAfter.type.name === "imageUpload" &&
editor.isActive("imageUpload")
) {
const nodeEl = editor.view.nodeDOM(selection.$from.pos);
if (nodeEl && nodeEl instanceof HTMLElement) {
// Since NodeViewWrapper is wrapped with a div, we need to click the first child
const firstChild = nodeEl.firstChild;
if (firstChild && firstChild instanceof HTMLElement) {
firstChild.click();
return true;
}
}
}
return false;
},
};
},
});
export default ImageUploadNode;

View File

@@ -0,0 +1,249 @@
:root {
--tiptap-image-upload-active: var(--tt-brand-color-500);
--tiptap-image-upload-progress-bg: var(--tt-brand-color-50);
--tiptap-image-upload-icon-bg: var(--tt-brand-color-500);
--tiptap-image-upload-text-color: var(--tt-gray-light-a-700);
--tiptap-image-upload-subtext-color: var(--tt-gray-light-a-400);
--tiptap-image-upload-border: var(--tt-gray-light-a-300);
--tiptap-image-upload-border-hover: var(--tt-gray-light-a-400);
--tiptap-image-upload-border-active: var(--tt-brand-color-500);
--tiptap-image-upload-icon-doc-bg: var(--tt-gray-light-a-200);
--tiptap-image-upload-icon-doc-border: var(--tt-gray-light-300);
--tiptap-image-upload-icon-color: var(--white);
}
.dark {
--tiptap-image-upload-active: var(--tt-brand-color-400);
--tiptap-image-upload-progress-bg: var(--tt-brand-color-900);
--tiptap-image-upload-icon-bg: var(--tt-brand-color-400);
--tiptap-image-upload-text-color: var(--tt-gray-dark-a-700);
--tiptap-image-upload-subtext-color: var(--tt-gray-dark-a-400);
--tiptap-image-upload-border: var(--tt-gray-dark-a-300);
--tiptap-image-upload-border-hover: var(--tt-gray-dark-a-400);
--tiptap-image-upload-border-active: var(--tt-brand-color-400);
--tiptap-image-upload-icon-doc-bg: var(--tt-gray-dark-a-200);
--tiptap-image-upload-icon-doc-border: var(--tt-gray-dark-300);
--tiptap-image-upload-icon-color: var(--black);
}
.tiptap-image-upload {
margin: 2rem 0;
input[type="file"] {
display: none;
}
.tiptap-image-upload-dropzone {
position: relative;
width: 3.125rem;
height: 3.75rem;
display: inline-flex;
align-items: flex-start;
justify-content: center;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none;
}
.tiptap-image-upload-icon-container {
position: absolute;
width: 1.75rem;
height: 1.75rem;
bottom: 0;
right: 0;
background-color: var(--tiptap-image-upload-icon-bg);
border-radius: var(--tt-radius-lg, 0.75rem);
display: flex;
align-items: center;
justify-content: center;
}
.tiptap-image-upload-icon {
width: 0.875rem;
height: 0.875rem;
color: var(--tiptap-image-upload-icon-color);
}
.tiptap-image-upload-dropzone-rect-primary {
color: var(--tiptap-image-upload-icon-doc-bg);
position: absolute;
}
.tiptap-image-upload-dropzone-rect-secondary {
position: absolute;
top: 0;
right: 0.25rem;
bottom: 0;
color: var(--tiptap-image-upload-icon-doc-border);
}
.tiptap-image-upload-text {
color: var(--tiptap-image-upload-text-color);
font-weight: 500;
font-size: 0.875rem;
line-height: normal;
em {
font-style: normal;
text-decoration: underline;
}
}
.tiptap-image-upload-subtext {
color: var(--tiptap-image-upload-subtext-color);
font-weight: 600;
line-height: normal;
font-size: 0.75rem;
}
.tiptap-image-upload-drag-area {
padding: 2rem 1.5rem;
border: 1.5px dashed var(--tiptap-image-upload-border);
border-radius: var(--tt-radius-md, 0.5rem);
text-align: center;
cursor: pointer;
position: relative;
overflow: hidden;
transition: all 0.2s ease;
&:hover {
border-color: var(--tiptap-image-upload-border-hover);
}
&.drag-active {
border-color: var(--tiptap-image-upload-border-active);
background-color: rgba(
var(--tiptap-image-upload-active-rgb, 0, 123, 255),
0.05
);
}
&.drag-over {
border-color: var(--tiptap-image-upload-border-active);
background-color: rgba(
var(--tiptap-image-upload-active-rgb, 0, 123, 255),
0.1
);
}
}
.tiptap-image-upload-content {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 0.25rem;
-webkit-user-select: none; /* Safari */
-ms-user-select: none; /* IE 10 and IE 11 */
user-select: none;
}
.tiptap-image-upload-previews {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tiptap-image-upload-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--tiptap-image-upload-border);
margin-bottom: 0.5rem;
span {
font-size: 0.875rem;
font-weight: 500;
color: var(--tiptap-image-upload-text-color);
}
}
// === Individual File Preview Styles ===
.tiptap-image-upload-preview {
position: relative;
border-radius: var(--tt-radius-md, 0.5rem);
overflow: hidden;
.tiptap-image-upload-progress {
position: absolute;
inset: 0;
background-color: var(--tiptap-image-upload-progress-bg);
transition: all 300ms ease-out;
}
.tiptap-image-upload-preview-content {
position: relative;
border: 1px solid var(--tiptap-image-upload-border);
border-radius: var(--tt-radius-md, 0.5rem);
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.tiptap-image-upload-file-info {
display: flex;
align-items: center;
gap: 0.75rem;
height: 2rem;
.tiptap-image-upload-file-icon {
padding: 0.5rem;
background-color: var(--tiptap-image-upload-icon-bg);
border-radius: var(--tt-radius-lg, 0.75rem);
svg {
width: 0.875rem;
height: 0.875rem;
color: var(--tiptap-image-upload-icon-color);
}
}
}
.tiptap-image-upload-details {
display: flex;
flex-direction: column;
}
.tiptap-image-upload-actions {
display: flex;
align-items: center;
gap: 0.5rem;
.tiptap-image-upload-progress-text {
font-size: 0.75rem;
color: var(--tiptap-image-upload-border-active);
font-weight: 600;
}
}
}
}
.tiptap.ProseMirror.ProseMirror-focused {
.ProseMirror-selectednode .tiptap-image-upload-drag-area {
border-color: var(--tiptap-image-upload-active);
}
}
@media (max-width: 480px) {
.tiptap-image-upload {
.tiptap-image-upload-drag-area {
padding: 1.5rem 1rem;
}
.tiptap-image-upload-header {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.tiptap-image-upload-preview-content {
padding: 0.75rem;
}
}
}

View File

@@ -0,0 +1,557 @@
"use client";
import * as React from "react";
import type { NodeViewProps } from "@tiptap/react";
import { NodeViewWrapper } from "@tiptap/react";
import { CloseIcon } from "@/components/shared/tiptap/tiptap-icons/close-icon";
import { Button } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
import "@/components/shared/tiptap/tiptap-node/image-upload-node/image-upload-node.scss";
import { focusNextNode, isValidPosition } from "@/lib/tiptap-utils";
export interface FileItem {
/**
* Unique identifier for the file item
*/
id: string;
/**
* The actual File object being uploaded
*/
file: File;
/**
* Current upload progress as a percentage (0-100)
*/
progress: number;
/**
* Current status of the file upload process
* @default "uploading"
*/
status: "uploading" | "success" | "error";
/**
* URL to the uploaded file, available after successful upload
* @optional
*/
url?: string;
/**
* Controller that can be used to abort the upload process
* @optional
*/
abortController?: AbortController;
}
export interface UploadOptions {
/**
* Maximum allowed file size in bytes
*/
maxSize: number;
/**
* Maximum number of files that can be uploaded
*/
limit: number;
/**
* String specifying acceptable file types (MIME types or extensions)
* @example ".jpg,.png,image/jpeg" or "image/*"
*/
accept: string;
/**
* Function that handles the actual file upload process
* @param {File} file - The file to be uploaded
* @param {Function} onProgress - Callback function to report upload progress
* @param {AbortSignal} signal - Signal that can be used to abort the upload
* @returns {Promise<string>} Promise resolving to the URL of the uploaded file
*/
upload: (
file: File,
onProgress: (event: { progress: number }) => void,
signal: AbortSignal,
) => Promise<string>;
/**
* Callback triggered when a file is uploaded successfully
* @param {string} url - URL of the successfully uploaded file
* @optional
*/
onSuccess?: (url: string) => void;
/**
* Callback triggered when an error occurs during upload
* @param {Error} error - The error that occurred
* @optional
*/
onError?: (error: Error) => void;
}
/**
* Custom hook for managing multiple file uploads with progress tracking and cancellation
*/
function useFileUpload(options: UploadOptions) {
const [fileItems, setFileItems] = React.useState<FileItem[]>([]);
const uploadFile = async (file: File): Promise<string | null> => {
if (file.size > options.maxSize) {
const error = new Error(
`File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`,
);
options.onError?.(error);
return null;
}
const abortController = new AbortController();
const fileId = crypto.randomUUID();
const newFileItem: FileItem = {
id: fileId,
file,
progress: 0,
status: "uploading",
abortController,
};
setFileItems((prev) => [...prev, newFileItem]);
try {
if (!options.upload) {
throw new Error("Upload function is not defined");
}
const url = await options.upload(
file,
(event: { progress: number }) => {
setFileItems((prev) =>
prev.map((item) =>
item.id === fileId ? { ...item, progress: event.progress } : item,
),
);
},
abortController.signal,
);
if (!url) throw new Error("Upload failed: No URL returned");
if (!abortController.signal.aborted) {
setFileItems((prev) =>
prev.map((item) =>
item.id === fileId
? { ...item, status: "success", url, progress: 100 }
: item,
),
);
options.onSuccess?.(url);
return url;
}
return null;
} catch (error) {
if (!abortController.signal.aborted) {
setFileItems((prev) =>
prev.map((item) =>
item.id === fileId
? { ...item, status: "error", progress: 0 }
: item,
),
);
options.onError?.(
error instanceof Error ? error : new Error("Upload failed"),
);
}
return null;
}
};
const uploadFiles = async (files: File[]): Promise<string[]> => {
if (!files || files.length === 0) {
options.onError?.(new Error("No files to upload"));
return [];
}
if (options.limit && files.length > options.limit) {
options.onError?.(
new Error(
`Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`,
),
);
return [];
}
// Upload all files concurrently
const uploadPromises = files.map((file) => uploadFile(file));
const results = await Promise.all(uploadPromises);
// Filter out null results (failed uploads)
return results.filter((url): url is string => url !== null);
};
const removeFileItem = (fileId: string) => {
setFileItems((prev) => {
const fileToRemove = prev.find((item) => item.id === fileId);
if (fileToRemove?.abortController) {
fileToRemove.abortController.abort();
}
if (fileToRemove?.url) {
URL.revokeObjectURL(fileToRemove.url);
}
return prev.filter((item) => item.id !== fileId);
});
};
const clearAllFiles = () => {
fileItems.forEach((item) => {
if (item.abortController) {
item.abortController.abort();
}
if (item.url) {
URL.revokeObjectURL(item.url);
}
});
setFileItems([]);
};
return {
fileItems,
uploadFiles,
removeFileItem,
clearAllFiles,
};
}
const CloudUploadIcon: React.FC = () => (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
className="tiptap-image-upload-icon"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.1953 4.41771C10.3478 4.08499 9.43578 3.94949 8.5282 4.02147C7.62062 4.09345 6.74133 4.37102 5.95691 4.83316C5.1725 5.2953 4.50354 5.92989 4.00071 6.68886C3.49788 7.44783 3.17436 8.31128 3.05465 9.2138C2.93495 10.1163 3.0222 11.0343 3.3098 11.8981C3.5974 12.7619 4.07781 13.5489 4.71463 14.1995C5.10094 14.5942 5.09414 15.2274 4.69945 15.6137C4.30476 16 3.67163 15.9932 3.28532 15.5985C2.43622 14.731 1.79568 13.6816 1.41221 12.5299C1.02875 11.3781 0.91241 10.1542 1.07201 8.95084C1.23162 7.74748 1.66298 6.59621 2.33343 5.58425C3.00387 4.57229 3.89581 3.72617 4.9417 3.10998C5.98758 2.4938 7.15998 2.1237 8.37008 2.02773C9.58018 1.93176 10.7963 2.11243 11.9262 2.55605C13.0561 2.99968 14.0703 3.69462 14.8919 4.58825C15.5423 5.29573 16.0585 6.11304 16.4177 7.00002H17.4999C18.6799 6.99991 19.8288 7.37933 20.7766 8.08222C21.7245 8.78515 22.4212 9.7743 22.7637 10.9036C23.1062 12.0328 23.0765 13.2423 22.6788 14.3534C22.2812 15.4644 21.5367 16.4181 20.5554 17.0736C20.0962 17.3803 19.4752 17.2567 19.1684 16.7975C18.8617 16.3382 18.9853 15.7172 19.4445 15.4105C20.069 14.9934 20.5427 14.3865 20.7958 13.6794C21.0488 12.9724 21.0678 12.2027 20.8498 11.4841C20.6318 10.7655 20.1885 10.136 19.5853 9.6887C18.9821 9.24138 18.251 8.99993 17.5001 9.00002H15.71C15.2679 9.00002 14.8783 8.70973 14.7518 8.28611C14.4913 7.41374 14.0357 6.61208 13.4195 5.94186C12.8034 5.27164 12.0427 4.75043 11.1953 4.41771Z"
fill="currentColor"
/>
<path
d="M11 14.4142V21C11 21.5523 11.4477 22 12 22C12.5523 22 13 21.5523 13 21V14.4142L15.2929 16.7071C15.6834 17.0976 16.3166 17.0976 16.7071 16.7071C17.0976 16.3166 17.0976 15.6834 16.7071 15.2929L12.7078 11.2936C12.7054 11.2912 12.703 11.2888 12.7005 11.2864C12.5208 11.1099 12.2746 11.0008 12.003 11L12 11L11.997 11C11.8625 11.0004 11.7343 11.0273 11.6172 11.0759C11.502 11.1236 11.3938 11.1937 11.2995 11.2864C11.297 11.2888 11.2946 11.2912 11.2922 11.2936L7.29289 15.2929C6.90237 15.6834 6.90237 16.3166 7.29289 16.7071C7.68342 17.0976 8.31658 17.0976 8.70711 16.7071L11 14.4142Z"
fill="currentColor"
/>
</svg>
);
const FileIcon: React.FC = () => (
<svg
width="43"
height="57"
viewBox="0 0 43 57"
fill="currentColor"
className="tiptap-image-upload-dropzone-rect-primary"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0.75 10.75C0.75 5.64137 4.89137 1.5 10 1.5H32.3431C33.2051 1.5 34.0317 1.84241 34.6412 2.4519L40.2981 8.10876C40.9076 8.71825 41.25 9.5449 41.25 10.4069V46.75C41.25 51.8586 37.1086 56 32 56H10C4.89137 56 0.75 51.8586 0.75 46.75V10.75Z"
fill="currentColor"
fillOpacity="0.11"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);
const FileCornerIcon: React.FC = () => (
<svg
width="10"
height="10"
className="tiptap-image-upload-dropzone-rect-secondary"
viewBox="0 0 10 10"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M0 0.75H0.343146C1.40401 0.75 2.42143 1.17143 3.17157 1.92157L8.82843 7.57843C9.57857 8.32857 10 9.34599 10 10.4069V10.75H4C1.79086 10.75 0 8.95914 0 6.75V0.75Z"
fill="currentColor"
/>
</svg>
);
interface ImageUploadDragAreaProps {
/**
* Callback function triggered when files are dropped or selected
* @param {File[]} files - Array of File objects that were dropped or selected
*/
onFile: (files: File[]) => void;
/**
* Optional child elements to render inside the drag area
* @optional
* @default undefined
*/
children?: React.ReactNode;
}
/**
* A component that creates a drag-and-drop area for image uploads
*/
const ImageUploadDragArea: React.FC<ImageUploadDragAreaProps> = ({
onFile,
children,
}) => {
const [isDragOver, setIsDragOver] = React.useState(false);
const [isDragActive, setIsDragActive] = React.useState(false);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
setIsDragActive(false);
setIsDragOver(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragActive(false);
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onFile(files);
}
};
return (
<div
className={`tiptap-image-upload-drag-area ${isDragActive ? "drag-active" : ""} ${isDragOver ? "drag-over" : ""}`}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children}
</div>
);
};
interface ImageUploadPreviewProps {
/**
* The file item to preview
*/
fileItem: FileItem;
/**
* Callback to remove this file from upload queue
*/
onRemove: () => void;
}
/**
* Component that displays a preview of an uploading file with progress
*/
const ImageUploadPreview: React.FC<ImageUploadPreviewProps> = ({
fileItem,
onRemove,
}) => {
const formatFileSize = (bytes: number) => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<div className="tiptap-image-upload-preview">
{fileItem.status === "uploading" && (
<div
className="tiptap-image-upload-progress"
style={{ width: `${fileItem.progress}%` }}
/>
)}
<div className="tiptap-image-upload-preview-content">
<div className="tiptap-image-upload-file-info">
<div className="tiptap-image-upload-file-icon">
<CloudUploadIcon />
</div>
<div className="tiptap-image-upload-details">
<span className="tiptap-image-upload-text">
{fileItem.file.name}
</span>
<span className="tiptap-image-upload-subtext">
{formatFileSize(fileItem.file.size)}
</span>
</div>
</div>
<div className="tiptap-image-upload-actions">
{fileItem.status === "uploading" && (
<span className="tiptap-image-upload-progress-text">
{fileItem.progress}%
</span>
)}
<Button
type="button"
data-style="ghost"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<CloseIcon className="tiptap-button-icon" />
</Button>
</div>
</div>
</div>
);
};
const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({
maxSize,
limit,
}) => (
<>
<div className="tiptap-image-upload-dropzone">
<FileIcon />
<FileCornerIcon />
<div className="tiptap-image-upload-icon-container">
<CloudUploadIcon />
</div>
</div>
<div className="tiptap-image-upload-content">
<span className="tiptap-image-upload-text">
<em>Click to upload</em> or drag and drop
</span>
<span className="tiptap-image-upload-subtext">
Maximum {limit} file{limit === 1 ? "" : "s"}, {maxSize / 1024 / 1024}MB
each.
</span>
</div>
</>
);
export const ImageUploadNode: React.FC<NodeViewProps> = (props) => {
const { accept, limit, maxSize } = props.node.attrs;
const inputRef = React.useRef<HTMLInputElement>(null);
const extension = props.extension;
const uploadOptions: UploadOptions = {
maxSize,
limit,
accept,
upload: extension.options.upload,
onSuccess: extension.options.onSuccess,
onError: extension.options.onError,
};
const { fileItems, uploadFiles, removeFileItem, clearAllFiles } =
useFileUpload(uploadOptions);
const handleUpload = async (files: File[]) => {
const urls = await uploadFiles(files);
if (urls.length > 0) {
const pos = props.getPos();
if (isValidPosition(pos)) {
const imageNodes = urls.map((url, index) => {
const filename =
files[index]?.name.replace(/\.[^/.]+$/, "") || "unknown";
return {
type: extension.options.type,
attrs: {
...extension.options,
src: url,
alt: filename,
title: filename,
},
};
});
props.editor
.chain()
.focus()
.deleteRange({ from: pos, to: pos + props.node.nodeSize })
.insertContentAt(pos, imageNodes)
.run();
focusNextNode(props.editor);
}
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) {
extension.options.onError?.(new Error("No file selected"));
return;
}
handleUpload(Array.from(files));
};
const handleClick = () => {
if (inputRef.current && fileItems.length === 0) {
inputRef.current.value = "";
inputRef.current.click();
}
};
const hasFiles = fileItems.length > 0;
return (
<NodeViewWrapper
className="tiptap-image-upload"
tabIndex={0}
onClick={handleClick}
>
{!hasFiles && (
<ImageUploadDragArea onFile={handleUpload}>
<DropZoneContent maxSize={maxSize} limit={limit} />
</ImageUploadDragArea>
)}
{hasFiles && (
<div className="tiptap-image-upload-previews">
{fileItems.length > 1 && (
<div className="tiptap-image-upload-header">
<span>Uploading {fileItems.length} files</span>
<Button
type="button"
data-style="ghost"
onClick={(e) => {
e.stopPropagation();
clearAllFiles();
}}
>
Clear All
</Button>
</div>
)}
{fileItems.map((fileItem) => (
<ImageUploadPreview
key={fileItem.id}
fileItem={fileItem}
onRemove={() => removeFileItem(fileItem.id)}
/>
))}
</div>
)}
<input
ref={inputRef}
name="file"
accept={accept}
type="file"
multiple={limit > 1}
onChange={handleChange}
onClick={(e: React.MouseEvent<HTMLInputElement>) => e.stopPropagation()}
/>
</NodeViewWrapper>
);
};

View File

@@ -0,0 +1 @@
export * from "./image-upload-node-extension"

View File

@@ -0,0 +1,160 @@
.tiptap.ProseMirror {
--tt-checklist-bg-color: var(--tt-gray-light-a-100);
--tt-checklist-bg-active-color: var(--tt-gray-light-a-900);
--tt-checklist-border-color: var(--tt-gray-light-a-200);
--tt-checklist-border-active-color: var(--tt-gray-light-a-900);
--tt-checklist-check-icon-color: var(--white);
--tt-checklist-text-active: var(--tt-gray-light-a-500);
.dark & {
--tt-checklist-bg-color: var(--tt-gray-dark-a-100);
--tt-checklist-bg-active-color: var(--tt-gray-dark-a-900);
--tt-checklist-border-color: var(--tt-gray-dark-a-200);
--tt-checklist-border-active-color: var(--tt-gray-dark-a-900);
--tt-checklist-check-icon-color: var(--black);
--tt-checklist-text-active: var(--tt-gray-dark-a-500);
}
}
/* =====================
LISTS
===================== */
.tiptap.ProseMirror {
// Common list styles
ol,
ul {
margin-top: 1.5em;
margin-bottom: 1.5em;
padding-left: 1.5em;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
ol,
ul {
margin-top: 0;
margin-bottom: 0;
}
}
li {
p {
margin-top: 0;
line-height: 1.6;
}
}
// Ordered lists
ol {
list-style: decimal;
ol {
list-style: lower-alpha;
ol {
list-style: lower-roman;
}
}
}
// Unordered lists
ul:not([data-type="taskList"]) {
list-style: disc;
ul {
list-style: circle;
ul {
list-style: square;
}
}
}
// Task lists
ul[data-type="taskList"] {
padding-left: 0.25em;
li {
display: flex;
flex-direction: row;
align-items: flex-start;
&:not(:has(> p:first-child)) {
list-style-type: none;
}
&[data-checked="true"] {
> div > p {
opacity: 0.5;
text-decoration: line-through;
}
> div > p span {
text-decoration: line-through;
}
}
label {
position: relative;
padding-top: 0.375rem;
padding-right: 0.5rem;
input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
span {
display: block;
width: 1em;
height: 1em;
border: 1px solid var(--tt-checklist-border-color);
border-radius: var(--tt-radius-xs, 0.25rem);
position: relative;
cursor: pointer;
background-color: var(--tt-checklist-bg-color);
transition:
background-color 80ms ease-out,
border-color 80ms ease-out;
&::before {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 0.75em;
height: 0.75em;
background-color: var(--tt-checklist-check-icon-color);
opacity: 0;
-webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E")
center/contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E")
center/contain no-repeat;
}
}
input[type="checkbox"]:checked + span {
background: var(--tt-checklist-bg-active-color);
border-color: var(--tt-checklist-border-active-color);
&::before {
opacity: 1;
}
}
}
div {
flex: 1 1 0%;
min-width: 0;
}
}
}
}

View File

@@ -0,0 +1,272 @@
.tiptap.ProseMirror {
--tt-collaboration-carets-label: var(--tt-gray-light-900);
--link-text-color: var(--tt-brand-color-500);
--thread-text: var(--tt-gray-light-900);
--placeholder-color: var(--tt-gray-light-a-400);
--thread-bg-color: var(--tt-color-yellow-inc-2);
// ai
--tiptap-ai-insertion-color: var(--tt-brand-color-600);
.dark & {
--tt-collaboration-carets-label: var(--tt-gray-dark-100);
--link-text-color: var(--tt-brand-color-400);
--thread-text: var(--tt-gray-dark-900);
--placeholder-color: var(--tt-gray-dark-a-400);
--thread-bg-color: var(--tt-color-yellow-dec-2);
--tiptap-ai-insertion-color: var(--tt-brand-color-400);
}
}
/* Ensure each top-level node has relative positioning
so absolutely positioned placeholders work correctly */
.tiptap.ProseMirror > * {
position: relative;
}
/* =====================
CORE EDITOR STYLES
===================== */
.tiptap.ProseMirror {
white-space: pre-wrap;
outline: none;
caret-color: var(--tt-cursor-color);
// Paragraph spacing
p:not(:first-child) {
font-size: 1rem;
line-height: 1.6;
font-weight: normal;
margin-top: 20px;
}
// Selection styles
&:not(.readonly):not(.ProseMirror-hideselection) {
::selection {
background-color: var(--tt-selection-color);
}
.selection::selection {
background: transparent;
}
}
.selection {
display: inline;
background-color: var(--tt-selection-color);
}
// Selected node styles
.ProseMirror-selectednode:not(img):not(pre):not(.react-renderer) {
border-radius: var(--tt-radius-md);
background-color: var(--tt-selection-color);
}
.ProseMirror-hideselection {
caret-color: transparent;
}
// Resize cursor
&.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
}
/* =====================
TEXT DECORATION
===================== */
.tiptap.ProseMirror {
// Text decoration inheritance for spans
a span {
text-decoration: underline;
}
s span {
text-decoration: line-through;
}
u span {
text-decoration: underline;
}
.tiptap-ai-insertion {
color: var(--tiptap-ai-insertion-color);
}
}
/* =====================
COLLABORATION
===================== */
.tiptap.ProseMirror {
.collaboration-carets {
&__caret {
border-right: 1px solid transparent;
border-left: 1px solid transparent;
pointer-events: none;
margin-left: -1px;
margin-right: -1px;
position: relative;
word-break: normal;
}
&__label {
color: var(--tt-collaboration-carets-label);
border-radius: 0.25rem;
border-bottom-left-radius: 0;
font-size: 0.75rem;
font-weight: 600;
left: -1px;
line-height: 1;
padding: 0.125rem 0.375rem;
position: absolute;
top: -1.3em;
user-select: none;
white-space: nowrap;
}
}
}
/* =====================
EMOJI
===================== */
.tiptap.ProseMirror [data-type="emoji"] img {
display: inline-block;
width: 1.25em;
height: 1.25em;
cursor: text;
}
/* =====================
LINKS
===================== */
.tiptap.ProseMirror {
a {
color: var(--link-text-color);
text-decoration: underline;
}
}
/* =====================
MENTION
===================== */
.tiptap.ProseMirror {
[data-type="mention"] {
display: inline-block;
color: var(--tt-brand-color-500);
}
}
/* =====================
THREADS
===================== */
.tiptap.ProseMirror {
// Base styles for inline threads
.tiptap-thread.tiptap-thread--unresolved.tiptap-thread--inline {
transition:
color 0.2s ease-in-out,
background-color 0.2s ease-in-out;
color: var(--thread-text);
border-bottom: 2px dashed var(--tt-color-yellow-base);
font-weight: 600;
&.tiptap-thread--selected,
&.tiptap-thread--hovered {
background-color: var(--thread-bg-color);
border-bottom-color: transparent;
}
}
// Block thread styles with images
.tiptap-thread.tiptap-thread--unresolved.tiptap-thread--block {
&:has(img) {
outline: 0.125rem solid var(--tt-color-yellow-base);
border-radius: var(--tt-radius-xs, 0.25rem);
overflow: hidden;
width: fit-content;
&.tiptap-thread--selected {
outline-width: 0.25rem;
outline-color: var(--tt-color-yellow-base);
}
&.tiptap-thread--hovered {
outline-width: 0.25rem;
}
}
// Block thread styles without images
&:not(:has(img)) {
border-radius: 0.25rem;
border-bottom: 0.125rem dashed var(--tt-color-yellow-base);
padding-bottom: 0.5rem;
outline: 0.25rem solid transparent;
&.tiptap-thread--hovered,
&.tiptap-thread--selected {
background-color: var(--tt-color-yellow-base);
outline-color: var(--tt-color-yellow-base);
}
}
}
// Resolved thread styles
.tiptap-thread.tiptap-thread--resolved.tiptap-thread--inline.tiptap-thread--selected {
background-color: var(--tt-color-yellow-base);
border-color: transparent;
opacity: 0.5;
}
// React renderer specific styles
.tiptap-thread.tiptap-thread--block:has(.react-renderer) {
margin-top: 3rem;
margin-bottom: 3rem;
}
}
/* =====================
PLACEHOLDER
===================== */
.is-empty:not(.with-slash)[data-placeholder]:has(
> .ProseMirror-trailingBreak:only-child
)::before {
content: attr(data-placeholder);
}
.is-empty.with-slash[data-placeholder]:has(
> .ProseMirror-trailingBreak:only-child
)::before {
content: "Write, type '/' for commands…";
font-style: italic;
}
.is-empty[data-placeholder]:has(
> .ProseMirror-trailingBreak:only-child
):before {
pointer-events: none;
height: 0;
position: absolute;
width: 100%;
text-align: inherit;
left: 0;
right: 0;
}
.is-empty[data-placeholder]:has(> .ProseMirror-trailingBreak):before {
color: var(--placeholder-color);
}
/* =====================
DROPCURSOR
===================== */
.prosemirror-dropcursor-block,
.prosemirror-dropcursor-inline {
background: var(--tt-brand-color-400) !important;
border-radius: 0.25rem;
margin-left: -1px;
margin-right: -1px;
width: 100%;
height: 0.188rem;
cursor: grabbing;
}

View File

@@ -0,0 +1,477 @@
{
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"textAlign": null,
"level": 1
},
"content": [
{
"type": "text",
"text": "Getting started"
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": null
},
"content": [
{
"type": "text",
"text": "Welcome to the "
},
{
"type": "text",
"marks": [
{
"type": "italic"
},
{
"type": "highlight",
"attrs": {
"color": "var(--tt-color-highlight-yellow)"
}
}
],
"text": "Simple Editor"
},
{
"type": "text",
"text": " template! This template integrates "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "open source"
},
{
"type": "text",
"text": " UI components and Tiptap extensions licensed under "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "MIT"
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": null
},
"content": [
{
"type": "text",
"text": "Integrate it by following the "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://tiptap.dev/docs/ui-components/templates/simple-editor",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": null
}
}
],
"text": "Tiptap UI Components docs"
},
{
"type": "text",
"text": " or using our CLI tool."
}
]
},
{
"type": "codeBlock",
"attrs": {
"language": null
},
"content": [
{
"type": "text",
"text": "npx @tiptap/cli init"
}
]
},
{
"type": "heading",
"attrs": {
"textAlign": null,
"level": 2
},
"content": [
{
"type": "text",
"text": "Features"
}
]
},
{
"type": "blockquote",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": null
},
"content": [
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "A fully responsive rich text editor with built-in support for common formatting and layout tools. Type markdown "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "**"
},
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": " or use keyboard shortcuts "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "⌘+B"
},
{
"type": "text",
"text": " for "
},
{
"type": "text",
"marks": [
{
"type": "strike"
}
],
"text": "most"
},
{
"type": "text",
"text": " all common markdown marks. 🪄"
}
]
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Add images, customize alignment, and apply "
},
{
"type": "text",
"marks": [
{
"type": "highlight",
"attrs": {
"color": "var(--tt-color-highlight-blue)"
}
}
],
"text": "advanced formatting"
},
{
"type": "text",
"text": " to make your writing more engaging and professional."
}
]
},
{
"type": "image",
"attrs": {
"src": "/images/tiptap-ui-placeholder-image.jpg",
"alt": "placeholder-image",
"title": "placeholder-image"
}
},
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Superscript"
},
{
"type": "text",
"text": " (x"
},
{
"type": "text",
"marks": [
{
"type": "superscript"
}
],
"text": "2"
},
{
"type": "text",
"text": ") and "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Subscript"
},
{
"type": "text",
"text": " (H"
},
{
"type": "text",
"marks": [
{
"type": "subscript"
}
],
"text": "2"
},
{
"type": "text",
"text": "O) for precision."
}
]
}
]
},
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Typographic conversion"
},
{
"type": "text",
"text": ": automatically convert to "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "->"
},
{
"type": "text",
"text": " an arrow "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "→"
},
{
"type": "text",
"text": "."
}
]
}
]
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "→ "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://tiptap.dev/docs/ui-components/templates/simple-editor#features",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": null
}
}
],
"text": "Learn more"
}
]
},
{
"type": "horizontalRule"
},
{
"type": "heading",
"attrs": {
"textAlign": "left",
"level": 2
},
"content": [
{
"type": "text",
"text": "Make it your own"
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Switch between light and dark modes, and tailor the editor's appearance with customizable CSS to match your style."
}
]
},
{
"type": "taskList",
"content": [
{
"type": "taskItem",
"attrs": {
"checked": true
},
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"text": "Test template"
}
]
}
]
},
{
"type": "taskItem",
"attrs": {
"checked": false
},
"content": [
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
},
"content": [
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://tiptap.dev/docs/ui-components/templates/simple-editor",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": null
}
}
],
"text": "Integrate the free template"
}
]
}
]
}
]
},
{
"type": "paragraph",
"attrs": {
"textAlign": "left"
}
}
]
}

View File

@@ -0,0 +1,83 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
body {
--tt-toolbar-height: 44px;
--tt-theme-text: var(--tt-gray-light-900);
.dark & {
--tt-theme-text: var(--tt-gray-dark-900);
}
}
body {
font-family: "Inter", sans-serif;
color: var(--tt-theme-text);
font-optical-sizing: auto;
font-weight: 400;
font-style: normal;
padding: 0;
overscroll-behavior-y: none;
}
html,
body {
overscroll-behavior-x: none;
}
html,
body,
#root,
#app {
height: 100%;
background-color: var(--tt-bg-color);
}
::-webkit-scrollbar {
width: 0.25rem;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--tt-scrollbar-color) transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--tt-scrollbar-color);
border-radius: 9999px;
}
::-webkit-scrollbar-track {
background: transparent;
}
.tiptap.ProseMirror {
font-family: "DM Sans", sans-serif;
}
.simple-editor-wrapper {
width: 100%;
height: 100%;
overflow: auto;
}
.simple-editor-content {
// max-width: 648px;
width: 100%;
// margin: 0 auto;
height: 100%;
display: flex;
flex-direction: column;
flex: 1;
}
.simple-editor-content .tiptap.ProseMirror.simple-editor {
flex: 1;
// padding: 3rem 3rem 30vh;
padding: 1rem;
}
@media screen and (max-width: 480px) {
.simple-editor-content .tiptap.ProseMirror.simple-editor {
padding: 1rem 1.5rem 30vh;
}
}

View File

@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
// --- UI Primitives ---
import { Button } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
import { Spacer } from "@/components/shared/tiptap/tiptap-ui-primitive/spacer";
import {
ToolbarGroup,
ToolbarSeparator,
} from "@/components/shared/tiptap/tiptap-ui-primitive/toolbar";
import "@/components/shared/tiptap/tiptap-node/blockquote-node/blockquote-node.scss";
import "@/components/shared/tiptap/tiptap-node/code-block-node/code-block-node.scss";
import "@/components/shared/tiptap/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss";
import "@/components/shared/tiptap/tiptap-node/list-node/list-node.scss";
import "@/components/shared/tiptap/tiptap-node/image-node/image-node.scss";
import "@/components/shared/tiptap/tiptap-node/heading-node/heading-node.scss";
import "@/components/shared/tiptap/tiptap-node/paragraph-node/paragraph-node.scss";
// --- Icons ---
import { ArrowLeftIcon } from "@/components/shared/tiptap/tiptap-icons/arrow-left-icon";
import { HighlighterIcon } from "@/components/shared/tiptap/tiptap-icons/highlighter-icon";
import { LinkIcon } from "@/components/shared/tiptap/tiptap-icons/link-icon";
// --- Components ---
import { BlockquoteButton } from "@/components/shared/tiptap/tiptap-ui/blockquote-button";
import { CodeBlockButton } from "@/components/shared/tiptap/tiptap-ui/code-block-button";
import {
ColorHighlightPopover,
ColorHighlightPopoverButton,
ColorHighlightPopoverContent,
} from "@/components/shared/tiptap/tiptap-ui/color-highlight-popover";
// --- Tiptap UI ---
import { HeadingDropdownMenu } from "@/components/shared/tiptap/tiptap-ui/heading-dropdown-menu";
import { ImageUploadButton } from "@/components/shared/tiptap/tiptap-ui/image-upload-button";
import {
LinkButton,
LinkContent,
LinkPopover,
} from "@/components/shared/tiptap/tiptap-ui/link-popover";
import { ListDropdownMenu } from "@/components/shared/tiptap/tiptap-ui/list-dropdown-menu";
import { MarkButton } from "@/components/shared/tiptap/tiptap-ui/mark-button";
import { TextAlignButton } from "@/components/shared/tiptap/tiptap-ui/text-align-button";
import { UndoRedoButton } from "@/components/shared/tiptap/tiptap-ui/undo-redo-button";
// --- Styles ---
import "@/components/shared/tiptap/tiptap-templates/simple/simple-editor.scss";
export const MainToolbarContent = ({
onHighlighterClick,
onLinkClick,
isMobile,
}: {
onHighlighterClick: () => void;
onLinkClick: () => void;
isMobile: boolean;
}) => {
return (
<>
{/* <Spacer /> */}
<ToolbarGroup>
<UndoRedoButton action="undo" />
<UndoRedoButton action="redo" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<HeadingDropdownMenu levels={[1, 2, 3, 4]} portal={isMobile} />
<ListDropdownMenu
types={["bulletList", "orderedList", "taskList"]}
portal={isMobile}
/>
<BlockquoteButton />
<CodeBlockButton />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<MarkButton type="bold" />
<MarkButton type="italic" />
<MarkButton type="strike" />
<MarkButton type="code" />
<MarkButton type="underline" />
{!isMobile ? (
<ColorHighlightPopover />
) : (
<ColorHighlightPopoverButton onClick={onHighlighterClick} />
)}
{!isMobile ? <LinkPopover /> : <LinkButton onClick={onLinkClick} />}
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<MarkButton type="superscript" />
<MarkButton type="subscript" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<TextAlignButton align="left" />
<TextAlignButton align="center" />
<TextAlignButton align="right" />
<TextAlignButton align="justify" />
</ToolbarGroup>
<ToolbarSeparator />
<ToolbarGroup>
<ImageUploadButton />
</ToolbarGroup>
<ToolbarSeparator />
{/* <Spacer /> */}
{/* {isMobile && <ToolbarSeparator />} */}
{/* <ToolbarGroup>
<ThemeToggle />
</ToolbarGroup> */}
</>
);
};
export const MobileToolbarContent = ({
type,
onBack,
}: {
type: "highlighter" | "link";
onBack: () => void;
}) => (
<>
<ToolbarGroup>
<Button data-style="ghost" onClick={onBack}>
<ArrowLeftIcon className="tiptap-button-icon" />
{type === "highlighter" ? (
<HighlighterIcon className="tiptap-button-icon" />
) : (
<LinkIcon className="tiptap-button-icon" />
)}
</Button>
</ToolbarGroup>
<ToolbarSeparator />
{type === "highlighter" ? (
<ColorHighlightPopoverContent />
) : (
<LinkContent />
)}
</>
);

View File

@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
// --- Icons ---
import { MoonStarIcon } from "@/components/shared/tiptap/tiptap-icons/moon-star-icon";
import { SunIcon } from "@/components/shared/tiptap/tiptap-icons/sun-icon";
// --- UI Primitives ---
import { Button } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
export function ThemeToggle() {
const [isDarkMode, setIsDarkMode] = React.useState<boolean>(false);
React.useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => setIsDarkMode(mediaQuery.matches);
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, []);
React.useEffect(() => {
const initialDarkMode =
!!document.querySelector('meta[name="color-scheme"][content="dark"]') ||
window.matchMedia("(prefers-color-scheme: dark)").matches;
setIsDarkMode(initialDarkMode);
}, []);
React.useEffect(() => {
document.documentElement.classList.toggle("dark", isDarkMode);
}, [isDarkMode]);
const toggleDarkMode = () => setIsDarkMode((isDark) => !isDark);
return (
<Button
onClick={toggleDarkMode}
aria-label={`Switch to ${isDarkMode ? "light" : "dark"} mode`}
data-style="ghost"
>
{isDarkMode ? (
<MoonStarIcon className="tiptap-button-icon" />
) : (
<SunIcon className="tiptap-button-icon" />
)}
</Button>
);
}

View File

@@ -0,0 +1,395 @@
.tiptap-badge {
/**************************************************
Default
**************************************************/
/* Light mode */
--tt-badge-border-color: var(--tt-gray-light-a-200);
--tt-badge-border-color-subdued: var(--tt-gray-light-a-200);
--tt-badge-border-color-emphasized: var(--tt-gray-light-a-600);
--tt-badge-text-color: var(--tt-gray-light-a-500);
--tt-badge-text-color-subdued: var(
--tt-gray-light-a-400
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-gray-light-a-600
); //more important badge
--tt-badge-bg-color: var(--white);
--tt-badge-bg-color-subdued: var(--white); //less important badge
--tt-badge-bg-color-emphasized: var(--white); //more important badge
--tt-badge-icon-color: var(--tt-gray-light-a-500);
--tt-badge-icon-color-subdued: var(
--tt-gray-light-a-400
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-brand-color-600
); //more important badge
/* Dark mode */
.dark & {
--tt-badge-border-color: var(--tt-gray-dark-a-200);
--tt-badge-border-color-subdued: var(--tt-gray-dark-a-200);
--tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500);
--tt-badge-text-color: var(--tt-gray-dark-a-500);
--tt-badge-text-color-subdued: var(
--tt-gray-dark-a-400
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-gray-dark-a-600
); //more important badge
--tt-badge-bg-color: var(--black);
--tt-badge-bg-color-subdued: var(--black); //less important badge
--tt-badge-bg-color-emphasized: var(--black); //more important badge
--tt-badge-icon-color: var(--tt-gray-dark-a-500);
--tt-badge-icon-color-subdued: var(
--tt-gray-dark-a-400
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-brand-color-400
); //more important badge
}
/**************************************************
Ghost
**************************************************/
&[data-style="ghost"] {
/* Light mode */
--tt-badge-border-color: var(--tt-gray-light-a-200);
--tt-badge-border-color-subdued: var(--tt-gray-light-a-200);
--tt-badge-border-color-emphasized: var(--tt-gray-light-a-600);
--tt-badge-text-color: var(--tt-gray-light-a-500);
--tt-badge-text-color-subdued: var(
--tt-gray-light-a-400
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-gray-light-a-600
); //more important badge
--tt-badge-bg-color: var(--transparent);
--tt-badge-bg-color-subdued: var(--transparent); //less important badge
--tt-badge-bg-color-emphasized: var(--transparent); //more important badge
--tt-badge-icon-color: var(--tt-gray-light-a-500);
--tt-badge-icon-color-subdued: var(
--tt-gray-light-a-400
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-brand-color-600
); //more important badge
/* Dark mode */
.dark & {
--tt-badge-border-color: var(--tt-gray-dark-a-200);
--tt-badge-border-color-subdued: var(--tt-gray-dark-a-200);
--tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500);
--tt-badge-text-color: var(--tt-gray-dark-a-500);
--tt-badge-text-color-subdued: var(
--tt-gray-dark-a-400
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-gray-dark-a-600
); //more important badge
--tt-badge-bg-color: var(--transparent);
--tt-badge-bg-color-subdued: var(--transparent); //less important badge
--tt-badge-bg-color-emphasized: var(--transparent); //more important badge
--tt-badge-icon-color: var(--tt-gray-dark-a-500);
--tt-badge-icon-color-subdued: var(
--tt-gray-dark-a-400
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-brand-color-400
); //more important badge
}
}
/**************************************************
Gray
**************************************************/
&[data-style="gray"] {
/* Light mode */
--tt-badge-border-color: var(--tt-gray-light-a-200);
--tt-badge-border-color-subdued: var(--tt-gray-light-a-200);
--tt-badge-border-color-emphasized: var(--tt-gray-light-a-500);
--tt-badge-text-color: var(--tt-gray-light-a-500);
--tt-badge-text-color-subdued: var(
--tt-gray-light-a-400
); //less important badge
--tt-badge-text-color-emphasized: var(--white); //more important badge
--tt-badge-bg-color: var(--tt-gray-light-a-100);
--tt-badge-bg-color-subdued: var(
--tt-gray-light-a-50
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-gray-light-a-700
); //more important badge
--tt-badge-icon-color: var(--tt-gray-light-a-500);
--tt-badge-icon-color-subdued: var(
--tt-gray-light-a-400
); //less important badge
--tt-badge-icon-color-emphasized: var(--white); //more important badge
/* Dark mode */
.dark & {
--tt-badge-border-color: var(--tt-gray-dark-a-200);
--tt-badge-border-color-subdued: var(--tt-gray-dark-a-200);
--tt-badge-border-color-emphasized: var(--tt-gray-dark-a-500);
--tt-badge-text-color: var(--tt-gray-dark-a-500);
--tt-badge-text-color-subdued: var(
--tt-gray-dark-a-400
); //less important badge
--tt-badge-text-color-emphasized: var(--black); //more important badge
--tt-badge-bg-color: var(--tt-gray-dark-a-100);
--tt-badge-bg-color-subdued: var(
--tt-gray-dark-a-50
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-gray-dark-a-800
); //more important badge
--tt-badge-icon-color: var(--tt-gray-dark-a-500);
--tt-badge-icon-color-subdued: var(
--tt-gray-dark-a-400
); //less important badge
--tt-badge-icon-color-emphasized: var(--black); //more important badge
}
}
/**************************************************
Green
**************************************************/
&[data-style="green"] {
/* Light mode */
--tt-badge-border-color: var(--tt-color-green-inc-2);
--tt-badge-border-color-subdued: var(--tt-color-green-inc-3);
--tt-badge-border-color-emphasized: var(--tt-color-green-dec-2);
--tt-badge-text-color: var(--tt-color-green-dec-3);
--tt-badge-text-color-subdued: var(
--tt-color-green-dec-2
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-color-green-inc-5
); //more important badge
--tt-badge-bg-color: var(--tt-color-green-inc-4);
--tt-badge-bg-color-subdued: var(
--tt-color-green-inc-5
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-color-green-dec-1
); //more important badge
--tt-badge-icon-color: var(--tt-color-green-dec-3);
--tt-badge-icon-color-subdued: var(
--tt-color-green-dec-2
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-color-green-inc-5
); //more important badge
/* Dark mode */
.dark & {
--tt-badge-border-color: var(--tt-color-green-dec-2);
--tt-badge-border-color-subdued: var(--tt-color-green-dec-3);
--tt-badge-border-color-emphasized: var(--tt-color-green-base);
--tt-badge-text-color: var(--tt-color-green-inc-3);
--tt-badge-text-color-subdued: var(
--tt-color-green-inc-2
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-color-green-dec-5
); //more important badge
--tt-badge-bg-color: var(--tt-color-green-dec-4);
--tt-badge-bg-color-subdued: var(
--tt-color-green-dec-5
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-color-green-inc-1
); //more important badge
--tt-badge-icon-color: var(--tt-color-green-inc-3);
--tt-badge-icon-color-subdued: var(
--tt-color-green-inc-2
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-color-green-dec-5
); //more important badge
}
}
/**************************************************
Yellow
**************************************************/
&[data-style="yellow"] {
/* Light mode */
--tt-badge-border-color: var(--tt-color-yellow-inc-2);
--tt-badge-border-color-subdued: var(--tt-color-yellow-inc-3);
--tt-badge-border-color-emphasized: var(--tt-color-yellow-dec-1);
--tt-badge-text-color: var(--tt-color-yellow-dec-3);
--tt-badge-text-color-subdued: var(
--tt-color-yellow-dec-2
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-color-yellow-dec-3
); //more important badge
--tt-badge-bg-color: var(--tt-color-yellow-inc-4);
--tt-badge-bg-color-subdued: var(
--tt-color-yellow-inc-5
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-color-yellow-base
); //more important badge
--tt-badge-icon-color: var(--tt-color-yellow-dec-3);
--tt-badge-icon-color-subdued: var(
--tt-color-yellow-dec-2
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-color-yellow-dec-3
); //more important badge
/* Dark mode */
.dark & {
--tt-badge-border-color: var(--tt-color-yellow-dec-2);
--tt-badge-border-color-subdued: var(--tt-color-yellow-dec-3);
--tt-badge-border-color-emphasized: var(--tt-color-yellow-inc-1);
--tt-badge-text-color: var(--tt-color-yellow-inc-3);
--tt-badge-text-color-subdued: var(
--tt-color-yellow-inc-2
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-color-yellow-dec-3
); //more important badge
--tt-badge-bg-color: var(--tt-color-yellow-dec-4);
--tt-badge-bg-color-subdued: var(
--tt-color-yellow-dec-5
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-color-yellow-base
); //more important badge
--tt-badge-icon-color: var(--tt-color-yellow-inc-3);
--tt-badge-icon-color-subdued: var(
--tt-color-yellow-inc-2
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-color-yellow-dec-3
); //more important badge
}
}
/**************************************************
Red
**************************************************/
&[data-style="red"] {
/* Light mode */
--tt-badge-border-color: var(--tt-color-red-inc-2);
--tt-badge-border-color-subdued: var(--tt-color-red-inc-3);
--tt-badge-border-color-emphasized: var(--tt-color-red-dec-2);
--tt-badge-text-color: var(--tt-color-red-dec-3);
--tt-badge-text-color-subdued: var(
--tt-color-red-dec-2
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-color-red-inc-5
); //more important badge
--tt-badge-bg-color: var(--tt-color-red-inc-4);
--tt-badge-bg-color-subdued: var(
--tt-color-red-inc-5
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-color-red-dec-1
); //more important badge
--tt-badge-icon-color: var(--tt-color-red-dec-3);
--tt-badge-icon-color-subdued: var(
--tt-color-red-dec-2
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-color-red-inc-5
); //more important badge
/* Dark mode */
.dark & {
--tt-badge-border-color: var(--tt-color-red-dec-2);
--tt-badge-border-color-subdued: var(--tt-color-red-dec-3);
--tt-badge-border-color-emphasized: var(--tt-color-red-base);
--tt-badge-text-color: var(--tt-color-red-inc-3);
--tt-badge-text-color-subdued: var(
--tt-color-red-inc-2
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-color-red-dec-5
); //more important badge
--tt-badge-bg-color: var(--tt-color-red-dec-4);
--tt-badge-bg-color-subdued: var(
--tt-color-red-dec-5
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-color-red-inc-1
); //more important badge
--tt-badge-icon-color: var(--tt-color-red-inc-3);
--tt-badge-icon-color-subdued: var(
--tt-color-red-inc-2
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-color-red-dec-5
); //more important badge
}
}
/**************************************************
Brand
**************************************************/
&[data-style="brand"] {
/* Light mode */
--tt-badge-border-color: var(--tt-brand-color-300);
--tt-badge-border-color-subdued: var(--tt-brand-color-200);
--tt-badge-border-color-emphasized: var(--tt-brand-color-600);
--tt-badge-text-color: var(--tt-brand-color-800);
--tt-badge-text-color-subdued: var(
--tt-brand-color-700
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-brand-color-50
); //more important badge
--tt-badge-bg-color: var(--tt-brand-color-100);
--tt-badge-bg-color-subdued: var(
--tt-brand-color-50
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-brand-color-600
); //more important badge
--tt-badge-icon-color: var(--tt-brand-color-800);
--tt-badge-icon-color-subdued: var(
--tt-brand-color-700
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-brand-color-100
); //more important badge
/* Dark mode */
.dark & {
--tt-badge-border-color: var(--tt-brand-color-700);
--tt-badge-border-color-subdued: var(--tt-brand-color-800);
--tt-badge-border-color-emphasized: var(--tt-brand-color-400);
--tt-badge-text-color: var(--tt-brand-color-200);
--tt-badge-text-color-subdued: var(
--tt-brand-color-300
); //less important badge
--tt-badge-text-color-emphasized: var(
--tt-brand-color-950
); //more important badge
--tt-badge-bg-color: var(--tt-brand-color-900);
--tt-badge-bg-color-subdued: var(
--tt-brand-color-950
); //less important badge
--tt-badge-bg-color-emphasized: var(
--tt-brand-color-400
); //more important badge
--tt-badge-icon-color: var(--tt-brand-color-200);
--tt-badge-icon-color-subdued: var(
--tt-brand-color-300
); //less important badge
--tt-badge-icon-color-emphasized: var(
--tt-brand-color-900
); //more important badge
}
}
}

View File

@@ -0,0 +1,16 @@
.tiptap-badge-group {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.tiptap-badge-group {
[data-orientation="vertical"] {
flex-direction: column;
}
[data-orientation="horizontal"] {
flex-direction: row;
}
}

View File

@@ -0,0 +1,99 @@
.tiptap-badge {
font-size: 0.625rem;
font-weight: 700;
font-feature-settings:
"salt" on,
"cv01" on;
line-height: 1.15;
height: 1.25rem;
min-width: 1.25rem;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border: solid 1px;
border-radius: var(--tt-radius-sm, 0.375rem);
transition-property: background, color, opacity;
transition-duration: var(--tt-transition-duration-default);
transition-timing-function: var(--tt-transition-easing-default);
/* button size large */
&[data-size="large"] {
font-size: 0.75rem;
height: 1.5rem;
min-width: 1.5rem;
padding: 0.375rem;
border-radius: var(--tt-radius-md, 0.375rem);
}
/* button size small */
&[data-size="small"] {
height: 1rem;
min-width: 1rem;
padding: 0.125rem;
border-radius: var(--tt-radius-xs, 0.25rem);
}
/* trim / expand text of the button */
.tiptap-badge-text {
padding: 0 0.125rem;
flex-grow: 1;
text-align: left;
}
&[data-text-trim="on"] {
.tiptap-badge-text {
text-overflow: ellipsis;
overflow: hidden;
}
}
/* standard icon, what is used */
.tiptap-badge-icon {
pointer-events: none;
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
}
&[data-size="large"] .tiptap-badge-icon {
width: 0.75rem;
height: 0.75rem;
}
}
/* --------------------------------------------
----------- BADGE COLOR SETTINGS -------------
-------------------------------------------- */
.tiptap-badge {
background-color: var(--tt-badge-bg-color);
border-color: var(--tt-badge-border-color);
color: var(--tt-badge-text-color);
.tiptap-badge-icon {
color: var(--tt-badge-icon-color);
}
/* Emphasized */
&[data-appearance="emphasized"] {
background-color: var(--tt-badge-bg-color-emphasized);
border-color: var(--tt-badge-border-color-emphasized);
color: var(--tt-badge-text-color-emphasized);
.tiptap-badge-icon {
color: var(--tt-badge-icon-color-emphasized);
}
}
/* Subdued */
&[data-appearance="subdued"] {
background-color: var(--tt-badge-bg-color-subdued);
border-color: var(--tt-badge-border-color-subdued);
color: var(--tt-badge-text-color-subdued);
.tiptap-badge-icon {
color: var(--tt-badge-icon-color-subdued);
}
}
}

View File

@@ -0,0 +1,47 @@
"use client";
import * as React from "react";
import "@/components/shared/tiptap/tiptap-ui-primitive/badge/badge-colors.scss";
import "@/components/shared/tiptap/tiptap-ui-primitive/badge/badge-group.scss";
import "@/components/shared/tiptap/tiptap-ui-primitive/badge/badge.scss";
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: "ghost" | "white" | "gray" | "green" | "default";
size?: "default" | "small";
appearance?: "default" | "subdued" | "emphasized";
trimText?: boolean;
}
export const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
(
{
variant,
size = "default",
appearance = "default",
trimText = false,
className,
children,
...props
},
ref,
) => {
return (
<div
ref={ref}
className={`tiptap-badge ${className || ""}`}
data-style={variant}
data-size={size}
data-appearance={appearance}
data-text-trim={trimText ? "on" : "off"}
{...props}
>
{children}
</div>
);
},
);
Badge.displayName = "Badge";
export default Badge;

View File

@@ -0,0 +1 @@
export * from "./badge"

View File

@@ -0,0 +1,429 @@
.tiptap-button {
/**************************************************
Default button background color
**************************************************/
/* Light mode */
--tt-button-default-bg-color: var(--tt-gray-light-a-100);
--tt-button-hover-bg-color: var(--tt-gray-light-200);
--tt-button-active-bg-color: var(--tt-gray-light-a-200);
--tt-button-active-bg-color-emphasized: var(
--tt-brand-color-100
); //more important active state
--tt-button-active-bg-color-subdued: var(
--tt-gray-light-a-200
); //less important active state
--tt-button-active-hover-bg-color: var(--tt-gray-light-300);
--tt-button-active-hover-bg-color-emphasized: var(
--tt-brand-color-200
); //more important active state hover
--tt-button-active-hover-bg-color-subdued: var(
--tt-gray-light-a-300
); //less important active state hover
--tt-button-disabled-bg-color: var(--tt-gray-light-a-50);
/* Dark mode */
.dark & {
--tt-button-default-bg-color: var(--tt-gray-dark-a-100);
--tt-button-hover-bg-color: var(--tt-gray-dark-200);
--tt-button-active-bg-color: var(--tt-gray-dark-a-200);
--tt-button-active-bg-color-emphasized: var(
--tt-brand-color-900
); //more important active state
--tt-button-active-bg-color-subdued: var(
--tt-gray-dark-a-200
); //less important active state
--tt-button-active-hover-bg-color: var(--tt-gray-dark-300);
--tt-button-active-hover-bg-color-emphasized: var(
--tt-brand-color-800
); //more important active state hover
--tt-button-active-hover-bg-color-subdued: var(
--tt-gray-dark-a-300
); //less important active state hover
--tt-button-disabled-bg-color: var(--tt-gray-dark-a-50);
}
/**************************************************
Default button text color
**************************************************/
/* Light mode */
--tt-button-default-text-color: var(--tt-gray-light-a-600);
--tt-button-hover-text-color: var(--tt-gray-light-a-900);
--tt-button-active-text-color: var(--tt-gray-light-a-900);
--tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900);
--tt-button-active-text-color-subdued: var(--tt-gray-light-a-900);
--tt-button-disabled-text-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-text-color: var(--tt-gray-dark-a-600);
--tt-button-hover-text-color: var(--tt-gray-dark-a-900);
--tt-button-active-text-color: var(--tt-gray-dark-a-900);
--tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900);
--tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900);
--tt-button-disabled-text-color: var(--tt-gray-dark-a-300);
}
/**************************************************
Default button icon color
**************************************************/
/* Light mode */
--tt-button-default-icon-color: var(--tt-gray-light-a-600);
--tt-button-hover-icon-color: var(--tt-gray-light-a-900);
--tt-button-active-icon-color: var(--tt-brand-color-500);
--tt-button-active-icon-color-emphasized: var(--tt-brand-color-600);
--tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900);
--tt-button-disabled-icon-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-icon-color: var(--tt-gray-dark-a-600);
--tt-button-hover-icon-color: var(--tt-gray-dark-a-900);
--tt-button-active-icon-color: var(--tt-brand-color-400);
--tt-button-active-icon-color-emphasized: var(--tt-brand-color-400);
--tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900);
--tt-button-disabled-icon-color: var(--tt-gray-dark-a-400);
}
/**************************************************
Default button subicon color
**************************************************/
/* Light mode */
--tt-button-default-icon-sub-color: var(--tt-gray-light-a-400);
--tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500);
--tt-button-active-icon-sub-color: var(--tt-gray-light-a-400);
--tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500);
--tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400);
--tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100);
/* Dark mode */
.dark & {
--tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300);
--tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400);
--tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300);
--tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400);
--tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300);
--tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100);
}
/**************************************************
Default button dropdown / arrows color
**************************************************/
/* Light mode */
--tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600);
--tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700);
--tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600);
--tt-button-active-dropdown-arrows-color-emphasized: var(
--tt-gray-light-a-700
);
--tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-light-a-600);
--tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600);
--tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700);
--tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600);
--tt-button-active-dropdown-arrows-color-emphasized: var(
--tt-gray-dark-a-700
);
--tt-button-active-dropdown-arrows-color-subdued: var(--tt-gray-dark-a-600);
--tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400);
}
/* ----------------------------------------------------------------
--------------------------- GHOST BUTTON --------------------------
---------------------------------------------------------------- */
&[data-style="ghost"] {
/**************************************************
Ghost button background color
**************************************************/
/* Light mode */
--tt-button-default-bg-color: var(--transparent);
--tt-button-hover-bg-color: var(--tt-gray-light-200);
--tt-button-active-bg-color: var(--tt-gray-light-a-100);
--tt-button-active-bg-color-emphasized: var(
--tt-brand-color-100
); //more important active state
--tt-button-active-bg-color-subdued: var(
--tt-gray-light-a-100
); //less important active state
--tt-button-active-hover-bg-color: var(--tt-gray-light-200);
--tt-button-active-hover-bg-color-emphasized: var(
--tt-brand-color-200
); //more important active state hover
--tt-button-active-hover-bg-color-subdued: var(
--tt-gray-light-a-200
); //less important active state hover
--tt-button-disabled-bg-color: var(--transparent);
/* Dark mode */
.dark & {
--tt-button-default-bg-color: var(--transparent);
--tt-button-hover-bg-color: var(--tt-gray-dark-200);
--tt-button-active-bg-color: var(--tt-gray-dark-a-100);
--tt-button-active-bg-color-emphasized: var(
--tt-brand-color-900
); //more important active state
--tt-button-active-bg-color-subdued: var(
--tt-gray-dark-a-100
); //less important active state
--tt-button-active-hover-bg-color: var(--tt-gray-dark-200);
--tt-button-active-hover-bg-color-emphasized: var(
--tt-brand-color-800
); //more important active state hover
--tt-button-active-hover-bg-color-subdued: var(
--tt-gray-dark-a-200
); //less important active state hover
--tt-button-disabled-bg-color: var(--transparent);
}
/**************************************************
Ghost button text color
**************************************************/
/* Light mode */
--tt-button-default-text-color: var(--tt-gray-light-a-600);
--tt-button-hover-text-color: var(--tt-gray-light-a-900);
--tt-button-active-text-color: var(--tt-gray-light-a-900);
--tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900);
--tt-button-active-text-color-subdued: var(--tt-gray-light-a-900);
--tt-button-disabled-text-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-text-color: var(--tt-gray-dark-a-600);
--tt-button-hover-text-color: var(--tt-gray-dark-a-900);
--tt-button-active-text-color: var(--tt-gray-dark-a-900);
--tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900);
--tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900);
--tt-button-disabled-text-color: var(--tt-gray-dark-a-300);
}
/**************************************************
Ghost button icon color
**************************************************/
/* Light mode */
--tt-button-default-icon-color: var(--tt-gray-light-a-600);
--tt-button-hover-icon-color: var(--tt-gray-light-a-900);
--tt-button-active-icon-color: var(--tt-brand-color-500);
--tt-button-active-icon-color-emphasized: var(--tt-brand-color-600);
--tt-button-active-icon-color-subdued: var(--tt-gray-light-a-900);
--tt-button-disabled-icon-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-icon-color: var(--tt-gray-dark-a-600);
--tt-button-hover-icon-color: var(--tt-gray-dark-a-900);
--tt-button-active-icon-color: var(--tt-brand-color-400);
--tt-button-active-icon-color-emphasized: var(--tt-brand-color-300);
--tt-button-active-icon-color-subdued: var(--tt-gray-dark-a-900);
--tt-button-disabled-icon-color: var(--tt-gray-dark-a-400);
}
/**************************************************
Ghost button subicon color
**************************************************/
/* Light mode */
--tt-button-default-icon-sub-color: var(--tt-gray-light-a-400);
--tt-button-hover-icon-sub-color: var(--tt-gray-light-a-500);
--tt-button-active-icon-sub-color: var(--tt-gray-light-a-400);
--tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500);
--tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-400);
--tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100);
/* Dark mode */
.dark & {
--tt-button-default-icon-sub-color: var(--tt-gray-dark-a-300);
--tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-400);
--tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300);
--tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400);
--tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300);
--tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100);
}
/**************************************************
Ghost button dropdown / arrows color
**************************************************/
/* Light mode */
--tt-button-default-dropdown-arrows-color: var(--tt-gray-light-a-600);
--tt-button-hover-dropdown-arrows-color: var(--tt-gray-light-a-700);
--tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-600);
--tt-button-active-dropdown-arrows-color-emphasized: var(
--tt-gray-light-a-700
);
--tt-button-active-dropdown-arrows-color-subdued: var(
--tt-gray-light-a-600
);
--tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-dropdown-arrows-color: var(--tt-gray-dark-a-600);
--tt-button-hover-dropdown-arrows-color: var(--tt-gray-dark-a-700);
--tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600);
--tt-button-active-dropdown-arrows-color-emphasized: var(
--tt-gray-dark-a-700
);
--tt-button-active-dropdown-arrows-color-subdued: var(
--tt-gray-dark-a-600
);
--tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400);
}
}
/* ----------------------------------------------------------------
-------------------------- PRIMARY BUTTON -------------------------
---------------------------------------------------------------- */
&[data-style="primary"] {
/**************************************************
Primary button background color
**************************************************/
/* Light mode */
--tt-button-default-bg-color: var(--tt-brand-color-500);
--tt-button-hover-bg-color: var(--tt-brand-color-600);
--tt-button-active-bg-color: var(--tt-brand-color-100);
--tt-button-active-bg-color-emphasized: var(
--tt-brand-color-100
); //more important active state
--tt-button-active-bg-color-subdued: var(
--tt-brand-color-100
); //less important active state
--tt-button-active-hover-bg-color: var(--tt-brand-color-200);
--tt-button-active-hover-bg-color-emphasized: var(
--tt-brand-color-200
); //more important active state hover
--tt-button-active-hover-bg-color-subdued: var(
--tt-brand-color-200
); //less important active state hover
--tt-button-disabled-bg-color: var(--tt-gray-light-a-100);
/* Dark mode */
.dark & {
--tt-button-default-bg-color: var(--tt-brand-color-500);
--tt-button-hover-bg-color: var(--tt-brand-color-600);
--tt-button-active-bg-color: var(--tt-brand-color-900);
--tt-button-active-bg-color-emphasized: var(
--tt-brand-color-900
); //more important active state
--tt-button-active-bg-color-subdued: var(
--tt-brand-color-900
); //less important active state
--tt-button-active-hover-bg-color: var(--tt-brand-color-800);
--tt-button-active-hover-bg-color-emphasized: var(
--tt-brand-color-800
); //more important active state hover
--tt-button-active-hover-bg-color-subdued: var(
--tt-brand-color-800
); //less important active state hover
--tt-button-disabled-bg-color: var(--tt-gray-dark-a-100);
}
/**************************************************
Primary button text color
**************************************************/
/* Light mode */
--tt-button-default-text-color: var(--white);
--tt-button-hover-text-color: var(--white);
--tt-button-active-text-color: var(--tt-gray-light-a-900);
--tt-button-active-text-color-emphasized: var(--tt-gray-light-a-900);
--tt-button-active-text-color-subdued: var(--tt-gray-light-a-900);
--tt-button-disabled-text-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-text-color: var(--white);
--tt-button-hover-text-color: var(--white);
--tt-button-active-text-color: var(--tt-gray-dark-a-900);
--tt-button-active-text-color-emphasized: var(--tt-gray-dark-a-900);
--tt-button-active-text-color-subdued: var(--tt-gray-dark-a-900);
--tt-button-disabled-text-color: var(--tt-gray-dark-a-300);
}
/**************************************************
Primary button icon color
**************************************************/
/* Light mode */
--tt-button-default-icon-color: var(--white);
--tt-button-hover-icon-color: var(--white);
--tt-button-active-icon-color: var(--tt-brand-color-600);
--tt-button-active-icon-color-emphasized: var(--tt-brand-color-600);
--tt-button-active-icon-color-subdued: var(--tt-brand-color-600);
--tt-button-disabled-icon-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-icon-color: var(--white);
--tt-button-hover-icon-color: var(--white);
--tt-button-active-icon-color: var(--tt-brand-color-400);
--tt-button-active-icon-color-emphasized: var(--tt-brand-color-400);
--tt-button-active-icon-color-subdued: var(--tt-brand-color-400);
--tt-button-disabled-icon-color: var(--tt-gray-dark-a-300);
}
/**************************************************
Primary button subicon color
**************************************************/
/* Light mode */
--tt-button-default-icon-sub-color: var(--tt-gray-dark-a-500);
--tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500);
--tt-button-active-icon-sub-color: var(--tt-gray-light-a-500);
--tt-button-active-icon-sub-color-emphasized: var(--tt-gray-light-a-500);
--tt-button-active-icon-sub-color-subdued: var(--tt-gray-light-a-500);
--tt-button-disabled-icon-sub-color: var(--tt-gray-light-a-100);
/* Dark mode */
.dark & {
--tt-button-default-icon-sub-color: var(--tt-gray-dark-a-400);
--tt-button-hover-icon-sub-color: var(--tt-gray-dark-a-500);
--tt-button-active-icon-sub-color: var(--tt-gray-dark-a-300);
--tt-button-active-icon-sub-color-emphasized: var(--tt-gray-dark-a-400);
--tt-button-active-icon-sub-color-subdued: var(--tt-gray-dark-a-300);
--tt-button-disabled-icon-sub-color: var(--tt-gray-dark-a-100);
}
/**************************************************
Primary button dropdown / arrows color
**************************************************/
/* Light mode */
--tt-button-default-dropdown-arrows-color: var(--white);
--tt-button-hover-dropdown-arrows-color: var(--white);
--tt-button-active-dropdown-arrows-color: var(--tt-gray-light-a-700);
--tt-button-active-dropdown-arrows-color-emphasized: var(
--tt-gray-light-a-700
);
--tt-button-active-dropdown-arrows-color-subdued: var(
--tt-gray-light-a-700
);
--tt-button-disabled-dropdown-arrows-color: var(--tt-gray-light-a-400);
/* Dark mode */
.dark & {
--tt-button-default-dropdown-arrows-color: var(--white);
--tt-button-hover-dropdown-arrows-color: var(--white);
--tt-button-active-dropdown-arrows-color: var(--tt-gray-dark-a-600);
--tt-button-active-dropdown-arrows-color-emphasized: var(
--tt-gray-dark-a-600
);
--tt-button-active-dropdown-arrows-color-subdued: var(
--tt-gray-dark-a-600
);
--tt-button-disabled-dropdown-arrows-color: var(--tt-gray-dark-a-400);
}
}
}

View File

@@ -0,0 +1,22 @@
.tiptap-button-group {
position: relative;
display: flex;
vertical-align: middle;
&[data-orientation="vertical"] {
flex-direction: column;
align-items: flex-start;
justify-content: center;
min-width: max-content;
> .tiptap-button {
width: 100%;
}
}
&[data-orientation="horizontal"] {
gap: 0.125rem;
flex-direction: row;
align-items: center;
}
}

View File

@@ -0,0 +1,314 @@
.tiptap-button {
font-size: 0.875rem;
font-weight: 500;
font-feature-settings:
"salt" on,
"cv01" on;
line-height: 1.15;
height: 2rem;
min-width: 2rem;
border: none;
padding: 0.5rem;
gap: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--tt-radius-lg, 0.75rem);
transition-property: background, color, opacity;
transition-duration: var(--tt-transition-duration-default);
transition-timing-function: var(--tt-transition-easing-default);
// focus-visible
&:focus-visible {
outline: none;
}
&[data-highlighted="true"],
&[data-focus-visible="true"] {
background-color: var(--tt-button-hover-bg-color);
color: var(--tt-button-hover-text-color);
// outline: 2px solid var(--tt-button-active-icon-color);
}
&[data-weight="small"] {
width: 1.5rem;
min-width: 1.5rem;
padding-right: 0;
padding-left: 0;
}
/* button size large */
&[data-size="large"] {
font-size: 0.9375rem;
height: 2.375rem;
min-width: 2.375rem;
padding: 0.625rem;
}
/* button size small */
&[data-size="small"] {
font-size: 0.75rem;
line-height: 1.2;
height: 1.5rem;
min-width: 1.5rem;
padding: 0.3125rem;
border-radius: var(--tt-radius-md, 0.5rem);
}
/* trim / expand text of the button */
.tiptap-button-text {
padding: 0 0.125rem;
flex-grow: 1;
text-align: left;
line-height: 1.5rem;
}
&[data-text-trim="on"] {
.tiptap-button-text {
text-overflow: ellipsis;
overflow: hidden;
}
}
/* global icon settings */
.tiptap-button-icon,
.tiptap-button-icon-sub,
.tiptap-button-dropdown-arrows,
.tiptap-button-dropdown-small {
flex-shrink: 0;
}
/* standard icon, what is used */
.tiptap-button-icon {
width: 1rem;
height: 1rem;
}
&[data-size="large"] .tiptap-button-icon {
width: 1.125rem;
height: 1.125rem;
}
&[data-size="small"] .tiptap-button-icon {
width: 0.875rem;
height: 0.875rem;
}
/* if 2 icons are used and this icon should be more subtle */
.tiptap-button-icon-sub {
width: 1rem;
height: 1rem;
}
&[data-size="large"] .tiptap-button-icon-sub {
width: 1.125rem;
height: 1.125rem;
}
&[data-size="small"] .tiptap-button-icon-sub {
width: 0.875rem;
height: 0.875rem;
}
/* dropdown menus or arrows that are slightly smaller */
.tiptap-button-dropdown-arrows {
width: 0.75rem;
height: 0.75rem;
}
&[data-size="large"] .tiptap-button-dropdown-arrows {
width: 0.875rem;
height: 0.875rem;
}
&[data-size="small"] .tiptap-button-dropdown-arrows {
width: 0.625rem;
height: 0.625rem;
}
/* dropdown menu for icon buttons only */
.tiptap-button-dropdown-small {
width: 0.625rem;
height: 0.625rem;
}
&[data-size="large"] .tiptap-button-dropdown-small {
width: 0.75rem;
height: 0.75rem;
}
&[data-size="small"] .tiptap-button-dropdown-small {
width: 0.5rem;
height: 0.5rem;
}
/* button only has icons */
&:has(> svg):not(:has(> :not(svg))) {
gap: 0.125rem;
&[data-size="large"],
&[data-size="small"] {
gap: 0.125rem;
}
}
/* button only has 2 icons and one of them is dropdown small */
&:has(> svg:nth-of-type(2)):has(> .tiptap-button-dropdown-small):not(
:has(> svg:nth-of-type(3))
):not(:has(> .tiptap-button-text)) {
gap: 0;
padding-right: 0.25rem;
&[data-size="large"] {
padding-right: 0.375rem;
}
&[data-size="small"] {
padding-right: 0.25rem;
}
}
/* Emoji is used in a button */
.tiptap-button-emoji {
width: 1rem;
display: flex;
justify-content: center;
}
&[data-size="large"] .tiptap-button-emoji {
width: 1.125rem;
}
&[data-size="small"] .tiptap-button-emoji {
width: 0.875rem;
}
}
/* --------------------------------------------
----------- BUTTON COLOR SETTINGS -------------
-------------------------------------------- */
.tiptap-button {
background-color: var(--tt-button-default-bg-color);
color: var(--tt-button-default-text-color);
.tiptap-button-icon {
color: var(--tt-button-default-icon-color);
}
.tiptap-button-icon-sub {
color: var(--tt-button-default-icon-sub-color);
}
.tiptap-button-dropdown-arrows {
color: var(--tt-button-default-dropdown-arrows-color);
}
.tiptap-button-dropdown-small {
color: var(--tt-button-default-dropdown-arrows-color);
}
/* hover state of a button */
&:hover:not([data-active-item="true"]):not([disabled]),
&[data-active-item="true"]:not([disabled]),
&[data-highlighted]:not([disabled]):not([data-highlighted="false"]) {
background-color: var(--tt-button-hover-bg-color);
color: var(--tt-button-hover-text-color);
.tiptap-button-icon {
color: var(--tt-button-hover-icon-color);
}
.tiptap-button-icon-sub {
color: var(--tt-button-hover-icon-sub-color);
}
.tiptap-button-dropdown-arrows,
.tiptap-button-dropdown-small {
color: var(--tt-button-hover-dropdown-arrows-color);
}
}
/* Active state of a button */
&[data-active-state="on"]:not([disabled]),
&[data-state="open"]:not([disabled]) {
background-color: var(--tt-button-active-bg-color);
color: var(--tt-button-active-text-color);
.tiptap-button-icon {
color: var(--tt-button-active-icon-color);
}
.tiptap-button-icon-sub {
color: var(--tt-button-active-icon-sub-color);
}
.tiptap-button-dropdown-arrows,
.tiptap-button-dropdown-small {
color: var(--tt-button-active-dropdown-arrows-color);
}
&:hover {
background-color: var(--tt-button-active-hover-bg-color);
}
/* Emphasized */
&[data-appearance="emphasized"] {
background-color: var(--tt-button-active-bg-color-emphasized);
color: var(--tt-button-active-text-color-emphasized);
.tiptap-button-icon {
color: var(--tt-button-active-icon-color-emphasized);
}
.tiptap-button-icon-sub {
color: var(--tt-button-active-icon-sub-color-emphasized);
}
.tiptap-button-dropdown-arrows,
.tiptap-button-dropdown-small {
color: var(--tt-button-active-dropdown-arrows-color-emphasized);
}
&:hover {
background-color: var(--tt-button-active-hover-bg-color-emphasized);
}
}
/* Subdued */
&[data-appearance="subdued"] {
background-color: var(--tt-button-active-bg-color-subdued);
color: var(--tt-button-active-text-color-subdued);
.tiptap-button-icon {
color: var(--tt-button-active-icon-color-subdued);
}
.tiptap-button-icon-sub {
color: var(--tt-button-active-icon-sub-color-subdued);
}
.tiptap-button-dropdown-arrows,
.tiptap-button-dropdown-small {
color: var(--tt-button-active-dropdown-arrows-color-subdued);
}
&:hover {
background-color: var(--tt-button-active-hover-bg-color-subdued);
.tiptap-button-icon {
color: var(--tt-button-active-icon-color-subdued);
}
}
}
}
&:disabled {
background-color: var(--tt-button-disabled-bg-color);
color: var(--tt-button-disabled-text-color);
.tiptap-button-icon {
color: var(--tt-button-disabled-icon-color);
}
}
}

View File

@@ -0,0 +1,115 @@
"use client";
import * as React from "react";
// --- Lib ---
import { cn, parseShortcutKeys } from "@/lib/tiptap-utils";
// --- Tiptap UI Primitive ---
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shared/tiptap/tiptap-ui-primitive/tooltip";
import "@/components/shared/tiptap/tiptap-ui-primitive/button/button-colors.scss";
import "@/components/shared/tiptap/tiptap-ui-primitive/button/button-group.scss";
import "@/components/shared/tiptap/tiptap-ui-primitive/button/button.scss";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
className?: string;
showTooltip?: boolean;
tooltip?: React.ReactNode;
shortcutKeys?: string;
}
export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({
shortcuts,
}) => {
if (shortcuts.length === 0) return null;
return (
<div>
{shortcuts.map((key, index) => (
<React.Fragment key={index}>
{index > 0 && <kbd>+</kbd>}
<kbd>{key}</kbd>
</React.Fragment>
))}
</div>
);
};
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
children,
tooltip,
showTooltip = true,
shortcutKeys,
"aria-label": ariaLabel,
...props
},
ref,
) => {
const shortcuts = React.useMemo(
() => parseShortcutKeys({ shortcutKeys }),
[shortcutKeys],
);
if (!tooltip || !showTooltip) {
return (
<button
className={cn("tiptap-button", className)}
ref={ref}
aria-label={ariaLabel}
{...props}
>
{children}
</button>
);
}
return (
<Tooltip delay={200}>
<TooltipTrigger
className={cn("tiptap-button", className)}
ref={ref}
aria-label={ariaLabel}
{...props}
>
{children}
</TooltipTrigger>
<TooltipContent>
{tooltip}
<ShortcutDisplay shortcuts={shortcuts} />
</TooltipContent>
</Tooltip>
);
},
);
Button.displayName = "Button";
export const ButtonGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
orientation?: "horizontal" | "vertical";
}
>(({ className, children, orientation = "vertical", ...props }, ref) => {
return (
<div
ref={ref}
className={cn("tiptap-button-group", className)}
data-orientation={orientation}
role="group"
{...props}
>
{children}
</div>
);
});
ButtonGroup.displayName = "ButtonGroup";
export default Button;

View File

@@ -0,0 +1 @@
export * from "./button"

View File

@@ -0,0 +1,77 @@
:root {
--tiptap-card-bg-color: var(--white);
--tiptap-card-border-color: var(--tt-gray-light-a-100);
--tiptap-card-group-label-color: var(--tt-gray-light-a-800);
}
.dark {
--tiptap-card-bg-color: var(--tt-gray-dark-50);
--tiptap-card-border-color: var(--tt-gray-dark-a-100);
--tiptap-card-group-label-color: var(--tt-gray-dark-a-800);
}
.tiptap-card {
--padding: 0.375rem;
--border-width: 1px;
border-radius: calc(var(--padding) + var(--tt-radius-lg));
box-shadow: var(--tt-shadow-elevated-md);
background-color: var(--tiptap-card-bg-color);
border: 1px solid var(--tiptap-card-border-color);
display: flex;
flex-direction: column;
outline: none;
align-items: center;
position: relative;
min-width: 0;
word-wrap: break-word;
background-clip: border-box;
}
.tiptap-card-header {
padding: 0.375rem;
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
border-bottom: var(--border-width) solid var(--tiptap-card-border-color);
}
.tiptap-card-body {
padding: 0.375rem;
flex: 1 1 auto;
overflow-y: auto;
}
.tiptap-card-item-group {
position: relative;
display: flex;
vertical-align: middle;
min-width: max-content;
&[data-orientation="vertical"] {
flex-direction: column;
justify-content: center;
}
&[data-orientation="horizontal"] {
gap: 0.25rem;
flex-direction: row;
align-items: center;
}
}
.tiptap-card-group-label {
padding-top: 0.75rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
padding-bottom: 0.25rem;
line-height: normal;
font-size: 0.75rem;
font-weight: 600;
line-height: normal;
text-transform: capitalize;
color: var(--tiptap-card-group-label-color);
}

View File

@@ -0,0 +1,85 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/tiptap-utils";
import "@/components/shared/tiptap/tiptap-ui-primitive/card/card.scss";
const Card = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div ref={ref} className={cn("tiptap-card", className)} {...props} />
);
},
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div ref={ref} className={cn("tiptap-card-header", className)} {...props} />
);
});
CardHeader.displayName = "CardHeader";
const CardBody = React.forwardRef<HTMLDivElement, React.ComponentProps<"div">>(
({ className, ...props }, ref) => {
return (
<div ref={ref} className={cn("tiptap-card-body", className)} {...props} />
);
},
);
CardBody.displayName = "CardBody";
const CardItemGroup = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
orientation?: "horizontal" | "vertical";
}
>(({ className, orientation = "vertical", ...props }, ref) => {
return (
<div
ref={ref}
data-orientation={orientation}
className={cn("tiptap-card-item-group", className)}
{...props}
/>
);
});
CardItemGroup.displayName = "CardItemGroup";
const CardGroupLabel = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn("tiptap-card-group-label", className)}
{...props}
/>
);
});
CardGroupLabel.displayName = "CardGroupLabel";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div">
>(({ className, ...props }, ref) => {
return (
<div ref={ref} className={cn("tiptap-card-footer", className)} {...props} />
);
});
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardFooter,
CardBody,
CardItemGroup,
CardGroupLabel,
};

View File

@@ -0,0 +1 @@
export * from "./card"

View File

@@ -0,0 +1,63 @@
.tiptap-dropdown-menu {
--tt-dropdown-menu-bg-color: var(--white);
--tt-dropdown-menu-border-color: var(--tt-gray-light-a-100);
--tt-dropdown-menu-text-color: var(--tt-gray-light-a-600);
.dark & {
--tt-dropdown-menu-border-color: var(--tt-gray-dark-a-50);
--tt-dropdown-menu-bg-color: var(--tt-gray-dark-50);
--tt-dropdown-menu-text-color: var(--tt-gray-dark-a-600);
}
}
/* --------------------------------------------
--------- DROPDOWN MENU STYLING SETTINGS -----------
-------------------------------------------- */
.tiptap-dropdown-menu {
z-index: 50;
outline: none;
transform-origin: var(--radix-dropdown-menu-content-transform-origin);
max-height: var(--radix-dropdown-menu-content-available-height);
> * {
max-height: var(--radix-dropdown-menu-content-available-height);
}
/* Animation states */
&[data-state="open"] {
animation:
fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1),
zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-state="closed"] {
animation:
fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1),
zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Position-based animations */
&[data-side="top"],
&[data-side="top-start"],
&[data-side="top-end"] {
animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-side="right"],
&[data-side="right-start"],
&[data-side="right-end"] {
animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-side="bottom"],
&[data-side="bottom-start"],
&[data-side="bottom-end"] {
animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-side="left"],
&[data-side="left-start"],
&[data-side="left-end"] {
animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
}

View File

@@ -0,0 +1,102 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cn } from "@/lib/tiptap-utils";
import "@/components/shared/tiptap/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root modal={false} {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal {...props} />;
}
const DropdownMenuTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
>(({ ...props }, ref) => (
<DropdownMenuPrimitive.Trigger ref={ref} {...props} />
));
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuItem = DropdownMenuPrimitive.Item;
const DropdownMenuSubTrigger = DropdownMenuPrimitive.SubTrigger;
const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent> & {
portal?: boolean | React.ComponentProps<typeof DropdownMenuPortal>;
}
>(({ className, portal = true, ...props }, ref) => {
const content = (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn("tiptap-dropdown-menu", className)}
{...props}
/>
);
return portal ? (
<DropdownMenuPortal {...(typeof portal === "object" ? portal : {})}>
{content}
</DropdownMenuPortal>
) : (
content
);
});
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> & {
portal?: boolean;
}
>(({ className, sideOffset = 4, portal = false, ...props }, ref) => {
const content = (
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
onCloseAutoFocus={(e) => e.preventDefault()}
className={cn("tiptap-dropdown-menu", className)}
{...props}
/>
);
return portal ? (
<DropdownMenuPortal {...(typeof portal === "object" ? portal : {})}>
{content}
</DropdownMenuPortal>
) : (
content
);
});
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuGroup,
DropdownMenuSub,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

View File

@@ -0,0 +1 @@
export * from "./dropdown-menu"

View File

@@ -0,0 +1 @@
export * from "./input"

View File

@@ -0,0 +1,45 @@
:root {
--tiptap-input-placeholder: var(--tt-gray-light-a-400);
}
.dark {
--tiptap-input-placeholder: var(--tt-gray-dark-a-400);
}
.tiptap-input {
display: block;
width: 100%;
height: 2rem;
font-size: 0.875rem;
font-weight: 400;
line-height: 1.5;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: none;
appearance: none;
outline: none;
&::placeholder {
color: var(--tiptap-input-placeholder);
}
}
.tiptap-input-clamp {
min-width: 12rem;
padding-right: 0;
text-overflow: ellipsis;
white-space: nowrap;
&:focus {
text-overflow: clip;
overflow: visible;
}
}
.tiptap-input-group {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: stretch;
}

View File

@@ -0,0 +1,27 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/tiptap-utils";
import "@/components/shared/tiptap/tiptap-ui-primitive/input/input.scss";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input type={type} className={cn("tiptap-input", className)} {...props} />
);
}
function InputGroup({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div className={cn("tiptap-input-group", className)} {...props}>
{children}
</div>
);
}
export { Input, InputGroup };

View File

@@ -0,0 +1 @@
export * from "./popover"

View File

@@ -0,0 +1,63 @@
.tiptap-popover {
--tt-popover-bg-color: var(--white);
--tt-popover-border-color: var(--tt-gray-light-a-100);
--tt-popover-text-color: var(--tt-gray-light-a-600);
.dark & {
--tt-popover-border-color: var(--tt-gray-dark-a-50);
--tt-popover-bg-color: var(--tt-gray-dark-50);
--tt-popover-text-color: var(--tt-gray-dark-a-600);
}
}
/* --------------------------------------------
--------- POPOVER STYLING SETTINGS -----------
-------------------------------------------- */
.tiptap-popover {
z-index: 50;
outline: none;
transform-origin: var(--radix-popover-content-transform-origin);
max-height: var(--radix-popover-content-available-height);
> * {
max-height: var(--radix-popover-content-available-height);
}
/* Animation states */
&[data-state="open"] {
animation:
fadeIn 150ms cubic-bezier(0.16, 1, 0.3, 1),
zoomIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-state="closed"] {
animation:
fadeOut 150ms cubic-bezier(0.16, 1, 0.3, 1),
zoomOut 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Position-based animations */
&[data-side="top"],
&[data-side="top-start"],
&[data-side="top-end"] {
animation: slideFromBottom 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-side="right"],
&[data-side="right-start"],
&[data-side="right-end"] {
animation: slideFromLeft 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-side="bottom"],
&[data-side="bottom-start"],
&[data-side="bottom-end"] {
animation: slideFromTop 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
&[data-side="left"],
&[data-side="left-start"],
&[data-side="left-end"] {
animation: slideFromRight 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
}

View File

@@ -0,0 +1,40 @@
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/tiptap-utils";
import "@/components/shared/tiptap/tiptap-ui-primitive/popover/popover.scss";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align={align}
sideOffset={sideOffset}
className={cn("tiptap-popover", className)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
export { Popover, PopoverTrigger, PopoverContent };

View File

@@ -0,0 +1 @@
export * from "./separator"

View File

@@ -0,0 +1,23 @@
.tiptap-separator {
--tt-link-border-color: var(--tt-gray-light-a-200);
.dark & {
--tt-link-border-color: var(--tt-gray-dark-a-200);
}
}
.tiptap-separator {
flex-shrink: 0;
background-color: var(--tt-link-border-color);
&[data-orientation="horizontal"] {
height: 1px;
width: 100%;
margin: 0.5rem 0;
}
&[data-orientation="vertical"] {
height: 1.5rem;
width: 1px;
}
}

View File

@@ -0,0 +1,36 @@
"use client";
import * as React from "react";
import "@/components/shared/tiptap/tiptap-ui-primitive/separator/separator.scss";
import { cn } from "@/lib/tiptap-utils";
export type Orientation = "horizontal" | "vertical";
export interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: Orientation;
decorative?: boolean;
}
export const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
({ decorative, orientation = "vertical", className, ...divProps }, ref) => {
const ariaOrientation =
orientation === "vertical" ? orientation : undefined;
const semanticProps = decorative
? { role: "none" }
: { "aria-orientation": ariaOrientation, role: "separator" };
return (
<div
className={cn("tiptap-separator", className)}
data-orientation={orientation}
{...semanticProps}
{...divProps}
ref={ref}
/>
);
},
);
Separator.displayName = "Separator";

View File

@@ -0,0 +1 @@
export * from "./spacer"

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
export type SpacerOrientation = "horizontal" | "vertical"
export interface SpacerProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: SpacerOrientation
size?: string | number
}
export function Spacer({
orientation = "horizontal",
size,
style = {},
...props
}: SpacerProps) {
const computedStyle = {
...style,
...(orientation === "horizontal" && !size && { flex: 1 }),
...(size && {
width: orientation === "vertical" ? "1px" : size,
height: orientation === "horizontal" ? "1px" : size,
}),
}
return <div {...props} style={computedStyle} />
}

View File

@@ -0,0 +1 @@
export * from "./toolbar"

View File

@@ -0,0 +1,98 @@
:root {
--tt-toolbar-height: 2.75rem;
--tt-safe-area-bottom: env(safe-area-inset-bottom, 0px);
--tt-toolbar-bg-color: var(--white);
--tt-toolbar-border-color: var(--tt-gray-light-a-100);
}
.dark {
--tt-toolbar-bg-color: var(--black);
--tt-toolbar-border-color: var(--tt-gray-dark-a-50);
}
.tiptap-toolbar {
display: flex;
align-items: center;
gap: 0.25rem;
&-group {
display: flex;
align-items: center;
gap: 0.125rem;
&:empty {
display: none;
}
&:empty + .tiptap-separator,
.tiptap-separator + &:empty {
display: none;
}
}
&[data-variant="fixed"] {
position: sticky;
top: 0;
z-index: 10;
width: 100%;
min-height: var(--tt-toolbar-height);
background: var(--tt-toolbar-bg-color);
border-bottom: 1px solid var(--tt-toolbar-border-color);
padding: 0 0.5rem;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
@media (max-width: 480px) {
position: absolute;
top: auto;
height: calc(var(--tt-toolbar-height) + var(--tt-safe-area-bottom));
border-top: 1px solid var(--tt-toolbar-border-color);
border-bottom: none;
padding: 0 0.5rem var(--tt-safe-area-bottom);
flex-wrap: nowrap;
justify-content: flex-start;
.tiptap-toolbar-group {
flex: 0 0 auto;
}
}
}
&[data-variant="floating"] {
--tt-toolbar-padding: 0.125rem;
--tt-toolbar-border-width: 1px;
padding: 0.188rem;
border-radius: calc(
var(--tt-toolbar-padding) + var(--tt-radius-lg) +
var(--tt-toolbar-border-width)
);
border: var(--tt-toolbar-border-width) solid var(--tt-toolbar-border-color);
background-color: var(--tt-toolbar-bg-color);
box-shadow: var(--tt-shadow-elevated-md);
outline: none;
overflow: hidden;
&[data-plain="true"] {
padding: 0;
border-radius: 0;
border: none;
box-shadow: none;
background-color: transparent;
}
@media screen and (max-width: 480px) {
width: 100%;
border-radius: 0;
border: none;
box-shadow: none;
}
}
}

View File

@@ -0,0 +1,127 @@
"use client";
import * as React from "react";
import { Separator } from "@/components/shared/tiptap/tiptap-ui-primitive/separator";
import "@/components/shared/tiptap/tiptap-ui-primitive/toolbar/toolbar.scss";
import { cn } from "@/lib/tiptap-utils";
import { useComposedRef } from "@/hooks/use-composed-ref";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
type BaseProps = React.HTMLAttributes<HTMLDivElement>;
interface ToolbarProps extends BaseProps {
variant?: "floating" | "fixed";
}
const useToolbarNavigation = (
toolbarRef: React.RefObject<HTMLDivElement | null>,
) => {
const [items, setItems] = React.useState<HTMLElement[]>([]);
const collectItems = React.useCallback(() => {
if (!toolbarRef.current) return [];
return Array.from(
toolbarRef.current.querySelectorAll<HTMLElement>(
'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])',
),
);
}, [toolbarRef]);
React.useEffect(() => {
const toolbar = toolbarRef.current;
if (!toolbar) return;
const updateItems = () => setItems(collectItems());
updateItems();
const observer = new MutationObserver(updateItems);
observer.observe(toolbar, { childList: true, subtree: true });
return () => observer.disconnect();
}, [collectItems, toolbarRef]);
const { selectedIndex } = useMenuNavigation<HTMLElement>({
containerRef: toolbarRef,
items,
orientation: "horizontal",
onSelect: (el) => el.click(),
autoSelectFirstItem: false,
});
React.useEffect(() => {
const toolbar = toolbarRef.current;
if (!toolbar) return;
const handleFocus = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (toolbar.contains(target))
target.setAttribute("data-focus-visible", "true");
};
const handleBlur = (e: FocusEvent) => {
const target = e.target as HTMLElement;
if (toolbar.contains(target))
target.removeAttribute("data-focus-visible");
};
toolbar.addEventListener("focus", handleFocus, true);
toolbar.addEventListener("blur", handleBlur, true);
return () => {
toolbar.removeEventListener("focus", handleFocus, true);
toolbar.removeEventListener("blur", handleBlur, true);
};
}, [toolbarRef]);
React.useEffect(() => {
if (selectedIndex !== undefined && items[selectedIndex]) {
items[selectedIndex].focus();
}
}, [selectedIndex, items]);
};
export const Toolbar = React.forwardRef<HTMLDivElement, ToolbarProps>(
({ children, className, variant = "fixed", ...props }, ref) => {
const toolbarRef = React.useRef<HTMLDivElement>(null);
const composedRef = useComposedRef(toolbarRef, ref);
useToolbarNavigation(toolbarRef);
return (
<div
ref={composedRef}
role="toolbar"
aria-label="toolbar"
data-variant={variant}
className={cn("tiptap-toolbar", className)}
{...props}
>
{children}
</div>
);
},
);
Toolbar.displayName = "Toolbar";
export const ToolbarGroup = React.forwardRef<HTMLDivElement, BaseProps>(
({ children, className, ...props }, ref) => (
<div
ref={ref}
role="group"
className={cn("tiptap-toolbar-group", className)}
{...props}
>
{children}
</div>
),
);
ToolbarGroup.displayName = "ToolbarGroup";
export const ToolbarSeparator = React.forwardRef<HTMLDivElement, BaseProps>(
({ ...props }, ref) => (
<Separator ref={ref} orientation="vertical" decorative {...props} />
),
);
ToolbarSeparator.displayName = "ToolbarSeparator";

View File

@@ -0,0 +1 @@
export * from "./tooltip"

View File

@@ -0,0 +1,43 @@
.tiptap-tooltip {
--tt-tooltip-bg: var(--tt-gray-light-900);
--tt-tooltip-text: var(--white);
--tt-kbd: var(--tt-gray-dark-a-400);
.dark & {
--tt-tooltip-bg: var(--white);
--tt-tooltip-text: var(--tt-gray-light-600);
--tt-kbd: var(--tt-gray-light-a-400);
}
}
.tiptap-tooltip {
z-index: 200;
overflow: hidden;
border-radius: var(--tt-radius-md, 0.375rem);
background-color: var(--tt-tooltip-bg);
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--tt-tooltip-text);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
text-align: center;
kbd {
display: inline-block;
text-align: center;
vertical-align: baseline;
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
"Noto Sans",
sans-serif;
text-transform: capitalize;
color: var(--tt-kbd);
}
}

View File

@@ -0,0 +1,234 @@
"use client";
import * as React from "react";
import {
autoUpdate,
flip,
FloatingDelayGroup,
FloatingPortal,
offset,
shift,
useDismiss,
useFloating,
useFocus,
useHover,
useInteractions,
useMergeRefs,
useRole,
type Placement,
type ReferenceType,
type UseFloatingReturn,
} from "@floating-ui/react";
import "@/components/shared/tiptap/tiptap-ui-primitive/tooltip/tooltip.scss";
interface TooltipProviderProps {
children: React.ReactNode;
initialOpen?: boolean;
placement?: Placement;
open?: boolean;
onOpenChange?: (open: boolean) => void;
delay?: number;
closeDelay?: number;
timeout?: number;
useDelayGroup?: boolean;
}
interface TooltipTriggerProps
extends Omit<React.HTMLProps<HTMLElement>, "ref"> {
asChild?: boolean;
children: React.ReactNode;
}
interface TooltipContentProps
extends Omit<React.HTMLProps<HTMLDivElement>, "ref"> {
children?: React.ReactNode;
portal?: boolean;
portalProps?: Omit<React.ComponentProps<typeof FloatingPortal>, "children">;
}
interface TooltipContextValue extends UseFloatingReturn<ReferenceType> {
open: boolean;
setOpen: (open: boolean) => void;
getReferenceProps: (
userProps?: React.HTMLProps<HTMLElement>,
) => Record<string, unknown>;
getFloatingProps: (
userProps?: React.HTMLProps<HTMLDivElement>,
) => Record<string, unknown>;
}
function useTooltip({
initialOpen = false,
placement = "top",
open: controlledOpen,
onOpenChange: setControlledOpen,
delay = 600,
closeDelay = 0,
}: Omit<TooltipProviderProps, "children"> = {}) {
const [uncontrolledOpen, setUncontrolledOpen] =
React.useState<boolean>(initialOpen);
const open = controlledOpen ?? uncontrolledOpen;
const setOpen = setControlledOpen ?? setUncontrolledOpen;
const data = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: autoUpdate,
middleware: [
offset(4),
flip({
crossAxis: placement.includes("-"),
fallbackAxisSideDirection: "start",
padding: 4,
}),
shift({ padding: 4 }),
],
});
const context = data.context;
const hover = useHover(context, {
mouseOnly: true,
move: false,
restMs: delay,
enabled: controlledOpen == null,
delay: {
close: closeDelay,
},
});
const focus = useFocus(context, {
enabled: controlledOpen == null,
});
const dismiss = useDismiss(context);
const role = useRole(context, { role: "tooltip" });
const interactions = useInteractions([hover, focus, dismiss, role]);
return React.useMemo(
() => ({
open,
setOpen,
...interactions,
...data,
}),
[open, setOpen, interactions, data],
);
}
const TooltipContext = React.createContext<TooltipContextValue | null>(null);
function useTooltipContext() {
const context = React.useContext(TooltipContext);
if (context == null) {
throw new Error(
"Tooltip components must be wrapped in <TooltipProvider />",
);
}
return context;
}
export function Tooltip({ children, ...props }: TooltipProviderProps) {
const tooltip = useTooltip(props);
if (!props.useDelayGroup) {
return (
<TooltipContext.Provider value={tooltip}>
{children}
</TooltipContext.Provider>
);
}
return (
<FloatingDelayGroup
delay={{ open: props.delay ?? 0, close: props.closeDelay ?? 0 }}
timeoutMs={props.timeout}
>
<TooltipContext.Provider value={tooltip}>
{children}
</TooltipContext.Provider>
</FloatingDelayGroup>
);
}
export const TooltipTrigger = React.forwardRef<
HTMLElement,
TooltipTriggerProps
>(function TooltipTrigger({ children, asChild = false, ...props }, propRef) {
const context = useTooltipContext();
const childrenRef = React.isValidElement(children)
? parseInt(React.version, 10) >= 19
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(children as { props: { ref?: React.Ref<any> } }).props.ref
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
(children as any).ref
: undefined;
const ref = useMergeRefs([context.refs.setReference, propRef, childrenRef]);
if (asChild && React.isValidElement(children)) {
const dataAttributes = {
"data-tooltip-state": context.open ? "open" : "closed",
};
return React.cloneElement(
children,
context.getReferenceProps({
ref,
...props,
...(typeof children.props === "object" ? children.props : {}),
...dataAttributes,
}),
);
}
return (
<button
ref={ref}
data-tooltip-state={context.open ? "open" : "closed"}
{...context.getReferenceProps(props)}
>
{children}
</button>
);
});
export const TooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps
>(function TooltipContent(
{ style, children, portal = true, portalProps = {}, ...props },
propRef,
) {
const context = useTooltipContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);
if (!context.open) return null;
const content = (
<div
ref={ref}
style={{
...context.floatingStyles,
...style,
}}
{...context.getFloatingProps(props)}
className="tiptap-tooltip"
>
{children}
</div>
);
if (portal) {
return <FloatingPortal {...portalProps}>{content}</FloatingPortal>;
}
return content;
});
Tooltip.displayName = "Tooltip";
TooltipTrigger.displayName = "TooltipTrigger";
TooltipContent.displayName = "TooltipContent";

View File

@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
// --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils";
// --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor";
import { Badge } from "@/components/shared/tiptap/tiptap-ui-primitive/badge";
// --- UI Primitives ---
import type { ButtonProps } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
import { Button } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
// --- Tiptap UI ---
import type { UseBlockquoteConfig } from "@/components/shared/tiptap/tiptap-ui/blockquote-button";
import {
BLOCKQUOTE_SHORTCUT_KEY,
useBlockquote,
} from "@/components/shared/tiptap/tiptap-ui/blockquote-button";
export interface BlockquoteButtonProps
extends Omit<ButtonProps, "type">,
UseBlockquoteConfig {
/**
* Optional text to display alongside the icon.
*/
text?: string;
/**
* Optional show shortcut keys in the button.
* @default false
*/
showShortcut?: boolean;
}
export function BlockquoteShortcutBadge({
shortcutKeys = BLOCKQUOTE_SHORTCUT_KEY,
}: {
shortcutKeys?: string;
}) {
return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>;
}
/**
* Button component for toggling blockquote in a Tiptap editor.
*
* For custom button implementations, use the `useBlockquote` hook instead.
*/
export const BlockquoteButton = React.forwardRef<
HTMLButtonElement,
BlockquoteButtonProps
>(
(
{
editor: providedEditor,
text,
hideWhenUnavailable = false,
onToggled,
showShortcut = false,
onClick,
children,
...buttonProps
},
ref,
) => {
const { editor } = useTiptapEditor(providedEditor);
const {
isVisible,
canToggle,
isActive,
handleToggle,
label,
shortcutKeys,
Icon,
} = useBlockquote({
editor,
hideWhenUnavailable,
onToggled,
});
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
handleToggle();
},
[handleToggle, onClick],
);
if (!isVisible) {
return null;
}
return (
<Button
type="button"
data-style="ghost"
data-active-state={isActive ? "on" : "off"}
role="button"
tabIndex={-1}
disabled={!canToggle}
data-disabled={!canToggle}
aria-label={label}
aria-pressed={isActive}
tooltip="Blockquote"
onClick={handleClick}
{...buttonProps}
ref={ref}
>
{children ?? (
<>
<Icon className="tiptap-button-icon" />
{text && <span className="tiptap-button-text">{text}</span>}
{showShortcut && (
<BlockquoteShortcutBadge shortcutKeys={shortcutKeys} />
)}
</>
)}
</Button>
);
},
);
BlockquoteButton.displayName = "BlockquoteButton";

View File

@@ -0,0 +1,2 @@
export * from "./blockquote-button"
export * from "./use-blockquote"

View File

@@ -0,0 +1,238 @@
"use client";
import * as React from "react";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import type { Editor } from "@tiptap/react";
// --- UI Utils ---
import {
findNodePosition,
isNodeInSchema,
isNodeTypeSelected,
isValidPosition,
} from "@/lib/tiptap-utils";
// --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor";
// --- Icons ---
import { BlockquoteIcon } from "@/components/shared/tiptap/tiptap-icons/blockquote-icon";
export const BLOCKQUOTE_SHORTCUT_KEY = "mod+shift+b";
/**
* Configuration for the blockquote functionality
*/
export interface UseBlockquoteConfig {
/**
* The Tiptap editor instance.
*/
editor?: Editor | null;
/**
* Whether the button should hide when blockquote is not available.
* @default false
*/
hideWhenUnavailable?: boolean;
/**
* Callback function called after a successful toggle.
*/
onToggled?: () => void;
}
/**
* Checks if blockquote can be toggled in the current editor state
*/
export function canToggleBlockquote(
editor: Editor | null,
turnInto: boolean = true,
): boolean {
if (!editor || !editor.isEditable) return false;
if (
!isNodeInSchema("blockquote", editor) ||
isNodeTypeSelected(editor, ["image"])
)
return false;
if (!turnInto) {
return editor.can().toggleWrap("blockquote");
}
try {
const view = editor.view;
const state = view.state;
const selection = state.selection;
if (selection.empty || selection instanceof TextSelection) {
const pos = findNodePosition({
editor,
node: state.selection.$anchor.node(1),
})?.pos;
if (!isValidPosition(pos)) return false;
}
return true;
} catch {
return false;
}
}
/**
* Toggles blockquote formatting for a specific node or the current selection
*/
export function toggleBlockquote(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false;
if (!canToggleBlockquote(editor)) return false;
try {
const view = editor.view;
let state = view.state;
let tr = state.tr;
// No selection, find the the cursor position
if (state.selection.empty || state.selection instanceof TextSelection) {
const pos = findNodePosition({
editor,
node: state.selection.$anchor.node(1),
})?.pos;
if (!isValidPosition(pos)) return false;
tr = tr.setSelection(NodeSelection.create(state.doc, pos));
view.dispatch(tr);
state = view.state;
}
const selection = state.selection;
let chain = editor.chain().focus();
// Handle NodeSelection
if (selection instanceof NodeSelection) {
const firstChild = selection.node.firstChild?.firstChild;
const lastChild = selection.node.lastChild?.lastChild;
const from = firstChild
? selection.from + firstChild.nodeSize
: selection.from + 1;
const to = lastChild
? selection.to - lastChild.nodeSize
: selection.to - 1;
chain = chain.setTextSelection({ from, to }).clearNodes();
}
const toggle = editor.isActive("blockquote")
? chain.lift("blockquote")
: chain.wrapIn("blockquote");
toggle.run();
editor.chain().focus().selectTextblockEnd().run();
return true;
} catch {
return false;
}
}
/**
* Determines if the blockquote button should be shown
*/
export function shouldShowButton(props: {
editor: Editor | null;
hideWhenUnavailable: boolean;
}): boolean {
const { editor, hideWhenUnavailable } = props;
if (!editor || !editor.isEditable) return false;
if (!isNodeInSchema("blockquote", editor)) return false;
if (hideWhenUnavailable && !editor.isActive("code")) {
return canToggleBlockquote(editor);
}
return true;
}
/**
* Custom hook that provides blockquote functionality for Tiptap editor
*
* @example
* ```tsx
* // Simple usage - no params needed
* function MySimpleBlockquoteButton() {
* const { isVisible, handleToggle, isActive } = useBlockquote()
*
* if (!isVisible) return null
*
* return <button onClick={handleToggle}>Blockquote</button>
* }
*
* // Advanced usage with configuration
* function MyAdvancedBlockquoteButton() {
* const { isVisible, handleToggle, label, isActive } = useBlockquote({
* editor: myEditor,
* hideWhenUnavailable: true,
* onToggled: () => console.log('Blockquote toggled!')
* })
*
* if (!isVisible) return null
*
* return (
* <MyButton
* onClick={handleToggle}
* aria-label={label}
* aria-pressed={isActive}
* >
* Toggle Blockquote
* </MyButton>
* )
* }
* ```
*/
export function useBlockquote(config?: UseBlockquoteConfig) {
const {
editor: providedEditor,
hideWhenUnavailable = false,
onToggled,
} = config || {};
const { editor } = useTiptapEditor(providedEditor);
const [isVisible, setIsVisible] = React.useState<boolean>(true);
const canToggle = canToggleBlockquote(editor);
const isActive = editor?.isActive("blockquote") || false;
React.useEffect(() => {
if (!editor) return;
const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }));
};
handleSelectionUpdate();
editor.on("selectionUpdate", handleSelectionUpdate);
return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
};
}, [editor, hideWhenUnavailable]);
const handleToggle = React.useCallback(() => {
if (!editor) return false;
const success = toggleBlockquote(editor);
if (success) {
onToggled?.();
}
return success;
}, [editor, onToggled]);
return {
isVisible,
isActive,
handleToggle,
canToggle,
label: "Blockquote",
shortcutKeys: BLOCKQUOTE_SHORTCUT_KEY,
Icon: BlockquoteIcon,
};
}

View File

@@ -0,0 +1,122 @@
"use client";
import * as React from "react";
// --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils";
// --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor";
import { Badge } from "@/components/shared/tiptap/tiptap-ui-primitive/badge";
// --- UI Primitives ---
import type { ButtonProps } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
import { Button } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
// --- Tiptap UI ---
import type { UseCodeBlockConfig } from "@/components/shared/tiptap/tiptap-ui/code-block-button";
import {
CODE_BLOCK_SHORTCUT_KEY,
useCodeBlock,
} from "@/components/shared/tiptap/tiptap-ui/code-block-button";
export interface CodeBlockButtonProps
extends Omit<ButtonProps, "type">,
UseCodeBlockConfig {
/**
* Optional text to display alongside the icon.
*/
text?: string;
/**
* Optional show shortcut keys in the button.
* @default false
*/
showShortcut?: boolean;
}
export function CodeBlockShortcutBadge({
shortcutKeys = CODE_BLOCK_SHORTCUT_KEY,
}: {
shortcutKeys?: string;
}) {
return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>;
}
/**
* Button component for toggling code block in a Tiptap editor.
*
* For custom button implementations, use the `useCodeBlock` hook instead.
*/
export const CodeBlockButton = React.forwardRef<
HTMLButtonElement,
CodeBlockButtonProps
>(
(
{
editor: providedEditor,
text,
hideWhenUnavailable = false,
onToggled,
showShortcut = false,
onClick,
children,
...buttonProps
},
ref,
) => {
const { editor } = useTiptapEditor(providedEditor);
const {
isVisible,
canToggle,
isActive,
handleToggle,
label,
shortcutKeys,
Icon,
} = useCodeBlock({
editor,
hideWhenUnavailable,
onToggled,
});
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
handleToggle();
},
[handleToggle, onClick],
);
if (!isVisible) {
return null;
}
return (
<Button
type="button"
data-style="ghost"
data-active-state={isActive ? "on" : "off"}
role="button"
disabled={!canToggle}
data-disabled={!canToggle}
tabIndex={-1}
aria-label={label}
aria-pressed={isActive}
tooltip="Code Block"
onClick={handleClick}
{...buttonProps}
ref={ref}
>
{children ?? (
<>
<Icon className="tiptap-button-icon" />
{text && <span className="tiptap-button-text">{text}</span>}
{showShortcut && (
<CodeBlockShortcutBadge shortcutKeys={shortcutKeys} />
)}
</>
)}
</Button>
);
},
);
CodeBlockButton.displayName = "CodeBlockButton";

View File

@@ -0,0 +1,2 @@
export * from "./code-block-button"
export * from "./use-code-block"

View File

@@ -0,0 +1,245 @@
"use client";
import * as React from "react";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import { type Editor } from "@tiptap/react";
// --- Lib ---
import {
findNodePosition,
isNodeInSchema,
isNodeTypeSelected,
isValidPosition,
} from "@/lib/tiptap-utils";
// --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor";
// --- Icons ---
import { CodeBlockIcon } from "@/components/shared/tiptap/tiptap-icons/code-block-icon";
export const CODE_BLOCK_SHORTCUT_KEY = "mod+alt+c";
/**
* Configuration for the code block functionality
*/
export interface UseCodeBlockConfig {
/**
* The Tiptap editor instance.
*/
editor?: Editor | null;
/**
* Whether the button should hide when code block is not available.
* @default false
*/
hideWhenUnavailable?: boolean;
/**
* Callback function called after a successful code block toggle.
*/
onToggled?: () => void;
}
/**
* Checks if code block can be toggled in the current editor state
*/
export function canToggle(
editor: Editor | null,
turnInto: boolean = true,
): boolean {
if (!editor || !editor.isEditable) return false;
if (
!isNodeInSchema("codeBlock", editor) ||
isNodeTypeSelected(editor, ["image"])
)
return false;
if (!turnInto) {
return editor.can().toggleNode("codeBlock", "paragraph");
}
try {
const view = editor.view;
const state = view.state;
const selection = state.selection;
if (selection.empty || selection instanceof TextSelection) {
const pos = findNodePosition({
editor,
node: state.selection.$anchor.node(1),
})?.pos;
if (!isValidPosition(pos)) return false;
}
return true;
} catch {
return false;
}
}
/**
* Toggles code block in the editor
*/
export function toggleCodeBlock(editor: Editor | null): boolean {
if (!editor || !editor.isEditable) return false;
if (!canToggle(editor)) return false;
try {
const view = editor.view;
let state = view.state;
let tr = state.tr;
// No selection, find the the cursor position
if (state.selection.empty || state.selection instanceof TextSelection) {
const pos = findNodePosition({
editor,
node: state.selection.$anchor.node(1),
})?.pos;
if (!isValidPosition(pos)) return false;
tr = tr.setSelection(NodeSelection.create(state.doc, pos));
view.dispatch(tr);
state = view.state;
}
const selection = state.selection;
let chain = editor.chain().focus();
// Handle NodeSelection
if (selection instanceof NodeSelection) {
const firstChild = selection.node.firstChild?.firstChild;
const lastChild = selection.node.lastChild?.lastChild;
const from = firstChild
? selection.from + firstChild.nodeSize
: selection.from + 1;
const to = lastChild
? selection.to - lastChild.nodeSize
: selection.to - 1;
chain = chain.setTextSelection({ from, to }).clearNodes();
}
const toggle = editor.isActive("codeBlock")
? chain.setNode("paragraph")
: chain.toggleNode("codeBlock", "paragraph");
toggle.run();
editor.chain().focus().selectTextblockEnd().run();
return true;
} catch {
return false;
}
}
/**
* Determines if the code block button should be shown
*/
export function shouldShowButton(props: {
editor: Editor | null;
hideWhenUnavailable: boolean;
}): boolean {
const { editor, hideWhenUnavailable } = props;
if (!editor || !editor.isEditable) return false;
if (!isNodeInSchema("codeBlock", editor)) return false;
if (hideWhenUnavailable && !editor.isActive("code")) {
return canToggle(editor);
}
return true;
}
/**
* Custom hook that provides code block functionality for Tiptap editor
*
* @example
* ```tsx
* // Simple usage - no params needed
* function MySimpleCodeBlockButton() {
* const { isVisible, isActive, handleToggle } = useCodeBlock()
*
* if (!isVisible) return null
*
* return (
* <button
* onClick={handleToggle}
* aria-pressed={isActive}
* >
* Code Block
* </button>
* )
* }
*
* // Advanced usage with configuration
* function MyAdvancedCodeBlockButton() {
* const { isVisible, isActive, handleToggle, label } = useCodeBlock({
* editor: myEditor,
* hideWhenUnavailable: true,
* onToggled: (isActive) => console.log('Code block toggled:', isActive)
* })
*
* if (!isVisible) return null
*
* return (
* <MyButton
* onClick={handleToggle}
* aria-label={label}
* aria-pressed={isActive}
* >
* Toggle Code Block
* </MyButton>
* )
* }
* ```
*/
export function useCodeBlock(config?: UseCodeBlockConfig) {
const {
editor: providedEditor,
hideWhenUnavailable = false,
onToggled,
} = config || {};
const { editor } = useTiptapEditor(providedEditor);
const [isVisible, setIsVisible] = React.useState<boolean>(true);
const canToggleState = canToggle(editor);
const isActive = editor?.isActive("codeBlock") || false;
React.useEffect(() => {
if (!editor) return;
const handleSelectionUpdate = () => {
setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }));
};
handleSelectionUpdate();
editor.on("selectionUpdate", handleSelectionUpdate);
return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
};
}, [editor, hideWhenUnavailable]);
const handleToggle = React.useCallback(() => {
if (!editor) return false;
const success = toggleCodeBlock(editor);
if (success) {
onToggled?.();
}
return success;
}, [editor, onToggled]);
return {
isVisible,
isActive,
handleToggle,
canToggle: canToggleState,
label: "Code Block",
shortcutKeys: CODE_BLOCK_SHORTCUT_KEY,
Icon: CodeBlockIcon,
};
}

View File

@@ -0,0 +1,49 @@
.tiptap-button-highlight {
position: relative;
width: 1.25rem;
height: 1.25rem;
margin: 0 -0.175rem;
border-radius: var(--tt-radius-xl);
background-color: var(--highlight-color);
transition: transform 0.2s ease;
&::after {
content: "";
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
border-radius: inherit;
box-sizing: border-box;
border: 1px solid var(--highlight-color);
filter: brightness(95%);
mix-blend-mode: multiply;
.dark & {
filter: brightness(140%);
mix-blend-mode: lighten;
}
}
}
.tiptap-button {
&[data-active-state="on"] {
.tiptap-button-highlight {
&::after {
filter: brightness(80%);
}
}
}
.dark & {
&[data-active-state="on"] {
.tiptap-button-highlight {
&::after {
// Andere Eigenschaft für .dark Kontext
filter: brightness(180%);
}
}
}
}
}

View File

@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
// --- Lib ---
import { parseShortcutKeys } from "@/lib/tiptap-utils";
// --- Hooks ---
import { useTiptapEditor } from "@/hooks/use-tiptap-editor";
import { Badge } from "@/components/shared/tiptap/tiptap-ui-primitive/badge";
// --- UI Primitives ---
import type { ButtonProps } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
import { Button } from "@/components/shared/tiptap/tiptap-ui-primitive/button";
// --- Tiptap UI ---
import type { UseColorHighlightConfig } from "@/components/shared/tiptap/tiptap-ui/color-highlight-button";
import {
COLOR_HIGHLIGHT_SHORTCUT_KEY,
useColorHighlight,
} from "@/components/shared/tiptap/tiptap-ui/color-highlight-button";
// --- Styles ---
import "@/components/shared/tiptap/tiptap-ui/color-highlight-button/color-highlight-button.scss";
export interface ColorHighlightButtonProps
extends Omit<ButtonProps, "type">,
UseColorHighlightConfig {
/**
* Optional text to display alongside the icon.
*/
text?: string;
/**
* Optional show shortcut keys in the button.
* @default false
*/
showShortcut?: boolean;
}
export function ColorHighlightShortcutBadge({
shortcutKeys = COLOR_HIGHLIGHT_SHORTCUT_KEY,
}: {
shortcutKeys?: string;
}) {
return <Badge>{parseShortcutKeys({ shortcutKeys })}</Badge>;
}
/**
* Button component for applying color highlights in a Tiptap editor.
*
* For custom button implementations, use the `useColorHighlight` hook instead.
*/
export const ColorHighlightButton = React.forwardRef<
HTMLButtonElement,
ColorHighlightButtonProps
>(
(
{
editor: providedEditor,
highlightColor,
text,
hideWhenUnavailable = false,
onApplied,
showShortcut = false,
onClick,
children,
style,
...buttonProps
},
ref,
) => {
const { editor } = useTiptapEditor(providedEditor);
const {
isVisible,
canColorHighlight,
isActive,
handleColorHighlight,
label,
shortcutKeys,
} = useColorHighlight({
editor,
highlightColor,
label: text || `Toggle highlight (${highlightColor})`,
hideWhenUnavailable,
onApplied,
});
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented) return;
handleColorHighlight();
},
[handleColorHighlight, onClick],
);
const buttonStyle = React.useMemo(
() =>
({
...style,
"--highlight-color": highlightColor,
}) as React.CSSProperties,
[highlightColor, style],
);
if (!isVisible) {
return null;
}
return (
<Button
type="button"
data-style="ghost"
data-active-state={isActive ? "on" : "off"}
role="button"
tabIndex={-1}
disabled={!canColorHighlight}
data-disabled={!canColorHighlight}
aria-label={label}
aria-pressed={isActive}
tooltip={label}
onClick={handleClick}
style={buttonStyle}
{...buttonProps}
ref={ref}
>
{children ?? (
<>
<span
className="tiptap-button-highlight"
style={
{ "--highlight-color": highlightColor } as React.CSSProperties
}
/>
{text && <span className="tiptap-button-text">{text}</span>}
{showShortcut && (
<ColorHighlightShortcutBadge shortcutKeys={shortcutKeys} />
)}
</>
)}
</Button>
);
},
);
ColorHighlightButton.displayName = "ColorHighlightButton";

View File

@@ -0,0 +1,2 @@
export * from "./color-highlight-button"
export * from "./use-color-highlight"

Some files were not shown because too many files have changed in this diff Show More