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 0000000..36014c9
Binary files /dev/null and b/public/images/emoji/1F600.png differ
diff --git a/public/images/emoji/1F641.png b/public/images/emoji/1F641.png
new file mode 100644
index 0000000..c618faf
Binary files /dev/null and b/public/images/emoji/1F641.png differ
diff --git a/public/images/emoji/1F642.png b/public/images/emoji/1F642.png
new file mode 100644
index 0000000..9a5a956
Binary files /dev/null and b/public/images/emoji/1F642.png differ
diff --git a/public/images/emoji/2764.png b/public/images/emoji/2764.png
new file mode 100644
index 0000000..8b9f480
Binary files /dev/null and b/public/images/emoji/2764.png differ
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 (
+
+ );
+}
+
+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' ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ {/*
*/}
+
+ {isLink && createPortal(
, document.body)}
+
+
+
+
+
+ {/*
+
+
+
*/}
+ >
+ )}
+
+ );
+}
diff --git a/src/components/LexicalEditor/plugins/TreeViewPlugin.jsx b/src/components/LexicalEditor/plugins/TreeViewPlugin.jsx
new file mode 100644
index 0000000..45474a0
--- /dev/null
+++ b/src/components/LexicalEditor/plugins/TreeViewPlugin.jsx
@@ -0,0 +1,16 @@
+import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
+import { TreeView } from "@lexical/react/LexicalTreeView";
+
+export default function TreeViewPlugin() {
+ const [editor] = useLexicalComposerContext();
+ return (
+
+ );
+}
diff --git a/src/components/LexicalEditor/shared/package.json b/src/components/LexicalEditor/shared/package.json
new file mode 100644
index 0000000..8fe88be
--- /dev/null
+++ b/src/components/LexicalEditor/shared/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "shared",
+ "private": "true",
+ "keywords": [
+ "react",
+ "lexical",
+ "editor",
+ "rich-text"
+ ],
+ "license": "MIT",
+ "version": "0.17.1",
+ "dependencies": {
+ "lexical": "0.17.1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/facebook/lexical",
+ "directory": "packages/shared"
+ },
+ "sideEffects": false
+}
diff --git a/src/components/LexicalEditor/shared/src/__mocks__/invariant.ts b/src/components/LexicalEditor/shared/src/__mocks__/invariant.ts
new file mode 100644
index 0000000..ff3b7cb
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/__mocks__/invariant.ts
@@ -0,0 +1,24 @@
+/**
+ * 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.
+ *
+ */
+
+// invariant(condition, message) will refine types based on "condition", and
+// if "condition" is false will throw an error. This function is special-cased
+// in flow itself, so we can't name it anything else.
+export default function invariant(
+ cond?: boolean,
+ message?: string,
+ ...args: string[]
+): asserts cond {
+ if (cond) {
+ return;
+ }
+
+ throw new Error(
+ args.reduce((msg, arg) => msg.replace('%s', String(arg)), message || ''),
+ );
+}
diff --git a/src/components/LexicalEditor/shared/src/canUseDOM.ts b/src/components/LexicalEditor/shared/src/canUseDOM.ts
new file mode 100644
index 0000000..78db6aa
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/canUseDOM.ts
@@ -0,0 +1,12 @@
+/**
+ * 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 const CAN_USE_DOM: boolean =
+ typeof window !== 'undefined' &&
+ typeof window.document !== 'undefined' &&
+ typeof window.document.createElement !== 'undefined';
diff --git a/src/components/LexicalEditor/shared/src/caretFromPoint.ts b/src/components/LexicalEditor/shared/src/caretFromPoint.ts
new file mode 100644
index 0000000..642e070
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/caretFromPoint.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.
+ *
+ */
+
+export default function caretFromPoint(
+ x: number,
+ y: number,
+): null | {
+ offset: number;
+ node: Node;
+} {
+ if (typeof document.caretRangeFromPoint !== 'undefined') {
+ const range = document.caretRangeFromPoint(x, y);
+ if (range === null) {
+ return null;
+ }
+ return {
+ node: range.startContainer,
+ offset: range.startOffset,
+ };
+ // @ts-ignore
+ } else if (document.caretPositionFromPoint !== 'undefined') {
+ // @ts-ignore FF - no types
+ const range = document.caretPositionFromPoint(x, y);
+ if (range === null) {
+ return null;
+ }
+ return {
+ node: range.offsetNode,
+ offset: range.offset,
+ };
+ } else {
+ // Gracefully handle IE
+ return null;
+ }
+}
diff --git a/src/components/LexicalEditor/shared/src/environment.ts b/src/components/LexicalEditor/shared/src/environment.ts
new file mode 100644
index 0000000..914887e
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/environment.ts
@@ -0,0 +1,56 @@
+/**
+ * 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 {CAN_USE_DOM} from 'shared/canUseDOM';
+
+declare global {
+ interface Document {
+ documentMode?: unknown;
+ }
+
+ interface Window {
+ MSStream?: unknown;
+ }
+}
+
+const documentMode =
+ CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null;
+
+export const IS_APPLE: boolean =
+ CAN_USE_DOM && /Mac|iPod|iPhone|iPad/.test(navigator.platform);
+
+export const IS_FIREFOX: boolean =
+ CAN_USE_DOM && /^(?!.*Seamonkey)(?=.*Firefox).*/i.test(navigator.userAgent);
+
+export const CAN_USE_BEFORE_INPUT: boolean =
+ CAN_USE_DOM && 'InputEvent' in window && !documentMode
+ ? 'getTargetRanges' in new window.InputEvent('input')
+ : false;
+
+export const IS_SAFARI: boolean =
+ CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent);
+
+export const IS_IOS: boolean =
+ CAN_USE_DOM &&
+ /iPad|iPhone|iPod/.test(navigator.userAgent) &&
+ !window.MSStream;
+
+export const IS_ANDROID: boolean =
+ CAN_USE_DOM && /Android/.test(navigator.userAgent);
+
+// Keep these in case we need to use them in the future.
+// export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform);
+export const IS_CHROME: boolean =
+ CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent);
+// export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode;
+
+export const IS_ANDROID_CHROME: boolean =
+ CAN_USE_DOM && IS_ANDROID && IS_CHROME;
+
+export const IS_APPLE_WEBKIT =
+ CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME;
diff --git a/src/components/LexicalEditor/shared/src/invariant.ts b/src/components/LexicalEditor/shared/src/invariant.ts
new file mode 100644
index 0000000..0e73848
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/invariant.ts
@@ -0,0 +1,26 @@
+/**
+ * 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.
+ *
+ */
+
+// invariant(condition, message) will refine types based on "condition", and
+// if "condition" is false will throw an error. This function is special-cased
+// in flow itself, so we can't name it anything else.
+export default function invariant(
+ cond?: boolean,
+ message?: string,
+ ...args: string[]
+): asserts cond {
+ if (cond) {
+ return;
+ }
+
+ throw new Error(
+ 'Internal Lexical error: invariant() is meant to be replaced at compile ' +
+ 'time. There is no runtime version. Error: ' +
+ message,
+ );
+}
diff --git a/src/components/LexicalEditor/shared/src/normalizeClassNames.ts b/src/components/LexicalEditor/shared/src/normalizeClassNames.ts
new file mode 100644
index 0000000..22ea3a9
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/normalizeClassNames.ts
@@ -0,0 +1,21 @@
+/**
+ * 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 normalizeClassNames(
+ ...classNames: Array
+): Array {
+ const rval = [];
+ for (const className of classNames) {
+ if (className && typeof className === 'string') {
+ for (const [s] of className.matchAll(/\S+/g)) {
+ rval.push(s);
+ }
+ }
+ }
+ return rval;
+}
diff --git a/src/components/LexicalEditor/shared/src/react-test-utils.ts b/src/components/LexicalEditor/shared/src/react-test-utils.ts
new file mode 100644
index 0000000..8e08674
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/react-test-utils.ts
@@ -0,0 +1,18 @@
+/**
+ * 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 * as React from 'react';
+import * as ReactTestUtils from 'react-dom/test-utils';
+
+/**
+ * React 19 moved act from react-dom/test-utils to react
+ * https://react.dev/blog/2024/04/25/react-19-upgrade-guide#removed-react-dom-test-utils
+ */
+export const act =
+ 'act' in React
+ ? (React.act as typeof ReactTestUtils.act)
+ : ReactTestUtils.act;
diff --git a/src/components/LexicalEditor/shared/src/reactPatches.ts b/src/components/LexicalEditor/shared/src/reactPatches.ts
new file mode 100644
index 0000000..9685cd8
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/reactPatches.ts
@@ -0,0 +1,22 @@
+/**
+ * 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 React from 'react';
+
+// Webpack + React 17 fails to compile on the usage of `React.startTransition` or
+// `React["startTransition"]` even if it's behind a feature detection of
+// `"startTransition" in React`. Moving this to a constant avoids the issue :/
+const START_TRANSITION = 'startTransition';
+
+export function startTransition(callback: () => void) {
+ if (START_TRANSITION in React) {
+ React[START_TRANSITION](callback);
+ } else {
+ callback();
+ }
+}
diff --git a/src/components/LexicalEditor/shared/src/simpleDiffWithCursor.ts b/src/components/LexicalEditor/shared/src/simpleDiffWithCursor.ts
new file mode 100644
index 0000000..39f3d3b
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/simpleDiffWithCursor.ts
@@ -0,0 +1,49 @@
+/**
+ * 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 simpleDiffWithCursor(
+ a: string,
+ b: string,
+ cursor: number,
+): {index: number; insert: string; remove: number} {
+ const aLength = a.length;
+ const bLength = b.length;
+ let left = 0; // number of same characters counting from left
+ let right = 0; // number of same characters counting from right
+ // Iterate left to the right until we find a changed character
+ // First iteration considers the current cursor position
+ while (
+ left < aLength &&
+ left < bLength &&
+ a[left] === b[left] &&
+ left < cursor
+ ) {
+ left++;
+ }
+ // Iterate right to the left until we find a changed character
+ while (
+ right + left < aLength &&
+ right + left < bLength &&
+ a[aLength - right - 1] === b[bLength - right - 1]
+ ) {
+ right++;
+ }
+ // Try to iterate left further to the right without caring about the current cursor position
+ while (
+ right + left < aLength &&
+ right + left < bLength &&
+ a[left] === b[left]
+ ) {
+ left++;
+ }
+ return {
+ index: left,
+ insert: b.slice(left, bLength - right),
+ remove: aLength - left - right,
+ };
+}
diff --git a/src/components/LexicalEditor/shared/src/useLayoutEffect.ts b/src/components/LexicalEditor/shared/src/useLayoutEffect.ts
new file mode 100644
index 0000000..05787b4
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/useLayoutEffect.ts
@@ -0,0 +1,19 @@
+/**
+ * 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 {useEffect, useLayoutEffect} from 'react';
+import {CAN_USE_DOM} from 'shared/canUseDOM';
+
+// This workaround is no longer necessary in React 19,
+// but we currently support React >=17.x
+// https://github.com/facebook/react/pull/26395
+const useLayoutEffectImpl: typeof useLayoutEffect = CAN_USE_DOM
+ ? useLayoutEffect
+ : useEffect;
+
+export default useLayoutEffectImpl;
diff --git a/src/components/LexicalEditor/shared/src/warnOnlyOnce.ts b/src/components/LexicalEditor/shared/src/warnOnlyOnce.ts
new file mode 100644
index 0000000..d29e99e
--- /dev/null
+++ b/src/components/LexicalEditor/shared/src/warnOnlyOnce.ts
@@ -0,0 +1,20 @@
+/**
+ * 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 warnOnlyOnce(message: string) {
+ if (!__DEV__) {
+ return;
+ }
+ let run = false;
+ return () => {
+ if (!run) {
+ console.warn(message);
+ }
+ run = true;
+ };
+}
diff --git a/src/components/LexicalEditor/shared/viteModuleResolution.ts b/src/components/LexicalEditor/shared/viteModuleResolution.ts
new file mode 100644
index 0000000..572e1e5
--- /dev/null
+++ b/src/components/LexicalEditor/shared/viteModuleResolution.ts
@@ -0,0 +1,88 @@
+/**
+ * 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 {
+ ModuleExportEntry,
+ NpmModuleExportEntry,
+ PackageMetadata,
+} from '../../scripts/shared/PackageMetadata';
+
+import * as fs from 'node:fs';
+import {createRequire} from 'node:module';
+import * as path from 'node:path';
+
+const require = createRequire(import.meta.url);
+const {packagesManager} =
+ require('../../scripts/shared/packagesManager') as typeof import('../../scripts/shared/packagesManager');
+
+const sourceModuleResolution = () => {
+ function toAlias(pkg: PackageMetadata, entry: ModuleExportEntry) {
+ return {
+ find: entry.name,
+ replacement: pkg.resolve('src', entry.sourceFileName),
+ };
+ }
+
+ return [
+ ...packagesManager
+ .getPublicPackages()
+ .flatMap((pkg) =>
+ pkg.getExportedNpmModuleEntries().map(toAlias.bind(null, pkg)),
+ ),
+ ...['shared']
+ .map((name) => packagesManager.getPackageByDirectoryName(name))
+ .flatMap((pkg) =>
+ pkg.getPrivateModuleEntries().map(toAlias.bind(null, pkg)),
+ ),
+ ];
+};
+
+const distModuleResolution = (environment: 'development' | 'production') => {
+ return [
+ ...packagesManager.getPublicPackages().flatMap((pkg) =>
+ pkg
+ .getNormalizedNpmModuleExportEntries()
+ .map((entry: NpmModuleExportEntry) => {
+ const [name, moduleExports] = entry;
+ const replacements = ([environment, 'default'] as const).map(
+ (condition) => pkg.resolve('dist', moduleExports.import[condition]),
+ );
+ const replacement = replacements.find(fs.existsSync.bind(fs));
+ if (!replacement) {
+ throw new Error(
+ `ERROR: Missing ./${path.relative(
+ '../..',
+ replacements[1],
+ )}. Did you run \`npm run build\` in the monorepo first?`,
+ );
+ }
+ return {
+ find: name,
+ replacement,
+ };
+ }),
+ ),
+ ...[packagesManager.getPackageByDirectoryName('shared')].flatMap(
+ (pkg: PackageMetadata) =>
+ pkg.getPrivateModuleEntries().map((entry: ModuleExportEntry) => {
+ return {
+ find: entry.name,
+ replacement: pkg.resolve('src', entry.sourceFileName),
+ };
+ }),
+ ),
+ ];
+};
+
+export default function moduleResolution(
+ environment: 'source' | 'development' | 'production',
+) {
+ return environment === 'source'
+ ? sourceModuleResolution()
+ : distModuleResolution(environment);
+}
diff --git a/src/components/LexicalEditor/styles.css b/src/components/LexicalEditor/styles.css
new file mode 100644
index 0000000..1bae0f5
--- /dev/null
+++ b/src/components/LexicalEditor/styles.css
@@ -0,0 +1,873 @@
+/* body {
+ margin: 0;
+ background: #eee;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, ".SFNSText-Regular",
+ sans-serif;
+ font-weight: 500;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale; */
+/* } */
+.editor-container{
+ margin: 0;
+ background: #eee;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, ".SFNSText-Regular",
+ sans-serif;
+ font-weight: 500;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.email-container{
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, ".SFNSText-Regular",
+ sans-serif;
+ font-weight: 500;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+.other h2 {
+ font-size: 18px;
+ color: #444;
+ margin-bottom: 7px;
+}
+
+.other a {
+ color: #777;
+ text-decoration: underline;
+ font-size: 14px;
+}
+
+.other ul {
+ padding: 0;
+ margin: 0;
+ list-style-type: none;
+}
+
+/* .App {
+ font-family: sans-serif;
+ text-align: center;
+} */
+
+h1 {
+ font-size: 24px;
+ color: #333;
+}
+
+.ltr {
+ text-align: left;
+}
+
+.rtl {
+ text-align: right;
+}
+
+.editor-container {
+ margin: 0 auto 20px auto;
+ border-radius: 2px;
+ /* max-width: 600px; */
+ color: #000;
+ position: relative;
+ line-height: 20px;
+ font-weight: 400;
+ text-align: left;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+}
+
+.editor-inner {
+ background: #fff;
+ position: relative;
+}
+
+.editor-input {
+ min-height: 150px;
+ resize: none;
+ font-size: 15px;
+ caret-color: rgb(5, 5, 5);
+ position: relative;
+ tab-size: 1;
+ outline: 0;
+ padding: 15px 10px;
+ caret-color: #444;
+}
+
+.editor-pure-input {
+ min-height: 150px;
+ resize: none;
+ font-size: 15px;
+ caret-color: rgb(5, 5, 5);
+ position: relative;
+ tab-size: 1;
+ outline: 0;
+ padding: 15px 10px;
+ caret-color: #444;
+}
+
+.editor-placeholder {
+ color: #999;
+ overflow: hidden;
+ position: absolute;
+ text-overflow: ellipsis;
+ top: 15px;
+ left: 10px;
+ font-size: 15px;
+ user-select: none;
+ display: inline-block;
+ pointer-events: none;
+}
+
+.editor-text-bold {
+ font-weight: bold;
+}
+
+.editor-text-italic {
+ font-style: italic;
+}
+
+.editor-text-underline {
+ text-decoration: underline;
+}
+
+.editor-text-strikethrough {
+ text-decoration: line-through;
+}
+
+.editor-text-underlineStrikethrough {
+ text-decoration: underline line-through;
+}
+
+.editor-text-code {
+ background-color: rgb(240, 242, 245);
+ padding: 1px 0.25rem;
+ font-family: Menlo, Consolas, Monaco, monospace;
+ font-size: 94%;
+}
+
+.editor-link {
+ color: rgb(33, 111, 219);
+ text-decoration: none;
+}
+.editor-link:hover{
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.tree-view-output {
+ display: block;
+ background: #222;
+ color: #fff;
+ padding: 5px;
+ font-size: 12px;
+ white-space: pre-wrap;
+ margin: 1px auto 10px auto;
+ max-height: 250px;
+ position: relative;
+ border-bottom-left-radius: 10px;
+ border-bottom-right-radius: 10px;
+ overflow: auto;
+ line-height: 14px;
+}
+
+.editor-code {
+ background-color: rgb(240, 242, 245);
+ font-family: Menlo, Consolas, Monaco, monospace;
+ display: block;
+ padding: 8px 8px 8px 52px;
+ line-height: 1.53;
+ font-size: 13px;
+ margin: 0;
+ margin-top: 8px;
+ margin-bottom: 8px;
+ tab-size: 2;
+ /* white-space: pre; */
+ overflow-x: auto;
+ position: relative;
+}
+
+.editor-code:before {
+ content: attr(data-gutter);
+ position: absolute;
+ background-color: #eee;
+ left: 0;
+ top: 0;
+ border-right: 1px solid #ccc;
+ padding: 8px;
+ color: #777;
+ white-space: pre-wrap;
+ text-align: right;
+ min-width: 25px;
+}
+.editor-code:after {
+ content: attr(data-highlight-language);
+ top: 0;
+ right: 3px;
+ padding: 3px;
+ font-size: 10px;
+ text-transform: uppercase;
+ position: absolute;
+ color: rgba(0, 0, 0, 0.5);
+}
+
+.editor-tokenComment {
+ color: slategray;
+}
+
+.editor-tokenPunctuation {
+ color: #999;
+}
+
+.editor-tokenProperty {
+ color: #905;
+}
+
+.editor-tokenSelector {
+ color: #690;
+}
+
+.editor-tokenOperator {
+ color: #9a6e3a;
+}
+
+.editor-tokenAttr {
+ color: #07a;
+}
+
+.editor-tokenVariable {
+ color: #e90;
+}
+
+.editor-tokenFunction {
+ color: #dd4a68;
+}
+
+.editor-paragraph {
+ margin: 0;
+ margin-bottom: 8px;
+ position: relative;
+}
+
+.editor-paragraph:last-child {
+ margin-bottom: 0;
+}
+
+.editor-heading-h1 {
+ font-size: 24px;
+ color: rgb(5, 5, 5);
+ font-weight: 400;
+ margin: 0;
+ margin-bottom: 12px;
+ padding: 0;
+}
+
+.editor-heading-h2 {
+ font-size: 15px;
+ color: rgb(101, 103, 107);
+ font-weight: 700;
+ margin: 0;
+ margin-top: 10px;
+ padding: 0;
+ text-transform: uppercase;
+}
+
+.editor-heading-h3 {
+ font-size: 14px;
+ /* color: rgb(101, 103, 107); */
+ font-weight: 700;
+ margin: 0;
+ margin-top: 10px;
+ padding: 0;
+ /* text-transform: uppercase; */
+}
+
+.editor-quote {
+ margin: 0;
+ margin-left: 20px;
+ font-size: 15px;
+ color: rgb(101, 103, 107);
+ border-left-color: rgb(206, 208, 212);
+ border-left-width: 4px;
+ border-left-style: solid;
+ padding-left: 16px;
+}
+
+.editor-list-ol {
+ padding: 0;
+ margin: 0;
+ margin-left: 16px;
+}
+
+.editor-list-ul {
+ padding: 0;
+ margin: 0;
+ margin-left: 16px;
+}
+
+.editor-listitem {
+ margin: 8px 32px 8px 32px;
+}
+
+.editor-nested-listitem {
+ list-style-type: none;
+}
+
+pre::-webkit-scrollbar {
+ background: transparent;
+ width: 10px;
+}
+
+pre::-webkit-scrollbar-thumb {
+ background: #999;
+}
+
+.debug-timetravel-panel {
+ overflow: hidden;
+ padding: 0 0 10px 0;
+ margin: auto;
+ display: flex;
+}
+
+.debug-timetravel-panel-slider {
+ padding: 0;
+ flex: 8;
+}
+
+.debug-timetravel-panel-button {
+ padding: 0;
+ border: 0;
+ background: none;
+ flex: 1;
+ color: #fff;
+ font-size: 12px;
+}
+
+.debug-timetravel-panel-button:hover {
+ text-decoration: underline;
+}
+
+.debug-timetravel-button {
+ border: 0;
+ padding: 0;
+ font-size: 12px;
+ top: 10px;
+ right: 15px;
+ position: absolute;
+ background: none;
+ color: #fff;
+}
+
+.debug-timetravel-button:hover {
+ text-decoration: underline;
+}
+
+.emoji {
+ color: transparent;
+ background-size: 16px 16px;
+ background-position: center;
+ background-repeat: no-repeat;
+ vertical-align: middle;
+ margin: 0 -1px;
+}
+
+.emoji-inner {
+ padding: 0 0.15em;
+}
+
+.emoji-inner::selection {
+ color: transparent;
+ background-color: rgba(150, 150, 150, 0.4);
+}
+
+.emoji-inner::moz-selection {
+ color: transparent;
+ background-color: rgba(150, 150, 150, 0.4);
+}
+
+.emoji.happysmile {
+ background-image: url(./images/emoji/1F642.png);
+}
+
+.toolbar {
+ display: flex;
+ margin-bottom: 1px;
+ background: #fff;
+ padding: 4px;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ vertical-align: middle;
+ width: inherit;
+ overflow-x: auto;
+}
+
+.toolbar button.toolbar-item {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: 10px;
+ padding: 8px;
+ cursor: pointer;
+ vertical-align: middle;
+}
+
+.toolbar button.toolbar-item:disabled {
+ cursor: not-allowed;
+}
+
+.toolbar button.toolbar-item.spaced {
+ margin-right: 2px;
+}
+
+.toolbar button.toolbar-item i.format {
+ background-size: contain;
+ display: inline-block;
+ height: 18px;
+ width: 18px;
+ margin-top: 2px;
+ vertical-align: -0.25em;
+ display: flex;
+ opacity: 0.6;
+}
+
+.toolbar button.toolbar-item:disabled i.format {
+ opacity: 0.2;
+}
+
+.toolbar button.toolbar-item.active {
+ background-color: rgba(223, 232, 250, 0.3);
+}
+
+.toolbar button.toolbar-item.active i {
+ opacity: 1;
+}
+
+.toolbar .toolbar-item:hover:not([disabled]) {
+ background-color: #eee;
+}
+
+.toolbar .divider {
+ width: 1px;
+ background-color: #eee;
+ margin: 0 4px;
+}
+.dropdown .divider {
+ width: auto;
+ background-color: #eee;
+ margin: 4px 8px;
+ height: 1px;
+}
+.toolbar select.toolbar-item {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: 10px;
+ padding: 8px;
+ vertical-align: middle;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ width: 70px;
+ font-size: 14px;
+ color: #777;
+ text-overflow: ellipsis;
+}
+
+.toolbar select.code-language {
+ text-transform: capitalize;
+ width: 130px;
+}
+
+.toolbar .toolbar-item .text {
+ display: flex;
+ line-height: 20px;
+ width: 200px;
+ vertical-align: middle;
+ font-size: 14px;
+ color: #777;
+ text-overflow: ellipsis;
+ width: 70px;
+ overflow: hidden;
+ height: 20px;
+ text-align: left;
+}
+
+.toolbar .toolbar-item .icon {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ user-select: none;
+ margin-right: 8px;
+ line-height: 16px;
+ background-size: contain;
+}
+
+.toolbar i.chevron-down {
+ margin-top: 3px;
+ width: 16px;
+ height: 16px;
+ display: flex;
+ user-select: none;
+}
+
+.toolbar i.chevron-down.inside {
+ width: 16px;
+ height: 16px;
+ display: flex;
+ margin-left: -25px;
+ margin-top: 11px;
+ margin-right: 10px;
+ pointer-events: none;
+}
+
+i.chevron-down {
+ background-color: transparent;
+ background-size: contain;
+ display: inline-block;
+ height: 8px;
+ width: 8px;
+ background-image: url(/images/icons/chevron-down.svg);
+}
+
+#block-controls button:hover {
+ background-color: #efefef;
+}
+
+#block-controls button:focus-visible {
+ border-color: blue;
+}
+
+#block-controls span.block-type {
+ background-size: contain;
+ display: block;
+ width: 18px;
+ height: 18px;
+ margin: 2px;
+}
+
+#block-controls span.block-type.paragraph {
+ background-image: url(/images/icons/text-paragraph.svg);
+}
+
+#block-controls span.block-type.h1 {
+ background-image: url(/images/icons/type-h1.svg);
+}
+
+#block-controls span.block-type.h2 {
+ background-image: url(/images/icons/type-h2.svg);
+}
+
+#block-controls span.block-type.quote {
+ background-image: url(/images/icons/chat-square-quote.svg);
+}
+
+#block-controls span.block-type.ul {
+ background-image: url(/images/icons/list-ul.svg);
+}
+
+#block-controls span.block-type.ol {
+ background-image: url(/images/icons/list-ol.svg);
+}
+
+#block-controls span.block-type.code {
+ background-image: url(/images/icons/code.svg);
+}
+
+.dropdown {
+ z-index: 5;
+ display: block;
+ position: absolute;
+ box-shadow: 0 12px 28px 0 rgba(0, 0, 0, 0.2), 0 2px 4px 0 rgba(0, 0, 0, 0.1),
+ inset 0 0 0 1px rgba(255, 255, 255, 0.5);
+ border-radius: 8px;
+ min-width: 100px;
+ min-height: 40px;
+ background-color: #fff;
+}
+
+.dropdown .item {
+ margin: 0 8px 0 8px;
+ padding: 8px;
+ color: #050505;
+ cursor: pointer;
+ line-height: 16px;
+ font-size: 15px;
+ display: flex;
+ align-content: center;
+ flex-direction: row;
+ flex-shrink: 0;
+ justify-content: space-between;
+ background-color: #fff;
+ border-radius: 8px;
+ border: 0;
+ min-width: 268px;
+}
+
+.dropdown .item .active {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ background-size: contain;
+}
+button.item.dropdown-item-active {
+ background-color: #dfe8fa4d;
+}
+
+.dropdown .item:first-child {
+ margin-top: 8px;
+}
+
+.dropdown .item:last-child {
+ margin-bottom: 8px;
+}
+
+.dropdown .item:hover {
+ background-color: #eee;
+}
+
+.dropdown .item .text {
+ display: flex;
+ line-height: 20px;
+ flex-grow: 1;
+ width: 200px;
+}
+
+.dropdown .item .icon {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ user-select: none;
+ margin-right: 12px;
+ line-height: 16px;
+ background-size: contain;
+}
+.dropdown .item.font-m-Arial {
+ font-family: Arial, sans-serif;
+}
+.dropdown .item.font-m-Courier_New {
+ font-family: 'Courier New', monospace;
+}
+.dropdown .item.font-m-Georgia {
+ font-family: Georgia, serif;
+}
+.dropdown .item.font-m-Times_New_Roman {
+ font-family: 'Times New Roman', serif;
+}
+.dropdown .item.font-m-Trebuchet_MS {
+ font-family: 'Trebuchet MS', sans-serif;
+}
+.dropdown .item.font-m-Verdana {
+ font-family: Verdana, sans-serif;
+}
+.link-editor {
+ position: absolute;
+ z-index: 100;
+ top: -10000px;
+ left: -10000px;
+ margin-top: -6px;
+ max-width: 400px;
+ width: 100%;
+ opacity: 0;
+ background-color: #fff;
+ box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ transition: opacity 0.5s;
+}
+
+.link-editor .link-input {
+ display: block;
+ width: calc(100% - 24px);
+ box-sizing: border-box;
+ margin: 8px 12px;
+ padding: 8px 12px;
+ border-radius: 15px;
+ background-color: #eee;
+ font-size: 15px;
+ color: rgb(5, 5, 5);
+ border: 0;
+ outline: 0;
+ position: relative;
+ font-family: inherit;
+}
+
+.link-editor div.link-edit {
+ background-image: url(/images/icons/pencil-fill.svg);
+ background-size: 16px;
+ background-position: center;
+ background-repeat: no-repeat;
+ width: 35px;
+ vertical-align: -0.25em;
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ cursor: pointer;
+}
+.link-editor div.link-trash {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-trash'%3e%3cpath%20d='M5.5%205.5A.5.5%200%200%201%206%206v6a.5.5%200%200%201-1%200V6a.5.5%200%200%201%20.5-.5zm2.5%200a.5.5%200%200%201%20.5.5v6a.5.5%200%200%201-1%200V6a.5.5%200%200%201%20.5-.5zm3%20.5a.5.5%200%200%200-1%200v6a.5.5%200%200%200%201%200V6z'/%3e%3cpath%20fill-rule='evenodd'%20d='M14.5%203a1%201%200%200%201-1%201H13v9a2%202%200%200%201-2%202H5a2%202%200%200%201-2-2V4h-.5a1%201%200%200%201-1-1V2a1%201%200%200%201%201-1H6a1%201%200%200%201%201-1h2a1%201%200%200%201%201%201h3.5a1%201%200%200%201%201%201v1zM4.118%204%204%204.059V13a1%201%200%200%200%201%201h6a1%201%200%200%200%201-1V4.059L11.882%204H4.118zM2.5%203V2h11v1h-11z'/%3e%3c/svg%3e");
+ background-size: 16px;
+ background-position: center;
+ background-repeat: no-repeat;
+ width: 35px;
+ vertical-align: -.25em;
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ cursor: pointer;
+}
+
+.link-editor .link-input a {
+ color: rgb(33, 111, 219);
+ text-decoration: none;
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ margin-right: 30px;
+ text-overflow: ellipsis;
+}
+
+.link-editor .link-input a:hover {
+ text-decoration: underline;
+}
+
+.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;
+}
+
+i.undo {
+ background-image: url(/images/icons/arrow-counterclockwise.svg);
+}
+
+i.redo {
+ background-image: url(/images/icons/arrow-clockwise.svg);
+}
+
+.icon.paragraph {
+ background-image: url(/images/icons/text-paragraph.svg);
+}
+
+.icon.large-heading,
+.icon.h1 {
+ background-image: url(/images/icons/type-h1.svg);
+}
+
+.icon.small-heading,
+.icon.h2 {
+ background-image: url(/images/icons/type-h2.svg);
+}
+.icon.h3 {
+ background-image: url(/images/icons/type-h3.svg);
+}
+
+.icon.bullet-list,
+.icon.ul {
+ background-image: url(/images/icons/list-ul.svg);
+}
+
+.icon.numbered-list,
+.icon.ol {
+ background-image: url(/images/icons/list-ol.svg);
+}
+
+.icon.quote {
+ background-image: url(/images/icons/chat-square-quote.svg);
+}
+
+.icon.code {
+ background-image: url(/images/icons/code.svg);
+}
+
+.icon.font-family {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-fonts'%3e%3cpath%20d='M12.258%203h-8.51l-.083%202.46h.479c.26-1.544.758-1.783%202.693-1.845l.424-.013v7.827c0%20.663-.144.82-1.3.923v.52h4.082v-.52c-1.162-.103-1.306-.26-1.306-.923V3.602l.431.013c1.934.062%202.434.301%202.693%201.846h.479L12.258%203z'/%3e%3c/svg%3e")
+}
+/* .icon.font-color {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='14'%20height='14'%20viewBox='0%200%20512%20512'%3e%3cpath%20fill='%23777'%20d='M221.631%20109%20109.92%20392h58.055l24.079-61h127.892l24.079%2061h58.055L290.369%20109Zm-8.261%20168L256%20169l42.63%20108Z'/%3e%3c/svg%3e");
+} */
+.icon.font-color {
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23777'%3E%3Cpath d='M15.2459 14H8.75407L7.15407 18H5L11 3H13L19 18H16.8459L15.2459 14ZM14.4459 12L12 5.88516L9.55407 12H14.4459ZM3 20H21V22H3V20Z'%3E%3C/path%3E%3C/svg%3E");
+}
+.icon.bg-color {
+ background-image: url("data:image/svg+xml,%3csvg%20width='16'%20height='16'%20viewBox='0%200%2048%2048'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cpath%20fill='%23fff'%20fill-opacity='.01'%20d='M0%200h48v48H0z'/%3e%3cpath%20fill-rule='evenodd'%20clip-rule='evenodd'%20d='M37%2037a4%204%200%200%200%204-4c0-1.473-1.333-3.473-4-6-2.667%202.527-4%204.527-4%206a4%204%200%200%200%204%204Z'%20fill='%23777'/%3e%3cpath%20d='m20.854%205.504%203.535%203.536'%20stroke='%23777'%20stroke-width='4'%20stroke-linecap='round'/%3e%3cpath%20d='M23.682%208.333%208.125%2023.889%2019.44%2035.203l15.556-15.557L23.682%208.333Z'%20stroke='%23777'%20stroke-width='4'%20stroke-linejoin='round'/%3e%3cpath%20d='m12%2020.073%2016.961%205.577M4%2043h40'%20stroke='%23777'%20stroke-width='4'%20stroke-linecap='round'/%3e%3c/svg%3e");
+}
+.icon.left-align, i.left-align {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-left'%3e%3cpath%20fill-rule='evenodd'%20d='M2%2012.5a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e");
+}
+
+.icon.center-align,i.center-align {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-center'%3e%3cpath%20fill-rule='evenodd'%20d='M4%2012.5a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-2-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm2-3a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-2-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
+}
+
+.icon.right-align,i.right-align {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-right'%3e%3cpath%20fill-rule='evenodd'%20d='M6%2012.5a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-4-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm4-3a.5.5%200%200%201%20.5-.5h7a.5.5%200%200%201%200%201h-7a.5.5%200%200%201-.5-.5zm-4-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
+}
+
+.icon.justify-align,i.justify-align {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-justify'%3e%3cpath%20fill-rule='evenodd'%20d='M2%2012.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm0-3a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
+}
+
+i.indent {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-indent-left'%3e%3cpath%20d='M2%203.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm.646%202.146a.5.5%200%200%201%20.708%200l2%202a.5.5%200%200%201%200%20.708l-2%202a.5.5%200%200%201-.708-.708L4.293%208%202.646%206.354a.5.5%200%200%201%200-.708zM7%206.5a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm-5%203a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
+}
+i.outdent {
+ background-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20width='16'%20height='16'%20fill='currentColor'%20class='bi%20bi-text-indent-right'%3e%3cpath%20d='M2%203.5a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5zm10.646%202.146a.5.5%200%200%201%20.708.708L11.707%208l1.647%201.646a.5.5%200%200%201-.708.708l-2-2a.5.5%200%200%201%200-.708l2-2zM2%206.5a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h6a.5.5%200%200%201%200%201h-6a.5.5%200%200%201-.5-.5zm0%203a.5.5%200%200%201%20.5-.5h11a.5.5%200%200%201%200%201h-11a.5.5%200%200%201-.5-.5z'/%3e%3c/svg%3e")
+}
+
+i.bold {
+ background-image: url(/images/icons/type-bold.svg);
+}
+
+i.italic {
+ background-image: url(/images/icons/type-italic.svg);
+}
+
+i.underline {
+ background-image: url(/images/icons/type-underline.svg);
+}
+
+i.strikethrough {
+ background-image: url(/images/icons/type-strikethrough.svg);
+}
+
+i.code {
+ background-image: url(/images/icons/code.svg);
+}
+
+i.link {
+ background-image: url(/images/icons/link.svg);
+}
+i.horizontal-rule {
+ background-image: url(/images/icons/horizontal-rule.svg);
+}
+
+i.left-align {
+ background-image: url(/images/icons/text-left.svg);
+}
+
+i.center-align {
+ background-image: url(/images/icons/text-center.svg);
+}
+
+i.right-align {
+ background-image: url(/images/icons/text-right.svg);
+}
+
+i.justify-align {
+ background-image: url(/images/icons/justify.svg);
+}
diff --git a/src/components/LexicalEditor/themes/ExampleTheme.js b/src/components/LexicalEditor/themes/ExampleTheme.js
new file mode 100644
index 0000000..ecd4bdd
--- /dev/null
+++ b/src/components/LexicalEditor/themes/ExampleTheme.js
@@ -0,0 +1,70 @@
+const exampleTheme = {
+ ltr: "ltr",
+ rtl: "rtl",
+ placeholder: "editor-placeholder",
+ paragraph: "editor-paragraph",
+ // paragraph: "editor-p",
+ quote: "editor-quote",
+ heading: {
+ h1: "editor-heading-h1",
+ h2: "editor-heading-h2",
+ h3: "editor-heading-h3",
+ h4: "editor-heading-h4",
+ h5: "editor-heading-h5"
+ },
+ list: {
+ nested: {
+ listitem: "editor-nested-listitem"
+ },
+ ol: "editor-list-ol",
+ ul: "editor-list-ul",
+ listitem: "editor-listitem"
+ },
+ image: "editor-image",
+ link: "editor-link",
+ text: {
+ bold: "editor-text-bold",
+ italic: "editor-text-italic",
+ overflowed: "editor-text-overflowed",
+ hashtag: "editor-text-hashtag",
+ underline: "editor-text-underline",
+ strikethrough: "editor-text-strikethrough",
+ underlineStrikethrough: "editor-text-underlineStrikethrough",
+ code: "editor-text-code"
+ },
+ code: "editor-code",
+ codeHighlight: {
+ atrule: "editor-tokenAttr",
+ attr: "editor-tokenAttr",
+ boolean: "editor-tokenProperty",
+ builtin: "editor-tokenSelector",
+ cdata: "editor-tokenComment",
+ char: "editor-tokenSelector",
+ class: "editor-tokenFunction",
+ "class-name": "editor-tokenFunction",
+ comment: "editor-tokenComment",
+ constant: "editor-tokenProperty",
+ deleted: "editor-tokenProperty",
+ doctype: "editor-tokenComment",
+ entity: "editor-tokenOperator",
+ function: "editor-tokenFunction",
+ important: "editor-tokenVariable",
+ inserted: "editor-tokenSelector",
+ keyword: "editor-tokenAttr",
+ namespace: "editor-tokenVariable",
+ number: "editor-tokenProperty",
+ operator: "editor-tokenOperator",
+ prolog: "editor-tokenComment",
+ property: "editor-tokenProperty",
+ punctuation: "editor-tokenPunctuation",
+ regex: "editor-tokenVariable",
+ selector: "editor-tokenSelector",
+ string: "editor-tokenSelector",
+ symbol: "editor-tokenProperty",
+ tag: "editor-tokenProperty",
+ url: "editor-tokenOperator",
+ variable: "editor-tokenVariable"
+ }
+};
+
+export default exampleTheme;
diff --git a/src/components/LexicalEditor/ui/Button.css b/src/components/LexicalEditor/ui/Button.css
new file mode 100644
index 0000000..946c4dc
--- /dev/null
+++ b/src/components/LexicalEditor/ui/Button.css
@@ -0,0 +1,36 @@
+/**
+ * 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.
+ *
+ *
+ */
+
+.Button__root {
+ padding-top: 10px;
+ padding-bottom: 10px;
+ padding-left: 15px;
+ padding-right: 15px;
+ border: 0px;
+ background-color: #eee;
+ border-radius: 5px;
+ cursor: pointer;
+ font-size: 14px;
+}
+.Button__root:hover {
+ background-color: #ddd;
+}
+.Button__small {
+ padding-top: 5px;
+ padding-bottom: 5px;
+ padding-left: 10px;
+ padding-right: 10px;
+ font-size: 13px;
+}
+.Button__disabled {
+ cursor: not-allowed;
+}
+.Button__disabled:hover {
+ background-color: #eee;
+}
diff --git a/src/components/LexicalEditor/ui/Button.tsx b/src/components/LexicalEditor/ui/Button.tsx
new file mode 100644
index 0000000..a83f7a8
--- /dev/null
+++ b/src/components/LexicalEditor/ui/Button.tsx
@@ -0,0 +1,49 @@
+/**
+ * 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 './Button.css';
+
+import * as React from 'react';
+import {ReactNode} from 'react';
+
+import joinClasses from '../utils/joinClasses';
+
+export default function Button({
+ 'data-test-id': dataTestId,
+ children,
+ className,
+ onClick,
+ disabled,
+ small,
+ title,
+}: {
+ 'data-test-id'?: string;
+ children: ReactNode;
+ className?: string;
+ disabled?: boolean;
+ onClick: () => void;
+ small?: boolean;
+ title?: string;
+}): JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/components/LexicalEditor/ui/ColorPicker.css b/src/components/LexicalEditor/ui/ColorPicker.css
new file mode 100644
index 0000000..63217ba
--- /dev/null
+++ b/src/components/LexicalEditor/ui/ColorPicker.css
@@ -0,0 +1,88 @@
+/**
+ * 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.
+ *
+ */
+
+.color-picker-wrapper {
+ padding: 20px;
+}
+
+.color-picker-basic-color {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin: 0;
+ padding: 0;
+}
+
+.color-picker-basic-color button {
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ height: 16px;
+ width: 16px;
+ cursor: pointer;
+ list-style-type: none;
+}
+
+.color-picker-basic-color button.active {
+ box-shadow: 0px 0px 2px 2px rgba(0, 0, 0, 0.3);
+}
+
+.color-picker-saturation {
+ width: 100%;
+ position: relative;
+ margin-top: 15px;
+ height: 150px;
+ background-image: linear-gradient(transparent, black),
+ linear-gradient(to right, white, transparent);
+ user-select: none;
+}
+.color-picker-saturation_cursor {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ box-shadow: 0 0 15px #00000026;
+ box-sizing: border-box;
+ transform: translate(-10px, -10px);
+}
+.color-picker-hue {
+ width: 100%;
+ position: relative;
+ margin-top: 15px;
+ height: 12px;
+ background-image: linear-gradient(
+ to right,
+ rgb(255, 0, 0),
+ rgb(255, 255, 0),
+ rgb(0, 255, 0),
+ rgb(0, 255, 255),
+ rgb(0, 0, 255),
+ rgb(255, 0, 255),
+ rgb(255, 0, 0)
+ );
+ user-select: none;
+ border-radius: 12px;
+}
+
+.color-picker-hue_cursor {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ border: 2px solid #ffffff;
+ border-radius: 50%;
+ box-shadow: #0003 0 0 0 0.5px;
+ box-sizing: border-box;
+ transform: translate(-10px, -4px);
+}
+
+.color-picker-color {
+ border: 1px solid #ccc;
+ margin-top: 15px;
+ width: 100%;
+ height: 20px;
+}
diff --git a/src/components/LexicalEditor/ui/ColorPicker.tsx b/src/components/LexicalEditor/ui/ColorPicker.tsx
new file mode 100644
index 0000000..ebd7b3d
--- /dev/null
+++ b/src/components/LexicalEditor/ui/ColorPicker.tsx
@@ -0,0 +1,364 @@
+/**
+ * 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 './ColorPicker.css';
+
+import {calculateZoomLevel} from '@lexical/utils';
+import {useEffect, useMemo, useRef, useState} from 'react';
+import * as React from 'react';
+
+import TextInput from './TextInput';
+
+let skipAddingToHistoryStack = false;
+
+interface ColorPickerProps {
+ color: string;
+ onChange?: (value: string, skipHistoryStack: boolean) => void;
+}
+
+const basicColors = [
+ '#d0021b',
+ '#f5a623',
+ '#f8e71c',
+ '#8b572a',
+ '#7ed321',
+ '#417505',
+ '#bd10e0',
+ '#9013fe',
+ '#4a90e2',
+ '#50e3c2',
+ '#b8e986',
+ '#000000',
+ '#4a4a4a',
+ '#9b9b9b',
+ '#ffffff',
+];
+
+const WIDTH = 214;
+const HEIGHT = 150;
+
+export default function ColorPicker({
+ color,
+ onChange,
+}: Readonly): JSX.Element {
+ const [selfColor, setSelfColor] = useState(transformColor('hex', color));
+ const [inputColor, setInputColor] = useState(color);
+ const innerDivRef = useRef(null);
+
+ const saturationPosition = useMemo(
+ () => ({
+ x: (selfColor.hsv.s / 100) * WIDTH,
+ y: ((100 - selfColor.hsv.v) / 100) * HEIGHT,
+ }),
+ [selfColor.hsv.s, selfColor.hsv.v],
+ );
+
+ const huePosition = useMemo(
+ () => ({
+ x: (selfColor.hsv.h / 360) * WIDTH,
+ }),
+ [selfColor.hsv],
+ );
+
+ const onSetHex = (hex: string) => {
+ setInputColor(hex);
+ if (/^#[0-9A-Fa-f]{6}$/i.test(hex)) {
+ const newColor = transformColor('hex', hex);
+ setSelfColor(newColor);
+ }
+ };
+
+ const onMoveSaturation = ({x, y}: Position) => {
+ const newHsv = {
+ ...selfColor.hsv,
+ s: (x / WIDTH) * 100,
+ v: 100 - (y / HEIGHT) * 100,
+ };
+ const newColor = transformColor('hsv', newHsv);
+ setSelfColor(newColor);
+ setInputColor(newColor.hex);
+ };
+
+ const onMoveHue = ({x}: Position) => {
+ const newHsv = {...selfColor.hsv, h: (x / WIDTH) * 360};
+ const newColor = transformColor('hsv', newHsv);
+
+ setSelfColor(newColor);
+ setInputColor(newColor.hex);
+ };
+
+ useEffect(() => {
+ // Check if the dropdown is actually active
+ if (innerDivRef.current !== null && onChange) {
+ onChange(selfColor.hex, skipAddingToHistoryStack);
+ setInputColor(selfColor.hex);
+ }
+ }, [selfColor, onChange]);
+
+ useEffect(() => {
+ if (color === undefined) {
+ return;
+ }
+ const newColor = transformColor('hex', color);
+ setSelfColor(newColor);
+ setInputColor(newColor.hex);
+ }, [color]);
+
+ return (
+
+
+
+ {basicColors.map((basicColor) => (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export interface Position {
+ x: number;
+ y: number;
+}
+
+interface MoveWrapperProps {
+ className?: string;
+ style?: React.CSSProperties;
+ onChange: (position: Position) => void;
+ children: JSX.Element;
+}
+
+function MoveWrapper({className, style, onChange, children}: MoveWrapperProps) {
+ const divRef = useRef(null);
+ const draggedRef = useRef(false);
+
+ const move = (e: React.MouseEvent | MouseEvent): void => {
+ if (divRef.current) {
+ const {current: div} = divRef;
+ const {width, height, left, top} = div.getBoundingClientRect();
+ const zoom = calculateZoomLevel(div);
+ const x = clamp(e.clientX / zoom - left, width, 0);
+ const y = clamp(e.clientY / zoom - top, height, 0);
+
+ onChange({x, y});
+ }
+ };
+
+ const onMouseDown = (e: React.MouseEvent): void => {
+ if (e.button !== 0) {
+ return;
+ }
+
+ move(e);
+
+ const onMouseMove = (_e: MouseEvent): void => {
+ draggedRef.current = true;
+ skipAddingToHistoryStack = true;
+ move(_e);
+ };
+
+ const onMouseUp = (_e: MouseEvent): void => {
+ if (draggedRef.current) {
+ skipAddingToHistoryStack = false;
+ }
+
+ document.removeEventListener('mousemove', onMouseMove, false);
+ document.removeEventListener('mouseup', onMouseUp, false);
+
+ move(_e);
+ draggedRef.current = false;
+ };
+
+ document.addEventListener('mousemove', onMouseMove, false);
+ document.addEventListener('mouseup', onMouseUp, false);
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+function clamp(value: number, max: number, min: number) {
+ return value > max ? max : value < min ? min : value;
+}
+
+interface RGB {
+ b: number;
+ g: number;
+ r: number;
+}
+interface HSV {
+ h: number;
+ s: number;
+ v: number;
+}
+interface Color {
+ hex: string;
+ hsv: HSV;
+ rgb: RGB;
+}
+
+export function toHex(value: string): string {
+ if (!value.startsWith('#')) {
+ const ctx = document.createElement('canvas').getContext('2d');
+
+ if (!ctx) {
+ throw new Error('2d context not supported or canvas already initialized');
+ }
+
+ ctx.fillStyle = value;
+
+ return ctx.fillStyle;
+ } else if (value.length === 4 || value.length === 5) {
+ value = value
+ .split('')
+ .map((v, i) => (i ? v + v : '#'))
+ .join('');
+
+ return value;
+ } else if (value.length === 7 || value.length === 9) {
+ return value;
+ }
+
+ return '#000000';
+}
+
+function hex2rgb(hex: string): RGB {
+ const rbgArr = (
+ hex
+ .replace(
+ /^#?([a-f\d])([a-f\d])([a-f\d])$/i,
+ (m, r, g, b) => '#' + r + r + g + g + b + b,
+ )
+ .substring(1)
+ .match(/.{2}/g) || []
+ ).map((x) => parseInt(x, 16));
+
+ return {
+ b: rbgArr[2],
+ g: rbgArr[1],
+ r: rbgArr[0],
+ };
+}
+
+function rgb2hsv({r, g, b}: RGB): HSV {
+ r /= 255;
+ g /= 255;
+ b /= 255;
+
+ const max = Math.max(r, g, b);
+ const d = max - Math.min(r, g, b);
+
+ const h = d
+ ? (max === r
+ ? (g - b) / d + (g < b ? 6 : 0)
+ : max === g
+ ? 2 + (b - r) / d
+ : 4 + (r - g) / d) * 60
+ : 0;
+ const s = max ? (d / max) * 100 : 0;
+ const v = max * 100;
+
+ return {h, s, v};
+}
+
+function hsv2rgb({h, s, v}: HSV): RGB {
+ s /= 100;
+ v /= 100;
+
+ const i = ~~(h / 60);
+ const f = h / 60 - i;
+ const p = v * (1 - s);
+ const q = v * (1 - s * f);
+ const t = v * (1 - s * (1 - f));
+ const index = i % 6;
+
+ const r = Math.round([v, q, p, p, t, v][index] * 255);
+ const g = Math.round([t, v, v, q, p, p][index] * 255);
+ const b = Math.round([p, p, t, v, v, q][index] * 255);
+
+ return {b, g, r};
+}
+
+function rgb2hex({b, g, r}: RGB): string {
+ return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
+}
+
+function transformColor(
+ format: M,
+ color: C,
+): Color {
+ let hex: Color['hex'] = toHex('#121212');
+ let rgb: Color['rgb'] = hex2rgb(hex);
+ let hsv: Color['hsv'] = rgb2hsv(rgb);
+
+ if (format === 'hex') {
+ const value = color as Color['hex'];
+
+ hex = toHex(value);
+ rgb = hex2rgb(hex);
+ hsv = rgb2hsv(rgb);
+ } else if (format === 'rgb') {
+ const value = color as Color['rgb'];
+
+ rgb = value;
+ hex = rgb2hex(rgb);
+ hsv = rgb2hsv(rgb);
+ } else if (format === 'hsv') {
+ const value = color as Color['hsv'];
+
+ hsv = value;
+ rgb = hsv2rgb(hsv);
+ hex = rgb2hex(rgb);
+ }
+
+ return {hex, hsv, rgb};
+}
diff --git a/src/components/LexicalEditor/ui/ContentEditable.css b/src/components/LexicalEditor/ui/ContentEditable.css
new file mode 100644
index 0000000..efced7f
--- /dev/null
+++ b/src/components/LexicalEditor/ui/ContentEditable.css
@@ -0,0 +1,44 @@
+/**
+ * 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.
+ *
+ *
+ */
+.ContentEditable__root {
+ border: 0;
+ font-size: 15px;
+ display: block;
+ position: relative;
+ outline: 0;
+ padding: 8px 28px 40px;
+ min-height: 150px;
+}
+@media (max-width: 1025px) {
+ .ContentEditable__root {
+ padding-left: 8px;
+ padding-right: 8px;
+ }
+}
+
+.ContentEditable__placeholder {
+ font-size: 15px;
+ color: #999;
+ overflow: hidden;
+ position: absolute;
+ text-overflow: ellipsis;
+ top: 8px;
+ left: 28px;
+ right: 28px;
+ user-select: none;
+ white-space: nowrap;
+ display: inline-block;
+ pointer-events: none;
+}
+@media (max-width: 1025px) {
+ .ContentEditable__placeholder {
+ left: 8px;
+ right: 8px;
+ }
+}
diff --git a/src/components/LexicalEditor/ui/ContentEditable.tsx b/src/components/LexicalEditor/ui/ContentEditable.tsx
new file mode 100644
index 0000000..b1ee20b
--- /dev/null
+++ b/src/components/LexicalEditor/ui/ContentEditable.tsx
@@ -0,0 +1,36 @@
+/**
+ * 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 './ContentEditable.css';
+
+import {ContentEditable} from '@lexical/react/LexicalContentEditable';
+import * as React from 'react';
+
+type Props = {
+ className?: string;
+ placeholderClassName?: string;
+ placeholder: string;
+};
+
+export default function LexicalContentEditable({
+ className,
+ placeholder,
+ placeholderClassName,
+}: Props): JSX.Element {
+ return (
+
+ {placeholder}
+
+ }
+ />
+ );
+}
diff --git a/src/components/LexicalEditor/ui/Dialog.css b/src/components/LexicalEditor/ui/Dialog.css
new file mode 100644
index 0000000..1014b4a
--- /dev/null
+++ b/src/components/LexicalEditor/ui/Dialog.css
@@ -0,0 +1,25 @@
+/**
+ * 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.
+ *
+ */
+
+.DialogActions {
+ display: flex;
+ flex-direction: row;
+ justify-content: right;
+ margin-top: 20px;
+}
+
+.DialogButtonsList {
+ display: flex;
+ flex-direction: column;
+ justify-content: right;
+ margin-top: 20px;
+}
+
+.DialogButtonsList button {
+ margin-bottom: 20px;
+}
diff --git a/src/components/LexicalEditor/ui/Dialog.tsx b/src/components/LexicalEditor/ui/Dialog.tsx
new file mode 100644
index 0000000..36e3b8c
--- /dev/null
+++ b/src/components/LexicalEditor/ui/Dialog.tsx
@@ -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.
+ *
+ */
+
+import './Dialog.css';
+
+import * as React from 'react';
+import {ReactNode} from 'react';
+
+type Props = Readonly<{
+ 'data-test-id'?: string;
+ children: ReactNode;
+}>;
+
+export function DialogButtonsList({children}: Props): JSX.Element {
+ return
{children}
;
+}
+
+export function DialogActions({
+ 'data-test-id': dataTestId,
+ children,
+}: Props): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/LexicalEditor/ui/DropDown.tsx b/src/components/LexicalEditor/ui/DropDown.tsx
new file mode 100644
index 0000000..012d467
--- /dev/null
+++ b/src/components/LexicalEditor/ui/DropDown.tsx
@@ -0,0 +1,259 @@
+/**
+ * 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 * as React from 'react';
+import {
+ ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import {createPortal} from 'react-dom';
+
+type DropDownContextType = {
+ registerItem: (ref: React.RefObject
) => void;
+};
+
+const DropDownContext = React.createContext(null);
+
+const dropDownPadding = 4;
+
+export function DropDownItem({
+ children,
+ className,
+ onClick,
+ title,
+}: {
+ children: React.ReactNode;
+ className: string;
+ onClick: (event: React.MouseEvent) => void;
+ title?: string;
+}) {
+ const ref = useRef(null);
+
+ const dropDownContext = React.useContext(DropDownContext);
+
+ if (dropDownContext === null) {
+ throw new Error('DropDownItem must be used within a DropDown');
+ }
+
+ const {registerItem} = dropDownContext;
+
+ useEffect(() => {
+ if (ref && ref.current) {
+ registerItem(ref);
+ }
+ }, [ref, registerItem]);
+
+ return (
+
+ );
+}
+
+function DropDownItems({
+ children,
+ dropDownRef,
+ onClose,
+}: {
+ children: React.ReactNode;
+ dropDownRef: React.Ref;
+ onClose: () => void;
+}) {
+ const [items, setItems] = useState[]>();
+ const [highlightedItem, setHighlightedItem] =
+ useState>();
+
+ const registerItem = useCallback(
+ (itemRef: React.RefObject) => {
+ setItems((prev) => (prev ? [...prev, itemRef] : [itemRef]));
+ },
+ [setItems],
+ );
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (!items) {
+ return;
+ }
+
+ const key = event.key;
+
+ if (['Escape', 'ArrowUp', 'ArrowDown', 'Tab'].includes(key)) {
+ event.preventDefault();
+ }
+
+ if (key === 'Escape' || key === 'Tab') {
+ onClose();
+ } else if (key === 'ArrowUp') {
+ setHighlightedItem((prev) => {
+ if (!prev) {
+ return items[0];
+ }
+ const index = items.indexOf(prev) - 1;
+ return items[index === -1 ? items.length - 1 : index];
+ });
+ } else if (key === 'ArrowDown') {
+ setHighlightedItem((prev) => {
+ if (!prev) {
+ return items[0];
+ }
+ return items[items.indexOf(prev) + 1];
+ });
+ }
+ };
+
+ const contextValue = useMemo(
+ () => ({
+ registerItem,
+ }),
+ [registerItem],
+ );
+
+ useEffect(() => {
+ if (items && !highlightedItem) {
+ setHighlightedItem(items[0]);
+ }
+
+ if (highlightedItem && highlightedItem.current) {
+ highlightedItem.current.focus();
+ }
+ }, [items, highlightedItem]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export default function DropDown({
+ disabled = false,
+ buttonLabel,
+ buttonAriaLabel,
+ buttonClassName,
+ buttonIconClassName,
+ children,
+ stopCloseOnClickSelf,
+}: {
+ disabled?: boolean;
+ buttonAriaLabel?: string;
+ buttonClassName: string;
+ buttonIconClassName?: string;
+ buttonLabel?: string;
+ children: ReactNode;
+ stopCloseOnClickSelf?: boolean;
+}): JSX.Element {
+ const dropDownRef = useRef(null);
+ const buttonRef = useRef(null);
+ const [showDropDown, setShowDropDown] = useState(false);
+
+ const handleClose = () => {
+ setShowDropDown(false);
+ if (buttonRef && buttonRef.current) {
+ buttonRef.current.focus();
+ }
+ };
+
+ useEffect(() => {
+ const button = buttonRef.current;
+ const dropDown = dropDownRef.current;
+
+ if (showDropDown && button !== null && dropDown !== null) {
+ const {top, left} = button.getBoundingClientRect();
+ dropDown.style.top = `${top + button.offsetHeight + dropDownPadding}px`;
+ dropDown.style.left = `${Math.min(
+ left,
+ window.innerWidth - dropDown.offsetWidth - 20,
+ )}px`;
+ }
+ }, [dropDownRef, buttonRef, showDropDown]);
+
+ useEffect(() => {
+ const button = buttonRef.current;
+
+ if (button !== null && showDropDown) {
+ const handle = (event: MouseEvent) => {
+ const target = event.target;
+ if (stopCloseOnClickSelf) {
+ if (
+ dropDownRef.current &&
+ dropDownRef.current.contains(target as Node)
+ ) {
+ return;
+ }
+ }
+ if (!button.contains(target as Node)) {
+ setShowDropDown(false);
+ }
+ };
+ document.addEventListener('click', handle);
+
+ return () => {
+ document.removeEventListener('click', handle);
+ };
+ }
+ }, [dropDownRef, buttonRef, showDropDown, stopCloseOnClickSelf]);
+
+ useEffect(() => {
+ const handleButtonPositionUpdate = () => {
+ if (showDropDown) {
+ const button = buttonRef.current;
+ const dropDown = dropDownRef.current;
+ if (button !== null && dropDown !== null) {
+ const {top} = button.getBoundingClientRect();
+ const newPosition = top + button.offsetHeight + dropDownPadding;
+ if (newPosition !== dropDown.getBoundingClientRect().top) {
+ dropDown.style.top = `${newPosition}px`;
+ }
+ }
+ }
+ };
+
+ document.addEventListener('scroll', handleButtonPositionUpdate);
+
+ return () => {
+ document.removeEventListener('scroll', handleButtonPositionUpdate);
+ };
+ }, [buttonRef, dropDownRef, showDropDown]);
+
+ return (
+ <>
+
+
+ {showDropDown &&
+ createPortal(
+
+ {children}
+ ,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/src/components/LexicalEditor/ui/DropdownColorPicker.tsx b/src/components/LexicalEditor/ui/DropdownColorPicker.tsx
new file mode 100644
index 0000000..e3647c3
--- /dev/null
+++ b/src/components/LexicalEditor/ui/DropdownColorPicker.tsx
@@ -0,0 +1,41 @@
+/**
+ * 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 * as React from 'react';
+
+import ColorPicker from './ColorPicker';
+import DropDown from './DropDown';
+
+type Props = {
+ disabled?: boolean;
+ buttonAriaLabel?: string;
+ buttonClassName: string;
+ buttonIconClassName?: string;
+ buttonLabel?: string;
+ title?: string;
+ stopCloseOnClickSelf?: boolean;
+ color: string;
+ onChange?: (color: string, skipHistoryStack: boolean) => void;
+};
+
+export default function DropdownColorPicker({
+ disabled = false,
+ stopCloseOnClickSelf = true,
+ color,
+ onChange,
+ ...rest
+}: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/LexicalEditor/ui/FileInput.tsx b/src/components/LexicalEditor/ui/FileInput.tsx
new file mode 100644
index 0000000..465330b
--- /dev/null
+++ b/src/components/LexicalEditor/ui/FileInput.tsx
@@ -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.
+ *
+ */
+
+import './Input.css';
+
+import * as React from 'react';
+
+type Props = Readonly<{
+ 'data-test-id'?: string;
+ accept?: string;
+ label: string;
+ onChange: (files: FileList | null) => void;
+}>;
+
+export default function FileInput({
+ accept,
+ label,
+ onChange,
+ 'data-test-id': dataTestId,
+}: Props): JSX.Element {
+ return (
+
+
+ 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 = () => {
- : } onClick={() => setCollapsedLeft(!collapsedLeft)} className=' rounded-none rounded-l' />
+ {/* : } onClick={() => setCollapsedLeft(!collapsedLeft)} className=' rounded-none rounded-l' /> */}
{/* } onClick={() => setCollapsedRight(!collapsedRight)} className='' title='最新消息记录' /> */}
: } onClick={() => setCollapsedRight(!collapsedRight)} className=' rounded-none rounded-r' />
@@ -54,7 +55,9 @@ const ChatWindow = () => {
diff --git a/src/views/Conversations/Conversations.css b/src/views/Conversations/Conversations.css
index ecfc770..a10d1bc 100644
--- a/src/views/Conversations/Conversations.css
+++ b/src/views/Conversations/Conversations.css
@@ -102,11 +102,25 @@
}
.chatwindow-wrapper .rce-citem {
background: transparent;
+ height: auto;
+ min-height: 72px;
}
.chatwindow-wrapper .bg-transparent .rce-mbox{
background: unset;
/* box-shadow: none; */
}
+.chatwindow-wrapper .rce-mbox{
+ margin-left: 10px;
+ margin-right: 5px;
+}
+.chatwindow-wrapper .rce-mbox.rce-mbox-right{
+ margin-left: 5px;
+ margin-right: 10px;
+}
+.chatwindow-wrapper .rce-mbox.rce-mbox--clear-notch{
+ margin-left: 5px;
+ margin-right: 5px;
+}
.chatwindow-wrapper .bg-transparent .rce-mbox-left-notch,
.chatwindow-wrapper .bg-transparent .rce-mbox-right-notch
{
@@ -135,7 +149,9 @@
content: none;
}
.chatwindow-wrapper .rce-mbox-photo--img img{
- width: auto;
+ /* width: auto; */
+ max-width: 100%;
+ object-fit: contain;
}
.chatwindow-wrapper .failed-msg .rce-mbox-photo--img,
.chatwindow-wrapper .failed-msg .rce-mbox-video--video
@@ -160,6 +176,7 @@
}
.chatwindow-wrapper .rce-citem-avatar{
border-bottom: 1px solid #0000000d;
+ padding-left: 0;
}
.chatwindow-wrapper .rce-avatar-letter{
margin-top: 0;
@@ -241,6 +258,17 @@
animation-duration: 2s;
}
+.chatwindow-wrapper .reply-wrapper .ant-tabs-nav {
+ margin-top: 0;
+ margin-bottom: 0;
+}
+.chatwindow-wrapper .reply-wrapper .ant-tabs-card >.ant-tabs-nav .ant-tabs-tab-active, .ant-tabs-card >div>.ant-tabs-nav .ant-tabs-tab-active{
+ background: #e5e7eb;
+ /* background: #f3f4f6; */
+ border-top: none;
+ border: 1px solid #a7f3d0;
+ border-top: none;
+}
/**
* Mobile chat ------------------------------------------------------------------------------------
diff --git a/src/views/Conversations/History/MessagesList.jsx b/src/views/Conversations/History/MessagesList.jsx
index 8dbb8ee..202502c 100644
--- a/src/views/Conversations/History/MessagesList.jsx
+++ b/src/views/Conversations/History/MessagesList.jsx
@@ -151,8 +151,8 @@ const MessagesList = ({ ...props }) => {
buttonsArr = componentsObj?.buttons?.reduce((r, c) => r.concat(c.buttons), []);
}
- const parts = str.split(/(https?:\/\/[^\s]+|\p{Emoji_Presentation})/gmu).filter((s) => s !== '');
- const links = str.match(/https?:\/\/[\S]+/gi) || [];
+ const parts = str.split(/(https?:\/\/[^\s()]+|\p{Emoji_Presentation})/gmu).filter((s) => s !== '');
+ const links = str.match(/https?:\/\/[^\s()]+/gi) || [];
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) => {
diff --git a/src/views/Conversations/History/SearchForm.jsx b/src/views/Conversations/History/SearchForm.jsx
index 961f4ac..0315086 100644
--- a/src/views/Conversations/History/SearchForm.jsx
+++ b/src/views/Conversations/History/SearchForm.jsx
@@ -1,6 +1,6 @@
/* eslint-disable react/display-name */
import { memo } from 'react';
-import { Form, Flex, Input, Button, DatePicker } from 'antd';
+import { Form, Flex, Input, Button, DatePicker, Select } from 'antd';
import SearchInput from '@/components/SearchInput';
import { isNotEmpty } from '@/utils/commons';
import { fetchSalesAgentWithDD as fetchSalesAgent, fetchCustomerList } from '@/actions/CommonActions';
@@ -39,6 +39,22 @@ const SearchForm = memo(function ({ initialValues, onSubmit, onReset }) {
+
+
+
+
+
+
diff --git a/src/views/Conversations/Online/Components/BubbleEmail.jsx b/src/views/Conversations/Online/Components/BubbleEmail.jsx
new file mode 100644
index 0000000..f63cef7
--- /dev/null
+++ b/src/views/Conversations/Online/Components/BubbleEmail.jsx
@@ -0,0 +1,68 @@
+import { createContext, useEffect, useState, memo } from 'react';
+import { Button } from 'antd';
+import { MailFilled, MailOutlined, WhatsAppOutlined } from '@ant-design/icons';
+import { MessageBox } from 'react-chat-elements';
+import { groupBy, isEmpty, } from '@/utils/commons';
+
+const ChatboxEmail = ({ onOpenEditor, onOpenEmail, ...message }) => {
+
+ const RenderText = memo(function renderText({ className, email, sender }) {
+ return (
+ handlePreview(message)} className={`text-sm leading-5 emoji-text whitespace-pre-wrap cursor-pointer ${className}`} key={'msg-text'}>
+ {sender === 'me' &&
From: {email.fromName} <{email.fromEmail}>
}
+
To: {email.toName} <{email.toEmail}>
+
Subject: {email.subject}
+
+
{email.abstract}
+
+ );
+ });
+ const handlePreview = (message) => {
+ console.log('handlePreview');
+ if (typeof onOpenEmail === 'function') {
+ onOpenEmail(message);
+ }
+ }
+ return (
+
+
+
+ From:
+
+ {message?.emailOrigin?.fromName} <{message?.emailOrigin.fromEmail}>
+
+
+ >
+ }
+ // titleColor={message.sender !== 'me' ? '#4f46e5' : ''} // 600
+ notch={false}
+ position={message.sender === 'me' ? 'right' : 'left'}
+ onReplyClick={() => onOpenEditor(message.emailOrigin)}
+ // onReplyMessageClick={() => scrollToMessage(message.reply.id)}
+ onOpen={() => handlePreview(message)}
+ onTitleClick={() => handlePreview(message)}
+ text={}
+ // forwarded={true}
+ // replyButton={message.sender !== 'me'}
+ // replyButton={['text', 'document', 'image'].includes(message.whatsapp_msg_type)}
+ {...(message.sender === 'me'
+ ? {
+ styles: { backgroundColor: '#e0e7ff', boxShadow: 'none', border: '1px solid #818cf8' }, // 100 400
+ // replyButton: ['text', 'document', 'image'].includes(message.whatsapp_msg_type) && message.status !== 'failed' ? true : false, // todo: 仅对接收的显示
+ }
+ : {})}
+ className={[
+ 'whitespace-pre-wrap',
+ message.sender === 'me' ? 'whatsappme-container' : '',
+ // focusMsg === message.id ? 'message-box-focus' : '',
+ message.status === 'failed' ? 'failed-msg' : '',
+ ].join(' ')}
+ />
+ );
+};
+export default ChatboxEmail;
diff --git a/src/views/Conversations/Online/Components/BubbleIM.jsx b/src/views/Conversations/Online/Components/BubbleIM.jsx
new file mode 100644
index 0000000..6bc8069
--- /dev/null
+++ b/src/views/Conversations/Online/Components/BubbleIM.jsx
@@ -0,0 +1,151 @@
+import { createContext, useEffect, useState, memo } from 'react';
+import { App, Button } from 'antd';
+import { MailFilled, MailOutlined, WhatsAppOutlined } from '@ant-design/icons';
+import { MessageBox } from 'react-chat-elements';
+import { groupBy, isEmpty } from '@/utils/commons';
+import useConversationStore from '@/stores/ConversationStore';
+import { useShallow } from 'zustand/react/shallow';
+import { WABIcon } from '@/components/Icons';
+
+const BubbleIM = ({ handlePreview, handleContactClick, setNewChatModalVisible, setNewChatFormValues, scrollToMessage, focusMsg, ...message }) => {
+ const { message: appMessage } = App.useApp();
+ const setReferenceMsg = useConversationStore(useShallow((state) => state.setReferenceMsg));
+
+ 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}
+
+ );
+ });
+ 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}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ >
+ }>
+ {/* : } type='text' size='middle' /> */}
+ } type='text' size='middle' />
+
+
+ >
+ );
+};
+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: },
+ ]}
+ />
+ >
+ }>
+ } type='text' size='middle' title='消息记录' />
+
+ >
+ );
+};
+export default MessageListFilter;
diff --git a/src/views/Conversations/Online/Components/emailRe.json b/src/views/Conversations/Online/Components/emailRe.json
new file mode 100644
index 0000000..a5bc725
--- /dev/null
+++ b/src/views/Conversations/Online/Components/emailRe.json
@@ -0,0 +1,51 @@
+{
+ "conversationid": 2983,
+ "sn": 14201,
+ "msg_direction": "inbound",
+ "msgtime": "2024-02-21T11:37:33",
+ "msgtext_AsJOSN": {},
+ "msgtype": "email",
+ "template_AsJOSN": {},
+ "messageorigin_AsJOSN": [],
+ "messageorigin_direction": "inbound",
+ "orgmsgtime": "2024-02-22T00:41:30",
+ "msgOrigin": {},
+ "id": "emailid.qjMVpfPuxd8cwKs9o3bGIgYL6SWinB5vHRyQX1ZTU4OmeEAtDk07zaF=",
+ "text": "",
+ "title": "",
+ "type": "email",
+ "emailOrigin": {
+ "id": "emailid.qjMVpfPuxd8cwKs9o3bGIgYL6SWinB5vHRyQX1ZTU4OmeEAtDk07zaF=",
+ "status": "read",
+ "fromName": "LYT",
+ "fromEmail": "leioting@hotmail.com",
+ "toName": "LYT",
+ "toEmail": "lyt@hainatravel.com",
+ "cc": "lioyjun@gmail.com, LJ ",
+ "bcc": "",
+ "subject": "New booking: Sun 29.Sep '24 @ (TP-T78840699) Ext. booking ref: 1184791715",
+ "content": "The following booking was just created.
Booking ref.VIA-51242561
Product booking ref.TP-T78840699
Ext. booking ref1184791715
Product7137P273 - Qatar: Doha Hamad International Airport (DOH) Al Maha Lounge
SupplierTrippest Tours
Sold byViator.com
Booking channelViator.com
Customerhung, tak wai
Customer emailS-1a553e197d734ab1b19b3ac34c56beb7+1184791715-2efw857tc2u53@expmessaging.tripadvisor.com
Customer phone+66 2 030 4763
DateSun 29.Sep '24
RateQatar: Doha Hamad International Airport (DOH) VIP Lounge Access
PAX1 Adult
Extras
CreatedWed, September 25 2024 @ 16:38
Notes--- Inclusions: ---
Unlimited buffet food & beverage, including alcoholic beverages
Halal and vegetarian food
Wi-Fi connection & flight monitor
International TV channels, newspapers and magazines
Disabled Access
Smoking Room
--- Questions and answers: ---
Departure Flight No : qr817
Departure Airline : qatar
Pick up Location : hkg
Departure Time : 19:10
Viator amount: USD 37.41
",
+
+ "abstract": "阿坝州九寨岷江国际旅行社有限责任公司……",
+ "replyToEmail": "leioting@hotmail.com",
+ "replyToName": "LYT",
+ "senderEmail": "",
+ "senderName": "",
+ "bCopyEmail1": "",
+ "priority": "",
+ "sent": "2024-02-21T03:37:33.000Z",
+ "#": "#"
+ },
+ "date": "2024-02-21T03:37:33.000Z",
+ "dateText": "02-21 11:37",
+ "localDate": "2024-02-21 11:37:33",
+ "from": "ycc@hainatravel.com",
+ "sender": "example@test.com",
+ "senderName": "example@test.com",
+ "replyButton": true,
+ "status": "",
+ "dateString": "",
+ "statusCN": "",
+ "statusTitle": "",
+ "whatsapp_msg_type": ""
+}
diff --git a/src/views/Conversations/Online/Components/emailSent.json b/src/views/Conversations/Online/Components/emailSent.json
new file mode 100644
index 0000000..88f3cc5
--- /dev/null
+++ b/src/views/Conversations/Online/Components/emailSent.json
@@ -0,0 +1,50 @@
+{
+ "conversationid": 2983,
+ "sn": 14201,
+ "msg_direction": "outbound",
+ "msgtime": "2024-02-21T11:37:33",
+ "msgtext_AsJOSN": {},
+ "msgtype": "email",
+ "template_AsJOSN": {},
+ "messageorigin_AsJOSN": [],
+ "messageorigin_direction": "outbound",
+ "orgmsgtime": "2024-02-22T00:41:30",
+ "msgOrigin": {},
+ "id": "emailid.qjMVpfPuxd8cwKs9o3bGIgYL6SWinB5vHRyQX1ZTU4OmeEAtDk07zaF=",
+ "text": "",
+ "title": "",
+ "type": "email",
+ "emailOrigin": {
+ "id": "emailid.qjMVpfPuxd8cwKs9o3bGIgYL6SWinB5vHRyQX1ZTU4OmeEAtDk07zaF=",
+ "status": "read",
+ "fromName": "LYT-HN",
+ "fromEmail": "lyt@hainatravel.com",
+ "toName": "LYT",
+ "toEmail": "ldj_yt@outlook.com",
+ "cc": "lioyjun@gmail.com",
+ "bcc": "",
+ "subject": "发送示例",
+ "content": "发送示例发送示例发送示例发送示例",
+ "abstract": "发送示例发送示例发送示例发送示例发送示例……",
+ "replyToEmail": "lyt@hainatravel.com",
+ "replyToName": "LYT-HN",
+ "senderEmail": "",
+ "senderName": "",
+ "bCopyEmail1": "",
+ "priority": "",
+ "sent": "2024-02-21T03:37:33.000Z",
+ "#": "#"
+ },
+ "date": "2024-02-21T03:37:33.000Z",
+ "dateText": "02-21 11:37",
+ "localDate": "2024-02-21 11:37:33",
+ "from": "lyt@hainatravel.com",
+ "sender": "me",
+ "senderName": "me",
+ "replyButton": true,
+ "status": "sent",
+ "dateString": "",
+ "statusCN": "",
+ "statusTitle": "",
+ "whatsapp_msg_type": ""
+}
diff --git a/src/views/Conversations/Online/ConversationBind.jsx b/src/views/Conversations/Online/ConversationBind.jsx
index 74a881b..5e83f6d 100644
--- a/src/views/Conversations/Online/ConversationBind.jsx
+++ b/src/views/Conversations/Online/ConversationBind.jsx
@@ -17,7 +17,7 @@ const fetchBindOrder = async (params) => {
// return errcode !== 0 ? {} : result;
};
-export const ConversationBindFormModal = ({ mobile, currentConversationID, userId, onBoundSuccess }) => {
+export const ConversationBindFormModal = ({ currentConversationID, userId, onBoundSuccess }) => {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false); // bind loading
@@ -119,7 +119,7 @@ export const ConversationBindFormModal = ({ mobile, currentConversationID, userI
现在关联
}
{
- 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 (
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+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 && }
+
{/* } size={'middle'} />
} size={'middle'} />
@@ -306,7 +315,15 @@ const InputComposer = ({ mobile }) => {
{/* */}
{textPlaceHolder}
-
} disabled={!textabled || pastedUploading}>
+
}
+ disabled={!textabled || pastedUploading}
+ className={ButtonStyleClsMapped[channel]
+ }>
Send
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);
+ }}
+ >
+ Maximize
+ {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.text}
-
- ) : btn.type.toLowerCase() === 'phone_number' ? (
-
- {btn.text} ({btn.phone_number})
-
- ) : (
-
- {btn.text}
-
- )
- )}
-
- ) : 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) => (
- handleContactItemClick(contact)} type='link' key={contact.id}>{contact.name}: {contact.wa_id}
+ handleContactItemClick(contact)} type='link' key={contact.id}>
+ {contact.name}: {contact.wa_id}
+
))}
{ 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',
},