diff --git a/frontend/src/components/stories/RichTextEditor.tsx b/frontend/src/components/stories/RichTextEditor.tsx index 91f9104..bd6f93b 100644 --- a/frontend/src/components/stories/RichTextEditor.tsx +++ b/frontend/src/components/stories/RichTextEditor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import { Textarea } from '../ui/Input'; import Button from '../ui/Button'; import { sanitizeHtmlSync } from '../../lib/sanitization'; @@ -74,6 +74,104 @@ export default function RichTextEditor({ setIsMaximized(!isMaximized); }; + const formatText = useCallback((tag: string) => { + if (viewMode === 'visual') { + const visualDiv = visualDivRef.current; + if (!visualDiv) return; + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const selectedText = range.toString(); + + if (selectedText) { + // Wrap selected text in the formatting tag + const formattedElement = document.createElement(tag); + formattedElement.textContent = selectedText; + + range.deleteContents(); + range.insertNode(formattedElement); + + // Move cursor to end of inserted content + range.selectNodeContents(formattedElement); + range.collapse(false); + selection.removeAllRanges(); + selection.addRange(range); + } else { + // No selection - insert template + const template = tag === 'h1' ? 'Heading 1' : + tag === 'h2' ? 'Heading 2' : + tag === 'h3' ? 'Heading 3' : + tag === 'h4' ? 'Heading 4' : + tag === 'h5' ? 'Heading 5' : + tag === 'h6' ? 'Heading 6' : + 'Formatted text'; + + const formattedElement = document.createElement(tag); + formattedElement.textContent = template; + + range.insertNode(formattedElement); + + // Select the inserted text for easy editing + range.selectNodeContents(formattedElement); + selection.removeAllRanges(); + selection.addRange(range); + } + + // Update the state + setIsUserTyping(true); + onChange(visualDiv.innerHTML); + setHtmlValue(visualDiv.innerHTML); + setTimeout(() => setIsUserTyping(false), 100); + } + } else { + // HTML mode - existing logic with improvements + const textarea = document.querySelector('textarea') as HTMLTextAreaElement; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = htmlValue.substring(start, end); + + if (selectedText) { + const beforeText = htmlValue.substring(0, start); + const afterText = htmlValue.substring(end); + const formattedText = `<${tag}>${selectedText}`; + const newValue = beforeText + formattedText + afterText; + + setHtmlValue(newValue); + onChange(newValue); + + // Restore cursor position + setTimeout(() => { + textarea.focus(); + textarea.setSelectionRange(start, start + formattedText.length); + }, 0); + } else { + // No selection - insert template at cursor + const template = tag === 'h1' ? '

Heading 1

' : + tag === 'h2' ? '

Heading 2

' : + tag === 'h3' ? '

Heading 3

' : + tag === 'h4' ? '

Heading 4

' : + tag === 'h5' ? '
Heading 5
' : + tag === 'h6' ? '
Heading 6
' : + `<${tag}>Formatted text`; + + const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start); + setHtmlValue(newValue); + onChange(newValue); + + // Position cursor inside the new tag + setTimeout(() => { + const tagLength = `<${tag}>`.length; + const newPosition = start + tagLength; + textarea.focus(); + textarea.setSelectionRange(newPosition, newPosition + (tag === 'p' ? 0 : template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length)); + }, 0); + } + } + }, [viewMode, htmlValue, onChange]); + // Handle manual resize when dragging resize handle const handleMouseDown = (e: React.MouseEvent) => { if (isMaximized) return; // Don't allow resize when maximized @@ -97,16 +195,43 @@ export default function RichTextEditor({ document.addEventListener('mouseup', handleMouseUp); }; - // Escape key handler for maximized mode + // Keyboard shortcuts handler useEffect(() => { - const handleEscapeKey = (e: KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { + // Escape key to exit maximized mode if (e.key === 'Escape' && isMaximized) { setIsMaximized(false); + return; + } + + // Heading shortcuts: Ctrl+Shift+1-6 + if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { + const num = parseInt(e.key); + if (num >= 1 && num <= 6) { + e.preventDefault(); + formatText(`h${num}`); + return; + } + } + + // Additional common shortcuts + if (e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { + switch (e.key.toLowerCase()) { + case 'b': + e.preventDefault(); + formatText('strong'); + return; + case 'i': + e.preventDefault(); + formatText('em'); + return; + } } }; + document.addEventListener('keydown', handleKeyDown); + if (isMaximized) { - document.addEventListener('keydown', handleEscapeKey); // Prevent body from scrolling when maximized document.body.style.overflow = 'hidden'; } else { @@ -114,10 +239,10 @@ export default function RichTextEditor({ } return () => { - document.removeEventListener('keydown', handleEscapeKey); + document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; - }, [isMaximized]); + }, [isMaximized, formatText]); // Set initial content when component mounts useEffect(() => { @@ -380,97 +505,6 @@ export default function RichTextEditor({ .trim(); }; - const formatText = (tag: string) => { - if (viewMode === 'visual') { - const visualDiv = visualDivRef.current; - if (!visualDiv) return; - - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - const selectedText = range.toString(); - - if (selectedText) { - // Wrap selected text in the formatting tag - const formattedElement = document.createElement(tag); - formattedElement.textContent = selectedText; - - range.deleteContents(); - range.insertNode(formattedElement); - - // Move cursor to end of inserted content - range.selectNodeContents(formattedElement); - range.collapse(false); - selection.removeAllRanges(); - selection.addRange(range); - } else { - // No selection - insert template - const template = tag === 'h1' ? 'Heading 1' : - tag === 'h2' ? 'Heading 2' : - tag === 'h3' ? 'Heading 3' : - 'Formatted text'; - - const formattedElement = document.createElement(tag); - formattedElement.textContent = template; - - range.insertNode(formattedElement); - - // Select the inserted text for easy editing - range.selectNodeContents(formattedElement); - selection.removeAllRanges(); - selection.addRange(range); - } - - // Update the state - setIsUserTyping(true); - onChange(visualDiv.innerHTML); - setHtmlValue(visualDiv.innerHTML); - setTimeout(() => setIsUserTyping(false), 100); - } - } else { - // HTML mode - existing logic with improvements - const textarea = document.querySelector('textarea') as HTMLTextAreaElement; - if (!textarea) return; - - const start = textarea.selectionStart; - const end = textarea.selectionEnd; - const selectedText = htmlValue.substring(start, end); - - if (selectedText) { - const beforeText = htmlValue.substring(0, start); - const afterText = htmlValue.substring(end); - const formattedText = `<${tag}>${selectedText}`; - const newValue = beforeText + formattedText + afterText; - - setHtmlValue(newValue); - onChange(newValue); - - // Restore cursor position - setTimeout(() => { - textarea.focus(); - textarea.setSelectionRange(start, start + formattedText.length); - }, 0); - } else { - // No selection - insert template at cursor - const template = tag === 'h1' ? '

Heading 1

' : - tag === 'h2' ? '

Heading 2

' : - tag === 'h3' ? '

Heading 3

' : - `<${tag}>Formatted text`; - - const newValue = htmlValue.substring(0, start) + template + htmlValue.substring(start); - setHtmlValue(newValue); - onChange(newValue); - - // Position cursor inside the new tag - setTimeout(() => { - const tagLength = `<${tag}>`.length; - const newPosition = start + tagLength; - textarea.focus(); - textarea.setSelectionRange(newPosition, newPosition + (tag === 'p' ? 0 : template.includes('Heading') ? template.split('>')[1].split('<')[0].length : 'Formatted text'.length)); - }, 0); - } - } - }; return (
@@ -514,7 +548,7 @@ export default function RichTextEditor({ size="sm" variant="ghost" onClick={() => formatText('strong')} - title="Bold" + title="Bold (Ctrl+B)" className="font-bold" > B @@ -524,7 +558,7 @@ export default function RichTextEditor({ size="sm" variant="ghost" onClick={() => formatText('em')} - title="Italic" + title="Italic (Ctrl+I)" className="italic" > I @@ -535,7 +569,7 @@ export default function RichTextEditor({ size="sm" variant="ghost" onClick={() => formatText('h1')} - title="Heading 1" + title="Heading 1 (Ctrl+Shift+1)" className="text-lg font-bold" > H1 @@ -545,7 +579,7 @@ export default function RichTextEditor({ size="sm" variant="ghost" onClick={() => formatText('h2')} - title="Heading 2" + title="Heading 2 (Ctrl+Shift+2)" className="text-base font-bold" > H2 @@ -555,11 +589,41 @@ export default function RichTextEditor({ size="sm" variant="ghost" onClick={() => formatText('h3')} - title="Heading 3" + title="Heading 3 (Ctrl+Shift+3)" className="text-sm font-bold" > H3 + + +
+ + +