151 lines
6.5 KiB
TypeScript
151 lines
6.5 KiB
TypeScript
'use client';
|
|
|
|
import React, { useEffect } from 'react';
|
|
import { useEditor, EditorContent } from '@tiptap/react';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import Underline from '@tiptap/extension-underline';
|
|
import Link from '@tiptap/extension-link';
|
|
import Placeholder from '@tiptap/extension-placeholder';
|
|
|
|
interface RichTextEditorProps {
|
|
value: string;
|
|
onChange: (html: string) => void;
|
|
placeholder?: string;
|
|
minHeight?: string;
|
|
}
|
|
|
|
export default function RichTextEditor({ value, onChange, placeholder = 'Notes...', minHeight = '120px' }: RichTextEditorProps) {
|
|
const editor = useEditor({
|
|
immediatelyRender: false,
|
|
extensions: [
|
|
StarterKit.configure({
|
|
heading: { levels: [1, 2, 3] },
|
|
}),
|
|
Underline,
|
|
Link.configure({
|
|
openOnClick: false,
|
|
HTMLAttributes: { rel: 'noopener noreferrer', target: '_blank' },
|
|
}),
|
|
Placeholder.configure({ placeholder }),
|
|
],
|
|
content: value,
|
|
onUpdate: ({ editor }) => {
|
|
// When content is just empty paragraph, return empty string
|
|
const html = editor.isEmpty ? '' : editor.getHTML();
|
|
onChange(html);
|
|
},
|
|
editorProps: {
|
|
attributes: {
|
|
class: 'rte-editor-content',
|
|
style: `min-height: ${minHeight}; outline: none;`,
|
|
},
|
|
},
|
|
});
|
|
|
|
// Sync external value changes (e.g. when editing an existing event)
|
|
useEffect(() => {
|
|
if (editor && editor.getHTML() !== value && !editor.isFocused) {
|
|
editor.commands.setContent(value || '');
|
|
}
|
|
}, [value, editor]);
|
|
|
|
if (!editor) return null;
|
|
|
|
const btn = (active: boolean) => ({
|
|
padding: '3px 7px',
|
|
border: '1px solid var(--weekly-border, #ddd)',
|
|
borderRadius: '4px',
|
|
background: active ? 'var(--weekly-accent, #2563eb)' : 'var(--weekly-bg, white)',
|
|
color: active ? 'white' : 'var(--weekly-text, #333)',
|
|
cursor: 'pointer',
|
|
fontSize: '0.78rem',
|
|
fontWeight: active ? '700' : '500',
|
|
lineHeight: 1,
|
|
transition: 'all 0.15s',
|
|
} as React.CSSProperties);
|
|
|
|
const handleLink = () => {
|
|
const prev = editor.getAttributes('link').href || '';
|
|
const url = window.prompt('Enter URL:', prev);
|
|
if (url === null) return;
|
|
if (url === '') {
|
|
editor.chain().focus().unsetLink().run();
|
|
} else {
|
|
editor.chain().focus().setLink({ href: url }).run();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={{
|
|
border: '1px solid var(--weekly-border, #ddd)',
|
|
borderRadius: '6px',
|
|
overflow: 'hidden',
|
|
background: 'var(--weekly-bg, white)',
|
|
}}>
|
|
{/* Toolbar */}
|
|
<div style={{
|
|
display: 'flex',
|
|
flexWrap: 'wrap',
|
|
gap: '4px',
|
|
padding: '6px 8px',
|
|
borderBottom: '1px solid var(--weekly-border, #ddd)',
|
|
background: 'var(--weekly-settings-item-bg, #f8f8f8)',
|
|
}}>
|
|
<button type="button" style={btn(editor.isActive('bold'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBold().run(); }} title="Bold">
|
|
<b>B</b>
|
|
</button>
|
|
<button type="button" style={btn(editor.isActive('italic'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleItalic().run(); }} title="Italic">
|
|
<i>I</i>
|
|
</button>
|
|
<button type="button" style={btn(editor.isActive('underline'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleUnderline().run(); }} title="Underline">
|
|
<u>U</u>
|
|
</button>
|
|
<div style={{ width: 1, background: 'var(--weekly-border, #ddd)', margin: '0 2px' }} />
|
|
<button type="button" style={btn(editor.isActive('bulletList'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBulletList().run(); }} title="Bullet list">
|
|
≡
|
|
</button>
|
|
<button type="button" style={btn(editor.isActive('orderedList'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleOrderedList().run(); }} title="Numbered list">
|
|
1.
|
|
</button>
|
|
<div style={{ width: 1, background: 'var(--weekly-border, #ddd)', margin: '0 2px' }} />
|
|
<button type="button" style={btn(editor.isActive('blockquote'))} onMouseDown={e => { e.preventDefault(); editor.chain().focus().toggleBlockquote().run(); }} title="Blockquote">
|
|
“
|
|
</button>
|
|
<button type="button" style={btn(editor.isActive('link'))} onMouseDown={e => { e.preventDefault(); handleLink(); }} title="Link">
|
|
🔗
|
|
</button>
|
|
<div style={{ width: 1, background: 'var(--weekly-border, #ddd)', margin: '0 2px' }} />
|
|
<button type="button" style={btn(false)} onMouseDown={e => { e.preventDefault(); editor.chain().focus().undo().run(); }} title="Undo">
|
|
↩
|
|
</button>
|
|
<button type="button" style={btn(false)} onMouseDown={e => { e.preventDefault(); editor.chain().focus().redo().run(); }} title="Redo">
|
|
↪
|
|
</button>
|
|
</div>
|
|
|
|
{/* Editor content */}
|
|
<EditorContent editor={editor} style={{ padding: '10px 12px' }} />
|
|
|
|
<style>{`
|
|
.rte-editor-content p { margin: 0 0 0.5em; }
|
|
.rte-editor-content p:last-child { margin-bottom: 0; }
|
|
.rte-editor-content ul, .rte-editor-content ol { padding-left: 1.4em; margin: 0.3em 0; }
|
|
.rte-editor-content blockquote {
|
|
border-left: 3px solid var(--weekly-accent, #2563eb);
|
|
margin: 0.3em 0;
|
|
padding-left: 0.8em;
|
|
color: var(--weekly-text-muted, #666);
|
|
}
|
|
.rte-editor-content a { color: var(--weekly-accent, #2563eb); text-decoration: underline; }
|
|
.rte-editor-content .is-editor-empty:first-child::before {
|
|
content: attr(data-placeholder);
|
|
float: left;
|
|
color: var(--weekly-text-muted, #adb5bd);
|
|
pointer-events: none;
|
|
height: 0;
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|