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';
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.

View File

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