mirror of
https://github.com/Qortal/Qortal-Hub.git
synced 2025-06-13 11:31:22 +00:00
604 lines
18 KiB
TypeScript
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>
|
|
);
|
|
};
|