Various Fixes and QoL enhancements.

This commit is contained in:
Stefan Hardegger
2025-07-26 12:05:54 +02:00
parent 5e8164c6a4
commit f95d7aa8bb
32 changed files with 758 additions and 136 deletions

View File

@@ -23,6 +23,62 @@ export default function RichTextEditor({
const previewRef = useRef<HTMLDivElement>(null);
const visualTextareaRef = useRef<HTMLTextAreaElement>(null);
const visualDivRef = useRef<HTMLDivElement>(null);
const [isUserTyping, setIsUserTyping] = useState(false);
// Utility functions for cursor position preservation
const saveCursorPosition = () => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
const div = visualDivRef.current;
if (!div) return null;
return {
startContainer: range.startContainer,
startOffset: range.startOffset,
endContainer: range.endContainer,
endOffset: range.endOffset
};
};
const restoreCursorPosition = (position: any) => {
if (!position) return;
try {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.setStart(position.startContainer, position.startOffset);
range.setEnd(position.endContainer, position.endOffset);
selection.removeAllRanges();
selection.addRange(range);
} catch (e) {
console.warn('Could not restore cursor position:', e);
}
};
// Set initial content when component mounts
useEffect(() => {
const div = visualDivRef.current;
if (div && div.innerHTML !== value) {
div.innerHTML = value || '';
}
}, []);
// Update div content when value changes externally (not from user typing)
useEffect(() => {
const div = visualDivRef.current;
if (div && !isUserTyping && div.innerHTML !== value) {
const cursorPosition = saveCursorPosition();
div.innerHTML = value || '';
if (cursorPosition) {
setTimeout(() => restoreCursorPosition(cursorPosition), 0);
}
}
}, [value, isUserTyping]);
// Preload sanitization config
useEffect(() => {
@@ -38,8 +94,16 @@ export default function RichTextEditor({
const div = visualDivRef.current;
if (div) {
const newHtml = div.innerHTML;
onChange(newHtml);
setHtmlValue(newHtml);
setIsUserTyping(true);
// Only call onChange if content actually changed
if (newHtml !== value) {
onChange(newHtml);
setHtmlValue(newHtml);
}
// Reset typing state after a short delay
setTimeout(() => setIsUserTyping(false), 100);
}
};
@@ -155,8 +219,10 @@ export default function RichTextEditor({
}
// Update the state
setIsUserTyping(true);
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
setTimeout(() => setIsUserTyping(false), 100);
} else if (textarea) {
// Fallback for textarea mode (shouldn't happen in visual mode but good to have)
const start = textarea.selectionStart;
@@ -213,8 +279,10 @@ export default function RichTextEditor({
visualDiv.innerHTML += textAsHtml;
}
setIsUserTyping(true);
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
setTimeout(() => setIsUserTyping(false), 100);
}
} else {
console.log('No usable clipboard content found');
@@ -229,8 +297,10 @@ export default function RichTextEditor({
.filter(paragraph => paragraph.trim())
.map(paragraph => `<p>${paragraph.replace(/\n/g, '<br>')}</p>`)
.join('\n');
setIsUserTyping(true);
onChange(value + textAsHtml);
setHtmlValue(value + textAsHtml);
setTimeout(() => setIsUserTyping(false), 100);
}
}
};
@@ -293,8 +363,10 @@ export default function RichTextEditor({
}
// Update the state
setIsUserTyping(true);
onChange(visualDiv.innerHTML);
setHtmlValue(visualDiv.innerHTML);
setTimeout(() => setIsUserTyping(false), 100);
}
} else {
// HTML mode - existing logic with improvements
@@ -434,16 +506,25 @@ export default function RichTextEditor({
{/* Editor */}
<div className="border theme-border rounded-b-lg overflow-hidden">
{viewMode === 'visual' ? (
<div
ref={visualDivRef}
contentEditable
onInput={handleVisualContentChange}
onPaste={handlePaste}
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
style={{ minHeight: '300px' }}
dangerouslySetInnerHTML={{ __html: value || `<p>${placeholder}</p>` }}
suppressContentEditableWarning={true}
/>
<div className="relative">
<div
ref={visualDivRef}
contentEditable
onInput={handleVisualContentChange}
onPaste={handlePaste}
className="p-3 min-h-[300px] focus:outline-none focus:ring-0 whitespace-pre-wrap"
style={{ minHeight: '300px' }}
suppressContentEditableWarning={true}
/>
{!value && (
<div
className="absolute top-3 left-3 text-gray-500 dark:text-gray-400 pointer-events-none select-none"
style={{ minHeight: '300px' }}
>
{placeholder}
</div>
)}
</div>
) : (
<Textarea
value={htmlValue}

View File

@@ -6,17 +6,21 @@ interface TagFilterProps {
tags: Tag[];
selectedTags: string[];
onTagToggle: (tagName: string) => void;
showCollectionCount?: boolean;
}
export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilterProps) {
export default function TagFilter({ tags, selectedTags, onTagToggle, showCollectionCount = false }: TagFilterProps) {
if (!Array.isArray(tags) || tags.length === 0) return null;
// Filter out tags with no stories, then sort by usage count (descending) and then alphabetically
// Filter out tags with no count, then sort by usage count (descending) and then alphabetically
const sortedTags = [...tags]
.filter(tag => (tag.storyCount || 0) > 0)
.filter(tag => {
const count = showCollectionCount ? (tag.collectionCount || 0) : (tag.storyCount || 0);
return count > 0;
})
.sort((a, b) => {
const aCount = a.storyCount || 0;
const bCount = b.storyCount || 0;
const aCount = showCollectionCount ? (a.collectionCount || 0) : (a.storyCount || 0);
const bCount = showCollectionCount ? (b.collectionCount || 0) : (b.storyCount || 0);
if (bCount !== aCount) {
return bCount - aCount;
}
@@ -40,7 +44,7 @@ export default function TagFilter({ tags, selectedTags, onTagToggle }: TagFilter
: 'theme-card theme-text theme-border hover:border-gray-400'
}`}
>
{tag.name} ({tag.storyCount || 0})
{tag.name} ({showCollectionCount ? (tag.collectionCount || 0) : (tag.storyCount || 0)})
</button>
);
})}