Improvements to Editor

This commit is contained in:
Stefan Hardegger
2025-09-02 09:28:06 +02:00
parent 11b2a8b071
commit 702fcb33c1
3 changed files with 207 additions and 112 deletions

View File

@@ -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.

View File

@@ -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