RichTextEditor.tsx 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { useEditor, EditorContent } from '@tiptap/react';
  2. import StarterKit from '@tiptap/starter-kit';
  3. import Link from '@tiptap/extension-link';
  4. import Underline from '@tiptap/extension-underline';
  5. import TextAlign from '@tiptap/extension-text-align';
  6. import { TextStyle } from '@tiptap/extension-text-style';
  7. import Color from '@tiptap/extension-color';
  8. import Image from '@tiptap/extension-image';
  9. import {
  10. Bold,
  11. Italic,
  12. Underline as UnderlineIcon,
  13. List,
  14. ListOrdered,
  15. AlignLeft,
  16. AlignCenter,
  17. AlignRight,
  18. Link as LinkIcon,
  19. Unlink,
  20. } from 'lucide-react';
  21. import { useTranslation } from 'react-i18next';
  22. interface RichTextEditorProps {
  23. content: string;
  24. onChange: (html: string) => void;
  25. placeholder?: string;
  26. }
  27. export function RichTextEditor({ content, onChange, placeholder }: RichTextEditorProps) {
  28. const { t } = useTranslation();
  29. const editor = useEditor({
  30. extensions: [
  31. StarterKit.configure({
  32. heading: false,
  33. codeBlock: false,
  34. code: false,
  35. }),
  36. Underline,
  37. Link.configure({
  38. openOnClick: false,
  39. HTMLAttributes: {
  40. target: '_blank',
  41. rel: 'noopener noreferrer',
  42. },
  43. }),
  44. TextAlign.configure({
  45. types: ['paragraph'],
  46. }),
  47. TextStyle,
  48. Color,
  49. Image.configure({
  50. HTMLAttributes: {
  51. style: 'max-width: 100%; height: auto;',
  52. },
  53. }),
  54. ],
  55. content,
  56. onUpdate: ({ editor }) => {
  57. onChange(editor.getHTML());
  58. },
  59. editorProps: {
  60. attributes: {
  61. class: 'prose prose-invert prose-sm max-w-none focus:outline-none min-h-[120px] px-3 py-2',
  62. placeholder: placeholder || '',
  63. },
  64. },
  65. });
  66. if (!editor) {
  67. return null;
  68. }
  69. const ToolbarButton = ({
  70. onClick,
  71. isActive = false,
  72. children,
  73. title,
  74. }: {
  75. onClick: () => void;
  76. isActive?: boolean;
  77. children: React.ReactNode;
  78. title: string;
  79. }) => (
  80. <button
  81. type="button"
  82. onClick={onClick}
  83. title={title}
  84. className={`p-1.5 rounded hover:bg-bambu-dark-tertiary transition-colors ${
  85. isActive ? 'bg-bambu-dark-tertiary text-bambu-green' : 'text-bambu-gray'
  86. }`}
  87. >
  88. {children}
  89. </button>
  90. );
  91. const setLink = () => {
  92. const url = window.prompt('Enter URL:');
  93. if (url) {
  94. editor.chain().focus().setLink({ href: url }).run();
  95. }
  96. };
  97. return (
  98. <div className="border border-bambu-dark-tertiary rounded-lg overflow-hidden bg-bambu-dark">
  99. {/* Toolbar */}
  100. <div className="flex items-center gap-0.5 p-1.5 border-b border-bambu-dark-tertiary bg-bambu-dark-secondary">
  101. <ToolbarButton
  102. onClick={() => editor.chain().focus().toggleBold().run()}
  103. isActive={editor.isActive('bold')}
  104. title={t('richTextEditor.bold')}
  105. >
  106. <Bold className="w-4 h-4" />
  107. </ToolbarButton>
  108. <ToolbarButton
  109. onClick={() => editor.chain().focus().toggleItalic().run()}
  110. isActive={editor.isActive('italic')}
  111. title={t('richTextEditor.italic')}
  112. >
  113. <Italic className="w-4 h-4" />
  114. </ToolbarButton>
  115. <ToolbarButton
  116. onClick={() => editor.chain().focus().toggleUnderline().run()}
  117. isActive={editor.isActive('underline')}
  118. title={t('richTextEditor.underline')}
  119. >
  120. <UnderlineIcon className="w-4 h-4" />
  121. </ToolbarButton>
  122. <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
  123. <ToolbarButton
  124. onClick={() => editor.chain().focus().toggleBulletList().run()}
  125. isActive={editor.isActive('bulletList')}
  126. title={t('richTextEditor.bulletList')}
  127. >
  128. <List className="w-4 h-4" />
  129. </ToolbarButton>
  130. <ToolbarButton
  131. onClick={() => editor.chain().focus().toggleOrderedList().run()}
  132. isActive={editor.isActive('orderedList')}
  133. title={t('richTextEditor.numberedList')}
  134. >
  135. <ListOrdered className="w-4 h-4" />
  136. </ToolbarButton>
  137. <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
  138. <ToolbarButton
  139. onClick={() => editor.chain().focus().setTextAlign('left').run()}
  140. isActive={editor.isActive({ textAlign: 'left' })}
  141. title={t('richTextEditor.alignLeft')}
  142. >
  143. <AlignLeft className="w-4 h-4" />
  144. </ToolbarButton>
  145. <ToolbarButton
  146. onClick={() => editor.chain().focus().setTextAlign('center').run()}
  147. isActive={editor.isActive({ textAlign: 'center' })}
  148. title={t('richTextEditor.alignCenter')}
  149. >
  150. <AlignCenter className="w-4 h-4" />
  151. </ToolbarButton>
  152. <ToolbarButton
  153. onClick={() => editor.chain().focus().setTextAlign('right').run()}
  154. isActive={editor.isActive({ textAlign: 'right' })}
  155. title={t('richTextEditor.alignRight')}
  156. >
  157. <AlignRight className="w-4 h-4" />
  158. </ToolbarButton>
  159. <div className="w-px h-5 bg-bambu-dark-tertiary mx-1" />
  160. <ToolbarButton
  161. onClick={setLink}
  162. isActive={editor.isActive('link')}
  163. title={t('richTextEditor.addLink')}
  164. >
  165. <LinkIcon className="w-4 h-4" />
  166. </ToolbarButton>
  167. {editor.isActive('link') && (
  168. <ToolbarButton
  169. onClick={() => editor.chain().focus().unsetLink().run()}
  170. title={t('richTextEditor.removeLink')}
  171. >
  172. <Unlink className="w-4 h-4" />
  173. </ToolbarButton>
  174. )}
  175. </div>
  176. {/* Editor */}
  177. <EditorContent editor={editor} />
  178. </div>
  179. );
  180. }