Various Fixes and QoL enhancements.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user