@@ -20,7 +20,6 @@ import { cn } from '@/lib/utils';
2020import { Select , SelectContent , SelectItem , SelectTrigger , SelectValue } from '@/components/ui/select' ;
2121import type { Project , GlobalDocument } from '@/types' ;
2222
23-
2423const documentEditorFormSchema = z . object ( {
2524 title : z . string ( ) . min ( 1 , 'Title is required.' ) . max ( 255 ) ,
2625 content : z . string ( ) . optional ( ) ,
@@ -45,9 +44,76 @@ interface DocumentEditorProps {
4544interface MarkdownTool {
4645 label : string ;
4746 icon : React . ElementType ;
48- action : ( ) => void ;
47+ action : ( textarea : HTMLTextAreaElement ) => void ;
4948}
5049
50+ // Notion-like Block component
51+ const ContentBlock = ( {
52+ blockContent,
53+ onUpdate,
54+ onFocus,
55+ } : {
56+ blockContent : string ;
57+ onUpdate : ( newContent : string ) => void ;
58+ onFocus : ( ) => void ;
59+ } ) => {
60+ const [ isEditing , setIsEditing ] = useState ( false ) ;
61+ const [ localContent , setLocalContent ] = useState ( blockContent ) ;
62+ const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
63+
64+ useEffect ( ( ) => {
65+ setLocalContent ( blockContent ) ;
66+ } , [ blockContent ] ) ;
67+
68+ useEffect ( ( ) => {
69+ if ( isEditing && textareaRef . current ) {
70+ textareaRef . current . focus ( ) ;
71+ textareaRef . current . style . height = 'auto' ;
72+ textareaRef . current . style . height = `${ textareaRef . current . scrollHeight } px` ;
73+ }
74+ } , [ isEditing , localContent ] ) ;
75+
76+ const handleBlur = ( ) => {
77+ setIsEditing ( false ) ;
78+ if ( localContent !== blockContent ) {
79+ onUpdate ( localContent ) ;
80+ }
81+ } ;
82+
83+ const handleKeyDown = ( e : React . KeyboardEvent < HTMLTextAreaElement > ) => {
84+ if ( e . key === 'Enter' && ! e . shiftKey ) {
85+ e . preventDefault ( ) ;
86+ handleBlur ( ) ;
87+ }
88+ }
89+
90+ if ( isEditing ) {
91+ return (
92+ < Textarea
93+ ref = { textareaRef }
94+ value = { localContent }
95+ onChange = { ( e ) => setLocalContent ( e . target . value ) }
96+ onBlur = { handleBlur }
97+ onKeyDown = { handleKeyDown }
98+ onFocus = { onFocus }
99+ className = "w-full p-0 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent resize-none font-mono text-sm"
100+ placeholder = "Type '/' for commands..."
101+ />
102+ ) ;
103+ }
104+
105+ return (
106+ < div
107+ className = "prose dark:prose-invert max-w-none min-h-[24px] cursor-text"
108+ onClick = { ( ) => setIsEditing ( true ) }
109+ >
110+ < ReactMarkdown remarkPlugins = { [ remarkGfm ] } >
111+ { localContent || '' }
112+ </ ReactMarkdown >
113+ </ div >
114+ ) ;
115+ } ;
116+
51117export function DocumentEditor ( {
52118 initialData,
53119 onSave,
@@ -61,15 +127,13 @@ export function DocumentEditor({
61127} : DocumentEditorProps ) {
62128 const { toast } = useToast ( ) ;
63129 const [ isSubmitting , setIsSubmitting ] = useState ( false ) ;
64- const textareaRef = useRef < HTMLTextAreaElement > ( null ) ;
130+ const mainEditorRef = useRef < HTMLDivElement > ( null ) ;
131+ const [ activeTextarea , setActiveTextarea ] = useState < HTMLTextAreaElement | null > ( null ) ;
65132
66133 const [ isAiDialogOpen , setIsAiDialogOpen ] = useState ( false ) ;
67134 const [ aiPrompt , setAiPrompt ] = useState ( '' ) ;
68135 const [ isAiGenerating , setIsAiGenerating ] = useState ( false ) ;
69136
70- // WYSIWYG-like state
71- const [ isEditingContent , setIsEditingContent ] = useState ( false ) ;
72-
73137 const form = useForm < DocumentEditorFormValues > ( {
74138 resolver : zodResolver ( documentEditorFormSchema ) ,
75139 defaultValues : {
@@ -81,7 +145,7 @@ export function DocumentEditor({
81145 } ) ;
82146
83147 const contentValue = form . watch ( 'content' ) ;
84- const contentField = form . register ( 'content ') ;
148+ const contentBlocks = ( contentValue || '' ) . split ( '\n\n ') ;
85149
86150 useEffect ( ( ) => {
87151 form . reset ( {
@@ -92,14 +156,20 @@ export function DocumentEditor({
92156 } ) ;
93157 } , [ initialData , form ] ) ;
94158
159+ const updateContentBlock = ( index : number , newContent : string ) => {
160+ const newBlocks = [ ...contentBlocks ] ;
161+ newBlocks [ index ] = newContent ;
162+ form . setValue ( 'content' , newBlocks . join ( '\n\n' ) , { shouldDirty : true } ) ;
163+ } ;
164+
165+
95166 const applyMarkdownSyntax = (
96167 syntaxStart : string ,
97168 syntaxEnd : string = '' ,
98- _isBlock : boolean = false ,
99169 prefixEachLine : boolean = false
100170 ) => {
101- if ( ! textareaRef . current ) return ;
102- const { selectionStart, selectionEnd, value } = textareaRef . current ;
171+ if ( ! activeTextarea ) return ;
172+ const { selectionStart, selectionEnd, value } = activeTextarea ;
103173 const selectedText = value . substring ( selectionStart , selectionEnd ) ;
104174 let newText = '' ;
105175
@@ -113,42 +183,43 @@ export function DocumentEditor({
113183 }
114184
115185 const newValue = value . substring ( 0 , selectionStart ) + newText + value . substring ( selectionEnd ) ;
116- form . setValue ( 'content' , newValue , { shouldValidate : true , shouldDirty : true } ) ;
186+
187+ // This is tricky. We need to find which block this textarea belongs to.
188+ // For now, let's assume one active textarea. The parent component will handle state update.
189+ const event = new Event ( 'input' , { bubbles : true } ) ;
190+ activeTextarea . value = newValue ;
191+ activeTextarea . dispatchEvent ( event ) ;
192+
117193
118194 setTimeout ( ( ) => {
119- if ( textareaRef . current ) {
120- textareaRef . current . focus ( ) ;
195+ if ( activeTextarea ) {
196+ activeTextarea . focus ( ) ;
121197 if ( selectedText ) {
122- if ( prefixEachLine ) {
123- textareaRef . current . selectionStart = selectionStart ;
124- textareaRef . current . selectionEnd = selectionEnd + ( syntaxStart . length * selectedText . split ( '\n' ) . length ) ;
125- } else {
126- textareaRef . current . selectionStart = selectionStart + syntaxStart . length ;
127- textareaRef . current . selectionEnd = selectionEnd + syntaxStart . length ;
128- }
198+ activeTextarea . selectionStart = selectionStart + syntaxStart . length ;
199+ activeTextarea . selectionEnd = selectionEnd + syntaxStart . length ;
129200 } else {
130- textareaRef . current . selectionStart = selectionStart + syntaxStart . length ;
131- textareaRef . current . selectionEnd = selectionStart + syntaxStart . length ;
201+ activeTextarea . selectionStart = selectionStart + syntaxStart . length ;
202+ activeTextarea . selectionEnd = selectionStart + syntaxStart . length ;
132203 }
133204 }
134205 } , 0 ) ;
135206 } ;
136-
207+
137208 const markdownTools : MarkdownTool [ ] = [
138- { label : 'H1' , icon : Heading1 , action : ( ) => applyMarkdownSyntax ( '# ' , '' , false , true ) } ,
139- { label : 'H2' , icon : Heading2 , action : ( ) => applyMarkdownSyntax ( '## ' , '' , false , true ) } ,
140- { label : 'H3' , icon : Heading3 , action : ( ) => applyMarkdownSyntax ( '### ' , '' , false , true ) } ,
141- { label : 'Bold' , icon : Bold , action : ( ) => applyMarkdownSyntax ( '**' , '**' ) } ,
142- { label : 'Italic' , icon : Italic , action : ( ) => applyMarkdownSyntax ( '*' , '*' ) } ,
143- { label : 'Strikethrough' , icon : Strikethrough , action : ( ) => applyMarkdownSyntax ( '~~' , '~~' ) } ,
144- { label : 'Unordered List' , icon : List , action : ( ) => applyMarkdownSyntax ( '- ' , '' , false , true ) } ,
145- { label : 'Ordered List' , icon : ListOrdered , action : ( ) => applyMarkdownSyntax ( '1. ' , '' , false , true ) } ,
146- { label : 'Link' , icon : LinkIcon , action : ( ) => applyMarkdownSyntax ( '[' , '](url)' ) } ,
147- { label : 'Image' , icon : ImageIcon , action : ( ) => applyMarkdownSyntax ( '' ) } ,
148- { label : 'Code Block' , icon : SquareCode , action : ( ) => applyMarkdownSyntax ( '\n```\n' , '\n```\n' , true ) } ,
149- { label : 'Inline Code' , icon : Code2 , action : ( ) => applyMarkdownSyntax ( '`' , '`' ) } ,
150- { label : 'Quote' , icon : Quote , action : ( ) => applyMarkdownSyntax ( '> ' , '' , false , true ) } ,
151- { label : 'Horizontal Line' , icon : Minus , action : ( ) => applyMarkdownSyntax ( '\n---\n' , '' , true ) } ,
209+ { label : 'H1' , icon : Heading1 , action : ( ta ) => applyMarkdownSyntax ( '# ' , '' , true ) } ,
210+ { label : 'H2' , icon : Heading2 , action : ( ta ) => applyMarkdownSyntax ( '## ' , '' , true ) } ,
211+ { label : 'H3' , icon : Heading3 , action : ( ta ) => applyMarkdownSyntax ( '### ' , '' , true ) } ,
212+ { label : 'Bold' , icon : Bold , action : ( ta ) => applyMarkdownSyntax ( '**' , '**' ) } ,
213+ { label : 'Italic' , icon : Italic , action : ( ta ) => applyMarkdownSyntax ( '*' , '*' ) } ,
214+ { label : 'Strikethrough' , icon : Strikethrough , action : ( ta ) => applyMarkdownSyntax ( '~~' , '~~' ) } ,
215+ { label : 'Unordered List' , icon : List , action : ( ta ) => applyMarkdownSyntax ( '- ' , '' , true ) } ,
216+ { label : 'Ordered List' , icon : ListOrdered , action : ( ta ) => applyMarkdownSyntax ( '1. ' , '' , true ) } ,
217+ { label : 'Link' , icon : LinkIcon , action : ( ta ) => applyMarkdownSyntax ( '[' , '](url)' ) } ,
218+ { label : 'Image' , icon : ImageIcon , action : ( ta ) => applyMarkdownSyntax ( '' ) } ,
219+ { label : 'Code Block' , icon : SquareCode , action : ( ta ) => applyMarkdownSyntax ( '\n```\n' , '\n```\n' ) } ,
220+ { label : 'Inline Code' , icon : Code2 , action : ( ta ) => applyMarkdownSyntax ( '`' , '`' ) } ,
221+ { label : 'Quote' , icon : Quote , action : ( ta ) => applyMarkdownSyntax ( '> ' , '' , true ) } ,
222+ { label : 'Horizontal Line' , icon : Minus , action : ( ta ) => applyMarkdownSyntax ( '\n---\n' , '' ) } ,
152223 ] ;
153224
154225 const onSubmit = async ( data : DocumentEditorFormValues ) => {
@@ -198,7 +269,6 @@ export function DocumentEditor({
198269 }
199270 } ;
200271
201-
202272 return (
203273 < >
204274 < form onSubmit = { form . handleSubmit ( onSubmit ) } className = "space-y-6" >
@@ -228,16 +298,14 @@ export function DocumentEditor({
228298 < Label htmlFor = "ai-prompt" > Your Prompt</ Label >
229299 < Textarea
230300 id = "ai-prompt"
231- placeholder = "e.g., 'Create a getting started guide for a new SaaS product focusing on user onboarding and key features like X, Y, and Z .'"
301+ placeholder = "e.g., 'Create a getting started guide for a new SaaS product.. .'"
232302 value = { aiPrompt }
233303 onChange = { ( e ) => setAiPrompt ( e . target . value ) }
234304 rows = { 5 }
235305 />
236306 </ div >
237307 < DialogFooter >
238- < DialogClose asChild >
239- < Button type = "button" variant = "ghost" disabled = { isAiGenerating } > Cancel</ Button >
240- </ DialogClose >
308+ < DialogClose asChild > < Button type = "button" variant = "ghost" disabled = { isAiGenerating } > Cancel</ Button > </ DialogClose >
241309 < Button type = "button" onClick = { handleAiGenerate } disabled = { isAiGenerating || ! aiPrompt . trim ( ) } >
242310 { isAiGenerating && < Loader2 className = "mr-2 h-4 w-4 animate-spin" /> }
243311 Generate Content
@@ -278,7 +346,7 @@ export function DocumentEditor({
278346 name = "linkedProjectUuid"
279347 control = { form . control }
280348 render = { ( { field } ) => (
281- < Select onValueChange = { field . onChange } defaultValue = { field . value } value = { field . value } >
349+ < Select onValueChange = { field . onChange } value = { field . value || 'none' } >
282350 < SelectTrigger >
283351 < SelectValue placeholder = "Select a project to link..." />
284352 </ SelectTrigger >
@@ -305,40 +373,35 @@ export function DocumentEditor({
305373 type = "button"
306374 variant = "outline"
307375 size = "icon"
308- onClick = { tool . action }
376+ onClick = { ( ) => activeTextarea && tool . action ( activeTextarea ) }
309377 title = { tool . label }
310378 className = "h-8 w-8"
379+ disabled = { ! activeTextarea }
311380 >
312381 < tool . icon className = "h-4 w-4" />
313382 </ Button >
314383 ) ) }
315384 </ div >
316385 < div
317- className = { cn ( "border rounded-b-md p-4 min-h-[450px] bg-background focus-within:ring-2 focus-within:ring-ring" ) }
318- onClick = { ( ) => {
319- setIsEditingContent ( true ) ;
320- setTimeout ( ( ) => textareaRef . current ?. focus ( ) , 0 ) ;
386+ ref = { mainEditorRef }
387+ className = "border rounded-b-md p-4 min-h-[450px] bg-background focus-within:ring-2 focus-within:ring-ring space-y-2"
388+ onFocus = { ( e ) => {
389+ if ( e . target . tagName === 'TEXTAREA' ) {
390+ setActiveTextarea ( e . target as HTMLTextAreaElement ) ;
391+ }
321392 } }
322393 >
323- { isEditingContent ? (
324- < Textarea
325- id = "content"
326- { ...contentField }
327- ref = { ( e ) => {
328- contentField . ref ( e ) ;
329- textareaRef . current = e ;
330- } }
331- className = "w-full h-full min-h-[450px] p-0 border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent resize-none font-mono text-sm"
332- placeholder = "Write your content here..."
333- onBlur = { ( ) => setIsEditingContent ( false ) }
334- />
335- ) : (
336- < div className = "prose dark:prose-invert max-w-none" >
337- < ReactMarkdown remarkPlugins = { [ remarkGfm ] } >
338- { contentValue || '*Click to start writing*' }
339- </ ReactMarkdown >
340- </ div >
341- ) }
394+ { contentBlocks . map ( ( block , index ) => (
395+ < ContentBlock
396+ key = { index }
397+ blockContent = { block }
398+ onUpdate = { ( newBlockContent ) => updateContentBlock ( index , newBlockContent ) }
399+ onFocus = { ( ) => {
400+ const textarea = mainEditorRef . current ?. querySelectorAll ( 'textarea' ) [ index ] ;
401+ if ( textarea ) setActiveTextarea ( textarea ) ;
402+ } }
403+ />
404+ ) ) }
342405 </ div >
343406 </ div >
344407
@@ -357,3 +420,4 @@ export function DocumentEditor({
357420 </ >
358421 ) ;
359422}
423+
0 commit comments