Improvements to Editor
This commit is contained in:
@@ -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}</${tag}>`;
|
||||
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' ? '<h1>Heading 1</h1>' :
|
||||
tag === 'h2' ? '<h2>Heading 2</h2>' :
|
||||
tag === 'h3' ? '<h3>Heading 3</h3>' :
|
||||
tag === 'h4' ? '<h4>Heading 4</h4>' :
|
||||
tag === 'h5' ? '<h5>Heading 5</h5>' :
|
||||
tag === 'h6' ? '<h6>Heading 6</h6>' :
|
||||
`<${tag}>Formatted text</${tag}>`;
|
||||
|
||||
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}</${tag}>`;
|
||||
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' ? '<h1>Heading 1</h1>' :
|
||||
tag === 'h2' ? '<h2>Heading 2</h2>' :
|
||||
tag === 'h3' ? '<h3>Heading 3</h3>' :
|
||||
`<${tag}>Formatted text</${tag}>`;
|
||||
|
||||
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 (
|
||||
<div className="space-y-2">
|
||||
@@ -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
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h4')}
|
||||
title="Heading 4 (Ctrl+Shift+4)"
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
H4
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h5')}
|
||||
title="Heading 5 (Ctrl+Shift+5)"
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
H5
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h6')}
|
||||
title="Heading 6 (Ctrl+Shift+6)"
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
H6
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -625,7 +689,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('strong')}
|
||||
title="Bold"
|
||||
title="Bold (Ctrl+B)"
|
||||
className="font-bold"
|
||||
>
|
||||
B
|
||||
@@ -635,7 +699,7 @@ export default function RichTextEditor({
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('em')}
|
||||
title="Italic"
|
||||
title="Italic (Ctrl+I)"
|
||||
className="italic"
|
||||
>
|
||||
I
|
||||
@@ -646,7 +710,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
|
||||
@@ -656,7 +720,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
|
||||
@@ -666,11 +730,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
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h4')}
|
||||
title="Heading 4 (Ctrl+Shift+4)"
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
H4
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h5')}
|
||||
title="Heading 5 (Ctrl+Shift+5)"
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
H5
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => formatText('h6')}
|
||||
title="Heading 6 (Ctrl+Shift+6)"
|
||||
className="text-xs font-bold"
|
||||
>
|
||||
H6
|
||||
</Button>
|
||||
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||
<Button
|
||||
type="button"
|
||||
@@ -751,6 +845,9 @@ export default function RichTextEditor({
|
||||
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
|
||||
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
|
||||
</p>
|
||||
<p>
|
||||
<strong>Tips:</strong> Use the ⊞ button to maximize the editor for larger stories.
|
||||
Drag the resize handle at the bottom to adjust height. Press Escape to exit maximized mode.
|
||||
|
||||
@@ -38,10 +38,8 @@ export default function TableOfContents({
|
||||
headings.forEach((heading, index) => {
|
||||
const level = parseInt(heading.tagName.charAt(1));
|
||||
let title = heading.textContent?.trim() || '';
|
||||
const id = `heading-${index}`;
|
||||
|
||||
// Add ID to heading for later reference
|
||||
heading.id = id;
|
||||
// Use existing ID if present, otherwise fall back to index-based ID
|
||||
const id = heading.id || `heading-${index}`;
|
||||
|
||||
// Handle empty headings with a fallback
|
||||
if (!title) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user