From 8d80e794bd1ce43dc5c383fc550d94d759780fa5 Mon Sep 17 00:00:00 2001 From: Lei OT Date: Thu, 17 Oct 2024 12:04:19 +0800 Subject: [PATCH] test: Email # Conflicts: # package.json # src/utils/pagespy.js # src/views/MobileApp.jsx --- .gitignore | 1 + package.json | 8 + public/icon-email-fill.svg | 1 + public/icon-email.svg | 1 + public/images/emoji/1F600.png | Bin 0 -> 1768 bytes public/images/emoji/1F641.png | Bin 0 -> 1417 bytes public/images/emoji/1F642.png | Bin 0 -> 1380 bytes public/images/emoji/2764.png | Bin 0 -> 1386 bytes public/images/emoji/LICENSE.md | 5 + public/images/icons/LICENSE.md | 5 + public/images/icons/arrow-clockwise.svg | 4 + .../images/icons/arrow-counterclockwise.svg | 4 + public/images/icons/chat-square-quote.svg | 4 + public/images/icons/chevron-down.svg | 3 + public/images/icons/code.svg | 3 + public/images/icons/horizontal-rule.svg | 1 + public/images/icons/journal-code.svg | 5 + public/images/icons/journal-text.svg | 5 + public/images/icons/justify.svg | 3 + public/images/icons/link.svg | 4 + public/images/icons/list-ol.svg | 4 + public/images/icons/list-ul.svg | 3 + public/images/icons/pencil-fill.svg | 3 + public/images/icons/text-center.svg | 3 + public/images/icons/text-left.svg | 3 + public/images/icons/text-paragraph.svg | 3 + public/images/icons/text-right.svg | 3 + public/images/icons/type-bold.svg | 3 + public/images/icons/type-h1.svg | 3 + public/images/icons/type-h2.svg | 3 + public/images/icons/type-h3.svg | 3 + public/images/icons/type-italic.svg | 3 + public/images/icons/type-strikethrough.svg | 3 + public/images/icons/type-underline.svg | 3 + public/main_whatsapp_business.svg | 1 + public/main_whatsapp_business2 copy.svg | 1 + public/main_whatsapp_business2.svg | 33 + src/actions/ConversationActions.js | 57 ++ src/actions/EmailActions.js | 17 + src/assets/icons/archive-fill.svg | 1 + src/assets/icons/archive-line.svg | 1 + src/assets/icons/attachment-fill.svg | 1 + src/assets/icons/attachment-line.svg | 1 + src/assets/icons/flag-2-fill.svg | 1 + src/assets/icons/flag-2-line.svg | 1 + src/assets/icons/inbox-2-fill.svg | 1 + src/assets/icons/inbox-2-line.svg | 1 + src/assets/icons/mail-line.svg | 1 + src/assets/icons/mail-open-line.svg | 1 + src/assets/icons/mail-send-fill.svg | 1 + src/assets/icons/mail-send-line.svg | 1 + src/assets/icons/price-tag-3-fill.svg | 1 + src/assets/icons/price-tag-3-line.svg | 1 + src/assets/icons/quill-pen-fill.svg | 1 + src/assets/icons/quill-pen-line.svg | 1 + src/assets/icons/reply-all-fill.svg | 1 + src/assets/icons/reply-all-line.svg | 1 + src/assets/icons/reply-fill.svg | 1 + src/assets/icons/reply-line.svg | 1 + src/assets/icons/send-plane-fill.svg | 1 + src/assets/icons/send-plane-line.svg | 1 + src/assets/icons/share-forward-fill.svg | 1 + src/assets/icons/share-forward-line.svg | 1 + src/channel/whatsappUtils.js | 11 +- src/components/DndModal.jsx | 71 ++ src/components/Icons.jsx | 57 ++ src/components/LexicalEditor/Index.jsx | 162 +++ src/components/LexicalEditor/LICENSE | 21 + src/components/LexicalEditor/appSettings.ts | 40 + .../LexicalEditor/context/SettingsContext.tsx | 71 ++ .../context/SharedHistoryContext.tsx | 35 + .../LexicalEditor/nodes/ImageComponent.tsx | 487 +++++++++ .../LexicalEditor/nodes/ImageNode.css | 43 + .../LexicalEditor/nodes/ImageNode.tsx | 266 +++++ .../LexicalEditor/plugins/AutoLinkPlugin.jsx | 34 + .../plugins/CodeHighlightPlugin.jsx | 11 + .../plugins/DragDropPastePlugin/index.ts | 51 + .../FloatingLinkEditorPlugin/index.css | 41 + .../FloatingLinkEditorPlugin/index.tsx | 393 ++++++++ .../FloatingTextFormatToolbarPlugin.tsx | 400 ++++++++ .../plugins/ImagesPlugin/index.tsx | 393 ++++++++ .../plugins/LinkPlugin/index.tsx | 16 + .../plugins/ListMaxIndentLevelPlugin.jsx | 68 ++ .../LexicalEditor/plugins/TabFocusPlugin.jsx | 57 ++ .../LexicalEditor/plugins/ToolbarPlugin.jsx | 929 ++++++++++++++++++ .../LexicalEditor/plugins/TreeViewPlugin.jsx | 16 + .../LexicalEditor/shared/package.json | 21 + .../shared/src/__mocks__/invariant.ts | 24 + .../LexicalEditor/shared/src/canUseDOM.ts | 12 + .../shared/src/caretFromPoint.ts | 40 + .../LexicalEditor/shared/src/environment.ts | 56 ++ .../LexicalEditor/shared/src/invariant.ts | 26 + .../shared/src/normalizeClassNames.ts | 21 + .../shared/src/react-test-utils.ts | 18 + .../LexicalEditor/shared/src/reactPatches.ts | 22 + .../shared/src/simpleDiffWithCursor.ts | 49 + .../shared/src/useLayoutEffect.ts | 19 + .../LexicalEditor/shared/src/warnOnlyOnce.ts | 20 + .../shared/viteModuleResolution.ts | 88 ++ src/components/LexicalEditor/styles.css | 873 ++++++++++++++++ .../LexicalEditor/themes/ExampleTheme.js | 70 ++ src/components/LexicalEditor/ui/Button.css | 36 + src/components/LexicalEditor/ui/Button.tsx | 49 + .../LexicalEditor/ui/ColorPicker.css | 88 ++ .../LexicalEditor/ui/ColorPicker.tsx | 364 +++++++ .../LexicalEditor/ui/ContentEditable.css | 44 + .../LexicalEditor/ui/ContentEditable.tsx | 36 + src/components/LexicalEditor/ui/Dialog.css | 25 + src/components/LexicalEditor/ui/Dialog.tsx | 32 + src/components/LexicalEditor/ui/DropDown.tsx | 259 +++++ .../LexicalEditor/ui/DropdownColorPicker.tsx | 41 + src/components/LexicalEditor/ui/FileInput.tsx | 38 + .../LexicalEditor/ui/ImageResizer.tsx | 316 ++++++ src/components/LexicalEditor/ui/Input.css | 32 + src/components/LexicalEditor/ui/TextInput.tsx | 46 + .../LexicalEditor/utils/getSelectedNode.ts | 27 + .../LexicalEditor/utils/joinClasses.ts | 13 + .../setFloatingElemPositionForLinkEditor.ts | 46 + src/components/LexicalEditor/utils/url.ts | 38 + src/stores/ConversationStore.js | 38 +- src/stores/StyleStore.js | 10 + src/utils/commons.js | 5 +- src/views/ChatHistory.jsx | 2 +- src/views/ChatWindow.jsx | 9 +- src/views/Conversations/Conversations.css | 30 +- .../Conversations/History/MessagesList.jsx | 4 +- .../Conversations/History/SearchForm.jsx | 18 +- .../Online/Components/BubbleEmail.jsx | 68 ++ .../Online/Components/BubbleIM.jsx | 151 +++ .../Online/Components/ChannelLogo.jsx | 17 + .../Online/Components/ChatListFilter.jsx | 111 +++ .../Online/Components/ChatListItem.jsx | 323 ++++++ .../Online/Components/EmailDetail.jsx | 118 +++ .../Online/Components/MessageListFilter.jsx | 416 ++++++++ .../Online/Components/emailRe.json | 51 + .../Online/Components/emailSent.json | 50 + .../Conversations/Online/ConversationBind.jsx | 4 +- .../Online/ConversationsList.jsx | 170 ++-- .../Online/ConversationsNewItem.jsx | 50 +- .../Online/Input/EmailChannelTab.jsx | 58 ++ .../Online/Input/EmailEditor.css | 7 + .../Online/Input/EmailEditorPopup.jsx | 316 ++++++ .../Conversations/Online/Input/Emoji.jsx | 6 +- .../Online/{ => Input}/InputComposer.jsx | 53 +- .../Conversations/Online/Input/Template.jsx | 7 +- .../Online/Input/bak/EmailEditor.jsx | 116 +++ .../Online/Input/bak/EmailEditorPopup1.jsx | 49 + .../Online/Input/bak/EmailEditor_Quill.jsx | 40 + .../Conversations/Online/MessagesHeader.jsx | 2 + .../Conversations/Online/MessagesList.jsx | 143 +-- .../Conversations/Online/MessagesWrapper.jsx | 81 +- .../Conversations/Online/ReplyWrapper.jsx | 72 ++ .../Online/order/CustomerProfile.jsx | 2 +- src/views/MobileApp.jsx | 5 + src/views/mobile/Chat.jsx | 5 +- src/views/mobile/Conversation.jsx | 2 +- tailwind.config.js | 34 +- tsconfig.json | 5 + vite.config.js | 3 +- 159 files changed, 9166 insertions(+), 280 deletions(-) create mode 100644 public/icon-email-fill.svg create mode 100644 public/icon-email.svg create mode 100644 public/images/emoji/1F600.png create mode 100644 public/images/emoji/1F641.png create mode 100644 public/images/emoji/1F642.png create mode 100644 public/images/emoji/2764.png create mode 100644 public/images/emoji/LICENSE.md create mode 100644 public/images/icons/LICENSE.md create mode 100644 public/images/icons/arrow-clockwise.svg create mode 100644 public/images/icons/arrow-counterclockwise.svg create mode 100644 public/images/icons/chat-square-quote.svg create mode 100644 public/images/icons/chevron-down.svg create mode 100644 public/images/icons/code.svg create mode 100644 public/images/icons/horizontal-rule.svg create mode 100644 public/images/icons/journal-code.svg create mode 100644 public/images/icons/journal-text.svg create mode 100644 public/images/icons/justify.svg create mode 100644 public/images/icons/link.svg create mode 100644 public/images/icons/list-ol.svg create mode 100644 public/images/icons/list-ul.svg create mode 100644 public/images/icons/pencil-fill.svg create mode 100644 public/images/icons/text-center.svg create mode 100644 public/images/icons/text-left.svg create mode 100644 public/images/icons/text-paragraph.svg create mode 100644 public/images/icons/text-right.svg create mode 100644 public/images/icons/type-bold.svg create mode 100644 public/images/icons/type-h1.svg create mode 100644 public/images/icons/type-h2.svg create mode 100644 public/images/icons/type-h3.svg create mode 100644 public/images/icons/type-italic.svg create mode 100644 public/images/icons/type-strikethrough.svg create mode 100644 public/images/icons/type-underline.svg create mode 100644 public/main_whatsapp_business.svg create mode 100644 public/main_whatsapp_business2 copy.svg create mode 100644 public/main_whatsapp_business2.svg create mode 100644 src/actions/EmailActions.js create mode 100644 src/assets/icons/archive-fill.svg create mode 100644 src/assets/icons/archive-line.svg create mode 100644 src/assets/icons/attachment-fill.svg create mode 100644 src/assets/icons/attachment-line.svg create mode 100644 src/assets/icons/flag-2-fill.svg create mode 100644 src/assets/icons/flag-2-line.svg create mode 100644 src/assets/icons/inbox-2-fill.svg create mode 100644 src/assets/icons/inbox-2-line.svg create mode 100644 src/assets/icons/mail-line.svg create mode 100644 src/assets/icons/mail-open-line.svg create mode 100644 src/assets/icons/mail-send-fill.svg create mode 100644 src/assets/icons/mail-send-line.svg create mode 100644 src/assets/icons/price-tag-3-fill.svg create mode 100644 src/assets/icons/price-tag-3-line.svg create mode 100644 src/assets/icons/quill-pen-fill.svg create mode 100644 src/assets/icons/quill-pen-line.svg create mode 100644 src/assets/icons/reply-all-fill.svg create mode 100644 src/assets/icons/reply-all-line.svg create mode 100644 src/assets/icons/reply-fill.svg create mode 100644 src/assets/icons/reply-line.svg create mode 100644 src/assets/icons/send-plane-fill.svg create mode 100644 src/assets/icons/send-plane-line.svg create mode 100644 src/assets/icons/share-forward-fill.svg create mode 100644 src/assets/icons/share-forward-line.svg create mode 100644 src/components/DndModal.jsx create mode 100644 src/components/Icons.jsx create mode 100644 src/components/LexicalEditor/Index.jsx create mode 100644 src/components/LexicalEditor/LICENSE create mode 100644 src/components/LexicalEditor/appSettings.ts create mode 100644 src/components/LexicalEditor/context/SettingsContext.tsx create mode 100644 src/components/LexicalEditor/context/SharedHistoryContext.tsx create mode 100644 src/components/LexicalEditor/nodes/ImageComponent.tsx create mode 100644 src/components/LexicalEditor/nodes/ImageNode.css create mode 100644 src/components/LexicalEditor/nodes/ImageNode.tsx create mode 100644 src/components/LexicalEditor/plugins/AutoLinkPlugin.jsx create mode 100644 src/components/LexicalEditor/plugins/CodeHighlightPlugin.jsx create mode 100644 src/components/LexicalEditor/plugins/DragDropPastePlugin/index.ts create mode 100644 src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.css create mode 100644 src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.tsx create mode 100644 src/components/LexicalEditor/plugins/FloatingTextFormatToolbarPlugin.tsx create mode 100644 src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx create mode 100644 src/components/LexicalEditor/plugins/LinkPlugin/index.tsx create mode 100644 src/components/LexicalEditor/plugins/ListMaxIndentLevelPlugin.jsx create mode 100644 src/components/LexicalEditor/plugins/TabFocusPlugin.jsx create mode 100644 src/components/LexicalEditor/plugins/ToolbarPlugin.jsx create mode 100644 src/components/LexicalEditor/plugins/TreeViewPlugin.jsx create mode 100644 src/components/LexicalEditor/shared/package.json create mode 100644 src/components/LexicalEditor/shared/src/__mocks__/invariant.ts create mode 100644 src/components/LexicalEditor/shared/src/canUseDOM.ts create mode 100644 src/components/LexicalEditor/shared/src/caretFromPoint.ts create mode 100644 src/components/LexicalEditor/shared/src/environment.ts create mode 100644 src/components/LexicalEditor/shared/src/invariant.ts create mode 100644 src/components/LexicalEditor/shared/src/normalizeClassNames.ts create mode 100644 src/components/LexicalEditor/shared/src/react-test-utils.ts create mode 100644 src/components/LexicalEditor/shared/src/reactPatches.ts create mode 100644 src/components/LexicalEditor/shared/src/simpleDiffWithCursor.ts create mode 100644 src/components/LexicalEditor/shared/src/useLayoutEffect.ts create mode 100644 src/components/LexicalEditor/shared/src/warnOnlyOnce.ts create mode 100644 src/components/LexicalEditor/shared/viteModuleResolution.ts create mode 100644 src/components/LexicalEditor/styles.css create mode 100644 src/components/LexicalEditor/themes/ExampleTheme.js create mode 100644 src/components/LexicalEditor/ui/Button.css create mode 100644 src/components/LexicalEditor/ui/Button.tsx create mode 100644 src/components/LexicalEditor/ui/ColorPicker.css create mode 100644 src/components/LexicalEditor/ui/ColorPicker.tsx create mode 100644 src/components/LexicalEditor/ui/ContentEditable.css create mode 100644 src/components/LexicalEditor/ui/ContentEditable.tsx create mode 100644 src/components/LexicalEditor/ui/Dialog.css create mode 100644 src/components/LexicalEditor/ui/Dialog.tsx create mode 100644 src/components/LexicalEditor/ui/DropDown.tsx create mode 100644 src/components/LexicalEditor/ui/DropdownColorPicker.tsx create mode 100644 src/components/LexicalEditor/ui/FileInput.tsx create mode 100644 src/components/LexicalEditor/ui/ImageResizer.tsx create mode 100644 src/components/LexicalEditor/ui/Input.css create mode 100644 src/components/LexicalEditor/ui/TextInput.tsx create mode 100644 src/components/LexicalEditor/utils/getSelectedNode.ts create mode 100644 src/components/LexicalEditor/utils/joinClasses.ts create mode 100644 src/components/LexicalEditor/utils/setFloatingElemPositionForLinkEditor.ts create mode 100644 src/components/LexicalEditor/utils/url.ts create mode 100644 src/stores/StyleStore.js create mode 100644 src/views/Conversations/Online/Components/BubbleEmail.jsx create mode 100644 src/views/Conversations/Online/Components/BubbleIM.jsx create mode 100644 src/views/Conversations/Online/Components/ChannelLogo.jsx create mode 100644 src/views/Conversations/Online/Components/ChatListFilter.jsx create mode 100644 src/views/Conversations/Online/Components/ChatListItem.jsx create mode 100644 src/views/Conversations/Online/Components/EmailDetail.jsx create mode 100644 src/views/Conversations/Online/Components/MessageListFilter.jsx create mode 100644 src/views/Conversations/Online/Components/emailRe.json create mode 100644 src/views/Conversations/Online/Components/emailSent.json create mode 100644 src/views/Conversations/Online/Input/EmailChannelTab.jsx create mode 100644 src/views/Conversations/Online/Input/EmailEditor.css create mode 100644 src/views/Conversations/Online/Input/EmailEditorPopup.jsx rename src/views/Conversations/Online/{ => Input}/InputComposer.jsx (86%) create mode 100644 src/views/Conversations/Online/Input/bak/EmailEditor.jsx create mode 100644 src/views/Conversations/Online/Input/bak/EmailEditorPopup1.jsx create mode 100644 src/views/Conversations/Online/Input/bak/EmailEditor_Quill.jsx create mode 100644 src/views/Conversations/Online/ReplyWrapper.jsx create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index c96ffac..5954f1e 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ tmp /package-lock.json +**/LexicalEditor0 diff --git a/package.json b/package.json index ad71f9a..b2d8a8b 100644 --- a/package.json +++ b/package.json @@ -10,17 +10,24 @@ "preview": "vite preview" }, "dependencies": { + "@dckj/react-better-modal": "^0.1.2", + "@lexical/react": "^0.17.1", "@vonage/client-sdk": "^1.6.0", "antd": "^5.21.0", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", "dingtalk-jsapi": "^3.0.38", "emoji-picker-react": "^4.8.0", + "lexical": "^0.17.1", + "re-resizable": "^6.9.18", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.26.2", "zustand": "^4.5.5", "react-chat-elements": "^12.0.11", + "react-draggable": "^4.4.6", + "react-quill": "^2.0.0", + "react-rnd": "^10.4.12", "rxjs": "^7.8.1", "uuid": "^9.0.1", "vite-plugin-pwa": "^0.19.6" @@ -39,6 +46,7 @@ "tailwindcss": "^3.4.1", "vite": "^4.5.1", "vite-plugin-css-modules": "^0.0.1", + "vite-plugin-svgr": "^4.2.0", "vite-plugin-windicss": "^1.9.3", "windicss": "^3.5.6" } diff --git a/public/icon-email-fill.svg b/public/icon-email-fill.svg new file mode 100644 index 0000000..d33030b --- /dev/null +++ b/public/icon-email-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icon-email.svg b/public/icon-email.svg new file mode 100644 index 0000000..2ad939b --- /dev/null +++ b/public/icon-email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/emoji/1F600.png b/public/images/emoji/1F600.png new file mode 100644 index 0000000000000000000000000000000000000000..36014c94df203cc0380c6402f285a3ae3b90309f GIT binary patch literal 1768 zcmdT_`#aMM9Nw5=ZKiy8bD7x%8*=F+t=u=Z%vwX29i5DFD3@?n%QcH0w~j@*M;Wo? zP$8!dM;+y!OC08K>WPqR#6iMw{*3c^p3nO}&*%N+ectDNo=-B5>xzJB!$2Sqg3V&^ zcbN6xswwZF?>=V{2&B}-<9IS%FLHJ+|I_(4ZL;HhTHw09y$uF|E(HVsjMKESkk9X^ zR_5x7(HNF9+|>!LuBPVVa9$79C(jT);7kzpt{11`CO zK7anasi|rAQ9E%c@Uq6>&lU<+T@|gax(`620Z2R7_Fk3_M-S85+IohphsWau0s))N zhC-oCCez&9oJb^&j*hOZtUM~gR~6$0o=C0T%C}Q-7JD?@ToA?-)xKj66-9clo)WXu zwBL0bD%vS~X=>?-*wv4;3>(w;J%(9yv&F?lN84TNleFjv6k8X~Wg+@sk)cXTKb+8v zyM%_SfE5Y_pa!jB9yn`=@8#JS6&1ba94;>}r)eUpocF_3AT%1y00C>LB8bJ}*x1-% zPp8DhM47#%mzNhy7v0<2E3q<4PfxFASP!;RJPF!CRD+cLG;5URen68>r|)FFxw&a; zYl}o8^>tNnI2@Hq{q*TmJB6_SiO=-_seHSzuJ!Zs))$Ple)f249j<%v6bk4H zzT86+&9BWVM*dnEx{jzDW|d-j-KM- zxxtT@ffHEh=jZudN4#|;HGm1g50LX>iq1WzDn_N(HAxh*qQtToHA10?!;C{jCq{O1 zo(uIcd@fBm=RY6*Ue|7RVQfSA=GTRo`KSd8bMvant7j789KQI|Pd}LeZ-uTuGFpC_ zf4ZmXkX>VEL{&|oddbPLCz(%7vmTBwj0ID#I_kF{mL*#|RNpt^+a_;TD#3fTC*rv` z;tPo3`)n|3GtKoe0ZzlL$cF}j77gt-4U6<}dkXGnc)&Y9Z@R;j8|?b7fM%QM5>zaa z=?5`una)cf6?&#gKE>SsaP9S4yeER0BY+j({Krxr?s->!q!58+>n;iTZ^Sy|n%ReI z{9)1G!K#FF!;n)EOdaU^rH+-x8 zQaRL0$vR^h5-I8!qA#>2G9HZOSjPNab0ZAtbV@=Pa6Ysh!GWRmP6GxOMU{-q}I>vQ-9QlIlume7}LTy!i-uXfchC6DNy(iYh{ ze8F1ZK5o75Rj5f-XQSZMmB<&0%!satAAR#~B?yy*?cWJo3B@AHP*~Hf|D@@|+w@!4 zj1es)-&;T?TN54a7vi(}3mJ%YXT%6Ui0edu!u(3^vmE( zcKA}7v+H2#lR!7z)xM&Zf&R4o)3CdyvLwbI!r9i%<&F+w@0xvFu5z}1@T&^eI2IPH z`dS`gLtAQ-e@*Z1d;6s<-KgxFP%X5jz5%T=j*{l@mc^GFb}Hv#wIF{rK^_){MJ>-J1ey-@w&^!4ADwN hypq+YZ#!lVUex#7)~I)jl$QPXtk_I0;}<6%$-mc$tm^;( literal 0 HcmV?d00001 diff --git a/public/images/emoji/1F641.png b/public/images/emoji/1F641.png new file mode 100644 index 0000000000000000000000000000000000000000..c618faf7ae7bab0080d7eb7de610c5c99af055dd GIT binary patch literal 1417 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`ol6-Qn5+VPLR{Soe1IY&4g7hf4fOK2 zCt78>B8-d-QW7kZ;w-H-;vUXCpC4&nKBBUAp`5!D&zHwqJlst0?`eL0qSag_#>L6x zV$T!k&3ECTa*z*SL=b;i0Kb_LXK9Xzv?ObJo`{4P%j;VjuWo8wJ*M*fy84F2a+eOP zxH|Bxm?OJ%rp&TgGRtSnxHawn%+rNu zQir6PG8-!^qmn#ZurFVDApgB{YKrn~N(yYs3T#m!{BLe+e7LXq_KwEXZb>g!-ri=3 zD@RpWSQ!00c-h$)SI(6^u~R86Uhu~=Ek!xDYsXbD9#Z-AP}9MN``I=1o2OLS*cg94 z*LrY4ZPh&4q-eq8JCvB28M7A5c>@f@ppqcJV1_@e5`Ds-zx?|-VMkZC=O_j$r;Gaf4LKVG}Tcz1evx_)tSaWsQX?cUl$O<#XK zFpz)$Rr;n(?hO%6_UK1@X1t!JeuR1HvOmiXu{AGT_#vpSP;)`(a)av?Dw&Eiqr6Y} zdA55tmG!e1J65q>i1+nl%W9C_J!3<%R8P;PdR|_o0#@NIS*BTXlX;4DL)vFf2=NeJ zvcTPmLAW4B)mG6pc#@aG?x~I^)cZU$_>B6PB`X|aN>4l~brf=w6TJSTHl^@aWOtyD z0h_N<@T7)ll_gvoeHz2}g!nq`QdxV6h5dmRr&gQ&Wbw|hO$KvLoHms@ameygpGK?g zGVQJBb_+7}q&$?-+_8o`y{IN}QT-=|2PT3iU85cxe^PPVv{a8_j*8Mw56de)ef)Y> zYDHZgPn29Ejy+l9xOnZBV=>;DsYlN2fw^PVAN%cHV}r~BDM=g(7bS(ym+ z2W+gp;9|4q`F2wk`TdHufj*o6oLGP6aPMpX`K6b2rbe8Wj+(8%|KEW}SEG$@YOlC( z)F3kYqGMUz^M}!I&s&T0oR8mM)A&9AgrD>xQSsTGId!~mAz%i(tZ7F+9sZvvLwU6h3TB)g~^(y4lS6u zB@PHSw)tfs2pOaTb%8J=*T7z7rQ^2goKe^)a~xU0}ZJ_we%iSBfVM z?zd@8)Kgpzv)`PIc`hoxv{^QG%l|Yp_lI?_IxYz8wNo)sx~6~Z f@9me*wgIz1o^I&&Rddc*fl5M%=@hM^c>S(qWa$M^x4>l;h%LYO59h{7Cc5 zV=W$TCU+;EKySVvAHLVOG#+18Z>|zsHcMvdOc`YbHYIts_xCg<#8^smM5HBI%kxB1 z;{>PoNcnm2{(P?W>ZZokV=B+Dt8Z8=_xq*xnZ3&P*4)KeB7D3|F`)uXObpyyOe^Nd zx;pT<*z+u(Efeg^=jO=s=C;O#gUaXjE1%t`9OB2fZKb@5BAdS_@A(7DJJ%@Gm5T0K ztMKlw=Jr+c5kdT_N^Ff4VpF;#SIv`MJzw_aO^pd{lIs`Av9mG0xS_$p&iLet`own0 z0586cOXQj=#XMbjCUr=L2l9sn@PD|k`Sy-RR0#jnZpqTm4oiSR6I>GH7tFx=M?$#o z^Ot`=cT5P2%+CGz?#JslZ*ShXwQZ|d@97iAMRUIPOZSvZrhk$OD88V6_MA$of5PuO zp||tIBScP~67oIs@qEf>cc-aW7X#BzIC@bTmak4l>KKi~Pc#AU&)pevD4$!6E5 z?9^JW_PIyxD8ogg5LdrdZn{c5;ihMpZ$=(Cqp~w{1GD&gYjV} ziSgLW3yC-CC-pot&B!;>%M_UI;n>=daO`1Y*dO06Ggn>rQg-J67cH{|`_G2lIeADw z=&S6`tk?-nU*(r%EfuWP`kQmWs4$|{ZmOo|A&J6`inBC7_=>1^o}Q8sFzZ1=jiO%| zi(#dqgcAGb#gCW{`Uq`bBDayN`boLV~nJfZFBH_kqp zk)W2>dgJSPyD$H}t=ss54wv4jd!b^wYJYr|hiMG2B!_mP^ufTV3!jEW=Jlj+I+rj- zCARg+vJKrTydA+YIbxH(eE+&nU3}}_+P^B2;W0V;*a{nESOnkad=-8YaQVYWUYACh zWmCg-FCX-ql=qvV;n+4Y$BfKuCIf?OzLP3dj8!G8bgZs^TD4q;uh~{U>*e~$CBF?^ zD`#Y@slQb{AFZtw6+dOm|+*B+ZPm0-DYyTh5Nb0c4^Co+^#!5=b4|cH{j36 zd#M=pT08dH-ozM}s%NIpW`@jV)R14NtzDU^R}&rdt#XNsfy!@#^FP&&NIg6vAzm^q vH#nq0&q(P^3RibW4`0W3JL|oB${OqyHb*TCmB~H?Do#9I{an^LB{Ts5*MQ#9 literal 0 HcmV?d00001 diff --git a/public/images/emoji/2764.png b/public/images/emoji/2764.png new file mode 100644 index 0000000000000000000000000000000000000000..8b9f48089353dd07043fe7750c6cf0dab9fc0dda GIT binary patch literal 1386 zcmV-w1(o`VP)K4)eW|G19k!347)i7LBBBNqKO2j`((UC6EPu1 zW4y$Om>6T!Xo4h$7)^#oLnH_>-m+j&6j9>`k?4x-@z0y{*s5#TSkLJ>?fWFZFWdT@ zr@!;=J?Fgda~5>p)w4j77%4#tl7gflDM$*Ef}|iRNFoIpMi3$gM8H2z1&2T%P-c-+0sZP{qJR=`K|OYl;RGR?zz1Bc z0_uwjXwbWub_6_hXN`|WmR3*@KeH-I{FgcouD?p&hzR|UwO>)p>hN(CVv;}NsF+DG5q+`$E3mG6c-#b+$aKWBY^T;9il z_#H-y_}f|L6*cS(c;iRZzElX}kwf+@3q9LX&jrqz7sElywpjSf>;+?-_h6*qn)5!r zrk;?4^X|}75YG)!oF$esFAju*e8&F*moai!&b)XAwW)cdAf%_Dqd@$Nppc}1Tzt0i zZFtAmBD0!;;&|VwG=d_Uf!jk1cGh_G`!SpIzdA|Hw)UPrJy!I zbuRNw(zUL978KD^kR4pKC<=Wsu+}VcM6IZquG9|fTHh$X4e0&`zi9;B7hKMx=a3$E z(%rm9!;b_;g&$$cbL=drQx8E3ukzjI_ZVK#sNchKj_LU{9|TiamY`SKR~c3gYU2gD zDX>EC1+#HLh1oXxxHoJxIie=i#-nFE6)TDgcok4pfs-bO)*C_$FT45+^H|QCKuOh_ zbRIYbXv30H`jM9^QpkDGTUo|(J_iM<-qrAep8<{4Elar|)WC9n1yx4b7wZ6*6cqM+ z>xz`gc`4Gslk#P-(NH_9{G{)+X3@kAExOD3u&0&sZPwTWsX@1%4*;sn%i&EvUHE!% z9r^NeSYdId(cZhrRvJ`rB!7tYz;B&X(Lpke!x- z7(_8RJ=+b)ZBFE66tvOV literal 0 HcmV?d00001 diff --git a/public/images/emoji/LICENSE.md b/public/images/emoji/LICENSE.md new file mode 100644 index 0000000..87b04e9 --- /dev/null +++ b/public/images/emoji/LICENSE.md @@ -0,0 +1,5 @@ +OpenMoji +https://openmoji.org + +Licensed under Attribution-ShareAlike 4.0 International +https://creativecommons.org/licenses/by-sa/4.0/ diff --git a/public/images/icons/LICENSE.md b/public/images/icons/LICENSE.md new file mode 100644 index 0000000..ce74f6a --- /dev/null +++ b/public/images/icons/LICENSE.md @@ -0,0 +1,5 @@ +Bootstrap Icons +https://icons.getbootstrap.com + +Licensed under MIT license +https://github.com/twbs/icons/blob/main/LICENSE.md diff --git a/public/images/icons/arrow-clockwise.svg b/public/images/icons/arrow-clockwise.svg new file mode 100644 index 0000000..b072eb0 --- /dev/null +++ b/public/images/icons/arrow-clockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/images/icons/arrow-counterclockwise.svg b/public/images/icons/arrow-counterclockwise.svg new file mode 100644 index 0000000..b0b23b9 --- /dev/null +++ b/public/images/icons/arrow-counterclockwise.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/images/icons/chat-square-quote.svg b/public/images/icons/chat-square-quote.svg new file mode 100644 index 0000000..40893f4 --- /dev/null +++ b/public/images/icons/chat-square-quote.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/images/icons/chevron-down.svg b/public/images/icons/chevron-down.svg new file mode 100644 index 0000000..1f0b8bc --- /dev/null +++ b/public/images/icons/chevron-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/code.svg b/public/images/icons/code.svg new file mode 100644 index 0000000..079f5c6 --- /dev/null +++ b/public/images/icons/code.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/horizontal-rule.svg b/public/images/icons/horizontal-rule.svg new file mode 100644 index 0000000..cb84970 --- /dev/null +++ b/public/images/icons/horizontal-rule.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/images/icons/journal-code.svg b/public/images/icons/journal-code.svg new file mode 100644 index 0000000..82098b9 --- /dev/null +++ b/public/images/icons/journal-code.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/images/icons/journal-text.svg b/public/images/icons/journal-text.svg new file mode 100644 index 0000000..9b66f43 --- /dev/null +++ b/public/images/icons/journal-text.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/public/images/icons/justify.svg b/public/images/icons/justify.svg new file mode 100644 index 0000000..009bd72 --- /dev/null +++ b/public/images/icons/justify.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/link.svg b/public/images/icons/link.svg new file mode 100644 index 0000000..df35bc8 --- /dev/null +++ b/public/images/icons/link.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/images/icons/list-ol.svg b/public/images/icons/list-ol.svg new file mode 100644 index 0000000..5782568 --- /dev/null +++ b/public/images/icons/list-ol.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/public/images/icons/list-ul.svg b/public/images/icons/list-ul.svg new file mode 100644 index 0000000..217d153 --- /dev/null +++ b/public/images/icons/list-ul.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/pencil-fill.svg b/public/images/icons/pencil-fill.svg new file mode 100644 index 0000000..59d2830 --- /dev/null +++ b/public/images/icons/pencil-fill.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/text-center.svg b/public/images/icons/text-center.svg new file mode 100644 index 0000000..2887a99 --- /dev/null +++ b/public/images/icons/text-center.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/text-left.svg b/public/images/icons/text-left.svg new file mode 100644 index 0000000..0452611 --- /dev/null +++ b/public/images/icons/text-left.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/text-paragraph.svg b/public/images/icons/text-paragraph.svg new file mode 100644 index 0000000..9779bea --- /dev/null +++ b/public/images/icons/text-paragraph.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/text-right.svg b/public/images/icons/text-right.svg new file mode 100644 index 0000000..34686b0 --- /dev/null +++ b/public/images/icons/text-right.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/type-bold.svg b/public/images/icons/type-bold.svg new file mode 100644 index 0000000..276d133 --- /dev/null +++ b/public/images/icons/type-bold.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/type-h1.svg b/public/images/icons/type-h1.svg new file mode 100644 index 0000000..4c89181 --- /dev/null +++ b/public/images/icons/type-h1.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/type-h2.svg b/public/images/icons/type-h2.svg new file mode 100644 index 0000000..b6ab765 --- /dev/null +++ b/public/images/icons/type-h2.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/type-h3.svg b/public/images/icons/type-h3.svg new file mode 100644 index 0000000..154c293 --- /dev/null +++ b/public/images/icons/type-h3.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/type-italic.svg b/public/images/icons/type-italic.svg new file mode 100644 index 0000000..3ac6b09 --- /dev/null +++ b/public/images/icons/type-italic.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/type-strikethrough.svg b/public/images/icons/type-strikethrough.svg new file mode 100644 index 0000000..1c940e4 --- /dev/null +++ b/public/images/icons/type-strikethrough.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/icons/type-underline.svg b/public/images/icons/type-underline.svg new file mode 100644 index 0000000..c299b8b --- /dev/null +++ b/public/images/icons/type-underline.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/main_whatsapp_business.svg b/public/main_whatsapp_business.svg new file mode 100644 index 0000000..de9634c --- /dev/null +++ b/public/main_whatsapp_business.svg @@ -0,0 +1 @@ + diff --git a/public/main_whatsapp_business2 copy.svg b/public/main_whatsapp_business2 copy.svg new file mode 100644 index 0000000..e5a7c2d --- /dev/null +++ b/public/main_whatsapp_business2 copy.svg @@ -0,0 +1 @@ + diff --git a/public/main_whatsapp_business2.svg b/public/main_whatsapp_business2.svg new file mode 100644 index 0000000..c4d9dff --- /dev/null +++ b/public/main_whatsapp_business2.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/actions/ConversationActions.js b/src/actions/ConversationActions.js index eac83a3..1bb8507 100644 --- a/src/actions/ConversationActions.js +++ b/src/actions/ConversationActions.js @@ -103,6 +103,14 @@ export const fetchConversationItemUnread = async (body) => { const { errcode, result } = await fetchJSON(`${API_HOST}/set_state_unread`, body); return errcode !== 0 ? {} : result; }; +/** + * 设置置顶 + * @param {object} body { conversationid, top_state } + */ +export const fetchConversationItemTop = async (body) => { + const { errcode, result } = await fetchJSON(`${API_HOST}/set_state_top`, body); + return errcode !== 0 ? {} : result; +}; /** * ------------------------------------------------------------------------------------------------ @@ -190,3 +198,52 @@ export const postAssignConversation = async (params) => { const { errcode, result } = await fetchJSON(`${API_HOST}/assign_conversation`, params); return errcode !== 0 ? {} : result; } + +/** + * ------------------------------------------------------------------------------------------------ + * + */ +/** + * 顾问的自定义标签 + * @param {object} params { opisn, } + */ +export const fetchTags = async (params) => { + return [ + { label: '已付款', key: 'p1', value: 'p1', }, + { label: '地接', key: 'p2', value: 'p2', }, + ]; // test: + const { errcode, result } = await fetchJSON(`${API_HOST}/opi_tags`, params); + return errcode !== 0 ? {} : result; +} +/** + * 会话设置标签 + * @param {object} body { opisn, conversationid, tag_label, tag_id } + */ +export const postConversationTags = async (body) => { + const formData = new FormData(); + Object.keys(body).forEach(function (key) { + formData.append(key, body[key]); + }); + const { errcode, result } = await fetchJSON(`${API_HOST}/set_conversation_tags_add`, formData); + return errcode !== 0 ? {} : result; +} +/** + * 会话删除标签 + * @param {object} params { opisn, conversationid, tag_id } + */ +export const deleteConversationTags = async (params) => { + const { errcode, result } = await fetchJSON(`${API_HOST}/delete_conversation_tags`, params); + return errcode !== 0 ? {} : result; +} +/** + * 附加备注 + * @param {object} body { opisn, conversationid, memo } + */ +export const postConversationMemo = async (body) => { + const formData = new FormData(); + Object.keys(body).forEach(function (key) { + formData.append(key, body[key]); + }); + const { errcode, result } = await fetchJSON(`${API_HOST}/set_conversation_Memo`, formData); + return errcode !== 0 ? {} : result; +} diff --git a/src/actions/EmailActions.js b/src/actions/EmailActions.js new file mode 100644 index 0000000..9647eb8 --- /dev/null +++ b/src/actions/EmailActions.js @@ -0,0 +1,17 @@ +import { fetchJSON, postForm } from '@/utils/request'; +import { API_HOST } from '@/config'; + +/** + * 获取顾问签名 + */ +export const salesSignature = async (opisn, lgc = 1) => { + try { + const html = await fetchJSON(`http://202.103.68.35/CustomerManager/english/mailsign.asp`, { WL_SN: opisn, LGC: lgc }); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const bodyContent = doc.body.innerHTML; + return bodyContent; + } catch (error) { + return ''; + } +}; diff --git a/src/assets/icons/archive-fill.svg b/src/assets/icons/archive-fill.svg new file mode 100644 index 0000000..7075ffe --- /dev/null +++ b/src/assets/icons/archive-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/archive-line.svg b/src/assets/icons/archive-line.svg new file mode 100644 index 0000000..e063d6f --- /dev/null +++ b/src/assets/icons/archive-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/attachment-fill.svg b/src/assets/icons/attachment-fill.svg new file mode 100644 index 0000000..d76c534 --- /dev/null +++ b/src/assets/icons/attachment-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/attachment-line.svg b/src/assets/icons/attachment-line.svg new file mode 100644 index 0000000..1043644 --- /dev/null +++ b/src/assets/icons/attachment-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/flag-2-fill.svg b/src/assets/icons/flag-2-fill.svg new file mode 100644 index 0000000..2fc5b4e --- /dev/null +++ b/src/assets/icons/flag-2-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/flag-2-line.svg b/src/assets/icons/flag-2-line.svg new file mode 100644 index 0000000..10807cd --- /dev/null +++ b/src/assets/icons/flag-2-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/inbox-2-fill.svg b/src/assets/icons/inbox-2-fill.svg new file mode 100644 index 0000000..90c56a8 --- /dev/null +++ b/src/assets/icons/inbox-2-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/inbox-2-line.svg b/src/assets/icons/inbox-2-line.svg new file mode 100644 index 0000000..858c8c0 --- /dev/null +++ b/src/assets/icons/inbox-2-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/mail-line.svg b/src/assets/icons/mail-line.svg new file mode 100644 index 0000000..63fdbac --- /dev/null +++ b/src/assets/icons/mail-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/mail-open-line.svg b/src/assets/icons/mail-open-line.svg new file mode 100644 index 0000000..b6b1027 --- /dev/null +++ b/src/assets/icons/mail-open-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/mail-send-fill.svg b/src/assets/icons/mail-send-fill.svg new file mode 100644 index 0000000..0199d1b --- /dev/null +++ b/src/assets/icons/mail-send-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/mail-send-line.svg b/src/assets/icons/mail-send-line.svg new file mode 100644 index 0000000..e7c8ef7 --- /dev/null +++ b/src/assets/icons/mail-send-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/price-tag-3-fill.svg b/src/assets/icons/price-tag-3-fill.svg new file mode 100644 index 0000000..c42d21d --- /dev/null +++ b/src/assets/icons/price-tag-3-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/price-tag-3-line.svg b/src/assets/icons/price-tag-3-line.svg new file mode 100644 index 0000000..1d0485c --- /dev/null +++ b/src/assets/icons/price-tag-3-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/quill-pen-fill.svg b/src/assets/icons/quill-pen-fill.svg new file mode 100644 index 0000000..1691b9c --- /dev/null +++ b/src/assets/icons/quill-pen-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/quill-pen-line.svg b/src/assets/icons/quill-pen-line.svg new file mode 100644 index 0000000..6431d27 --- /dev/null +++ b/src/assets/icons/quill-pen-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/reply-all-fill.svg b/src/assets/icons/reply-all-fill.svg new file mode 100644 index 0000000..7956f37 --- /dev/null +++ b/src/assets/icons/reply-all-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/reply-all-line.svg b/src/assets/icons/reply-all-line.svg new file mode 100644 index 0000000..1784225 --- /dev/null +++ b/src/assets/icons/reply-all-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/reply-fill.svg b/src/assets/icons/reply-fill.svg new file mode 100644 index 0000000..c253dd0 --- /dev/null +++ b/src/assets/icons/reply-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/reply-line.svg b/src/assets/icons/reply-line.svg new file mode 100644 index 0000000..6b51857 --- /dev/null +++ b/src/assets/icons/reply-line.svg @@ -0,0 +1 @@ + diff --git a/src/assets/icons/send-plane-fill.svg b/src/assets/icons/send-plane-fill.svg new file mode 100644 index 0000000..f7e084d --- /dev/null +++ b/src/assets/icons/send-plane-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/send-plane-line.svg b/src/assets/icons/send-plane-line.svg new file mode 100644 index 0000000..37f08e7 --- /dev/null +++ b/src/assets/icons/send-plane-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/share-forward-fill.svg b/src/assets/icons/share-forward-fill.svg new file mode 100644 index 0000000..a39019d --- /dev/null +++ b/src/assets/icons/share-forward-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/share-forward-line.svg b/src/assets/icons/share-forward-line.svg new file mode 100644 index 0000000..1514fca --- /dev/null +++ b/src/assets/icons/share-forward-line.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/channel/whatsappUtils.js b/src/channel/whatsappUtils.js index a785876..de8025c 100644 --- a/src/channel/whatsappUtils.js +++ b/src/channel/whatsappUtils.js @@ -468,7 +468,9 @@ export const whatsappMsgTypeMapped = { id: msg.wamid, title: `位置信息 ${msg.location.name || ''} ↓打开高德地图`, text: msg.location.address, // 地址 - src: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`, + // src: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`, + src: 'https://cdn.pixabay.com/photo/2016/03/22/04/23/map-1272165_1280.png', + href: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`, data: { longitude: msg.location?.longitude, latitude: msg.location?.latitude, @@ -505,6 +507,7 @@ export const parseRenderMessageItem = (msg) => { conversationid: msg.conversationid, ...(typeof whatsappMsgTypeMapped[thisMsgType].type === 'function' ? whatsappMsgTypeMapped[thisMsgType].type(msg) : { type: whatsappMsgTypeMapped[thisMsgType].type || 'text' }), // type: whatsappMsgTypeMapped?.[thisMsgType]?.type || 'text', + localDate: (msg?.sendTime || msg?.createTime || '').replace('T', ' '), from: msg.from, sender: msg.from, senderName: msg?.customerProfile?.name || 'me', // msg.from, @@ -543,6 +546,11 @@ export const parseRenderMessageList = (messages) => { if (typeof msg.msgtext_AsJOSN === 'string') { // debug: json 缺少一部分 msgContentString = msg.msgtext_AsJOSN.charAt(msg.msgtext_AsJOSN.length - 1) !== '}' ? msg.msgtext_AsJOSN + '}}' : msg.msgtext_AsJOSN; + // if (msg.msgtext_AsJOSN.charAt(msg.msgtext_AsJOSN.length - 1) === '"') { + // msgContentString = msg.msgtext_AsJOSN + '}}'; + // } else { + // msgContentString = msg.msgtext_AsJOSN + '"}'; + // } } const msgContent = typeof msg.msgtext_AsJOSN === 'string' ? JSON.parse(msgContentString) : msg.msgtext_AsJOSN; msgContent.template = msg.msgtype === 'template' ? { ...msgContent.template, ...msg.template_AsJOSN } : {}; @@ -619,6 +627,7 @@ export const whatsappError = { '131047': '[131047] 会话未激活. \n请使用模板消息💬发送', '131053': '[131053] 文件上传失败.', '131048': '[131048] 账户被风控.', // 消息发送太多, 达到垃圾数量限制 + '131049': '[131049] 号码触发风控. \n请暂停发送营销消息, 引导客户主动发起会话.', // 消息发送太多, 营销限制 '131031': '[131031] 账户已锁定.', '130472': '[130472] 此号码不接收商业号消息\n请使用邮件联系 或 引导客户主动发起会话.', }; diff --git a/src/components/DndModal.jsx b/src/components/DndModal.jsx new file mode 100644 index 0000000..3fe25b5 --- /dev/null +++ b/src/components/DndModal.jsx @@ -0,0 +1,71 @@ +import { createContext, useEffect, useState } from 'react'; +import {} from 'antd'; +import Modal from '@dckj/react-better-modal'; +import '@dckj/react-better-modal/dist/index.css'; +import { isEmpty } from '@/utils/commons'; +import useStyleStore from '@/stores/StyleStore'; + +const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial = {}, title, footer=null, ...props }) => { + // const [open, setOpen] = useState(false); + function onHandleMove(e) { + // console.log(e, '--->>> onHandleMove'); + if (typeof onMove === 'function') { + onMove(e); + } + } + function onHandleResize(e) { + // console.log(e, '--->>> onHandleResize'); + if (typeof onResize === 'function') { + onResize(e); + } + } + + function onHandleOk() { + // console.log('onOk callback'); + } + + function onHandleCancel() { + // console.log('onCancel callback'); + if (typeof onCancel === 'function') { + onCancel(); + } + setOpen(false); + } + function onStageChange({ state, target }) { + // console.log(state); + } + const [mobile] = useStyleStore((state) => [state.mobile]); + + return ( + } + onMove={onHandleMove} + onResize={onHandleResize} + onCancel={onHandleCancel} + // onOk={onHandleOk} + onStageChange={onStageChange} + footer={footer} + {...(mobile ? { maximizeButton: <> } : {})}> + <>{children} + + ); +}; +export default DnDModal; diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx new file mode 100644 index 0000000..8ac97be --- /dev/null +++ b/src/components/Icons.jsx @@ -0,0 +1,57 @@ +import Icon from '@ant-design/icons'; + +import ReplyLineSVG from '@/assets/icons/reply-line.svg?react'; +import ReplyAllLineSVG from '@/assets/icons/reply-all-line.svg?react'; +import AttachmentLineSVG from '@/assets/icons/attachment-line.svg?react'; +import AttachmentFillSVG from '@/assets/icons/attachment-fill.svg?react'; +// import ShareForwardFillSVG from '@/assets/icons/share-forward-fill.svg?react'; +import ShareForwardLineSVG from '@/assets/icons/share-forward-line.svg?react'; +import InboxSVG from '@/assets/icons/inbox-2-fill.svg?react'; +import MailSendFillSVG from '@/assets/icons/mail-send-fill.svg?react'; +import SendPlaneFillSVG from '@/assets/icons/send-plane-fill.svg?react'; +import SendPlaneLineSVG from '@/assets/icons/send-plane-line.svg?react'; + + +export const ReplyIcon = (props) => ; +export const ReplyAllIcon = (props) => ; +export const AttachmentIcon = (props) => ; +export const AttachmentFillIcon = (props) => ; +export const ShareForwardIcon = (props) => ; +export const InboxIcon = (props) => ; +export const MailSendIcon = (props) => ; +export const SendPlaneFillIcon = (props) => ; +export const SendPlaneLineIcon = (props) => ; + +const WABSvg = () => ( + + + + +); +export const WABIcon = (props) => ; + +const Read = () => ( + +) +export const ReadIcon = (props) => ; + +const Deliver = () => ( + +) +export const DeliverIcon = (props) => ; + +const Sent = () => ( + +) +export const SentIcon = (props) => ; + +const Filter = () => ( + +) +export const FilterIcon = (props) => ; diff --git a/src/components/LexicalEditor/Index.jsx b/src/components/LexicalEditor/Index.jsx new file mode 100644 index 0000000..a6fb5d3 --- /dev/null +++ b/src/components/LexicalEditor/Index.jsx @@ -0,0 +1,162 @@ +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"; +import {LexicalErrorBoundary} from "@lexical/react/LexicalErrorBoundary"; +import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin'; +import TreeViewPlugin from "./plugins/TreeViewPlugin"; +import ToolbarPlugin from "./plugins/ToolbarPlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +// import {ClickableLinkPlugin} from '@lexical/react/LexicalClickableLinkPlugin'; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { ListPlugin } from "@lexical/react/LexicalListPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import {HorizontalRulePlugin} from '@lexical/react/LexicalHorizontalRulePlugin'; +import {HorizontalRuleNode} from '@lexical/react/LexicalHorizontalRuleNode'; +import { TRANSFORMERS } from "@lexical/markdown"; + +import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentLevelPlugin"; +import CodeHighlightPlugin from "./plugins/CodeHighlightPlugin"; +import AutoLinkPlugin from "./plugins/AutoLinkPlugin"; +import TabFocusPlugin from './plugins/TabFocusPlugin'; +// import ImagesPlugin from './plugins/ImagesPlugin'; +import { ImageNode } from './nodes/ImageNode'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +// import { useLexicalEditable } from '@lexical/react/useLexicalEditable'; + +import { $getRoot, $getSelection, $createParagraphNode } from 'lexical'; +import { $generateHtmlFromNodes, $generateNodesFromDOM, } from '@lexical/html'; +// import { } from '@lexical/clipboard'; + +import './styles.css'; + +function Placeholder() { + return
Enter some rich text...
; +} + +const editorConfig = { + // The editor theme + // theme: {}, + theme: ExampleTheme, + // Handling of errors during update + onError(error) { + throw error; + }, + // Any custom nodes go here + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + QuoteNode, + CodeNode, + CodeHighlightNode, + TableNode, + TableCellNode, + TableRowNode, + AutoLinkNode, + LinkNode, + HorizontalRuleNode, + ImageNode, + ] +}; + +function LexicalDefaultValuePlugin({ value = "" }= {}) { + const [editor] = useLexicalComposerContext(); + + const updateHTML = (editor, value, clear) => { + const root = $getRoot(); + const parser = new DOMParser(); + const dom = parser.parseFromString(value, "text/html"); + const nodes = $generateNodesFromDOM(editor, dom); + if (clear) { + root.clear(); + } + console.log(nodes); + + const p = $createParagraphNode(); + const _p = nodes.filter(n => n).forEach((n) => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append(n); + // p.append(paragraphNode); + root.append(paragraphNode); + }); + + // root.append(...nodes.filter(n => n)); + }; + + useEffect(() => { + if (editor && value) { + editor.update(() => { + updateHTML(editor, value, true); + }); + } + }, [value]); + + return null; +} +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); + + // setEditorContent(content); + if (typeof onChange === 'function') { + onChange({ editorState, html, textContent }); + } + }); + }); + }, [editor, onChange]); + return null; +} +export default function Editor({ isRichText, onChange, initialValue, ...props }) { + // const isEditable = useLexicalEditable(); + return ( + +
+ {isRichText && } +
+ {/* */} + {isRichText ? ( + } placeholder={} ErrorBoundary={LexicalErrorBoundary} /> + ) : ( + } ErrorBoundary={LexicalErrorBoundary} /> + )} + + {import.meta.env.DEV && } + + + + + + + + + + + + {/* */} + {/* */} + +
+
+
+ ); +} diff --git a/src/components/LexicalEditor/LICENSE b/src/components/LexicalEditor/LICENSE new file mode 100644 index 0000000..b93be90 --- /dev/null +++ b/src/components/LexicalEditor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Meta Platforms, Inc. and affiliates. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/components/LexicalEditor/appSettings.ts b/src/components/LexicalEditor/appSettings.ts new file mode 100644 index 0000000..61892c2 --- /dev/null +++ b/src/components/LexicalEditor/appSettings.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +const hostName = window.location.hostname; +export const isDevPlayground: boolean = + hostName !== 'playground.lexical.dev' && + hostName !== 'lexical-playground.vercel.app'; + +export const DEFAULT_SETTINGS = { + disableBeforeInput: false, + emptyEditor: isDevPlayground, + isAutocomplete: false, + isCharLimit: false, + isCharLimitUtf8: false, + isCollab: false, + isMaxLength: false, + isRichText: true, + measureTypingPerf: false, + shouldPreserveNewLinesInMarkdown: false, + shouldUseLexicalContextMenu: false, + showNestedEditorTreeView: false, + showTableOfContents: false, + showTreeView: true, + tableCellBackgroundColor: true, + tableCellMerge: true, +} as const; + +// These are mutated in setupEnv +export const INITIAL_SETTINGS: Record = { + ...DEFAULT_SETTINGS, +}; + +export type SettingName = keyof typeof DEFAULT_SETTINGS; + +export type Settings = typeof INITIAL_SETTINGS; diff --git a/src/components/LexicalEditor/context/SettingsContext.tsx b/src/components/LexicalEditor/context/SettingsContext.tsx new file mode 100644 index 0000000..f114c29 --- /dev/null +++ b/src/components/LexicalEditor/context/SettingsContext.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {SettingName} from '../appSettings'; + +import * as React from 'react'; +import { + createContext, + ReactNode, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +import {DEFAULT_SETTINGS, INITIAL_SETTINGS} from '../appSettings'; + +type SettingsContextShape = { + setOption: (name: SettingName, value: boolean) => void; + settings: Record; +}; + +const Context: React.Context = createContext({ + setOption: (name: SettingName, value: boolean) => { + return; + }, + settings: INITIAL_SETTINGS, +}); + +export const SettingsContext = ({ + children, +}: { + children: ReactNode; +}): JSX.Element => { + const [settings, setSettings] = useState(INITIAL_SETTINGS); + + const setOption = useCallback((setting: SettingName, value: boolean) => { + setSettings((options) => ({ + ...options, + [setting]: value, + })); + setURLParam(setting, value); + }, []); + + const contextValue = useMemo(() => { + return {setOption, settings}; + }, [setOption, settings]); + + return {children}; +}; + +export const useSettings = (): SettingsContextShape => { + return useContext(Context); +}; + +function setURLParam(param: SettingName, value: null | boolean) { + const url = new URL(window.location.href); + const params = new URLSearchParams(url.search); + if (value !== DEFAULT_SETTINGS[param]) { + params.set(param, String(value)); + } else { + params.delete(param); + } + url.search = params.toString(); + window.history.pushState(null, '', url.toString()); +} diff --git a/src/components/LexicalEditor/context/SharedHistoryContext.tsx b/src/components/LexicalEditor/context/SharedHistoryContext.tsx new file mode 100644 index 0000000..316c337 --- /dev/null +++ b/src/components/LexicalEditor/context/SharedHistoryContext.tsx @@ -0,0 +1,35 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {HistoryState} from '@lexical/react/LexicalHistoryPlugin'; + +import {createEmptyHistoryState} from '@lexical/react/LexicalHistoryPlugin'; +import * as React from 'react'; +import {createContext, ReactNode, useContext, useMemo} from 'react'; + +type ContextShape = { + historyState?: HistoryState; +}; + +const Context: React.Context = createContext({}); + +export const SharedHistoryContext = ({ + children, +}: { + children: ReactNode; +}): JSX.Element => { + const historyContext = useMemo( + () => ({historyState: createEmptyHistoryState()}), + [], + ); + return {children}; +}; + +export const useSharedHistoryContext = (): ContextShape => { + return useContext(Context); +}; diff --git a/src/components/LexicalEditor/nodes/ImageComponent.tsx b/src/components/LexicalEditor/nodes/ImageComponent.tsx new file mode 100644 index 0000000..9492fb9 --- /dev/null +++ b/src/components/LexicalEditor/nodes/ImageComponent.tsx @@ -0,0 +1,487 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + BaseSelection, + LexicalCommand, + LexicalEditor, + NodeKey, +} from 'lexical'; + +import './ImageNode.css'; + +import {HashtagNode} from '@lexical/hashtag'; +import {LinkNode} from '@lexical/link'; +import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin'; +import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext'; +import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; +import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin'; +import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin'; +import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer'; +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection'; +import {mergeRegister} from '@lexical/utils'; +import { + $getNodeByKey, + $getSelection, + $isNodeSelection, + $isRangeSelection, + $setSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_LOW, + createCommand, + DRAGSTART_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, + KEY_ESCAPE_COMMAND, + LineBreakNode, + ParagraphNode, + RootNode, + SELECTION_CHANGE_COMMAND, + TextNode, +} from 'lexical'; +import * as React from 'react'; +import {Suspense, useCallback, useEffect, useRef, useState} from 'react'; + +// import {createWebsocketProvider} from '../collaboration'; +import {useSettings} from '../context/SettingsContext'; +import {useSharedHistoryContext} from '../context/SharedHistoryContext'; +// import brokenImage from '../images/image-broken.svg'; +// import EmojisPlugin from '../plugins/EmojisPlugin'; +// import KeywordsPlugin from '../plugins/KeywordsPlugin'; +import LinkPlugin from '../plugins/LinkPlugin'; +// import MentionsPlugin from '../plugins/MentionsPlugin'; +// import TreeViewPlugin from '../plugins/TreeViewPlugin'; +import ContentEditable from '../ui/ContentEditable'; +import ImageResizer from '../ui/ImageResizer'; +// import {EmojiNode} from './EmojiNode'; +import {$isImageNode} from './ImageNode'; +// import {KeywordNode} from './KeywordNode'; + +const imageCache = new Set(); + +export const RIGHT_CLICK_IMAGE_COMMAND: LexicalCommand = + createCommand('RIGHT_CLICK_IMAGE_COMMAND'); + +function useSuspenseImage(src: string) { + if (!imageCache.has(src)) { + throw new Promise((resolve) => { + const img = new Image(); + img.src = src; + img.onload = () => { + imageCache.add(src); + resolve(null); + }; + img.onerror = () => { + imageCache.add(src); + }; + }); + } +} + +function LazyImage({ + altText, + className, + imageRef, + src, + width, + height, + maxWidth, + onError, +}: { + altText: string; + className: string | null; + height: 'inherit' | number; + imageRef: {current: null | HTMLImageElement}; + maxWidth: number; + src: string; + width: 'inherit' | number; + onError: () => void; +}): JSX.Element { + useSuspenseImage(src); + return ( + {altText} + ); +} + +function BrokenImage(): JSX.Element { + return ( + + ); +} + +export default function ImageComponent({ + src, + altText, + nodeKey, + width, + height, + maxWidth, + resizable, + showCaption, + caption, + captionsEnabled, +}: { + altText: string; + caption: LexicalEditor; + height: 'inherit' | number; + maxWidth: number; + nodeKey: NodeKey; + resizable: boolean; + showCaption: boolean; + src: string; + width: 'inherit' | number; + captionsEnabled: boolean; +}): JSX.Element { + const imageRef = useRef(null); + const buttonRef = useRef(null); + const [isSelected, setSelected, clearSelection] = + useLexicalNodeSelection(nodeKey); + const [isResizing, setIsResizing] = useState(false); + const {isCollabActive} = useCollaborationContext(); + const [editor] = useLexicalComposerContext(); + const [selection, setSelection] = useState(null); + const activeEditorRef = useRef(null); + const [isLoadError, setIsLoadError] = useState(false); + + const $onDelete = useCallback( + (payload: KeyboardEvent) => { + const deleteSelection = $getSelection(); + if (isSelected && $isNodeSelection(deleteSelection)) { + const event: KeyboardEvent = payload; + event.preventDefault(); + editor.update(() => { + deleteSelection.getNodes().forEach((node) => { + if ($isImageNode(node)) { + node.remove(); + } + }); + }); + } + return false; + }, + [editor, isSelected], + ); + + const $onEnter = useCallback( + (event: KeyboardEvent) => { + const latestSelection = $getSelection(); + const buttonElem = buttonRef.current; + if ( + isSelected && + $isNodeSelection(latestSelection) && + latestSelection.getNodes().length === 1 + ) { + if (showCaption) { + // Move focus into nested editor + $setSelection(null); + event.preventDefault(); + caption.focus(); + return true; + } else if ( + buttonElem !== null && + buttonElem !== document.activeElement + ) { + event.preventDefault(); + buttonElem.focus(); + return true; + } + } + return false; + }, + [caption, isSelected, showCaption], + ); + + const $onEscape = useCallback( + (event: KeyboardEvent) => { + if ( + activeEditorRef.current === caption || + buttonRef.current === event.target + ) { + $setSelection(null); + editor.update(() => { + setSelected(true); + const parentRootElement = editor.getRootElement(); + if (parentRootElement !== null) { + parentRootElement.focus(); + } + }); + return true; + } + return false; + }, + [caption, editor, setSelected], + ); + + const onClick = useCallback( + (payload: MouseEvent) => { + const event = payload; + + if (isResizing) { + return true; + } + if (event.target === imageRef.current) { + if (event.shiftKey) { + setSelected(!isSelected); + } else { + clearSelection(); + setSelected(true); + } + return true; + } + + return false; + }, + [isResizing, isSelected, setSelected, clearSelection], + ); + + const onRightClick = useCallback( + (event: MouseEvent): void => { + editor.getEditorState().read(() => { + const latestSelection = $getSelection(); + const domElement = event.target as HTMLElement; + if ( + domElement.tagName === 'IMG' && + $isRangeSelection(latestSelection) && + latestSelection.getNodes().length === 1 + ) { + editor.dispatchCommand( + RIGHT_CLICK_IMAGE_COMMAND, + event as MouseEvent, + ); + } + }); + }, + [editor], + ); + + useEffect(() => { + let isMounted = true; + const rootElement = editor.getRootElement(); + const unregister = mergeRegister( + editor.registerUpdateListener(({editorState}) => { + if (isMounted) { + setSelection(editorState.read(() => $getSelection())); + } + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_, activeEditor) => { + activeEditorRef.current = activeEditor; + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + CLICK_COMMAND, + onClick, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + RIGHT_CLICK_IMAGE_COMMAND, + onClick, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + if (event.target === imageRef.current) { + // TODO This is just a temporary workaround for FF to behave like other browsers. + // Ideally, this handles drag & drop too (and all browsers). + event.preventDefault(); + return true; + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_DELETE_COMMAND, + $onDelete, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_BACKSPACE_COMMAND, + $onDelete, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand(KEY_ENTER_COMMAND, $onEnter, COMMAND_PRIORITY_LOW), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + $onEscape, + COMMAND_PRIORITY_LOW, + ), + ); + + rootElement?.addEventListener('contextmenu', onRightClick); + + return () => { + isMounted = false; + unregister(); + rootElement?.removeEventListener('contextmenu', onRightClick); + }; + }, [ + clearSelection, + editor, + isResizing, + isSelected, + nodeKey, + $onDelete, + $onEnter, + $onEscape, + onClick, + onRightClick, + setSelected, + ]); + + const setShowCaption = () => { + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + node.setShowCaption(true); + } + }); + }; + + const onResizeEnd = ( + nextWidth: 'inherit' | number, + nextHeight: 'inherit' | number, + ) => { + // Delay hiding the resize bars for click case + setTimeout(() => { + setIsResizing(false); + }, 200); + + editor.update(() => { + const node = $getNodeByKey(nodeKey); + if ($isImageNode(node)) { + node.setWidthAndHeight(nextWidth, nextHeight); + } + }); + }; + + const onResizeStart = () => { + setIsResizing(true); + }; + + const {historyState} = useSharedHistoryContext(); + const { + settings: {showNestedEditorTreeView}, + } = useSettings(); + + const draggable = isSelected && $isNodeSelection(selection) && !isResizing; + const isFocused = isSelected || isResizing; + return ( + + <> +
+ {isLoadError ? ( + + ) : ( + setIsLoadError(true)} + /> + )} +
+ + {showCaption && ( +
+ + + {/* */} + + {/* */} + + {/* */} + {/* {isCollabActive ? ( + + ) : ( + + )} */} + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + {/* {showNestedEditorTreeView === true ? : null} */} + +
+ )} + {resizable && $isNodeSelection(selection) && isFocused && ( + + )} + +
+ ); +} diff --git a/src/components/LexicalEditor/nodes/ImageNode.css b/src/components/LexicalEditor/nodes/ImageNode.css new file mode 100644 index 0000000..a9c1901 --- /dev/null +++ b/src/components/LexicalEditor/nodes/ImageNode.css @@ -0,0 +1,43 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +.ImageNode__contentEditable { + min-height: 20px; + border: 0px; + resize: none; + cursor: text; + caret-color: rgb(5, 5, 5); + display: block; + position: relative; + outline: 0px; + padding: 10px; + user-select: text; + font-size: 12px; + width: calc(100% - 20px); + white-space: pre-wrap; + word-break: break-word; +} + +.ImageNode__placeholder { + font-size: 12px; + color: #888; + overflow: hidden; + position: absolute; + text-overflow: ellipsis; + top: 10px; + left: 10px; + user-select: none; + white-space: nowrap; + display: inline-block; + pointer-events: none; +} + +.image-control-wrapper--resizing { + touch-action: none; +} diff --git a/src/components/LexicalEditor/nodes/ImageNode.tsx b/src/components/LexicalEditor/nodes/ImageNode.tsx new file mode 100644 index 0000000..a21971c --- /dev/null +++ b/src/components/LexicalEditor/nodes/ImageNode.tsx @@ -0,0 +1,266 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalEditor, + LexicalNode, + NodeKey, + SerializedEditor, + SerializedLexicalNode, + Spread, +} from 'lexical'; + +import {$applyNodeReplacement, createEditor, DecoratorNode} from 'lexical'; +import * as React from 'react'; +import {Suspense} from 'react'; + +const ImageComponent = React.lazy(() => import('./ImageComponent')); + +export interface ImagePayload { + altText: string; + caption?: LexicalEditor; + height?: number; + key?: NodeKey; + maxWidth?: number; + showCaption?: boolean; + src: string; + width?: number; + captionsEnabled?: boolean; +} + +function isGoogleDocCheckboxImg(img: HTMLImageElement): boolean { + return ( + img.parentElement != null && + img.parentElement.tagName === 'LI' && + img.previousSibling === null && + img.getAttribute('aria-roledescription') === 'checkbox' + ); +} + +function $convertImageElement(domNode: Node): null | DOMConversionOutput { + const img = domNode as HTMLImageElement; + if (img.src.startsWith('file:///') || isGoogleDocCheckboxImg(img)) { + return null; + } + const {alt: altText, src, width, height} = img; + const node = $createImageNode({altText, height, src, width}); + return {node}; +} + +export type SerializedImageNode = Spread< + { + altText: string; + caption: SerializedEditor; + height?: number; + maxWidth: number; + showCaption: boolean; + src: string; + width?: number; + }, + SerializedLexicalNode +>; + +export class ImageNode extends DecoratorNode { + __src: string; + __altText: string; + __width: 'inherit' | number; + __height: 'inherit' | number; + __maxWidth: number; + __showCaption: boolean; + __caption: LexicalEditor; + // Captions cannot yet be used within editor cells + __captionsEnabled: boolean; + + static getType(): string { + return 'image'; + } + + static clone(node: ImageNode): ImageNode { + return new ImageNode( + node.__src, + node.__altText, + node.__maxWidth, + node.__width, + node.__height, + node.__showCaption, + node.__caption, + node.__captionsEnabled, + node.__key, + ); + } + + static importJSON(serializedNode: SerializedImageNode): ImageNode { + const {altText, height, width, maxWidth, caption, src, showCaption} = + serializedNode; + const node = $createImageNode({ + altText, + height, + maxWidth, + showCaption, + src, + width, + }); + const nestedEditor = node.__caption; + const editorState = nestedEditor.parseEditorState(caption.editorState); + if (!editorState.isEmpty()) { + nestedEditor.setEditorState(editorState); + } + return node; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('img'); + element.setAttribute('src', this.__src); + element.setAttribute('alt', this.__altText); + element.setAttribute('width', this.__width.toString()); + element.setAttribute('height', this.__height.toString()); + return {element}; + } + + static importDOM(): DOMConversionMap | null { + return { + img: (node: Node) => ({ + conversion: $convertImageElement, + priority: 0, + }), + }; + } + + constructor( + src: string, + altText: string, + maxWidth: number, + width?: 'inherit' | number, + height?: 'inherit' | number, + showCaption?: boolean, + caption?: LexicalEditor, + captionsEnabled?: boolean, + key?: NodeKey, + ) { + super(key); + this.__src = src; + this.__altText = altText; + this.__maxWidth = maxWidth; + this.__width = width || 'inherit'; + this.__height = height || 'inherit'; + this.__showCaption = showCaption || false; + this.__caption = + caption || + createEditor({ + nodes: [], + }); + this.__captionsEnabled = captionsEnabled || captionsEnabled === undefined; + } + + exportJSON(): SerializedImageNode { + return { + altText: this.getAltText(), + caption: this.__caption.toJSON(), + height: this.__height === 'inherit' ? 0 : this.__height, + maxWidth: this.__maxWidth, + showCaption: this.__showCaption, + src: this.getSrc(), + type: 'image', + version: 1, + width: this.__width === 'inherit' ? 0 : this.__width, + }; + } + + setWidthAndHeight( + width: 'inherit' | number, + height: 'inherit' | number, + ): void { + const writable = this.getWritable(); + writable.__width = width; + writable.__height = height; + } + + setShowCaption(showCaption: boolean): void { + const writable = this.getWritable(); + writable.__showCaption = showCaption; + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const span = document.createElement('span'); + const theme = config.theme; + const className = theme.image; + if (className !== undefined) { + span.className = className; + } + return span; + } + + updateDOM(): false { + return false; + } + + getSrc(): string { + return this.__src; + } + + getAltText(): string { + return this.__altText; + } + + decorate(): JSX.Element { + return ( + + + + ); + } +} + +export function $createImageNode({ + altText, + height, + maxWidth = 500, + captionsEnabled, + src, + width, + showCaption, + caption, + key, +}: ImagePayload): ImageNode { + return $applyNodeReplacement( + new ImageNode( + src, + altText, + maxWidth, + width, + height, + showCaption, + caption, + captionsEnabled, + key, + ), + ); +} + +export function $isImageNode( + node: LexicalNode | null | undefined, +): node is ImageNode { + return node instanceof ImageNode; +} diff --git a/src/components/LexicalEditor/plugins/AutoLinkPlugin.jsx b/src/components/LexicalEditor/plugins/AutoLinkPlugin.jsx new file mode 100644 index 0000000..3475c91 --- /dev/null +++ b/src/components/LexicalEditor/plugins/AutoLinkPlugin.jsx @@ -0,0 +1,34 @@ +import { AutoLinkPlugin } from "@lexical/react/LexicalAutoLinkPlugin"; + +const URL_MATCHER = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + +const EMAIL_MATCHER = /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/; + +const MATCHERS = [ + (text) => { + const match = URL_MATCHER.exec(text); + return ( + match && { + index: match.index, + length: match[0].length, + text: match[0], + url: match[0] + } + ); + }, + (text) => { + const match = EMAIL_MATCHER.exec(text); + return ( + match && { + index: match.index, + length: match[0].length, + text: match[0], + url: `mailto:${match[0]}` + } + ); + } +]; + +export default function PlaygroundAutoLinkPlugin() { + return ; +} diff --git a/src/components/LexicalEditor/plugins/CodeHighlightPlugin.jsx b/src/components/LexicalEditor/plugins/CodeHighlightPlugin.jsx new file mode 100644 index 0000000..f931805 --- /dev/null +++ b/src/components/LexicalEditor/plugins/CodeHighlightPlugin.jsx @@ -0,0 +1,11 @@ +import { registerCodeHighlighting } from "@lexical/code"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useEffect } from "react"; + +export default function CodeHighlightPlugin() { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + return null; +} diff --git a/src/components/LexicalEditor/plugins/DragDropPastePlugin/index.ts b/src/components/LexicalEditor/plugins/DragDropPastePlugin/index.ts new file mode 100644 index 0000000..b38a10c --- /dev/null +++ b/src/components/LexicalEditor/plugins/DragDropPastePlugin/index.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {DRAG_DROP_PASTE} from '@lexical/rich-text'; +import {isMimeType, mediaFileReader} from '@lexical/utils'; +import {COMMAND_PRIORITY_LOW} from 'lexical'; +import {useEffect} from 'react'; + +import {INSERT_IMAGE_COMMAND} from '../ImagesPlugin'; + +const ACCEPTABLE_IMAGE_TYPES = [ + 'image/', + 'image/heic', + 'image/heif', + 'image/gif', + 'image/webp', +]; + +export default function DragDropPaste(): null { + const [editor] = useLexicalComposerContext(); + useEffect(() => { + return editor.registerCommand( + DRAG_DROP_PASTE, + (files) => { + (async () => { + const filesResult = await mediaFileReader( + files, + [ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x), + ); + for (const {file, result} of filesResult) { + if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) { + editor.dispatchCommand(INSERT_IMAGE_COMMAND, { + altText: file.name, + src: result, + }); + } + } + })(); + return true; + }, + COMMAND_PRIORITY_LOW, + ); + }, [editor]); + return null; +} diff --git a/src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.css b/src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.css new file mode 100644 index 0000000..8c56f98 --- /dev/null +++ b/src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.css @@ -0,0 +1,41 @@ +.link-editor { + display: flex; + position: absolute; + top: 0; + left: 0; + z-index: 10; + max-width: 400px; + width: 100%; + opacity: 0; + background-color: #fff; + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); + border-radius: 0 0 8px 8px; + transition: opacity 0.5s; + will-change: transform; +} + +.link-editor .button { + width: 20px; + height: 20px; + display: inline-block; + padding: 6px; + border-radius: 8px; + cursor: pointer; + margin: 0 2px; +} + +.link-editor .button.hovered { + width: 20px; + height: 20px; + display: inline-block; + background-color: #eee; +} + +.link-editor .button i, +.actions i { + background-size: contain; + display: inline-block; + height: 20px; + width: 20px; + vertical-align: -0.25em; +} diff --git a/src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.tsx b/src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.tsx new file mode 100644 index 0000000..4dd0cb2 --- /dev/null +++ b/src/components/LexicalEditor/plugins/FloatingLinkEditorPlugin/index.tsx @@ -0,0 +1,393 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import './index.css'; + +import { + $createLinkNode, + $isAutoLinkNode, + $isLinkNode, + TOGGLE_LINK_COMMAND, +} from '@lexical/link'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$findMatchingParent, mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isLineBreakNode, + $isRangeSelection, + BaseSelection, + CLICK_COMMAND, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + KEY_ESCAPE_COMMAND, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {Dispatch, useCallback, useEffect, useRef, useState} from 'react'; +import * as React from 'react'; +import {createPortal} from 'react-dom'; + +import {getSelectedNode} from '../../utils/getSelectedNode'; +import {setFloatingElemPositionForLinkEditor} from '../../utils/setFloatingElemPositionForLinkEditor'; +import {sanitizeUrl} from '../../utils/url'; + +function FloatingLinkEditor({ + editor, + isLink, + setIsLink, + anchorElem, + isLinkEditMode, + setIsLinkEditMode, +}: { + editor: LexicalEditor; + isLink: boolean; + setIsLink: Dispatch; + anchorElem: HTMLElement; + isLinkEditMode: boolean; + setIsLinkEditMode: Dispatch; +}): JSX.Element { + const editorRef = useRef(null); + const inputRef = useRef(null); + const [linkUrl, setLinkUrl] = useState(''); + const [editedLinkUrl, setEditedLinkUrl] = useState('https://'); + const [lastSelection, setLastSelection] = useState( + null, + ); + + const $updateLinkEditor = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const linkParent = $findMatchingParent(node, $isLinkNode); + + if (linkParent) { + setLinkUrl(linkParent.getURL()); + } else if ($isLinkNode(node)) { + setLinkUrl(node.getURL()); + } else { + setLinkUrl(''); + } + if (isLinkEditMode) { + setEditedLinkUrl(linkUrl); + } + } + const editorElem = editorRef.current; + const nativeSelection = window.getSelection(); + const activeElement = document.activeElement; + + if (editorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + + if ( + selection !== null && + nativeSelection !== null && + rootElement !== null && + rootElement.contains(nativeSelection.anchorNode) && + editor.isEditable() + ) { + const domRect: DOMRect | undefined = + nativeSelection.focusNode?.parentElement?.getBoundingClientRect(); + if (domRect) { + domRect.y += 40; + setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem); + } + setLastSelection(selection); + } else if (!activeElement || activeElement.className !== 'link-input') { + if (rootElement !== null) { + setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem); + } + setLastSelection(null); + setIsLinkEditMode(false); + setLinkUrl(''); + } + + return true; + }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl]); + + useEffect(() => { + const scrollerElem = anchorElem.parentElement; + + const update = () => { + editor.getEditorState().read(() => { + $updateLinkEditor(); + }); + }; + + window.addEventListener('resize', update); + + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update); + } + + return () => { + window.removeEventListener('resize', update); + + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update); + } + }; + }, [anchorElem.parentElement, editor, $updateLinkEditor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateLinkEditor(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateLinkEditor(); + return true; + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + KEY_ESCAPE_COMMAND, + () => { + if (isLink) { + setIsLink(false); + return true; + } + return false; + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [editor, $updateLinkEditor, setIsLink, isLink]); + + useEffect(() => { + editor.getEditorState().read(() => { + $updateLinkEditor(); + }); + }, [editor, $updateLinkEditor]); + + useEffect(() => { + if (isLinkEditMode && inputRef.current) { + inputRef.current.focus(); + } + }, [isLinkEditMode, isLink]); + + const monitorInputInteraction = ( + event: React.KeyboardEvent, + ) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleLinkSubmission(); + } else if (event.key === 'Escape') { + event.preventDefault(); + setIsLinkEditMode(false); + } + }; + + const handleLinkSubmission = () => { + if (lastSelection !== null) { + if (linkUrl !== '') { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl)); + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const parent = getSelectedNode(selection).getParent(); + if ($isAutoLinkNode(parent)) { + const linkNode = $createLinkNode(parent.getURL(), { + rel: parent.__rel, + target: parent.__target, + title: parent.__title, + }); + parent.replace(linkNode, true); + } + } + }); + } + setEditedLinkUrl('https://'); + setIsLinkEditMode(false); + } + }; + + return ( +
+ {!isLink ? null : isLinkEditMode ? ( + <> + { + setEditedLinkUrl(event.target.value); + }} + onKeyDown={(event) => { + monitorInputInteraction(event); + }} + /> +
+
event.preventDefault()} + onClick={() => { + setIsLinkEditMode(false); + }} + /> + +
event.preventDefault()} + onClick={handleLinkSubmission} + /> +
+ + ) : ( +
+ + {linkUrl} + +
event.preventDefault()} + onClick={() => { + setEditedLinkUrl(linkUrl); + setIsLinkEditMode(true); + }} + /> +
event.preventDefault()} + onClick={() => { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + }} + /> +
+ )} +
+ ); +} + +function useFloatingLinkEditorToolbar( + editor: LexicalEditor, + anchorElem: HTMLElement, + isLinkEditMode: boolean, + setIsLinkEditMode: Dispatch, +): JSX.Element | null { + const [activeEditor, setActiveEditor] = useState(editor); + const [isLink, setIsLink] = useState(false); + + useEffect(() => { + function $updateToolbar() { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const focusNode = getSelectedNode(selection); + const focusLinkNode = $findMatchingParent(focusNode, $isLinkNode); + const focusAutoLinkNode = $findMatchingParent( + focusNode, + $isAutoLinkNode, + ); + if (!(focusLinkNode || focusAutoLinkNode)) { + setIsLink(false); + return; + } + const badNode = selection + .getNodes() + .filter((node) => !$isLineBreakNode(node)) + .find((node) => { + const linkNode = $findMatchingParent(node, $isLinkNode); + const autoLinkNode = $findMatchingParent(node, $isAutoLinkNode); + return ( + (focusLinkNode && !focusLinkNode.is(linkNode)) || + (linkNode && !linkNode.is(focusLinkNode)) || + (focusAutoLinkNode && !focusAutoLinkNode.is(autoLinkNode)) || + (autoLinkNode && + (!autoLinkNode.is(focusAutoLinkNode) || + autoLinkNode.getIsUnlinked())) + ); + }); + if (!badNode) { + setIsLink(true); + } else { + setIsLink(false); + } + } + } + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateToolbar(); + }); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + $updateToolbar(); + setActiveEditor(newEditor); + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + CLICK_COMMAND, + (payload) => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const linkNode = $findMatchingParent(node, $isLinkNode); + if ($isLinkNode(linkNode) && (payload.metaKey || payload.ctrlKey)) { + window.open(linkNode.getURL(), '_blank'); + return true; + } + } + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor]); + + return createPortal( + , + anchorElem, + ); +} + +export default function FloatingLinkEditorPlugin({ + anchorElem = document.body, + isLinkEditMode, + setIsLinkEditMode, +}: { + anchorElem?: HTMLElement; + isLinkEditMode: boolean; + setIsLinkEditMode: Dispatch; +}): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + return useFloatingLinkEditorToolbar( + editor, + anchorElem, + isLinkEditMode, + setIsLinkEditMode, + ); +} diff --git a/src/components/LexicalEditor/plugins/FloatingTextFormatToolbarPlugin.tsx b/src/components/LexicalEditor/plugins/FloatingTextFormatToolbarPlugin.tsx new file mode 100644 index 0000000..6283fa7 --- /dev/null +++ b/src/components/LexicalEditor/plugins/FloatingTextFormatToolbarPlugin.tsx @@ -0,0 +1,400 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import './index.css'; + +import {$isCodeHighlightNode} from '@lexical/code'; +import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {mergeRegister} from '@lexical/utils'; +import { + $getSelection, + $isParagraphNode, + $isRangeSelection, + $isTextNode, + COMMAND_PRIORITY_LOW, + FORMAT_TEXT_COMMAND, + LexicalEditor, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import {Dispatch, useCallback, useEffect, useRef, useState} from 'react'; +import * as React from 'react'; +import {createPortal} from 'react-dom'; + +import {getDOMRangeRect} from '../../utils/getDOMRangeRect'; +import {getSelectedNode} from '../../utils/getSelectedNode'; +import {setFloatingElemPosition} from '../../utils/setFloatingElemPosition'; +import {INSERT_INLINE_COMMAND} from '../CommentPlugin'; + +function TextFormatFloatingToolbar({ + editor, + anchorElem, + isLink, + isBold, + isItalic, + isUnderline, + isCode, + isStrikethrough, + isSubscript, + isSuperscript, + setIsLinkEditMode, +}: { + editor: LexicalEditor; + anchorElem: HTMLElement; + isBold: boolean; + isCode: boolean; + isItalic: boolean; + isLink: boolean; + isStrikethrough: boolean; + isSubscript: boolean; + isSuperscript: boolean; + isUnderline: boolean; + setIsLinkEditMode: Dispatch; +}): JSX.Element { + const popupCharStylesEditorRef = useRef(null); + + const insertLink = useCallback(() => { + if (!isLink) { + setIsLinkEditMode(true); + editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://'); + } else { + setIsLinkEditMode(false); + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }, [editor, isLink, setIsLinkEditMode]); + + const insertComment = () => { + editor.dispatchCommand(INSERT_INLINE_COMMAND, undefined); + }; + + function mouseMoveListener(e: MouseEvent) { + if ( + popupCharStylesEditorRef?.current && + (e.buttons === 1 || e.buttons === 3) + ) { + if (popupCharStylesEditorRef.current.style.pointerEvents !== 'none') { + const x = e.clientX; + const y = e.clientY; + const elementUnderMouse = document.elementFromPoint(x, y); + + if (!popupCharStylesEditorRef.current.contains(elementUnderMouse)) { + // Mouse is not over the target element => not a normal click, but probably a drag + popupCharStylesEditorRef.current.style.pointerEvents = 'none'; + } + } + } + } + function mouseUpListener(e: MouseEvent) { + if (popupCharStylesEditorRef?.current) { + if (popupCharStylesEditorRef.current.style.pointerEvents !== 'auto') { + popupCharStylesEditorRef.current.style.pointerEvents = 'auto'; + } + } + } + + useEffect(() => { + if (popupCharStylesEditorRef?.current) { + document.addEventListener('mousemove', mouseMoveListener); + document.addEventListener('mouseup', mouseUpListener); + + return () => { + document.removeEventListener('mousemove', mouseMoveListener); + document.removeEventListener('mouseup', mouseUpListener); + }; + } + }, [popupCharStylesEditorRef]); + + const $updateTextFormatFloatingToolbar = useCallback(() => { + const selection = $getSelection(); + + const popupCharStylesEditorElem = popupCharStylesEditorRef.current; + const nativeSelection = window.getSelection(); + + if (popupCharStylesEditorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + if ( + selection !== null && + nativeSelection !== null && + !nativeSelection.isCollapsed && + rootElement !== null && + rootElement.contains(nativeSelection.anchorNode) + ) { + const rangeRect = getDOMRangeRect(nativeSelection, rootElement); + + setFloatingElemPosition( + rangeRect, + popupCharStylesEditorElem, + anchorElem, + isLink, + ); + } + }, [editor, anchorElem, isLink]); + + useEffect(() => { + const scrollerElem = anchorElem.parentElement; + + const update = () => { + editor.getEditorState().read(() => { + $updateTextFormatFloatingToolbar(); + }); + }; + + window.addEventListener('resize', update); + if (scrollerElem) { + scrollerElem.addEventListener('scroll', update); + } + + return () => { + window.removeEventListener('resize', update); + if (scrollerElem) { + scrollerElem.removeEventListener('scroll', update); + } + }; + }, [editor, $updateTextFormatFloatingToolbar, anchorElem]); + + useEffect(() => { + editor.getEditorState().read(() => { + $updateTextFormatFloatingToolbar(); + }); + return mergeRegister( + editor.registerUpdateListener(({editorState}) => { + editorState.read(() => { + $updateTextFormatFloatingToolbar(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateTextFormatFloatingToolbar(); + return false; + }, + COMMAND_PRIORITY_LOW, + ), + ); + }, [editor, $updateTextFormatFloatingToolbar]); + + return ( +
+ {editor.isEditable() && ( + <> + + + + + + + + + + )} + +
+ ); +} + +function useFloatingTextFormatToolbar( + editor: LexicalEditor, + anchorElem: HTMLElement, + setIsLinkEditMode: Dispatch, +): JSX.Element | null { + const [isText, setIsText] = useState(false); + const [isLink, setIsLink] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isSubscript, setIsSubscript] = useState(false); + const [isSuperscript, setIsSuperscript] = useState(false); + const [isCode, setIsCode] = useState(false); + + const updatePopup = useCallback(() => { + editor.getEditorState().read(() => { + // Should not to pop up the floating toolbar when using IME input + if (editor.isComposing()) { + return; + } + const selection = $getSelection(); + const nativeSelection = window.getSelection(); + const rootElement = editor.getRootElement(); + + if ( + nativeSelection !== null && + (!$isRangeSelection(selection) || + rootElement === null || + !rootElement.contains(nativeSelection.anchorNode)) + ) { + setIsText(false); + return; + } + + if (!$isRangeSelection(selection)) { + return; + } + + const node = getSelectedNode(selection); + + // Update text format + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsSubscript(selection.hasFormat('subscript')); + setIsSuperscript(selection.hasFormat('superscript')); + setIsCode(selection.hasFormat('code')); + + // Update links + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } + + if ( + !$isCodeHighlightNode(selection.anchor.getNode()) && + selection.getTextContent() !== '' + ) { + setIsText($isTextNode(node) || $isParagraphNode(node)); + } else { + setIsText(false); + } + + const rawTextContent = selection.getTextContent().replace(/\n/g, ''); + if (!selection.isCollapsed() && rawTextContent === '') { + setIsText(false); + return; + } + }); + }, [editor]); + + useEffect(() => { + document.addEventListener('selectionchange', updatePopup); + return () => { + document.removeEventListener('selectionchange', updatePopup); + }; + }, [updatePopup]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(() => { + updatePopup(); + }), + editor.registerRootListener(() => { + if (editor.getRootElement() === null) { + setIsText(false); + } + }), + ); + }, [editor, updatePopup]); + + if (!isText) { + return null; + } + + return createPortal( + , + anchorElem, + ); +} + +export default function FloatingTextFormatToolbarPlugin({ + anchorElem = document.body, + setIsLinkEditMode, +}: { + anchorElem?: HTMLElement; + setIsLinkEditMode: Dispatch; +}): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + return useFloatingTextFormatToolbar(editor, anchorElem, setIsLinkEditMode); +} diff --git a/src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx b/src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx new file mode 100644 index 0000000..b9b2c0d --- /dev/null +++ b/src/components/LexicalEditor/plugins/ImagesPlugin/index.tsx @@ -0,0 +1,393 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$wrapNodeInElement, mergeRegister} from '@lexical/utils'; +import { + $createParagraphNode, + $createRangeSelection, + $getSelection, + $insertNodes, + $isNodeSelection, + $isRootOrShadowRoot, + $setSelection, + COMMAND_PRIORITY_EDITOR, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + createCommand, + DRAGOVER_COMMAND, + DRAGSTART_COMMAND, + DROP_COMMAND, + LexicalCommand, + LexicalEditor, +} from 'lexical'; +import {useEffect, useRef, useState} from 'react'; +import * as React from 'react'; +// import {CAN_USE_DOM} from '../../shared/canUseDOM'; + +// import landscapeImage from '../../images/landscape.jpg'; +// import yellowFlowerImage from '../../images/yellow-flower.jpg'; +import { + $createImageNode, + $isImageNode, + ImageNode, + ImagePayload, +} from '../../nodes/ImageNode'; +import Button from '../../ui/Button'; +import {DialogActions, DialogButtonsList} from '../../ui/Dialog'; +import FileInput from '../../ui/FileInput'; +import TextInput from '../../ui/TextInput'; + +export type InsertImagePayload = Readonly; + +// const getDOMSelection = (targetWindow: Window | null): Selection | null => +// CAN_USE_DOM ? (targetWindow || window).getSelection() : null; +const getDOMSelection = (targetWindow: Window | null): Selection | null => + (targetWindow || window).getSelection(); + +export const INSERT_IMAGE_COMMAND: LexicalCommand = + createCommand('INSERT_IMAGE_COMMAND'); + +export function InsertImageUriDialogBody({ + onClick, +}: { + onClick: (payload: InsertImagePayload) => void; +}) { + const [src, setSrc] = useState(''); + const [altText, setAltText] = useState(''); + + const isDisabled = src === ''; + + return ( + <> + + + + + + + ); +} + +export function InsertImageUploadedDialogBody({ + onClick, +}: { + onClick: (payload: InsertImagePayload) => void; +}) { + const [src, setSrc] = useState(''); + const [altText, setAltText] = useState(''); + + const isDisabled = src === ''; + + const loadImage = (files: FileList | null) => { + const reader = new FileReader(); + reader.onload = function () { + if (typeof reader.result === 'string') { + setSrc(reader.result); + } + return ''; + }; + if (files !== null) { + reader.readAsDataURL(files[0]); + } + }; + + return ( + <> + + + + + + + ); +} + +export function InsertImageDialog({ + activeEditor, + onClose, +}: { + activeEditor: LexicalEditor; + onClose: () => void; +}): JSX.Element { + const [mode, setMode] = useState(null); + const hasModifier = useRef(false); + + useEffect(() => { + hasModifier.current = false; + const handler = (e: KeyboardEvent) => { + hasModifier.current = e.altKey; + }; + document.addEventListener('keydown', handler); + return () => { + document.removeEventListener('keydown', handler); + }; + }, [activeEditor]); + + const onClick = (payload: InsertImagePayload) => { + activeEditor.dispatchCommand(INSERT_IMAGE_COMMAND, payload); + onClose(); + }; + + return ( + <> + {!mode && ( + + {/* */} + + + + )} + {mode === 'url' && } + {mode === 'file' && } + + ); +} + +export default function ImagesPlugin({ + captionsEnabled, +}: { + captionsEnabled?: boolean; +}): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([ImageNode])) { + throw new Error('ImagesPlugin: ImageNode not registered on editor'); + } + + return mergeRegister( + editor.registerCommand( + INSERT_IMAGE_COMMAND, + (payload) => { + const imageNode = $createImageNode(payload); + $insertNodes([imageNode]); + if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) { + $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd(); + } + + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + editor.registerCommand( + DRAGSTART_COMMAND, + (event) => { + return $onDragStart(event); + }, + COMMAND_PRIORITY_HIGH, + ), + editor.registerCommand( + DRAGOVER_COMMAND, + (event) => { + return $onDragover(event); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => { + return $onDrop(event, editor); + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [captionsEnabled, editor]); + + return null; +} + +const TRANSPARENT_IMAGE = + 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; +const img = document.createElement('img'); +img.src = TRANSPARENT_IMAGE; + +function $onDragStart(event: DragEvent): boolean { + const node = $getImageNodeInSelection(); + if (!node) { + return false; + } + const dataTransfer = event.dataTransfer; + if (!dataTransfer) { + return false; + } + dataTransfer.setData('text/plain', '_'); + dataTransfer.setDragImage(img, 0, 0); + dataTransfer.setData( + 'application/x-lexical-drag', + JSON.stringify({ + data: { + altText: node.__altText, + caption: node.__caption, + height: node.__height, + key: node.getKey(), + maxWidth: node.__maxWidth, + showCaption: node.__showCaption, + src: node.__src, + width: node.__width, + }, + type: 'image', + }), + ); + + return true; +} + +function $onDragover(event: DragEvent): boolean { + const node = $getImageNodeInSelection(); + if (!node) { + return false; + } + if (!canDropImage(event)) { + event.preventDefault(); + } + return true; +} + +function $onDrop(event: DragEvent, editor: LexicalEditor): boolean { + const node = $getImageNodeInSelection(); + if (!node) { + return false; + } + const data = getDragImageData(event); + if (!data) { + return false; + } + event.preventDefault(); + if (canDropImage(event)) { + const range = getDragSelection(event); + node.remove(); + const rangeSelection = $createRangeSelection(); + if (range !== null && range !== undefined) { + rangeSelection.applyDOMRange(range); + } + $setSelection(rangeSelection); + editor.dispatchCommand(INSERT_IMAGE_COMMAND, data); + } + return true; +} + +function $getImageNodeInSelection(): ImageNode | null { + const selection = $getSelection(); + if (!$isNodeSelection(selection)) { + return null; + } + const nodes = selection.getNodes(); + const node = nodes[0]; + return $isImageNode(node) ? node : null; +} + +function getDragImageData(event: DragEvent): null | InsertImagePayload { + const dragData = event.dataTransfer?.getData('application/x-lexical-drag'); + if (!dragData) { + return null; + } + const {type, data} = JSON.parse(dragData); + if (type !== 'image') { + return null; + } + + return data; +} + +declare global { + interface DragEvent { + rangeOffset?: number; + rangeParent?: Node; + } +} + +function canDropImage(event: DragEvent): boolean { + const target = event.target; + return !!( + target && + target instanceof HTMLElement && + !target.closest('code, span.editor-image') && + target.parentElement && + target.parentElement.closest('div.ContentEditable__root') + ); +} + +function getDragSelection(event: DragEvent): Range | null | undefined { + let range; + const target = event.target as null | Element | Document; + const targetWindow = + target == null + ? null + : target.nodeType === 9 + ? (target as Document).defaultView + : (target as Element).ownerDocument.defaultView; + const domSelection = getDOMSelection(targetWindow); + if (document.caretRangeFromPoint) { + range = document.caretRangeFromPoint(event.clientX, event.clientY); + } else if (event.rangeParent && domSelection !== null) { + domSelection.collapse(event.rangeParent, event.rangeOffset || 0); + range = domSelection.getRangeAt(0); + } else { + throw Error(`Cannot get the selection when dragging`); + } + + return range; +} diff --git a/src/components/LexicalEditor/plugins/LinkPlugin/index.tsx b/src/components/LexicalEditor/plugins/LinkPlugin/index.tsx new file mode 100644 index 0000000..1f3dc43 --- /dev/null +++ b/src/components/LexicalEditor/plugins/LinkPlugin/index.tsx @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import {LinkPlugin as LexicalLinkPlugin} from '@lexical/react/LexicalLinkPlugin'; +import * as React from 'react'; + +import {validateUrl} from '../../utils/url'; + +export default function LinkPlugin(): JSX.Element { + return ; +} diff --git a/src/components/LexicalEditor/plugins/ListMaxIndentLevelPlugin.jsx b/src/components/LexicalEditor/plugins/ListMaxIndentLevelPlugin.jsx new file mode 100644 index 0000000..657535e --- /dev/null +++ b/src/components/LexicalEditor/plugins/ListMaxIndentLevelPlugin.jsx @@ -0,0 +1,68 @@ +import { $getListDepth, $isListItemNode, $isListNode } from "@lexical/list"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $getSelection, + $isElementNode, + $isRangeSelection, + INDENT_CONTENT_COMMAND, + COMMAND_PRIORITY_HIGH +} from "lexical"; +import { useEffect } from "react"; + +function getElementNodesInSelection(selection) { + const nodesInSelection = selection.getNodes(); + + if (nodesInSelection.length === 0) { + return new Set([ + selection.anchor.getNode().getParentOrThrow(), + selection.focus.getNode().getParentOrThrow() + ]); + } + + return new Set( + nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())) + ); +} + +function isIndentPermitted(maxDepth) { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return false; + } + + const elementNodesInSelection = getElementNodesInSelection(selection); + + let totalDepth = 0; + + for (const elementNode of elementNodesInSelection) { + if ($isListNode(elementNode)) { + totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth); + } else if ($isListItemNode(elementNode)) { + const parent = elementNode.getParent(); + if (!$isListNode(parent)) { + throw new Error( + "ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent." + ); + } + + totalDepth = Math.max($getListDepth(parent) + 1, totalDepth); + } + } + + return totalDepth <= maxDepth; +} + +export default function ListMaxIndentLevelPlugin({ maxDepth }) { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return editor.registerCommand( + INDENT_CONTENT_COMMAND, + () => !isIndentPermitted(maxDepth ?? 7), + COMMAND_PRIORITY_HIGH + ); + }, [editor, maxDepth]); + + return null; +} diff --git a/src/components/LexicalEditor/plugins/TabFocusPlugin.jsx b/src/components/LexicalEditor/plugins/TabFocusPlugin.jsx new file mode 100644 index 0000000..53670b2 --- /dev/null +++ b/src/components/LexicalEditor/plugins/TabFocusPlugin.jsx @@ -0,0 +1,57 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getSelection, $isRangeSelection, $setSelection, FOCUS_COMMAND } from 'lexical'; +import { useEffect } from 'react'; + +const COMMAND_PRIORITY_LOW = 1; +const TAB_TO_FOCUS_INTERVAL = 100; + +let lastTabKeyDownTimestamp = 0; +let hasRegisteredKeyDownListener = false; + +function registerKeyTimeStampTracker() { + window.addEventListener( + 'keydown', + (event) => { + // Tab + if (event.key === 'Tab') { + lastTabKeyDownTimestamp = event.timeStamp; + } + }, + true + ); +} + +export default function TabFocusPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!hasRegisteredKeyDownListener) { + registerKeyTimeStampTracker(); + hasRegisteredKeyDownListener = true; + } + + return editor.registerCommand( + FOCUS_COMMAND, + (event) => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + if (lastTabKeyDownTimestamp + TAB_TO_FOCUS_INTERVAL > event.timeStamp) { + $setSelection(selection.clone()); + } + } + return false; + }, + COMMAND_PRIORITY_LOW + ); + }, [editor]); + + return null; +} diff --git a/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx b/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx new file mode 100644 index 0000000..a5cbea4 --- /dev/null +++ b/src/components/LexicalEditor/plugins/ToolbarPlugin.jsx @@ -0,0 +1,929 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + CAN_REDO_COMMAND, + CAN_UNDO_COMMAND, + REDO_COMMAND, + UNDO_COMMAND, + SELECTION_CHANGE_COMMAND, + FORMAT_TEXT_COMMAND, + FORMAT_ELEMENT_COMMAND, + OUTDENT_CONTENT_COMMAND, + INDENT_CONTENT_COMMAND, + $getSelection, + $isElementNode, + $isRangeSelection, + $createParagraphNode, + $getNodeByKey, +} from 'lexical'; +import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link'; +import { + $getSelectionStyleValueForProperty, + $isParentElementRTL, + $patchStyleText, + $setBlocksType, + // $wrapNodes, + $isAtNodeEnd, +} from '@lexical/selection'; +import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; +import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, $isListNode, ListNode } from '@lexical/list'; +import { createPortal } from 'react-dom'; +import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from '@lexical/rich-text'; +import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages } from '@lexical/code'; +import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; +import DropDown, { DropDownItem } from './../ui/DropDown'; +import DropdownColorPicker from '../ui/DropdownColorPicker'; + +const LowPriority = 1; + +const supportedBlockTypes = new Set(['paragraph', 'quote', 'code', 'h1', 'h2', 'h3', 'ul', 'ol']); + +const blockTypeToBlockName = { + code: 'Code Block', + h1: 'Large Heading', + h2: 'Small Heading', + h3: 'Heading', + h4: 'Heading', + h5: 'Heading', + ol: 'Numbered List', + paragraph: 'Normal', + quote: 'Quote', + ul: 'Bulleted List', +}; + +const FONT_FAMILY_OPTIONS = [ + ['Arial', 'Arial'], + ['Courier New', 'Courier New'], + ['Georgia', 'Georgia'], + ['Times New Roman', 'Times New Roman'], + ['Trebuchet MS', 'Trebuchet MS'], + ['Verdana', 'Verdana'], +]; + +const FONT_SIZE_OPTIONS = [ + ['10px', '10px'], + ['11px', '11px'], + ['12px', '12px'], + ['13px', '13px'], + ['14px', '14px'], + ['15px', '15px'], + ['16px', '16px'], + ['17px', '17px'], + ['18px', '18px'], + ['19px', '19px'], + ['20px', '20px'], +]; + +const ELEMENT_FORMAT_OPTIONS = { + center: { icon: 'center-align', iconRTL: 'center-align', name: 'Center Align' }, + end: { icon: 'right-align', iconRTL: 'left-align', name: 'End Align' }, + justify: { icon: 'justify-align', iconRTL: 'justify-align', name: 'Justify Align' }, + left: { icon: 'left-align', iconRTL: 'left-align', name: 'Left Align' }, + right: { icon: 'right-align', iconRTL: 'right-align', name: 'Right Align' }, + start: { icon: 'left-align', iconRTL: 'right-align', name: 'Start Align' }, +}; + +function dropDownActiveClass(active) { + if (active) { + return 'active dropdown-item-active'; + } else { + return ''; + } +} + +function Divider() { + return
; +} + +function positionEditorElement(editor, rect) { + if (rect === null) { + editor.style.opacity = '0'; + editor.style.top = '-1000px'; + editor.style.left = '-1000px'; + } else { + editor.style.opacity = '1'; + editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`; + editor.style.left = `${rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2}px`; + } +} + +function FloatingLinkEditor({ editor }) { + const editorRef = useRef(null); + const inputRef = useRef(null); + const mouseDownRef = useRef(false); + const [linkUrl, setLinkUrl] = useState(''); + const [isEditMode, setEditMode] = useState(false); + const [lastSelection, setLastSelection] = useState(null); + + const updateLinkEditor = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent)) { + setLinkUrl(parent.getURL()); + } else if ($isLinkNode(node)) { + setLinkUrl(node.getURL()); + } else { + setLinkUrl(''); + } + } + const editorElem = editorRef.current; + const nativeSelection = window.getSelection(); + const activeElement = document.activeElement; + + if (editorElem === null) { + return; + } + + const rootElement = editor.getRootElement(); + if (selection !== null && !nativeSelection.isCollapsed && rootElement !== null && rootElement.contains(nativeSelection.anchorNode)) { + const domRange = nativeSelection.getRangeAt(0); + let rect; + if (nativeSelection.anchorNode === rootElement) { + let inner = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange.getBoundingClientRect(); + } + + if (!mouseDownRef.current) { + positionEditorElement(editorElem, rect); + } + setLastSelection(selection); + } else if (!activeElement || activeElement.className !== 'link-input') { + positionEditorElement(editorElem, null); + setLastSelection(null); + setEditMode(false); + setLinkUrl(''); + } + + return true; + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateLinkEditor(); + }); + }), + + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + updateLinkEditor(); + return true; + }, + LowPriority + ) + ); + }, [editor, updateLinkEditor]); + + useEffect(() => { + editor.getEditorState().read(() => { + updateLinkEditor(); + }); + }, [editor, updateLinkEditor]); + + useEffect(() => { + if (isEditMode && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditMode]); + + return ( +
+ {isEditMode ? ( + { + setLinkUrl(event.target.value); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (lastSelection !== null) { + if (linkUrl !== '') { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl); + } + setEditMode(false); + } + } else if (event.key === 'Escape') { + event.preventDefault(); + setEditMode(false); + } + }} + /> + ) : ( + <> +
+ + {linkUrl} + +
event.preventDefault()} + onClick={() => { + setEditMode(true); + }} + /> + {/* todo: 删除后, AutoLink的作用会使文本再次自动转成链接 */} + {/*
event.preventDefault()} + onClick={() => { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + }} + /> */} +
+ + )} +
+ ); +} + +function Select({ onChange, className, options, value }) { + return ( + + ); +} + +function getSelectedNode(selection) { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? focusNode : anchorNode; + } +} + +function getDomRangeRect(nativeSelection, rootElement) { + const domRange = nativeSelection.getRangeAt(0); + + let rect; + + if (nativeSelection.anchorNode === rootElement) { + let inner = rootElement; + while (inner.firstElementChild != null) { + inner = inner.firstElementChild; + } + rect = inner.getBoundingClientRect(); + } else { + rect = domRange.getBoundingClientRect(); + } + return rect; +} + +function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockOptionsDropDown }) { + const dropDownRef = useRef(null); + + useEffect(() => { + const toolbar = toolbarRef.current; + const dropDown = dropDownRef.current; + + if (toolbar !== null && dropDown !== null) { + const { top, left } = toolbar.getBoundingClientRect(); + dropDown.style.top = `${top + 40}px`; + dropDown.style.left = `${left}px`; + } + }, [dropDownRef, toolbarRef]); + + useEffect(() => { + const dropDown = dropDownRef.current; + const toolbar = toolbarRef.current; + + if (dropDown !== null && toolbar !== null) { + const handle = (event) => { + const target = event.target; + + if (!dropDown.contains(target) && !toolbar.contains(target)) { + setShowBlockOptionsDropDown(false); + } + }; + document.addEventListener('click', handle); + + return () => { + document.removeEventListener('click', handle); + }; + } + }, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]); + + const formatParagraph = () => { + if (blockType !== 'paragraph') { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatLargeHeading = () => { + if (blockType !== 'h1') { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode('h1')); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatSmallHeading = () => { + if (blockType !== 'h2') { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode('h2')); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + const formatSmallHeading3 = () => { + if (blockType !== 'h3') { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode('h3')); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatBulletList = () => { + if (blockType !== 'ul') { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND); + } + setShowBlockOptionsDropDown(false); + }; + + const formatNumberedList = () => { + if (blockType !== 'ol') { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND); + } else { + editor.dispatchCommand(REMOVE_LIST_COMMAND); + } + setShowBlockOptionsDropDown(false); + }; + + const formatQuote = () => { + if (blockType !== 'quote') { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createQuoteNode()); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + const formatCode = () => { + if (blockType !== 'code') { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createCodeNode()); + } + }); + } + setShowBlockOptionsDropDown(false); + }; + + return ( +
+ + + + + + + + {/* */} +
+ ); +} + +function FontDropDown({ editor, value, style, disabled = false }) { + const handleClick = useCallback( + (option) => { + editor.update(() => { + const selection = $getSelection(); + if (selection !== null) { + $patchStyleText(selection, { + [style]: option, + }); + } + }); + }, + [editor, style] + ); + + const buttonAriaLabel = style === 'font-family' ? 'Formatting options for font family' : 'Formatting options for font size'; + + return ( + + {(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(([option, text]) => ( + handleClick(option)} + key={option}> + {text} + + ))} + + ); +} + +function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) { + const formatOption = ELEMENT_FORMAT_OPTIONS[value || 'left']; + + return ( + + { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'); + }} + className='item'> + + Left Align + + { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'); + }} + className='item'> + + Center Align + + { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'); + }} + className='item'> + + Right Align + + { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'); + }} + className='item'> + + Justify Align + + { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'start'); + }} + className='item'> + + Start Align + + { + editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'end'); + }} + className='item'> + + End Align + + + { + editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); + }} + className='item'> + + Outdent (Shift+Tab) + + { + editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); + }} + className='item'> + + Indent (Tab) + + + ); +} + +export default function ToolbarPlugin() { + const [editor] = useLexicalComposerContext(); + const toolbarRef = useRef(null); + const [isEditable, setIsEditable] = useState(() => editor.isEditable()); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); + const [blockType, setBlockType] = useState('paragraph'); + const [selectedElementKey, setSelectedElementKey] = useState(null); + const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState(false); + const [codeLanguage, setCodeLanguage] = useState(''); + const [isRTL, setIsRTL] = useState(false); + const [isLink, setIsLink] = useState(false); + const [isBold, setIsBold] = useState(false); + const [isItalic, setIsItalic] = useState(false); + const [isUnderline, setIsUnderline] = useState(false); + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isCode, setIsCode] = useState(false); + const [fontFamily, setFontFamily] = useState('Arial'); + const [fontColor, setFontColor] = useState('#000'); + const [bgColor, setBgColor] = useState('#fff'); + const [elementFormat, setElementFormat] = useState('left'); + + const [floatingAnchorElem, setFloatingAnchorElem] = useState(null); + const [isSmallWidthViewport, setIsSmallWidthViewport] = useState(false); + + const applyStyleText = useCallback( + (styles, skipHistoryStack = null) => { + editor.update( + () => { + const selection = $getSelection(); + if (selection !== null) { + $patchStyleText(selection, styles); + } + }, + skipHistoryStack ? { tag: 'historic' } : {} + ); + }, + [editor] + ); + + const onFontColorSelect = useCallback( + (value, skipHistoryStack) => { + applyStyleText({ color: value }, skipHistoryStack); + }, + [applyStyleText] + ); + const onBgColorSelect = useCallback( + (value, skipHistoryStack) => { + applyStyleText({ 'background-color': value }, skipHistoryStack); + }, + [applyStyleText] + ); + + const updateToolbar = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + const anchorNode = selection.anchor.getNode(); + const element = anchorNode.getKey() === 'root' ? anchorNode : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + if (elementDOM !== null) { + setSelectedElementKey(elementKey); + if ($isListNode(element)) { + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + const type = parentList ? parentList.getTag() : element.getTag(); + setBlockType(type); + } else { + const type = $isHeadingNode(element) ? element.getTag() : element.getType(); + setBlockType(type); + if ($isCodeNode(element)) { + setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()); + } + } + } + // Update text format + setIsBold(selection.hasFormat('bold')); + setIsItalic(selection.hasFormat('italic')); + setIsUnderline(selection.hasFormat('underline')); + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsCode(selection.hasFormat('code')); + setIsRTL($isParentElementRTL(selection)); + + // Update links + const node = getSelectedNode(selection); + const parent = node.getParent(); + if ($isLinkNode(parent) || $isLinkNode(node)) { + setIsLink(true); + } else { + setIsLink(false); + } + + // Handle buttons + setFontColor($getSelectionStyleValueForProperty(selection, 'color', '#000')); + setBgColor( + $getSelectionStyleValueForProperty( + selection, + 'background-color', + '#fff', + ), + ); + setFontFamily( + $getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'), + ); + let matchingParent; + if ($isLinkNode(parent)) { + // If node is a link, we need to fetch the parent paragraph node to set format + matchingParent = $findMatchingParent(node, (parentNode) => $isElementNode(parentNode) && !parentNode.isInline()); + } + // If matchingParent is a valid node, pass it's format type + setElementFormat($isElementNode(matchingParent) ? matchingParent.getFormatType() : $isElementNode(node) ? node.getFormatType() : parent?.getFormatType() || 'left'); + } + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + updateToolbar(); + }); + }), + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + (_payload, newEditor) => { + updateToolbar(); + return false; + }, + LowPriority + ), + editor.registerCommand( + CAN_UNDO_COMMAND, + (payload) => { + setCanUndo(payload); + return false; + }, + LowPriority + ), + editor.registerCommand( + CAN_REDO_COMMAND, + (payload) => { + setCanRedo(payload); + return false; + }, + LowPriority + ) + ); + }, [editor, updateToolbar]); + + const codeLanguges = useMemo(() => getCodeLanguages(), []); + const onCodeLanguageSelect = useCallback( + (e) => { + editor.update(() => { + if (selectedElementKey !== null) { + const node = $getNodeByKey(selectedElementKey); + if ($isCodeNode(node)) { + node.setLanguage(e.target.value); + } + } + }); + }, + [editor, selectedElementKey] + ); + + const insertLink = useCallback(() => { + if (!isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://'); + } else { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } + }, [editor, isLink]); + + const insertHorizontalRule = useCallback(() => { + editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined); + }, [editor]); + + return ( +
+ + + + {supportedBlockTypes.has(blockType) && ( + <> + + {showBlockOptionsDropDown && + createPortal( + , + document.body + )} + + + )} + {blockType === 'code' ? ( + <> + onChange(e.target.files)} + data-test-id={dataTestId} + /> +
+ ); +} diff --git a/src/components/LexicalEditor/ui/ImageResizer.tsx b/src/components/LexicalEditor/ui/ImageResizer.tsx new file mode 100644 index 0000000..13e9f48 --- /dev/null +++ b/src/components/LexicalEditor/ui/ImageResizer.tsx @@ -0,0 +1,316 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import type {LexicalEditor} from 'lexical'; + +import {calculateZoomLevel} from '@lexical/utils'; +import * as React from 'react'; +import {useRef} from 'react'; + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +const Direction = { + east: 1 << 0, + north: 1 << 3, + south: 1 << 1, + west: 1 << 2, +}; + +export default function ImageResizer({ + onResizeStart, + onResizeEnd, + buttonRef, + imageRef, + maxWidth, + editor, + showCaption, + setShowCaption, + captionsEnabled, +}: { + editor: LexicalEditor; + buttonRef: {current: null | HTMLButtonElement}; + imageRef: {current: null | HTMLElement}; + maxWidth?: number; + onResizeEnd: (width: 'inherit' | number, height: 'inherit' | number) => void; + onResizeStart: () => void; + setShowCaption: (show: boolean) => void; + showCaption: boolean; + captionsEnabled: boolean; +}): JSX.Element { + const controlWrapperRef = useRef(null); + const userSelect = useRef({ + priority: '', + value: 'default', + }); + const positioningRef = useRef<{ + currentHeight: 'inherit' | number; + currentWidth: 'inherit' | number; + direction: number; + isResizing: boolean; + ratio: number; + startHeight: number; + startWidth: number; + startX: number; + startY: number; + }>({ + currentHeight: 0, + currentWidth: 0, + direction: 0, + isResizing: false, + ratio: 0, + startHeight: 0, + startWidth: 0, + startX: 0, + startY: 0, + }); + const editorRootElement = editor.getRootElement(); + // Find max width, accounting for editor padding. + const maxWidthContainer = maxWidth + ? maxWidth + : editorRootElement !== null + ? editorRootElement.getBoundingClientRect().width - 20 + : 100; + const maxHeightContainer = + editorRootElement !== null + ? editorRootElement.getBoundingClientRect().height - 20 + : 100; + + const minWidth = 100; + const minHeight = 100; + + const setStartCursor = (direction: number) => { + const ew = direction === Direction.east || direction === Direction.west; + const ns = direction === Direction.north || direction === Direction.south; + const nwse = + (direction & Direction.north && direction & Direction.west) || + (direction & Direction.south && direction & Direction.east); + + const cursorDir = ew ? 'ew' : ns ? 'ns' : nwse ? 'nwse' : 'nesw'; + + if (editorRootElement !== null) { + editorRootElement.style.setProperty( + 'cursor', + `${cursorDir}-resize`, + 'important', + ); + } + if (document.body !== null) { + document.body.style.setProperty( + 'cursor', + `${cursorDir}-resize`, + 'important', + ); + userSelect.current.value = document.body.style.getPropertyValue( + '-webkit-user-select', + ); + userSelect.current.priority = document.body.style.getPropertyPriority( + '-webkit-user-select', + ); + document.body.style.setProperty( + '-webkit-user-select', + `none`, + 'important', + ); + } + }; + + const setEndCursor = () => { + if (editorRootElement !== null) { + editorRootElement.style.setProperty('cursor', 'text'); + } + if (document.body !== null) { + document.body.style.setProperty('cursor', 'default'); + document.body.style.setProperty( + '-webkit-user-select', + userSelect.current.value, + userSelect.current.priority, + ); + } + }; + + const handlePointerDown = ( + event: React.PointerEvent, + direction: number, + ) => { + if (!editor.isEditable()) { + return; + } + + const image = imageRef.current; + const controlWrapper = controlWrapperRef.current; + + if (image !== null && controlWrapper !== null) { + event.preventDefault(); + const {width, height} = image.getBoundingClientRect(); + const zoom = calculateZoomLevel(image); + const positioning = positioningRef.current; + positioning.startWidth = width; + positioning.startHeight = height; + positioning.ratio = width / height; + positioning.currentWidth = width; + positioning.currentHeight = height; + positioning.startX = event.clientX / zoom; + positioning.startY = event.clientY / zoom; + positioning.isResizing = true; + positioning.direction = direction; + + setStartCursor(direction); + onResizeStart(); + + controlWrapper.classList.add('image-control-wrapper--resizing'); + image.style.height = `${height}px`; + image.style.width = `${width}px`; + + document.addEventListener('pointermove', handlePointerMove); + document.addEventListener('pointerup', handlePointerUp); + } + }; + const handlePointerMove = (event: PointerEvent) => { + const image = imageRef.current; + const positioning = positioningRef.current; + + const isHorizontal = + positioning.direction & (Direction.east | Direction.west); + const isVertical = + positioning.direction & (Direction.south | Direction.north); + + if (image !== null && positioning.isResizing) { + const zoom = calculateZoomLevel(image); + // Corner cursor + if (isHorizontal && isVertical) { + let diff = Math.floor(positioning.startX - event.clientX / zoom); + diff = positioning.direction & Direction.east ? -diff : diff; + + const width = clamp( + positioning.startWidth + diff, + minWidth, + maxWidthContainer, + ); + + const height = width / positioning.ratio; + image.style.width = `${width}px`; + image.style.height = `${height}px`; + positioning.currentHeight = height; + positioning.currentWidth = width; + } else if (isVertical) { + let diff = Math.floor(positioning.startY - event.clientY / zoom); + diff = positioning.direction & Direction.south ? -diff : diff; + + const height = clamp( + positioning.startHeight + diff, + minHeight, + maxHeightContainer, + ); + + image.style.height = `${height}px`; + positioning.currentHeight = height; + } else { + let diff = Math.floor(positioning.startX - event.clientX / zoom); + diff = positioning.direction & Direction.east ? -diff : diff; + + const width = clamp( + positioning.startWidth + diff, + minWidth, + maxWidthContainer, + ); + + image.style.width = `${width}px`; + positioning.currentWidth = width; + } + } + }; + const handlePointerUp = () => { + const image = imageRef.current; + const positioning = positioningRef.current; + const controlWrapper = controlWrapperRef.current; + if (image !== null && controlWrapper !== null && positioning.isResizing) { + const width = positioning.currentWidth; + const height = positioning.currentHeight; + positioning.startWidth = 0; + positioning.startHeight = 0; + positioning.ratio = 0; + positioning.startX = 0; + positioning.startY = 0; + positioning.currentWidth = 0; + positioning.currentHeight = 0; + positioning.isResizing = false; + + controlWrapper.classList.remove('image-control-wrapper--resizing'); + + setEndCursor(); + onResizeEnd(width, height); + + document.removeEventListener('pointermove', handlePointerMove); + document.removeEventListener('pointerup', handlePointerUp); + } + }; + return ( +
+ {!showCaption && captionsEnabled && ( + + )} +
{ + handlePointerDown(event, Direction.north); + }} + /> +
{ + handlePointerDown(event, Direction.north | Direction.east); + }} + /> +
{ + handlePointerDown(event, Direction.east); + }} + /> +
{ + handlePointerDown(event, Direction.south | Direction.east); + }} + /> +
{ + handlePointerDown(event, Direction.south); + }} + /> +
{ + handlePointerDown(event, Direction.south | Direction.west); + }} + /> +
{ + handlePointerDown(event, Direction.west); + }} + /> +
{ + handlePointerDown(event, Direction.north | Direction.west); + }} + /> +
+ ); +} diff --git a/src/components/LexicalEditor/ui/Input.css b/src/components/LexicalEditor/ui/Input.css new file mode 100644 index 0000000..60eb2f1 --- /dev/null +++ b/src/components/LexicalEditor/ui/Input.css @@ -0,0 +1,32 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * + */ + +.Input__wrapper { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 10px; +} +.Input__label { + display: flex; + flex: 1; + color: #666; +} +.Input__input { + display: flex; + flex: 2; + border: 1px solid #999; + padding-top: 7px; + padding-bottom: 7px; + padding-left: 10px; + padding-right: 10px; + font-size: 16px; + border-radius: 5px; + min-width: 0; +} diff --git a/src/components/LexicalEditor/ui/TextInput.tsx b/src/components/LexicalEditor/ui/TextInput.tsx new file mode 100644 index 0000000..7b1765f --- /dev/null +++ b/src/components/LexicalEditor/ui/TextInput.tsx @@ -0,0 +1,46 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import './Input.css'; + +import * as React from 'react'; +import {HTMLInputTypeAttribute} from 'react'; + +type Props = Readonly<{ + 'data-test-id'?: string; + label: string; + onChange: (val: string) => void; + placeholder?: string; + value: string; + type?: HTMLInputTypeAttribute; +}>; + +export default function TextInput({ + label, + value, + onChange, + placeholder = '', + 'data-test-id': dataTestId, + type = 'text', +}: Props): JSX.Element { + return ( +
+ + { + onChange(e.target.value); + }} + data-test-id={dataTestId} + /> +
+ ); +} diff --git a/src/components/LexicalEditor/utils/getSelectedNode.ts b/src/components/LexicalEditor/utils/getSelectedNode.ts new file mode 100644 index 0000000..b106e4c --- /dev/null +++ b/src/components/LexicalEditor/utils/getSelectedNode.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +import {$isAtNodeEnd} from '@lexical/selection'; +import {ElementNode, RangeSelection, TextNode} from 'lexical'; + +export function getSelectedNode( + selection: RangeSelection, +): TextNode | ElementNode { + const anchor = selection.anchor; + const focus = selection.focus; + const anchorNode = selection.anchor.getNode(); + const focusNode = selection.focus.getNode(); + if (anchorNode === focusNode) { + return anchorNode; + } + const isBackward = selection.isBackward(); + if (isBackward) { + return $isAtNodeEnd(focus) ? anchorNode : focusNode; + } else { + return $isAtNodeEnd(anchor) ? anchorNode : focusNode; + } +} diff --git a/src/components/LexicalEditor/utils/joinClasses.ts b/src/components/LexicalEditor/utils/joinClasses.ts new file mode 100644 index 0000000..40a2dd5 --- /dev/null +++ b/src/components/LexicalEditor/utils/joinClasses.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +export default function joinClasses( + ...args: Array +) { + return args.filter(Boolean).join(' '); +} diff --git a/src/components/LexicalEditor/utils/setFloatingElemPositionForLinkEditor.ts b/src/components/LexicalEditor/utils/setFloatingElemPositionForLinkEditor.ts new file mode 100644 index 0000000..8b45582 --- /dev/null +++ b/src/components/LexicalEditor/utils/setFloatingElemPositionForLinkEditor.ts @@ -0,0 +1,46 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +const VERTICAL_GAP = 10; +const HORIZONTAL_OFFSET = 5; + +export function setFloatingElemPositionForLinkEditor( + targetRect: DOMRect | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, + verticalGap: number = VERTICAL_GAP, + horizontalOffset: number = HORIZONTAL_OFFSET, +): void { + const scrollerElem = anchorElem.parentElement; + + if (targetRect === null || !scrollerElem) { + floatingElem.style.opacity = '0'; + floatingElem.style.transform = 'translate(-10000px, -10000px)'; + return; + } + + const floatingElemRect = floatingElem.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + const editorScrollerRect = scrollerElem.getBoundingClientRect(); + + let top = targetRect.top - verticalGap; + let left = targetRect.left - horizontalOffset; + + if (top < editorScrollerRect.top) { + top += floatingElemRect.height + targetRect.height + verticalGap * 2; + } + + if (left + floatingElemRect.width > editorScrollerRect.right) { + left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset; + } + + top -= anchorElementRect.top; + left -= anchorElementRect.left; + + floatingElem.style.opacity = '1'; + floatingElem.style.transform = `translate(${left}px, ${top}px)`; +} diff --git a/src/components/LexicalEditor/utils/url.ts b/src/components/LexicalEditor/utils/url.ts new file mode 100644 index 0000000..7a05a5e --- /dev/null +++ b/src/components/LexicalEditor/utils/url.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +const SUPPORTED_URL_PROTOCOLS = new Set([ + 'http:', + 'https:', + 'mailto:', + 'sms:', + 'tel:', +]); + +export function sanitizeUrl(url: string): string { + try { + const parsedUrl = new URL(url); + // eslint-disable-next-line no-script-url + if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) { + return 'about:blank'; + } + } catch { + return url; + } + return url; +} + +// Source: https://stackoverflow.com/a/8234912/2013580 +const urlRegExp = new RegExp( + /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/, +); +export function validateUrl(url: string): boolean { + // TODO Fix UI for link insertion; it should never default to an invalid URL such as https://. + // Maybe show a dialog where they user can type the URL before inserting it. + return url === 'https://' || urlRegExp.test(url); +} diff --git a/src/stores/ConversationStore.js b/src/stores/ConversationStore.js index b46974d..9a54ed7 100644 --- a/src/stores/ConversationStore.js +++ b/src/stores/ConversationStore.js @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { RealTimeAPI } from '@/channel/realTimeAPI'; import { olog, isEmpty } from '@/utils/commons'; import { receivedMsgTypeMapped, handleNotification } from '@/channel/whatsappUtils'; -import { fetchConversationsList, fetchTemplates, fetchConversationsSearch, UNREAD_MARK } from '@/actions/ConversationActions'; +import { fetchConversationsList, fetchTemplates, fetchConversationsSearch, UNREAD_MARK, fetchTags } from '@/actions/ConversationActions'; import { devtools } from 'zustand/middleware'; import { WS_URL, DATETIME_FORMAT } from '@/config'; import dayjs from 'dayjs'; @@ -47,6 +47,35 @@ const initialConversationState = { }; +// 顾问的自定义标签 +const tagsSlice = (set) => ({ + tags: [], + setTags: (tags) => set({ tags }), + addTag: (tag) => set((state) => ({ tags: [...state.tags, tag] })), + removeTag: (tag) => set((state) => ({ tags: state.tags.filter((t) => t.key !== tag.key) })), + updateTag: (tag) => set((state) => ({ tags: state.tags.map((t) => (t.key === tag.key ? tag : t)) })), + resetTags: () => set({ tags: [] }), +}); + +// 会话筛选 +const filterObj = { + search: '', + otype: '', + tags: [], + status: [], + labels: [], +}; +const filterSlice = (set) => ({ + filter: structuredClone(filterObj), + setFilter: (filter) => set({ filter }), + setFilterSearch: (search) => set((state) => ({ filter: { ...state.filter, search } })), + setFilterOtype: (otype) => set((state) => ({ filter: {...state.filter, otype } })), + setFilterTags: (tags) => set((state) => ({ filter: {...state.filter, tags } })), + setFilterStatus: (status) => set((state) => ({ filter: {...state.filter, status } })), + setFilterLabels: (labels) => set((state) => ({ filter: {...state.filter, labels } })), + resetFilter: () => set({ filter: structuredClone(filterObj) }), +}) +// WABA 模板 const templatesSlice = (set) => ({ templates: [], setTemplates: (templates) => set({ templates }), @@ -362,6 +391,8 @@ export const useConversationStore = create( ...messageSlice(set, get), ...referenceMsgSlice(set, get), ...complexMsgSlice(set, get), + ...tagsSlice(set, get), + ...filterSlice(set, get), // state actions addError: (error) => set((state) => ({ errors: [...state.errors, error] })), @@ -369,7 +400,7 @@ export const useConversationStore = create( // side effects fetchInitialData: async (userIds) => { - const { addToConversationList, setTemplates, setInitial, setClosedConversationList } = get(); + const { addToConversationList, setTemplates, setInitial, setClosedConversationList, setTags } = get(); const conversationsList = await fetchConversationsList({ opisn: userIds }); addToConversationList(conversationsList); @@ -380,6 +411,9 @@ export const useConversationStore = create( const closedList = await fetchConversationsSearch({ opisn: userIds, session_enable: 0 }); setClosedConversationList(closedList); + const myTags = await fetchTags(); + setTags(myTags); + setInitial(true); }, diff --git a/src/stores/StyleStore.js b/src/stores/StyleStore.js new file mode 100644 index 0000000..6862205 --- /dev/null +++ b/src/stores/StyleStore.js @@ -0,0 +1,10 @@ +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; + +export const useStyleStore = create( + devtools((set, get) => ({ + mobile: false, + setMobile: (mobile) => set({ mobile }), + })) +); +export default useStyleStore; diff --git a/src/utils/commons.js b/src/utils/commons.js index b5d2587..db6032f 100644 --- a/src/utils/commons.js +++ b/src/utils/commons.js @@ -395,12 +395,13 @@ export const cartesianProductArray = (arr, sep = '_', index = 0, prefix = '') => return result; }; -export const stringToColour = (str) => { +export const stringToColour = (str='', withFlag = true) => { var hash = 0 + if (str.length === 0) return hash; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash) } - var colour = '#' + var colour = withFlag ? '#' : '' for (let i = 0; i < 3; i++) { var value = (hash >> (i * 8)) & 0xff value = (value % 150) + 50 diff --git a/src/views/ChatHistory.jsx b/src/views/ChatHistory.jsx index 10c072d..96106c0 100644 --- a/src/views/ChatHistory.jsx +++ b/src/views/ChatHistory.jsx @@ -33,7 +33,7 @@ const Index = (props) => { const getConversationsList = async () => { setConversationsListLoading(true); - const params = flush(pick(formValues, ['opisn', 'whatsapp_id', 'search', 'from_date', 'end_date', 'coli_id', 'lasttime'])); + const params = flush(pick(formValues, ['opisn', 'whatsapp_id', 'search', 'from_date', 'end_date', 'coli_id', 'lasttime', 'mtype', 'mchannel'])); const data = await fetchConversationsSearch({ ...params, ...pageParam, pagesize: CONVERSATION_PAGE_SIZE }); setConversationsListLoading(false); setConversationsList(conversationsList.concat(data)); diff --git a/src/views/ChatWindow.jsx b/src/views/ChatWindow.jsx index 2366d1a..ce803d3 100644 --- a/src/views/ChatWindow.jsx +++ b/src/views/ChatWindow.jsx @@ -4,11 +4,12 @@ import { RightCircleOutlined, RightOutlined, ReloadOutlined, MenuFoldOutlined, M // import { useParams, useNavigate } from 'react-router-dom'; import MessagesHeader from './Conversations/Online/MessagesHeader'; import MessagesWrapper from './Conversations/Online/MessagesWrapper'; -import InputComposer from './Conversations/Online/InputComposer'; +import InputComposer from './Conversations/Online/Input/InputComposer'; import ConversationsList from './Conversations/Online/ConversationsList'; import CustomerProfile from './Conversations/Online/order/CustomerProfile'; // import { useAuthContext } from '@/stores/AuthContext'; // import useConversationStore from '@/stores/ConversationStore'; +import ReplyWrapper from './Conversations/Online/ReplyWrapper'; import './Conversations/Conversations.css'; @@ -45,7 +46,7 @@ const ChatWindow = () => {
- + ) : btn.type.toLowerCase() === 'phone_number' ? ( + + ) : ( + + ) + )} +
+ ) : null} + + ); + }); + return ( + setReferenceMsg(message)} + onReplyMessageClick={() => scrollToMessage(message.reply.id)} + onOpen={() => handlePreview(message)} + onTitleClick={() => handlePreview(message)} + // title={
{message.title}
} + text={} + replyButton={['text', 'document', 'image'].includes(message.whatsapp_msg_type)} + {...(message.sender === 'me' + ? { + // styles: { backgroundColor: '#ccd4ae' }, + notchStyle: { fill: '#ccd4ae' }, // todo: channel color '#d9fdd3' + replyButton: ['text', 'document', 'image'].includes(message.whatsapp_msg_type) && message.status !== 'failed' ? true : false, + } + : {})} + // notch={false} + className={[ + 'whitespace-pre-wrap', + message.whatsapp_msg_type === 'sticker' ? 'bg-transparent' : '', + // message.sender === 'me' ? 'whatsappme-container' : '', + focusMsg === message.id ? 'message-box-focus' : '', + message.status === 'failed' ? 'failed-msg' : '', + // '*:bg-waba-me' + message.sender === 'me' ? '*:!bg-waba-me' : '', // todo: channel color + ].join(' ')} + {...(message.type === 'meetingLink' + ? { + actionButtons: [ + ...(message.waBtn + ? [ + { + onClickButton: () => handleContactClick(message.data), + Component: () =>
发消息
, + }, + ] + : []), + { + onClickButton: () => { + navigator.clipboard.writeText(message.text); + appMessage.success('复制成功😀'); + }, + Component: () =>
复制
, + }, + ], + } + : {})} + /> + ); +}; +export default BubbleIM; diff --git a/src/views/Conversations/Online/Components/ChannelLogo.jsx b/src/views/Conversations/Online/Components/ChannelLogo.jsx new file mode 100644 index 0000000..f9c4408 --- /dev/null +++ b/src/views/Conversations/Online/Components/ChannelLogo.jsx @@ -0,0 +1,17 @@ +import React, { } from 'react'; +import { WhatsAppOutlined, MailOutlined } from '@ant-design/icons'; +import { WABIcon, } from '@/components/Icons'; + +const ChannelLogo = ({channel}) => { + switch (channel) { + case 'waba': + return ; + case 'wa': + return ; + case 'email': + return + default: + return + } +} +export default ChannelLogo; diff --git a/src/views/Conversations/Online/Components/ChatListFilter.jsx b/src/views/Conversations/Online/Components/ChatListFilter.jsx new file mode 100644 index 0000000..9248f7f --- /dev/null +++ b/src/views/Conversations/Online/Components/ChatListFilter.jsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +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: '' }, + { label: '重点', value: 'zhongdian' }, + { label: '次重点', value: 'qianli' }, + { label: '成行', value: 'chengxing' }, + { label: '走团中', value: 'zoutuan' }, +]; +const otypesMapped = otypes.reduce((acc, cur) => ({ ...acc, [cur.value]: cur }), {}); +const TagColorStyle = (tag, outerStyle = false) => { + const color = stringToColour(tag); + const outerStyleObj = outerStyle ? { borderColor: `${color}66`, backgroundColor: `${color}0D` } : {}; + return { color: `${color}`, ...outerStyleObj }; +}; +const ChatListFilter = ({ ...props }) => { + const handleFilter = async (param) => {}; + + const [ + { tags: selectedTags, otype: selectedOType, ...filter }, + setFilterTags, setFilterOtype, resetFilter + ] = useConversationStore((state) => [ + state.filter, + state.setFilterTags, state.setFilterOtype, state.resetFilter + ]); + + const [tags] = useConversationStore((state) => [state.tags]); + const [form] = Form.useForm(); + const handleTagsChange = (tag, checked) => { + const nextSelectedTags = checked ? [...selectedTags, tag.key] : selectedTags.filter((t) => t !== tag.key); + setFilterTags(nextSelectedTags); + form.setFieldValue('tags', nextSelectedTags); + }; + const onFinish = async (values) => { + const filterParam = objectMapper(values, { tags: {key:'tags', transform: (v) => v ? v.join(',') : ''} }); + filterParam.otype = selectedOType; + console.log('Received values of form[filter_form]: ', values, ' \n', filterParam); + await handleFilter(filterParam); + setOpenPopup(false); + }; + const onReset = () => { + resetFilter(); + form.resetFields(); + } + const [openPopup, setOpenPopup] = useState(false); + return ( + <> +
+ setFilterOtype(e.target.value)} /> + +
更多会话筛选
+ +
+ } + content={ + <> +
+ + setFilterOtype('')}> + {otypesMapped[selectedOType].label} + + + + {tags.map((tag, ti) => ( + handleTagsChange(tag, checked)} + style={TagColorStyle(tag.label, selectedTags.includes(tag.key))}> + {tag.label} + + ))} + + + + + + + + +
+ + }> + {/*
+ + ); +}; +export default ChatListFilter; diff --git a/src/views/Conversations/Online/Components/ChatListItem.jsx b/src/views/Conversations/Online/Components/ChatListItem.jsx new file mode 100644 index 0000000..7ecda66 --- /dev/null +++ b/src/views/Conversations/Online/Components/ChatListItem.jsx @@ -0,0 +1,323 @@ +import React, { useEffect, useState } from 'react'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import { Dropdown, Input, Button, Tag, Popover, Form } from 'antd'; +import { CloseCircleOutlined } from '@ant-design/icons'; +import { fetchConversationItemClose, fetchConversationsSearch, fetchConversationItemUnread, fetchConversationItemTop, postConversationTags, deleteConversationTags } from '@/actions/ConversationActions'; +import { ChatItem } from 'react-chat-elements'; +// import ConversationsNewItem from './ConversationsNewItem'; +import { isEmpty, stringToColour } from '@/utils/commons'; +import useConversationStore from '@/stores/ConversationStore'; +import useAuthStore from '@/stores/AuthStore'; +import ChannelLogo from './ChannelLogo'; +import { DeliverIcon, ReadIcon, SentIcon } from '@/components/Icons'; +import useStyleStore from '@/stores/StyleStore'; + +const TagColorStyle = (tag) => { + const color = stringToColour(tag); + return { color: `${color}`, borderColor: `${color}66`, backgroundColor: `${color}0D` } +} +const TagColorStyle_2 = (tag, outerStyle = false) => { + const color = stringToColour(tag); + const outerStyleObj = outerStyle ? { borderColor: `${color}66`, } : {}; + return { color: `${color}`, ...outerStyleObj }; +}; + +const NewTagForm = ({onSubmit,...props}) => { + const [form] = Form.useForm(); + const [subLoding, setSubLoding] = useState(false); + const [tags, addTag] = useConversationStore(state => [state.tags, state.addTag]); + const onFinish = async (values) => { + console.log('Received values of form[new_tag]: ', values); + setSubLoding(true); + if (typeof onSubmit === 'function') { + onSubmit(); + } + // debug: + setTimeout(() => { + setSubLoding(false); + addTag({ label: values.tag_label, key: values.tag_label, value: values.tag_label }) + }, 2000); + form.resetFields(); + } + return ( +
+ + + + + + +
+ ); +}; +const EditChatMemoForm = ({onSubmit,...props}) => { + const [form] = Form.useForm(); + const [subLoding, setSubLoding] = useState(false); + const onFinish = async (values) => { + console.log('Received values of form[chat_memo]: ', values); + setSubLoding(true); + // debug: + setTimeout(() => { + setSubLoding(false); + }, 2000); + if (typeof onSubmit === 'function') { + onSubmit(); + } + form.resetFields(); + } + return ( +
+ + + + + + +
+ ); +}; + + +const ChatListItem = (({item, refreshConversationList,setListUpdateFlag,onSwitchConversation,tabSelectedConversation, setNewChatModalVisible,setEditingChat,...props}) => { + const [mobile] = useStyleStore((state) => [state.mobile]); + + const routerReplace = mobile === false ? true : false; // : true; + const routePrefix = mobile === false ? `/order/chat` : `/m/chat`; + const { state: orderRow } = useLocation(); + const { coli_guest_WhatsApp } = orderRow || {}; + const { order_sn } = useParams(); + const navigate = useNavigate(); + const userId = useAuthStore((state) => state.loginUser.userId); + const initialState = useConversationStore((state) => state.initialState); + const [currentConversation, setCurrentConversation] = useConversationStore((state) => [state.currentConversation, state.setCurrentConversation]); + const conversationsList = useConversationStore((state) => state.conversationsList); + const [conversationsListLoading, setConversationsListLoading] = useConversationStore((state) => [state.conversationsListLoading, state.setConversationsListLoading]); + const addToConversationList = useConversationStore((state) => state.addToConversationList); + const delConversationitem = useConversationStore((state) => state.delConversationitem); + + const closedConversationsList = useConversationStore((state) => state.closedConversationsList); + const setClosedConversationList = useConversationStore((state) => state.setClosedConversationList); + + const itemTagsKeys = (item.tags || []).map(t => t.key); + const [tags, addTag] = useConversationStore(state => [state.tags, state.addTag]); + const handleConversationItemClose = async (item) => { + await fetchConversationItemClose({ conversationid: item.sn, opisn: item.opi_sn }); + delConversationitem(item); + if (String(order_sn) === String(item.coli_sn)) { + navigate(routePrefix, { replace: routerReplace }); + } + const _clist = await fetchConversationsSearch({ opisn: userId, session_enable: 0 }); + setClosedConversationList(_clist); + }; + + const handleConversationItemUnread = async (item) => { + await fetchConversationItemUnread({ conversationid: item.sn }); + await refreshConversationList(); + setListUpdateFlag(Math.random()); + } + + const handleConversationItemTop = async (item) => { + await fetchConversationItemTop({ conversationid: item.sn, top_state: item.top_state === 0 ? 1 : 0 }); + await refreshConversationList(); + setListUpdateFlag(Math.random()); + } + + const handleConversationItemTags = async (item, tagKey) => { + const _tags = item.tags || []; + if (_tags.includes(tagKey)) { + await deleteConversationTags({ conversationid: item.sn, tag_id: tagKey, opisn: userId }) + } else { + await postConversationTags({ conversationid: item.sn, tag_id: tagKey, opisn: userId }); + } + await refreshConversationList(); + setListUpdateFlag(Math.random()); + } + + const [contextMenuOpen, setContextMenuOpen] = useState(false); + const handleContextMenuOpenChange = (nextOpen, info) => { + if (info.source === 'trigger' || nextOpen) { + setContextMenuOpen(nextOpen); + } + }; + + const [openTags, setOpenTags] = useState([]); + useEffect(() => { + if (contextMenuOpen === false) { + setOpenTags([]); + } + + return () => {}; + }, [contextMenuOpen]) + + return ( + <> + ({ + ...t, + key: `tag_${t.key}`, + style: { color: stringToColour(t.label) }, + icon: itemTagsKeys.includes(t.key) ? : false, + })), + { + label: ( + <> + setContextMenuOpen(false)} />} placement='bottom' trigger={['click']}> + {/* todo: refresh list */} + + + + ), + key: 'new_tags', + }, + ], + onTitleClick: ({ key, domEvent }) => { + console.log(']]]', key); + }, + }, + { label: '编辑联系人', key: 'edit0' }, + // { + // label: ( + // <> + // {/* todo: refresh list */} + // setContextMenuOpen(false)} />} placement='bottom' trigger={['click']}> + // {/* */} + // + // + // ), + // key: 'remark', + // }, + { type: 'divider' }, + { label: '隐藏会话', key: 'close', danger: true }, + ], + triggerSubMenuAction: 'click', + openKeys: openTags, + onOpenChange: (openKeys) => { + if (!isEmpty(openKeys) && contextMenuOpen) { + setOpenTags(openKeys); + } + }, + onClick: ({ key, domEvent }) => { + domEvent.stopPropagation(); + if (key.startsWith('tag_')) { + const tagKey = key.replace('tag_', ''); + return handleConversationItemTags(item, tagKey); + } + switch (key) { + case 'top': + setContextMenuOpen(false); + return handleConversationItemTop(item); + case 'unread': + setContextMenuOpen(false); + return handleConversationItemUnread(item); + // case 'remark': + // setOpenTags([]); + // return; + case 'close': + setContextMenuOpen(false); + return handleConversationItemClose(item); + case 'edit0': + setOpenTags([]); + setEditingChat({...item, is_new: false}); + return setNewChatModalVisible(true); + + default: + // setContextMenuOpen(false); + console.log('unknown key', key); + + return; + } + }, + }}> +
+ {/*
+ {tags.map((tag) => {tag.label})} +
*/} + + {/* */} + {/* */} + {/* */} + {/* todo: last message ⤴⤵↗️↖️↘✔️ */} + {/* {item.coli_id} */} + 最后一条消息 + {/* 最后一条消息 */} +
+ {[ + { label: '已付款', key: 'p1' }, + { label: '地接', key: 'p2' }, + ]?.map((tag) => ( + + {tag.label} + + ))} + {/* 附加备注 */} +
+
+ } + date={item.last_received_time || item.last_send_time} + unread={item.unread_msg_count > 99 ? 0 : item.unread_msg_count} + // className={[ + // String(item.sn) === String(currentConversation.sn) ? '__active text-primary bg-whatsapp-bg' : '', + // String(item.sn) === String(tabSelectedConversation?.sn) ? ' bg-neutral-200' : '', + // ].join(' ')} + // statusText={} + statusText={} + statusColor={'#fff'} + onClick={() => onSwitchConversation(item)} + customStatusComponents={[ + ...(item.unread_msg_count > 99 ? [() =>
] : []), + // () => { + const color = stringToColour(tag); + return { color: `${color}`, borderColor: `${color}66`, backgroundColor: `${color}0D` }; +}; +const EmailDetail = ({ open, setOpen, emailDetail, ...props }) => { + let { emailOrigin } = emailDetail; + emailOrigin = emailOrigin || {}; + // const [open, setOpen] = useState(false); + const [initialPosition, setInitialPosition] = useState({}); + const [initialSize, setInitialSize] = useState({}); + function onHandleMove(e) { + const { top, left, width, height } = e; + setInitialPosition({ top, left }); + } + function onHandleResize(e) { + const { top, left, width, height } = e; + setInitialPosition({ top, left }); + setInitialSize({ width, height }); + } + + const [action, setAction] = useState(''); + + const [openEmailEditor, setOpenEmailEditor] = useState(false); + const [fromEmail, setFromEmail] = useState(''); + const [ReferEmailMsg, setReferEmailMsg] = useState(''); + const onOpenEditor = (emailOrigin, action) => { + const { replyToEmail: email_addr, content } = emailOrigin; + setOpenEmailEditor(true); + setFromEmail(email_addr); + setReferEmailMsg(emailOrigin); + setAction(action); + setOpen(false); + }; + + return ( + <> + + {/* email toolbar */} +
+ {/*
+ +
*/} +
{emailOrigin.subject}
+ +
+
+
+ + {(emailOrigin.fromName || '').substring(0, 1)} + +
+ {emailOrigin.fromName} + {emailOrigin.fromEmail} +
+
+
+
+ + +
+ {/*
{emailDetail.dateText}
*/} +
{emailDetail.localDate}
+
+
+
+ 收件人: + {emailOrigin.toName} +   <{emailOrigin.toEmail}> +
+ {emailOrigin.cc && ( +
+ 抄送: + {emailOrigin.cc} +
+ )} + {emailOrigin.bcc && ( +
+ 密送: + {emailOrigin.bcc} +
+ )} + {/*
+ 主题: + {emailOrigin.subject} +
*/} + + {/*
{emailOrigin.body}
*/} +
+ {/*
{emailOrigin.attachments.map(attachment =>
{attachment.name}
)}
*/} +
+
+
+ + + ); +}; +export default EmailDetail; diff --git a/src/views/Conversations/Online/Components/MessageListFilter.jsx b/src/views/Conversations/Online/Components/MessageListFilter.jsx new file mode 100644 index 0000000..f67ec45 --- /dev/null +++ b/src/views/Conversations/Online/Components/MessageListFilter.jsx @@ -0,0 +1,416 @@ +import { useEffect, useState } from 'react'; +import { App, Button, Popover, Tabs, List, Image, Avatar, Card, Flex, Space } from 'antd'; +import { FileSearchOutlined, LoadingOutlined } from '@ant-design/icons'; +import { + DownloadOutlined, + LeftOutlined, + RightOutlined, + RotateLeftOutlined, + RotateRightOutlined, + SwapOutlined, + UndoOutlined, + ZoomInOutlined, + ZoomOutOutlined, +} from '@ant-design/icons'; +import { InboxIcon, SendPlaneFillIcon, ShareForwardIcon } from '@/components/Icons'; +import { groupBy, stringToColour } from '@/utils/commons'; +import { useShallow } from 'zustand/react/shallow'; +import EmailDetail from './EmailDetail'; +import { MESSAGE_PAGE_SIZE, fetchMessagesHistory } from '@/actions/ConversationActions'; +import DnDModal from '@/components/DnDModal'; +import useConversationStore from '@/stores/ConversationStore'; +import useStyleStore from '@/stores/StyleStore'; +import useAuthStore from '@/stores/AuthStore'; +import { sentMsgTypeMapped, whatsappSupportFileTypes, uploadProgressSimulate } from '@/channel/whatsappUtils'; +import { v4 as uuid } from 'uuid'; + +const BIG_PAGE_SIZE = MESSAGE_PAGE_SIZE * 10; + +const CalColorStyle = (tag, outerStyle = true) => { + const color = stringToColour(tag); + const outerStyleObj = outerStyle ? { borderColor: `${color}66`, backgroundColor: `${color}0D` } : {}; + return { color: `${color}`, ...outerStyleObj }; +}; +const getVideoName = (vUrl) => { + if (!vUrl) return ''; + const url = new URL(vUrl); + return url.pathname.split('/').pop(); +}; +/** + * 消息记录筛选---------------------------------------------------------------------------------------------------- + */ +const MessageListFilter = ({ ...props }) => { + const websocket = useConversationStore((state) => state.websocket); + const userId = useAuthStore((state) => state.loginUser.userId); + const sentOrReceivedNewMessage = useConversationStore((state) => state.sentOrReceivedNewMessage); + + const [mobile] = useStyleStore((state) => [state.mobile]); + const [openPopup, setOpenPopup] = useState(false); + + const activeMessages = useConversationStore( + useShallow((state) => (state.currentConversation.sn && state.activeConversations[state.currentConversation.sn] ? state.activeConversations[state.currentConversation.sn] : [])) + ); + const currentConversation = useConversationStore((state) => state.currentConversation); + const { opi_sn: opisn, whatsapp_phone_number: whatsappid } = currentConversation; + + const { message: appMessage } = App.useApp(); + + const LongList = () => { + return <>; + }; + + const [loading, setLoading] = useState(false); + + const [paramsForMsgList, setParamsForMsgList] = useState({}); + const [historyMessages, setHistoryMessages] = useState([]); + const getMessagesPre = async (param) => { + setLoading(true); + const chatItem = { opisn, whatsappid }; + const data = await fetchMessagesHistory({ ...chatItem, lasttime: param.pretime, pagedir: 'pre', pagesize: BIG_PAGE_SIZE }); + setLoading(false); + setHistoryMessages((prevValue) => [].concat(data, prevValue)); + const loadPrePage = !(data.length === 0 || data.length < BIG_PAGE_SIZE); + // if (data.length > 0) { + // setParamsForMsgList({ loadPrePage, pretime: data[0].orgmsgtime }); + // } + setParamsForMsgList((preVal) => ({ ...preVal, loadPrePage, pretime: data.length > 0 ? data[0].orgmsgtime : preVal.pretime })); + }; + const onLoadMore = async () => { + await getMessagesPre(paramsForMsgList); + }; + const loadMore = paramsForMsgList.loadPrePage ? ( +
+ {!loading ? ( + + ) : ( + + )} +
+ ) : null; + + const handleCopyClick = (url) => { + try { + navigator.clipboard.writeText(url); + appMessage.success('复制成功😀'); + } catch (error) { + appMessage.warning('不支持自动复制, 请手动复制'); + } + }; + + useEffect(() => { + if (activeMessages.length > 0) { + setHistoryMessages(activeMessages); + } + const { opi_sn: opisn, whatsapp_phone_number: whatsappid } = currentConversation; + setParamsForMsgList({ loadPrePage: true, pretime: activeMessages.length > 0 ? activeMessages[0].orgmsgtime : '', opisn, whatsappid }); + + return () => {}; + }, [activeMessages, currentConversation.sn]); + + const Album = () => { + const data = historyMessages.filter((item) => item.type === 'photo').reverse(); + const byDate = groupBy(data, (item) => item.localDate.slice(0, 10)); + + const [visible, setVisible] = useState(false); + const handleReSend = (currentIndex) => { + console.log('handleReSend', currentIndex, data[currentIndex]); + // todo: 没有先push到窗口上, 导致没有更新 + const item = data[currentIndex]; + const msgObjMerge = { + sender: 'me', + senderName: 'me', + to: currentConversation.whatsapp_phone_number, + date: new Date(), + status: 'waiting', + // ...msgObj, + data: { link: item.data.uri, dataUri: item.data.uri, uri: item.data.uri, loading: 1 }, // ...fileObj.data, + id: `${currentConversation.sn}.${uuid()}`, + type: item.whatsapp_msg_type, + // name: item.title, + }; + const contentToRender = sentMsgTypeMapped[item.type].contentToRender(msgObjMerge); + sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender); + + const contentToSend = sentMsgTypeMapped[item.type].contentToSend(msgObjMerge); + websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn, conversationid: currentConversation.sn }); + setOpenPopup(false); + setVisible(false); + }; + return ( + <> + { + setVisible(value); + }, + toolbarRender: (_, { transform: { scale }, actions: { onRotateLeft, onRotateRight, onZoomOut, onZoomIn }, current }) => ( + + + + + + {/* handleReSend(current)} className='cursor-pointer hover:opacity-30' title='重发' /> */} + + ), + }}> + ( + + +
+ {byDate[date].map((img) => ( + + ))} +
+
+
+ )} + /> +
+ + ); + }; + + const Videos = () => { + const data = historyMessages.filter((item) => item.type === 'video').reverse(); + + const [videoUrl, setVideoUrl] = useState(''); + const [openVideoPlay, setOpenVideoPlay] = useState(false); + const handleOpenVideoPlay = (vurl) => { + setVideoUrl(vurl); + setOpenVideoPlay(true); + setOpenPopup(false); + }; + + return ( + <> + ( + + + {item.senderName} + + } + title={ handleOpenVideoPlay(item?.data.videoURL)}>{getVideoName(item?.data.videoURL)}} + description={ + +
{item.localDate}
+ +
+ } + /> + {item.text} +
+ )} + /> + + + + + ); + }; + const Audios = () => { + const data = historyMessages.filter((item) => item.type === 'audio').reverse(); + return ( + <> + ( + + + {item.senderName.substring(0, 5)} + + } + /> + + + )} + /> + + ); + }; + + const FileList = () => { + const data = historyMessages.filter((item) => item.type === 'file').reverse(); + const invokeSendUploadMessage = (item) => { + const msgObjMerge = { + sender: 'me', + senderName: 'me', + to: currentConversation.whatsapp_phone_number, + date: new Date(), + status: 'waiting', + // ...msgObj, + data: { link: item.data.uri, dataUri: item.data.uri, uri: item.data.uri, loading: 1 }, // ...fileObj.data, + id: `${currentConversation.sn}.${uuid()}`, + type: 'document', + name: item.title, + }; + const contentToRender = sentMsgTypeMapped[msgObjMerge.type].contentToRender(msgObjMerge); + sentOrReceivedNewMessage(contentToRender.conversationid, contentToRender); + + const contentToSend = sentMsgTypeMapped[msgObjMerge.type].contentToSend(msgObjMerge); + websocket.sendMessage({ ...contentToSend, opi_sn: userId, coli_sn: currentConversation.coli_sn, conversationid: currentConversation.sn }); + setOpenPopup(false); + }; + return ( + <> + {/* {data.length === 0 && } */} + ( + + + {item.senderName} + + } + title={ + + {item.title} + + } + description={ + +
{item.localDate}
+ + {/* */} +
+ } + /> + {item.text} +
+ )} + /> + + ); + }; + + const EmailList = () => { + const data = historyMessages.filter((item) => item.type === 'email').reverse(); + + const [openEmailDetail, setOpenEmailDetail] = useState(false); + const [emailDetail, setEmailDetail] = useState({}); + const onOpenEmail = (email_detail) => { + setOpenEmailDetail(true); + setEmailDetail(email_detail); + }; + + return ( + <> + {/* {data.length === 0 && } */} + ( + { + onOpenEmail({ emailOrigin, ...item }); + setOpenPopup(false); + }}> + : + // + // {item.senderName.substring(0, 3)} + // + } + title={emailOrigin.subject} + // description={`To: ${emailOrigin.toEmail}`} + description={ + +
{`To: ${emailOrigin.toEmail}`}
+
{item.localDate}
+
+ } + /> + {emailOrigin.abstract} +
+ )} + /> + + + ); + }; + + return ( + <> + + }, + { key: 'image', label: '图片', children: }, + { key: 'video', label: '视频', children: }, + { key: 'audio', label: '音频', children: }, + { key: 'file', label: '文件', children: }, + { key: 'email', label: '邮件', children: }, + ]} + /> + + }> + } { - const routerReplace = mobile === undefined ? true : false; // : true; - const routePrefix = mobile === undefined ? `/order/chat` : `/m/chat`; +const Conversations = () => { + const { token } = useToken(); + const contentStyle = { + backgroundColor: token.colorBgElevated, + borderRadius: token.borderRadiusLG, + boxShadow: token.boxShadowSecondary, + }; + const menuStyle = { + boxShadow: 'none', + }; + const [mobile] = useStyleStore((state) => [state.mobile]); + const routerReplace = mobile === false ? true : false; // : true; + const routePrefix = mobile === false ? `/order/chat` : `/m/chat`; const { state: orderRow } = useLocation(); const { coli_guest_WhatsApp } = orderRow || {}; const { order_sn } = useParams(); @@ -171,6 +186,7 @@ const Conversations = ({ mobile }) => { } const [newChatModalVisible, setNewChatModalVisible] = useState(false); + const [editingChat, setEditingChat] = useState({}); // const closedVisible = closedConversationsList.length > 0; const toggleClosedConversationsList = () => { @@ -206,52 +222,91 @@ const Conversations = ({ mobile }) => { ); + const [newTagOpen, setNewTagOpen] = useState(false); + const [contextMenuOpen, setContextMenuOpen] = useState(false); + const handleContextMenuOpenChange = (nextOpen, info, ...x) => { + console.log(info); + console.log(nextOpen); + console.log(x); + + if (info.source === 'trigger' || nextOpen) { + setContextMenuOpen(nextOpen); + } + }; + return (
-
-
+
{conversationsListLoading && dataSource.length === 0 ? (
@@ -260,65 +315,28 @@ const Conversations = ({ mobile }) => { ) : null} {dataSource.map((item) => ( - { - domEvent.stopPropagation(); - switch (key) { - case 'close': - return handleConversationItemClose(item); - case 'unread': - return handleConversationItemUnread(item); - - default: - return; - } - }, + {...{ + item, + refreshConversationList, + setListUpdateFlag, + onSwitchConversation, + tabSelectedConversation, + setNewChatModalVisible, + setEditingChat, }} - trigger={['contextMenu']}> -
-
{/* {filterTags.map((tag) => {tag.label})} */}
- 99 ? 0 : item.unread_msg_count} - // className={[ - // String(item.sn) === String(currentConversation.sn) ? '__active text-primary bg-whatsapp-bg' : '', - // String(item.sn) === String(tabSelectedConversation?.sn) ? ' bg-neutral-200' : '', - // ].join(' ')} - statusText={} - statusColor={'#fff'} - onClick={() => onSwitchConversation(item)} - customStatusComponents={[...(item.unread_msg_count > 99 ? [() =>
] : [])]} - /> -
-
+ /> ))} {dataSource.length === 0 && }
setNewChatModalVisible(false)} onCancel={() => setNewChatModalVisible(false)} />
- ); + ) }; export default Conversations; diff --git a/src/views/Conversations/Online/ConversationsNewItem.jsx b/src/views/Conversations/Online/ConversationsNewItem.jsx index 2157a65..1d2c033 100644 --- a/src/views/Conversations/Online/ConversationsNewItem.jsx +++ b/src/views/Conversations/Online/ConversationsNewItem.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { Form, Input, Modal } from 'antd'; -import { isEmpty, pick } from '@/utils/commons'; +import { isEmpty, isNotEmpty, pick } from '@/utils/commons'; import useConversationStore from '@/stores/ConversationStore'; import { phoneNumberToWAID } from '@/channel/whatsappUtils'; import { useConversationNewItem } from '@/hooks/useConversation'; @@ -30,18 +30,31 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) => } }; return ( -
+ + {/* {!initialValues.is_current_order && ( */} + + + + {/* )} */} + + + - + - {/* hidden */} - )} - {!initialValues.is_current_order && ( - - - - )} {/*
如果会话已存在, 将直接切换
*/}
); @@ -73,16 +81,30 @@ export const ConversationItemForm = ({ initialValues, onFormInstanceReady }) => * * 消息记录中的号码点击: 不自动关联 * * 消息记录中的联系人卡片点击: 不自动关联 */ -export const ConversationItemFormModal = ({ open, onCreate, onCancel, initialValues, }) => { +export const ConversationItemFormModal = ({ open, onCreate, onCancel, initialValues: _initialValues, }) => { const [formInstance, setFormInstance] = useState(); const [newItemLoading, setNewItemLoading] = useState(false); const { newConversation } = useConversationNewItem(); + const [initialValues, setInitialValues] = useState({}); + useEffect(() => { + setInitialValues({ + ..._initialValues, + phone_number: _initialValues?.whatsapp_phone_number || _initialValues?.phone_number || '', + wa_id: _initialValues?.whatsapp_phone_number || _initialValues?.wa_id || '', + name: _initialValues?.whatsapp_name || _initialValues?.name || '', + is_new: _initialValues?.is_new ?? true, + }); + + return () => {}; + }, [_initialValues]) + + return ( { + const [mobile] = useStyleStore((state) => [state.mobile]); + const [open, setOpen] = useState(false); + const [fromEmail, setFromEmail] = useState(''); + const openEditor = (email_addr, i) => { + setOpen(true); + setFromEmail(email_addr); + }; + const [pickEmail, setPickEmail] = useState({ label: 'LYT', key: 'lyt@hainatravel.com' }); + return ( + + 新邮件: + + ', key: 'lyt@hainatravel.com' }, + { email: 'lot@hainatravel.com', label: 'LOT ', key: 'lot@hainatravel.com' }, + ], + onClick: ({ key }) => { + // todo: 读取邮箱列表 + console.log(key); + setPickEmail({ label: key, key }); + openEditor(key); + }, + }} + onClick={() => openEditor(pickEmail.key)} + type='primary' className='w-auto' + icon={}> + {pickEmail.label} <{pickEmail.key}> + + + {/* {[ + { email: 'lyt@hainatravel.com', name: 'LYT' }, + { email: 'lot@hainatravel.com', name: 'LOT' }, + ].map(({ email, name }, i) => ( + + ))} */} + {/* */} + + {/* */} + + ); +}; +export default EmailComposer; diff --git a/src/views/Conversations/Online/Input/EmailEditor.css b/src/views/Conversations/Online/Input/EmailEditor.css new file mode 100644 index 0000000..a44f546 --- /dev/null +++ b/src/views/Conversations/Online/Input/EmailEditor.css @@ -0,0 +1,7 @@ +.email-editor-wrapper .ant-upload-list.ant-upload-list-text{ + display: flex; + gap: 8px; +} +.email-editor-wrapper .ant-upload-list-item-container{ + flex-basis: 200px; +} diff --git a/src/views/Conversations/Online/Input/EmailEditorPopup.jsx b/src/views/Conversations/Online/Input/EmailEditorPopup.jsx new file mode 100644 index 0000000..853a2f7 --- /dev/null +++ b/src/views/Conversations/Online/Input/EmailEditorPopup.jsx @@ -0,0 +1,316 @@ +import { createContext, useEffect, useState } from 'react'; +import { ConfigProvider, Button, Form, Input, Flex, Checkbox, Switch, Mentions, Popover, Popconfirm, Select, Space, Upload } from 'antd'; +import { UploadOutlined } from '@ant-design/icons'; +import Modal from '@dckj/react-better-modal'; +import '@dckj/react-better-modal/dist/index.css'; +import DnDModal from '@/components/DndModal'; +import useStyleStore from '@/stores/StyleStore'; + +import LexicalEditor from '@/components/LexicalEditor'; + +import { isEmpty } from '@/utils/commons'; +import './EmailEditor.css'; + +const getAbstract = (longtext) => { + const lines = longtext.split('\n'); + const firstLine = lines[0]; + const abstract = firstLine.substring(0, 20); + return abstract; +}; + +const EmailEditorPopup = ({ open, setOpen, fromEmail, reference, quote = {}, initial = {}, action = 'reply', ...props }) => { + const [mobile] = useStyleStore((state) => [state.mobile]); + + const [form] = Form.useForm(); + + const [isRichText, setIsRichText] = useState(mobile === false); + // const [isRichText, setIsRichText] = useState(false); // 默认纯文本 + 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', getAbstract(textContent)); + }; + + const onHandleSend = () => { + console.log('onSend callback', '\nisRichText', isRichText); + // console.log(form.getFieldsValue()); + const body = structuredClone(form.getFieldsValue()); + body.content = isRichText ? htmlContent : textContent; + body.fromEmail = newFromEmail || fromEmail; + console.log('body', body); + form.validateFields().then((values) => { + form.resetFields(); + }); + // .catch((err) => {}) + + // setOpen(false); + }; + + const [newFromEmail, setNewFromEmail] = useState(''); + const [initialForm, setInitialForm] = useState({}); + const [initialContent, setInitialContent] = useState(''); + useEffect(() => { + if (isEmpty(quote)) { + return () => {}; + } + setShowCc(!isEmpty(quote.cc)); + const { fromEmail, replyToEmail, subject, content } = quote; + // const preQuoteBody = `



+ //


+ //

+ // + // From: + // + // ${quote.fromName} <${quote.fromEmail}> + //

+ //

+ // + // Sent: + // + // ${quote.sent} + //

+ //

+ // + // To: + // + // ${quote.toName} <${quote.toEmail}> + //

+ //

+ // + // Subject: + // + // ${subject} + //

+ //

${content}

+ // `; + const preQuoteBody = `

+
+

+ + From: + + ${quote.fromName} <${quote.fromEmail}> +

+

+ + Sent: + + ${quote.sent} +

+

+ + To: + + ${quote.toName} <${quote.toEmail}> +

+

+ + Subject: + + ${subject} +

+

+ ${content} +

+ `; + //
+ //
+ + setInitialContent(preQuoteBody); + const _formValues = { + to: replyToEmail || fromEmail, + cc: quote.cc, + subject: `Re: ${subject}`, + }; + const forwardValues = { subject: `Fw: ${subject}` }; + if (action === 'reply') { + form.setFieldsValue(_formValues); + setInitialForm(_formValues); + } else if (action === 'forward') { + form.setFieldsValue(forwardValues); + setInitialForm(forwardValues); + } + + return () => {}; + }, [quote, open]); + + const [openPlainTextConfirm, setOpenPlainTextConfirm] = useState(false); + const handlePlainTextOpenChange = ({ target }) => { + const { checked: newChecked } = target; + if (!newChecked) { + setIsRichText(true); + setOpenPlainTextConfirm(false); + return; + } + setOpenPlainTextConfirm(true); + }; + const confirmPlainText = () => { + setIsRichText(false); + setOpenPlainTextConfirm(false); + }; + + // todo: 附件: + // 1. 直接上传返回地址 + // 2. 发送文件信息 + const [fileList, setFileList] = useState([]); + const handleChange = (info) => { + let newFileList = [...info.fileList]; + // 2. Read from response and show file link + newFileList = newFileList.map((file) => { + if (file.response) { + // Component will show file.url as link + file.url = file.response.url; + } + return file; + }); + setFileList(newFileList); + }; + const normFile = (e) => { + console.log('Upload event:', e); + if (Array.isArray(e)) { + return e; + } + return e?.fileList; + }; + const uploadProps = { + action: 'https://660d2bd96ddfa2943b33731c.mockapi.io/api/upload', + // onChange: handleChange, + // multiple: true, + }; + + return ( + <> + + { + form.resetFields(); + }} + title={initialForm.subject || `${reference ? '回复: ' : '写邮件: '} ${fromEmail || ''}`} + footer={ +
+ + + + 纯文本 + + + + {!showCc && ( + + )} + {!showBcc && ( + + )} + + } + /> */} + + + + + + + + + + + + + + + + + + + ); +}; +export default EmailEditorPopup; diff --git a/src/views/Conversations/Online/Input/Emoji.jsx b/src/views/Conversations/Online/Input/Emoji.jsx index 1797dbf..b88d18e 100644 --- a/src/views/Conversations/Online/Input/Emoji.jsx +++ b/src/views/Conversations/Online/Input/Emoji.jsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import { Popover, Button } from 'antd'; import EmojiPicker from 'emoji-picker-react'; +import useStyleStore from '@/stores/StyleStore'; -const InputTemplate = ({ mobile, disabled = false, inputEmoji }) => { +const InputTemplate = ({ disabled = false, inputEmoji }) => { const [openPopup, setOpenPopup] = useState(false); + const [mobile] = useStyleStore((state) => [state.mobile]); const handlePickEmoji = (emojiData) => { inputEmoji(emojiData.emoji); @@ -13,7 +15,7 @@ const InputTemplate = ({ mobile, disabled = false, inputEmoji }) => { <> } diff --git a/src/views/Conversations/Online/InputComposer.jsx b/src/views/Conversations/Online/Input/InputComposer.jsx similarity index 86% rename from src/views/Conversations/Online/InputComposer.jsx rename to src/views/Conversations/Online/Input/InputComposer.jsx index 1ba38b4..3d41813 100644 --- a/src/views/Conversations/Online/InputComposer.jsx +++ b/src/views/Conversations/Online/Input/InputComposer.jsx @@ -20,15 +20,24 @@ import { import { isEmpty, } from '@/utils/commons'; import { v4 as uuid } from 'uuid'; import { sentMsgTypeMapped, whatsappSupportFileTypes, uploadProgressSimulate } from '@/channel/whatsappUtils'; -import InputTemplate from './Input/Template'; -import InputEmoji from './Input/Emoji'; -import InputMediaUpload from './Input/MediaUpload'; +import InputTemplate from './Template'; +import InputEmoji from './Emoji'; +import InputMediaUpload from './MediaUpload'; import { OSS_URL as aliOSSHost } from '@/config'; import { postUploadFileItem } from '@/actions/CommonActions'; -import ExpireTimeClock from './ExpireTimeClock'; +import ExpireTimeClock from '../ExpireTimeClock'; import dayjs from 'dayjs'; +import useStyleStore from '@/stores/StyleStore'; + +const ButtonStyleClsMapped = +{ + 'waba': 'bg-waba shadow shadow-waba-300 hover:!bg-waba-400 active:bg-waba-400 focus:bg-waba-400', + 'whatsapp': 'bg-whatsapp shadow shadow-whatsapp-300 hover:!bg-whatsapp-400 active:bg-whatsapp-400 focus:bg-whatsapp-400', +}; + +const InputComposer = ({ isWABA, channel }) => { + const [mobile] = useStyleStore((state) => [state.mobile]); -const InputComposer = ({ mobile }) => { const userId = useAuthStore((state) => state.loginUser.userId); const websocket = useConversationStore((state) => state.websocket); const websocketOpened = useConversationStore((state) => state.websocketOpened); @@ -45,17 +54,17 @@ const InputComposer = ({ mobile }) => { const textabled = talkabled; // && (lt24h || !isExpired); // 只要有一个时间没过期, 目前未知明确规则 const textabled0 = talkabled && (lt24h || !isExpired); // 只要有一个时间没过期, 目前未知明确规则 // debug: 日志 - console.group('InputComposer textabled'); - console.log('c_sn, websocketOpened, lt24h, isExpired, textabled', currentConversation.sn, websocketOpened, lt24h, isExpired, textabled); - console.log('received time, expire time', currentConversation.last_received_time, ', ', currentConversation.conversation_expiretime); + // console.group('InputComposer textabled'); + // console.log('c_sn, websocketOpened, lt24h, isExpired, textabled', currentConversation.sn, websocketOpened, lt24h, isExpired, textabled); + // console.log('received time, expire time', currentConversation.last_received_time, ', ', currentConversation.conversation_expiretime); if (!isEmpty(currentConversation.sn) && !textabled) { - console.log('current chat: ---- \n', JSON.stringify(currentConversation, null, 2)); + // console.log('current chat: ---- \n', JSON.stringify(currentConversation, null, 2)); // window.$pageSpy.triggerPlugins('onOfflineLog', 'upload'); } - console.groupEnd(); + // console.groupEnd(); const textPlaceHolder = !textabled ? '' - : mobile === undefined + : mobile === false ? 'Enter 发送, Shift+Enter 换行' : 'Enter 换行'; @@ -271,9 +280,9 @@ const InputComposer = ({ mobile }) => { placeholder={ !talkabled ? '请先选择会话' - : !textabled0 + : !textabled0 && isWABA ? '会话已超24h不活跃. 请发送打招呼消息激活对话💬.' - : mobile === undefined + : mobile === false ? 'Enter 发送, Shift+Enter 换行\n支持复制粘贴 [截图/文件] 以备发送' : 'Enter 换行, 点击 Send 发送' } @@ -283,17 +292,17 @@ const InputComposer = ({ mobile }) => { onChange={(e) => setTextContent(e.target.value)} className='rounded-b-none emoji' onPressEnter={(e) => { - if (!e.shiftKey && mobile === undefined) { + if (!e.shiftKey && mobile === false) { e.preventDefault(); if (textabled && !pastedUploading) handleSendText(); } }} autoSize={{ minRows: 2, maxRows: 6 }} /> - + - - + {isWABA && } + {/*
- diff --git a/src/views/Conversations/Online/Input/Template.jsx b/src/views/Conversations/Online/Input/Template.jsx index 8d7eca0..b8fab86 100644 --- a/src/views/Conversations/Online/Input/Template.jsx +++ b/src/views/Conversations/Online/Input/Template.jsx @@ -6,6 +6,7 @@ import useConversationStore from '@/stores/ConversationStore'; import { cloneDeep, getNestedValue, objectMapper, removeFormattingChars, sortArrayByOrder } from '@/utils/commons'; import { replaceTemplateString } from '@/channel/whatsappUtils'; import { isEmpty } from '@/utils/commons'; +import useStyleStore from '@/stores/StyleStore'; const splitTemplate = (template) => { const placeholders = template.match(/{{(.*?)}}/g) || []; @@ -21,7 +22,9 @@ const splitTemplate = (template) => { }, []); return obj; }; -const InputTemplate = ({ mobile, disabled = false, invokeSendMessage }) => { +const InputTemplate = ({ disabled = false, invokeSendMessage }) => { + const [mobile] = useStyleStore((state) => [state.mobile]); + const searchInputRef = useRef(null); const { notification } = App.useApp(); const loginUser = useAuthStore((state) => state.loginUser); @@ -185,7 +188,7 @@ const InputTemplate = ({ mobile, disabled = false, invokeSendMessage }) => { return ( <> { + const initialConfig = { + namespace: 'MyEditor', + theme, + onError, + }; + + return ( + + } placeholder={
Enter some text...
} ErrorBoundary={LexicalErrorBoundary} /> + + +
+ ); +}; + +const EmailEditor = ({ mobile, open, setOpen, fromEmail, reference, ...props }) => { + + const [dragDisabled, setDragDisabled] = useState(true); + const [bounds, setBounds] = useState({ + left: 0, + top: 0, + bottom: 0, + right: 0, + }); + const draggleRef = useRef(null); + const onStart = (_event, uiData) => { + const { clientWidth, clientHeight } = window.document.documentElement; + const targetRect = draggleRef.current?.getBoundingClientRect(); + if (!targetRect) { + return; + } + setBounds({ + left: -targetRect.left + uiData.x, + right: clientWidth - (targetRect.right - uiData.x), + top: -targetRect.top + uiData.y, + bottom: clientHeight - (targetRect.bottom - uiData.y), + }); + }; + + + return ( + { + if (dragDisabled) { + setDragDisabled(false); + } + }} + onMouseOut={() => { + setDragDisabled(true); + }} + // fix eslintjsx-a11y/mouse-events-have-key-events + // https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/mouse-events-have-key-events.md + onFocus={() => {}} + onBlur={() => {}} + // end + > + + {reference ? '回复: ' : '写邮件: ' }{fromEmail} +
+ } + // closeIcon={<> } + open={open} + mask={false} + maskClosable={false} + keyboard={false} + classNames={{ content: '!border !border-solid !border-indigo-500 rounded !p-2' }} + okButtonProps={{ className: 'bg-indigo-500 shadow shadow-indigo-300 hover:!bg-indigo-400 active:bg-indigo-400 focus:bg-indigo-400' }} + cancelButtonProps={{ className: 'hover:!text-indigo-500 hover:!border-indigo-400 active:border-indigo-400 focus:border-indigo-400' }} + style={{ bottom: 0, left: '18%' }} + width={mobile === false ? '800px' : '100%'} + zIndex={2} + // footer={false} + onCancel={() => setOpen(false)} + destroyOnClose={false} // todo: + modalRender={(modal) => ( + onStart(event, uiData)}> +
{modal}
+
+ )}> + +
+ ); +}; + +export default EmailEditor; diff --git a/src/views/Conversations/Online/Input/bak/EmailEditorPopup1.jsx b/src/views/Conversations/Online/Input/bak/EmailEditorPopup1.jsx new file mode 100644 index 0000000..c440567 --- /dev/null +++ b/src/views/Conversations/Online/Input/bak/EmailEditorPopup1.jsx @@ -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 = ( + { setX(d.x); setY(d.y); }} + onResizeStop={(e, direction, ref) => { + setWidth(ref.style.width); + setHeight(ref.style.height); + }} + > + + {children} + + ); + + return ReactDOM.createPortal( + render, + document.body + ); +}; + +export default DraggableResizableModal; diff --git a/src/views/Conversations/Online/Input/bak/EmailEditor_Quill.jsx b/src/views/Conversations/Online/Input/bak/EmailEditor_Quill.jsx new file mode 100644 index 0000000..f14042a --- /dev/null +++ b/src/views/Conversations/Online/Input/bak/EmailEditor_Quill.jsx @@ -0,0 +1,40 @@ +import React, { useState } from 'react'; +import ReactQuill from 'react-quill'; +import 'react-quill/dist/quill.snow.css'; // Import Quill themes + +const EmailComposer = () => { + const [content, setContent] = useState(''); + + const templates = { + 'Template 1': 'This is an example of template 1', + 'Template 2': 'This is an example of template 2', + 'Empty': '', + }; + + const handleChange = (value) => { + setContent(value); + }; + + const handleTemplateChange = (e) => { + setContent(templates[e.target.value]); + }; + + return ( +
+ + + +
+ ); +}; +export default EmailComposer; diff --git a/src/views/Conversations/Online/MessagesHeader.jsx b/src/views/Conversations/Online/MessagesHeader.jsx index 72d15aa..1baae2e 100644 --- a/src/views/Conversations/Online/MessagesHeader.jsx +++ b/src/views/Conversations/Online/MessagesHeader.jsx @@ -4,6 +4,7 @@ import { Flex, Typography, Avatar, Alert, Button, Tooltip, Spin } from 'antd'; import { ReloadOutlined, ApiOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons'; import ExpireTimeClock from './ExpireTimeClock'; +import MessageListFilter from './Components/MessageListFilter'; const MessagesHeader = () => { const userId = useAuthStore(state => state.loginUser.userId); @@ -59,6 +60,7 @@ const MessagesHeader = () => { + ); }; diff --git a/src/views/Conversations/Online/MessagesList.jsx b/src/views/Conversations/Online/MessagesList.jsx index ff75dc1..3c71925 100644 --- a/src/views/Conversations/Online/MessagesList.jsx +++ b/src/views/Conversations/Online/MessagesList.jsx @@ -5,12 +5,10 @@ import { DownOutlined, LoadingOutlined } from '@ant-design/icons'; import { useShallow } from 'zustand/react/shallow'; import useConversationStore from '@/stores/ConversationStore'; import { groupBy, isEmpty, } from '@/utils/commons'; +import BubbleEmail from './Components/BubbleEmail'; +import BubbleIM from './Components/BubbleIM'; -const MessagesList = ({ messages, handlePreview, reference, longListLoading, getMoreMessages, shouldScrollBottom, loadNextPage, handleContactClick, setNewChatModalVisible, setNewChatFormValues, ...props }) => { - - const { message: appMessage } = App.useApp() - - const setReferenceMsg = useConversationStore(useShallow((state) => state.setReferenceMsg)); +const MessagesList = ({ messages, handlePreview, reference, longListLoading, getMoreMessages, shouldScrollBottom, loadNextPage, handleContactClick, setNewChatModalVisible, setNewChatFormValues, ...listProps }) => { // const messagesEndRef = useRef(null); const messageRefs = useRef([]); @@ -40,100 +38,15 @@ const MessagesList = ({ messages, handlePreview, reference, longListLoading, get useEffect(scrollToBottom, [messages]); - const openNewChatModal = ({wa_id, wa_name}) => { - setNewChatModalVisible(true); - setNewChatFormValues(prev => ({...prev, phone_number: wa_id, name: wa_name })); - }; - - const RenderText = memo(function renderText({ str, className, template }) { - - let headerObj, footerObj, buttonsArr; - if (!isEmpty(template) && !isEmpty(template.components)) { - const componentsObj = groupBy(template.components, (item) => item.type); - headerObj = componentsObj?.header?.[0]; - footerObj = componentsObj?.footer?.[0]; - buttonsArr = componentsObj?.buttons?.reduce((r, c) => r.concat(c.buttons), []); - } - - const parts = str.split(/(https?:\/\/[^\s]+|\p{Emoji_Presentation}|\d{4,})/gmu).filter((s) => s !== ''); - const links = str.match(/https?:\/\/[\S]+/gi) || []; - const numbers = str.match(/\d{4,}/g) || []; - const emojis = str.match(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g) || []; - const extraClass = isEmpty(emojis) ? '' : ''; - const objArr = parts.reduce((prev, curr, index) => { - if (links.includes(curr)) { - prev.push({ type: 'link', key: curr }); - } else if (numbers.includes(curr)) { - prev.push({ type: 'number', key: curr }); - } else if (emojis.includes(curr)) { - prev.push({ type: 'emoji', key: curr }); - } else { - prev.push({ type: 'text', key: curr }); - } - return prev; - }, []); - return ( - - {headerObj ? ( -
- {'text' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() &&
{headerObj.text}
} - {'image' === (headerObj?.parameters?.[0]?.type || '').toLowerCase() && } - {['document', 'video'].includes((headerObj?.parameters?.[0]?.type || '').toLowerCase()) && ( - - [ {headerObj.parameters[0].type} ] - - )} -
- ) : null} - {(objArr || []).map((part, index) => { - if (part.type === 'link') { - return ( - - {part.key} - - ); - } else if (part.type === 'number') { - return ( - openNewChatModal({ wa_id: part.key, wa_name: part.key })}> - {part.key} - - ); - } else { - // if (part.type === 'emoji') - return part.key; - } - })} - {footerObj ?
{footerObj.text}
: null} - {buttonsArr && buttonsArr.length > 0 ? ( -
- {buttonsArr.map((btn, index) => - btn.type.toLowerCase() === 'url' ? ( - - ) : btn.type.toLowerCase() === 'phone_number' ? ( - - ) : ( - - ) - )} -
- ) : null} -
- ); - }); - const onLoadMore = async () => { - const newLen = await getMoreMessages(); + await getMoreMessages(); }; // eslint-disable-next-line react/display-name const MessageBoxWithRef = forwardRef((props, ref) => (
- + {props.whatsapp_msg_type + && } + {props.type === 'email' && }
)); @@ -156,48 +69,6 @@ const MessagesList = ({ messages, handlePreview, reference, longListLoading, get ref={(el) => (messageRefs.current[index] = el)} key={`${message.sn}.${message.id}`} {...message} - position={message.sender === 'me' ? 'right' : 'left'} - onReplyClick={() => setReferenceMsg(message)} - onReplyMessageClick={() => scrollToMessage(message.reply.id)} - onOpen={() => handlePreview(message)} - onTitleClick={() => handlePreview(message)} - text={} - replyButton={['text', 'document', 'image'].includes(message.whatsapp_msg_type)} - {...(message.sender === 'me' - ? { - styles: { backgroundColor: '#ccd4ae' }, - notchStyle: { fill: '#ccd4ae' }, - replyButton: ['text', 'document', 'image'].includes(message.whatsapp_msg_type) && message.status !== 'failed' ? true : false, - } - : {})} - className={[ - 'whitespace-pre-wrap', - message.whatsapp_msg_type === 'sticker' ? 'bg-transparent' : '', - message.sender === 'me' ? 'whatsappme-container' : '', - focusMsg === message.id ? 'message-box-focus' : '', - message.status === 'failed' ? 'failed-msg' : '', - ].join(' ')} - {...(message.type === 'meetingLink' - ? { - actionButtons: [ - ...(message.waBtn - ? [ - { - onClickButton: () => handleContactClick(message.data), - Component: () =>
发消息
, - }, - ] - : []), - { - onClickButton: () => { - navigator.clipboard.writeText(message.text); - appMessage.success('复制成功😀') - }, - Component: () =>
复制
, - }, - ], - } - : {})} /> ))}
diff --git a/src/views/Conversations/Online/MessagesWrapper.jsx b/src/views/Conversations/Online/MessagesWrapper.jsx index 0e6a267..4271d48 100644 --- a/src/views/Conversations/Online/MessagesWrapper.jsx +++ b/src/views/Conversations/Online/MessagesWrapper.jsx @@ -7,6 +7,11 @@ import { fetchCleanUnreadMsgCount, fetchMessages, MESSAGE_PAGE_SIZE } from '@/ac import useAuthStore from '@/stores/AuthStore'; import { useVisibilityState } from '@/hooks/useVisibilityState'; import ConversationNewItem from './ConversationsNewItem'; +import emailItem from './Components/emailSent.json'; +import emailReItem from './Components/emailRe.json'; +// import EmailEditor from './Input/bak/EmailEditor'; +import EmailEditorPopup from './Input/EmailEditorPopup'; +import EmailDetail from './Components/EmailDetail'; const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => { const userId = useAuthStore((state) => state.loginUser.userId); @@ -54,6 +59,11 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => { const getFirstPageMessages = async (item) => { setMsgLoading(true); const data = await fetchMessages({ opisn: forceGetMessages ? (currentConversation.opi_sn || '') : userId, whatsappid: item.whatsapp_phone_number, lasttime: '' }); + + // test: + data.push(emailItem); + data.push(emailReItem); + setMsgLoading(false); receivedMessageList(item.sn, data); const thisLastTime = data.length > 0 ? data[0].orgmsgtime : ''; @@ -115,28 +125,77 @@ const MessagesWrapper = ({ updateRead = true, forceGetMessages }) => { const handleContactClick = (data) => { return data.length > 1 ? handleContactListClick(data) : handleContactItemClick(data[0]); } + // EmailEditor + + const [openEmailEditor, setOpenEmailEditor] = useState(false); + const [fromEmail, setFromEmail] = useState(''); + const [ReferEmailMsg, setReferEmailMsg] = useState(''); + const onOpenEditor = (emailOrigin) => { + const { replyToEmail: email_addr, content } = emailOrigin; + setOpenEmailEditor(true); + setFromEmail(email_addr); + setReferEmailMsg(emailOrigin); + }; + + const [openEmailDetail, setOpenEmailDetail] = useState(false); + const [emailDetail, setEmailDetail] = useState({}); + const onOpenEmail = (email_detail) => { + setOpenEmailDetail(true); + setEmailDetail(email_detail); + } + return ( <> - + - - setContactsModalVisible(false)} footer={null} > + setContactsModalVisible(false)} footer={null}> {contactListData.map((contact) => ( - + ))} { setNewChatModalVisible(false); setContactsModalVisible(false);}} + onCreate={() => { + setNewChatModalVisible(false); + setContactsModalVisible(false); + }} onCancel={() => setNewChatModalVisible(false)} - /> - + /> + {/* */} + + + ); }; export default MessagesWrapper; diff --git a/src/views/Conversations/Online/ReplyWrapper.jsx b/src/views/Conversations/Online/ReplyWrapper.jsx new file mode 100644 index 0000000..4c22189 --- /dev/null +++ b/src/views/Conversations/Online/ReplyWrapper.jsx @@ -0,0 +1,72 @@ +import { Children, createContext, useEffect, useState } from 'react'; +import { Dropdown, Space, Tabs } from 'antd'; +import { MailFilled, MailOutlined, WhatsAppOutlined, DownOutlined } from '@ant-design/icons'; +import InputComposer from './Input/InputComposer'; +import EmailComposer from './Input/EmailChannelTab'; +import { WABIcon } from '@/components/Icons'; +import useConversationStore from '@/stores/ConversationStore'; +import { useShallow } from 'zustand/react/shallow'; +import useStyleStore from '@/stores/StyleStore'; + +const DEFAULT_CHANNEL = 'waba'; +const Wabas = [ + { key: 'Global Highlights', label: 'Global Highlights' }, + { key: 'Global Highlights-Multi', label: 'Global Highlights-Multi' }, +]; +const Wabas_mapped = Wabas.reduce((acc, cur) => ({...acc, [cur.key]: cur}), {}); + +const WABASwitcher = ({ onSelect, }) => { + const [pickV, setPickV] = useState({}); // todo: 全局管理? 后端管理? + return ( + { + domEvent.stopPropagation(); + setPickV(Wabas_mapped[key]); + onSelect?.(Wabas_mapped[key]); + }, + }}> + + {/* */} + {pickV.label || 'WABA'} + + + + ); +}; + +const ReplyWrapper = () => { + const [mobile] = useStyleStore(state => [state.mobile]); + const [activeChannel, setActiveChannel] = useState(DEFAULT_CHANNEL); + const onChannelTabsChange = (activeKey) => { + setActiveChannel(activeKey); + }; + + const activeMessages = useConversationStore( + useShallow((state) => (state.currentConversation.sn && state.activeConversations[state.currentConversation.sn] ? state.activeConversations[state.currentConversation.sn] : [])) + ); + useEffect(() => { + const len = activeMessages.length; + const thisLastChannel = activeMessages.length > 0 ? activeMessages[len - 1]?.type : DEFAULT_CHANNEL; + const channel = thisLastChannel === 'email' ? 'email' : DEFAULT_CHANNEL; + setActiveChannel(channel); + return () => {}; + }, [activeMessages]); + + const replyTypes = [ + { key: 'waba', label: mobile ? '' : (), icon: , children: }, + // { key: 'waba', label: 'WABA-Global Highlights', icon: , children: }, + { key: 'email', label: mobile ? '' : 'Email', icon: , children: }, + // { key: 'whatsapp', label: mobile ? '' : 'WhatsApp', icon: , children: }, + ]; + + return ( +
+ +
+ ); +}; +export default ReplyWrapper; diff --git a/src/views/Conversations/Online/order/CustomerProfile.jsx b/src/views/Conversations/Online/order/CustomerProfile.jsx index e19793c..ad62940 100644 --- a/src/views/Conversations/Online/order/CustomerProfile.jsx +++ b/src/views/Conversations/Online/order/CustomerProfile.jsx @@ -144,7 +144,7 @@ const CustomerProfile = () => { size={"small"} onClick={() => { setNewChatModalVisible(true); - setNewChatFormValues(prev => ({ ...prev, phone_number: customerDetail.whatsapp_phone_number, is_current_order: true })); + setNewChatFormValues(prev => ({ ...prev, phone_number: customerDetail.whatsapp_phone_number, name: customerDetail.name, is_current_order: true })); }}> {customerDetail.whatsapp_phone_number} diff --git a/src/views/MobileApp.jsx b/src/views/MobileApp.jsx index 97dc22e..e954bb3 100644 --- a/src/views/MobileApp.jsx +++ b/src/views/MobileApp.jsx @@ -8,6 +8,7 @@ import { DownOutlined } from '@ant-design/icons' import { NavLink, Outlet, Link } from 'react-router-dom' import ReloadPrompt from './ReloadPrompt' import ClearCache from './ClearCache' +import useStyleStore from '@/stores/StyleStore'; import { BUILD_VERSION } from '@/config' @@ -21,7 +22,11 @@ function MobileApp() { token: { colorBgContainer }, } = theme.useToken() + const [setMobile] = useStyleStore((state) => [state.setMobile]); + useEffect(() => { + + setMobile(true); const handleLoad = () => { const isPWAInstalled = window.matchMedia('(display-mode: window-controls-overlay)').matches || diff --git a/src/views/mobile/Chat.jsx b/src/views/mobile/Chat.jsx index e2b67c7..985cfbf 100644 --- a/src/views/mobile/Chat.jsx +++ b/src/views/mobile/Chat.jsx @@ -1,9 +1,10 @@ import { Layout, Button } from 'antd'; import MessagesHeader from '@/views/Conversations/Online/MessagesHeader'; import MessagesWrapper from '@/views/Conversations/Online/MessagesWrapper'; -import InputComposer from '@/views/Conversations/Online/InputComposer'; +import InputComposer from '@/views/Conversations/Online/Input/InputComposer'; import { UnorderedListOutlined, MenuUnfoldOutlined, MenuFoldOutlined } from '@ant-design/icons'; import { useNavigate } from 'react-router-dom'; +import ReplyWrapper from '../Conversations/Online/ReplyWrapper'; const { Content, Header, Footer } = Layout; @@ -21,7 +22,7 @@ function Chat() {
- +
diff --git a/src/views/mobile/Conversation.jsx b/src/views/mobile/Conversation.jsx index 7c8d7f8..c9c8417 100644 --- a/src/views/mobile/Conversation.jsx +++ b/src/views/mobile/Conversation.jsx @@ -4,7 +4,7 @@ function Conversation() { return (
- +
) } diff --git a/tailwind.config.js b/tailwind.config.js index 7ac9e91..7f29abf 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,12 +9,20 @@ export default { ...colors, 'whatsapp': { DEFAULT: '#25D366', + 400: '#66e094', + 300: '#92e9b3', dark: '#075E54', second: '#128c7e', gossip: '#dcf8c6', bg: '#ece5dd', // '#efeae2' '#eae6df' '#d1d7db' bgdark: '#0b141a', - me: '#ccd5ae', // '#d9fdd3' + me: '#d9fdd3', // '#d9fdd3' '#e1fef2' + }, + 'waba': { + DEFAULT: '#2ba84a', + 400: '#6bc280', + 300: '#95d4a5', + me: '#ccd5ae', // '#e1fef2', // '#d9fdd3' '#00a884' }, 'primary': '#1ba784', }, @@ -22,6 +30,11 @@ export default { // gridTemplateColumns: { // 'responsive':repeat(autofill,minmax('300px',1fr)) // } + boxShadow: { + '1md': '0 0 4px 2px rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)', + 'heavy': '0 1px 7px 1px rgba(0, 0, 0, 0.3)', + '3xl': '0 35px 60px -15px rgba(0, 0, 0, 0.3)', + } }, }, plugins: [], @@ -30,3 +43,22 @@ export default { divideColor: true, } }; +/** + * WhatsApp + --outgoing-background: #d9fdd3; + --outgoing-background-rgb: 217, 253, 211; + --outgoing-background-deeper: #d1f4cc; + --outgoing-background-deeper-rgb: 209, 244, 204; + --outgoing-background-highlight: #c4eec8; + --outgoing-background-highlight-rgb: 196, 238, 200; + --overlay: #0b141a; + --overlay-rgb: 11, 20, 26; + --panel-background: #f0f2f5; + --panel-background-rgb: 240, 242, 245; + --panel-background-active: #dee0e3; + --panel-background-active-rgb: 222, 224, 227; + --panel-background-colored: #008069; + --panel-background-colored-rgb: 0, 128, 105; + --panel-background-colored-deeper: #008069; + --shadow-rgb: 11, 20, 26; + */ diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ccc7e54 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "jsx": "react", + }, +} diff --git a/vite.config.js b/vite.config.js index 23b4f26..b3776a5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,7 @@ import WindiCSS from 'vite-plugin-windicss'; import { VitePWA } from 'vite-plugin-pwa'; import packageJson from './package.json'; import dayjs from 'dayjs' +import svgr from "vite-plugin-svgr"; const today = new dayjs().format('YYYY-MM-DD HH:mm:ss') @@ -145,7 +146,7 @@ export default defineConfig({ __BUILD_DATE__: JSON.stringify(`${today}`), __BUILD_VERSION__: JSON.stringify(`${packageJson.version}`), }, - plugins: [react(), WindiCSS(), buildDatePlugin(), VitePWA(manifestForPWAPlugIn)], + plugins: [ svgr(), react(), WindiCSS(), buildDatePlugin(), VitePWA(manifestForPWAPlugIn), ], server: { host: '0.0.0.0', },