feat: 邮件编辑

dev/email
Lei OT 1 year ago
parent ae5c210c28
commit 7f4cafe724

@ -12,6 +12,7 @@
"preview": "vite preview"
},
"dependencies": {
"@dckj/react-better-modal": "^0.1.2",
"@lexical/react": "^0.17.1",
"@vonage/client-sdk": "^1.6.0",
"antd": "^5.14.0",
@ -19,11 +20,13 @@
"dayjs": "^1.11.10",
"emoji-picker-react": "^4.8.0",
"lexical": "^0.17.1",
"re-resizable": "^6.9.18",
"react": "^18.2.0",
"react-chat-elements": "^12.0.11",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-quill": "^2.0.0",
"react-rnd": "^10.4.12",
"react-router-dom": "^6.21.1",
"rxjs": "^7.8.1",
"uuid": "^9.0.1",

@ -27,3 +27,8 @@ const Sent = () => (
<svg stroke="currentColor" fill="currentColor" strokeWidth="0" viewBox="0 0 24 24" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg"><path fill="none" d="M0 0h24v24H0z" stroke="none"/><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" stroke="none"/></svg>
)
export const SentIcon = (props) => <Icon component={Sent} {...props} />;
const Filter = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="1em" width="1em" fill="currentColor"><path d="M6.17071 18C6.58254 16.8348 7.69378 16 9 16C10.3062 16 11.4175 16.8348 11.8293 18H22V20H11.8293C11.4175 21.1652 10.3062 22 9 22C7.69378 22 6.58254 21.1652 6.17071 20H2V18H6.17071ZM12.1707 11C12.5825 9.83481 13.6938 9 15 9C16.3062 9 17.4175 9.83481 17.8293 11H22V13H17.8293C17.4175 14.1652 16.3062 15 15 15C13.6938 15 12.5825 14.1652 12.1707 13H2V11H12.1707ZM6.17071 4C6.58254 2.83481 7.69378 2 9 2C10.3062 2 11.4175 2.83481 11.8293 4H22V6H11.8293C11.4175 7.16519 10.3062 8 9 8C7.69378 8 6.58254 7.16519 6.17071 6H2V4H6.17071Z"></path></svg>
)
export const FilterIcon = (props) => <Icon component={Filter} {...props} />;

