"use client" import { cn } from "@lib/utils" import { Button } from "@repo/ui/components/button" import isHotkey from "is-hotkey" import { Bold, Code, Heading1, Heading2, Heading3, Italic, List, Quote, } from "lucide-react" import { useCallback, useMemo, useState } from "react" import { type BaseEditor, createEditor, type Descendant, Editor, Transforms, } from "slate" import type { ReactEditor as ReactEditorType } from "slate-react" import { Editable, ReactEditor, type RenderElementProps, type RenderLeafProps, Slate, withReact, } from "slate-react" type CustomEditor = BaseEditor & ReactEditorType type ParagraphElement = { type: "paragraph" children: CustomText[] } type HeadingElement = { type: "heading" level: number children: CustomText[] } type ListItemElement = { type: "list-item" children: CustomText[] } type BlockQuoteElement = { type: "block-quote" children: CustomText[] } type CustomElement = | ParagraphElement | HeadingElement | ListItemElement | BlockQuoteElement type FormattedText = { text: string bold?: true italic?: true code?: true } type CustomText = FormattedText declare module "slate" { interface CustomTypes { Editor: CustomEditor Element: CustomElement Text: CustomText } } // Hotkey mappings const HOTKEYS: Record = { "mod+b": "bold", "mod+i": "italic", "mod+`": "code", } interface TextEditorProps { value?: string onChange?: (value: string) => void onBlur?: () => void placeholder?: string disabled?: boolean className?: string containerClassName?: string } const initialValue: Descendant[] = [ { type: "paragraph", children: [{ text: "" }], }, ] const serialize = (nodes: Descendant[]): string => { return nodes.map((n) => serializeNode(n)).join("\n") } const serializeNode = (node: CustomElement | CustomText): string => { if ("text" in node) { let text = node.text if (node.bold) text = `**${text}**` if (node.italic) text = `*${text}*` if (node.code) text = `\`${text}\`` return text } const children = node.children ? node.children.map(serializeNode).join("") : "" switch (node.type) { case "paragraph": return children case "heading": return `${"#".repeat(node.level || 1)} ${children}` case "list-item": return `- ${children}` case "block-quote": return `> ${children}` default: return children } } const deserialize = (text: string): Descendant[] => { if (!text.trim()) { return initialValue } const lines = text.split("\n") const nodes: Descendant[] = [] for (const line of lines) { const trimmedLine = line.trim() if (trimmedLine.startsWith("# ")) { nodes.push({ type: "heading", level: 1, children: [{ text: trimmedLine.slice(2) }], }) } else if (trimmedLine.startsWith("## ")) { nodes.push({ type: "heading", level: 2, children: [{ text: trimmedLine.slice(3) }], }) } else if (trimmedLine.startsWith("### ")) { nodes.push({ type: "heading", level: 3, children: [{ text: trimmedLine.slice(4) }], }) } else if (trimmedLine.startsWith("- ")) { nodes.push({ type: "list-item", children: [{ text: trimmedLine.slice(2) }], }) } else if (trimmedLine.startsWith("> ")) { nodes.push({ type: "block-quote", children: [{ text: trimmedLine.slice(2) }], }) } else { nodes.push({ type: "paragraph", children: [{ text: line }], }) } } return nodes.length > 0 ? nodes : initialValue } const isMarkActive = (editor: CustomEditor, format: keyof CustomText) => { const marks = Editor.marks(editor) return marks ? marks[format as keyof typeof marks] === true : false } const toggleMark = (editor: CustomEditor, format: keyof CustomText) => { const isActive = isMarkActive(editor, format) if (isActive) { Editor.removeMark(editor, format) } else { Editor.addMark(editor, format, true) } // Focus back to editor after toggling ReactEditor.focus(editor) } const isBlockActive = ( editor: CustomEditor, format: string, level?: number, ) => { const { selection } = editor if (!selection) return false const [match] = Array.from( Editor.nodes(editor, { at: Editor.unhangRange(editor, selection), match: (n) => !Editor.isEditor(n) && (n as CustomElement).type === format && (level === undefined || (n as HeadingElement).level === level), }), ) return !!match } const toggleBlock = (editor: CustomEditor, format: string, level?: number) => { const isActive = isBlockActive(editor, format, level) const newProperties: any = { type: isActive ? "paragraph" : format, } if (format === "heading" && level && !isActive) { newProperties.level = level } Transforms.setNodes(editor, newProperties) // Focus back to editor after toggling ReactEditor.focus(editor) } export function TextEditor({ value = "", onChange, onBlur, placeholder = "Start writing...", disabled = false, className, containerClassName, }: TextEditorProps) { const editor = useMemo(() => withReact(createEditor()) as CustomEditor, []) const [editorValue, setEditorValue] = useState(() => deserialize(value), ) const [selection, setSelection] = useState(editor.selection) const renderElement = useCallback((props: RenderElementProps) => { switch (props.element.type) { case "heading": { const element = props.element as HeadingElement const HeadingTag = `h${element.level || 1}` as | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" return ( {props.children} ) } case "list-item": return (
  • {props.children}
  • ) case "block-quote": return (
    {props.children}
    ) default: return (

    {props.children}

    ) } }, []) const renderLeaf = useCallback((props: RenderLeafProps) => { let { attributes, children, leaf } = props if (leaf.bold) { children = {children} } if (leaf.italic) { children = {children} } if (leaf.code) { children = ( {children} ) } return {children} }, []) const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { // Handle hotkeys for formatting for (const hotkey in HOTKEYS) { if (isHotkey(hotkey, event)) { event.preventDefault() const mark = HOTKEYS[hotkey] if (mark) { toggleMark(editor, mark) } return } } // Handle block formatting hotkeys if (isHotkey("mod+shift+1", event)) { event.preventDefault() toggleBlock(editor, "heading", 1) return } if (isHotkey("mod+shift+2", event)) { event.preventDefault() toggleBlock(editor, "heading", 2) return } if (isHotkey("mod+shift+3", event)) { event.preventDefault() toggleBlock(editor, "heading", 3) return } if (isHotkey("mod+shift+8", event)) { event.preventDefault() toggleBlock(editor, "list-item") return } if (isHotkey("mod+shift+.", event)) { event.preventDefault() toggleBlock(editor, "block-quote") return } }, [editor], ) const handleSlateChange = useCallback( (newValue: Descendant[]) => { setEditorValue(newValue) const serializedValue = serialize(newValue) onChange?.(serializedValue) }, [onChange], ) // Memoized active states that update when selection changes const activeStates = useMemo( () => ({ bold: isMarkActive(editor, "bold"), italic: isMarkActive(editor, "italic"), code: isMarkActive(editor, "code"), heading1: isBlockActive(editor, "heading", 1), heading2: isBlockActive(editor, "heading", 2), heading3: isBlockActive(editor, "heading", 3), listItem: isBlockActive(editor, "list-item"), blockQuote: isBlockActive(editor, "block-quote"), }), [editor, selection], ) const ToolbarButton = ({ icon: Icon, isActive, onMouseDown, title, }: { icon: React.ComponentType<{ className?: string }> isActive: boolean onMouseDown: (event: React.MouseEvent) => void title: string }) => ( ) return (
    setSelection(editor.selection)} > { return (
    {children}
    ) }} onKeyDown={handleKeyDown} onBlur={onBlur} readOnly={disabled} className={cn( "outline-none w-full h-full placeholder:text-foreground/50", disabled && "opacity-50 cursor-not-allowed", )} style={{ minHeight: "23rem", maxHeight: "23rem", padding: "12px", overflowX: "hidden", }} />
    {/* Toolbar */}
    {/* Text formatting */} { event.preventDefault() toggleMark(editor, "bold") }} title="Bold (Ctrl/Cmd+B)" /> { event.preventDefault() toggleMark(editor, "italic") }} title="Italic (Ctrl/Cmd+I)" /> { event.preventDefault() toggleMark(editor, "code") }} title="Code (Ctrl/Cmd+`)" />
    {/* Block formatting */} { event.preventDefault() toggleBlock(editor, "heading", 1) }} title="Heading 1 (Ctrl/Cmd+Shift+1)" /> { event.preventDefault() toggleBlock(editor, "heading", 2) }} title="Heading 2 (Ctrl/Cmd+Shift+2)" /> { event.preventDefault() toggleBlock(editor, "heading", 3) }} title="Heading 3" /> { event.preventDefault() toggleBlock(editor, "list-item") }} title="Bullet List" /> { event.preventDefault() toggleBlock(editor, "block-quote") }} title="Quote" />
    ) }