Files
PrivyDrop/frontend/components/Editor/RichTextEditor.tsx
T
2025-06-22 08:09:13 +08:00

162 lines
5.4 KiB
TypeScript

import React, { useState, useRef, useCallback, useEffect } from 'react';
import { EditorProps, CustomClipboardEvent, DOMNodeWithStyle } from './types';
import { fontFamilies, fontSizes, colors } from './constants';
import { useEditorCommands } from './hooks/useEditorCommands';
import { useSelection } from './hooks/useSelection';
import { useStyleManagement } from './hooks/useStyleManagement';
import { BasicFormatTools } from './EditorToolbar/BasicFormatTools';
import { FontTools } from './EditorToolbar/FontTools';
import { AlignmentTools } from './EditorToolbar/AlignmentTools';
import { InsertTools } from './EditorToolbar/InsertTools';
import { Divider } from './Divider';
const RichTextEditor: React.FC<EditorProps> = ({ onChange, value = '' }) => {
const editorRef = useRef<HTMLDivElement>(null);
const [html, setHtml] = useState(value);
const [isMounted, setIsMounted] = useState(false);
const isInternalChange = useRef(false);
useEffect(() => {
setIsMounted(true);
}, []);
// Update editor content after mounting, listen for external value changes
useEffect(() => {
if (isMounted && editorRef.current && !isInternalChange.current) {
// Only update when the content is truly different
if (editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value;
setHtml(value);
}
}
isInternalChange.current = false;
}, [value, isMounted]);
// Handle content change
const handleChange = useCallback(() => {
if (editorRef.current) {
const content = (editorRef.current as HTMLDivElement).innerHTML;
if (content !== html) {// If the content has not changed, do not trigger an update
isInternalChange.current = true;
setHtml(content);
onChange(content);
}
}
}, [html, onChange]);
const {
formatText,
alignText,
setFontStyle,
insertLink,
insertImage,
insertCodeBlock
} = useEditorCommands(editorRef, handleChange);
const getSelection = useSelection();
const { findStyleParent } = useStyleManagement(editorRef);
// Check the style of the currently selected text
const isStyleActive = useCallback((style: string): boolean => {
if (typeof window === 'undefined') return false;
const selectionInfo = getSelection();
if (!selectionInfo || !selectionInfo.selection.toString()) return false;
const node = selectionInfo.selection.anchorNode;
if (!node) return false;
const styleParent = findStyleParent(node as DOMNodeWithStyle, style);
return !!styleParent;
}, [findStyleParent, getSelection]);
const handlePaste = useCallback((e: CustomClipboardEvent) => {
// Handle image pasting
if (Array.from(e.clipboardData.items).some(item => item.type.indexOf('image') !== -1)) {
const items = Array.from(e.clipboardData.items);
const imageItem = items.find(item => item.type.indexOf('image') !== -1);
if (imageItem) {
e.preventDefault();
const blob = imageItem.getAsFile();
if (!blob) return;
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
if (!event.target || !event.target.result) return;
const img = document.createElement('img');
img.src = event.target.result as string;
img.style.maxWidth = '100%';
img.style.height = 'auto';
img.style.margin = '10px 0';
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
range.deleteContents();
range.insertNode(img);
handleChange();
};
reader.readAsDataURL(blob);
}
return;
}
// Handle plain text
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
if (typeof document !== 'undefined') {
document.execCommand('insertText', false, text);
}
}, [getSelection, handleChange]);
if (!isMounted) {
return <div>Loading...</div>;
}
return (
<div className="w-full space-x-2 mb-4">
<div className="border rounded-lg shadow-sm overflow-hidden">
{/* Toolbar - Add light gray background and bottom border */}
<div className="flex flex-wrap gap-1 p-2 bg-gray-50 border-b">
{/* Basic format tool group */}
<BasicFormatTools
isStyleActive={isStyleActive}
formatText={formatText}
/>
<Divider />
{/* Font-related selector group */}
<FontTools
fontFamilies={fontFamilies}
fontSizes={fontSizes}
colors={colors}
setFontStyle={setFontStyle}
/>
<Divider />
{/* Alignment tool group */}
<AlignmentTools alignText={alignText} />
<Divider />
{/* Insert tool group */}
<InsertTools
insertLink={insertLink}
insertImage={insertImage}
insertCodeBlock={insertCodeBlock}
/>
</div>
{/* Editor area - Add pure white background and inner shadow effect */}
<div
ref={editorRef}
className="p-4 min-h-[200px] md:min-h-[400px] focus:outline-none bg-white shadow-inner"
contentEditable
onPaste={handlePaste}
onInput={handleChange}
suppressContentEditableWarning
/>
</div>
</div>
);
};
export default RichTextEditor;