@ -1,6 +1,8 @@
import { createContext, useEffect, useState } from 'react';
import ExampleTheme from "./themes/ExampleTheme";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin";
@ -20,6 +22,10 @@ import { TRANSFORMERS } from "@lexical/markdown";
import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentLevelPlugin";
import CodeHighlightPlugin from "./plugins/CodeHighlightPlugin";
import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $getSelection, } from 'lexical';
import { $generateHtmlFromNodes } from '@lexical/html';
import './styles.css';
@ -50,19 +56,42 @@ const editorConfig = {
LinkNode
]
};
function MyOnChangePlugin({ onChange }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
// const editorStateJSON = editorState.toJSON();
let html;
let textContent;
editorState.read(() => {
const root = $getRoot();
const textContent = root.getTextContent();
// console.log('textContent', textContent);
const html = $generateHtmlFromNodes(editor);
// console.log('html', html);
export default function Editor() {
// setEditorContent(content);
if (typeof onChange === 'function') {
onChange({ editorState, html, textContent });
}
});
});
}, [editor, onChange]);
return null;
}
export default function Editor({ isRichText, onChange, ...props }) {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className="editor-container">
<ToolbarPlugin />
<div className="editor-inner">
<div className='editor-container'>
{isRichText && <ToolbarPlugin />}
<div className='editor-inner'>
{/* <LexicalPlainText /> */}
<RichTextPlugin
contentEditable={<ContentEditable className="editor-input" />}
placeholder={<Placeholder />}
ErrorBoundary={LexicalErrorBoundary}
/>
{isRichText ? (
<RichTextPlugin contentEditable={<ContentEditable className='editor-input' />} placeholder={<Placeholder />} ErrorBoundary={LexicalErrorBoundary} />
) : (
<PlainTextPlugin contentEditable={<ContentEditable className='editor-pure-input' />} ErrorBoundary={LexicalErrorBoundary} />
)}
<HistoryPlugin />
{import.meta.env.DEV && <TreeViewPlugin />}
<AutoFocusPlugin />
@ -72,6 +101,7 @@ export default function Editor() {
<AutoLinkPlugin />
<ListMaxIndentLevelPlugin maxDepth={7} />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<MyOnChangePlugin onChange={onChange}/>
</div>
</div>
</LexicalComposer>

@ -48,6 +48,7 @@ const supportedBlockTypes = new Set([
"code",
"h1",
"h2",
"h3",
"ul",
"ol"
]);
@ -346,6 +347,18 @@ function BlockOptionsDropdownList({
}
setShowBlockOptionsDropDown(false);
};
const formatSmallHeading3 = () => {
if (blockType !== "h3") {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
$wrapNodes(selection, () => $createHeadingNode("h3"));
}
});
}
setShowBlockOptionsDropDown(false);
};
const formatBulletList = () => {
if (blockType !== "ul") {
@ -408,6 +421,11 @@ function BlockOptionsDropdownList({
<span className="text">Small Heading</span>
{blockType === "h2" && <span className="active" />}
</button>
<button className="item" onClick={formatSmallHeading3}>
<span className="icon h3" />
<span className="text">Heading 3</span>
{blockType === "h3" && <span className="active" />}
</button>
<button className="item" onClick={formatBulletList}>
<span className="icon bullet-list" />
<span className="text">Bullet List</span>

@ -7,7 +7,15 @@
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; */
/* } */
.editor-container{
margin: 0;
background: #eee;
font-family: system-ui, -apple-system, BlinkMacSystemFont, ".SFNSText-Regular",
sans-serif;
font-weight: 500;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.other h2 {
font-size: 18px;
color: #444;
@ -45,9 +53,9 @@ h1 {
}
.editor-container {
margin: 20px auto 20px auto;
margin: 0 auto 20px auto;
border-radius: 2px;
max-width: 600px;
/* max-width: 600px; */
color: #000;
position: relative;
line-height: 20px;
@ -74,6 +82,18 @@ h1 {
caret-color: #444;
}
.editor-pure-input {
min-height: 150px;
resize: none;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
outline: 0;
padding: 15px 10px;
caret-color: #444;
}
.editor-placeholder {
color: #999;
overflow: hidden;
@ -236,6 +256,16 @@ h1 {
text-transform: uppercase;
}
.editor-heading-h3 {
font-size: 14px;
/* color: rgb(101, 103, 107); */
font-weight: 700;
margin: 0;
margin-top: 10px;
padding: 0;
text-transform: uppercase;
}
.editor-quote {
margin: 0;
margin-left: 20px;
@ -691,6 +721,9 @@ i.redo {
.icon.h2 {
background-image: url(/images/icons/type-h2.svg);
}
.icon.h3 {
background-image: url(/images/icons/type-h3.svg);
}
.icon.bullet-list,
.icon.ul {

@ -3,6 +3,7 @@ const exampleTheme = {
rtl: "rtl",
placeholder: "editor-placeholder",
paragraph: "editor-paragraph",
// paragraph: "editor-p",
quote: "editor-quote",
heading: {
h1: "editor-heading-h1",

@ -3,6 +3,7 @@ import { Button, Tag, Radio, Popover, Form } from 'antd';
import { FilterOutlined, FilterTwoTone } from '@ant-design/icons';
import { isEmpty, objectMapper, stringToColour } from '@/utils/commons';
import useConversationStore from '@/stores/ConversationStore';
import { FilterIcon } from '@/components/Icons';
const otypes = [
{ label: 'All', value: '' },
@ -51,8 +52,10 @@ const ChatListFilter = ({ ...props }) => {
<>
<div className='my-1 flex justify-between items-center '>
<Radio.Group optionType={'button'} buttonStyle='solid' size='small' options={otypes} value={selectedOType} onChange={(e) => setFilterOtype(e.target.value)} />
<Popover destroyTooltipOnHide
placement='bottom' overlayClassName='max-w-80'
<Popover
destroyTooltipOnHide
placement='bottom'
overlayClassName='max-w-80'
trigger={'click'}
open={openPopup}
onOpenChange={setOpenPopup}
@ -68,13 +71,14 @@ const ChatListFilter = ({ ...props }) => {
<>
<Form form={form} name='conversation_filter_form' layout='vertical' size='small' initialValues={{}} onFinish={onFinish} className='*:mb-2'>
<Form.Item label='订单'>
<Tag key={selectedOType} closeIcon={selectedOType!==''} onClose={() => setFilterOtype('')}>
<Tag key={selectedOType} closeIcon={selectedOType !== ''} onClose={() => setFilterOtype('')}>
{otypesMapped[selectedOType].label}
</Tag>
</Form.Item>
<Form.Item name={'tags'} label='标签' className='*.div:gap-1'>
{tags.map((tag, ti) => (
<Tag.CheckableTag className='mb-1'
<Tag.CheckableTag
className='mb-1'
key={tag.key}
checked={selectedTags.includes(tag.key)}
onChange={(checked) => handleTagsChange(tag, checked)}
@ -97,7 +101,8 @@ const ChatListFilter = ({ ...props }) => {
</Form>
</>
}>
<Button icon={isEmpty(selectedTags) ? <FilterOutlined /> : <FilterTwoTone />} type='text' size='middle' />
{/* <Button icon={isEmpty(selectedTags) ? <FilterOutlined /> : <FilterTwoTone />} type='text' size='middle' /> */}
<Button icon={<FilterIcon className={isEmpty(selectedTags) ? 'text-neutral-500' : 'text-blue-500'} />} type='text' size='middle' />
</Popover>
</div>
</>

@ -3,26 +3,33 @@ import { Button, Flex } from 'antd';
import 'react-quill/dist/quill.snow.css';
import EmailEditor from './EmailEditor';
import { WABIcon } from '@/components/Icons';
import EmailEditorPopup from './EmailEditorPopup';
import EmailEditorPopup2 from './EmailEditorPopup1';
const EmailComposer = ({ ...props }) => {
const [open, setOpen] = useState(false);
const [fromEmail, setFromEmail] = useState('');
const openEditor = (email_addr) => {
const openEditor = (email_addr, i) => {
setOpen(true);
setFromEmail(email_addr);
};
return (
<Flex gap={8} className='p-2 bg-gray-200 justify-end rounded rounded-b-none border-gray-300 border-solid border border-b-0 border-x-0'>
{[{ email: 'lyt@hainatravel.com', name: 'LYT' }].map(({ email, name }, i) => (
{[
{ email: 'lyt@hainatravel.com', name: 'LYT' },
{ email: 'lot@hainatravel.com', name: 'LOT' },
].map(({ email, name }, i) => (
<Button
key={email}
type='primary'
className='bg-indigo-500 shadow shadow-indigo-300 hover:!bg-indigo-400 active:bg-indigo-400 focus:bg-indigo-400'
onClick={() => openEditor(email)}>
onClick={() => openEditor(email, i)}>
{name}&nbsp;&lt;{email}&gt;
</Button>
))}
<EmailEditor {...{ open, setOpen }} fromEmail={fromEmail} key={'email-editor'} />
{/* <EmailEditor {...{ open, setOpen }} fromEmail={fromEmail} key={'email-editor'} /> */}
<EmailEditorPopup {...{ open, setOpen }} fromEmail={fromEmail} key={'email-editor-popup'}/>
{/* <EmailEditorPopup2 {...{ open, setOpen }} fromEmail={fromEmail} key={'email-editor-popup2'} /> */}
</Flex>
);
};

@ -1,5 +1,6 @@
import { createContext, useEffect, useState, useRef } from 'react';
import { Button, Flex, Modal } from 'antd';
import { FilterOutlined, FilterTwoTone, CloseOutlined, CloseCircleOutlined, ExpandAltOutlined, ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons';
import Draggable from 'react-draggable';
import 'react-quill/dist/quill.snow.css';
@ -41,7 +42,7 @@ const LexicalEditor1 = () => {
);
};
const EmailEditor = ({ mobile, open, setOpen, fromEmail, ...props }) => {
const EmailEditor = ({ mobile, open, setOpen, fromEmail, reference, ...props }) => {
const [dragDisabled, setDragDisabled] = useState(true);
const [bounds, setBounds] = useState({
@ -69,11 +70,7 @@ const EmailEditor = ({ mobile, open, setOpen, fromEmail, ...props }) => {
return (
<Modal
title={
<div
style={{
width: '100%',
cursor: 'move',
}}
<div className=' cursor-move flex justify-between pr-10'
onMouseOver={() => {
if (dragDisabled) {
setDragDisabled(false);
@ -88,9 +85,11 @@ const EmailEditor = ({ mobile, open, setOpen, fromEmail, ...props }) => {
onBlur={() => {}}
// end
>
写邮件: {fromEmail}
{reference ? '回复: ' : '写邮件: ' }{fromEmail}
</div>
}
// closeIcon={<> <ShrinkOutlined /> <ArrowsAltOutlined /></>}
open={open}
mask={false}
maskClosable={false}

@ -0,0 +1,187 @@
import { createContext, useEffect, useState } from 'react';
import { Button, Form, Input, Flex, Checkbox, Switch, Mentions, Popover } from 'antd';
import Modal from '@dckj/react-better-modal';
import '@dckj/react-better-modal/dist/index.css';
import LexicalEditor from '@/components/LexicalEditor';
import { $getRoot, $getSelection, } from 'lexical';
import {$generateHtmlFromNodes} from '@lexical/html';
// import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
// import { LexicalComposer } from '@lexical/react/LexicalComposer';
// import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
// import { ContentEditable } from '@lexical/react/LexicalContentEditable';
// import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
// import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
// const theme = {
// // Theme styling goes here
// //...
// };
// // Catch any errors that occur during Lexical updates and log them
// // or throw them as needed. If you don't throw them, Lexical will
// // try to recover gracefully without losing user data.
// function onError(error) {
// console.error(error);
// }
const EmailEditorPopup = ({ open, setOpen, fromEmail, reference, ...props }) => {
const [form] = Form.useForm();
// const [open, setOpen] = useState(false);
function onHandleMove(e) {
console.log(e, '--->>> onHandleMove');
}
function onHandleResize(e) {
console.log(e, '--->>> onHandleResize');
}
function onHandleOk() {
console.log('onOk callback');
}
function onHandleCancel() {
console.log('onCancel callback');
setOpen(false);
}
function onStageChange({ state }) {
console.log(state);
}
const [isRichText, setIsRichText] = useState(true);
const [htmlContent, setHtmlContent] = useState('');
const [textContent, setTextContent] = useState('');
const [showCc, setShowCc] = useState(false);
const [showBcc, setShowBcc] = useState(false);
const handleShowCc = () => {
setShowCc(true);
};
const handleShowBcc = () => {
setShowBcc(true);
};
const handleEditorChange = ({ editorState, html, textContent }) => {
// console.log('textContent', textContent);
// console.log('html', html);
setHtmlContent(html);
setTextContent(textContent);
form.setFieldValue('content', html);
form.setFieldValue('abstract', textContent.substring(0, 20));
};
const onHandleSend = () => {
console.log('onSend callback', '\nisRichText', isRichText);
console.log(form.getFieldsValue());
const body = structuredClone(form.getFieldsValue());
body.content = isRichText ? htmlContent : textContent;
console.log('body', body);
form
.validateFields()
.then((values) => {})
.catch((err) => {});
form.resetFields();
// setOpen(false);
};
return (
<>
<Modal
visible={open}
keyboard={false}
draggable
resizable
mask={false}
maskClosable={false}
// theme='dark'
// className={'!border !border-solid !border-indigo-500 rounded !p-2' }
className='rounded-t rounded-b-none border border-solid border-indigo-300 shadow-heavy '
titleBarClassName='bg-neutral-100 rounded rounded-b-none border-none p-3 font-bold text-slate-600'
contentClassName='p-2'
footerClassName='p-2'
zIndex={2}
initialWidth={680}
initialHeight={600}
initialTop={74}
initialLeft={window.innerWidth - 700}
title={`${reference ? '回复: ' : '写邮件: '} ${fromEmail || ''}`}
minimizeButton={<></>}
onMove={onHandleMove}
onResize={onHandleResize}
onCancel={onHandleCancel}
// onOk={onHandleOk}
onStageChange={onStageChange}
footer={
<div className='w-full flex gap-8 justify-start items-center text-indigo-600'>
<Button type='primary' className='bg-indigo-500 shadow shadow-indigo-300 hover:!bg-indigo-400 active:bg-indigo-400 focus:bg-indigo-400'
onClick={onHandleSend}
>
发送
</Button>
<Popover>
{/* <Switch checkedChildren='纯文本' unCheckedChildren='HTML' /> */}
<Checkbox checked={!isRichText} onChange={e => setIsRichText(!(e.target.checked))}>纯文本</Checkbox>
</Popover>
</div>
}>
<Form
form={form} preserve={false}
name='conversation_filter_form'
size='small'
variant={'borderless'}
initialValues={{ }}
// onFinish={() => {}}
className='*:mb-2 *:border-b *:border-t-0 *:border-x-0 *:border-indigo-100 *:border-solid '
requiredMark={false}
labelCol={{ span: 3 }}>
<Form.Item label='To' name={'to'} rules={[{required: true}]}>
{/* <Mentions
split='; '
options={[
{ value: 'afc163', label: 'afc163' },
{ value: 'zombieJ', label: 'zombieJ' },
{ value: 'yesmeck', label: 'yesmeck' },
]}
/> */}
<Input
addonAfter={
<Flex gap={4}>
{!showCc && (
<Button type='text' onClick={handleShowCc}>
Cc
</Button>
)}
{!showBcc && (
<Button type='text' hidden={showBcc} onClick={handleShowBcc}>
Bcc
</Button>
)}
</Flex>
}
/>
</Form.Item>
<Form.Item label='Cc' name={'cc'} hidden={!showCc}>
<Input />
</Form.Item>
<Form.Item label='Bcc' name={'bcc'} hidden={!showBcc}>
<Input />
</Form.Item>
<Form.Item label='Subject' name={'subject'} rules={[{required: true}]}>
<Input />
</Form.Item>
<Form.Item name='content' hidden>
<Input />
</Form.Item>
<Form.Item name='abstract' hidden>
<Input />
</Form.Item>
</Form>
<LexicalEditor {...{isRichText}} onChange={handleEditorChange} />
</Modal>
</>
);
};
export default EmailEditorPopup;

@ -0,0 +1,49 @@
import React, { useState } from 'react';
import ReactDOM from 'react-dom';
import { Rnd } from 'react-rnd';
const DraggableResizableModal = ({ children }) => {
const [width, setWidth] = useState(320);
const [height, setHeight] = useState(240);
const [x, setX] = useState(300);
const [y, setY] = useState(72);
const [isMaximized, setIsMaximized] = useState(false);
const handleMaximize = () => {
if (isMaximized) {
setWidth(320);
setHeight(240);
setX(0);
setY(0);
} else {
setWidth(window.innerWidth-20);
setHeight(window.innerHeight-20);
setX(0);
setY(0);
}
setIsMaximized(!isMaximized);
};
const render = (
<Rnd className='bg-indigo-100 z-20 !fixed'
size={{ width: width, height: height }}
position={{ x: x, y: y }}
onDragStop={(e, d) => { setX(d.x); setY(d.y); }}
onResizeStop={(e, direction, ref) => {
setWidth(ref.style.width);
setHeight(ref.style.height);
}}
>
<button onClick={handleMaximize}>Maximize</button>
{children}
</Rnd>
);
return ReactDOM.createPortal(
render,
document.body
);
};
export default DraggableResizableModal;

@ -10,6 +10,7 @@ import ConversationNewItem from './ConversationsNewItem';
import emailItem from './Components/emailSent.json';
import emailReItem from './Components/emailRe.json';
import EmailEditor from './Input/EmailEditor';
import EmailEditorPopup from './Input/EmailEditorPopup';
const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
const userId = useAuthStore((state) => state.loginUser.userId);
@ -127,6 +128,7 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
const [openEmailEditor, setOpenEmailEditor] = useState(false);
const [fromEmail, setFromEmail] = useState('');
const [ReferEmailMsg, setReferEmailMsg] = useState('');
const onOpenEditor = (email_addr) => {
setOpenEmailEditor(true);
setFromEmail(email_addr);
@ -148,6 +150,7 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
getMoreMessages,
loadNextPage: currentConversation?.loadNextPage ?? true,
onOpenEditor,
setRefernce: setReferEmailMsg,
}}
/>
<Image
@ -177,8 +180,9 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => {
}}
onCancel={() => setNewChatModalVisible(false)}
/>
<EmailEditor open={openEmailEditor} setOpen={setOpenEmailEditor} {...{ fromEmail }} key={'email-editor-reply'} />
</>
{/* <EmailEditor open={openEmailEditor} setOpen={setOpenEmailEditor} reference={ReferEmailMsg} setRefernce={setReferEmailMsg} {...{ fromEmail, }} key={'email-editor-reply'} /> */}
<EmailEditorPopup open={openEmailEditor} setOpen={setOpenEmailEditor} fromEmail={fromEmail} key={'email-editor-reply-popup'}/>
</>
);
};
export default MessagesWrapper;

@ -2,7 +2,7 @@ import { Children, createContext, useEffect, useState } from 'react';
import { Tabs } from 'antd';
import { MailFilled, MailOutlined, WhatsAppOutlined } from '@ant-design/icons';
import InputComposer from './Input/InputComposer';
import EmailComposer from './Input/EmailComposer';
import EmailComposer from './Input/EmailChannelTab';
import { WABIcon } from '@/components/Icons';
import useConversationStore from '@/stores/ConversationStore';
import { useShallow } from 'zustand/react/shallow';

@ -29,6 +29,10 @@ export default {
// gridTemplateColumns: {
// 'responsive':repeat(autofill,minmax('300px',1fr))
// }
boxShadow: {
'heavy': '0 1px 7px 1px rgba(0, 0, 0, 0.3)',
'3xl': '0 35px 60px -15px rgba(0, 0, 0, 0.3)',
}
},
},
plugins: [],

Loading…
Cancel
Save