604 lines
18 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { EditorProvider, useCurrentEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { Color } from '@tiptap/extension-color';
import ListItem from '@tiptap/extension-list-item';
import TextStyle from '@tiptap/extension-text-style';
import Placeholder from '@tiptap/extension-placeholder';
import IconButton from '@mui/material/IconButton';
import FormatBoldIcon from '@mui/icons-material/FormatBold';
import FormatItalicIcon from '@mui/icons-material/FormatItalic';
import StrikethroughSIcon from '@mui/icons-material/StrikethroughS';
import FormatClearIcon from '@mui/icons-material/FormatClear';
import FormatListBulletedIcon from '@mui/icons-material/FormatListBulleted';
import FormatListNumberedIcon from '@mui/icons-material/FormatListNumbered';
import CodeIcon from '@mui/icons-material/Code';
import ImageIcon from '@mui/icons-material/Image'; // Import Image icon
import FormatQuoteIcon from '@mui/icons-material/FormatQuote';
import HorizontalRuleIcon from '@mui/icons-material/HorizontalRule';
import UndoIcon from '@mui/icons-material/Undo';
import RedoIcon from '@mui/icons-material/Redo';
import FormatHeadingIcon from '@mui/icons-material/FormatSize';
import DeveloperModeIcon from '@mui/icons-material/DeveloperMode';
import Compressor from 'compressorjs';
import Mention from '@tiptap/extension-mention';
import ImageResize from 'tiptap-extension-resize-image'; // Import the ResizeImage extension
import tippy from 'tippy.js';
import 'tippy.js/dist/tippy.css';
import { ReactRenderer } from '@tiptap/react';
import MentionList from './MentionList.jsx';
import { isDisabledEditorEnterAtom } from '../../atoms/global.js';
import { Box, Checkbox, Typography, useTheme } from '@mui/material';
import { useAtom } from 'jotai';
import { fileToBase64 } from '../../utils/fileReading/index.js';
function textMatcher(doc, from) {
const textBeforeCursor = doc.textBetween(0, from, ' ', ' ');
const match = textBeforeCursor.match(/@[\w]*$/); // Match '@' followed by valid characters
if (!match) return null;
const start = from - match[0].length;
const query = match[0];
return { start, query };
}
const MenuBar = React.memo(
({
setEditorRef,
isChat,
isDisabledEditorEnter,
setIsDisabledEditorEnter,
}) => {
const { editor } = useCurrentEditor();
const fileInputRef = useRef(null);
const theme = useTheme();
useEffect(() => {
if (editor && setEditorRef) {
setEditorRef(editor);
}
}, [editor, setEditorRef]);
if (!editor) {
return null;
}
const handleImageUpload = async (file) => {
let compressedFile;
await new Promise<void>((resolve) => {
new Compressor(file, {
quality: 0.6,
maxWidth: 1200,
mimeType: 'image/webp',
success(result) {
compressedFile = new File([result], 'image.webp', {
type: 'image/webp',
});
resolve();
},
error(err) {
console.error('Image compression error:', err);
},
});
});
if (compressedFile) {
const reader = new FileReader();
reader.onload = () => {
const url = reader.result;
editor
.chain()
.focus()
.setImage({ src: url, style: 'width: auto' })
.run();
fileInputRef.current.value = '';
};
reader.readAsDataURL(compressedFile);
}
};
const triggerImageUpload = () => {
fileInputRef.current.click(); // Trigger the file input click
};
const handlePaste = (event) => {
const items = event.clipboardData.items;
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
event.preventDefault(); // Prevent the default paste behavior
handleImageUpload(file); // Call the image upload function
}
}
}
};
useEffect(() => {
if (editor && !isChat) {
editor.view.dom.addEventListener('paste', handlePaste);
return () => {
editor.view.dom.removeEventListener('paste', handlePaste);
};
}
}, [editor, isChat]);
return (
<div className="control-group">
<div
className="button-group"
style={{
display: 'flex',
}}
>
<IconButton
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
sx={{
color: editor.isActive('bold')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatBoldIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
sx={{
color: editor.isActive('italic')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatItalicIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
sx={{
color: editor.isActive('strike')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<StrikethroughSIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
sx={{
color: editor.isActive('code')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<CodeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().unsetAllMarks().run()}
sx={{
color:
editor.isActive('bold') ||
editor.isActive('italic') ||
editor.isActive('strike') ||
editor.isActive('code')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatClearIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
sx={{
color: editor.isActive('bulletList')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatListBulletedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
sx={{
color: editor.isActive('orderedList')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatListNumberedIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
sx={{
color: editor.isActive('codeBlock')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<DeveloperModeIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
sx={{
color: editor.isActive('blockquote')
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatQuoteIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
disabled={!editor.can().chain().focus().setHorizontalRule().run()}
sx={{ color: 'gray', padding: 'revert' }}
>
<HorizontalRuleIcon />
</IconButton>
<IconButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
sx={{
color: editor.isActive('heading', { level: 1 })
? theme.palette.text.primary
: theme.palette.text.secondary,
padding: 'revert',
}}
>
<FormatHeadingIcon fontSize="small" />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
sx={{ color: 'gray', padding: 'revert' }}
>
<UndoIcon />
</IconButton>
<IconButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
sx={{ color: 'gray' }}
>
<RedoIcon />
</IconButton>
{isChat && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
marginLeft: '5px',
cursor: 'pointer',
}}
onClick={() => {
setIsDisabledEditorEnter(!isDisabledEditorEnter);
}}
>
<Checkbox
edge="start"
tabIndex={-1}
disableRipple
checked={isDisabledEditorEnter}
sx={{
'&.Mui-checked': {
color: theme.palette.text.secondary,
},
'& .MuiSvgIcon-root': {
color: theme.palette.text.secondary,
},
}}
/>
<Typography
sx={{
fontSize: '14px',
color: theme.palette.text.primary,
}}
>
disable enter
</Typography>
</Box>
)}
{!isChat && (
<>
<IconButton
onClick={triggerImageUpload}
sx={{
color: theme.palette.text.secondary,
padding: 'revert',
}}
>
<ImageIcon />
</IconButton>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={(event) => handleImageUpload(event.target.files[0])}
accept="image/*"
/>
</>
)}
</div>
</div>
);
}
);
const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }),
TextStyle.configure({ types: [ListItem.name] }),
StarterKit.configure({
bulletList: {
keepMarks: true,
keepAttributes: false,
},
orderedList: {
keepMarks: true,
keepAttributes: false,
},
}),
Placeholder.configure({
placeholder: 'Start typing here...',
}),
ImageResize,
];
const content = ``;
export default ({
setEditorRef,
onEnter,
disableEnter,
isChat,
maxHeightOffset,
setIsFocusedParent,
isFocusedParent,
overrideMobile,
customEditorHeight,
membersWithNames,
enableMentions,
insertImage,
}) => {
const theme = useTheme();
const [isDisabledEditorEnter, setIsDisabledEditorEnter] = useAtom(
isDisabledEditorEnterAtom
);
const handleImageUpload = useCallback(
async (file) => {
try {
if (!file.type.includes('image')) return;
let compressedFile = file;
if (file.type !== 'image/gif') {
await new Promise<void>((resolve) => {
new Compressor(file, {
quality: 0.6,
maxWidth: 1200,
mimeType: 'image/webp',
success(result) {
compressedFile = result;
resolve();
},
error(err) {
console.error('Image compression error:', err);
},
});
});
}
if (compressedFile) {
const toBase64 = await fileToBase64(compressedFile);
insertImage(toBase64);
}
} catch (error) {
console.error(error);
}
},
[insertImage]
);
const extensionsFiltered = isChat
? extensions.filter((item) => item?.name !== 'image')
: extensions;
const editorRef = useRef(null);
const setEditorRefFunc = useCallback((editorInstance) => {
editorRef.current = editorInstance;
setEditorRef(editorInstance);
}, []);
// const users = [
// { id: 1, label: 'Alice' },
// { id: 2, label: 'Bob' },
// { id: 3, label: 'Charlie' },
// ];
const users = useMemo(() => {
return (membersWithNames || [])?.map((item) => {
return {
id: item,
label: item,
};
});
}, [membersWithNames]);
const usersRef = useRef([]);
useEffect(() => {
usersRef.current = users; // Keep users up-to-date
}, [users]);
const handleBlur = () => {
const htmlContent = editorRef.current.getHTML();
if (!htmlContent?.trim() || htmlContent?.trim() === '<p></p>') {
// Set focus state based on content
}
};
const additionalExtensions = useMemo(() => {
if (!enableMentions) return [];
return [
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: {
items: ({ query }) => {
if (!query) return usersRef?.current;
return usersRef?.current?.filter((user) =>
user.label.toLowerCase().includes(query.toLowerCase())
);
},
render: () => {
let popup; // Reference to the Tippy.js instance
let component;
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
},
}),
];
}, [enableMentions]);
const handleSetIsDisabledEditorEnter = useCallback((val) => {
setIsDisabledEditorEnter(val);
localStorage.setItem('settings-disable-editor-enter', JSON.stringify(val));
}, []);
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
height: '100%',
justifyContent: 'space-between',
'--text-primary': theme.palette.text.primary,
'--text-secondary': theme.palette.text.secondary,
'--background-default': theme.palette.background.default,
'--background-secondary': theme.palette.background.paper,
}}
>
<EditorProvider
slotBefore={
<MenuBar
setEditorRef={setEditorRefFunc}
isChat={isChat}
isDisabledEditorEnter={isDisabledEditorEnter}
setIsDisabledEditorEnter={handleSetIsDisabledEditorEnter}
/>
}
extensions={[...extensionsFiltered, ...additionalExtensions]}
content={content}
onCreate={({ editor }) => {
editor.on('blur', handleBlur); // Listen for blur event
}}
onUpdate={({ editor }) => {
editor.on('blur', handleBlur); // Ensure blur is updated
}}
editorProps={{
attributes: {
class: 'tiptap-prosemirror',
style: `overflow: auto; max-height: 250px`,
},
handleKeyDown(view, event) {
if (
!disableEnter &&
!isDisabledEditorEnter &&
event.key === 'Enter'
) {
if (event.shiftKey) {
view.dispatch(
view.state.tr.replaceSelectionWith(
view.state.schema.nodes.hardBreak.create()
)
);
return true;
} else {
if (typeof onEnter === 'function') {
onEnter();
}
return true;
}
}
return false;
},
handlePaste(view, event) {
if (!isChat) return;
const items = event.clipboardData?.items;
if (!items) return false;
for (const item of items) {
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) {
event.preventDefault(); // Block the default paste
handleImageUpload(file); // Custom handler
return true; // Let ProseMirror know we handled it
}
}
}
return false; // fallback to default behavior otherwise
},
}}
/>
</Box>
);
};