-
+ >
+
)
}
\ No newline at end of file
diff --git a/app/api/session/sync/route.ts b/app/api/session/sync/route.ts
index e14fbe9..6359184 100644
--- a/app/api/session/sync/route.ts
+++ b/app/api/session/sync/route.ts
@@ -48,13 +48,16 @@ export async function POST(request: NextRequest) {
} catch (error) {
console.error('Login error:', error);
- return NextResponse.json(
+ const res = NextResponse.json(
{
error: 'Internal server error',
message: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
);
+
+ res.cookies.delete('jwt');
+ return res;
}
}
diff --git a/app/app-sidebar.tsx b/app/app-sidebar.tsx
index 6139a3f..f196004 100644
--- a/app/app-sidebar.tsx
+++ b/app/app-sidebar.tsx
@@ -1,97 +1,269 @@
import * as React from "react"
-import { Plus } from "lucide-react"
-import { Calendars } from '@/app/calendars'
-import { NavUser } from '@/app/nav-user'
+import { Book, Command, Home, LucideIcon, Plus, User, Settings, Crown, LogOut } from "lucide-react"
+import { motion } from "framer-motion"
+import Link from "next/link"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
+ SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
- SidebarSeparator,
} from '@/components/ui/sidebar'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Label } from "@/components/ui/label"
-import { Slider } from "@/components/ui/slider"
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
import { useMapLocation, type LocationKey } from "@/hooks/use-map-location"
import { ThemeToggle } from "@/components/theme-toggle"
import { useMapZoom } from "@/hooks/use-map-zoom"
import { useUser } from "./user-context"
+import { Separator } from "@/components/ui/separator"
+import { cn } from "@/lib/utils"
+import { useRouter } from "next/navigation"
-// This is sample data.
-const data = {
- user: {
- name: "shadcn",
- email: "m@example.com",
- avatar: "/avatars/shadcn.jpg",
- },
- calendars: [
- {
- name: "My Calendars",
- items: ["Personal", "Work", "Family"],
- },
- {
- name: "Favorites",
- items: ["Holidays", "Birthdays"],
- },
- {
- name: "Other",
- items: ["Travel", "Reminders", "Deadlines"],
- },
- ],
+
+interface MainNavItemProps {
+ icon: LucideIcon
+ label: string
+ onClick?: () => void
+ className?: string
+}
+
+const MainNavItem = React.forwardRef
(
+ ({ icon: Icon, label, onClick, className }, ref) => {
+ return (
+
+
+
+ {label}
+
+
+ )
+ }
+)
+MainNavItem.displayName = "MainNavItem"
+
+interface MainNavProps {
+ className?: string
+ items: {
+ icon: LucideIcon
+ label: string
+ onClick?: () => void
+ }[]
+}
+
+
+
+const MainNav = React.forwardRef(
+ ({ items, className }, ref) => {
+ return (
+
+ {items.map((item) => (
+
+ ))}
+
+ )
+ }
+)
+MainNav.displayName = "MainNav"
+
+// User avatar component
+function UserAvatar() {
+ const { user } = useUser()
+
+ if (user) {
+ return (
+
+
+
+
+
+
+ {user.name?.charAt(0)?.toUpperCase() || "U"}
+
+
+
+
+
+
+
+
{user.name}
+
{user.email}
+
+
+
+
+
+
+
+ Profile
+ ⌘P
+
+
+
+
+
+ Account
+ ⌘A
+
+
+
+
+
+ Notifications
+ ⌘N
+
+
+
+
+
+
+
+
+ Upgrade to Pro
+ ⌘U
+
+
+
+
+
+ Billing
+ ⌘B
+
+
+
+
+
+
+
+ Help Center
+
+
+
+ API Documentation
+
+
+
+
+ Log out
+ ⇧⌘Q
+
+
+
+ )
+ }
+
+ return (
+
+
+
+
+
+ )
}
export function AppSidebar({ ...props }: React.ComponentProps) {
-
- const { currentLocation, flyToLocation, isMapReady } = useMapLocation();
- const { zoomToLocation, zoomIn, zoomOut, mapState } = useMapZoom();
- const { user } = useUser()
+ const router = useRouter()
+ const mainNavItems = [
+ {
+ icon: Home,
+ label: "Home",
+ onClick: () => console.log("Navigate to home")
+ },
+ {
+ icon: Book,
+ label: "Docs",
+ onClick: () => router.push("/blog")
+ },
+ {
+ icon: Plus,
+ label: "New",
+ onClick: () => console.log("Create new project")
+ }
+ ]
return (
-
-
-
-
-
- Location
- {
- if (isMapReady) {
- flyToLocation(value as LocationKey)
- }
- }}>
-
-
-
-
- USA
- Singapore
- Malaysia
- China
-
-
-
+
+
+
+
-
+
-
- Zoom
-
- zoomToLocation(value[0])} />
- {mapState.zoomLevel.toFixed(1)}
-
-
-
-
-
- {
- user ? : null
- }
-
-
-
+
+
+ {/* User avatar - fixed at bottom */}
+
+
+
+
+ )
+}
+
+
+export function NavSecondary({
+ items,
+ ...props
+}: {
+ items: {
+ title: string
+ url: string
+ icon: LucideIcon
+ }[]
+} & React.ComponentPropsWithoutRef) {
+ return (
+
+
+
+ {items.map((item) => (
+
+
+
+
+ {item.title}
+
+
+
+ ))}
+
+
+
)
}
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx
new file mode 100644
index 0000000..229032d
--- /dev/null
+++ b/app/blog/[slug]/page.tsx
@@ -0,0 +1,60 @@
+"use client"
+import { gql, useQuery } from '@apollo/client'
+import { use, useState } from 'react'
+import BlogViewer from '@/components/blog-viewer'
+import TableOfContents from '@/components/table-of-contents'
+import { cn } from '@/lib/utils'
+
+const BLOG = gql`
+ query Blog($slug: String!) {
+ blogBySlug(slug: $slug) {
+ id
+ title
+ content
+ }
+ }
+`
+
+export default function Page({ params }: { params: Promise<{ slug: string }> }) {
+ // 使用 React.use() 来解包 params Promise
+ const resolvedParams = use(params);
+ const { slug } = resolvedParams;
+ const [contentReady, setContentReady] = useState(false);
+
+ const { data, loading, error } = useQuery(BLOG, {
+ variables: { slug },
+ })
+
+ // 条件渲染处理
+ if (loading) return Loading...
;
+ if (error) return Error: {error.message}
;
+ if (!data?.blogBySlug) return Blog not found
;
+
+ const content = data.blogBySlug.content;
+ console.log('Blog content:', content);
+
+ return (
+
+
+ {contentReady && (
+
+ )}
+
+ {/* 主内容区域 */}
+
+
+ {data.blogBySlug.title}
+ setContentReady(true)}
+ />
+
+
+
+ );
+}
diff --git a/app/blog/layout.tsx b/app/blog/layout.tsx
new file mode 100644
index 0000000..969bbc8
--- /dev/null
+++ b/app/blog/layout.tsx
@@ -0,0 +1,13 @@
+import { Navigation } from "../nav"
+
+export default function Layout({ children }: { children: React.ReactNode }) {
+
+ return (
+
+
+ )
+}
\ No newline at end of file
diff --git a/app/blog/page.tsx b/app/blog/page.tsx
new file mode 100644
index 0000000..7cd5ead
--- /dev/null
+++ b/app/blog/page.tsx
@@ -0,0 +1,50 @@
+"use client"
+
+import { Button } from "@/components/ui/button";
+import { gql, useQuery } from "@apollo/client";
+import Link from "next/link";
+import { useEffect } from "react";
+
+const BLOG = gql`
+ query Blog {
+
+ blogs {
+ items {
+ id
+ title
+ excerpt
+ slug
+ }
+ }
+
+ }
+`
+
+export default function Blog() {
+
+ const { data, loading, error } = useQuery(BLOG);
+
+ const items = data?.blogs?.items || [];
+
+ return
+
Blog
+ {loading &&
Loading...
}
+ {error &&
Error: {error.message}
}
+ {!loading && items.length === 0 &&
No blogs found
}
+ {items.map((blog: any) => (
+
+ ))}
+
;
+}
+
+function BlogItem({ blog }: { blog: any }) {
+ return
+
+
+ {blog.title}
+
+
+
{blog.excerpt}
+
+}
+
diff --git a/app/globals.css b/app/globals.css
index a23a767..fe9db1a 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,5 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
+@import '../styles/_variables.scss';
+@import '../styles/_keyframe-animations.scss';
@custom-variant dark (&:is(.dark *));
diff --git a/app/layout.tsx b/app/layout.tsx
index dee53ab..cefab62 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -28,7 +28,7 @@ export default function RootLayout({
return (
{children}
diff --git a/app/nav.tsx b/app/nav.tsx
new file mode 100644
index 0000000..3472fce
--- /dev/null
+++ b/app/nav.tsx
@@ -0,0 +1,212 @@
+"use client"
+
+import * as React from "react"
+import Link from "next/link"
+import { CircleCheckIcon, CircleHelpIcon, CircleIcon } from "lucide-react"
+
+import {
+ NavigationMenu,
+ NavigationMenuContent,
+ NavigationMenuItem,
+ NavigationMenuLink,
+ NavigationMenuList,
+ NavigationMenuTrigger,
+ navigationMenuTriggerStyle,
+} from "@/components/ui/navigation-menu"
+
+const components: { title: string; href: string; description: string }[] = [
+ {
+ title: "Alert Dialog",
+ href: "/docs/primitives/alert-dialog",
+ description:
+ "A modal dialog that interrupts the user with important content and expects a response.",
+ },
+ {
+ title: "Hover Card",
+ href: "/docs/primitives/hover-card",
+ description:
+ "For sighted users to preview content available behind a link.",
+ },
+ {
+ title: "Progress",
+ href: "/docs/primitives/progress",
+ description:
+ "Displays an indicator showing the completion progress of a task, typically displayed as a progress bar.",
+ },
+ {
+ title: "Scroll-area",
+ href: "/docs/primitives/scroll-area",
+ description: "Visually or semantically separates content.",
+ },
+ {
+ title: "Tabs",
+ href: "/docs/primitives/tabs",
+ description:
+ "A set of layered sections of content—known as tab panels—that are displayed one at a time.",
+ },
+ {
+ title: "Tooltip",
+ href: "/docs/primitives/tooltip",
+ description:
+ "A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.",
+ },
+]
+
+export function Navigation() {
+ return (
+
+
+
+ Home
+
+
+
+
+
+ Components
+
+
+ {components.map((component) => (
+
+ {component.description}
+
+ ))}
+
+
+
+
+
+ Docs
+
+
+
+ List
+
+
+
+
+
+ Components
+
+ Browse all components in the library.
+
+
+
+
+
+ Documentation
+
+ Learn how to use the library.
+
+
+
+
+
+ Blog
+
+ Read our latest blog posts.
+
+
+
+
+
+
+
+
+ Simple
+
+
+
+
+ Components
+
+
+ Documentation
+
+
+ Blocks
+
+
+
+
+
+
+ With Icon
+
+
+
+
+
+
+ Backlog
+
+
+
+
+
+ To Do
+
+
+
+
+
+ Done
+
+
+
+
+
+
+
+
+ )
+}
+
+function ListItem({
+ title,
+ children,
+ href,
+ ...props
+}: React.ComponentPropsWithoutRef<"li"> & { href: string }) {
+ return (
+
+
+
+ {title}
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/app/page.tsx b/app/page.tsx
index 74d7a94..72444e7 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -21,35 +21,23 @@ import { cn } from '@/lib/utils';
import { useMap } from './map-context'
import { format } from 'date-fns'
import { Label } from '@/components/ui/label'
+import { Navigation } from './nav'
export default function Page() {
- const { currentDatetime, timelineDatetime } = useMap()
-
return (
-
+
-
-
-
)
}
diff --git a/app/simple/page.tsx b/app/simple/page.tsx
new file mode 100644
index 0000000..5bb89be
--- /dev/null
+++ b/app/simple/page.tsx
@@ -0,0 +1,5 @@
+import { SimpleEditor } from "@/components/tiptap-templates/simple/simple-editor"
+
+export default function Page() {
+ return
+}
diff --git a/components/blog-viewer.tsx b/components/blog-viewer.tsx
new file mode 100644
index 0000000..f28d53a
--- /dev/null
+++ b/components/blog-viewer.tsx
@@ -0,0 +1,100 @@
+"use client";
+import { EditorContent, useEditor } from '@tiptap/react';
+import { StarterKit } from '@tiptap/starter-kit';
+import { Image } from '@tiptap/extension-image';
+import { TaskItem, TaskList } from '@tiptap/extension-list';
+import { TextAlign } from '@tiptap/extension-text-align';
+import { Typography } from '@tiptap/extension-typography';
+import { Highlight } from '@tiptap/extension-highlight';
+import { Subscript } from '@tiptap/extension-subscript';
+import { Superscript } from '@tiptap/extension-superscript';
+import { HorizontalRule } from '@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension';
+import { useEffect } from 'react';
+
+// 导入必要的样式
+import '@/components/tiptap-node/blockquote-node/blockquote-node.scss';
+import '@/components/tiptap-node/code-block-node/code-block-node.scss';
+import '@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss';
+import '@/components/tiptap-node/list-node/list-node.scss';
+import '@/components/tiptap-node/image-node/image-node.scss';
+import '@/components/tiptap-node/heading-node/heading-node.scss';
+import '@/components/tiptap-node/paragraph-node/paragraph-node.scss';
+
+interface BlogViewerProps {
+ content: any;
+ onContentReady?: () => void;
+}
+
+export default function BlogViewer({ content, onContentReady }: BlogViewerProps) {
+ const editor = useEditor({
+ immediatelyRender: false,
+ editable: false,
+ editorProps: {
+ attributes: {
+ class: 'simple-editor prose prose-gray max-w-none dark:prose-invert',
+ },
+ },
+ extensions: [
+ StarterKit.configure({
+ horizontalRule: false,
+ link: {
+ openOnClick: true,
+ enableClickSelection: false,
+ },
+ }),
+ HorizontalRule,
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Highlight.configure({ multicolor: true }),
+ Image,
+ Typography,
+ Superscript,
+ Subscript,
+ ],
+ content: content || '',
+ });
+
+ // 为标题添加 ID,并在内容加载完成后通知父组件
+ useEffect(() => {
+ if (editor && editor.view.dom) {
+ // 等待 DOM 更新
+ setTimeout(() => {
+ const headings = editor.view.dom.querySelectorAll('h1, h2, h3, h4, h5, h6');
+ headings.forEach((heading, index) => {
+ const element = heading as HTMLElement;
+ if (!element.id) {
+ const text = element.textContent || '';
+ let id = text
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '')
+ .replace(/[\s_-]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+
+ // 确保 ID 唯一
+ let uniqueId = id;
+ let counter = 1;
+ while (document.getElementById(uniqueId)) {
+ uniqueId = `${id}-${counter}`;
+ counter++;
+ }
+
+ element.id = uniqueId;
+ }
+ });
+
+ // 通知父组件内容已准备好
+ onContentReady?.();
+ }, 100);
+ }
+ }, [editor, content, onContentReady]);
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/map-component.tsx b/components/map-component.tsx
index 1046572..14a8d14 100644
--- a/components/map-component.tsx
+++ b/components/map-component.tsx
@@ -541,10 +541,11 @@ export function MapComponent({
}, [currentColorMapType, isReady])
return (
-
+
{/* Colorbar 在右下角 */}
diff --git a/components/table-of-contents.tsx b/components/table-of-contents.tsx
new file mode 100644
index 0000000..4783799
--- /dev/null
+++ b/components/table-of-contents.tsx
@@ -0,0 +1,165 @@
+"use client";
+import { useEffect, useState, useRef, useCallback } from 'react';
+import { cn } from '@/lib/utils';
+
+export interface TOCItem {
+ id: string;
+ text: string;
+ level: number;
+ element: HTMLElement;
+}
+
+interface TableOfContentsProps {
+ className?: string;
+}
+
+export default function TableOfContents({ className }: TableOfContentsProps) {
+ const [toc, setToc] = useState
([]);
+ const [activeId, setActiveId] = useState('');
+ const observerRef = useRef(null);
+
+ // 提取页面中的标题
+ const extractHeadings = useCallback(() => {
+ const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
+ const tocItems: TOCItem[] = [];
+
+ headings.forEach((heading, index) => {
+ const element = heading as HTMLElement;
+ const text = element.textContent || '';
+ const level = parseInt(element.tagName.charAt(1));
+
+ // 为标题添加 ID(如果没有的话)
+ let id = element.id;
+ if (!id) {
+ id = text
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, '') // 移除特殊字符
+ .replace(/[\s_-]+/g, '-') // 替换空格和下划线为连字符
+ .replace(/^-+|-+$/g, ''); // 移除首尾的连字符
+
+ // 确保 ID 唯一
+ let uniqueId = id;
+ let counter = 1;
+ while (document.getElementById(uniqueId)) {
+ uniqueId = `${id}-${counter}`;
+ counter++;
+ }
+
+ element.id = uniqueId;
+ id = uniqueId;
+ }
+
+ tocItems.push({
+ id,
+ text,
+ level,
+ element
+ });
+ });
+
+ setToc(tocItems);
+ return tocItems;
+ }, []);
+
+ // 设置 Intersection Observer 来追踪当前可见的标题
+ useEffect(() => {
+ const headings = extractHeadings();
+
+ if (headings.length === 0) return;
+
+ observerRef.current = new IntersectionObserver(
+ (entries) => {
+ // 找到所有可见的标题
+ const visibleHeadings = entries
+ .filter(entry => entry.isIntersecting)
+ .map(entry => entry.target.id);
+
+ if (visibleHeadings.length > 0) {
+ // 设置第一个可见标题为活跃状态
+ setActiveId(visibleHeadings[0]);
+ }
+ },
+ {
+ rootMargin: '-80px 0px -80%',
+ threshold: 0
+ }
+ );
+
+ // 观察所有标题
+ headings.forEach((item) => {
+ observerRef.current?.observe(item.element);
+ });
+
+ return () => {
+ observerRef.current?.disconnect();
+ };
+ }, [extractHeadings]);
+
+ // 点击目录项滚动到对应位置
+ const scrollToHeading = useCallback((id: string) => {
+ console.log('Scrolling to:', id); // 调试日志
+ const element = document.getElementById(id);
+ if (element) {
+ console.log('Element found:', element); // 调试日志
+ const offsetTop = element.getBoundingClientRect().top + window.pageYOffset - 100;
+ window.scrollTo({
+ top: offsetTop,
+ behavior: 'smooth'
+ });
+
+ // 更新活跃状态
+ setActiveId(id);
+ } else {
+ console.log('Element not found for id:', id); // 调试日志
+ }
+ }, []);
+
+ if (toc.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+
+ Navigation
+
+
+
+ {toc.map((item) => (
+
+ {
+ e.preventDefault();
+ scrollToHeading(item.id);
+ }}
+ className={cn(
+ "block w-full text-left py-1.5 px-2 text-xs rounded transition-colors duration-200",
+ "hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer",
+ {
+ 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 font-medium':
+ activeId === item.id,
+ 'text-gray-600 dark:text-gray-400':
+ activeId !== item.id,
+ }
+ )}
+ style={{
+ paddingLeft: `${(item.level - 1) * 8 + 8}px`,
+ }}
+ type="button"
+ >
+ {item.text}
+
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/tiptap-icons/align-center-icon.tsx b/components/tiptap-icons/align-center-icon.tsx
new file mode 100644
index 0000000..5756812
--- /dev/null
+++ b/components/tiptap-icons/align-center-icon.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+
+export const AlignCenterIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+AlignCenterIcon.displayName = "AlignCenterIcon"
diff --git a/components/tiptap-icons/align-justify-icon.tsx b/components/tiptap-icons/align-justify-icon.tsx
new file mode 100644
index 0000000..e43b354
--- /dev/null
+++ b/components/tiptap-icons/align-justify-icon.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+
+export const AlignJustifyIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+AlignJustifyIcon.displayName = "AlignJustifyIcon"
diff --git a/components/tiptap-icons/align-left-icon.tsx b/components/tiptap-icons/align-left-icon.tsx
new file mode 100644
index 0000000..4cb2373
--- /dev/null
+++ b/components/tiptap-icons/align-left-icon.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+
+export const AlignLeftIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+AlignLeftIcon.displayName = "AlignLeftIcon"
diff --git a/components/tiptap-icons/align-right-icon.tsx b/components/tiptap-icons/align-right-icon.tsx
new file mode 100644
index 0000000..af832df
--- /dev/null
+++ b/components/tiptap-icons/align-right-icon.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+
+export const AlignRightIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+AlignRightIcon.displayName = "AlignRightIcon"
diff --git a/components/tiptap-icons/arrow-left-icon.tsx b/components/tiptap-icons/arrow-left-icon.tsx
new file mode 100644
index 0000000..e206c68
--- /dev/null
+++ b/components/tiptap-icons/arrow-left-icon.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+export const ArrowLeftIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+ArrowLeftIcon.displayName = "ArrowLeftIcon"
diff --git a/components/tiptap-icons/ban-icon.tsx b/components/tiptap-icons/ban-icon.tsx
new file mode 100644
index 0000000..052f8ec
--- /dev/null
+++ b/components/tiptap-icons/ban-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const BanIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+BanIcon.displayName = "BanIcon"
diff --git a/components/tiptap-icons/blockquote-icon.tsx b/components/tiptap-icons/blockquote-icon.tsx
new file mode 100644
index 0000000..8282a39
--- /dev/null
+++ b/components/tiptap-icons/blockquote-icon.tsx
@@ -0,0 +1,44 @@
+import * as React from "react"
+
+export const BlockquoteIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+
+ )
+ }
+)
+
+BlockquoteIcon.displayName = "BlockquoteIcon"
diff --git a/components/tiptap-icons/bold-icon.tsx b/components/tiptap-icons/bold-icon.tsx
new file mode 100644
index 0000000..33840fd
--- /dev/null
+++ b/components/tiptap-icons/bold-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const BoldIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+BoldIcon.displayName = "BoldIcon"
diff --git a/components/tiptap-icons/chevron-down-icon.tsx b/components/tiptap-icons/chevron-down-icon.tsx
new file mode 100644
index 0000000..36c09b0
--- /dev/null
+++ b/components/tiptap-icons/chevron-down-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const ChevronDownIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+ChevronDownIcon.displayName = "ChevronDownIcon"
diff --git a/components/tiptap-icons/close-icon.tsx b/components/tiptap-icons/close-icon.tsx
new file mode 100644
index 0000000..5fce813
--- /dev/null
+++ b/components/tiptap-icons/close-icon.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+export const CloseIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+CloseIcon.displayName = "CloseIcon"
diff --git a/components/tiptap-icons/code-block-icon.tsx b/components/tiptap-icons/code-block-icon.tsx
new file mode 100644
index 0000000..12027f1
--- /dev/null
+++ b/components/tiptap-icons/code-block-icon.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+
+export const CodeBlockIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+CodeBlockIcon.displayName = "CodeBlockIcon"
diff --git a/components/tiptap-icons/code2-icon.tsx b/components/tiptap-icons/code2-icon.tsx
new file mode 100644
index 0000000..4092170
--- /dev/null
+++ b/components/tiptap-icons/code2-icon.tsx
@@ -0,0 +1,32 @@
+import * as React from "react"
+
+export const Code2Icon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+Code2Icon.displayName = "Code2Icon"
diff --git a/components/tiptap-icons/corner-down-left-icon.tsx b/components/tiptap-icons/corner-down-left-icon.tsx
new file mode 100644
index 0000000..a739cca
--- /dev/null
+++ b/components/tiptap-icons/corner-down-left-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const CornerDownLeftIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+CornerDownLeftIcon.displayName = "CornerDownLeftIcon"
diff --git a/components/tiptap-icons/external-link-icon.tsx b/components/tiptap-icons/external-link-icon.tsx
new file mode 100644
index 0000000..cfe92b4
--- /dev/null
+++ b/components/tiptap-icons/external-link-icon.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+
+export const ExternalLinkIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+ExternalLinkIcon.displayName = "ExternalLinkIcon"
diff --git a/components/tiptap-icons/heading-five-icon.tsx b/components/tiptap-icons/heading-five-icon.tsx
new file mode 100644
index 0000000..c8e5403
--- /dev/null
+++ b/components/tiptap-icons/heading-five-icon.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+
+export const HeadingFiveIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+HeadingFiveIcon.displayName = "HeadingFiveIcon"
diff --git a/components/tiptap-icons/heading-four-icon.tsx b/components/tiptap-icons/heading-four-icon.tsx
new file mode 100644
index 0000000..41e004e
--- /dev/null
+++ b/components/tiptap-icons/heading-four-icon.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+
+export const HeadingFourIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+HeadingFourIcon.displayName = "HeadingFourIcon"
diff --git a/components/tiptap-icons/heading-icon.tsx b/components/tiptap-icons/heading-icon.tsx
new file mode 100644
index 0000000..8c727de
--- /dev/null
+++ b/components/tiptap-icons/heading-icon.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+export const HeadingIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+HeadingIcon.displayName = "HeadingIcon"
diff --git a/components/tiptap-icons/heading-one-icon.tsx b/components/tiptap-icons/heading-one-icon.tsx
new file mode 100644
index 0000000..4deddde
--- /dev/null
+++ b/components/tiptap-icons/heading-one-icon.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+
+export const HeadingOneIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+HeadingOneIcon.displayName = "HeadingOneIcon"
diff --git a/components/tiptap-icons/heading-six-icon.tsx b/components/tiptap-icons/heading-six-icon.tsx
new file mode 100644
index 0000000..d38c877
--- /dev/null
+++ b/components/tiptap-icons/heading-six-icon.tsx
@@ -0,0 +1,30 @@
+import * as React from "react"
+
+export const HeadingSixIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+HeadingSixIcon.displayName = "HeadingSixIcon"
diff --git a/components/tiptap-icons/heading-three-icon.tsx b/components/tiptap-icons/heading-three-icon.tsx
new file mode 100644
index 0000000..17db21c
--- /dev/null
+++ b/components/tiptap-icons/heading-three-icon.tsx
@@ -0,0 +1,36 @@
+import * as React from "react"
+
+export const HeadingThreeIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+HeadingThreeIcon.displayName = "HeadingThreeIcon"
diff --git a/components/tiptap-icons/heading-two-icon.tsx b/components/tiptap-icons/heading-two-icon.tsx
new file mode 100644
index 0000000..3b3c727
--- /dev/null
+++ b/components/tiptap-icons/heading-two-icon.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+
+export const HeadingTwoIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+HeadingTwoIcon.displayName = "HeadingTwoIcon"
diff --git a/components/tiptap-icons/highlighter-icon.tsx b/components/tiptap-icons/highlighter-icon.tsx
new file mode 100644
index 0000000..edc5a67
--- /dev/null
+++ b/components/tiptap-icons/highlighter-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const HighlighterIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+HighlighterIcon.displayName = "HighlighterIcon"
diff --git a/components/tiptap-icons/image-plus-icon.tsx b/components/tiptap-icons/image-plus-icon.tsx
new file mode 100644
index 0000000..5184d3d
--- /dev/null
+++ b/components/tiptap-icons/image-plus-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const ImagePlusIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+ImagePlusIcon.displayName = "ImagePlusIcon"
diff --git a/components/tiptap-icons/italic-icon.tsx b/components/tiptap-icons/italic-icon.tsx
new file mode 100644
index 0000000..7d7a79e
--- /dev/null
+++ b/components/tiptap-icons/italic-icon.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+export const ItalicIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+ItalicIcon.displayName = "ItalicIcon"
diff --git a/components/tiptap-icons/link-icon.tsx b/components/tiptap-icons/link-icon.tsx
new file mode 100644
index 0000000..f653770
--- /dev/null
+++ b/components/tiptap-icons/link-icon.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+
+export const LinkIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+LinkIcon.displayName = "LinkIcon"
diff --git a/components/tiptap-icons/list-icon.tsx b/components/tiptap-icons/list-icon.tsx
new file mode 100644
index 0000000..046830c
--- /dev/null
+++ b/components/tiptap-icons/list-icon.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+
+export const ListIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+)
+
+ListIcon.displayName = "ListIcon"
diff --git a/components/tiptap-icons/list-ordered-icon.tsx b/components/tiptap-icons/list-ordered-icon.tsx
new file mode 100644
index 0000000..ca153d1
--- /dev/null
+++ b/components/tiptap-icons/list-ordered-icon.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+
+export const ListOrderedIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+
+
+
+ )
+ }
+)
+
+ListOrderedIcon.displayName = "ListOrderedIcon"
diff --git a/components/tiptap-icons/list-todo-icon.tsx b/components/tiptap-icons/list-todo-icon.tsx
new file mode 100644
index 0000000..61c3d61
--- /dev/null
+++ b/components/tiptap-icons/list-todo-icon.tsx
@@ -0,0 +1,50 @@
+import * as React from "react"
+
+export const ListTodoIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+
+
+ )
+ }
+)
+
+ListTodoIcon.displayName = "ListTodoIcon"
diff --git a/components/tiptap-icons/moon-star-icon.tsx b/components/tiptap-icons/moon-star-icon.tsx
new file mode 100644
index 0000000..a49f02c
--- /dev/null
+++ b/components/tiptap-icons/moon-star-icon.tsx
@@ -0,0 +1,30 @@
+import * as React from "react"
+
+export const MoonStarIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+MoonStarIcon.displayName = "MoonStarIcon"
diff --git a/components/tiptap-icons/redo2-icon.tsx b/components/tiptap-icons/redo2-icon.tsx
new file mode 100644
index 0000000..2b7760f
--- /dev/null
+++ b/components/tiptap-icons/redo2-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const Redo2Icon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+Redo2Icon.displayName = "Redo2Icon"
diff --git a/components/tiptap-icons/strike-icon.tsx b/components/tiptap-icons/strike-icon.tsx
new file mode 100644
index 0000000..6616929
--- /dev/null
+++ b/components/tiptap-icons/strike-icon.tsx
@@ -0,0 +1,28 @@
+import * as React from "react"
+
+export const StrikeIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+ )
+ }
+)
+
+StrikeIcon.displayName = "StrikeIcon"
diff --git a/components/tiptap-icons/subscript-icon.tsx b/components/tiptap-icons/subscript-icon.tsx
new file mode 100644
index 0000000..5d1d11d
--- /dev/null
+++ b/components/tiptap-icons/subscript-icon.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+
+export const SubscriptIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+SubscriptIcon.displayName = "SubscriptIcon"
diff --git a/components/tiptap-icons/sun-icon.tsx b/components/tiptap-icons/sun-icon.tsx
new file mode 100644
index 0000000..25c6cb4
--- /dev/null
+++ b/components/tiptap-icons/sun-icon.tsx
@@ -0,0 +1,58 @@
+import * as React from "react"
+
+export const SunIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }
+)
+
+SunIcon.displayName = "SunIcon"
diff --git a/components/tiptap-icons/superscript-icon.tsx b/components/tiptap-icons/superscript-icon.tsx
new file mode 100644
index 0000000..fb95a62
--- /dev/null
+++ b/components/tiptap-icons/superscript-icon.tsx
@@ -0,0 +1,38 @@
+import * as React from "react"
+
+export const SuperscriptIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+
+
+ )
+ }
+)
+
+SuperscriptIcon.displayName = "SuperscriptIcon"
diff --git a/components/tiptap-icons/trash-icon.tsx b/components/tiptap-icons/trash-icon.tsx
new file mode 100644
index 0000000..d928994
--- /dev/null
+++ b/components/tiptap-icons/trash-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const TrashIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+TrashIcon.displayName = "TrashIcon"
diff --git a/components/tiptap-icons/underline-icon.tsx b/components/tiptap-icons/underline-icon.tsx
new file mode 100644
index 0000000..8563129
--- /dev/null
+++ b/components/tiptap-icons/underline-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const UnderlineIcon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+UnderlineIcon.displayName = "UnderlineIcon"
diff --git a/components/tiptap-icons/undo2-icon.tsx b/components/tiptap-icons/undo2-icon.tsx
new file mode 100644
index 0000000..bef8d3d
--- /dev/null
+++ b/components/tiptap-icons/undo2-icon.tsx
@@ -0,0 +1,26 @@
+import * as React from "react"
+
+export const Undo2Icon = React.memo(
+ ({ className, ...props }: React.SVGProps) => {
+ return (
+
+
+
+ )
+ }
+)
+
+Undo2Icon.displayName = "Undo2Icon"
diff --git a/components/tiptap-node/blockquote-node/blockquote-node.scss b/components/tiptap-node/blockquote-node/blockquote-node.scss
new file mode 100644
index 0000000..b49c5e1
--- /dev/null
+++ b/components/tiptap-node/blockquote-node/blockquote-node.scss
@@ -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;
+ }
+ }
+}
diff --git a/components/tiptap-node/code-block-node/code-block-node.scss b/components/tiptap-node/code-block-node/code-block-node.scss
new file mode 100644
index 0000000..d31b312
--- /dev/null
+++ b/components/tiptap-node/code-block-node/code-block-node.scss
@@ -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;
+ }
+ }
+}
diff --git a/components/tiptap-node/heading-node/heading-node.scss b/components/tiptap-node/heading-node/heading-node.scss
new file mode 100644
index 0000000..9bb9234
--- /dev/null
+++ b/components/tiptap-node/heading-node/heading-node.scss
@@ -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;
+ }
+}
diff --git a/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts b/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts
new file mode 100644
index 0000000..de28208
--- /dev/null
+++ b/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension.ts
@@ -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
diff --git a/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss b/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss
new file mode 100644
index 0000000..4626e65
--- /dev/null
+++ b/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss
@@ -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;
+ }
+}
diff --git a/components/tiptap-node/image-node/image-node.scss b/components/tiptap-node/image-node/image-node.scss
new file mode 100644
index 0000000..10d4231
--- /dev/null
+++ b/components/tiptap-node/image-node/image-node.scss
@@ -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;
+ }
+}
diff --git a/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/components/tiptap-node/image-upload-node/image-upload-node-extension.ts
new file mode 100644
index 0000000..d1a18f5
--- /dev/null
+++ b/components/tiptap-node/image-upload-node/image-upload-node-extension.ts
@@ -0,0 +1,145 @@
+import { mergeAttributes, Node } from "@tiptap/react"
+import { ReactNodeViewRenderer } from "@tiptap/react"
+import { ImageUploadNode as ImageUploadNodeComponent } from "@/components/tiptap-node/image-upload-node/image-upload-node"
+
+export type UploadFunction = (
+ file: File,
+ onProgress?: (event: { progress: number }) => void,
+ abortSignal?: AbortSignal
+) => Promise
+
+export interface ImageUploadNodeOptions {
+ /**
+ * 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
+}
+
+declare module "@tiptap/react" {
+ interface Commands {
+ 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({
+ name: "imageUpload",
+
+ group: "block",
+
+ draggable: true,
+
+ atom: true,
+
+ addOptions() {
+ return {
+ accept: "image/*",
+ limit: 1,
+ maxSize: 0,
+ upload: undefined,
+ onError: undefined,
+ onSuccess: undefined,
+ }
+ },
+
+ 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
diff --git a/components/tiptap-node/image-upload-node/image-upload-node.scss b/components/tiptap-node/image-upload-node/image-upload-node.scss
new file mode 100644
index 0000000..b85e1e3
--- /dev/null
+++ b/components/tiptap-node/image-upload-node/image-upload-node.scss
@@ -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;
+ }
+ }
+}
diff --git a/components/tiptap-node/image-upload-node/image-upload-node.tsx b/components/tiptap-node/image-upload-node/image-upload-node.tsx
new file mode 100644
index 0000000..79bac39
--- /dev/null
+++ b/components/tiptap-node/image-upload-node/image-upload-node.tsx
@@ -0,0 +1,547 @@
+"use client"
+
+import * as React from "react"
+import type { NodeViewProps } from "@tiptap/react"
+import { NodeViewWrapper } from "@tiptap/react"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { CloseIcon } from "@/components/tiptap-icons/close-icon"
+import "@/components/tiptap-node/image-upload-node/image-upload-node.scss"
+import { 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} Promise resolving to the URL of the uploaded file
+ */
+ upload: (
+ file: File,
+ onProgress: (event: { progress: number }) => void,
+ signal: AbortSignal
+ ) => Promise
+ /**
+ * 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([])
+
+ const uploadFile = async (file: File): Promise => {
+ 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 => {
+ 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 = () => (
+
+
+
+
+)
+
+const FileIcon: React.FC = () => (
+
+
+
+)
+
+const FileCornerIcon: React.FC = () => (
+
+
+
+)
+
+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 = ({
+ 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 (
+
+ {children}
+
+ )
+}
+
+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 = ({
+ 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 (
+
+ {fileItem.status === "uploading" && (
+
+ )}
+
+
+
+
+
+
+
+
+ {fileItem.file.name}
+
+
+ {formatFileSize(fileItem.file.size)}
+
+
+
+
+ {fileItem.status === "uploading" && (
+
+ {fileItem.progress}%
+
+ )}
+ {
+ e.stopPropagation()
+ onRemove()
+ }}
+ >
+
+
+
+
+
+ )
+}
+
+const DropZoneContent: React.FC<{ maxSize: number; limit: number }> = ({
+ maxSize,
+ limit,
+}) => (
+ <>
+
+
+
+
+ Click to upload or drag and drop
+
+
+ Maximum {limit} file{limit === 1 ? "" : "s"}, {maxSize / 1024 / 1024}MB
+ each.
+
+
+ >
+)
+
+export const ImageUploadNode: React.FC = (props) => {
+ const { accept, limit, maxSize } = props.node.attrs
+ const inputRef = React.useRef(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: "image",
+ attrs: { src: url, alt: filename, title: filename },
+ }
+ })
+
+ props.editor
+ .chain()
+ .focus()
+ .deleteRange({ from: pos, to: pos + 1 })
+ .insertContentAt(pos, imageNodes)
+ .run()
+ }
+ }
+ }
+
+ const handleChange = (e: React.ChangeEvent) => {
+ 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 (
+
+ {!hasFiles && (
+
+
+
+ )}
+
+ {hasFiles && (
+
+ {fileItems.length > 1 && (
+
+ Uploading {fileItems.length} files
+ {
+ e.stopPropagation()
+ clearAllFiles()
+ }}
+ >
+ Clear All
+
+
+ )}
+ {fileItems.map((fileItem) => (
+
removeFileItem(fileItem.id)}
+ />
+ ))}
+
+ )}
+
+ 1}
+ onChange={handleChange}
+ onClick={(e: React.MouseEvent) => e.stopPropagation()}
+ />
+
+ )
+}
diff --git a/components/tiptap-node/image-upload-node/index.tsx b/components/tiptap-node/image-upload-node/index.tsx
new file mode 100644
index 0000000..2510a62
--- /dev/null
+++ b/components/tiptap-node/image-upload-node/index.tsx
@@ -0,0 +1 @@
+export * from "./image-upload-node-extension"
diff --git a/components/tiptap-node/list-node/list-node.scss b/components/tiptap-node/list-node/list-node.scss
new file mode 100644
index 0000000..d0fe5c8
--- /dev/null
+++ b/components/tiptap-node/list-node/list-node.scss
@@ -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;
+ }
+ }
+ }
+}
diff --git a/components/tiptap-node/paragraph-node/paragraph-node.scss b/components/tiptap-node/paragraph-node/paragraph-node.scss
new file mode 100644
index 0000000..cc2ec59
--- /dev/null
+++ b/components/tiptap-node/paragraph-node/paragraph-node.scss
@@ -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;
+}
diff --git a/components/tiptap-templates/simple/data/content.json b/components/tiptap-templates/simple/data/content.json
new file mode 100644
index 0000000..4a3c0e8
--- /dev/null
+++ b/components/tiptap-templates/simple/data/content.json
@@ -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"
+ }
+ }
+ ]
+}
diff --git a/components/tiptap-templates/simple/simple-editor.scss b/components/tiptap-templates/simple/simple-editor.scss
new file mode 100644
index 0000000..086b461
--- /dev/null
+++ b/components/tiptap-templates/simple/simple-editor.scss
@@ -0,0 +1,82 @@
+@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;
+}
+
+@media screen and (max-width: 480px) {
+ .simple-editor-content .tiptap.ProseMirror.simple-editor {
+ padding: 1rem 1.5rem 30vh;
+ }
+}
\ No newline at end of file
diff --git a/components/tiptap-templates/simple/simple-editor.tsx b/components/tiptap-templates/simple/simple-editor.tsx
new file mode 100644
index 0000000..276614a
--- /dev/null
+++ b/components/tiptap-templates/simple/simple-editor.tsx
@@ -0,0 +1,281 @@
+"use client"
+
+import * as React from "react"
+import { EditorContent, EditorContext, useEditor } from "@tiptap/react"
+
+// --- Tiptap Core Extensions ---
+import { StarterKit } from "@tiptap/starter-kit"
+import { Image } from "@tiptap/extension-image"
+import { TaskItem, TaskList } from "@tiptap/extension-list"
+import { TextAlign } from "@tiptap/extension-text-align"
+import { Typography } from "@tiptap/extension-typography"
+import { Highlight } from "@tiptap/extension-highlight"
+import { Subscript } from "@tiptap/extension-subscript"
+import { Superscript } from "@tiptap/extension-superscript"
+import { Selection } from "@tiptap/extensions"
+
+// --- UI Primitives ---
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Spacer } from "@/components/tiptap-ui-primitive/spacer"
+import {
+ Toolbar,
+ ToolbarGroup,
+ ToolbarSeparator,
+} from "@/components/tiptap-ui-primitive/toolbar"
+
+// --- Tiptap Node ---
+import { ImageUploadNode } from "@/components/tiptap-node/image-upload-node/image-upload-node-extension"
+import { HorizontalRule } from "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node-extension"
+import "@/components/tiptap-node/blockquote-node/blockquote-node.scss"
+import "@/components/tiptap-node/code-block-node/code-block-node.scss"
+import "@/components/tiptap-node/horizontal-rule-node/horizontal-rule-node.scss"
+import "@/components/tiptap-node/list-node/list-node.scss"
+import "@/components/tiptap-node/image-node/image-node.scss"
+import "@/components/tiptap-node/heading-node/heading-node.scss"
+import "@/components/tiptap-node/paragraph-node/paragraph-node.scss"
+
+// --- Tiptap UI ---
+import { HeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
+import { ImageUploadButton } from "@/components/tiptap-ui/image-upload-button"
+import { ListDropdownMenu } from "@/components/tiptap-ui/list-dropdown-menu"
+import { BlockquoteButton } from "@/components/tiptap-ui/blockquote-button"
+import { CodeBlockButton } from "@/components/tiptap-ui/code-block-button"
+import {
+ ColorHighlightPopover,
+ ColorHighlightPopoverContent,
+ ColorHighlightPopoverButton,
+} from "@/components/tiptap-ui/color-highlight-popover"
+import {
+ LinkPopover,
+ LinkContent,
+ LinkButton,
+} from "@/components/tiptap-ui/link-popover"
+import { MarkButton } from "@/components/tiptap-ui/mark-button"
+import { TextAlignButton } from "@/components/tiptap-ui/text-align-button"
+import { UndoRedoButton } from "@/components/tiptap-ui/undo-redo-button"
+
+// --- Icons ---
+import { ArrowLeftIcon } from "@/components/tiptap-icons/arrow-left-icon"
+import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
+import { LinkIcon } from "@/components/tiptap-icons/link-icon"
+
+// --- Hooks ---
+import { useIsMobile } from "@/hooks/use-mobile"
+import { useWindowSize } from "@/hooks/use-window-size"
+import { useCursorVisibility } from "@/hooks/use-cursor-visibility"
+
+// --- Components ---
+import { ThemeToggle } from "@/components/tiptap-templates/simple/theme-toggle"
+
+// --- Lib ---
+import { handleImageUpload, MAX_FILE_SIZE } from "@/lib/tiptap-utils"
+
+// --- Styles ---
+import "@/components/tiptap-templates/simple/simple-editor.scss"
+
+import content from "@/components/tiptap-templates/simple/data/content.json"
+
+const MainToolbarContent = ({
+ onHighlighterClick,
+ onLinkClick,
+ isMobile,
+}: {
+ onHighlighterClick: () => void
+ onLinkClick: () => void
+ isMobile: boolean
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isMobile ? (
+
+ ) : (
+
+ )}
+ {!isMobile ? : }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isMobile && }
+
+
+
+
+ >
+ )
+}
+
+const MobileToolbarContent = ({
+ type,
+ onBack,
+}: {
+ type: "highlighter" | "link"
+ onBack: () => void
+}) => (
+ <>
+
+
+
+ {type === "highlighter" ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+ {type === "highlighter" ? (
+
+ ) : (
+
+ )}
+ >
+)
+
+export function SimpleEditor() {
+ const isMobile = useIsMobile()
+ const { height } = useWindowSize()
+ const [mobileView, setMobileView] = React.useState<
+ "main" | "highlighter" | "link"
+ >("main")
+ const toolbarRef = React.useRef(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,
+ })
+
+ const rect = useCursorVisibility({
+ editor,
+ overlayHeight: toolbarRef.current?.getBoundingClientRect().height ?? 0,
+ })
+
+ React.useEffect(() => {
+ if (!isMobile && mobileView !== "main") {
+ setMobileView("main")
+ }
+ }, [isMobile, mobileView])
+
+ return (
+
+
+
+ {mobileView === "main" ? (
+ setMobileView("highlighter")}
+ onLinkClick={() => setMobileView("link")}
+ isMobile={isMobile}
+ />
+ ) : (
+ setMobileView("main")}
+ />
+ )}
+
+
+
+
+
+ )
+}
diff --git a/components/tiptap-templates/simple/theme-toggle.tsx b/components/tiptap-templates/simple/theme-toggle.tsx
new file mode 100644
index 0000000..4164aa9
--- /dev/null
+++ b/components/tiptap-templates/simple/theme-toggle.tsx
@@ -0,0 +1,48 @@
+"use client"
+
+import * as React from "react"
+
+// --- UI Primitives ---
+import { Button } from "@/components/tiptap-ui-primitive/button"
+
+// --- Icons ---
+import { MoonStarIcon } from "@/components/tiptap-icons/moon-star-icon"
+import { SunIcon } from "@/components/tiptap-icons/sun-icon"
+
+export function ThemeToggle() {
+ const [isDarkMode, setIsDarkMode] = React.useState(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 (
+
+ {isDarkMode ? (
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/components/tiptap-ui-primitive/badge/badge-colors.scss b/components/tiptap-ui-primitive/badge/badge-colors.scss
new file mode 100644
index 0000000..8f8a988
--- /dev/null
+++ b/components/tiptap-ui-primitive/badge/badge-colors.scss
@@ -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
+ }
+ }
+}
diff --git a/components/tiptap-ui-primitive/badge/badge-group.scss b/components/tiptap-ui-primitive/badge/badge-group.scss
new file mode 100644
index 0000000..91bd45b
--- /dev/null
+++ b/components/tiptap-ui-primitive/badge/badge-group.scss
@@ -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;
+ }
+}
diff --git a/components/tiptap-ui-primitive/badge/badge.scss b/components/tiptap-ui-primitive/badge/badge.scss
new file mode 100644
index 0000000..b2ca9a8
--- /dev/null
+++ b/components/tiptap-ui-primitive/badge/badge.scss
@@ -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);
+ }
+ }
+}
diff --git a/components/tiptap-ui-primitive/badge/badge.tsx b/components/tiptap-ui-primitive/badge/badge.tsx
new file mode 100644
index 0000000..2afa5ea
--- /dev/null
+++ b/components/tiptap-ui-primitive/badge/badge.tsx
@@ -0,0 +1,46 @@
+"use client"
+
+import * as React from "react"
+import "@/components/tiptap-ui-primitive/badge/badge-colors.scss"
+import "@/components/tiptap-ui-primitive/badge/badge-group.scss"
+import "@/components/tiptap-ui-primitive/badge/badge.scss"
+
+export interface BadgeProps extends React.HTMLAttributes {
+ variant?: "ghost" | "white" | "gray" | "green" | "default"
+ size?: "default" | "small"
+ appearance?: "default" | "subdued" | "emphasized"
+ trimText?: boolean
+}
+
+export const Badge = React.forwardRef(
+ (
+ {
+ variant,
+ size = "default",
+ appearance = "default",
+ trimText = false,
+ className,
+ children,
+ ...props
+ },
+ ref
+ ) => {
+ return (
+
+ {children}
+
+ )
+ }
+)
+
+Badge.displayName = "Badge"
+
+export default Badge
diff --git a/components/tiptap-ui-primitive/badge/index.tsx b/components/tiptap-ui-primitive/badge/index.tsx
new file mode 100644
index 0000000..051fa6e
--- /dev/null
+++ b/components/tiptap-ui-primitive/badge/index.tsx
@@ -0,0 +1 @@
+export * from "./badge"
diff --git a/components/tiptap-ui-primitive/button/button-colors.scss b/components/tiptap-ui-primitive/button/button-colors.scss
new file mode 100644
index 0000000..fc0dd35
--- /dev/null
+++ b/components/tiptap-ui-primitive/button/button-colors.scss
@@ -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);
+ }
+ }
+}
diff --git a/components/tiptap-ui-primitive/button/button-group.scss b/components/tiptap-ui-primitive/button/button-group.scss
new file mode 100644
index 0000000..59fd256
--- /dev/null
+++ b/components/tiptap-ui-primitive/button/button-group.scss
@@ -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;
+ }
+}
diff --git a/components/tiptap-ui-primitive/button/button.scss b/components/tiptap-ui-primitive/button/button.scss
new file mode 100644
index 0000000..32d1499
--- /dev/null
+++ b/components/tiptap-ui-primitive/button/button.scss
@@ -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);
+ }
+ }
+}
diff --git a/components/tiptap-ui-primitive/button/button.tsx b/components/tiptap-ui-primitive/button/button.tsx
new file mode 100644
index 0000000..a3e2d9c
--- /dev/null
+++ b/components/tiptap-ui-primitive/button/button.tsx
@@ -0,0 +1,116 @@
+"use client"
+
+import * as React from "react"
+
+// --- Tiptap UI Primitive ---
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/tiptap-ui-primitive/tooltip"
+
+// --- Lib ---
+import { cn, parseShortcutKeys } from "@/lib/tiptap-utils"
+
+import "@/components/tiptap-ui-primitive/button/button-colors.scss"
+import "@/components/tiptap-ui-primitive/button/button-group.scss"
+import "@/components/tiptap-ui-primitive/button/button.scss"
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes {
+ className?: string
+ showTooltip?: boolean
+ tooltip?: React.ReactNode
+ shortcutKeys?: string
+}
+
+export const ShortcutDisplay: React.FC<{ shortcuts: string[] }> = ({
+ shortcuts,
+}) => {
+ if (shortcuts.length === 0) return null
+
+ return (
+
+ {shortcuts.map((key, index) => (
+
+ {index > 0 && + }
+ {key}
+
+ ))}
+
+ )
+}
+
+export const Button = React.forwardRef(
+ (
+ {
+ className,
+ children,
+ tooltip,
+ showTooltip = true,
+ shortcutKeys,
+ "aria-label": ariaLabel,
+ ...props
+ },
+ ref
+ ) => {
+ const shortcuts = React.useMemo(
+ () => parseShortcutKeys({ shortcutKeys }),
+ [shortcutKeys]
+ )
+
+ if (!tooltip || !showTooltip) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+
+ {children}
+
+
+ {tooltip}
+
+
+
+ )
+ }
+)
+
+Button.displayName = "Button"
+
+export const ButtonGroup = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ orientation?: "horizontal" | "vertical"
+ }
+>(({ className, children, orientation = "vertical", ...props }, ref) => {
+ return (
+
+ {children}
+
+ )
+})
+ButtonGroup.displayName = "ButtonGroup"
+
+export default Button
diff --git a/components/tiptap-ui-primitive/button/index.tsx b/components/tiptap-ui-primitive/button/index.tsx
new file mode 100644
index 0000000..e93d26f
--- /dev/null
+++ b/components/tiptap-ui-primitive/button/index.tsx
@@ -0,0 +1 @@
+export * from "./button"
diff --git a/components/tiptap-ui-primitive/card/card.scss b/components/tiptap-ui-primitive/card/card.scss
new file mode 100644
index 0000000..97b757e
--- /dev/null
+++ b/components/tiptap-ui-primitive/card/card.scss
@@ -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);
+}
diff --git a/components/tiptap-ui-primitive/card/card.tsx b/components/tiptap-ui-primitive/card/card.tsx
new file mode 100644
index 0000000..3fec720
--- /dev/null
+++ b/components/tiptap-ui-primitive/card/card.tsx
@@ -0,0 +1,74 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/tiptap-utils"
+import "@/components/tiptap-ui-primitive/card/card.scss"
+
+const Card = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return
+ }
+)
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+CardHeader.displayName = "CardHeader"
+
+const CardBody = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+CardBody.displayName = "CardBody"
+
+const CardItemGroup = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ orientation?: "horizontal" | "vertical"
+ }
+>(({ className, orientation = "vertical", ...props }, ref) => {
+ return (
+
+ )
+})
+CardItemGroup.displayName = "CardItemGroup"
+
+const CardGroupLabel = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+CardGroupLabel.displayName = "CardGroupLabel"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div">
+>(({ className, ...props }, ref) => {
+ return (
+
+ )
+})
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardBody, CardItemGroup, CardGroupLabel }
diff --git a/components/tiptap-ui-primitive/card/index.tsx b/components/tiptap-ui-primitive/card/index.tsx
new file mode 100644
index 0000000..288c75f
--- /dev/null
+++ b/components/tiptap-ui-primitive/card/index.tsx
@@ -0,0 +1 @@
+export * from "./card"
diff --git a/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss b/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss
new file mode 100644
index 0000000..03b47e8
--- /dev/null
+++ b/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss
@@ -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);
+ }
+}
diff --git a/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx b/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx
new file mode 100644
index 0000000..2ad44f6
--- /dev/null
+++ b/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.tsx
@@ -0,0 +1,98 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { cn } from "@/lib/tiptap-utils"
+import "@/components/tiptap-ui-primitive/dropdown-menu/dropdown-menu.scss"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+const DropdownMenuTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ ...props }, ref) => )
+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,
+ React.ComponentPropsWithoutRef & {
+ portal?: boolean | React.ComponentProps
+ }
+>(({ className, portal = true, ...props }, ref) => {
+ const content = (
+
+ )
+
+ return portal ? (
+
+ {content}
+
+ ) : (
+ content
+ )
+})
+DropdownMenuSubContent.displayName =
+ DropdownMenuPrimitive.SubContent.displayName
+
+const DropdownMenuContent = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef & {
+ portal?: boolean
+ }
+>(({ className, sideOffset = 4, portal = false, ...props }, ref) => {
+ const content = (
+ e.preventDefault()}
+ className={cn("tiptap-dropdown-menu", className)}
+ {...props}
+ />
+ )
+
+ return portal ? (
+
+ {content}
+
+ ) : (
+ content
+ )
+})
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+
+export {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuGroup,
+ DropdownMenuSub,
+ DropdownMenuPortal,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuRadioGroup,
+}
diff --git a/components/tiptap-ui-primitive/dropdown-menu/index.tsx b/components/tiptap-ui-primitive/dropdown-menu/index.tsx
new file mode 100644
index 0000000..c4adece
--- /dev/null
+++ b/components/tiptap-ui-primitive/dropdown-menu/index.tsx
@@ -0,0 +1 @@
+export * from "./dropdown-menu"
diff --git a/components/tiptap-ui-primitive/input/index.tsx b/components/tiptap-ui-primitive/input/index.tsx
new file mode 100644
index 0000000..be91c8e
--- /dev/null
+++ b/components/tiptap-ui-primitive/input/index.tsx
@@ -0,0 +1 @@
+export * from "./input"
diff --git a/components/tiptap-ui-primitive/input/input.scss b/components/tiptap-ui-primitive/input/input.scss
new file mode 100644
index 0000000..b9f777c
--- /dev/null
+++ b/components/tiptap-ui-primitive/input/input.scss
@@ -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;
+}
diff --git a/components/tiptap-ui-primitive/input/input.tsx b/components/tiptap-ui-primitive/input/input.tsx
new file mode 100644
index 0000000..3e676e0
--- /dev/null
+++ b/components/tiptap-ui-primitive/input/input.tsx
@@ -0,0 +1,25 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/tiptap-utils"
+import "@/components/tiptap-ui-primitive/input/input.scss"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+function InputGroup({
+ className,
+ children,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ )
+}
+
+export { Input, InputGroup }
diff --git a/components/tiptap-ui-primitive/popover/index.tsx b/components/tiptap-ui-primitive/popover/index.tsx
new file mode 100644
index 0000000..137ef5d
--- /dev/null
+++ b/components/tiptap-ui-primitive/popover/index.tsx
@@ -0,0 +1 @@
+export * from "./popover"
diff --git a/components/tiptap-ui-primitive/popover/popover.scss b/components/tiptap-ui-primitive/popover/popover.scss
new file mode 100644
index 0000000..07fb0e5
--- /dev/null
+++ b/components/tiptap-ui-primitive/popover/popover.scss
@@ -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);
+ }
+}
diff --git a/components/tiptap-ui-primitive/popover/popover.tsx b/components/tiptap-ui-primitive/popover/popover.tsx
new file mode 100644
index 0000000..7c5aeed
--- /dev/null
+++ b/components/tiptap-ui-primitive/popover/popover.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import * as React from "react"
+import * as PopoverPrimitive from "@radix-ui/react-popover"
+import { cn } from "@/lib/tiptap-utils"
+import "@/components/tiptap-ui-primitive/popover/popover.scss"
+
+function Popover({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function PopoverContent({
+ className,
+ align = "center",
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { Popover, PopoverTrigger, PopoverContent }
diff --git a/components/tiptap-ui-primitive/separator/index.tsx b/components/tiptap-ui-primitive/separator/index.tsx
new file mode 100644
index 0000000..068cfa8
--- /dev/null
+++ b/components/tiptap-ui-primitive/separator/index.tsx
@@ -0,0 +1 @@
+export * from "./separator"
diff --git a/components/tiptap-ui-primitive/separator/separator.scss b/components/tiptap-ui-primitive/separator/separator.scss
new file mode 100644
index 0000000..78ec9ac
--- /dev/null
+++ b/components/tiptap-ui-primitive/separator/separator.scss
@@ -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;
+ }
+}
diff --git a/components/tiptap-ui-primitive/separator/separator.tsx b/components/tiptap-ui-primitive/separator/separator.tsx
new file mode 100644
index 0000000..ac90765
--- /dev/null
+++ b/components/tiptap-ui-primitive/separator/separator.tsx
@@ -0,0 +1,33 @@
+"use client"
+
+import * as React from "react"
+import "@/components/tiptap-ui-primitive/separator/separator.scss"
+import { cn } from "@/lib/tiptap-utils"
+
+export type Orientation = "horizontal" | "vertical"
+
+export interface SeparatorProps extends React.HTMLAttributes {
+ orientation?: Orientation
+ decorative?: boolean
+}
+
+export const Separator = React.forwardRef(
+ ({ decorative, orientation = "vertical", className, ...divProps }, ref) => {
+ const ariaOrientation = orientation === "vertical" ? orientation : undefined
+ const semanticProps = decorative
+ ? { role: "none" }
+ : { "aria-orientation": ariaOrientation, role: "separator" }
+
+ return (
+
+ )
+ }
+)
+
+Separator.displayName = "Separator"
diff --git a/components/tiptap-ui-primitive/spacer/index.tsx b/components/tiptap-ui-primitive/spacer/index.tsx
new file mode 100644
index 0000000..b0789bf
--- /dev/null
+++ b/components/tiptap-ui-primitive/spacer/index.tsx
@@ -0,0 +1 @@
+export * from "./spacer"
diff --git a/components/tiptap-ui-primitive/spacer/spacer.tsx b/components/tiptap-ui-primitive/spacer/spacer.tsx
new file mode 100644
index 0000000..8de9600
--- /dev/null
+++ b/components/tiptap-ui-primitive/spacer/spacer.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+
+export type SpacerOrientation = "horizontal" | "vertical"
+
+export interface SpacerProps extends React.HTMLAttributes {
+ 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
+}
diff --git a/components/tiptap-ui-primitive/toolbar/index.tsx b/components/tiptap-ui-primitive/toolbar/index.tsx
new file mode 100644
index 0000000..94b1819
--- /dev/null
+++ b/components/tiptap-ui-primitive/toolbar/index.tsx
@@ -0,0 +1 @@
+export * from "./toolbar"
diff --git a/components/tiptap-ui-primitive/toolbar/toolbar.scss b/components/tiptap-ui-primitive/toolbar/toolbar.scss
new file mode 100644
index 0000000..3ce1862
--- /dev/null
+++ b/components/tiptap-ui-primitive/toolbar/toolbar.scss
@@ -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;
+ }
+ }
+}
diff --git a/components/tiptap-ui-primitive/toolbar/toolbar.tsx b/components/tiptap-ui-primitive/toolbar/toolbar.tsx
new file mode 100644
index 0000000..433dec9
--- /dev/null
+++ b/components/tiptap-ui-primitive/toolbar/toolbar.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import * as React from "react"
+import { Separator } from "@/components/tiptap-ui-primitive/separator"
+import "@/components/tiptap-ui-primitive/toolbar/toolbar.scss"
+import { cn } from "@/lib/tiptap-utils"
+import { useMenuNavigation } from "@/hooks/use-menu-navigation"
+import { useComposedRef } from "@/hooks/use-composed-ref"
+
+type BaseProps = React.HTMLAttributes
+
+interface ToolbarProps extends BaseProps {
+ variant?: "floating" | "fixed"
+}
+
+const useToolbarNavigation = (
+ toolbarRef: React.RefObject
+) => {
+ const [items, setItems] = React.useState([])
+
+ const collectItems = React.useCallback(() => {
+ if (!toolbarRef.current) return []
+ return Array.from(
+ toolbarRef.current.querySelectorAll(
+ '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({
+ 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(
+ ({ children, className, variant = "fixed", ...props }, ref) => {
+ const toolbarRef = React.useRef(null)
+ const composedRef = useComposedRef(toolbarRef, ref)
+ useToolbarNavigation(toolbarRef)
+
+ return (
+
+ {children}
+
+ )
+ }
+)
+Toolbar.displayName = "Toolbar"
+
+export const ToolbarGroup = React.forwardRef(
+ ({ children, className, ...props }, ref) => (
+
+ {children}
+
+ )
+)
+ToolbarGroup.displayName = "ToolbarGroup"
+
+export const ToolbarSeparator = React.forwardRef(
+ ({ ...props }, ref) => (
+
+ )
+)
+ToolbarSeparator.displayName = "ToolbarSeparator"
diff --git a/components/tiptap-ui-primitive/tooltip/index.tsx b/components/tiptap-ui-primitive/tooltip/index.tsx
new file mode 100644
index 0000000..e12712a
--- /dev/null
+++ b/components/tiptap-ui-primitive/tooltip/index.tsx
@@ -0,0 +1 @@
+export * from "./tooltip"
diff --git a/components/tiptap-ui-primitive/tooltip/tooltip.scss b/components/tiptap-ui-primitive/tooltip/tooltip.scss
new file mode 100644
index 0000000..d717757
--- /dev/null
+++ b/components/tiptap-ui-primitive/tooltip/tooltip.scss
@@ -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);
+ }
+}
diff --git a/components/tiptap-ui-primitive/tooltip/tooltip.tsx b/components/tiptap-ui-primitive/tooltip/tooltip.tsx
new file mode 100644
index 0000000..8af0c04
--- /dev/null
+++ b/components/tiptap-ui-primitive/tooltip/tooltip.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import {
+ useFloating,
+ autoUpdate,
+ offset,
+ flip,
+ shift,
+ useHover,
+ useFocus,
+ useDismiss,
+ useRole,
+ useInteractions,
+ useMergeRefs,
+ FloatingPortal,
+ type Placement,
+ type UseFloatingReturn,
+ type ReferenceType,
+ FloatingDelayGroup,
+} from "@floating-ui/react"
+import "@/components/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, "ref"> {
+ asChild?: boolean
+ children: React.ReactNode
+}
+
+interface TooltipContentProps
+ extends Omit, "ref"> {
+ children?: React.ReactNode
+ portal?: boolean
+ portalProps?: Omit, "children">
+}
+
+interface TooltipContextValue extends UseFloatingReturn {
+ open: boolean
+ setOpen: (open: boolean) => void
+ getReferenceProps: (
+ userProps?: React.HTMLProps
+ ) => Record
+ getFloatingProps: (
+ userProps?: React.HTMLProps
+ ) => Record
+}
+
+function useTooltip({
+ initialOpen = false,
+ placement = "top",
+ open: controlledOpen,
+ onOpenChange: setControlledOpen,
+ delay = 600,
+ closeDelay = 0,
+}: Omit = {}) {
+ const [uncontrolledOpen, setUncontrolledOpen] =
+ React.useState(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(null)
+
+function useTooltipContext() {
+ const context = React.useContext(TooltipContext)
+
+ if (context == null) {
+ throw new Error("Tooltip components must be wrapped in ")
+ }
+
+ return context
+}
+
+export function Tooltip({ children, ...props }: TooltipProviderProps) {
+ const tooltip = useTooltip(props)
+
+ if (!props.useDelayGroup) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+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 } }).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 (
+
+ {children}
+
+ )
+})
+
+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 = (
+
+ {children}
+
+ )
+
+ if (portal) {
+ return {content}
+ }
+
+ return content
+})
+
+Tooltip.displayName = "Tooltip"
+TooltipTrigger.displayName = "TooltipTrigger"
+TooltipContent.displayName = "TooltipContent"
diff --git a/components/tiptap-ui/blockquote-button/blockquote-button.tsx b/components/tiptap-ui/blockquote-button/blockquote-button.tsx
new file mode 100644
index 0000000..631fbbf
--- /dev/null
+++ b/components/tiptap-ui/blockquote-button/blockquote-button.tsx
@@ -0,0 +1,125 @@
+"use client"
+
+import * as React from "react"
+
+// --- Tiptap UI ---
+import type { UseBlockquoteConfig } from "@/components/tiptap-ui/blockquote-button"
+import {
+ BLOCKQUOTE_SHORTCUT_KEY,
+ useBlockquote,
+} from "@/components/tiptap-ui/blockquote-button"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+export interface BlockquoteButtonProps
+ extends Omit,
+ 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 {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * 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) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+BlockquoteButton.displayName = "BlockquoteButton"
diff --git a/components/tiptap-ui/blockquote-button/index.tsx b/components/tiptap-ui/blockquote-button/index.tsx
new file mode 100644
index 0000000..0b46edf
--- /dev/null
+++ b/components/tiptap-ui/blockquote-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./blockquote-button"
+export * from "./use-blockquote"
diff --git a/components/tiptap-ui/blockquote-button/use-blockquote.ts b/components/tiptap-ui/blockquote-button/use-blockquote.ts
new file mode 100644
index 0000000..1e81b84
--- /dev/null
+++ b/components/tiptap-ui/blockquote-button/use-blockquote.ts
@@ -0,0 +1,256 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+import { useHotkeys } from "react-hotkeys-hook"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Icons ---
+import { BlockquoteIcon } from "@/components/tiptap-icons/blockquote-icon"
+
+// --- UI Utils ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+} from "@/lib/tiptap-utils"
+
+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 Blockquote
+ * }
+ *
+ * // 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 (
+ *
+ * Toggle Blockquote
+ *
+ * )
+ * }
+ * ```
+ */
+export function useBlockquote(config?: UseBlockquoteConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(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])
+
+ useHotkeys(
+ BLOCKQUOTE_SHORTCUT_KEY,
+ (event) => {
+ event.preventDefault()
+ handleToggle()
+ },
+ {
+ enabled: isVisible && canToggle,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle,
+ label: "Blockquote",
+ shortcutKeys: BLOCKQUOTE_SHORTCUT_KEY,
+ Icon: BlockquoteIcon,
+ }
+}
diff --git a/components/tiptap-ui/code-block-button/code-block-button.tsx b/components/tiptap-ui/code-block-button/code-block-button.tsx
new file mode 100644
index 0000000..a3bb04a
--- /dev/null
+++ b/components/tiptap-ui/code-block-button/code-block-button.tsx
@@ -0,0 +1,125 @@
+"use client"
+
+import * as React from "react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Tiptap UI ---
+import type { UseCodeBlockConfig } from "@/components/tiptap-ui/code-block-button"
+import {
+ CODE_BLOCK_SHORTCUT_KEY,
+ useCodeBlock,
+} from "@/components/tiptap-ui/code-block-button"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+export interface CodeBlockButtonProps
+ extends Omit,
+ 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 {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * 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) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+CodeBlockButton.displayName = "CodeBlockButton"
diff --git a/components/tiptap-ui/code-block-button/index.tsx b/components/tiptap-ui/code-block-button/index.tsx
new file mode 100644
index 0000000..77d541f
--- /dev/null
+++ b/components/tiptap-ui/code-block-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./code-block-button"
+export * from "./use-code-block"
diff --git a/components/tiptap-ui/code-block-button/use-code-block.ts b/components/tiptap-ui/code-block-button/use-code-block.ts
new file mode 100644
index 0000000..8d4fa2f
--- /dev/null
+++ b/components/tiptap-ui/code-block-button/use-code-block.ts
@@ -0,0 +1,263 @@
+"use client"
+
+import * as React from "react"
+import { type Editor } from "@tiptap/react"
+import { useHotkeys } from "react-hotkeys-hook"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Lib ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+} from "@/lib/tiptap-utils"
+
+// --- Icons ---
+import { CodeBlockIcon } from "@/components/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 (
+ *
+ * Code Block
+ *
+ * )
+ * }
+ *
+ * // 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 (
+ *
+ * Toggle Code Block
+ *
+ * )
+ * }
+ * ```
+ */
+export function useCodeBlock(config?: UseCodeBlockConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(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])
+
+ useHotkeys(
+ CODE_BLOCK_SHORTCUT_KEY,
+ (event) => {
+ event.preventDefault()
+ handleToggle()
+ },
+ {
+ enabled: isVisible && canToggleState,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle: canToggleState,
+ label: "Code Block",
+ shortcutKeys: CODE_BLOCK_SHORTCUT_KEY,
+ Icon: CodeBlockIcon,
+ }
+}
diff --git a/components/tiptap-ui/color-highlight-button/color-highlight-button.scss b/components/tiptap-ui/color-highlight-button/color-highlight-button.scss
new file mode 100644
index 0000000..2c6f387
--- /dev/null
+++ b/components/tiptap-ui/color-highlight-button/color-highlight-button.scss
@@ -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%);
+ }
+ }
+ }
+ }
+}
diff --git a/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx b/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx
new file mode 100644
index 0000000..1f2b21f
--- /dev/null
+++ b/components/tiptap-ui/color-highlight-button/color-highlight-button.tsx
@@ -0,0 +1,146 @@
+"use client"
+
+import * as React from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { UseColorHighlightConfig } from "@/components/tiptap-ui/color-highlight-button"
+import {
+ COLOR_HIGHLIGHT_SHORTCUT_KEY,
+ useColorHighlight,
+} from "@/components/tiptap-ui/color-highlight-button"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+// --- Styles ---
+import "@/components/tiptap-ui/color-highlight-button/color-highlight-button.scss"
+
+export interface ColorHighlightButtonProps
+ extends Omit,
+ 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 {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * 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) => {
+ 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 (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+ColorHighlightButton.displayName = "ColorHighlightButton"
diff --git a/components/tiptap-ui/color-highlight-button/index.tsx b/components/tiptap-ui/color-highlight-button/index.tsx
new file mode 100644
index 0000000..c517648
--- /dev/null
+++ b/components/tiptap-ui/color-highlight-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./color-highlight-button"
+export * from "./use-color-highlight"
diff --git a/components/tiptap-ui/color-highlight-button/use-color-highlight.ts b/components/tiptap-ui/color-highlight-button/use-color-highlight.ts
new file mode 100644
index 0000000..1bea118
--- /dev/null
+++ b/components/tiptap-ui/color-highlight-button/use-color-highlight.ts
@@ -0,0 +1,240 @@
+"use client"
+
+import * as React from "react"
+import { type Editor } from "@tiptap/react"
+import { useHotkeys } from "react-hotkeys-hook"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Lib ---
+import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils"
+
+// --- Icons ---
+import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
+
+export const COLOR_HIGHLIGHT_SHORTCUT_KEY = "mod+shift+h"
+export const HIGHLIGHT_COLORS = [
+ {
+ label: "Default background",
+ value: "var(--tt-bg-color)",
+ border: "var(--tt-bg-color-contrast)",
+ },
+ {
+ label: "Gray background",
+ value: "var(--tt-color-highlight-gray)",
+ border: "var(--tt-color-highlight-gray-contrast)",
+ },
+ {
+ label: "Brown background",
+ value: "var(--tt-color-highlight-brown)",
+ border: "var(--tt-color-highlight-brown-contrast)",
+ },
+ {
+ label: "Orange background",
+ value: "var(--tt-color-highlight-orange)",
+ border: "var(--tt-color-highlight-orange-contrast)",
+ },
+ {
+ label: "Yellow background",
+ value: "var(--tt-color-highlight-yellow)",
+ border: "var(--tt-color-highlight-yellow-contrast)",
+ },
+ {
+ label: "Green background",
+ value: "var(--tt-color-highlight-green)",
+ border: "var(--tt-color-highlight-green-contrast)",
+ },
+ {
+ label: "Blue background",
+ value: "var(--tt-color-highlight-blue)",
+ border: "var(--tt-color-highlight-blue-contrast)",
+ },
+ {
+ label: "Purple background",
+ value: "var(--tt-color-highlight-purple)",
+ border: "var(--tt-color-highlight-purple-contrast)",
+ },
+ {
+ label: "Pink background",
+ value: "var(--tt-color-highlight-pink)",
+ border: "var(--tt-color-highlight-pink-contrast)",
+ },
+ {
+ label: "Red background",
+ value: "var(--tt-color-highlight-red)",
+ border: "var(--tt-color-highlight-red-contrast)",
+ },
+]
+export type HighlightColor = (typeof HIGHLIGHT_COLORS)[number]
+
+/**
+ * Configuration for the color highlight functionality
+ */
+export interface UseColorHighlightConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The color to apply when toggling the highlight.
+ */
+ highlightColor?: string
+ /**
+ * Optional label to display alongside the icon.
+ */
+ label?: string
+ /**
+ * Whether the button should hide when the mark is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Called when the highlight is applied.
+ */
+ onApplied?: ({ color, label }: { color: string; label: string }) => void
+}
+
+export function pickHighlightColorsByValue(values: string[]) {
+ const colorMap = new Map(
+ HIGHLIGHT_COLORS.map((color) => [color.value, color])
+ )
+ return values
+ .map((value) => colorMap.get(value))
+ .filter((color): color is (typeof HIGHLIGHT_COLORS)[number] => !!color)
+}
+
+export function canColorHighlight(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isMarkInSchema("highlight", editor) ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ return editor.can().setMark("highlight")
+}
+
+export function isColorHighlightActive(
+ editor: Editor | null,
+ highlightColor?: string
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ return highlightColor
+ ? editor.isActive("highlight", { color: highlightColor })
+ : editor.isActive("highlight")
+}
+
+export function removeHighlight(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canColorHighlight(editor)) return false
+
+ return editor.chain().focus().unsetMark("highlight").run()
+}
+
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isMarkInSchema("highlight", editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canColorHighlight(editor)
+ }
+
+ return true
+}
+
+export function useColorHighlight(config: UseColorHighlightConfig) {
+ const {
+ editor: providedEditor,
+ label,
+ highlightColor,
+ hideWhenUnavailable = false,
+ onApplied,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(true)
+ const canColorHighlightState = canColorHighlight(editor)
+ const isActive = isColorHighlightActive(editor, highlightColor)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ const handleColorHighlight = React.useCallback(() => {
+ if (!editor || !canColorHighlightState || !highlightColor || !label)
+ return false
+
+ if (editor.state.storedMarks) {
+ const highlightMarkType = editor.schema.marks.highlight
+ if (highlightMarkType) {
+ editor.view.dispatch(
+ editor.state.tr.removeStoredMark(highlightMarkType)
+ )
+ }
+ }
+
+ setTimeout(() => {
+ const success = editor
+ .chain()
+ .focus()
+ .toggleMark("highlight", { color: highlightColor })
+ .run()
+ if (success) {
+ onApplied?.({ color: highlightColor, label })
+ }
+ return success
+ }, 0)
+ }, [canColorHighlightState, highlightColor, editor, label, onApplied])
+
+ const handleRemoveHighlight = React.useCallback(() => {
+ const success = removeHighlight(editor)
+ if (success) {
+ onApplied?.({ color: "", label: "Remove highlight" })
+ }
+ return success
+ }, [editor, onApplied])
+
+ useHotkeys(
+ COLOR_HIGHLIGHT_SHORTCUT_KEY,
+ (event) => {
+ event.preventDefault()
+ handleColorHighlight()
+ },
+ {
+ enabled: isVisible && canColorHighlightState,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleColorHighlight,
+ handleRemoveHighlight,
+ canColorHighlight: canColorHighlightState,
+ label: label || `Highlight`,
+ shortcutKeys: COLOR_HIGHLIGHT_SHORTCUT_KEY,
+ Icon: HighlighterIcon,
+ }
+}
diff --git a/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx b/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx
new file mode 100644
index 0000000..e287a8b
--- /dev/null
+++ b/components/tiptap-ui/color-highlight-popover/color-highlight-popover.tsx
@@ -0,0 +1,210 @@
+"use client"
+
+import * as React from "react"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useMenuNavigation } from "@/hooks/use-menu-navigation"
+import { useIsMobile } from "@/hooks/use-mobile"
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { BanIcon } from "@/components/tiptap-icons/ban-icon"
+import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/tiptap-ui-primitive/popover"
+import { Separator } from "@/components/tiptap-ui-primitive/separator"
+import {
+ Card,
+ CardBody,
+ CardItemGroup,
+} from "@/components/tiptap-ui-primitive/card"
+
+// --- Tiptap UI ---
+import type {
+ HighlightColor,
+ UseColorHighlightConfig,
+} from "@/components/tiptap-ui/color-highlight-button"
+import {
+ ColorHighlightButton,
+ pickHighlightColorsByValue,
+ useColorHighlight,
+} from "@/components/tiptap-ui/color-highlight-button"
+
+export interface ColorHighlightPopoverContentProps {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Optional colors to use in the highlight popover.
+ * If not provided, defaults to a predefined set of colors.
+ */
+ colors?: HighlightColor[]
+}
+
+export interface ColorHighlightPopoverProps
+ extends Omit,
+ Pick<
+ UseColorHighlightConfig,
+ "editor" | "hideWhenUnavailable" | "onApplied"
+ > {
+ /**
+ * Optional colors to use in the highlight popover.
+ * If not provided, defaults to a predefined set of colors.
+ */
+ colors?: HighlightColor[]
+}
+
+export const ColorHighlightPopoverButton = React.forwardRef<
+ HTMLButtonElement,
+ ButtonProps
+>(({ className, children, ...props }, ref) => (
+
+ {children ?? }
+
+))
+
+ColorHighlightPopoverButton.displayName = "ColorHighlightPopoverButton"
+
+export function ColorHighlightPopoverContent({
+ editor,
+ colors = pickHighlightColorsByValue([
+ "var(--tt-color-highlight-green)",
+ "var(--tt-color-highlight-blue)",
+ "var(--tt-color-highlight-red)",
+ "var(--tt-color-highlight-purple)",
+ "var(--tt-color-highlight-yellow)",
+ ]),
+}: ColorHighlightPopoverContentProps) {
+ const { handleRemoveHighlight } = useColorHighlight({ editor })
+ const isMobile = useIsMobile()
+ const containerRef = React.useRef(null)
+
+ const menuItems = React.useMemo(
+ () => [...colors, { label: "Remove highlight", value: "none" }],
+ [colors]
+ )
+
+ const { selectedIndex } = useMenuNavigation({
+ containerRef,
+ items: menuItems,
+ orientation: "both",
+ onSelect: (item) => {
+ if (!containerRef.current) return false
+ const highlightedElement = containerRef.current.querySelector(
+ '[data-highlighted="true"]'
+ ) as HTMLElement
+ if (highlightedElement) highlightedElement.click()
+ if (item.value === "none") handleRemoveHighlight()
+ },
+ autoSelectFirstItem: false,
+ })
+
+ return (
+
+
+
+
+ {colors.map((color, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export function ColorHighlightPopover({
+ editor: providedEditor,
+ colors = pickHighlightColorsByValue([
+ "var(--tt-color-highlight-green)",
+ "var(--tt-color-highlight-blue)",
+ "var(--tt-color-highlight-red)",
+ "var(--tt-color-highlight-purple)",
+ "var(--tt-color-highlight-yellow)",
+ ]),
+ hideWhenUnavailable = false,
+ onApplied,
+ ...props
+}: ColorHighlightPopoverProps) {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = React.useState(false)
+ const { isVisible, canColorHighlight, isActive, label, Icon } =
+ useColorHighlight({
+ editor,
+ hideWhenUnavailable,
+ onApplied,
+ })
+
+ if (!isVisible) return null
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default ColorHighlightPopover
diff --git a/components/tiptap-ui/color-highlight-popover/index.tsx b/components/tiptap-ui/color-highlight-popover/index.tsx
new file mode 100644
index 0000000..626b81f
--- /dev/null
+++ b/components/tiptap-ui/color-highlight-popover/index.tsx
@@ -0,0 +1 @@
+export * from "./color-highlight-popover"
diff --git a/components/tiptap-ui/heading-button/heading-button.tsx b/components/tiptap-ui/heading-button/heading-button.tsx
new file mode 100644
index 0000000..66a0986
--- /dev/null
+++ b/components/tiptap-ui/heading-button/heading-button.tsx
@@ -0,0 +1,130 @@
+"use client"
+
+import * as React from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Tiptap UI ---
+import type {
+ Level,
+ UseHeadingConfig,
+} from "@/components/tiptap-ui/heading-button"
+import {
+ HEADING_SHORTCUT_KEYS,
+ useHeading,
+} from "@/components/tiptap-ui/heading-button"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+export interface HeadingButtonProps
+ extends Omit,
+ UseHeadingConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function HeadingShortcutBadge({
+ level,
+ shortcutKeys = HEADING_SHORTCUT_KEYS[level],
+}: {
+ level: Level
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling heading in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useHeading` hook instead.
+ */
+export const HeadingButton = React.forwardRef<
+ HTMLButtonElement,
+ HeadingButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ level,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canToggle,
+ isActive,
+ handleToggle,
+ label,
+ Icon,
+ shortcutKeys,
+ } = useHeading({
+ editor,
+ level,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+HeadingButton.displayName = "HeadingButton"
diff --git a/components/tiptap-ui/heading-button/index.tsx b/components/tiptap-ui/heading-button/index.tsx
new file mode 100644
index 0000000..009a700
--- /dev/null
+++ b/components/tiptap-ui/heading-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./heading-button"
+export * from "./use-heading"
diff --git a/components/tiptap-ui/heading-button/use-heading.ts b/components/tiptap-ui/heading-button/use-heading.ts
new file mode 100644
index 0000000..9f7551c
--- /dev/null
+++ b/components/tiptap-ui/heading-button/use-heading.ts
@@ -0,0 +1,329 @@
+"use client"
+
+import * as React from "react"
+import { useHotkeys } from "react-hotkeys-hook"
+import { type Editor } from "@tiptap/react"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Lib ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+} from "@/lib/tiptap-utils"
+
+// --- Icons ---
+import { HeadingOneIcon } from "@/components/tiptap-icons/heading-one-icon"
+import { HeadingTwoIcon } from "@/components/tiptap-icons/heading-two-icon"
+import { HeadingThreeIcon } from "@/components/tiptap-icons/heading-three-icon"
+import { HeadingFourIcon } from "@/components/tiptap-icons/heading-four-icon"
+import { HeadingFiveIcon } from "@/components/tiptap-icons/heading-five-icon"
+import { HeadingSixIcon } from "@/components/tiptap-icons/heading-six-icon"
+
+export type Level = 1 | 2 | 3 | 4 | 5 | 6
+
+/**
+ * Configuration for the heading functionality
+ */
+export interface UseHeadingConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The heading level.
+ */
+ level: Level
+ /**
+ * Whether the button should hide when heading is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful heading toggle.
+ */
+ onToggled?: () => void
+}
+
+export const headingIcons = {
+ 1: HeadingOneIcon,
+ 2: HeadingTwoIcon,
+ 3: HeadingThreeIcon,
+ 4: HeadingFourIcon,
+ 5: HeadingFiveIcon,
+ 6: HeadingSixIcon,
+}
+
+export const HEADING_SHORTCUT_KEYS: Record = {
+ 1: "ctrl+alt+1",
+ 2: "ctrl+alt+2",
+ 3: "ctrl+alt+3",
+ 4: "ctrl+alt+4",
+ 5: "ctrl+alt+5",
+ 6: "ctrl+alt+6",
+}
+
+/**
+ * Checks if heading can be toggled in the current editor state
+ */
+export function canToggle(
+ editor: Editor | null,
+ level?: Level,
+ turnInto: boolean = true
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isNodeInSchema("heading", editor) ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ if (!turnInto) {
+ return level
+ ? editor.can().setNode("heading", { level })
+ : editor.can().setNode("heading")
+ }
+
+ 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
+ }
+}
+
+/**
+ * Checks if heading is currently active
+ */
+export function isHeadingActive(
+ editor: Editor | null,
+ level?: Level | Level[]
+): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ if (Array.isArray(level)) {
+ return level.some((l) => editor.isActive("heading", { level: l }))
+ }
+
+ return level
+ ? editor.isActive("heading", { level })
+ : editor.isActive("heading")
+}
+
+/**
+ * Toggles heading in the editor
+ */
+export function toggleHeading(
+ editor: Editor | null,
+ level: Level | Level[]
+): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ const levels = Array.isArray(level) ? level : [level]
+ const toggleLevel = levels.find((l) => canToggle(editor, l))
+
+ if (!toggleLevel) return false
+
+ try {
+ const view = editor.view
+ let state = view.state
+ let tr = state.tr
+
+ // No selection, find 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 isActive = levels.some((l) =>
+ editor.isActive("heading", { level: l })
+ )
+
+ const toggle = isActive
+ ? chain.setNode("paragraph")
+ : chain.setNode("heading", { level: toggleLevel })
+
+ toggle.run()
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the heading button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ level?: Level | Level[]
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, level, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema("heading", editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ if (Array.isArray(level)) {
+ return level.some((l) => canToggle(editor, l))
+ }
+ return canToggle(editor, level)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides heading functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleHeadingButton() {
+ * const { isVisible, isActive, handleToggle, Icon } = useHeading({ level: 1 })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ *
+ * Heading 1
+ *
+ * )
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedHeadingButton() {
+ * const { isVisible, isActive, handleToggle, label, Icon } = useHeading({
+ * level: 2,
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onToggled: (isActive) => console.log('Heading toggled:', isActive)
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ *
+ * Toggle Heading 2
+ *
+ * )
+ * }
+ * ```
+ */
+export function useHeading(config: UseHeadingConfig) {
+ const {
+ editor: providedEditor,
+ level,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(true)
+ const canToggleState = canToggle(editor, level)
+ const isActive = isHeadingActive(editor, level)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, level, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, level, hideWhenUnavailable])
+
+ const handleToggle = React.useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleHeading(editor, level)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, level, onToggled])
+
+ useHotkeys(
+ HEADING_SHORTCUT_KEYS[level],
+ (event) => {
+ event.preventDefault()
+ handleToggle()
+ },
+ {
+ enabled: isVisible && canToggleState,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle: canToggleState,
+ label: `Heading ${level}`,
+ shortcutKeys: HEADING_SHORTCUT_KEYS[level],
+ Icon: headingIcons[level],
+ }
+}
diff --git a/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx b/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx
new file mode 100644
index 0000000..6f1286f
--- /dev/null
+++ b/components/tiptap-ui/heading-dropdown-menu/heading-dropdown-menu.tsx
@@ -0,0 +1,129 @@
+"use client"
+
+import * as React from "react"
+
+// --- Icons ---
+import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import { HeadingButton } from "@/components/tiptap-ui/heading-button"
+import type { UseHeadingDropdownMenuConfig } from "@/components/tiptap-ui/heading-dropdown-menu"
+import { useHeadingDropdownMenu } from "@/components/tiptap-ui/heading-dropdown-menu"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button"
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from "@/components/tiptap-ui-primitive/dropdown-menu"
+import { Card, CardBody } from "@/components/tiptap-ui-primitive/card"
+
+export interface HeadingDropdownMenuProps
+ extends Omit,
+ UseHeadingDropdownMenuConfig {
+ /**
+ * Whether to render the dropdown menu in a portal
+ * @default false
+ */
+ portal?: boolean
+ /**
+ * Callback for when the dropdown opens or closes
+ */
+ onOpenChange?: (isOpen: boolean) => void
+}
+
+/**
+ * Dropdown menu component for selecting heading levels in a Tiptap editor.
+ *
+ * For custom dropdown implementations, use the `useHeadingDropdownMenu` hook instead.
+ */
+export const HeadingDropdownMenu = React.forwardRef<
+ HTMLButtonElement,
+ HeadingDropdownMenuProps
+>(
+ (
+ {
+ editor: providedEditor,
+ levels = [1, 2, 3, 4, 5, 6],
+ hideWhenUnavailable = false,
+ portal = false,
+ onOpenChange,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = React.useState(false)
+ const { isVisible, isActive, canToggle, Icon } = useHeadingDropdownMenu({
+ editor,
+ levels,
+ hideWhenUnavailable,
+ })
+
+ const handleOpenChange = React.useCallback(
+ (open: boolean) => {
+ if (!editor || !canToggle) return
+ setIsOpen(open)
+ onOpenChange?.(open)
+ },
+ [canToggle, editor, onOpenChange]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {levels.map((level) => (
+
+
+
+ ))}
+
+
+
+
+
+ )
+ }
+)
+
+HeadingDropdownMenu.displayName = "HeadingDropdownMenu"
+
+export default HeadingDropdownMenu
diff --git a/components/tiptap-ui/heading-dropdown-menu/index.tsx b/components/tiptap-ui/heading-dropdown-menu/index.tsx
new file mode 100644
index 0000000..33b9679
--- /dev/null
+++ b/components/tiptap-ui/heading-dropdown-menu/index.tsx
@@ -0,0 +1,2 @@
+export * from "./heading-dropdown-menu"
+export * from "./use-heading-dropdown-menu"
diff --git a/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts b/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts
new file mode 100644
index 0000000..42e1ad4
--- /dev/null
+++ b/components/tiptap-ui/heading-dropdown-menu/use-heading-dropdown-menu.ts
@@ -0,0 +1,132 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { HeadingIcon } from "@/components/tiptap-icons/heading-icon"
+
+// --- Tiptap UI ---
+import {
+ headingIcons,
+ type Level,
+ isHeadingActive,
+ canToggle,
+ shouldShowButton,
+} from "@/components/tiptap-ui/heading-button"
+
+/**
+ * Configuration for the heading dropdown menu functionality
+ */
+export interface UseHeadingDropdownMenuConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Available heading levels to show in the dropdown
+ * @default [1, 2, 3, 4, 5, 6]
+ */
+ levels?: Level[]
+ /**
+ * Whether the dropdown should hide when headings are not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+}
+
+/**
+ * Gets the currently active heading level from the available levels
+ */
+export function getActiveHeadingLevel(
+ editor: Editor | null,
+ levels: Level[] = [1, 2, 3, 4, 5, 6]
+): Level | undefined {
+ if (!editor || !editor.isEditable) return undefined
+ return levels.find((level) => isHeadingActive(editor, level))
+}
+
+/**
+ * Custom hook that provides heading dropdown menu functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MyHeadingDropdown() {
+ * const {
+ * isVisible,
+ * activeLevel,
+ * isAnyHeadingActive,
+ * canToggle,
+ * levels,
+ * } = useHeadingDropdownMenu()
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * // dropdown content
+ *
+ * )
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedHeadingDropdown() {
+ * const {
+ * isVisible,
+ * activeLevel,
+ * } = useHeadingDropdownMenu({
+ * editor: myEditor,
+ * levels: [1, 2, 3],
+ * hideWhenUnavailable: true,
+ * })
+ *
+ * // component implementation
+ * }
+ * ```
+ */
+export function useHeadingDropdownMenu(config?: UseHeadingDropdownMenuConfig) {
+ const {
+ editor: providedEditor,
+ levels = [1, 2, 3, 4, 5, 6],
+ hideWhenUnavailable = false,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = React.useState(true)
+
+ const activeLevel = getActiveHeadingLevel(editor, levels)
+ const isActive = isHeadingActive(editor)
+ const canToggleState = canToggle(editor)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(
+ shouldShowButton({ editor, hideWhenUnavailable, level: levels })
+ )
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable, levels])
+
+ return {
+ isVisible,
+ activeLevel,
+ isActive,
+ canToggle: canToggleState,
+ levels,
+ label: "Heading",
+ Icon: activeLevel ? headingIcons[activeLevel] : HeadingIcon,
+ }
+}
diff --git a/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/components/tiptap-ui/image-upload-button/image-upload-button.tsx
new file mode 100644
index 0000000..b95898d
--- /dev/null
+++ b/components/tiptap-ui/image-upload-button/image-upload-button.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import * as React from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { UseImageUploadConfig } from "@/components/tiptap-ui/image-upload-button"
+import {
+ IMAGE_UPLOAD_SHORTCUT_KEY,
+ useImageUpload,
+} from "@/components/tiptap-ui/image-upload-button"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+export interface ImageUploadButtonProps
+ extends Omit,
+ UseImageUploadConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function ImageShortcutBadge({
+ shortcutKeys = IMAGE_UPLOAD_SHORTCUT_KEY,
+}: {
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for uploading/inserting images in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useImage` hook instead.
+ */
+export const ImageUploadButton = React.forwardRef<
+ HTMLButtonElement,
+ ImageUploadButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ text,
+ hideWhenUnavailable = false,
+ onInserted,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canInsert,
+ handleImage,
+ label,
+ isActive,
+ shortcutKeys,
+ Icon,
+ } = useImageUpload({
+ editor,
+ hideWhenUnavailable,
+ onInserted,
+ })
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleImage()
+ },
+ [handleImage, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && }
+ >
+ )}
+
+ )
+ }
+)
+
+ImageUploadButton.displayName = "ImageUploadButton"
diff --git a/components/tiptap-ui/image-upload-button/index.tsx b/components/tiptap-ui/image-upload-button/index.tsx
new file mode 100644
index 0000000..815d5bb
--- /dev/null
+++ b/components/tiptap-ui/image-upload-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./image-upload-button"
+export * from "./use-image-upload"
diff --git a/components/tiptap-ui/image-upload-button/use-image-upload.ts b/components/tiptap-ui/image-upload-button/use-image-upload.ts
new file mode 100644
index 0000000..377a6ed
--- /dev/null
+++ b/components/tiptap-ui/image-upload-button/use-image-upload.ts
@@ -0,0 +1,199 @@
+"use client"
+
+import * as React from "react"
+import { useHotkeys } from "react-hotkeys-hook"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Lib ---
+import {
+ isExtensionAvailable,
+ isNodeTypeSelected,
+} from "@/lib/tiptap-utils"
+
+// --- Icons ---
+import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon"
+
+export const IMAGE_UPLOAD_SHORTCUT_KEY = "mod+shift+i"
+
+/**
+ * Configuration for the image upload functionality
+ */
+export interface UseImageUploadConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether the button should hide when insertion is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful image insertion.
+ */
+ onInserted?: () => void
+}
+
+/**
+ * Checks if image can be inserted in the current editor state
+ */
+export function canInsertImage(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isExtensionAvailable(editor, "imageUpload") ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ return editor.can().insertContent({ type: "imageUpload" })
+}
+
+/**
+ * Checks if image is currently active
+ */
+export function isImageActive(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive("imageUpload")
+}
+
+/**
+ * Inserts an image in the editor
+ */
+export function insertImage(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canInsertImage(editor)) return false
+
+ try {
+ return editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: "imageUpload",
+ })
+ .run()
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the image 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 (!isExtensionAvailable(editor, "imageUpload")) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canInsertImage(editor)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides image functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage - no params needed
+ * function MySimpleImageButton() {
+ * const { isVisible, handleImage } = useImage()
+ *
+ * if (!isVisible) return null
+ *
+ * return Add Image
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedImageButton() {
+ * const { isVisible, handleImage, label, isActive } = useImage({
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onInserted: () => console.log('Image inserted!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Add Image
+ *
+ * )
+ * }
+ * ```
+ */
+export function useImageUpload(config?: UseImageUploadConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onInserted,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(true)
+ const canInsert = canInsertImage(editor)
+ const isActive = isImageActive(editor)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ const handleImage = React.useCallback(() => {
+ if (!editor) return false
+
+ const success = insertImage(editor)
+ if (success) {
+ onInserted?.()
+ }
+ return success
+ }, [editor, onInserted])
+
+ useHotkeys(
+ IMAGE_UPLOAD_SHORTCUT_KEY,
+ (event) => {
+ event.preventDefault()
+ handleImage()
+ },
+ {
+ enabled: isVisible && canInsert,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleImage,
+ canInsert,
+ label: "Add image",
+ shortcutKeys: IMAGE_UPLOAD_SHORTCUT_KEY,
+ Icon: ImagePlusIcon,
+ }
+}
diff --git a/components/tiptap-ui/link-popover/index.tsx b/components/tiptap-ui/link-popover/index.tsx
new file mode 100644
index 0000000..e725ea8
--- /dev/null
+++ b/components/tiptap-ui/link-popover/index.tsx
@@ -0,0 +1,2 @@
+export * from "./link-popover"
+export * from "./use-link-popover"
diff --git a/components/tiptap-ui/link-popover/link-popover.tsx b/components/tiptap-ui/link-popover/link-popover.tsx
new file mode 100644
index 0000000..ba7fa08
--- /dev/null
+++ b/components/tiptap-ui/link-popover/link-popover.tsx
@@ -0,0 +1,310 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useIsMobile } from "@/hooks/use-mobile"
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { CornerDownLeftIcon } from "@/components/tiptap-icons/corner-down-left-icon"
+import { ExternalLinkIcon } from "@/components/tiptap-icons/external-link-icon"
+import { LinkIcon } from "@/components/tiptap-icons/link-icon"
+import { TrashIcon } from "@/components/tiptap-icons/trash-icon"
+
+// --- Tiptap UI ---
+import type { UseLinkPopoverConfig } from "@/components/tiptap-ui/link-popover"
+import { useLinkPopover } from "@/components/tiptap-ui/link-popover"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/tiptap-ui-primitive/popover"
+import { Separator } from "@/components/tiptap-ui-primitive/separator"
+import {
+ Card,
+ CardBody,
+ CardItemGroup,
+} from "@/components/tiptap-ui-primitive/card"
+import { Input, InputGroup } from "@/components/tiptap-ui-primitive/input"
+
+export interface LinkMainProps {
+ /**
+ * The URL to set for the link.
+ */
+ url: string
+ /**
+ * Function to update the URL state.
+ */
+ setUrl: React.Dispatch>
+ /**
+ * Function to set the link in the editor.
+ */
+ setLink: () => void
+ /**
+ * Function to remove the link from the editor.
+ */
+ removeLink: () => void
+ /**
+ * Function to open the link.
+ */
+ openLink: () => void
+ /**
+ * Whether the link is currently active in the editor.
+ */
+ isActive: boolean
+}
+
+export interface LinkPopoverProps
+ extends Omit,
+ UseLinkPopoverConfig {
+ /**
+ * Callback for when the popover opens or closes.
+ */
+ onOpenChange?: (isOpen: boolean) => void
+ /**
+ * Whether to automatically open the popover when a link is active.
+ * @default true
+ */
+ autoOpenOnLinkActive?: boolean
+}
+
+/**
+ * Link button component for triggering the link popover
+ */
+export const LinkButton = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ return (
+
+ {children || }
+
+ )
+ }
+)
+
+LinkButton.displayName = "LinkButton"
+
+/**
+ * Main content component for the link popover
+ */
+const LinkMain: React.FC = ({
+ url,
+ setUrl,
+ setLink,
+ removeLink,
+ openLink,
+ isActive,
+}) => {
+ const isMobile = useIsMobile()
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault()
+ setLink()
+ }
+ }
+
+ return (
+
+
+
+
+ setUrl(e.target.value)}
+ onKeyDown={handleKeyDown}
+ autoFocus
+ autoComplete="off"
+ autoCorrect="off"
+ autoCapitalize="off"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+/**
+ * Link content component for standalone use
+ */
+export const LinkContent: React.FC<{
+ editor?: Editor | null
+}> = ({ editor }) => {
+ const linkPopover = useLinkPopover({
+ editor,
+ })
+
+ return
+}
+
+/**
+ * Link popover component for Tiptap editors.
+ *
+ * For custom popover implementations, use the `useLinkPopover` hook instead.
+ */
+export const LinkPopover = React.forwardRef<
+ HTMLButtonElement,
+ LinkPopoverProps
+>(
+ (
+ {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onSetLink,
+ onOpenChange,
+ autoOpenOnLinkActive = true,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = React.useState(false)
+
+ const {
+ isVisible,
+ canSet,
+ isActive,
+ url,
+ setUrl,
+ setLink,
+ removeLink,
+ openLink,
+ label,
+ Icon,
+ } = useLinkPopover({
+ editor,
+ hideWhenUnavailable,
+ onSetLink,
+ })
+
+ const handleOnOpenChange = React.useCallback(
+ (nextIsOpen: boolean) => {
+ setIsOpen(nextIsOpen)
+ onOpenChange?.(nextIsOpen)
+ },
+ [onOpenChange]
+ )
+
+ const handleSetLink = React.useCallback(() => {
+ setLink()
+ setIsOpen(false)
+ }, [setLink])
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ setIsOpen(!isOpen)
+ },
+ [onClick, isOpen]
+ )
+
+ React.useEffect(() => {
+ if (autoOpenOnLinkActive && isActive) {
+ setIsOpen(true)
+ }
+ }, [autoOpenOnLinkActive, isActive])
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+
+
+ {children ?? }
+
+
+
+
+
+
+
+ )
+ }
+)
+
+LinkPopover.displayName = "LinkPopover"
+
+export default LinkPopover
diff --git a/components/tiptap-ui/link-popover/use-link-popover.ts b/components/tiptap-ui/link-popover/use-link-popover.ts
new file mode 100644
index 0000000..52b8262
--- /dev/null
+++ b/components/tiptap-ui/link-popover/use-link-popover.ts
@@ -0,0 +1,278 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { LinkIcon } from "@/components/tiptap-icons/link-icon"
+
+// --- Lib ---
+import { isMarkInSchema, sanitizeUrl } from "@/lib/tiptap-utils"
+
+/**
+ * Configuration for the link popover functionality
+ */
+export interface UseLinkPopoverConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * Whether to hide the link popover when not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called when the link is set.
+ */
+ onSetLink?: () => void
+}
+
+/**
+ * Configuration for the link handler functionality
+ */
+export interface LinkHandlerProps {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor: Editor | null
+ /**
+ * Callback function called when the link is set.
+ */
+ onSetLink?: () => void
+}
+
+/**
+ * Checks if a link can be set in the current editor state
+ */
+export function canSetLink(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.can().setMark("link")
+}
+
+/**
+ * Checks if a link is currently active in the editor
+ */
+export function isLinkActive(editor: Editor | null): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive("link")
+}
+
+/**
+ * Determines if the link button should be shown
+ */
+export function shouldShowLinkButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable } = props
+
+ const linkInSchema = isMarkInSchema("link", editor)
+
+ if (!linkInSchema || !editor) {
+ return false
+ }
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canSetLink(editor)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook for handling link operations in a Tiptap editor
+ */
+export function useLinkHandler(props: LinkHandlerProps) {
+ const { editor, onSetLink } = props
+ const [url, setUrl] = React.useState(null)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ // Get URL immediately on mount
+ const { href } = editor.getAttributes("link")
+
+ if (isLinkActive(editor) && url === null) {
+ setUrl(href || "")
+ }
+ }, [editor, url])
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const updateLinkState = () => {
+ const { href } = editor.getAttributes("link")
+ setUrl(href || "")
+ }
+
+ editor.on("selectionUpdate", updateLinkState)
+ return () => {
+ editor.off("selectionUpdate", updateLinkState)
+ }
+ }, [editor])
+
+ const setLink = React.useCallback(() => {
+ if (!url || !editor) return
+
+ const { selection } = editor.state
+ const isEmpty = selection.empty
+
+ let chain = editor.chain().focus()
+
+ chain = chain.extendMarkRange("link").setLink({ href: url })
+
+ if (isEmpty) {
+ chain = chain.insertContent({ type: "text", text: url })
+ }
+
+ chain.run()
+
+ setUrl(null)
+
+ onSetLink?.()
+ }, [editor, onSetLink, url])
+
+ const removeLink = React.useCallback(() => {
+ if (!editor) return
+ editor
+ .chain()
+ .focus()
+ .extendMarkRange("link")
+ .unsetLink()
+ .setMeta("preventAutolink", true)
+ .run()
+ setUrl("")
+ }, [editor])
+
+ const openLink = React.useCallback(
+ (target: string = "_blank", features: string = "noopener,noreferrer") => {
+ if (!url) return
+
+ const safeUrl = sanitizeUrl(url, window.location.href)
+ if (safeUrl !== "#") {
+ window.open(safeUrl, target, features)
+ }
+ },
+ [url]
+ )
+
+ return {
+ url: url || "",
+ setUrl,
+ setLink,
+ removeLink,
+ openLink,
+ }
+}
+
+/**
+ * Custom hook for link popover state management
+ */
+export function useLinkState(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+}) {
+ const { editor, hideWhenUnavailable = false } = props
+
+ const canSet = canSetLink(editor)
+ const isActive = isLinkActive(editor)
+
+ const [isVisible, setIsVisible] = React.useState(false)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(
+ shouldShowLinkButton({
+ editor,
+ hideWhenUnavailable,
+ })
+ )
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable])
+
+ return {
+ isVisible,
+ canSet,
+ isActive,
+ }
+}
+
+/**
+ * Main hook that provides link popover functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MyLinkButton() {
+ * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover()
+ *
+ * if (!isVisible) return null
+ *
+ * return Link
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedLinkButton() {
+ * const { isVisible, canSet, isActive, Icon, label } = useLinkPopover({
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onSetLink: () => console.log('Link set!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ *
+ * {label}
+ *
+ * )
+ * }
+ * ```
+ */
+export function useLinkPopover(config?: UseLinkPopoverConfig) {
+ const {
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onSetLink,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+
+ const { isVisible, canSet, isActive } = useLinkState({
+ editor,
+ hideWhenUnavailable,
+ })
+
+ const linkHandler = useLinkHandler({
+ editor,
+ onSetLink,
+ })
+
+ return {
+ isVisible,
+ canSet,
+ isActive,
+ label: "Link",
+ Icon: LinkIcon,
+ ...linkHandler,
+ }
+}
diff --git a/components/tiptap-ui/list-button/index.tsx b/components/tiptap-ui/list-button/index.tsx
new file mode 100644
index 0000000..9f3d066
--- /dev/null
+++ b/components/tiptap-ui/list-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./list-button"
+export * from "./use-list"
diff --git a/components/tiptap-ui/list-button/list-button.tsx b/components/tiptap-ui/list-button/list-button.tsx
new file mode 100644
index 0000000..234f5e8
--- /dev/null
+++ b/components/tiptap-ui/list-button/list-button.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import * as React from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+// --- Tiptap UI ---
+import type { ListType, UseListConfig } from "@/components/tiptap-ui/list-button"
+import { LIST_SHORTCUT_KEYS, useList } from "@/components/tiptap-ui/list-button"
+
+export interface ListButtonProps
+ extends Omit,
+ UseListConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function ListShortcutBadge({
+ type,
+ shortcutKeys = LIST_SHORTCUT_KEYS[type],
+}: {
+ type: ListType
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling lists in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useList` hook instead.
+ */
+export const ListButton = React.forwardRef(
+ (
+ {
+ editor: providedEditor,
+ type,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ canToggle,
+ isActive,
+ handleToggle,
+ label,
+ shortcutKeys,
+ Icon,
+ } = useList({
+ editor,
+ type,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleToggle()
+ },
+ [handleToggle, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+ListButton.displayName = "ListButton"
diff --git a/components/tiptap-ui/list-button/use-list.ts b/components/tiptap-ui/list-button/use-list.ts
new file mode 100644
index 0000000..d0e6c19
--- /dev/null
+++ b/components/tiptap-ui/list-button/use-list.ts
@@ -0,0 +1,324 @@
+"use client"
+
+import * as React from "react"
+import { type Editor } from "@tiptap/react"
+import { useHotkeys } from "react-hotkeys-hook"
+import { NodeSelection, TextSelection } from "@tiptap/pm/state"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Icons ---
+import { ListIcon } from "@/components/tiptap-icons/list-icon"
+import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
+import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
+
+// --- Lib ---
+import {
+ findNodePosition,
+ isNodeInSchema,
+ isNodeTypeSelected,
+ isValidPosition,
+} from "@/lib/tiptap-utils"
+
+export type ListType = "bulletList" | "orderedList" | "taskList"
+
+/**
+ * Configuration for the list functionality
+ */
+export interface UseListConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The type of list to toggle.
+ */
+ type: ListType
+ /**
+ * Whether the button should hide when list is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful toggle.
+ */
+ onToggled?: () => void
+}
+
+export const listIcons = {
+ bulletList: ListIcon,
+ orderedList: ListOrderedIcon,
+ taskList: ListTodoIcon,
+}
+
+export const listLabels: Record = {
+ bulletList: "Bullet List",
+ orderedList: "Ordered List",
+ taskList: "Task List",
+}
+
+export const LIST_SHORTCUT_KEYS: Record = {
+ bulletList: "mod+shift+8",
+ orderedList: "mod+shift+7",
+ taskList: "mod+shift+9",
+}
+
+/**
+ * Checks if a list can be toggled in the current editor state
+ */
+export function canToggleList(
+ editor: Editor | null,
+ type: ListType,
+ turnInto: boolean = true
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
+ return false
+
+ if (!turnInto) {
+ switch (type) {
+ case "bulletList":
+ return editor.can().toggleBulletList()
+ case "orderedList":
+ return editor.can().toggleOrderedList()
+ case "taskList":
+ return editor.can().toggleList("taskList", "taskItem")
+ default:
+ return false
+ }
+ }
+
+ 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
+ }
+}
+
+/**
+ * Checks if list is currently active
+ */
+export function isListActive(editor: Editor | null, type: ListType): boolean {
+ if (!editor || !editor.isEditable) return false
+
+ switch (type) {
+ case "bulletList":
+ return editor.isActive("bulletList")
+ case "orderedList":
+ return editor.isActive("orderedList")
+ case "taskList":
+ return editor.isActive("taskList")
+ default:
+ return false
+ }
+}
+
+/**
+ * Toggles list in the editor
+ */
+export function toggleList(editor: Editor | null, type: ListType): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggleList(editor, type)) 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()
+ }
+
+ if (editor.isActive(type)) {
+ // Unwrap list
+ chain
+ .liftListItem("listItem")
+ .lift("bulletList")
+ .lift("orderedList")
+ .lift("taskList")
+ .run()
+ } else {
+ // Wrap in specific list type
+ const toggleMap: Record typeof chain> = {
+ bulletList: () => chain.toggleBulletList(),
+ orderedList: () => chain.toggleOrderedList(),
+ taskList: () => chain.toggleList("taskList", "taskItem"),
+ }
+
+ const toggle = toggleMap[type]
+ if (!toggle) return false
+
+ toggle().run()
+ }
+
+ editor.chain().focus().selectTextblockEnd().run()
+
+ return true
+ } catch {
+ return false
+ }
+}
+
+/**
+ * Determines if the list button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ type: ListType
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, type, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isNodeInSchema(type, editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleList(editor, type)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides list functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleListButton() {
+ * const { isVisible, handleToggle, isActive } = useList({ type: "bulletList" })
+ *
+ * if (!isVisible) return null
+ *
+ * return Bullet List
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedListButton() {
+ * const { isVisible, handleToggle, label, isActive } = useList({
+ * type: "orderedList",
+ * editor: myEditor,
+ * hideWhenUnavailable: true,
+ * onToggled: () => console.log('List toggled!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Toggle List
+ *
+ * )
+ * }
+ * ```
+ */
+export function useList(config: UseListConfig) {
+ const {
+ editor: providedEditor,
+ type,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(true)
+ const canToggle = canToggleList(editor, type)
+ const isActive = isListActive(editor, type)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, type, hideWhenUnavailable])
+
+ const handleToggle = React.useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleList(editor, type)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, type, onToggled])
+
+ useHotkeys(
+ LIST_SHORTCUT_KEYS[type],
+ (event) => {
+ event.preventDefault()
+ handleToggle()
+ },
+ {
+ enabled: isVisible && canToggle,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleToggle,
+ canToggle,
+ label: listLabels[type],
+ shortcutKeys: LIST_SHORTCUT_KEYS[type],
+ Icon: listIcons[type],
+ }
+}
diff --git a/components/tiptap-ui/list-dropdown-menu/index.tsx b/components/tiptap-ui/list-dropdown-menu/index.tsx
new file mode 100644
index 0000000..9a215b8
--- /dev/null
+++ b/components/tiptap-ui/list-dropdown-menu/index.tsx
@@ -0,0 +1 @@
+export * from "./list-dropdown-menu"
diff --git a/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx b/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx
new file mode 100644
index 0000000..97b1da0
--- /dev/null
+++ b/components/tiptap-ui/list-dropdown-menu/list-dropdown-menu.tsx
@@ -0,0 +1,125 @@
+"use client"
+
+import * as React from "react"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { ChevronDownIcon } from "@/components/tiptap-icons/chevron-down-icon"
+
+// --- Tiptap UI ---
+import { ListButton, type ListType } from "@/components/tiptap-ui/list-button"
+
+import { useListDropdownMenu } from "./use-list-dropdown-menu"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button, ButtonGroup } from "@/components/tiptap-ui-primitive/button"
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuItem,
+} from "@/components/tiptap-ui-primitive/dropdown-menu"
+import { Card, CardBody } from "@/components/tiptap-ui-primitive/card"
+
+export interface ListDropdownMenuProps extends Omit {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor
+ /**
+ * The list types to display in the dropdown.
+ */
+ types?: ListType[]
+ /**
+ * Whether the dropdown should be hidden when no list types are available
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback for when the dropdown opens or closes
+ */
+ onOpenChange?: (isOpen: boolean) => void
+ /**
+ * Whether to render the dropdown menu in a portal
+ * @default false
+ */
+ portal?: boolean
+}
+
+export function ListDropdownMenu({
+ editor: providedEditor,
+ types = ["bulletList", "orderedList", "taskList"],
+ hideWhenUnavailable = false,
+ onOpenChange,
+ portal = false,
+ ...props
+}: ListDropdownMenuProps) {
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isOpen, setIsOpen] = React.useState(false)
+
+ const { filteredLists, canToggle, isActive, isVisible, Icon } =
+ useListDropdownMenu({
+ editor,
+ types,
+ hideWhenUnavailable,
+ })
+
+ const handleOnOpenChange = React.useCallback(
+ (open: boolean) => {
+ setIsOpen(open)
+ onOpenChange?.(open)
+ },
+ [onOpenChange]
+ )
+
+ if (!isVisible || !editor || !editor.isEditable) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ {filteredLists.map((option) => (
+
+
+
+ ))}
+
+
+
+
+
+ )
+}
+
+export default ListDropdownMenu
diff --git a/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts b/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts
new file mode 100644
index 0000000..fed4ed4
--- /dev/null
+++ b/components/tiptap-ui/list-dropdown-menu/use-list-dropdown-menu.ts
@@ -0,0 +1,219 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Icons ---
+import { ListIcon } from "@/components/tiptap-icons/list-icon"
+import { ListOrderedIcon } from "@/components/tiptap-icons/list-ordered-icon"
+import { ListTodoIcon } from "@/components/tiptap-icons/list-todo-icon"
+
+// --- Lib ---
+import { isNodeInSchema } from "@/lib/tiptap-utils"
+
+// --- Tiptap UI ---
+import {
+ canToggleList,
+ isListActive,
+ listIcons,
+ type ListType,
+} from "@/components/tiptap-ui/list-button"
+
+/**
+ * Configuration for the list dropdown menu functionality
+ */
+export interface UseListDropdownMenuConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The list types to display in the dropdown.
+ * @default ["bulletList", "orderedList", "taskList"]
+ */
+ types?: ListType[]
+ /**
+ * Whether the dropdown should be hidden when no list types are available
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+}
+
+export interface ListOption {
+ label: string
+ type: ListType
+ icon: React.ElementType
+}
+
+export const listOptions: ListOption[] = [
+ {
+ label: "Bullet List",
+ type: "bulletList",
+ icon: ListIcon,
+ },
+ {
+ label: "Ordered List",
+ type: "orderedList",
+ icon: ListOrderedIcon,
+ },
+ {
+ label: "Task List",
+ type: "taskList",
+ icon: ListTodoIcon,
+ },
+]
+
+export function canToggleAnyList(
+ editor: Editor | null,
+ listTypes: ListType[]
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ return listTypes.some((type) => canToggleList(editor, type))
+}
+
+export function isAnyListActive(
+ editor: Editor | null,
+ listTypes: ListType[]
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ return listTypes.some((type) => isListActive(editor, type))
+}
+
+export function getFilteredListOptions(
+ availableTypes: ListType[]
+): typeof listOptions {
+ return listOptions.filter(
+ (option) => !option.type || availableTypes.includes(option.type)
+ )
+}
+
+export function shouldShowListDropdown(params: {
+ editor: Editor | null
+ listTypes: ListType[]
+ hideWhenUnavailable: boolean
+ listInSchema: boolean
+ canToggleAny: boolean
+}): boolean {
+ const { editor, hideWhenUnavailable, listInSchema, canToggleAny } = params
+
+ if (!listInSchema || !editor) {
+ return false
+ }
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleAny
+ }
+
+ return true
+}
+
+/**
+ * Gets the currently active list type from the available types
+ */
+export function getActiveListType(
+ editor: Editor | null,
+ availableTypes: ListType[]
+): ListType | undefined {
+ if (!editor || !editor.isEditable) return undefined
+ return availableTypes.find((type) => isListActive(editor, type))
+}
+
+/**
+ * Custom hook that provides list dropdown menu functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MyListDropdown() {
+ * const {
+ * isVisible,
+ * activeType,
+ * isAnyActive,
+ * canToggleAny,
+ * filteredLists,
+ * } = useListDropdownMenu()
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * // dropdown content
+ *
+ * )
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedListDropdown() {
+ * const {
+ * isVisible,
+ * activeType,
+ * } = useListDropdownMenu({
+ * editor: myEditor,
+ * types: ["bulletList", "orderedList"],
+ * hideWhenUnavailable: true,
+ * })
+ *
+ * // component implementation
+ * }
+ * ```
+ */
+export function useListDropdownMenu(config?: UseListDropdownMenuConfig) {
+ const {
+ editor: providedEditor,
+ types = ["bulletList", "orderedList", "taskList"],
+ hideWhenUnavailable = false,
+ } = config || {}
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const [isVisible, setIsVisible] = React.useState(false)
+
+ const listInSchema = types.some((type) => isNodeInSchema(type, editor))
+
+ const filteredLists = React.useMemo(
+ () => getFilteredListOptions(types),
+ [types]
+ )
+
+ const canToggleAny = canToggleAnyList(editor, types)
+ const isAnyActive = isAnyListActive(editor, types)
+ const activeType = getActiveListType(editor, types)
+ const activeList = filteredLists.find((option) => option.type === activeType)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(
+ shouldShowListDropdown({
+ editor,
+ listTypes: types,
+ hideWhenUnavailable,
+ listInSchema,
+ canToggleAny,
+ })
+ )
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [canToggleAny, editor, hideWhenUnavailable, listInSchema, types])
+
+ return {
+ isVisible,
+ activeType,
+ isActive: isAnyActive,
+ canToggle: canToggleAny,
+ types,
+ filteredLists,
+ label: "List",
+ Icon: activeList ? listIcons[activeList.type] : ListIcon,
+ }
+}
diff --git a/components/tiptap-ui/mark-button/index.tsx b/components/tiptap-ui/mark-button/index.tsx
new file mode 100644
index 0000000..32e85b9
--- /dev/null
+++ b/components/tiptap-ui/mark-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./mark-button"
+export * from "./use-mark"
diff --git a/components/tiptap-ui/mark-button/mark-button.tsx b/components/tiptap-ui/mark-button/mark-button.tsx
new file mode 100644
index 0000000..8ee87ce
--- /dev/null
+++ b/components/tiptap-ui/mark-button/mark-button.tsx
@@ -0,0 +1,123 @@
+"use client"
+
+import * as React from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type { Mark, UseMarkConfig } from "@/components/tiptap-ui/mark-button"
+import { MARK_SHORTCUT_KEYS, useMark } from "@/components/tiptap-ui/mark-button"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+export interface MarkButtonProps
+ extends Omit,
+ UseMarkConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function MarkShortcutBadge({
+ type,
+ shortcutKeys = MARK_SHORTCUT_KEYS[type],
+}: {
+ type: Mark
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for toggling marks in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useMark` hook instead.
+ */
+export const MarkButton = React.forwardRef(
+ (
+ {
+ editor: providedEditor,
+ type,
+ text,
+ hideWhenUnavailable = false,
+ onToggled,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ handleMark,
+ label,
+ canToggle,
+ isActive,
+ Icon,
+ shortcutKeys,
+ } = useMark({
+ editor,
+ type,
+ hideWhenUnavailable,
+ onToggled,
+ })
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleMark()
+ },
+ [handleMark, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+MarkButton.displayName = "MarkButton"
diff --git a/components/tiptap-ui/mark-button/use-mark.ts b/components/tiptap-ui/mark-button/use-mark.ts
new file mode 100644
index 0000000..7b86ac8
--- /dev/null
+++ b/components/tiptap-ui/mark-button/use-mark.ts
@@ -0,0 +1,230 @@
+"use client"
+
+import * as React from "react"
+import { useHotkeys } from "react-hotkeys-hook"
+import type { Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Lib ---
+import { isMarkInSchema, isNodeTypeSelected } from "@/lib/tiptap-utils"
+
+// --- Icons ---
+import { BoldIcon } from "@/components/tiptap-icons/bold-icon"
+import { Code2Icon } from "@/components/tiptap-icons/code2-icon"
+import { ItalicIcon } from "@/components/tiptap-icons/italic-icon"
+import { StrikeIcon } from "@/components/tiptap-icons/strike-icon"
+import { SubscriptIcon } from "@/components/tiptap-icons/subscript-icon"
+import { SuperscriptIcon } from "@/components/tiptap-icons/superscript-icon"
+import { UnderlineIcon } from "@/components/tiptap-icons/underline-icon"
+
+export type Mark =
+ | "bold"
+ | "italic"
+ | "strike"
+ | "code"
+ | "underline"
+ | "superscript"
+ | "subscript"
+
+/**
+ * Configuration for the mark functionality
+ */
+export interface UseMarkConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The type of mark to toggle
+ */
+ type: Mark
+ /**
+ * Whether the button should hide when mark is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful mark toggle.
+ */
+ onToggled?: () => void
+}
+
+export const markIcons = {
+ bold: BoldIcon,
+ italic: ItalicIcon,
+ underline: UnderlineIcon,
+ strike: StrikeIcon,
+ code: Code2Icon,
+ superscript: SuperscriptIcon,
+ subscript: SubscriptIcon,
+}
+
+export const MARK_SHORTCUT_KEYS: Record = {
+ bold: "mod+b",
+ italic: "mod+i",
+ underline: "mod+u",
+ strike: "mod+shift+s",
+ code: "mod+e",
+ superscript: "mod+.",
+ subscript: "mod+,",
+}
+
+/**
+ * Checks if a mark can be toggled in the current editor state
+ */
+export function canToggleMark(editor: Editor | null, type: Mark): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!isMarkInSchema(type, editor) || isNodeTypeSelected(editor, ["image"]))
+ return false
+
+ return editor.can().toggleMark(type)
+}
+
+/**
+ * Checks if a mark is currently active
+ */
+export function isMarkActive(editor: Editor | null, type: Mark): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive(type)
+}
+
+/**
+ * Toggles a mark in the editor
+ */
+export function toggleMark(editor: Editor | null, type: Mark): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canToggleMark(editor, type)) return false
+
+ return editor.chain().focus().toggleMark(type).run()
+}
+
+/**
+ * Determines if the mark button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ type: Mark
+ hideWhenUnavailable: boolean
+}): boolean {
+ const { editor, type, hideWhenUnavailable } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isMarkInSchema(type, editor)) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canToggleMark(editor, type)
+ }
+
+ return true
+}
+
+/**
+ * Gets the formatted mark name
+ */
+export function getFormattedMarkName(type: Mark): string {
+ return type.charAt(0).toUpperCase() + type.slice(1)
+}
+
+/**
+ * Custom hook that provides mark functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleBoldButton() {
+ * const { isVisible, handleMark } = useMark({ type: "bold" })
+ *
+ * if (!isVisible) return null
+ *
+ * return Bold
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedItalicButton() {
+ * const { isVisible, handleMark, label, isActive } = useMark({
+ * editor: myEditor,
+ * type: "italic",
+ * hideWhenUnavailable: true,
+ * onToggled: () => console.log('Mark toggled!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Italic
+ *
+ * )
+ * }
+ * ```
+ */
+export function useMark(config: UseMarkConfig) {
+ const {
+ editor: providedEditor,
+ type,
+ hideWhenUnavailable = false,
+ onToggled,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(true)
+ const canToggle = canToggleMark(editor, type)
+ const isActive = isMarkActive(editor, type)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, type, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, type, hideWhenUnavailable])
+
+ const handleMark = React.useCallback(() => {
+ if (!editor) return false
+
+ const success = toggleMark(editor, type)
+ if (success) {
+ onToggled?.()
+ }
+ return success
+ }, [editor, type, onToggled])
+
+ useHotkeys(
+ MARK_SHORTCUT_KEYS[type],
+ (event) => {
+ event.preventDefault()
+ handleMark()
+ },
+ {
+ enabled: isVisible && canToggle,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleMark,
+ canToggle,
+ label: getFormattedMarkName(type),
+ shortcutKeys: MARK_SHORTCUT_KEYS[type],
+ Icon: markIcons[type],
+ }
+}
diff --git a/components/tiptap-ui/text-align-button/index.tsx b/components/tiptap-ui/text-align-button/index.tsx
new file mode 100644
index 0000000..d19f95c
--- /dev/null
+++ b/components/tiptap-ui/text-align-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./text-align-button"
+export * from "./use-text-align"
diff --git a/components/tiptap-ui/text-align-button/text-align-button.tsx b/components/tiptap-ui/text-align-button/text-align-button.tsx
new file mode 100644
index 0000000..ce9d6a0
--- /dev/null
+++ b/components/tiptap-ui/text-align-button/text-align-button.tsx
@@ -0,0 +1,135 @@
+"use client"
+
+import * as React from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type {
+ TextAlign,
+ UseTextAlignConfig,
+} from "@/components/tiptap-ui/text-align-button"
+import {
+ TEXT_ALIGN_SHORTCUT_KEYS,
+ useTextAlign,
+} from "@/components/tiptap-ui/text-align-button"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+export interface TextAlignButtonProps
+ extends Omit,
+ UseTextAlignConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function TextAlignShortcutBadge({
+ align,
+ shortcutKeys = TEXT_ALIGN_SHORTCUT_KEYS[align],
+}: {
+ align: TextAlign
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for setting text alignment in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useTextAlign` hook instead.
+ */
+export const TextAlignButton = React.forwardRef<
+ HTMLButtonElement,
+ TextAlignButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ align,
+ text,
+ hideWhenUnavailable = false,
+ onAligned,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const {
+ isVisible,
+ handleTextAlign,
+ label,
+ canAlign,
+ isActive,
+ Icon,
+ shortcutKeys,
+ } = useTextAlign({
+ editor,
+ align,
+ hideWhenUnavailable,
+ onAligned,
+ })
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleTextAlign()
+ },
+ [handleTextAlign, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+TextAlignButton.displayName = "TextAlignButton"
diff --git a/components/tiptap-ui/text-align-button/use-text-align.ts b/components/tiptap-ui/text-align-button/use-text-align.ts
new file mode 100644
index 0000000..b32fd20
--- /dev/null
+++ b/components/tiptap-ui/text-align-button/use-text-align.ts
@@ -0,0 +1,240 @@
+"use client"
+
+import * as React from "react"
+import { useHotkeys } from "react-hotkeys-hook"
+import type { ChainedCommands } from "@tiptap/react"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Lib ---
+import {
+ isExtensionAvailable,
+ isNodeTypeSelected,
+} from "@/lib/tiptap-utils"
+
+// --- Icons ---
+import { AlignCenterIcon } from "@/components/tiptap-icons/align-center-icon"
+import { AlignJustifyIcon } from "@/components/tiptap-icons/align-justify-icon"
+import { AlignLeftIcon } from "@/components/tiptap-icons/align-left-icon"
+import { AlignRightIcon } from "@/components/tiptap-icons/align-right-icon"
+
+export type TextAlign = "left" | "center" | "right" | "justify"
+
+/**
+ * Configuration for the text align functionality
+ */
+export interface UseTextAlignConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The text alignment to apply.
+ */
+ align: TextAlign
+ /**
+ * Whether the button should hide when alignment is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful alignment change.
+ */
+ onAligned?: () => void
+}
+
+export const TEXT_ALIGN_SHORTCUT_KEYS: Record = {
+ left: "mod+shift+l",
+ center: "mod+shift+e",
+ right: "mod+shift+r",
+ justify: "mod+shift+j",
+}
+
+export const textAlignIcons = {
+ left: AlignLeftIcon,
+ center: AlignCenterIcon,
+ right: AlignRightIcon,
+ justify: AlignJustifyIcon,
+}
+
+export const textAlignLabels: Record = {
+ left: "Align left",
+ center: "Align center",
+ right: "Align right",
+ justify: "Align justify",
+}
+
+/**
+ * Checks if text alignment can be performed in the current editor state
+ */
+export function canSetTextAlign(
+ editor: Editor | null,
+ align: TextAlign
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (
+ !isExtensionAvailable(editor, "textAlign") ||
+ isNodeTypeSelected(editor, ["image"])
+ )
+ return false
+
+ return editor.can().setTextAlign(align)
+}
+
+export function hasSetTextAlign(
+ commands: ChainedCommands
+): commands is ChainedCommands & {
+ setTextAlign: (align: TextAlign) => ChainedCommands
+} {
+ return "setTextAlign" in commands
+}
+
+/**
+ * Checks if the text alignment is currently active
+ */
+export function isTextAlignActive(
+ editor: Editor | null,
+ align: TextAlign
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ return editor.isActive({ textAlign: align })
+}
+
+/**
+ * Sets text alignment in the editor
+ */
+export function setTextAlign(editor: Editor | null, align: TextAlign): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canSetTextAlign(editor, align)) return false
+
+ const chain = editor.chain().focus()
+ if (hasSetTextAlign(chain)) {
+ return chain.setTextAlign(align).run()
+ }
+
+ return false
+}
+
+/**
+ * Determines if the text align button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+ align: TextAlign
+}): boolean {
+ const { editor, hideWhenUnavailable, align } = props
+
+ if (!editor || !editor.isEditable) return false
+ if (!isExtensionAvailable(editor, "textAlign")) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canSetTextAlign(editor, align)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides text align functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleAlignButton() {
+ * const { isVisible, handleTextAlign } = useTextAlign({ align: "center" })
+ *
+ * if (!isVisible) return null
+ *
+ * return Align Center
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedAlignButton() {
+ * const { isVisible, handleTextAlign, label, isActive } = useTextAlign({
+ * editor: myEditor,
+ * align: "right",
+ * hideWhenUnavailable: true,
+ * onAligned: () => console.log('Text aligned!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Align Right
+ *
+ * )
+ * }
+ * ```
+ */
+export function useTextAlign(config: UseTextAlignConfig) {
+ const {
+ editor: providedEditor,
+ align,
+ hideWhenUnavailable = false,
+ onAligned,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(true)
+ const canAlign = canSetTextAlign(editor, align)
+ const isActive = isTextAlignActive(editor, align)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleSelectionUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, align, hideWhenUnavailable }))
+ }
+
+ handleSelectionUpdate()
+
+ editor.on("selectionUpdate", handleSelectionUpdate)
+
+ return () => {
+ editor.off("selectionUpdate", handleSelectionUpdate)
+ }
+ }, [editor, hideWhenUnavailable, align])
+
+ const handleTextAlign = React.useCallback(() => {
+ if (!editor) return false
+
+ const success = setTextAlign(editor, align)
+ if (success) {
+ onAligned?.()
+ }
+ return success
+ }, [editor, align, onAligned])
+
+ useHotkeys(
+ TEXT_ALIGN_SHORTCUT_KEYS[align],
+ (event) => {
+ event.preventDefault()
+ handleTextAlign()
+ },
+ {
+ enabled: isVisible && canAlign,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ isActive,
+ handleTextAlign,
+ canAlign,
+ label: textAlignLabels[align],
+ shortcutKeys: TEXT_ALIGN_SHORTCUT_KEYS[align],
+ Icon: textAlignIcons[align],
+ }
+}
diff --git a/components/tiptap-ui/undo-redo-button/index.tsx b/components/tiptap-ui/undo-redo-button/index.tsx
new file mode 100644
index 0000000..fa0fdbe
--- /dev/null
+++ b/components/tiptap-ui/undo-redo-button/index.tsx
@@ -0,0 +1,2 @@
+export * from "./undo-redo-button"
+export * from "./use-undo-redo"
diff --git a/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx b/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx
new file mode 100644
index 0000000..9600a55
--- /dev/null
+++ b/components/tiptap-ui/undo-redo-button/undo-redo-button.tsx
@@ -0,0 +1,126 @@
+"use client"
+
+import * as React from "react"
+
+// --- Lib ---
+import { parseShortcutKeys } from "@/lib/tiptap-utils"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+
+// --- Tiptap UI ---
+import type {
+ UndoRedoAction,
+ UseUndoRedoConfig,
+} from "@/components/tiptap-ui/undo-redo-button"
+import {
+ UNDO_REDO_SHORTCUT_KEYS,
+ useUndoRedo,
+} from "@/components/tiptap-ui/undo-redo-button"
+
+// --- UI Primitives ---
+import type { ButtonProps } from "@/components/tiptap-ui-primitive/button"
+import { Button } from "@/components/tiptap-ui-primitive/button"
+import { Badge } from "@/components/tiptap-ui-primitive/badge"
+
+export interface UndoRedoButtonProps
+ extends Omit,
+ UseUndoRedoConfig {
+ /**
+ * Optional text to display alongside the icon.
+ */
+ text?: string
+ /**
+ * Optional show shortcut keys in the button.
+ * @default false
+ */
+ showShortcut?: boolean
+}
+
+export function HistoryShortcutBadge({
+ action,
+ shortcutKeys = UNDO_REDO_SHORTCUT_KEYS[action],
+}: {
+ action: UndoRedoAction
+ shortcutKeys?: string
+}) {
+ return {parseShortcutKeys({ shortcutKeys })}
+}
+
+/**
+ * Button component for triggering undo/redo actions in a Tiptap editor.
+ *
+ * For custom button implementations, use the `useHistory` hook instead.
+ */
+export const UndoRedoButton = React.forwardRef<
+ HTMLButtonElement,
+ UndoRedoButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ action,
+ text,
+ hideWhenUnavailable = false,
+ onExecuted,
+ showShortcut = false,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref
+ ) => {
+ const { editor } = useTiptapEditor(providedEditor)
+ const { isVisible, handleAction, label, canExecute, Icon, shortcutKeys } =
+ useUndoRedo({
+ editor,
+ action,
+ hideWhenUnavailable,
+ onExecuted,
+ })
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ onClick?.(event)
+ if (event.defaultPrevented) return
+ handleAction()
+ },
+ [handleAction, onClick]
+ )
+
+ if (!isVisible) {
+ return null
+ }
+
+ return (
+
+ {children ?? (
+ <>
+
+ {text && {text} }
+ {showShortcut && (
+
+ )}
+ >
+ )}
+
+ )
+ }
+)
+
+UndoRedoButton.displayName = "UndoRedoButton"
diff --git a/components/tiptap-ui/undo-redo-button/use-undo-redo.ts b/components/tiptap-ui/undo-redo-button/use-undo-redo.ts
new file mode 100644
index 0000000..42c3963
--- /dev/null
+++ b/components/tiptap-ui/undo-redo-button/use-undo-redo.ts
@@ -0,0 +1,200 @@
+"use client"
+
+import * as React from "react"
+import { useHotkeys } from "react-hotkeys-hook"
+import { type Editor } from "@tiptap/react"
+
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor"
+import { useIsMobile } from "@/hooks/use-mobile"
+
+// --- Lib ---
+import { isNodeTypeSelected } from "@/lib/tiptap-utils"
+
+// --- Icons ---
+import { Redo2Icon } from "@/components/tiptap-icons/redo2-icon"
+import { Undo2Icon } from "@/components/tiptap-icons/undo2-icon"
+
+export type UndoRedoAction = "undo" | "redo"
+
+/**
+ * Configuration for the history functionality
+ */
+export interface UseUndoRedoConfig {
+ /**
+ * The Tiptap editor instance.
+ */
+ editor?: Editor | null
+ /**
+ * The history action to perform (undo or redo).
+ */
+ action: UndoRedoAction
+ /**
+ * Whether the button should hide when action is not available.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean
+ /**
+ * Callback function called after a successful action execution.
+ */
+ onExecuted?: () => void
+}
+
+export const UNDO_REDO_SHORTCUT_KEYS: Record = {
+ undo: "mod+z",
+ redo: "mod+shift+z",
+}
+
+export const historyActionLabels: Record = {
+ undo: "Undo",
+ redo: "Redo",
+}
+
+export const historyIcons = {
+ undo: Undo2Icon,
+ redo: Redo2Icon,
+}
+
+/**
+ * Checks if a history action can be executed
+ */
+export function canExecuteUndoRedoAction(
+ editor: Editor | null,
+ action: UndoRedoAction
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (isNodeTypeSelected(editor, ["image"])) return false
+
+ return action === "undo" ? editor.can().undo() : editor.can().redo()
+}
+
+/**
+ * Executes a history action on the editor
+ */
+export function executeUndoRedoAction(
+ editor: Editor | null,
+ action: UndoRedoAction
+): boolean {
+ if (!editor || !editor.isEditable) return false
+ if (!canExecuteUndoRedoAction(editor, action)) return false
+
+ const chain = editor.chain().focus()
+ return action === "undo" ? chain.undo().run() : chain.redo().run()
+}
+
+/**
+ * Determines if the history button should be shown
+ */
+export function shouldShowButton(props: {
+ editor: Editor | null
+ hideWhenUnavailable: boolean
+ action: UndoRedoAction
+}): boolean {
+ const { editor, hideWhenUnavailable, action } = props
+
+ if (!editor || !editor.isEditable) return false
+
+ if (hideWhenUnavailable && !editor.isActive("code")) {
+ return canExecuteUndoRedoAction(editor, action)
+ }
+
+ return true
+}
+
+/**
+ * Custom hook that provides history functionality for Tiptap editor
+ *
+ * @example
+ * ```tsx
+ * // Simple usage
+ * function MySimpleUndoButton() {
+ * const { isVisible, handleAction } = useHistory({ action: "undo" })
+ *
+ * if (!isVisible) return null
+ *
+ * return Undo
+ * }
+ *
+ * // Advanced usage with configuration
+ * function MyAdvancedRedoButton() {
+ * const { isVisible, handleAction, label } = useHistory({
+ * editor: myEditor,
+ * action: "redo",
+ * hideWhenUnavailable: true,
+ * onExecuted: () => console.log('Action executed!')
+ * })
+ *
+ * if (!isVisible) return null
+ *
+ * return (
+ *
+ * Redo
+ *
+ * )
+ * }
+ * ```
+ */
+export function useUndoRedo(config: UseUndoRedoConfig) {
+ const {
+ editor: providedEditor,
+ action,
+ hideWhenUnavailable = false,
+ onExecuted,
+ } = config
+
+ const { editor } = useTiptapEditor(providedEditor)
+ const isMobile = useIsMobile()
+ const [isVisible, setIsVisible] = React.useState(true)
+ const canExecute = canExecuteUndoRedoAction(editor, action)
+
+ React.useEffect(() => {
+ if (!editor) return
+
+ const handleUpdate = () => {
+ setIsVisible(shouldShowButton({ editor, hideWhenUnavailable, action }))
+ }
+
+ handleUpdate()
+
+ editor.on("transaction", handleUpdate)
+
+ return () => {
+ editor.off("transaction", handleUpdate)
+ }
+ }, [editor, hideWhenUnavailable, action])
+
+ const handleAction = React.useCallback(() => {
+ if (!editor) return false
+
+ const success = executeUndoRedoAction(editor, action)
+ if (success) {
+ onExecuted?.()
+ }
+ return success
+ }, [editor, action, onExecuted])
+
+ useHotkeys(
+ UNDO_REDO_SHORTCUT_KEYS[action],
+ (event) => {
+ event.preventDefault()
+ handleAction()
+ },
+ {
+ enabled: isVisible && canExecute,
+ enableOnContentEditable: !isMobile,
+ enableOnFormTags: true,
+ }
+ )
+
+ return {
+ isVisible,
+ handleAction,
+ canExecute,
+ label: historyActionLabels[action],
+ shortcutKeys: UNDO_REDO_SHORTCUT_KEYS[action],
+ Icon: historyIcons[action],
+ }
+}
diff --git a/components/toc-toggle.tsx b/components/toc-toggle.tsx
new file mode 100644
index 0000000..43f4807
--- /dev/null
+++ b/components/toc-toggle.tsx
@@ -0,0 +1,66 @@
+"use client";
+import { cn } from '@/lib/utils';
+
+interface TOCToggleProps {
+ onToggle: (visible: boolean) => void;
+ isVisible: boolean;
+}
+
+export default function TOCToggle({ onToggle, isVisible }: TOCToggleProps) {
+ return (
+ onToggle(!isVisible)}
+ className={cn(
+ "fixed left-4 top-20 z-50 group transition-all duration-300 ease-out",
+ "bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700",
+ "rounded-xl shadow-xl backdrop-blur-sm bg-opacity-95 dark:bg-opacity-95",
+ "hover:shadow-2xl hover:scale-105 active:scale-95",
+ "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50 focus:ring-offset-2",
+ "hidden xl:flex items-center justify-center w-12 h-12",
+ {
+ "translate-x-0": isVisible,
+ "-translate-x-2": !isVisible
+ }
+ )}
+ title={isVisible ? "隐藏目录" : "显示目录"}
+ >
+
+ {/* 目录图标 */}
+
+ {isVisible ? (
+ /* X 图标 - 关闭 */
+
+ ) : (
+ /* 菜单图标 - 打开 */
+
+ )}
+
+
+ {/* 悬停提示点 */}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/components/ui/radio-group.tsx b/components/ui/radio-group.tsx
new file mode 100644
index 0000000..5e6778c
--- /dev/null
+++ b/components/ui/radio-group.tsx
@@ -0,0 +1,45 @@
+"use client"
+
+import * as React from "react"
+import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
+import { CircleIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function RadioGroup({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function RadioGroupItem({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+ )
+}
+
+export { RadioGroup, RadioGroupItem }
diff --git a/components/ui/scroll-area.tsx b/components/ui/scroll-area.tsx
new file mode 100644
index 0000000..8e4fa13
--- /dev/null
+++ b/components/ui/scroll-area.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
+
+import { cn } from "@/lib/utils"
+
+function ScrollArea({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function ScrollBar({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export { ScrollArea, ScrollBar }
diff --git a/hooks/use-composed-ref.ts b/hooks/use-composed-ref.ts
new file mode 100644
index 0000000..98bab6d
--- /dev/null
+++ b/hooks/use-composed-ref.ts
@@ -0,0 +1,47 @@
+"use client"
+
+import * as React from "react"
+
+// basically Exclude["ref"], string>
+type UserRef =
+ | ((instance: T | null) => void)
+ | React.RefObject
+ | null
+ | undefined
+
+const updateRef = (ref: NonNullable>, value: T | null) => {
+ if (typeof ref === "function") {
+ ref(value)
+ } else if (ref && typeof ref === "object" && "current" in ref) {
+ // Safe assignment without MutableRefObject
+ ;(ref as { current: T | null }).current = value
+ }
+}
+
+export const useComposedRef = (
+ libRef: React.RefObject,
+ userRef: UserRef
+) => {
+ const prevUserRef = React.useRef>(null)
+
+ return React.useCallback(
+ (instance: T | null) => {
+ if (libRef && "current" in libRef) {
+ ;(libRef as { current: T | null }).current = instance
+ }
+
+ if (prevUserRef.current) {
+ updateRef(prevUserRef.current, null)
+ }
+
+ prevUserRef.current = userRef
+
+ if (userRef) {
+ updateRef(userRef, instance)
+ }
+ },
+ [libRef, userRef]
+ )
+}
+
+export default useComposedRef
diff --git a/hooks/use-cursor-visibility.ts b/hooks/use-cursor-visibility.ts
new file mode 100644
index 0000000..c99296b
--- /dev/null
+++ b/hooks/use-cursor-visibility.ts
@@ -0,0 +1,71 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+import { useWindowSize } from "@/hooks/use-window-size"
+import { useBodyRect } from "./use-element-rect"
+
+export interface CursorVisibilityOptions {
+ /**
+ * The Tiptap editor instance
+ */
+ editor?: Editor | null
+ /**
+ * Reference to the toolbar element that may obscure the cursor
+ */
+ overlayHeight?: number
+}
+
+/**
+ * Custom hook that ensures the cursor remains visible when typing in a Tiptap editor.
+ * Automatically scrolls the window when the cursor would be hidden by the toolbar.
+ *
+ * @param options.editor The Tiptap editor instance
+ * @param options.overlayHeight Toolbar height to account for
+ * @returns The bounding rect of the body
+ */
+export function useCursorVisibility({
+ editor,
+ overlayHeight = 0,
+}: CursorVisibilityOptions) {
+ const { height: windowHeight } = useWindowSize()
+ const rect = useBodyRect({
+ enabled: true,
+ throttleMs: 100,
+ useResizeObserver: true,
+ })
+
+ React.useEffect(() => {
+ const ensureCursorVisibility = () => {
+ if (!editor) return
+
+ const { state, view } = editor
+ if (!view.hasFocus()) return
+
+ // Get current cursor position coordinates
+ const { from } = state.selection
+ const cursorCoords = view.coordsAtPos(from)
+
+ if (windowHeight < rect.height && cursorCoords) {
+ const availableSpace = windowHeight - cursorCoords.top
+
+ // If the cursor is hidden behind the overlay or offscreen, scroll it into view
+ if (availableSpace < overlayHeight) {
+ const targetCursorY = Math.max(windowHeight / 2, overlayHeight)
+ const currentScrollY = window.scrollY
+ const cursorAbsoluteY = cursorCoords.top + currentScrollY
+ const newScrollY = cursorAbsoluteY - targetCursorY
+
+ window.scrollTo({
+ top: Math.max(0, newScrollY),
+ behavior: "smooth",
+ })
+ }
+ }
+ }
+
+ ensureCursorVisibility()
+ }, [editor, overlayHeight, windowHeight, rect.height])
+
+ return rect
+}
diff --git a/hooks/use-element-rect.ts b/hooks/use-element-rect.ts
new file mode 100644
index 0000000..8a7a552
--- /dev/null
+++ b/hooks/use-element-rect.ts
@@ -0,0 +1,166 @@
+"use client"
+
+import * as React from "react"
+import { useThrottledCallback } from "./use-throttled-callback"
+
+export type RectState = Omit
+
+export interface ElementRectOptions {
+ /**
+ * The element to track. Can be an Element, ref, or selector string.
+ * Defaults to document.body if not provided.
+ */
+ element?: Element | React.RefObject | string | null
+ /**
+ * Whether to enable rect tracking
+ */
+ enabled?: boolean
+ /**
+ * Throttle delay in milliseconds for rect updates
+ */
+ throttleMs?: number
+ /**
+ * Whether to use ResizeObserver for more accurate tracking
+ */
+ useResizeObserver?: boolean
+}
+
+const initialRect: RectState = {
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+ left: 0,
+}
+
+const isSSR = typeof window === "undefined"
+const hasResizeObserver = !isSSR && typeof ResizeObserver !== "undefined"
+
+/**
+ * Helper function to check if code is running on client side
+ */
+const isClientSide = (): boolean => !isSSR
+
+/**
+ * Custom hook that tracks an element's bounding rectangle and updates on resize, scroll, etc.
+ *
+ * @param options Configuration options for element rect tracking
+ * @returns The current bounding rectangle of the element
+ */
+export function useElementRect({
+ element,
+ enabled = true,
+ throttleMs = 100,
+ useResizeObserver = true,
+}: ElementRectOptions = {}): RectState {
+ const [rect, setRect] = React.useState(initialRect)
+
+ const getTargetElement = React.useCallback((): Element | null => {
+ if (!enabled || !isClientSide()) return null
+
+ if (!element) {
+ return document.body
+ }
+
+ if (typeof element === "string") {
+ return document.querySelector(element)
+ }
+
+ if ("current" in element) {
+ return element.current
+ }
+
+ return element
+ }, [element, enabled])
+
+ const updateRect = useThrottledCallback(
+ () => {
+ if (!enabled || !isClientSide()) return
+
+ const targetElement = getTargetElement()
+ if (!targetElement) {
+ setRect(initialRect)
+ return
+ }
+
+ const newRect = targetElement.getBoundingClientRect()
+ setRect({
+ x: newRect.x,
+ y: newRect.y,
+ width: newRect.width,
+ height: newRect.height,
+ top: newRect.top,
+ right: newRect.right,
+ bottom: newRect.bottom,
+ left: newRect.left,
+ })
+ },
+ throttleMs,
+ [enabled, getTargetElement],
+ { leading: true, trailing: true }
+ )
+
+ React.useEffect(() => {
+ if (!enabled || !isClientSide()) {
+ setRect(initialRect)
+ return
+ }
+
+ const targetElement = getTargetElement()
+ if (!targetElement) return
+
+ updateRect()
+
+ const cleanup: (() => void)[] = []
+
+ if (useResizeObserver && hasResizeObserver) {
+ const resizeObserver = new ResizeObserver(() => {
+ window.requestAnimationFrame(updateRect)
+ })
+ resizeObserver.observe(targetElement)
+ cleanup.push(() => resizeObserver.disconnect())
+ }
+
+ const handleUpdate = () => updateRect()
+
+ window.addEventListener("scroll", handleUpdate, { passive: true })
+ window.addEventListener("resize", handleUpdate, { passive: true })
+
+ cleanup.push(() => {
+ window.removeEventListener("scroll", handleUpdate)
+ window.removeEventListener("resize", handleUpdate)
+ })
+
+ return () => {
+ cleanup.forEach((fn) => fn())
+ setRect(initialRect)
+ }
+ }, [enabled, getTargetElement, updateRect, useResizeObserver])
+
+ return rect
+}
+
+/**
+ * Convenience hook for tracking document.body rect
+ */
+export function useBodyRect(
+ options: Omit = {}
+): RectState {
+ return useElementRect({
+ ...options,
+ element: isClientSide() ? document.body : null,
+ })
+}
+
+/**
+ * Convenience hook for tracking a ref element's rect
+ */
+export function useRefRect(
+ ref: React.RefObject,
+ options: Omit = {}
+): RectState {
+ return useElementRect({ ...options, element: ref })
+}
diff --git a/hooks/use-menu-navigation.ts b/hooks/use-menu-navigation.ts
new file mode 100644
index 0000000..554bda1
--- /dev/null
+++ b/hooks/use-menu-navigation.ts
@@ -0,0 +1,196 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+
+type Orientation = "horizontal" | "vertical" | "both"
+
+interface MenuNavigationOptions {
+ /**
+ * The Tiptap editor instance, if using with a Tiptap editor.
+ */
+ editor?: Editor | null
+ /**
+ * Reference to the container element for handling keyboard events.
+ */
+ containerRef?: React.RefObject
+ /**
+ * Search query that affects the selected item.
+ */
+ query?: string
+ /**
+ * Array of items to navigate through.
+ */
+ items: T[]
+ /**
+ * Callback fired when an item is selected.
+ */
+ onSelect?: (item: T) => void
+ /**
+ * Callback fired when the menu should close.
+ */
+ onClose?: () => void
+ /**
+ * The navigation orientation of the menu.
+ * @default "vertical"
+ */
+ orientation?: Orientation
+ /**
+ * Whether to automatically select the first item when the menu opens.
+ * @default true
+ */
+ autoSelectFirstItem?: boolean
+}
+
+/**
+ * Hook that implements keyboard navigation for dropdown menus and command palettes.
+ *
+ * Handles arrow keys, tab, home/end, enter for selection, and escape to close.
+ * Works with both Tiptap editors and regular DOM elements.
+ *
+ * @param options - Configuration options for the menu navigation
+ * @returns Object containing the selected index and a setter function
+ */
+export function useMenuNavigation({
+ editor,
+ containerRef,
+ query,
+ items,
+ onSelect,
+ onClose,
+ orientation = "vertical",
+ autoSelectFirstItem = true,
+}: MenuNavigationOptions) {
+ const [selectedIndex, setSelectedIndex] = React.useState(
+ autoSelectFirstItem ? 0 : -1
+ )
+
+ React.useEffect(() => {
+ const handleKeyboardNavigation = (event: KeyboardEvent) => {
+ if (!items.length) return false
+
+ const moveNext = () =>
+ setSelectedIndex((currentIndex) => {
+ if (currentIndex === -1) return 0
+ return (currentIndex + 1) % items.length
+ })
+
+ const movePrev = () =>
+ setSelectedIndex((currentIndex) => {
+ if (currentIndex === -1) return items.length - 1
+ return (currentIndex - 1 + items.length) % items.length
+ })
+
+ switch (event.key) {
+ case "ArrowUp": {
+ if (orientation === "horizontal") return false
+ event.preventDefault()
+ movePrev()
+ return true
+ }
+
+ case "ArrowDown": {
+ if (orientation === "horizontal") return false
+ event.preventDefault()
+ moveNext()
+ return true
+ }
+
+ case "ArrowLeft": {
+ if (orientation === "vertical") return false
+ event.preventDefault()
+ movePrev()
+ return true
+ }
+
+ case "ArrowRight": {
+ if (orientation === "vertical") return false
+ event.preventDefault()
+ moveNext()
+ return true
+ }
+
+ case "Tab": {
+ event.preventDefault()
+ if (event.shiftKey) {
+ movePrev()
+ } else {
+ moveNext()
+ }
+ return true
+ }
+
+ case "Home": {
+ event.preventDefault()
+ setSelectedIndex(0)
+ return true
+ }
+
+ case "End": {
+ event.preventDefault()
+ setSelectedIndex(items.length - 1)
+ return true
+ }
+
+ case "Enter": {
+ if (event.isComposing) return false
+ event.preventDefault()
+ if (selectedIndex !== -1 && items[selectedIndex]) {
+ onSelect?.(items[selectedIndex])
+ }
+ return true
+ }
+
+ case "Escape": {
+ event.preventDefault()
+ onClose?.()
+ return true
+ }
+
+ default:
+ return false
+ }
+ }
+
+ let targetElement: HTMLElement | null = null
+
+ if (editor) {
+ targetElement = editor.view.dom
+ } else if (containerRef?.current) {
+ targetElement = containerRef.current
+ }
+
+ if (targetElement) {
+ targetElement.addEventListener("keydown", handleKeyboardNavigation, true)
+
+ return () => {
+ targetElement?.removeEventListener(
+ "keydown",
+ handleKeyboardNavigation,
+ true
+ )
+ }
+ }
+
+ return undefined
+ }, [
+ editor,
+ containerRef,
+ items,
+ selectedIndex,
+ onSelect,
+ onClose,
+ orientation,
+ ])
+
+ React.useEffect(() => {
+ if (query) {
+ setSelectedIndex(autoSelectFirstItem ? 0 : -1)
+ }
+ }, [query, autoSelectFirstItem])
+
+ return {
+ selectedIndex: items.length ? selectedIndex : undefined,
+ setSelectedIndex,
+ }
+}
diff --git a/hooks/use-mobile.ts b/hooks/use-mobile.ts
index 2b0fe1d..ef6b0e3 100644
--- a/hooks/use-mobile.ts
+++ b/hooks/use-mobile.ts
@@ -1,19 +1,19 @@
+"use client"
+
import * as React from "react"
-const MOBILE_BREAKPOINT = 768
-
-export function useIsMobile() {
+export function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
+ const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
const onChange = () => {
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ setIsMobile(window.innerWidth < breakpoint)
}
mql.addEventListener("change", onChange)
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
+ setIsMobile(window.innerWidth < breakpoint)
return () => mql.removeEventListener("change", onChange)
- }, [])
+ }, [breakpoint])
return !!isMobile
}
diff --git a/hooks/use-scrolling.ts b/hooks/use-scrolling.ts
new file mode 100644
index 0000000..3ba2a40
--- /dev/null
+++ b/hooks/use-scrolling.ts
@@ -0,0 +1,75 @@
+import type { RefObject } from "react"
+import { useEffect, useState } from "react"
+
+type ScrollTarget = RefObject | Window | null | undefined
+type EventTargetWithScroll = Window | HTMLElement | Document
+
+interface UseScrollingOptions {
+ debounce?: number
+ fallbackToDocument?: boolean
+}
+
+export function useScrolling(
+ target?: ScrollTarget,
+ options: UseScrollingOptions = {}
+): boolean {
+ const { debounce = 150, fallbackToDocument = true } = options
+ const [isScrolling, setIsScrolling] = useState(false)
+
+ useEffect(() => {
+ // Resolve element or window
+ const element: EventTargetWithScroll =
+ target && typeof Window !== "undefined" && target instanceof Window
+ ? target
+ : ((target as RefObject)?.current ?? window)
+
+ // Mobile: fallback to document when using window
+ const eventTarget: EventTargetWithScroll =
+ fallbackToDocument &&
+ element === window &&
+ typeof document !== "undefined"
+ ? document
+ : element
+
+ const on = (
+ el: EventTargetWithScroll,
+ event: string,
+ handler: EventListener
+ ) => el.addEventListener(event, handler, { passive: true })
+
+ const off = (
+ el: EventTargetWithScroll,
+ event: string,
+ handler: EventListener
+ ) => el.removeEventListener(event, handler)
+
+ let timeout: ReturnType
+ const supportsScrollEnd = element === window && "onscrollend" in window
+
+ const handleScroll: EventListener = () => {
+ if (!isScrolling) setIsScrolling(true)
+
+ if (!supportsScrollEnd) {
+ clearTimeout(timeout)
+ timeout = setTimeout(() => setIsScrolling(false), debounce)
+ }
+ }
+
+ const handleScrollEnd: EventListener = () => setIsScrolling(false)
+
+ on(eventTarget, "scroll", handleScroll)
+ if (supportsScrollEnd) {
+ on(eventTarget, "scrollend", handleScrollEnd)
+ }
+
+ return () => {
+ off(eventTarget, "scroll", handleScroll)
+ if (supportsScrollEnd) {
+ off(eventTarget, "scrollend", handleScrollEnd)
+ }
+ clearTimeout(timeout)
+ }
+ }, [target, debounce, fallbackToDocument, isScrolling])
+
+ return isScrolling
+}
diff --git a/hooks/use-throttled-callback.ts b/hooks/use-throttled-callback.ts
new file mode 100644
index 0000000..d291c6c
--- /dev/null
+++ b/hooks/use-throttled-callback.ts
@@ -0,0 +1,47 @@
+import throttle from "lodash.throttle"
+import * as React from "react"
+import { useUnmount } from "./use-unmount"
+
+interface ThrottleSettings {
+ leading?: boolean | undefined
+ trailing?: boolean | undefined
+}
+
+const defaultOptions: ThrottleSettings = {
+ leading: false,
+ trailing: true,
+}
+
+/**
+ * A hook that returns a throttled callback function.
+ *
+ * @param fn The function to throttle
+ * @param wait The time in ms to wait before calling the function
+ * @param dependencies The dependencies to watch for changes
+ * @param options The throttle options
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export function useThrottledCallback any>(
+ fn: T,
+ wait = 250,
+ dependencies: React.DependencyList = [],
+ options: ThrottleSettings = defaultOptions
+): {
+ (this: ThisParameterType, ...args: Parameters): ReturnType
+ cancel: () => void
+ flush: () => void
+} {
+ const handler = React.useMemo(
+ () => throttle(fn, wait, options),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ dependencies
+ )
+
+ useUnmount(() => {
+ handler.cancel()
+ })
+
+ return handler
+}
+
+export default useThrottledCallback
diff --git a/hooks/use-tiptap-editor.ts b/hooks/use-tiptap-editor.ts
new file mode 100644
index 0000000..4983ea2
--- /dev/null
+++ b/hooks/use-tiptap-editor.ts
@@ -0,0 +1,49 @@
+"use client"
+
+import * as React from "react"
+import type { Editor } from "@tiptap/react"
+import { useCurrentEditor, useEditorState } from "@tiptap/react"
+
+/**
+ * Hook that provides access to a Tiptap editor instance.
+ *
+ * Accepts an optional editor instance directly, or falls back to retrieving
+ * the editor from the Tiptap context if available. This allows components
+ * to work both when given an editor directly and when used within a Tiptap
+ * editor context.
+ *
+ * @param providedEditor - Optional editor instance to use instead of the context editor
+ * @returns The provided editor or the editor from context, whichever is available
+ */
+export function useTiptapEditor(providedEditor?: Editor | null): {
+ editor: Editor | null
+ editorState?: Editor["state"]
+ canCommand?: Editor["can"]
+} {
+ const { editor: coreEditor } = useCurrentEditor()
+ const mainEditor = React.useMemo(
+ () => providedEditor || coreEditor,
+ [providedEditor, coreEditor]
+ )
+
+ const editorState = useEditorState({
+ editor: mainEditor,
+ selector(context) {
+ if (!context.editor) {
+ return {
+ editor: null,
+ editorState: undefined,
+ canCommand: undefined,
+ }
+ }
+
+ return {
+ editor: context.editor,
+ editorState: context.editor.state,
+ canCommand: context.editor.can,
+ }
+ },
+ })
+
+ return editorState || { editor: null }
+}
diff --git a/hooks/use-unmount.ts b/hooks/use-unmount.ts
new file mode 100644
index 0000000..91a22e7
--- /dev/null
+++ b/hooks/use-unmount.ts
@@ -0,0 +1,21 @@
+import { useRef, useEffect } from "react"
+
+/**
+ * Hook that executes a callback when the component unmounts.
+ *
+ * @param callback Function to be called on component unmount
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const useUnmount = (callback: (...args: Array) => any) => {
+ const ref = useRef(callback)
+ ref.current = callback
+
+ useEffect(
+ () => () => {
+ ref.current()
+ },
+ []
+ )
+}
+
+export default useUnmount
diff --git a/hooks/use-window-size.ts b/hooks/use-window-size.ts
new file mode 100644
index 0000000..e758b94
--- /dev/null
+++ b/hooks/use-window-size.ts
@@ -0,0 +1,93 @@
+"use client"
+
+import * as React from "react"
+import { useThrottledCallback } from "./use-throttled-callback"
+
+export interface WindowSizeState {
+ /**
+ * The width of the window's visual viewport in pixels.
+ */
+ width: number
+ /**
+ * The height of the window's visual viewport in pixels.
+ */
+ height: number
+ /**
+ * The distance from the top of the visual viewport to the top of the layout viewport.
+ * Particularly useful for handling mobile keyboard appearance.
+ */
+ offsetTop: number
+ /**
+ * The distance from the left of the visual viewport to the left of the layout viewport.
+ */
+ offsetLeft: number
+ /**
+ * The scale factor of the visual viewport.
+ * This is useful for scaling elements based on the current zoom level.
+ */
+ scale: number
+}
+
+/**
+ * Hook that tracks the window's visual viewport dimensions, position, and provides
+ * a CSS transform for positioning elements.
+ *
+ * Uses the Visual Viewport API to get accurate measurements, especially important
+ * for mobile devices where virtual keyboards can change the visible area.
+ * Only updates state when values actually change to optimize performance.
+ *
+ * @returns An object containing viewport properties and a CSS transform string
+ */
+export function useWindowSize(): WindowSizeState {
+ const [windowSize, setWindowSize] = React.useState({
+ width: 0,
+ height: 0,
+ offsetTop: 0,
+ offsetLeft: 0,
+ scale: 0,
+ })
+
+ const handleViewportChange = useThrottledCallback(() => {
+ if (typeof window === "undefined") return
+
+ const vp = window.visualViewport
+ if (!vp) return
+
+ const {
+ width = 0,
+ height = 0,
+ offsetTop = 0,
+ offsetLeft = 0,
+ scale = 0,
+ } = vp
+
+ setWindowSize((prevState) => {
+ if (
+ width === prevState.width &&
+ height === prevState.height &&
+ offsetTop === prevState.offsetTop &&
+ offsetLeft === prevState.offsetLeft &&
+ scale === prevState.scale
+ ) {
+ return prevState
+ }
+
+ return { width, height, offsetTop, offsetLeft, scale }
+ })
+ }, 200)
+
+ React.useEffect(() => {
+ const visualViewport = window.visualViewport
+ if (!visualViewport) return
+
+ visualViewport.addEventListener("resize", handleViewportChange)
+
+ handleViewportChange()
+
+ return () => {
+ visualViewport.removeEventListener("resize", handleViewportChange)
+ }
+ }, [handleViewportChange])
+
+ return windowSize
+}
diff --git a/lib/tiptap-utils.ts b/lib/tiptap-utils.ts
new file mode 100644
index 0000000..14a2c3a
--- /dev/null
+++ b/lib/tiptap-utils.ts
@@ -0,0 +1,356 @@
+import type { Node as TiptapNode } from "@tiptap/pm/model"
+import { NodeSelection } from "@tiptap/pm/state"
+import type { Editor } from "@tiptap/react"
+
+export const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
+
+export const MAC_SYMBOLS: Record = {
+ mod: "⌘",
+ ctrl: "⌘",
+ alt: "⌥",
+ shift: "⇧",
+ backspace: "Del",
+} as const
+
+export function cn(
+ ...classes: (string | boolean | undefined | null)[]
+): string {
+ return classes.filter(Boolean).join(" ")
+}
+
+/**
+ * Determines if the current platform is macOS
+ * @returns boolean indicating if the current platform is Mac
+ */
+export function isMac(): boolean {
+ return (
+ typeof navigator !== "undefined" &&
+ navigator.platform.toLowerCase().includes("mac")
+ )
+}
+
+/**
+ * Formats a shortcut key based on the platform (Mac or non-Mac)
+ * @param key - The key to format (e.g., "ctrl", "alt", "shift")
+ * @param isMac - Boolean indicating if the platform is Mac
+ * @param capitalize - Whether to capitalize the key (default: true)
+ * @returns Formatted shortcut key symbol
+ */
+export const formatShortcutKey = (
+ key: string,
+ isMac: boolean,
+ capitalize: boolean = true
+) => {
+ if (isMac) {
+ const lowerKey = key.toLowerCase()
+ return MAC_SYMBOLS[lowerKey] || (capitalize ? key.toUpperCase() : key)
+ }
+
+ return capitalize ? key.charAt(0).toUpperCase() + key.slice(1) : key
+}
+
+/**
+ * Parses a shortcut key string into an array of formatted key symbols
+ * @param shortcutKeys - The string of shortcut keys (e.g., "ctrl-alt-shift")
+ * @param delimiter - The delimiter used to split the keys (default: "-")
+ * @param capitalize - Whether to capitalize the keys (default: true)
+ * @returns Array of formatted shortcut key symbols
+ */
+export const parseShortcutKeys = (props: {
+ shortcutKeys: string | undefined
+ delimiter?: string
+ capitalize?: boolean
+}) => {
+ const { shortcutKeys, delimiter = "+", capitalize = true } = props
+
+ if (!shortcutKeys) return []
+
+ return shortcutKeys
+ .split(delimiter)
+ .map((key) => key.trim())
+ .map((key) => formatShortcutKey(key, isMac(), capitalize))
+}
+
+/**
+ * Checks if a mark exists in the editor schema
+ * @param markName - The name of the mark to check
+ * @param editor - The editor instance
+ * @returns boolean indicating if the mark exists in the schema
+ */
+export const isMarkInSchema = (
+ markName: string,
+ editor: Editor | null
+): boolean => {
+ if (!editor?.schema) return false
+ return editor.schema.spec.marks.get(markName) !== undefined
+}
+
+/**
+ * Checks if a node exists in the editor schema
+ * @param nodeName - The name of the node to check
+ * @param editor - The editor instance
+ * @returns boolean indicating if the node exists in the schema
+ */
+export const isNodeInSchema = (
+ nodeName: string,
+ editor: Editor | null
+): boolean => {
+ if (!editor?.schema) return false
+ return editor.schema.spec.nodes.get(nodeName) !== undefined
+}
+
+/**
+ * Checks if a value is a valid number (not null, undefined, or NaN)
+ * @param value - The value to check
+ * @returns boolean indicating if the value is a valid number
+ */
+export function isValidPosition(pos: number | null | undefined): pos is number {
+ return typeof pos === "number" && pos >= 0
+}
+
+/**
+ * Checks if one or more extensions are registered in the Tiptap editor.
+ * @param editor - The Tiptap editor instance
+ * @param extensionNames - A single extension name or an array of names to check
+ * @returns True if at least one of the extensions is available, false otherwise
+ */
+export function isExtensionAvailable(
+ editor: Editor | null,
+ extensionNames: string | string[]
+): boolean {
+ if (!editor) return false
+
+ const names = Array.isArray(extensionNames)
+ ? extensionNames
+ : [extensionNames]
+
+ const found = names.some((name) =>
+ editor.extensionManager.extensions.some((ext) => ext.name === name)
+ )
+
+ if (!found) {
+ console.warn(
+ `None of the extensions [${names.join(", ")}] were found in the editor schema. Ensure they are included in the editor configuration.`
+ )
+ }
+
+ return found
+}
+
+/**
+ * Finds a node at the specified position with error handling
+ * @param editor The Tiptap editor instance
+ * @param position The position in the document to find the node
+ * @returns The node at the specified position, or null if not found
+ */
+export function findNodeAtPosition(editor: Editor, position: number) {
+ try {
+ const node = editor.state.doc.nodeAt(position)
+ if (!node) {
+ console.warn(`No node found at position ${position}`)
+ return null
+ }
+ return node
+ } catch (error) {
+ console.error(`Error getting node at position ${position}:`, error)
+ return null
+ }
+}
+
+/**
+ * Finds the position and instance of a node in the document
+ * @param props Object containing editor, node (optional), and nodePos (optional)
+ * @param props.editor The Tiptap editor instance
+ * @param props.node The node to find (optional if nodePos is provided)
+ * @param props.nodePos The position of the node to find (optional if node is provided)
+ * @returns An object with the position and node, or null if not found
+ */
+export function findNodePosition(props: {
+ editor: Editor | null
+ node?: TiptapNode | null
+ nodePos?: number | null
+}): { pos: number; node: TiptapNode } | null {
+ const { editor, node, nodePos } = props
+
+ if (!editor || !editor.state?.doc) return null
+
+ // Zero is valid position
+ const hasValidNode = node !== undefined && node !== null
+ const hasValidPos = isValidPosition(nodePos)
+
+ if (!hasValidNode && !hasValidPos) {
+ return null
+ }
+
+ // First search for the node in the document if we have a node
+ if (hasValidNode) {
+ let foundPos = -1
+ let foundNode: TiptapNode | null = null
+
+ editor.state.doc.descendants((currentNode, pos) => {
+ // TODO: Needed?
+ // if (currentNode.type && currentNode.type.name === node!.type.name) {
+ if (currentNode === node) {
+ foundPos = pos
+ foundNode = currentNode
+ return false
+ }
+ return true
+ })
+
+ if (foundPos !== -1 && foundNode !== null) {
+ return { pos: foundPos, node: foundNode }
+ }
+ }
+
+ // If we have a valid position, use findNodeAtPosition
+ if (hasValidPos) {
+ const nodeAtPos = findNodeAtPosition(editor, nodePos!)
+ if (nodeAtPos) {
+ return { pos: nodePos!, node: nodeAtPos }
+ }
+ }
+
+ return null
+}
+
+/**
+ * Checks if the current selection in the editor is a node selection of specified types
+ * @param editor The Tiptap editor instance
+ * @param types An array of node type names to check against
+ * @returns boolean indicating if the selected node matches any of the specified types
+ */
+export function isNodeTypeSelected(
+ editor: Editor,
+ types: string[] = []
+): boolean {
+ if (!editor || !editor.state.selection) return false
+
+ const { state } = editor
+ const { doc, selection } = state
+
+ if (selection.empty) return false
+
+ if (selection instanceof NodeSelection) {
+ const node = doc.nodeAt(selection.from)
+ return node ? types.includes(node.type.name) : false
+ }
+
+ return false
+}
+
+/**
+ * Handles image upload with progress tracking and abort capability
+ * @param file The file to upload
+ * @param onProgress Optional callback for tracking upload progress
+ * @param abortSignal Optional AbortSignal for cancelling the upload
+ * @returns Promise resolving to the URL of the uploaded image
+ */
+export const handleImageUpload = async (
+ file: File,
+ onProgress?: (event: { progress: number }) => void,
+ abortSignal?: AbortSignal
+): Promise => {
+ // Validate file
+ if (!file) {
+ throw new Error("No file provided")
+ }
+
+ if (file.size > MAX_FILE_SIZE) {
+ throw new Error(
+ `File size exceeds maximum allowed (${MAX_FILE_SIZE / (1024 * 1024)}MB)`
+ )
+ }
+
+ // For demo/testing: Simulate upload progress. In production, replace the following code
+ // with your own upload implementation.
+ for (let progress = 0; progress <= 100; progress += 10) {
+ if (abortSignal?.aborted) {
+ throw new Error("Upload cancelled")
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500))
+ onProgress?.({ progress })
+ }
+
+ return "/images/tiptap-ui-placeholder-image.jpg"
+}
+
+type ProtocolOptions = {
+ /**
+ * The protocol scheme to be registered.
+ * @default '''
+ * @example 'ftp'
+ * @example 'git'
+ */
+ scheme: string
+
+ /**
+ * If enabled, it allows optional slashes after the protocol.
+ * @default false
+ * @example true
+ */
+ optionalSlashes?: boolean
+}
+
+type ProtocolConfig = Array
+
+const ATTR_WHITESPACE =
+ // eslint-disable-next-line no-control-regex
+ /[\u0000-\u0020\u00A0\u1680\u180E\u2000-\u2029\u205F\u3000]/g
+
+export function isAllowedUri(
+ uri: string | undefined,
+ protocols?: ProtocolConfig
+) {
+ const allowedProtocols: string[] = [
+ "http",
+ "https",
+ "ftp",
+ "ftps",
+ "mailto",
+ "tel",
+ "callto",
+ "sms",
+ "cid",
+ "xmpp",
+ ]
+
+ if (protocols) {
+ protocols.forEach((protocol) => {
+ const nextProtocol =
+ typeof protocol === "string" ? protocol : protocol.scheme
+
+ if (nextProtocol) {
+ allowedProtocols.push(nextProtocol)
+ }
+ })
+ }
+
+ return (
+ !uri ||
+ uri.replace(ATTR_WHITESPACE, "").match(
+ new RegExp(
+ // eslint-disable-next-line no-useless-escape
+ `^(?:(?:${allowedProtocols.join("|")}):|[^a-z]|[a-z0-9+.\-]+(?:[^a-z+.\-:]|$))`,
+ "i"
+ )
+ )
+ )
+}
+
+export function sanitizeUrl(
+ inputUrl: string,
+ baseUrl: string,
+ protocols?: ProtocolConfig
+): string {
+ try {
+ const url = new URL(inputUrl, baseUrl)
+
+ if (isAllowedUri(url.href, protocols)) {
+ return url.href
+ }
+ } catch {
+ // If URL creation fails, it's considered invalid
+ }
+ return "#"
+}
diff --git a/package-lock.json b/package-lock.json
index a4d77ab..4ab3b71 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
+ "@floating-ui/react": "^0.27.15",
"@hookform/resolvers": "^5.2.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
@@ -24,6 +25,8 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
@@ -35,6 +38,18 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-table": "^8.21.3",
+ "@tiptap/extension-highlight": "^3.1.0",
+ "@tiptap/extension-horizontal-rule": "^3.1.0",
+ "@tiptap/extension-image": "^3.1.0",
+ "@tiptap/extension-list": "^3.1.0",
+ "@tiptap/extension-subscript": "^3.1.0",
+ "@tiptap/extension-superscript": "^3.1.0",
+ "@tiptap/extension-text-align": "^3.1.0",
+ "@tiptap/extension-typography": "^3.1.0",
+ "@tiptap/extensions": "^3.1.0",
+ "@tiptap/pm": "^3.1.0",
+ "@tiptap/react": "^3.1.0",
+ "@tiptap/starter-kit": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -44,6 +59,7 @@
"graphql": "^16.11.0",
"graphql-request": "^7.2.0",
"graphql-ws": "^6.0.6",
+ "lodash.throttle": "^4.1.1",
"lucide-react": "^0.525.0",
"maplibre-gl": "^5.6.1",
"next": "15.4.1",
@@ -54,6 +70,7 @@
"react-day-picker": "^9.8.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
+ "react-hotkeys-hook": "^5.1.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"rehype-katex": "^7.0.1",
@@ -69,10 +86,12 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/lodash.throttle": "^4.1.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"raw-loader": "^4.0.2",
+ "sass": "^1.90.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
@@ -287,28 +306,46 @@
}
},
"node_modules/@floating-ui/core": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
- "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+ "license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.10"
}
},
"node_modules/@floating-ui/dom": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
- "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
+ "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
+ "license": "MIT",
"dependencies": {
- "@floating-ui/core": "^1.7.2",
+ "@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
- "node_modules/@floating-ui/react-dom": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz",
- "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==",
+ "node_modules/@floating-ui/react": {
+ "version": "0.27.15",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.15.tgz",
+ "integrity": "sha512-0LGxhBi3BB1DwuSNQAmuaSuertFzNAerlMdPbotjTVnvPtdOs7CkrHLaev5NIXemhzDXNC0tFzuseut7cWA5mw==",
+ "license": "MIT",
"dependencies": {
- "@floating-ui/dom": "^1.7.2"
+ "@floating-ui/react-dom": "^2.1.5",
+ "@floating-ui/utils": "^0.2.10",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0",
+ "react-dom": ">=17.0.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz",
+ "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.3"
},
"peerDependencies": {
"react": ">=16.8.0",
@@ -990,6 +1027,330 @@
"node": ">= 10"
}
},
+ "node_modules/@parcel/watcher": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
+ "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "detect-libc": "^1.0.3",
+ "is-glob": "^4.0.3",
+ "micromatch": "^4.0.5",
+ "node-addon-api": "^7.0.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher-android-arm64": "2.5.1",
+ "@parcel/watcher-darwin-arm64": "2.5.1",
+ "@parcel/watcher-darwin-x64": "2.5.1",
+ "@parcel/watcher-freebsd-x64": "2.5.1",
+ "@parcel/watcher-linux-arm-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm-musl": "2.5.1",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm64-musl": "2.5.1",
+ "@parcel/watcher-linux-x64-glibc": "2.5.1",
+ "@parcel/watcher-linux-x64-musl": "2.5.1",
+ "@parcel/watcher-win32-arm64": "2.5.1",
+ "@parcel/watcher-win32-ia32": "2.5.1",
+ "@parcel/watcher-win32-x64": "2.5.1"
+ }
+ },
+ "node_modules/@parcel/watcher-android-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+ "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+ "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-darwin-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+ "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-freebsd-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+ "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+ "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+ "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+ "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+ "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+ "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+ "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+ "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-ia32": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+ "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher-win32-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+ "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/@parcel/watcher/node_modules/detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "bin": {
+ "detect-libc": "bin/detect-libc.js"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
"node_modules/@petamoriken/float16": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
@@ -1245,6 +1606,7 @@
"version": "2.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz",
"integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==",
+ "license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
@@ -1420,6 +1782,7 @@
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz",
"integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==",
+ "license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-collection": "1.1.7",
@@ -1455,6 +1818,7 @@
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz",
"integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==",
+ "license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.2",
@@ -1586,6 +1950,38 @@
}
}
},
+ "node_modules/@radix-ui/react-radio-group": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz",
+ "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
@@ -1616,6 +2012,37 @@
}
}
},
+ "node_modules/@radix-ui/react-scroll-area": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
+ "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==",
+ "license": "MIT",
+ "dependencies": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
@@ -2046,6 +2473,12 @@
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
},
+ "node_modules/@remirror/core-constants": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "http://mirrors.cloud.tencent.com/npm/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -2472,6 +2905,518 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tiptap/core": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.1.0.tgz",
+ "integrity": "sha512-GDxoCrA+ggdzhUcelcWWVsMcmoOYXWmpjIviYXZTyHR/fds8G/mNjG0ZpFqXNmFnZ7Rs16bAsSX2tjDZ9MyTFg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.1.0.tgz",
+ "integrity": "sha512-BRhtu2p/EDU9L0uxTcdk9EBUVOI098F+vUbR4G8qiuVzAb7xbYS5d0h0SaynlL/JMi/VjiLHl7qA/iWBlYpylQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.1.0.tgz",
+ "integrity": "sha512-wHiIR1u8QNBG30Ty0ZL34uKli7+nU4ArU5f/GN3BbhAD/gxQj13eo+TqLw1LjXd1yTzlW/EC4WNSPVy1qxChOg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bubble-menu": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.1.0.tgz",
+ "integrity": "sha512-nuJTNL3OXskObeXElHMqeU9LIWBgfSIudcBMfm0Elj3/2orjd1Z3nLmoloO6zGPmZkpRMjQnTe41PvAGIMibOw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@floating-ui/dom": "^1.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.1.0.tgz",
+ "integrity": "sha512-lSuj0T/c/lAI01sgOdDUmFmnxItIC2WQHxzg16KOoDCzW/PtWBc6pNmCSexmqqGoTg9xNWytPWvKapZ/GCwhiQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.1.0.tgz",
+ "integrity": "sha512-GESYNG11tOm41DH3zhPuWaQU2slK37aC28erkZ1DvUNKlKMhJd+DzbFVNyWqBB2IyeHpgl0eLqZAYB4QApKO1A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.1.0.tgz",
+ "integrity": "sha512-4sVF9ZaHgfkNZJXduGecNzluLfpLdsYW80bVoFKKm2u7itlh2TnhwJZTSSU8h8spR1kFWYu/HOwOYmghKq6dMg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.1.0.tgz",
+ "integrity": "sha512-n2X4ZeBsC2pORR1JXfyIFElJvAcQ0kAKqcblZlXzdewsZTS1GNd7NxFXTvuku1P2Op7CpP4X/lx8P7qSzUMFbA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.1.0.tgz",
+ "integrity": "sha512-zXyjLLcEYFl0pYQtSe12EKjByxS0qnycZhccpgQ7ePCusoDZTTyw4yE5pMxah5gkWK8N+qqynP+2oFcwcIWSLg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-floating-menu": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.1.0.tgz",
+ "integrity": "sha512-dNhRw3gH9VHqaaSEZ5y7n8k5Ot1cH6jzTpXrKOFt0EhrwV+4P+knTHsvKe33AumpukailMpuSGhDr2/RQQCRTQ==",
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@floating-ui/dom": "^1.0.0",
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.1.0.tgz",
+ "integrity": "sha512-9cm1ngE1SY26hCw/nzDNmWkyeZYRfidLamOuym7sg15pWj8BifTNuByrnK6fpQVSJWneV1y0npDj8WLpQ+t3CA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.1.0.tgz",
+ "integrity": "sha512-/4Ax5jX5l7mNd0XE93JNXSa1fWpsyqdSsM465XEsrvekoauaucuCgbWSP4qQ4v6eGBJpmI8o6aZFbQfOVDzVIQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.1.0.tgz",
+ "integrity": "sha512-GQjuwGeb5PIbAEJ4ZdTly9B2emdrkLiVVXZDWjsWX2PXGL/k+ZK8rP+/+NNYI3fLw0+DAZENu2pNh7F9NaiWlg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-highlight": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.1.0.tgz",
+ "integrity": "sha512-uRd9li79DVfQ2z4Q4pZn7UFmgY7hqTByPvI2/py4AvCgt3/HMwKj8N2izrUyCCrBclh2to+Oet0MqWZkDZhUQA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.1.0.tgz",
+ "integrity": "sha512-EjYW72H3YAQ/yuvoMz5YtrChVesrgQ1UNB/6WynEg+frvVsfGUcNv6B9zkRT7b+XEnOVzXUW8rNlSlkrWFiSbQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-image": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.1.0.tgz",
+ "integrity": "sha512-qCvmXlkGEwyr/TnbPfGcSzdSXeq4p2nHnWVi/naPisM3JnSpPxSAa07+LxmJ8mThmsPwPAOB5BxRaGOV/SEvyQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.1.0.tgz",
+ "integrity": "sha512-hsHNhnW5cJp/urTPIjG1C83Vov+gLFaaCsw3/Tdon9/uwAB5sLQ0Ig0iCEsKNh0KpckUnUmRjsPri5q5va7NLg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.1.0.tgz",
+ "integrity": "sha512-JRx3ZBnNqelbX+dBb2y2bSkjFqCLkLwZfjf55LHH4mQUFiPj7zUr6luXp9Ppq0WAFJEKXf+8tQQJrR+3eFUNlg==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.1.0.tgz",
+ "integrity": "sha512-6s0LjLzo01VojmUyohZcWiMi4njhYT76P+ESXL+3WIhHWKKVc0zNTMIWLE6Eu08wL+PoAwN2UufECNM7ZRxqkA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.1.0.tgz",
+ "integrity": "sha512-bI8v+qnlrCGmU41j19W02NpNH3jPf3qzX7tw/LmlM8uVinvjlguZzbNSWzpUFOiscEjoUcuqzGr+Cwa2CaBrQw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.1.0.tgz",
+ "integrity": "sha512-te50GvzfAaTV+SPR9iH/NGv/nDMaAKhYhr2m0XhuDaVmjgNIkWTKcTCxWt/9eljVJskFMvTHWXV3Tf/48basRg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.1.0.tgz",
+ "integrity": "sha512-bK85PC89/sUyGM3BL0dey8rGBkuDRYA2ynMGa/p+JG0Esqb0o5aKtNACTUKTYOdrDJw5kxNVE4BPzCkqO9/rsQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.1.0.tgz",
+ "integrity": "sha512-UR0FUc38IsGkfQBHhSJ5V8UGJ4juZZqnn33BzrW1L7elhlVVUM3greBXxa7vdMFBE/IcjpvOM414vJRZoBgvzw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.1.0.tgz",
+ "integrity": "sha512-7DzMXM5NrtTC3uVcjYgImNuXKMXonPZXwf0Q+No/sKqxtU+yXjusjVMmZqNLbzvtAFKghO0GDJGD4RxBNX9FgQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-subscript": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-3.1.0.tgz",
+ "integrity": "sha512-Ge3H8Nvte4BzvMqEOODAmOb+SrPi0i4QBLbjK8SL91HTjuGzFCHGYSBgMrmF07oDsK8psT4Rob6qUIUUI/b87Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-superscript": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-3.1.0.tgz",
+ "integrity": "sha512-y3mfFBEZ8IcsFrUM6MOWDz9pSP1TuQEFrgReOGGbiE+0VrzgHt+4N6q0nogqfSnLqGGQErEdgmaYCTemo0YoSw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.1.0.tgz",
+ "integrity": "sha512-BW1FEG4upSfhqBpBiPdEi8IMMJDmu0ThvZWYF385WVveQ5/jFK98RS2Kz7qt82jVvw2oyhOUL4Yy63T0Bh6W1w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-text-align": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.1.0.tgz",
+ "integrity": "sha512-b4Tpj3BvRe41iFjxDSFXObdtOGyK8Hy6sy9EHc7vYDn+NhhZgTcnwUf6QX6H/7JcItRUNi17W9KNSA1+0ZzXBg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-typography": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.1.0.tgz",
+ "integrity": "sha512-8mCyONmQnDcXC/FnD0axa7EE4bbZV97SbcWViiLW0mcchP48MD2KfF+Gk5DEQgaSjKEm00irWLI50SifMviSMA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.1.0.tgz",
+ "integrity": "sha512-M/gvRBleLzKeakcy62hWLsdUPE0TncuymwxvfSk8pY0L646vB1yQthH2x7b068mK8VmuNviPURMO35BAZYTYaA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.1.0.tgz",
+ "integrity": "sha512-b8mE6KA9CeyfhMOZPS+I4+Qp+aW6bNI/2mTRpDy0/WHM0XHZSh9/JOXCAuGzbz27Lcz9gp4FWwcpOICE39LPuQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.1.0.tgz",
+ "integrity": "sha512-9Pjr+bC89/ATSl5J0UMVrr50TML3B5viDoMMpksgkSrnQSJyuGGfCc8DHd0TKydxucMcjVG/oq+evyCW9xXRRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-collab": "^1.3.1",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-markdown": "^1.13.1",
+ "prosemirror-menu": "^1.2.4",
+ "prosemirror-model": "^1.24.1",
+ "prosemirror-schema-basic": "^1.2.3",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.6.4",
+ "prosemirror-trailing-node": "^3.0.0",
+ "prosemirror-transform": "^1.10.2",
+ "prosemirror-view": "^1.38.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/react": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.1.0.tgz",
+ "integrity": "sha512-JjcdnzaMpmE0XcqMKBztscUoranbeJ+GXP9TkdDjJSMgZ5pKn/knFTEr5n0HtpWcBl8QnSDzrk1/B7ULulXpHQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-deep-equal": "^3.1.3",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "optionalDependencies": {
+ "@tiptap/extension-bubble-menu": "^3.1.0",
+ "@tiptap/extension-floating-menu": "^3.1.0"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/pm": "^3.1.0",
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.1.0.tgz",
+ "integrity": "sha512-L+9WfA+XO4mwq4b0lEUr540q+jIENhZiVSNhOYdWlRyan+HCV60BkC8E/nBuqCk6tbM1jEqFQjVBqJWwjBsalQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/extension-blockquote": "^3.1.0",
+ "@tiptap/extension-bold": "^3.1.0",
+ "@tiptap/extension-bullet-list": "^3.1.0",
+ "@tiptap/extension-code": "^3.1.0",
+ "@tiptap/extension-code-block": "^3.1.0",
+ "@tiptap/extension-document": "^3.1.0",
+ "@tiptap/extension-dropcursor": "^3.1.0",
+ "@tiptap/extension-gapcursor": "^3.1.0",
+ "@tiptap/extension-hard-break": "^3.1.0",
+ "@tiptap/extension-heading": "^3.1.0",
+ "@tiptap/extension-horizontal-rule": "^3.1.0",
+ "@tiptap/extension-italic": "^3.1.0",
+ "@tiptap/extension-link": "^3.1.0",
+ "@tiptap/extension-list": "^3.1.0",
+ "@tiptap/extension-list-item": "^3.1.0",
+ "@tiptap/extension-list-keymap": "^3.1.0",
+ "@tiptap/extension-ordered-list": "^3.1.0",
+ "@tiptap/extension-paragraph": "^3.1.0",
+ "@tiptap/extension-strike": "^3.1.0",
+ "@tiptap/extension-text": "^3.1.0",
+ "@tiptap/extension-underline": "^3.1.0",
+ "@tiptap/extensions": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
"node_modules/@types/d3-array": {
"version": "3.2.1",
"resolved": "http://mirrors.cloud.tencent.com/npm/@types/d3-array/-/d3-array-3.2.1.tgz",
@@ -2610,6 +3555,29 @@
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="
},
+ "node_modules/@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash": {
+ "version": "4.17.20",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
+ "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/lodash.throttle": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz",
+ "integrity": "sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/lodash": "*"
+ }
+ },
"node_modules/@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
@@ -2625,6 +3593,16 @@
"@types/pbf": "*"
}
},
+ "node_modules/@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -2633,6 +3611,12 @@
"@types/unist": "*"
}
},
+ "node_modules/@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
+ "license": "MIT"
+ },
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -2696,6 +3680,12 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -3025,6 +4015,12 @@
"ajv": "^6.9.1"
}
},
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
"node_modules/aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -3054,6 +4050,20 @@
"node": "*"
}
},
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
@@ -3158,6 +4168,22 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "readdirp": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14.16.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -3258,6 +4284,12 @@
"dev": true,
"peer": true
},
+ "node_modules/crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -3647,8 +4679,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-equals": {
"version": "5.2.2",
@@ -3682,6 +4713,20 @@
],
"peer": true
},
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/framer-motion": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.6.tgz",
@@ -4070,6 +5115,13 @@
}
]
},
+ "node_modules/immutable": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
+ "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
+ "devOptional": true,
+ "license": "MIT"
+ },
"node_modules/ini": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
@@ -4129,6 +5181,31 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
@@ -4138,6 +5215,17 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
"node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -4500,6 +5588,21 @@
"url": "https://opencollective.com/parcel"
}
},
+ "node_modules/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "license": "MIT",
+ "dependencies": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "node_modules/linkifyjs": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+ "license": "MIT"
+ },
"node_modules/loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@@ -4530,6 +5633,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
+ "node_modules/lodash.throttle": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
+ "license": "MIT"
+ },
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -4615,6 +5724,35 @@
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
+ "node_modules/markdown-it": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "bin": {
+ "markdown-it": "bin/markdown-it.mjs"
+ }
+ },
+ "node_modules/markdown-it/node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -4897,6 +6035,12 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==",
+ "license": "MIT"
+ },
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -5457,6 +6601,21 @@
}
]
},
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -5659,6 +6818,14 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -5726,6 +6893,12 @@
"tslib": "^2.3.0"
}
},
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
@@ -5792,6 +6965,20 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
+ "node_modules/picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -5851,6 +7038,213 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/prosemirror-changeset": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+ "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-collab": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+ "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz",
+ "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz",
+ "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-inputrules": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz",
+ "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-markdown": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
+ "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/markdown-it": "^14.0.0",
+ "markdown-it": "^14.0.0",
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-menu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
+ "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
+ "license": "MIT",
+ "dependencies": {
+ "crelt": "^1.0.0",
+ "prosemirror-commands": "^1.0.0",
+ "prosemirror-history": "^1.0.0",
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz",
+ "integrity": "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-basic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
+ "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
+ "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz",
+ "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-model": "^1.25.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-transform": "^1.10.3",
+ "prosemirror-view": "^1.39.1"
+ }
+ },
+ "node_modules/prosemirror-trailing-node": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+ "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@remirror/core-constants": "3.0.0",
+ "escape-string-regexp": "^4.0.0"
+ },
+ "peerDependencies": {
+ "prosemirror-model": "^1.22.1",
+ "prosemirror-state": "^1.4.2",
+ "prosemirror-view": "^1.33.8"
+ }
+ },
+ "node_modules/prosemirror-trailing-node/node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.10.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz",
+ "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.40.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.1.tgz",
+ "integrity": "sha512-pbwUjt3G7TlsQQHDiYSupWBhJswpLVB09xXm1YiJPdkjkh9Pe7Y51XdLh5VWIZmROLY8UpUpG03lkdhm9lzIBA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.20.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
@@ -5865,6 +7259,15 @@
"node": ">=6"
}
},
+ "node_modules/punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/qss": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/qss/-/qss-3.0.0.tgz",
@@ -5983,6 +7386,19 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
+ "node_modules/react-hotkeys-hook": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.1.0.tgz",
+ "integrity": "sha512-GCNGXjBzV9buOS3REoQFmSmE4WTvBhYQ0YrAeeMZI83bhXg3dRWsLHXDutcVDdEjwJqJCxk5iewWYX5LtFUd7g==",
+ "license": "MIT",
+ "workspaces": [
+ "packages/*"
+ ],
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "http://mirrors.cloud.tencent.com/npm/react-is/-/react-is-18.3.1.tgz",
@@ -6112,6 +7528,20 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "devOptional": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14.18.0"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/recharts": {
"version": "2.15.4",
"resolved": "http://mirrors.cloud.tencent.com/npm/recharts/-/recharts-2.15.4.tgz",
@@ -6367,6 +7797,12 @@
"protocol-buffers-schema": "^3.3.1"
}
},
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
@@ -6393,6 +7829,27 @@
],
"peer": true
},
+ "node_modules/sass": {
+ "version": "1.90.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz",
+ "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "chokidar": "^4.0.0",
+ "immutable": "^5.0.2",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ },
+ "bin": {
+ "sass": "sass.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "optionalDependencies": {
+ "@parcel/watcher": "^2.4.1"
+ }
+ },
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -6634,6 +8091,12 @@
"node": ">=0.10"
}
},
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
+ "license": "MIT"
+ },
"node_modules/tabler": {
"version": "1.0.0",
"resolved": "http://mirrors.cloud.tencent.com/npm/tabler/-/tabler-1.0.0.tgz",
@@ -6804,6 +8267,20 @@
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
},
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -6866,6 +8343,12 @@
"node": ">=14.17"
}
},
+ "node_modules/uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
+ "license": "MIT"
+ },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -7161,6 +8644,12 @@
"pbf": "^3.2.1"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
@@ -7546,28 +9035,38 @@
}
},
"@floating-ui/core": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
- "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz",
+ "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
"requires": {
"@floating-ui/utils": "^0.2.10"
}
},
"@floating-ui/dom": {
- "version": "1.7.2",
- "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
- "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
+ "version": "1.7.3",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.3.tgz",
+ "integrity": "sha512-uZA413QEpNuhtb3/iIKoYMSK07keHPYeXF02Zhd6e213j+d1NamLix/mCLxBUDW/Gx52sPH2m+chlUsyaBs/Ag==",
"requires": {
- "@floating-ui/core": "^1.7.2",
+ "@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
}
},
- "@floating-ui/react-dom": {
- "version": "2.1.4",
- "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz",
- "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==",
+ "@floating-ui/react": {
+ "version": "0.27.15",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.15.tgz",
+ "integrity": "sha512-0LGxhBi3BB1DwuSNQAmuaSuertFzNAerlMdPbotjTVnvPtdOs7CkrHLaev5NIXemhzDXNC0tFzuseut7cWA5mw==",
"requires": {
- "@floating-ui/dom": "^1.7.2"
+ "@floating-ui/react-dom": "^2.1.5",
+ "@floating-ui/utils": "^0.2.10",
+ "tabbable": "^6.0.0"
+ }
+ },
+ "@floating-ui/react-dom": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.5.tgz",
+ "integrity": "sha512-HDO/1/1oH9fjj4eLgegrlH3dklZpHtUYYFiVwMUwfGvk9jWDRWqkklA2/NFScknrcNSspbV868WjXORvreDX+Q==",
+ "requires": {
+ "@floating-ui/dom": "^1.7.3"
}
},
"@floating-ui/utils": {
@@ -7912,6 +9411,132 @@
"integrity": "sha512-y+wTBxelk2xiNofmDOVU7O5WxTHcvOoL3srOM0kxTzKDjQ57kPU0tpnPJ/BWrRnsOwXEv0+3QSbGR7hY4n9LkQ==",
"optional": true
},
+ "@parcel/watcher": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
+ "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "@parcel/watcher-android-arm64": "2.5.1",
+ "@parcel/watcher-darwin-arm64": "2.5.1",
+ "@parcel/watcher-darwin-x64": "2.5.1",
+ "@parcel/watcher-freebsd-x64": "2.5.1",
+ "@parcel/watcher-linux-arm-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm-musl": "2.5.1",
+ "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+ "@parcel/watcher-linux-arm64-musl": "2.5.1",
+ "@parcel/watcher-linux-x64-glibc": "2.5.1",
+ "@parcel/watcher-linux-x64-musl": "2.5.1",
+ "@parcel/watcher-win32-arm64": "2.5.1",
+ "@parcel/watcher-win32-ia32": "2.5.1",
+ "@parcel/watcher-win32-x64": "2.5.1",
+ "detect-libc": "^1.0.3",
+ "is-glob": "^4.0.3",
+ "micromatch": "^4.0.5",
+ "node-addon-api": "^7.0.0"
+ },
+ "dependencies": {
+ "detect-libc": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
+ "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+ "dev": true,
+ "optional": true
+ }
+ }
+ },
+ "@parcel/watcher-android-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+ "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-darwin-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+ "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-darwin-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+ "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-freebsd-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+ "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-linux-arm-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+ "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-linux-arm-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+ "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-linux-arm64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+ "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-linux-arm64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+ "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-linux-x64-glibc": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+ "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-linux-x64-musl": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+ "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-win32-arm64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+ "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-win32-ia32": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+ "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
+ "dev": true,
+ "optional": true
+ },
+ "@parcel/watcher-win32-x64": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+ "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
+ "dev": true,
+ "optional": true
+ },
"@petamoriken/float16": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz",
@@ -8212,6 +9837,23 @@
"@radix-ui/react-slot": "1.2.3"
}
},
+ "@radix-ui/react-radio-group": {
+ "version": "1.3.7",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz",
+ "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==",
+ "requires": {
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-roving-focus": "1.1.10",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-previous": "1.1.1",
+ "@radix-ui/react-use-size": "1.1.1"
+ }
+ },
"@radix-ui/react-roving-focus": {
"version": "1.1.10",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz",
@@ -8228,6 +9870,22 @@
"@radix-ui/react-use-controllable-state": "1.2.2"
}
},
+ "@radix-ui/react-scroll-area": {
+ "version": "1.2.9",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz",
+ "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==",
+ "requires": {
+ "@radix-ui/number": "1.1.1",
+ "@radix-ui/primitive": "1.1.2",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-presence": "1.1.4",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-callback-ref": "1.1.1",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ }
+ },
"@radix-ui/react-select": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz",
@@ -8442,6 +10100,11 @@
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
},
+ "@remirror/core-constants": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
+ "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="
+ },
"@standard-schema/utils": {
"version": "0.3.0",
"resolved": "http://mirrors.cloud.tencent.com/npm/@standard-schema/utils/-/utils-0.3.0.tgz",
@@ -8701,6 +10364,266 @@
"resolved": "http://mirrors.cloud.tencent.com/npm/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="
},
+ "@tiptap/core": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.1.0.tgz",
+ "integrity": "sha512-GDxoCrA+ggdzhUcelcWWVsMcmoOYXWmpjIviYXZTyHR/fds8G/mNjG0ZpFqXNmFnZ7Rs16bAsSX2tjDZ9MyTFg==",
+ "requires": {}
+ },
+ "@tiptap/extension-blockquote": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.1.0.tgz",
+ "integrity": "sha512-BRhtu2p/EDU9L0uxTcdk9EBUVOI098F+vUbR4G8qiuVzAb7xbYS5d0h0SaynlL/JMi/VjiLHl7qA/iWBlYpylQ==",
+ "requires": {}
+ },
+ "@tiptap/extension-bold": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.1.0.tgz",
+ "integrity": "sha512-wHiIR1u8QNBG30Ty0ZL34uKli7+nU4ArU5f/GN3BbhAD/gxQj13eo+TqLw1LjXd1yTzlW/EC4WNSPVy1qxChOg==",
+ "requires": {}
+ },
+ "@tiptap/extension-bubble-menu": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.1.0.tgz",
+ "integrity": "sha512-nuJTNL3OXskObeXElHMqeU9LIWBgfSIudcBMfm0Elj3/2orjd1Z3nLmoloO6zGPmZkpRMjQnTe41PvAGIMibOw==",
+ "optional": true,
+ "requires": {
+ "@floating-ui/dom": "^1.0.0"
+ }
+ },
+ "@tiptap/extension-bullet-list": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.1.0.tgz",
+ "integrity": "sha512-lSuj0T/c/lAI01sgOdDUmFmnxItIC2WQHxzg16KOoDCzW/PtWBc6pNmCSexmqqGoTg9xNWytPWvKapZ/GCwhiQ==",
+ "requires": {}
+ },
+ "@tiptap/extension-code": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.1.0.tgz",
+ "integrity": "sha512-GESYNG11tOm41DH3zhPuWaQU2slK37aC28erkZ1DvUNKlKMhJd+DzbFVNyWqBB2IyeHpgl0eLqZAYB4QApKO1A==",
+ "requires": {}
+ },
+ "@tiptap/extension-code-block": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.1.0.tgz",
+ "integrity": "sha512-4sVF9ZaHgfkNZJXduGecNzluLfpLdsYW80bVoFKKm2u7itlh2TnhwJZTSSU8h8spR1kFWYu/HOwOYmghKq6dMg==",
+ "requires": {}
+ },
+ "@tiptap/extension-document": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.1.0.tgz",
+ "integrity": "sha512-n2X4ZeBsC2pORR1JXfyIFElJvAcQ0kAKqcblZlXzdewsZTS1GNd7NxFXTvuku1P2Op7CpP4X/lx8P7qSzUMFbA==",
+ "requires": {}
+ },
+ "@tiptap/extension-dropcursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.1.0.tgz",
+ "integrity": "sha512-zXyjLLcEYFl0pYQtSe12EKjByxS0qnycZhccpgQ7ePCusoDZTTyw4yE5pMxah5gkWK8N+qqynP+2oFcwcIWSLg==",
+ "requires": {}
+ },
+ "@tiptap/extension-floating-menu": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.1.0.tgz",
+ "integrity": "sha512-dNhRw3gH9VHqaaSEZ5y7n8k5Ot1cH6jzTpXrKOFt0EhrwV+4P+knTHsvKe33AumpukailMpuSGhDr2/RQQCRTQ==",
+ "optional": true,
+ "requires": {}
+ },
+ "@tiptap/extension-gapcursor": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.1.0.tgz",
+ "integrity": "sha512-9cm1ngE1SY26hCw/nzDNmWkyeZYRfidLamOuym7sg15pWj8BifTNuByrnK6fpQVSJWneV1y0npDj8WLpQ+t3CA==",
+ "requires": {}
+ },
+ "@tiptap/extension-hard-break": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.1.0.tgz",
+ "integrity": "sha512-/4Ax5jX5l7mNd0XE93JNXSa1fWpsyqdSsM465XEsrvekoauaucuCgbWSP4qQ4v6eGBJpmI8o6aZFbQfOVDzVIQ==",
+ "requires": {}
+ },
+ "@tiptap/extension-heading": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.1.0.tgz",
+ "integrity": "sha512-GQjuwGeb5PIbAEJ4ZdTly9B2emdrkLiVVXZDWjsWX2PXGL/k+ZK8rP+/+NNYI3fLw0+DAZENu2pNh7F9NaiWlg==",
+ "requires": {}
+ },
+ "@tiptap/extension-highlight": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.1.0.tgz",
+ "integrity": "sha512-uRd9li79DVfQ2z4Q4pZn7UFmgY7hqTByPvI2/py4AvCgt3/HMwKj8N2izrUyCCrBclh2to+Oet0MqWZkDZhUQA==",
+ "requires": {}
+ },
+ "@tiptap/extension-horizontal-rule": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.1.0.tgz",
+ "integrity": "sha512-EjYW72H3YAQ/yuvoMz5YtrChVesrgQ1UNB/6WynEg+frvVsfGUcNv6B9zkRT7b+XEnOVzXUW8rNlSlkrWFiSbQ==",
+ "requires": {}
+ },
+ "@tiptap/extension-image": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.1.0.tgz",
+ "integrity": "sha512-qCvmXlkGEwyr/TnbPfGcSzdSXeq4p2nHnWVi/naPisM3JnSpPxSAa07+LxmJ8mThmsPwPAOB5BxRaGOV/SEvyQ==",
+ "requires": {}
+ },
+ "@tiptap/extension-italic": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.1.0.tgz",
+ "integrity": "sha512-hsHNhnW5cJp/urTPIjG1C83Vov+gLFaaCsw3/Tdon9/uwAB5sLQ0Ig0iCEsKNh0KpckUnUmRjsPri5q5va7NLg==",
+ "requires": {}
+ },
+ "@tiptap/extension-link": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.1.0.tgz",
+ "integrity": "sha512-JRx3ZBnNqelbX+dBb2y2bSkjFqCLkLwZfjf55LHH4mQUFiPj7zUr6luXp9Ppq0WAFJEKXf+8tQQJrR+3eFUNlg==",
+ "requires": {
+ "linkifyjs": "^4.3.2"
+ }
+ },
+ "@tiptap/extension-list": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.1.0.tgz",
+ "integrity": "sha512-6s0LjLzo01VojmUyohZcWiMi4njhYT76P+ESXL+3WIhHWKKVc0zNTMIWLE6Eu08wL+PoAwN2UufECNM7ZRxqkA==",
+ "requires": {}
+ },
+ "@tiptap/extension-list-item": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.1.0.tgz",
+ "integrity": "sha512-bI8v+qnlrCGmU41j19W02NpNH3jPf3qzX7tw/LmlM8uVinvjlguZzbNSWzpUFOiscEjoUcuqzGr+Cwa2CaBrQw==",
+ "requires": {}
+ },
+ "@tiptap/extension-list-keymap": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.1.0.tgz",
+ "integrity": "sha512-te50GvzfAaTV+SPR9iH/NGv/nDMaAKhYhr2m0XhuDaVmjgNIkWTKcTCxWt/9eljVJskFMvTHWXV3Tf/48basRg==",
+ "requires": {}
+ },
+ "@tiptap/extension-ordered-list": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.1.0.tgz",
+ "integrity": "sha512-bK85PC89/sUyGM3BL0dey8rGBkuDRYA2ynMGa/p+JG0Esqb0o5aKtNACTUKTYOdrDJw5kxNVE4BPzCkqO9/rsQ==",
+ "requires": {}
+ },
+ "@tiptap/extension-paragraph": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.1.0.tgz",
+ "integrity": "sha512-UR0FUc38IsGkfQBHhSJ5V8UGJ4juZZqnn33BzrW1L7elhlVVUM3greBXxa7vdMFBE/IcjpvOM414vJRZoBgvzw==",
+ "requires": {}
+ },
+ "@tiptap/extension-strike": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.1.0.tgz",
+ "integrity": "sha512-7DzMXM5NrtTC3uVcjYgImNuXKMXonPZXwf0Q+No/sKqxtU+yXjusjVMmZqNLbzvtAFKghO0GDJGD4RxBNX9FgQ==",
+ "requires": {}
+ },
+ "@tiptap/extension-subscript": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-3.1.0.tgz",
+ "integrity": "sha512-Ge3H8Nvte4BzvMqEOODAmOb+SrPi0i4QBLbjK8SL91HTjuGzFCHGYSBgMrmF07oDsK8psT4Rob6qUIUUI/b87Q==",
+ "requires": {}
+ },
+ "@tiptap/extension-superscript": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-3.1.0.tgz",
+ "integrity": "sha512-y3mfFBEZ8IcsFrUM6MOWDz9pSP1TuQEFrgReOGGbiE+0VrzgHt+4N6q0nogqfSnLqGGQErEdgmaYCTemo0YoSw==",
+ "requires": {}
+ },
+ "@tiptap/extension-text": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.1.0.tgz",
+ "integrity": "sha512-BW1FEG4upSfhqBpBiPdEi8IMMJDmu0ThvZWYF385WVveQ5/jFK98RS2Kz7qt82jVvw2oyhOUL4Yy63T0Bh6W1w==",
+ "requires": {}
+ },
+ "@tiptap/extension-text-align": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.1.0.tgz",
+ "integrity": "sha512-b4Tpj3BvRe41iFjxDSFXObdtOGyK8Hy6sy9EHc7vYDn+NhhZgTcnwUf6QX6H/7JcItRUNi17W9KNSA1+0ZzXBg==",
+ "requires": {}
+ },
+ "@tiptap/extension-typography": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-3.1.0.tgz",
+ "integrity": "sha512-8mCyONmQnDcXC/FnD0axa7EE4bbZV97SbcWViiLW0mcchP48MD2KfF+Gk5DEQgaSjKEm00irWLI50SifMviSMA==",
+ "requires": {}
+ },
+ "@tiptap/extension-underline": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.1.0.tgz",
+ "integrity": "sha512-M/gvRBleLzKeakcy62hWLsdUPE0TncuymwxvfSk8pY0L646vB1yQthH2x7b068mK8VmuNviPURMO35BAZYTYaA==",
+ "requires": {}
+ },
+ "@tiptap/extensions": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.1.0.tgz",
+ "integrity": "sha512-b8mE6KA9CeyfhMOZPS+I4+Qp+aW6bNI/2mTRpDy0/WHM0XHZSh9/JOXCAuGzbz27Lcz9gp4FWwcpOICE39LPuQ==",
+ "requires": {}
+ },
+ "@tiptap/pm": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.1.0.tgz",
+ "integrity": "sha512-9Pjr+bC89/ATSl5J0UMVrr50TML3B5viDoMMpksgkSrnQSJyuGGfCc8DHd0TKydxucMcjVG/oq+evyCW9xXRRQ==",
+ "requires": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-collab": "^1.3.1",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-inputrules": "^1.4.0",
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-markdown": "^1.13.1",
+ "prosemirror-menu": "^1.2.4",
+ "prosemirror-model": "^1.24.1",
+ "prosemirror-schema-basic": "^1.2.3",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.6.4",
+ "prosemirror-trailing-node": "^3.0.0",
+ "prosemirror-transform": "^1.10.2",
+ "prosemirror-view": "^1.38.1"
+ }
+ },
+ "@tiptap/react": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.1.0.tgz",
+ "integrity": "sha512-JjcdnzaMpmE0XcqMKBztscUoranbeJ+GXP9TkdDjJSMgZ5pKn/knFTEr5n0HtpWcBl8QnSDzrk1/B7ULulXpHQ==",
+ "requires": {
+ "@tiptap/extension-bubble-menu": "^3.1.0",
+ "@tiptap/extension-floating-menu": "^3.1.0",
+ "@types/use-sync-external-store": "^0.0.6",
+ "fast-deep-equal": "^3.1.3",
+ "use-sync-external-store": "^1.4.0"
+ }
+ },
+ "@tiptap/starter-kit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.1.0.tgz",
+ "integrity": "sha512-L+9WfA+XO4mwq4b0lEUr540q+jIENhZiVSNhOYdWlRyan+HCV60BkC8E/nBuqCk6tbM1jEqFQjVBqJWwjBsalQ==",
+ "requires": {
+ "@tiptap/core": "^3.1.0",
+ "@tiptap/extension-blockquote": "^3.1.0",
+ "@tiptap/extension-bold": "^3.1.0",
+ "@tiptap/extension-bullet-list": "^3.1.0",
+ "@tiptap/extension-code": "^3.1.0",
+ "@tiptap/extension-code-block": "^3.1.0",
+ "@tiptap/extension-document": "^3.1.0",
+ "@tiptap/extension-dropcursor": "^3.1.0",
+ "@tiptap/extension-gapcursor": "^3.1.0",
+ "@tiptap/extension-hard-break": "^3.1.0",
+ "@tiptap/extension-heading": "^3.1.0",
+ "@tiptap/extension-horizontal-rule": "^3.1.0",
+ "@tiptap/extension-italic": "^3.1.0",
+ "@tiptap/extension-link": "^3.1.0",
+ "@tiptap/extension-list": "^3.1.0",
+ "@tiptap/extension-list-item": "^3.1.0",
+ "@tiptap/extension-list-keymap": "^3.1.0",
+ "@tiptap/extension-ordered-list": "^3.1.0",
+ "@tiptap/extension-paragraph": "^3.1.0",
+ "@tiptap/extension-strike": "^3.1.0",
+ "@tiptap/extension-text": "^3.1.0",
+ "@tiptap/extension-underline": "^3.1.0",
+ "@tiptap/extensions": "^3.1.0",
+ "@tiptap/pm": "^3.1.0"
+ }
+ },
"@types/d3-array": {
"version": "3.2.1",
"resolved": "http://mirrors.cloud.tencent.com/npm/@types/d3-array/-/d3-array-3.2.1.tgz",
@@ -8830,6 +10753,26 @@
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.7.tgz",
"integrity": "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="
},
+ "@types/linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
+ },
+ "@types/lodash": {
+ "version": "4.17.20",
+ "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
+ "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
+ "dev": true
+ },
+ "@types/lodash.throttle": {
+ "version": "4.1.9",
+ "resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz",
+ "integrity": "sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==",
+ "dev": true,
+ "requires": {
+ "@types/lodash": "*"
+ }
+ },
"@types/mapbox__point-geometry": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz",
@@ -8845,6 +10788,15 @@
"@types/pbf": "*"
}
},
+ "@types/markdown-it": {
+ "version": "14.1.2",
+ "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
+ "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
+ "requires": {
+ "@types/linkify-it": "^5",
+ "@types/mdurl": "^2"
+ }
+ },
"@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
@@ -8853,6 +10805,11 @@
"@types/unist": "*"
}
},
+ "@types/mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
+ },
"@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@@ -8914,6 +10871,11 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
},
+ "@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
+ },
"@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
@@ -9200,6 +11162,11 @@
"dev": true,
"requires": {}
},
+ "argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+ },
"aria-hidden": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
@@ -9219,6 +11186,16 @@
"integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==",
"dev": true
},
+ "braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "fill-range": "^7.1.1"
+ }
+ },
"browserslist": {
"version": "4.25.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz",
@@ -9269,6 +11246,15 @@
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="
},
+ "chokidar": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
+ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+ "devOptional": true,
+ "requires": {
+ "readdirp": "^4.0.1"
+ }
+ },
"chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
@@ -9347,6 +11333,11 @@
"dev": true,
"peer": true
},
+ "crelt": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
+ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="
+ },
"csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -9618,8 +11609,7 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-equals": {
"version": "5.2.2",
@@ -9639,6 +11629,16 @@
"dev": true,
"peer": true
},
+ "fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "to-regex-range": "^5.0.1"
+ }
+ },
"framer-motion": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.6.tgz",
@@ -9892,6 +11892,12 @@
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
+ "immutable": {
+ "version": "5.1.3",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.3.tgz",
+ "integrity": "sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==",
+ "devOptional": true
+ },
"ini": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz",
@@ -9932,11 +11938,35 @@
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="
},
+ "is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "optional": true
+ },
+ "is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-extglob": "^2.1.1"
+ }
+ },
"is-hexadecimal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="
},
+ "is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "optional": true
+ },
"is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
@@ -10119,6 +12149,19 @@
"dev": true,
"optional": true
},
+ "linkify-it": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
+ "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
+ "requires": {
+ "uc.micro": "^2.0.0"
+ }
+ },
+ "linkifyjs": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA=="
+ },
"loader-runner": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz",
@@ -10142,6 +12185,11 @@
"resolved": "http://mirrors.cloud.tencent.com/npm/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
+ "lodash.throttle": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
+ "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="
+ },
"longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
@@ -10208,6 +12256,26 @@
"vt-pbf": "^3.1.3"
}
},
+ "markdown-it": {
+ "version": "14.1.0",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
+ "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
+ "requires": {
+ "argparse": "^2.0.1",
+ "entities": "^4.4.0",
+ "linkify-it": "^5.0.0",
+ "mdurl": "^2.0.0",
+ "punycode.js": "^2.3.1",
+ "uc.micro": "^2.1.0"
+ },
+ "dependencies": {
+ "entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="
+ }
+ }
+ },
"markdown-table": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
@@ -10422,6 +12490,11 @@
"@types/mdast": "^4.0.0"
}
},
+ "mdurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
+ "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
+ },
"merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -10740,6 +12813,17 @@
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="
},
+ "micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ }
+ },
"mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -10857,6 +12941,13 @@
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
"requires": {}
},
+ "node-addon-api": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
+ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+ "dev": true,
+ "optional": true
+ },
"node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -10911,6 +13002,11 @@
"tslib": "^2.3.0"
}
},
+ "orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
+ },
"pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
@@ -10969,6 +13065,13 @@
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
+ "picomatch": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+ "dev": true,
+ "optional": true
+ },
"postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -11007,6 +13110,185 @@
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
"integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="
},
+ "prosemirror-changeset": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.1.tgz",
+ "integrity": "sha512-j0kORIBm8ayJNl3zQvD1TTPHJX3g042et6y/KQhZhnPrruO8exkTgG8X+NRpj7kIyMMEx74Xb3DyMIBtO0IKkQ==",
+ "requires": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "prosemirror-collab": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz",
+ "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==",
+ "requires": {
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "requires": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "requires": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "prosemirror-gapcursor": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz",
+ "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==",
+ "requires": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "prosemirror-history": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz",
+ "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==",
+ "requires": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "prosemirror-inputrules": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz",
+ "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==",
+ "requires": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "requires": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "prosemirror-markdown": {
+ "version": "1.13.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz",
+ "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==",
+ "requires": {
+ "@types/markdown-it": "^14.0.0",
+ "markdown-it": "^14.0.0",
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "prosemirror-menu": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz",
+ "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==",
+ "requires": {
+ "crelt": "^1.0.0",
+ "prosemirror-commands": "^1.0.0",
+ "prosemirror-history": "^1.0.0",
+ "prosemirror-state": "^1.0.0"
+ }
+ },
+ "prosemirror-model": {
+ "version": "1.25.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.3.tgz",
+ "integrity": "sha512-dY2HdaNXlARknJbrManZ1WyUtos+AP97AmvqdOQtWtrrC5g4mohVX5DTi9rXNFSk09eczLq9GuNTtq3EfMeMGA==",
+ "requires": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "prosemirror-schema-basic": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz",
+ "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==",
+ "requires": {
+ "prosemirror-model": "^1.25.0"
+ }
+ },
+ "prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "requires": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "prosemirror-state": {
+ "version": "1.4.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz",
+ "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==",
+ "requires": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "prosemirror-tables": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz",
+ "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==",
+ "requires": {
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-model": "^1.25.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-transform": "^1.10.3",
+ "prosemirror-view": "^1.39.1"
+ }
+ },
+ "prosemirror-trailing-node": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz",
+ "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==",
+ "requires": {
+ "@remirror/core-constants": "3.0.0",
+ "escape-string-regexp": "^4.0.0"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+ }
+ }
+ },
+ "prosemirror-transform": {
+ "version": "1.10.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz",
+ "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==",
+ "requires": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "prosemirror-view": {
+ "version": "1.40.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.1.tgz",
+ "integrity": "sha512-pbwUjt3G7TlsQQHDiYSupWBhJswpLVB09xXm1YiJPdkjkh9Pe7Y51XdLh5VWIZmROLY8UpUpG03lkdhm9lzIBA==",
+ "requires": {
+ "prosemirror-model": "^1.20.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
"protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
@@ -11018,6 +13300,11 @@
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true
},
+ "punycode.js": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
+ "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="
+ },
"qss": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/qss/-/qss-3.0.0.tgz",
@@ -11090,6 +13377,12 @@
"integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==",
"requires": {}
},
+ "react-hotkeys-hook": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/react-hotkeys-hook/-/react-hotkeys-hook-5.1.0.tgz",
+ "integrity": "sha512-GCNGXjBzV9buOS3REoQFmSmE4WTvBhYQ0YrAeeMZI83bhXg3dRWsLHXDutcVDdEjwJqJCxk5iewWYX5LtFUd7g==",
+ "requires": {}
+ },
"react-is": {
"version": "18.3.1",
"resolved": "http://mirrors.cloud.tencent.com/npm/react-is/-/react-is-18.3.1.tgz",
@@ -11164,6 +13457,12 @@
"prop-types": "^15.6.2"
}
},
+ "readdirp": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
+ "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+ "devOptional": true
+ },
"recharts": {
"version": "2.15.4",
"resolved": "http://mirrors.cloud.tencent.com/npm/recharts/-/recharts-2.15.4.tgz",
@@ -11353,6 +13652,11 @@
"protocol-buffers-schema": "^3.3.1"
}
},
+ "rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
+ },
"rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
@@ -11365,6 +13669,18 @@
"dev": true,
"peer": true
},
+ "sass": {
+ "version": "1.90.0",
+ "resolved": "https://registry.npmjs.org/sass/-/sass-1.90.0.tgz",
+ "integrity": "sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q==",
+ "devOptional": true,
+ "requires": {
+ "@parcel/watcher": "^2.4.1",
+ "chokidar": "^4.0.0",
+ "immutable": "^5.0.2",
+ "source-map-js": ">=0.6.2 <2.0.0"
+ }
+ },
"scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
@@ -11538,6 +13854,11 @@
"resolved": "http://mirrors.cloud.tencent.com/npm/symbol-observable/-/symbol-observable-4.0.0.tgz",
"integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ=="
},
+ "tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
+ },
"tabler": {
"version": "1.0.0",
"resolved": "http://mirrors.cloud.tencent.com/npm/tabler/-/tabler-1.0.0.tgz",
@@ -11656,6 +13977,16 @@
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="
},
+ "to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "optional": true,
+ "requires": {
+ "is-number": "^7.0.0"
+ }
+ },
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -11696,6 +14027,11 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true
},
+ "uc.micro": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
+ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
+ },
"undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -11895,6 +14231,11 @@
"pbf": "^3.2.1"
}
},
+ "w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="
+ },
"watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
diff --git a/package.json b/package.json
index e7e586b..55b3978 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
+ "@floating-ui/react": "^0.27.15",
"@hookform/resolvers": "^5.2.0",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
@@ -25,6 +26,8 @@
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
+ "@radix-ui/react-radio-group": "^1.3.7",
+ "@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
@@ -36,6 +39,18 @@
"@radix-ui/react-tooltip": "^1.2.7",
"@tabler/icons-react": "^3.34.1",
"@tanstack/react-table": "^8.21.3",
+ "@tiptap/extension-highlight": "^3.1.0",
+ "@tiptap/extension-horizontal-rule": "^3.1.0",
+ "@tiptap/extension-image": "^3.1.0",
+ "@tiptap/extension-list": "^3.1.0",
+ "@tiptap/extension-subscript": "^3.1.0",
+ "@tiptap/extension-superscript": "^3.1.0",
+ "@tiptap/extension-text-align": "^3.1.0",
+ "@tiptap/extension-typography": "^3.1.0",
+ "@tiptap/extensions": "^3.1.0",
+ "@tiptap/pm": "^3.1.0",
+ "@tiptap/react": "^3.1.0",
+ "@tiptap/starter-kit": "^3.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -45,6 +60,7 @@
"graphql": "^16.11.0",
"graphql-request": "^7.2.0",
"graphql-ws": "^6.0.6",
+ "lodash.throttle": "^4.1.1",
"lucide-react": "^0.525.0",
"maplibre-gl": "^5.6.1",
"next": "15.4.1",
@@ -55,6 +71,7 @@
"react-day-picker": "^9.8.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.61.1",
+ "react-hotkeys-hook": "^5.1.0",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"rehype-katex": "^7.0.1",
@@ -70,10 +87,12 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
+ "@types/lodash.throttle": "^4.1.9",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"raw-loader": "^4.0.2",
+ "sass": "^1.90.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
diff --git a/styles/_keyframe-animations.scss b/styles/_keyframe-animations.scss
new file mode 100644
index 0000000..dd98b7c
--- /dev/null
+++ b/styles/_keyframe-animations.scss
@@ -0,0 +1,91 @@
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes fadeOut {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes zoomIn {
+ from {
+ transform: scale(0.95);
+ }
+ to {
+ transform: scale(1);
+ }
+}
+
+@keyframes zoomOut {
+ from {
+ transform: scale(1);
+ }
+ to {
+ transform: scale(0.95);
+ }
+}
+
+@keyframes zoom {
+ 0% {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes slideFromTop {
+ from {
+ transform: translateY(-0.5rem);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes slideFromRight {
+ from {
+ transform: translateX(0.5rem);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideFromLeft {
+ from {
+ transform: translateX(-0.5rem);
+ }
+ to {
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideFromBottom {
+ from {
+ transform: translateY(0.5rem);
+ }
+ to {
+ transform: translateY(0);
+ }
+}
+
+@keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/styles/_variables.scss b/styles/_variables.scss
new file mode 100644
index 0000000..113a16b
--- /dev/null
+++ b/styles/_variables.scss
@@ -0,0 +1,296 @@
+:root {
+ /******************
+ Basics
+ ******************/
+
+ overflow-wrap: break-word;
+ text-size-adjust: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+
+ /******************
+ Colors variables
+ ******************/
+
+ /* Gray alpha (light mode) */
+ --tt-gray-light-a-50: rgba(56, 56, 56, 0.04);
+ --tt-gray-light-a-100: rgba(15, 22, 36, 0.05);
+ --tt-gray-light-a-200: rgba(37, 39, 45, 0.1);
+ --tt-gray-light-a-300: rgba(47, 50, 55, 0.2);
+ --tt-gray-light-a-400: rgba(40, 44, 51, 0.42);
+ --tt-gray-light-a-500: rgba(52, 55, 60, 0.64);
+ --tt-gray-light-a-600: rgba(36, 39, 46, 0.78);
+ --tt-gray-light-a-700: rgba(35, 37, 42, 0.87);
+ --tt-gray-light-a-800: rgba(30, 32, 36, 0.95);
+ --tt-gray-light-a-900: rgba(29, 30, 32, 0.98);
+
+ /* Gray (light mode) */
+ --tt-gray-light-50: rgba(250, 250, 250, 1);
+ --tt-gray-light-100: rgba(244, 244, 245, 1);
+ --tt-gray-light-200: rgba(234, 234, 235, 1);
+ --tt-gray-light-300: rgba(213, 214, 215, 1);
+ --tt-gray-light-400: rgba(166, 167, 171, 1);
+ --tt-gray-light-500: rgba(125, 127, 130, 1);
+ --tt-gray-light-600: rgba(83, 86, 90, 1);
+ --tt-gray-light-700: rgba(64, 65, 69, 1);
+ --tt-gray-light-800: rgba(44, 45, 48, 1);
+ --tt-gray-light-900: rgba(34, 35, 37, 1);
+
+ /* Gray alpha (dark mode) */
+ --tt-gray-dark-a-50: rgba(232, 232, 253, 0.05);
+ --tt-gray-dark-a-100: rgba(231, 231, 243, 0.07);
+ --tt-gray-dark-a-200: rgba(238, 238, 246, 0.11);
+ --tt-gray-dark-a-300: rgba(239, 239, 245, 0.22);
+ --tt-gray-dark-a-400: rgba(244, 244, 255, 0.37);
+ --tt-gray-dark-a-500: rgba(236, 238, 253, 0.5);
+ --tt-gray-dark-a-600: rgba(247, 247, 253, 0.64);
+ --tt-gray-dark-a-700: rgba(251, 251, 254, 0.75);
+ --tt-gray-dark-a-800: rgba(253, 253, 253, 0.88);
+ --tt-gray-dark-a-900: rgba(255, 255, 255, 0.96);
+
+ /* Gray (dark mode) */
+ --tt-gray-dark-50: rgba(25, 25, 26, 1);
+ --tt-gray-dark-100: rgba(32, 32, 34, 1);
+ --tt-gray-dark-200: rgba(45, 45, 47, 1);
+ --tt-gray-dark-300: rgba(70, 70, 73, 1);
+ --tt-gray-dark-400: rgba(99, 99, 105, 1);
+ --tt-gray-dark-500: rgba(124, 124, 131, 1);
+ --tt-gray-dark-600: rgba(163, 163, 168, 1);
+ --tt-gray-dark-700: rgba(192, 192, 195, 1);
+ --tt-gray-dark-800: rgba(224, 224, 225, 1);
+ --tt-gray-dark-900: rgba(245, 245, 245, 1);
+
+ /* Brand colors */
+ --tt-brand-color-50: rgba(239, 238, 255, 1);
+ --tt-brand-color-100: rgba(222, 219, 255, 1);
+ --tt-brand-color-200: rgba(195, 189, 255, 1);
+ --tt-brand-color-300: rgba(157, 138, 255, 1);
+ --tt-brand-color-400: rgba(122, 82, 255, 1);
+ --tt-brand-color-500: rgba(98, 41, 255, 1);
+ --tt-brand-color-600: rgba(84, 0, 229, 1);
+ --tt-brand-color-700: rgba(75, 0, 204, 1);
+ --tt-brand-color-800: rgba(56, 0, 153, 1);
+ --tt-brand-color-900: rgba(43, 25, 102, 1);
+ --tt-brand-color-950: hsla(257, 100%, 9%, 1);
+
+ /* Green */
+ --tt-color-green-inc-5: hsla(129, 100%, 97%, 1);
+ --tt-color-green-inc-4: hsla(129, 100%, 92%, 1);
+ --tt-color-green-inc-3: hsla(131, 100%, 86%, 1);
+ --tt-color-green-inc-2: hsla(133, 98%, 78%, 1);
+ --tt-color-green-inc-1: hsla(137, 99%, 70%, 1);
+ --tt-color-green-base: hsla(147, 99%, 50%, 1);
+ --tt-color-green-dec-1: hsla(147, 97%, 41%, 1);
+ --tt-color-green-dec-2: hsla(146, 98%, 32%, 1);
+ --tt-color-green-dec-3: hsla(146, 100%, 24%, 1);
+ --tt-color-green-dec-4: hsla(144, 100%, 16%, 1);
+ --tt-color-green-dec-5: hsla(140, 100%, 9%, 1);
+
+ /* Yellow */
+ --tt-color-yellow-inc-5: hsla(50, 100%, 97%, 1);
+ --tt-color-yellow-inc-4: hsla(50, 100%, 91%, 1);
+ --tt-color-yellow-inc-3: hsla(50, 100%, 84%, 1);
+ --tt-color-yellow-inc-2: hsla(50, 100%, 77%, 1);
+ --tt-color-yellow-inc-1: hsla(50, 100%, 68%, 1);
+ --tt-color-yellow-base: hsla(52, 100%, 50%, 1);
+ --tt-color-yellow-dec-1: hsla(52, 100%, 41%, 1);
+ --tt-color-yellow-dec-2: hsla(52, 100%, 32%, 1);
+ --tt-color-yellow-dec-3: hsla(52, 100%, 24%, 1);
+ --tt-color-yellow-dec-4: hsla(51, 100%, 16%, 1);
+ --tt-color-yellow-dec-5: hsla(50, 100%, 9%, 1);
+
+ /* Red */
+ --tt-color-red-inc-5: hsla(11, 100%, 96%, 1);
+ --tt-color-red-inc-4: hsla(11, 100%, 88%, 1);
+ --tt-color-red-inc-3: hsla(10, 100%, 80%, 1);
+ --tt-color-red-inc-2: hsla(9, 100%, 73%, 1);
+ --tt-color-red-inc-1: hsla(7, 100%, 64%, 1);
+ --tt-color-red-base: hsla(7, 100%, 54%, 1);
+ --tt-color-red-dec-1: hsla(7, 100%, 41%, 1);
+ --tt-color-red-dec-2: hsla(5, 100%, 32%, 1);
+ --tt-color-red-dec-3: hsla(4, 100%, 24%, 1);
+ --tt-color-red-dec-4: hsla(3, 100%, 16%, 1);
+ --tt-color-red-dec-5: hsla(1, 100%, 9%, 1);
+
+ /* Basic colors */
+ --white: rgba(255, 255, 255, 1);
+ --black: rgba(14, 14, 17, 1);
+ --transparent: rgba(255, 255, 255, 0);
+
+ /******************
+ Shadow variables
+ ******************/
+
+ /* Shadows Light */
+ --tt-shadow-elevated-md:
+ 0px 16px 48px 0px rgba(17, 24, 39, 0.04),
+ 0px 12px 24px 0px rgba(17, 24, 39, 0.04),
+ 0px 6px 8px 0px rgba(17, 24, 39, 0.02),
+ 0px 2px 3px 0px rgba(17, 24, 39, 0.02);
+
+ /**************************************************
+ Radius variables
+ **************************************************/
+
+ --tt-radius-xxs: 0.125rem; /* 2px */
+ --tt-radius-xs: 0.25rem; /* 4px */
+ --tt-radius-sm: 0.375rem; /* 6px */
+ --tt-radius-md: 0.5rem; /* 8px */
+ --tt-radius-lg: 0.75rem; /* 12px */
+ --tt-radius-xl: 1rem; /* 16px */
+
+ /**************************************************
+ Transition variables
+ **************************************************/
+
+ --tt-transition-duration-short: 0.1s;
+ --tt-transition-duration-default: 0.2s;
+ --tt-transition-duration-long: 0.64s;
+ --tt-transition-easing-default: cubic-bezier(0.46, 0.03, 0.52, 0.96);
+ --tt-transition-easing-cubic: cubic-bezier(0.65, 0.05, 0.36, 1);
+ --tt-transition-easing-quart: cubic-bezier(0.77, 0, 0.18, 1);
+ --tt-transition-easing-circ: cubic-bezier(0.79, 0.14, 0.15, 0.86);
+ --tt-transition-easing-back: cubic-bezier(0.68, -0.55, 0.27, 1.55);
+
+ /******************
+ Contrast variables
+ ******************/
+
+ --tt-accent-contrast: 8%;
+ --tt-destructive-contrast: 8%;
+ --tt-foreground-contrast: 8%;
+
+ &,
+ *,
+ ::before,
+ ::after {
+ box-sizing: border-box;
+ transition: none var(--tt-transition-duration-default)
+ var(--tt-transition-easing-default);
+ }
+}
+
+:root {
+ /**************************************************
+ Global colors
+ **************************************************/
+
+ /* Global colors - Light mode */
+ --tt-bg-color: var(--white);
+ --tt-border-color: var(--tt-gray-light-a-200);
+ --tt-border-color-tint: var(--tt-gray-light-a-100);
+ --tt-sidebar-bg-color: var(--tt-gray-light-100);
+ --tt-scrollbar-color: var(--tt-gray-light-a-200);
+ --tt-cursor-color: var(--tt-brand-color-500);
+ --tt-selection-color: rgba(157, 138, 255, 0.2);
+ --tt-card-bg-color: var(--white);
+ --tt-card-border-color: var(--tt-gray-light-a-100);
+}
+
+/* Global colors - Dark mode */
+.dark {
+ --tt-bg-color: var(--black);
+ --tt-border-color: var(--tt-gray-dark-a-200);
+ --tt-border-color-tint: var(--tt-gray-dark-a-100);
+ --tt-sidebar-bg-color: var(--tt-gray-dark-100);
+ --tt-scrollbar-color: var(--tt-gray-dark-a-200);
+ --tt-cursor-color: var(--tt-brand-color-400);
+ --tt-selection-color: rgba(122, 82, 255, 0.2);
+ --tt-card-bg-color: var(--tt-gray-dark-50);
+ --tt-card-border-color: var(--tt-gray-dark-a-50);
+
+ --tt-shadow-elevated-md:
+ 0px 16px 48px 0px rgba(0, 0, 0, 0.5), 0px 12px 24px 0px rgba(0, 0, 0, 0.24),
+ 0px 6px 8px 0px rgba(0, 0, 0, 0.22), 0px 2px 3px 0px rgba(0, 0, 0, 0.12);
+}
+
+/* Text colors */
+:root {
+ --tt-color-text-gray: hsl(45, 2%, 46%);
+ --tt-color-text-brown: hsl(19, 31%, 47%);
+ --tt-color-text-orange: hsl(30, 89%, 45%);
+ --tt-color-text-yellow: hsl(38, 62%, 49%);
+ --tt-color-text-green: hsl(148, 32%, 39%);
+ --tt-color-text-blue: hsl(202, 54%, 43%);
+ --tt-color-text-purple: hsl(274, 32%, 54%);
+ --tt-color-text-pink: hsl(328, 49%, 53%);
+ --tt-color-text-red: hsl(2, 62%, 55%);
+
+ --tt-color-text-gray-contrast: hsla(39, 26%, 26%, 0.15);
+ --tt-color-text-brown-contrast: hsla(18, 43%, 69%, 0.35);
+ --tt-color-text-orange-contrast: hsla(24, 73%, 55%, 0.27);
+ --tt-color-text-yellow-contrast: hsla(44, 82%, 59%, 0.39);
+ --tt-color-text-green-contrast: hsla(126, 29%, 60%, 0.27);
+ --tt-color-text-blue-contrast: hsla(202, 54%, 59%, 0.27);
+ --tt-color-text-purple-contrast: hsla(274, 37%, 64%, 0.27);
+ --tt-color-text-pink-contrast: hsla(331, 60%, 71%, 0.27);
+ --tt-color-text-red-contrast: hsla(8, 79%, 79%, 0.4);
+}
+
+.dark {
+ --tt-color-text-gray: hsl(0, 0%, 61%);
+ --tt-color-text-brown: hsl(18, 35%, 58%);
+ --tt-color-text-orange: hsl(25, 53%, 53%);
+ --tt-color-text-yellow: hsl(36, 54%, 55%);
+ --tt-color-text-green: hsl(145, 32%, 47%);
+ --tt-color-text-blue: hsl(202, 64%, 52%);
+ --tt-color-text-purple: hsl(270, 55%, 62%);
+ --tt-color-text-pink: hsl(329, 57%, 58%);
+ --tt-color-text-red: hsl(1, 69%, 60%);
+
+ --tt-color-text-gray-contrast: hsla(0, 0%, 100%, 0.09);
+ --tt-color-text-brown-contrast: hsla(17, 45%, 50%, 0.25);
+ --tt-color-text-orange-contrast: hsla(27, 82%, 53%, 0.2);
+ --tt-color-text-yellow-contrast: hsla(35, 49%, 47%, 0.2);
+ --tt-color-text-green-contrast: hsla(151, 55%, 39%, 0.2);
+ --tt-color-text-blue-contrast: hsla(202, 54%, 43%, 0.2);
+ --tt-color-text-purple-contrast: hsla(271, 56%, 60%, 0.18);
+ --tt-color-text-pink-contrast: hsla(331, 67%, 58%, 0.22);
+ --tt-color-text-red-contrast: hsla(0, 67%, 60%, 0.25);
+}
+
+/* Highlight colors */
+:root {
+ --tt-color-highlight-yellow: #fef9c3;
+ --tt-color-highlight-green: #dcfce7;
+ --tt-color-highlight-blue: #e0f2fe;
+ --tt-color-highlight-purple: #f3e8ff;
+ --tt-color-highlight-red: #ffe4e6;
+ --tt-color-highlight-gray: rgb(248, 248, 247);
+ --tt-color-highlight-brown: rgb(244, 238, 238);
+ --tt-color-highlight-orange: rgb(251, 236, 221);
+ --tt-color-highlight-pink: rgb(252, 241, 246);
+
+ --tt-color-highlight-yellow-contrast: #fbe604;
+ --tt-color-highlight-green-contrast: #c7fad8;
+ --tt-color-highlight-blue-contrast: #ceeafd;
+ --tt-color-highlight-purple-contrast: #e4ccff;
+ --tt-color-highlight-red-contrast: #ffccd0;
+ --tt-color-highlight-gray-contrast: rgba(84, 72, 49, 0.15);
+ --tt-color-highlight-brown-contrast: rgba(210, 162, 141, 0.35);
+ --tt-color-highlight-orange-contrast: rgba(224, 124, 57, 0.27);
+ --tt-color-highlight-pink-contrast: rgba(225, 136, 179, 0.27);
+}
+
+.dark {
+ --tt-color-highlight-yellow: #6b6524;
+ --tt-color-highlight-green: #509568;
+ --tt-color-highlight-blue: #6e92aa;
+ --tt-color-highlight-purple: #583e74;
+ --tt-color-highlight-red: #743e42;
+ --tt-color-highlight-gray: rgb(47, 47, 47);
+ --tt-color-highlight-brown: rgb(74, 50, 40);
+ --tt-color-highlight-orange: rgb(92, 59, 35);
+ --tt-color-highlight-pink: rgb(78, 44, 60);
+
+ --tt-color-highlight-yellow-contrast: #58531e;
+ --tt-color-highlight-green-contrast: #47855d;
+ --tt-color-highlight-blue-contrast: #5e86a1;
+ --tt-color-highlight-purple-contrast: #4c3564;
+ --tt-color-highlight-red-contrast: #643539;
+ --tt-color-highlight-gray-contrast: rgba(255, 255, 255, 0.094);
+ --tt-color-highlight-brown-contrast: rgba(184, 101, 69, 0.25);
+ --tt-color-highlight-orange-contrast: rgba(233, 126, 37, 0.2);
+ --tt-color-highlight-pink-contrast: rgba(220, 76, 145, 0.22);
+}
diff --git a/types/config.ts b/types/config.ts
new file mode 100644
index 0000000..bfeb964
--- /dev/null
+++ b/types/config.ts
@@ -0,0 +1,85 @@
+export interface Config {
+ // System Controls
+ volume: number
+ brightness: number
+ temperature: number
+ theme: string
+
+ // Server Configuration
+ serverName: string
+ apiKey: string
+ environment: string
+ region: string
+
+ // Performance Settings
+ maxConnections: number
+ cacheSize: number
+ threadCount: number
+ memoryLimit: number
+ diskQuota: number
+ networkBandwidth: number
+
+ // Security & Features
+ sslEnabled: boolean
+ autoBackup: boolean
+ compressionEnabled: boolean
+ debugMode: boolean
+ maintenanceMode: boolean
+ logLevel: string
+
+ // Notifications & Alerts
+ notifications: boolean
+ emailAlerts: boolean
+ smsAlerts: boolean
+ monitoringEnabled: boolean
+ language: string
+ timezone: string
+
+ // Advanced Configuration
+ description: string
+ selectedFeatures: string[]
+ deploymentStrategy: string
+}
+
+export const defaultConfig: Config = {
+ // System Controls
+ volume: 75,
+ brightness: 60,
+ temperature: 22,
+ theme: "dark",
+
+ // Server Configuration
+ serverName: "Production Server",
+ apiKey: "",
+ environment: "production",
+ region: "us-east-1",
+
+ // Performance Settings
+ maxConnections: 100,
+ cacheSize: 512,
+ threadCount: 8,
+ memoryLimit: 4096,
+ diskQuota: 1000,
+ networkBandwidth: 100,
+
+ // Security & Features
+ sslEnabled: true,
+ autoBackup: true,
+ compressionEnabled: false,
+ debugMode: false,
+ maintenanceMode: false,
+ logLevel: "info",
+
+ // Notifications & Alerts
+ notifications: true,
+ emailAlerts: true,
+ smsAlerts: false,
+ monitoringEnabled: true,
+ language: "en",
+ timezone: "UTC",
+
+ // Advanced Configuration
+ description: "",
+ selectedFeatures: ["analytics", "caching"],
+ deploymentStrategy: "rolling"
+}
\ No newline at end of file