1+ 'use client'
2+
3+ import * as React from 'react'
4+ import { useEditor , EditorContent } from '@tiptap/react'
5+ import StarterKit from '@tiptap/starter-kit'
6+ import { Markdown } from 'tiptap-markdown'
7+ import { Bold , Italic , List , ListOrdered , Code , Heading1 , Heading2 , Heading3 } from 'lucide-react'
8+ import { Button } from '@/components/ui/button'
9+ import { Card } from '@/components/ui/card'
10+ import { Textarea } from '@/components/ui/textarea'
11+ import {
12+ Tooltip ,
13+ TooltipContent ,
14+ TooltipProvider ,
15+ TooltipTrigger ,
16+ } from '@/components/ui/tooltip'
17+
18+ interface TipTapEditorProps {
19+ content : string
20+ onChange : ( content : string ) => void
21+ className ?: string
22+ }
23+
24+ export function TipTapEditor ( { content, onChange, className = '' } : TipTapEditorProps ) {
25+ const [ isFocused , setIsFocused ] = React . useState ( false ) ;
26+ const [ isRawMode , setIsRawMode ] = React . useState ( false ) ;
27+ const [ rawMarkdown , setRawMarkdown ] = React . useState ( content || '' ) ;
28+
29+ const editor = useEditor ( {
30+ extensions : [
31+ StarterKit . configure ( {
32+ bold : {
33+ HTMLAttributes : {
34+ class : 'font-bold'
35+ }
36+ } ,
37+ heading : {
38+ levels : [ 1 , 2 , 3 ]
39+ }
40+ } ) ,
41+ Markdown . configure ( {
42+ html : false ,
43+ transformPastedText : true ,
44+ transformCopiedText : true
45+ } )
46+ ] ,
47+ content : rawMarkdown ,
48+ onUpdate : ( { editor } ) => {
49+ // Use the markdown extension to get markdown content
50+ const markdown = editor . storage . markdown . getMarkdown ( ) ;
51+ setRawMarkdown ( markdown ) ;
52+ onChange ( markdown ) ;
53+ } ,
54+ editorProps : {
55+ attributes : {
56+ class : 'prose prose-sm dark:prose-invert focus:outline-none max-w-none p-4 min-h-[200px]'
57+ }
58+ } ,
59+ onFocus : ( ) => setIsFocused ( true ) ,
60+ onBlur : ( ) => setIsFocused ( false )
61+ } ) ;
62+
63+ // Update raw markdown when content changes from outside
64+ React . useEffect ( ( ) => {
65+ if ( ! isRawMode ) {
66+ setRawMarkdown ( content || '' ) ;
67+ }
68+ } , [ content , isRawMode ] ) ;
69+
70+ // Global event handler for keyboard shortcuts
71+ React . useEffect ( ( ) => {
72+ const handleKeyDown = ( e : KeyboardEvent ) => {
73+ if ( isFocused && ( e . metaKey || e . ctrlKey ) && e . key . toLowerCase ( ) === 'b' ) {
74+ // Prevent default behavior AND stop propagation
75+ e . preventDefault ( ) ;
76+ e . stopPropagation ( ) ;
77+
78+ // Manually toggle bold in the editor
79+ if ( editor ) {
80+ editor . chain ( ) . focus ( ) . toggleBold ( ) . run ( ) ;
81+ }
82+
83+ return false ;
84+ }
85+ } ;
86+
87+ // Add to document level to catch all events
88+ document . addEventListener ( 'keydown' , handleKeyDown , true ) ;
89+
90+ return ( ) => {
91+ document . removeEventListener ( 'keydown' , handleKeyDown , true ) ;
92+ } ;
93+ } , [ isFocused , editor ] ) ;
94+
95+ // Toggle between rich text and raw markdown modes
96+ const toggleEditMode = ( ) => {
97+ if ( isRawMode && editor ) {
98+ // When switching from raw to rich, set the markdown content
99+ editor . commands . clearContent ( ) ;
100+ editor . commands . insertContent ( rawMarkdown ) ;
101+ }
102+ setIsRawMode ( ! isRawMode ) ;
103+ } ;
104+
105+ // Handle raw markdown changes
106+ const handleRawMarkdownChange = ( e : React . ChangeEvent < HTMLTextAreaElement > ) => {
107+ const newValue = e . target . value ;
108+ setRawMarkdown ( newValue ) ;
109+ onChange ( newValue ) ;
110+ } ;
111+
112+ if ( ! editor && ! isRawMode ) {
113+ return null ;
114+ }
115+
116+ const tools = [
117+ {
118+ icon : Heading1 ,
119+ title : 'Heading 1' ,
120+ action : ( ) => editor ?. chain ( ) . focus ( ) . toggleHeading ( { level : 1 } ) . run ( ) ,
121+ isActive : ( ) => editor ?. isActive ( 'heading' , { level : 1 } ) || false ,
122+ showInRawMode : false ,
123+ } ,
124+ {
125+ icon : Heading2 ,
126+ title : 'Heading 2' ,
127+ action : ( ) => editor ?. chain ( ) . focus ( ) . toggleHeading ( { level : 2 } ) . run ( ) ,
128+ isActive : ( ) => editor ?. isActive ( 'heading' , { level : 2 } ) || false ,
129+ showInRawMode : false ,
130+ } ,
131+ {
132+ icon : Heading3 ,
133+ title : 'Heading 3' ,
134+ action : ( ) => editor ?. chain ( ) . focus ( ) . toggleHeading ( { level : 3 } ) . run ( ) ,
135+ isActive : ( ) => editor ?. isActive ( 'heading' , { level : 3 } ) || false ,
136+ showInRawMode : false ,
137+ } ,
138+ {
139+ icon : Bold ,
140+ title : 'Bold' ,
141+ action : ( ) => editor ?. chain ( ) . focus ( ) . toggleBold ( ) . run ( ) ,
142+ isActive : ( ) => editor ?. isActive ( 'bold' ) || false ,
143+ showInRawMode : false ,
144+ } ,
145+ {
146+ icon : Italic ,
147+ title : 'Italic' ,
148+ action : ( ) => editor ?. chain ( ) . focus ( ) . toggleItalic ( ) . run ( ) ,
149+ isActive : ( ) => editor ?. isActive ( 'italic' ) || false ,
150+ showInRawMode : false ,
151+ } ,
152+ {
153+ icon : List ,
154+ title : 'Bullet List' ,
155+ action : ( ) => editor ?. chain ( ) . focus ( ) . toggleBulletList ( ) . run ( ) ,
156+ isActive : ( ) => editor ?. isActive ( 'bulletList' ) || false ,
157+ showInRawMode : false ,
158+ } ,
159+ {
160+ icon : ListOrdered ,
161+ title : 'Numbered List' ,
162+ action : ( ) => editor ?. chain ( ) . focus ( ) . toggleOrderedList ( ) . run ( ) ,
163+ isActive : ( ) => editor ?. isActive ( 'orderedList' ) || false ,
164+ showInRawMode : false ,
165+ } ,
166+ {
167+ icon : Code ,
168+ title : 'Toggle Raw Markdown' ,
169+ action : toggleEditMode ,
170+ isActive : ( ) => isRawMode ,
171+ showInRawMode : true ,
172+ } ,
173+ ] ;
174+
175+ return (
176+ < Card className = { `min-h-[300px] ${ className } ` } >
177+ < div className = "flex items-center gap-1 border-b p-2" >
178+ < TooltipProvider >
179+ { tools
180+ . filter ( tool => isRawMode ? tool . showInRawMode : true )
181+ . map ( ( Tool ) => (
182+ < Tooltip key = { Tool . title } >
183+ < TooltipTrigger asChild >
184+ < Button
185+ variant = { Tool . isActive ( ) ? "secondary" : "ghost" }
186+ size = "icon"
187+ className = "h-8 w-8"
188+ onClick = { Tool . action }
189+ title = { Tool . title }
190+ >
191+ < Tool . icon className = "h-4 w-4" />
192+ </ Button >
193+ </ TooltipTrigger >
194+ < TooltipContent > { Tool . title } </ TooltipContent >
195+ </ Tooltip >
196+ ) ) }
197+ </ TooltipProvider >
198+ </ div >
199+
200+ { isRawMode ? (
201+ < Textarea
202+ value = { rawMarkdown }
203+ onChange = { handleRawMarkdownChange }
204+ className = "min-h-[200px] resize-none border-none rounded-none focus-visible:ring-0 p-4 font-mono text-sm"
205+ placeholder = "Enter markdown here..."
206+ />
207+ ) : (
208+ < div className = "[&_.ProseMirror]:focus-visible:outline-none [&_.ProseMirror]:focus-visible:ring-0" >
209+ < EditorContent
210+ editor = { editor }
211+ className = "[&_ul]:list-disc [&_ul]:ml-4 [&_ol]:list-decimal [&_ol]:ml-4 [&_h1]:text-3xl [&_h1]:font-bold [&_h1]:mb-4 [&_h2]:text-2xl [&_h2]:font-bold [&_h2]:mb-3 [&_h3]:text-xl [&_h3]:font-bold [&_h3]:mb-2"
212+ />
213+ </ div >
214+ ) }
215+ </ Card >
216+ )
217+ }
0 commit comments