Improvements to Editor
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { Textarea } from '../ui/Input';
|
import { Textarea } from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
import { sanitizeHtmlSync } from '../../lib/sanitization';
|
||||||
@@ -74,6 +74,104 @@ export default function RichTextEditor({
|
|||||||
setIsMaximized(!isMaximized);
|
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
|
// Handle manual resize when dragging resize handle
|
||||||
const handleMouseDown = (e: React.MouseEvent) => {
|
const handleMouseDown = (e: React.MouseEvent) => {
|
||||||
if (isMaximized) return; // Don't allow resize when maximized
|
if (isMaximized) return; // Don't allow resize when maximized
|
||||||
@@ -97,16 +195,43 @@ export default function RichTextEditor({
|
|||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Escape key handler for maximized mode
|
// Keyboard shortcuts handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Escape key to exit maximized mode
|
||||||
if (e.key === 'Escape' && isMaximized) {
|
if (e.key === 'Escape' && isMaximized) {
|
||||||
setIsMaximized(false);
|
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) {
|
if (isMaximized) {
|
||||||
document.addEventListener('keydown', handleEscapeKey);
|
|
||||||
// Prevent body from scrolling when maximized
|
// Prevent body from scrolling when maximized
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
} else {
|
} else {
|
||||||
@@ -114,10 +239,10 @@ export default function RichTextEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleEscapeKey);
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
};
|
};
|
||||||
}, [isMaximized]);
|
}, [isMaximized, formatText]);
|
||||||
|
|
||||||
// Set initial content when component mounts
|
// Set initial content when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -380,97 +505,6 @@ export default function RichTextEditor({
|
|||||||
.trim();
|
.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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -514,7 +548,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('strong')}
|
onClick={() => formatText('strong')}
|
||||||
title="Bold"
|
title="Bold (Ctrl+B)"
|
||||||
className="font-bold"
|
className="font-bold"
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
@@ -524,7 +558,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('em')}
|
onClick={() => formatText('em')}
|
||||||
title="Italic"
|
title="Italic (Ctrl+I)"
|
||||||
className="italic"
|
className="italic"
|
||||||
>
|
>
|
||||||
I
|
I
|
||||||
@@ -535,7 +569,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h1')}
|
onClick={() => formatText('h1')}
|
||||||
title="Heading 1"
|
title="Heading 1 (Ctrl+Shift+1)"
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
>
|
>
|
||||||
H1
|
H1
|
||||||
@@ -545,7 +579,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h2')}
|
onClick={() => formatText('h2')}
|
||||||
title="Heading 2"
|
title="Heading 2 (Ctrl+Shift+2)"
|
||||||
className="text-base font-bold"
|
className="text-base font-bold"
|
||||||
>
|
>
|
||||||
H2
|
H2
|
||||||
@@ -555,11 +589,41 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h3')}
|
onClick={() => formatText('h3')}
|
||||||
title="Heading 3"
|
title="Heading 3 (Ctrl+Shift+3)"
|
||||||
className="text-sm font-bold"
|
className="text-sm font-bold"
|
||||||
>
|
>
|
||||||
H3
|
H3
|
||||||
</Button>
|
</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" />
|
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -625,7 +689,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('strong')}
|
onClick={() => formatText('strong')}
|
||||||
title="Bold"
|
title="Bold (Ctrl+B)"
|
||||||
className="font-bold"
|
className="font-bold"
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
@@ -635,7 +699,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('em')}
|
onClick={() => formatText('em')}
|
||||||
title="Italic"
|
title="Italic (Ctrl+I)"
|
||||||
className="italic"
|
className="italic"
|
||||||
>
|
>
|
||||||
I
|
I
|
||||||
@@ -646,7 +710,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h1')}
|
onClick={() => formatText('h1')}
|
||||||
title="Heading 1"
|
title="Heading 1 (Ctrl+Shift+1)"
|
||||||
className="text-lg font-bold"
|
className="text-lg font-bold"
|
||||||
>
|
>
|
||||||
H1
|
H1
|
||||||
@@ -656,7 +720,7 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h2')}
|
onClick={() => formatText('h2')}
|
||||||
title="Heading 2"
|
title="Heading 2 (Ctrl+Shift+2)"
|
||||||
className="text-base font-bold"
|
className="text-base font-bold"
|
||||||
>
|
>
|
||||||
H2
|
H2
|
||||||
@@ -666,11 +730,41 @@ export default function RichTextEditor({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => formatText('h3')}
|
onClick={() => formatText('h3')}
|
||||||
title="Heading 3"
|
title="Heading 3 (Ctrl+Shift+3)"
|
||||||
className="text-sm font-bold"
|
className="text-sm font-bold"
|
||||||
>
|
>
|
||||||
H3
|
H3
|
||||||
</Button>
|
</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" />
|
<div className="w-px h-4 bg-gray-300 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -751,6 +845,9 @@ export default function RichTextEditor({
|
|||||||
<strong>HTML mode:</strong> Edit HTML source directly for advanced formatting.
|
<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.
|
Allowed tags: p, br, div, span, strong, em, b, i, u, s, h1-h6, ul, ol, li, blockquote, and more.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Keyboard shortcuts:</strong> Ctrl+B (Bold), Ctrl+I (Italic), Ctrl+Shift+1-6 (Headings 1-6).
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Tips:</strong> Use the ⊞ button to maximize the editor for larger stories.
|
<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.
|
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) => {
|
headings.forEach((heading, index) => {
|
||||||
const level = parseInt(heading.tagName.charAt(1));
|
const level = parseInt(heading.tagName.charAt(1));
|
||||||
let title = heading.textContent?.trim() || '';
|
let title = heading.textContent?.trim() || '';
|
||||||
const id = `heading-${index}`;
|
// Use existing ID if present, otherwise fall back to index-based ID
|
||||||
|
const id = heading.id || `heading-${index}`;
|
||||||
// Add ID to heading for later reference
|
|
||||||
heading.id = id;
|
|
||||||
|
|
||||||
// Handle empty headings with a fallback
|
// Handle empty headings with a fallback
|
||||||
if (!title) {
|
if (!title) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user