Compare commits

..

36 Commits

Author SHA1 Message Date
Lei OT a3d0a4f8f9 邮件编辑: 字体 9 months ago
Lei OT 7314895549 邮件编辑: 字体颜色; 背景颜色 9 months ago
Lei OT d36c9d5d43 邮件编辑: Tab 缩进层级 9 months ago
Lei OT 6c29f334bf feat: 切换邮件发件地址 9 months ago
Lei OT 8f3c25723d feat: 消息筛选: 重发 9 months ago
Lei OT 88cc2eb3ae feat: 移动端: 切换渠道 9 months ago
Lei OT 4177dd874c 消息筛选: 复制文件地址 9 months ago
Lei OT cae2a353d2 style: 移动端: 邮件阅读/回复; 消息筛选等 9 months ago
Lei OT b2884ce0a0 style: 位置消息 9 months ago
Lei OT 3f244729f1 feat: 编辑会话; 联系方式相关字段有值时不允许修改; todo: 会话列表显示 9 months ago
Lei OT f7c717c5c1 style: ChatWindow 删除左折叠按钮 9 months ago
Lei OT 1f91520f1b 组件: 可拖拽的弹窗 9 months ago
Lei OT 3f36c648f9 消息筛选: 实时请求; 打开视频播放 9 months ago
Lei OT b0a8c149a1 feat: 历史记录: 增加渠道和类型 9 months ago
Lei OT b37fb2c1aa feat: 消息筛选: 图片按日期, 9 months ago
Lei OT 18a499c7e2 fix: 消息,识别链接 9 months ago
Lei OT bdedbc4781 feat: 邮件编辑: 切换纯文本; 切换发件人; 消息筛选 9 months ago
Lei OT 912c49cddd # 9 months ago
Lei OT 7d951c037a feat: WABA 切换账户 9 months ago
Lei OT 8dd49be1cd feat: 在线: 会话中的消息分类筛选: 图片,视频,音频, 文件,邮件 9 months ago
Lei OT 9f37d32972 style: WhatsApp, WABA 9 months ago
Lei OT 720b388523 perf: WhatsApp error, 131049 号码风控 9 months ago
Lei OT ce07651e6a feat: 邮件详情页: 回复, 转发 9 months ago
Lei OT eea8457c36 feat: 邮件回复: 附带原文 9 months ago
Lei OT 00b8f940dc feat: 邮件阅读 9 months ago
Lei OT 7f4cafe724 feat: 邮件编辑 9 months ago
Lei OT ae5c210c28 根据最后一条消息切换渠道; Email气泡 10 months ago
Lei OT dd6d3e686e todo: 标签筛选; 订单标记筛选; 10 months ago
Lei OT 630c8e78f5 Merge branch '2.0/feat' into 2.0/email
# Conflicts:
#	src/components/Icons.jsx
10 months ago
Lei OT 305502920d todo: 会话右键菜单; 标签筛选; 订单标记筛选; 会话列表显示 10 months ago
Lei OT f54c1e790a style: 图片消息 10 months ago
Lei OT 065563b0c3 todo: 会话右键菜单; 标签筛选; 订单标记筛选; 会话列表显示 10 months ago
Lei OT 0408b796eb test: Lexical editor 10 months ago
Lei OT 03e437a075 feat: 多渠道回复框 10 months ago
Lei OT 0a90396b6a todo: EmailComposer, WABIcon 11 months ago
Lei OT 376015d287 todo: EmailComposer, WABIcon 11 months ago

@ -7,7 +7,7 @@ module.exports = {
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['node_modules/*', 'dist', '.eslintrc.cjs'],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],

12
.gitignore vendored

@ -13,9 +13,7 @@ dist-ssr
*.local
distTest
dev-dist
tmp
schema*
.gitkeep
tmp
# Editor directories and files
.vscode/*
@ -31,11 +29,3 @@ schema*
/package-lock.json
**/LexicalEditor0
*.zip
.env.*
vonage-client*
**/test
*.bak

@ -1,18 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 200,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"quoteProps": "as-needed",
"jsxSingleQuote": true,
"bracketSpacing": true,
"jsxBracketSameLine": true,
"singleAttributePerLine": false,
"arrowParens": "always",
"requirePragma": false,
"insertPragma": false,
"proseWrap": "preserve",
"htmlWhitespaceSensitivity": "ignore"
}

@ -1,32 +1,17 @@
# Global sales
销售平台通过邮件、WhatsApp Business Account、WhatsApp 和客人沟通。
聊天式销售平台
## 开发设置
所有命令都在 cmd 目录,
1. 安装组件npm install
2. 运行开发环境dev.bat
3. 打包代码build.bat
2. 运行开发环境npm run dev 或者 start.bat
3. 打包代码npm run build 或者 build.bat
## 版本设置
遵循 [Semantic Versioning 2.0.0](http://semver.org/lang/zh-CN/) 语义化版本规范。
修订版本号:日常 bugfix 更新。(如果有紧急的 bugfix则任何时候都可发布
次版本号:有新特性的向下兼容的版本。
主版本号:含有破坏性更新和新特性。
npm version [<newversion> | major | minor | patch | premajor | preminor | prepatch | prerelease | from-git]
npm version preminor --preid alpha --no-git-tag-version
npm version preminor --preid beta --no-git-tag-version
1.2.0 -> 1.3.0-beta.0
npm version premajor --no-git-tag-version
1.0.0 -> 2.0.0-0
npm version prerelease --no-git-tag-version
2.0.0-0 -> 2.0.0-1 -> 2.0.0-2 ..n -> 2.0.0-n
npm version patch --no-git-tag-version
@ -37,10 +22,5 @@ npm version patch --no-git-tag-version
[聊天式销售平台需求文档](https://www.kdocs.cn/l/calaUjgmCmDA?from=docs&reqtype=kdocs&startTime=1703645330177&createDirect=true&newFile=true)
## vonage语音视频
安装模块 npm i @vonage/client-sdk
## 本机测试账号
GLOBAL_SALES_LOGIN_USER
{"userId":"383","userIdStr":"383,609","emailList":[{"opi_sn":383,"mat_sn":760,"email":"lyj@asiahighlights.com","default":false,"backup":false},{"opi_sn":383,"mat_sn":759,"email":"lyj@chinahighlights.com","default":false,"backup":false},{"opi_sn":383,"mat_sn":758,"email":"lyj@hainatravel.com","default":false,"backup":false},{"opi_sn":383,"mat_sn":761,"email":"lyj@chinahighlights.net","default":true,"backup":false}],"username":"廖一军","avatarUrl":"https://static-legacy.dingtalk.com/media/lALPBDDrhXr716HNAoDNAoA_640_640.png","mobile":"+86-18777396951","email":"lyj@hainatravel.com","whatsAppBusiness":"8617458471254","openId":"iioljiPmZ4RPoOYpkFiSn7IKAiEiE","accountList":[{"OPI_SN":383,"OPI_Code":"LYJ","OPI_NameCN":"廖一军","OPI_DEI_SN":7,"OPI_NameEN":"Jimmy Liow"},{"OPI_SN":609,"OPI_Code":"LYJAH","OPI_NameCN":"廖一军ah","OPI_DEI_SN":28,"OPI_NameEN":"Jimmy Liow"}]}

@ -1 +0,0 @@
npm run build

@ -1 +0,0 @@
npm version patch

@ -1 +0,0 @@
npm version prerelease

@ -1,83 +0,0 @@
## 发送template 消息
### 模板原文
```json
{
"wabaId": "190290134156880",
"name": "order_updated_specialist_assigned_sharon",
"language": "en",
"messageSendTtlSeconds": -1,
"components": [
{
"type": "BODY",
"text": "Hi {{customer_name}}, this is {{your_name1}} your trip advisor. \nHow are you? {{free_style}} \nKindest regards, {{your_name2}}. ",
"example": {
"body_text": [
[
"Mike",
"Coco",
"I'm following up on your itinerary",
"Coco2"
]
]
}
}
],
"category": "UTILITY",
"status": "APPROVED",
"qualityRating": "UNKNOWN",
"reason": "NONE",
"createTime": "2024-12-11T08:31:11.290Z",
"updateTime": "2024-12-11T08:31:34.610Z",
"statusUpdateEvent": "APPROVED"
}
```
### 发送模板消息, 数据结构
```json
{
"action": "message", // 固定值
"actionId": "190.3c8322de-68df-4f9e-be8e-d92e7a13b7d7", // .uuid()
"renderId": "190.3c8322de-68df-4f9e-be8e-d92e7a13b7d7", // 忽略
"externalId": 190, // 留空
"to": "8613557032060",
"from": "+8617607730395",
"msgtype": "template", // 固定值
"msgcontent": {
"name": "order_updated_specialist_assigned_sharon", // 模板名称. 从原文
"language": {
"code": "en" // 语言. 从原文
},
"components": [ // 没有参数的组件, 建议不传. 组件顺序也会报错
{
"type": "body",
"parameters": [ // 模板参数. 按 当前 `type` 中的参数顺序排列
{
"type": "text",
"text": "test OT 22" // 参数值: 不能换行, 连续空格<4
},
{
"type": "text",
"text": "yoyo"
},
{
"type": "text",
"text": "111"
},
{
"type": "text",
"text": "yoyo"
}
],
"text": "Hi test OT 22, this is yoyo your trip advisor. \nHow are you? 111 \nKindest regards, yoyo. " // 拼接一个完整内容
}
]
},
"opi_sn": "404", // 发送人 ID
"coli_sn": 1082591,
"conversationid": 190 // 忽略
}
```

Binary file not shown.

@ -1,10 +1,3 @@
## 查找出掉线的 WhatsApp
select *
from whatsapp_individual.connections
where status IN ('offline')
and wa_id not in ('8618777396951', '8613557032060','8613317835586')
and wa_id <> 'null'
//
SELECT group_concat(opi_sn separator ',') as 'sn_list' FROM (
SELECT
@ -123,60 +116,3 @@ WHERE tos = '5534999923993' AND opi_sn = 587;
* end
* ---------------------------------------------------------
*/
/**
WhatsApp
*/
select * from whatsapp_user
##where wau_opi_sn in (252, 261,264,265,330,360,376,413,421,453,605,620) ## 国际部
where wau_opi_sn in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587,354, 414, 599, 606, 639, 648, 654, 662, 674, 676,391, 451, 476, 501, 512, 525, 528, 585, 586, 644) ## GH
-- 查找每个服务器在线的 WA 数量
select connect_name, count(*)
from whatsapp_individual.connections
where status IN ('open')
group by connect_name
set SESSION group_concat_max_len=4294967295;
-- 查找已经配置 WAI 服务的顾问
select group_concat(wau_whatsapp separator ''',''') as 'sn_list'
from sale_system.whatsapp_user
where wau_wai_server is not null
## 查找在线的 WhatsApp
SELECT group_concat(sesson_id separator ''',''') as 'sesson_list'
FROM whatsapp_individual.connections
where status = 'open'
-- 查找掉线的顾问
select group_concat(sesson_id separator ''',''') as 'sesson_list' from whatsapp_individual.connections
where status IN ('offline')
and wa_id not in ('8618777396951', '8613557032060','8613317835586')
## 查找 GH 没有扫码登录的顾问
select group_concat(wau_opi_sn separator ',') as 'sn_list'
from whatsapp_user
where wau_whatsapp not in ('8613317835586','8617607732272','8613978392676','8618378304803','8617607730629','8619107833371','8619107835971','8617607731491','8615080129281','8617607737720','8618777396951','8618078444860','8615778462307','8617774702925','8615078398450','8619178340224','8617607731153','8617607735120','8617607737646','8618877388203','8615778493040','8613617733956','8618290167273','8617776515283','8617607736381','8613557032060','8613667839691','8618378388403','8613635132972','8617607734598','8617607732512','8615878340720')
and wau_opi_sn in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587,354, 414, 599, 606, 639, 648, 654, 662, 674, 676,391, 451, 476, 501, 512, 525, 528, 585, 586, 644)
-- 查找使用 WhatsApp 顾问信息
SELECT
OPI_SN, OPI_Code,OPI_Name,OPI_DEI_SN,OPI_FirstName,OPI_RealName, DeleteFlag
FROM
dbo.OperatorInfo WHERE
OPI_SN in (143,476,528,391)
--OPI_SN in (495, 143, 370, 114, 513, 514, 517, 522, 550, 587)
SELECT OPI_RealName + '(' + CAST(OPI_SN AS VARCHAR(100)) + ')',OPI_SN
FROM
dbo.OperatorInfo
where
--DeleteFlag = 0 and
OPI_SN in (79,85,114,119,135,143,155,162,178,210,216,222,225,252,261,264,265,273,293,296,311,330,343,347,348,354,360,370,376,387,391,412,413,414,421,441,444,451,453,456,466,468,476,495,497,501,509,512,513,514,517,519,522,525,527,528,539,550,573,585,586,587,592,599,600,605,606,611,617,620,639,644,648,654,656,659,662,663,674,676,690,691)
and OPI_RealName in ('兰芬','孙俊垚','王继伟','曾君','潘宏宇','郑美珍','张丽娟','张倩倩','赵泽菲','王影','陆力影','吕燕珍','何秋云','沈慧香')

File diff suppressed because it is too large Load Diff

@ -1,46 +1,53 @@
{
"name": "global-sales",
"private": true,
"version": "1.5.0-1",
"version": "1.1.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:patch": "npm version patch && npm run build",
"build:minor": "vnpm version minor && npm run build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@dckj/react-better-modal": "^0.1.2",
"@lexical/react": "^0.20.0",
"@vonage/client-sdk": "^2.0.0",
"antd": "^5.25.2",
"dayjs": "^1.11.13",
"dingtalk-jsapi": "^3.0.41",
"emoji-picker-react": "^4.12.0",
"lexical": "^0.20.0",
"react": "^18.3.1",
"react-chat-elements": "^12.0.17",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.1",
"@lexical/react": "^0.17.1",
"@vonage/client-sdk": "^1.6.0",
"antd": "^5.14.0",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.10",
"emoji-picker-react": "^4.8.0",
"lexical": "^0.17.1",
"re-resizable": "^6.9.18",
"react": "^18.2.0",
"react-chat-elements": "^12.0.11",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"react-quill": "^2.0.0",
"react-rnd": "^10.4.12",
"react-router-dom": "^6.21.1",
"rxjs": "^7.8.1",
"uuid": "^9.0.1",
"zustand": "^4.5.7"
"vite-plugin-pwa": "^0.19.6",
"zustand": "^4.5.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.2.1",
"@vonage/client-sdk": "^1.6.0",
"autoprefixer": "^10.4.17",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.14",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.15",
"eslint-plugin-react-refresh": "^0.4.3",
"postcss": "^8.4.33",
"tailwindcss": "^3.4.1",
"vite": "^4.5.1",
"vite-plugin-css-modules": "^0.0.1",
"vite-plugin-pwa": "^0.21.0",
"vite-plugin-svgr": "^4.3.0",
"vite-plugin-svgr": "^4.2.0",
"vite-plugin-windicss": "^1.9.3",
"windicss": "^3.5.6"
}

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8 19.9967V14.9967H10V19.9967H19V12.9967H5V19.9967H8ZM4 10.9967H20V7.9967H14V3.9967H10V7.9967H4V10.9967ZM3 20.9967V12.9967H2V6.9967C2 6.44442 2.44772 5.9967 3 5.9967H8V2.9967C8 2.44442 8.44772 1.9967 9 1.9967H15C15.5523 1.9967 16 2.44442 16 2.9967V5.9967H21C21.5523 5.9967 22 6.44442 22 6.9967V12.9967H21V20.9967C21 21.549 20.5523 21.9967 20 21.9967H4C3.44772 21.9967 3 21.549 3 20.9967Z"></path></svg>

Before

Width:  |  Height:  |  Size: 491 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"></path></svg>

Before

Width:  |  Height:  |  Size: 555 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>

Before

Width:  |  Height:  |  Size: 640 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>

Before

Width:  |  Height:  |  Size: 223 B

@ -1,161 +1,55 @@
import { groupBy, isNotEmpty, pick, sortArrayByOrder, sortBy } from '@/utils/commons';
import { groupBy, pick, sortArrayByOrder } from '@/utils/commons';
import { fetchJSON, postJSON, postForm } from '@/utils/request'
import { parseRenderMessageList } from '@/channel/bubbleMsgUtils';
import { parseRenderMessageList } from '@/channel/whatsappUtils';
import { API_HOST } from '@/config';
import { isEmpty } from '@/utils/commons';
import dayjs from 'dayjs';
/**
* @param {object} params { waba }
*/
export const fetchTemplates = async (params) => {
const data = await fetchJSON(`${API_HOST}/listtemplates`, params);
const topName = [
'agent_intro_with_update_v1',
'online_inquiry_received',
'say_hello_again',
'order_updated_specialist_assigned_christy',
'order_resumed_specialist_followup_schedule_sharon',
'travel_service_update_v2',
'travel_service_update_v1',
'order_updated_specialist_assigned_sharon',
'first_message_for_not_reply',
// 'free_style_3',
// 'free_style_4',
];
// shouwcase
const scNames = ['trip_planner_showcase', 'showcase_different', 'order_status_updated'];
// 客运
const crNames = [
'notification_of_next_trip_planning',
// 'notification_of_following_up_by_cr_v3',
'notification_of_one_day_before_ending_the_trip_by_cr_v2',
'one_day_after_payment_by_yuni',
'notification_of_status_changed',
'notification_of_one_day_before_ending_the_trip_by_cr','one_day_after_payment_by_customer_relations',
'one_day_before_ending_the_trip_contacted_by_yuni','one_day_before_ending_the_trip_first_time_by_yuni',
'post_booking_confirmation_welcome',
];
const crNamesOmit = [
'birthday_greetings_by_marketing','one_day_before_ending_the_trip_by_marketing',
'introduce_the_voucher_one_day_before_ending_the_trip_by_marketing',
'birthday_greetings_by_customer_relations_0',
'post_trip_voucher_issued',
'account_updated_order_ref',
'post_trip_account_updated_from_cr',
'post_trip_account_updated',
'account_update_birthday',
'post_trip_birthday_reward',
'birthday_greetings_by_customer_relations_2',
'birthday_greetings_by_customer_relations_1',
'notification_of_account_updated_by_cr',
'birthday_greetings_by_customer_relations',
'one_day_before_ending_the_trip_by_customer_relations',
]
export const fetchTemplates = async () => {
const data = await fetchJSON(`${API_HOST}/listtemplates`);
const canUseTemplates = (data?.result?.items || [])
.filter((_t) => _t.status === 'APPROVED' && !['say_hello_from_trip_advisor', 'free_style_7', 'free_style_1', 'free_style_2'].includes(_t.name))
.map((ele, i) => ({
...ele,
components_origin: ele.components,
components: groupBy(ele.components, (_c) => _c.type.toLowerCase()),
key: ele.name,
// displayName: ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated']+`_${i}` : templatesDisplayNameMap?.[ele.name] || ele.name,
displayName: templatesDisplayNameMap?.[ele.name] || (ele.name.startsWith('order_updated') ? templatesDisplayNameMap['order_updated']+`_${i}` : ele.name),
displayLanguage: crNamesOmit.includes(ele.name) ? '客运-' : (crNames.includes(ele.name) || ele.name.includes('by_cr')) ? ele.language + '-客运' : scNames.includes(ele.name) ? ele.language + '-示例' : ele.language,
}))
const top2Name = topName.concat(canUseTemplates.filter(_t => _t.name.startsWith('order_updated')).map(_tem => _tem.name));
const top = sortArrayByOrder( canUseTemplates.filter((_t) => top2Name.includes(_t.name)), 'name', topName);
const second = canUseTemplates.filter(_t => _t.name.includes('free_style'));
const secondS = second.sort(sortBy('name'));
const raw = canUseTemplates.filter((_t) => !top2Name.includes(_t.name) && !_t.name.includes('free_style'));
// 剩下的排序
const rawS = sortArrayByOrder(raw, 'name', [...crNames, ...scNames, ...crNamesOmit ]);
return [...top, ...secondS, ...rawS];
};
/**
* 上面的模板名称bak
* order_updated_specialist_assigned_sharon : free_style_7
* order_updated_specialist_assigned_christy : free_style_1
* online_inquiry_received: say_hello_from_trip_advisor
* order_resumed_specialist_followup_schedule_sharon: free_style_2
*/
const templatesDisplayNameMap = {
'order_updated_specialist_assigned_sharon': 'specialist_followup',
'order_updated_specialist_assigned_christy': 'specialist_followup_1',
'online_inquiry_received': 'online_inquiry_received/say_hello',
'order_resumed_specialist_followup_schedule_sharon': 'order_resumed/specialist_followup',
'order_updated': 'specialist_followup',
'agent_intro_with_update_v1': 'quick_update_v1',
.filter((_t) => _t.status === 'APPROVED')
.map((ele) => ({ ...ele, components_origin: ele.components, components: groupBy(ele.components, (_c) => _c.type.toLowerCase()) }));
const topName = ['free_style_7', 'say_hello_from_trip_advisor', 'free_style_2', 'free_style_1', 'free_style_3', 'free_style_4'];
const top = sortArrayByOrder( canUseTemplates.filter((_t) => topName.includes(_t.name)), 'name', topName);
const raw = canUseTemplates.filter((_t) => !topName.includes(_t.name));
return [...top, ...raw];
};
export const CONVERSATION_PAGE_SIZE = 100;
/**
*
* @param {object} params { opisn }
*/
export const fetchConversationsList = async (params) => {
const defaultParams = {
opisn: '',
pagesize: CONVERSATION_PAGE_SIZE,
lastpagetime: '',
tags: '',
olabel: '',
keyword: '',
ostate: '',
intour: '',
session_enable: 1,
lastactivetime: '',
top_state: '',
}
const combinedFilterStr = Object.values(pick(params, ['keyword', 'tags', 'olabel', 'intour', 'ostate'])).join('')
if (isNotEmpty(combinedFilterStr) || params.session_enable === 0) {
params.lastactivetime = '';
}
const { errcode, result: data } = await fetchJSON(`${API_HOST}/getconversations`, { ...defaultParams, ...params })
const { errcode, result: data } = await fetchJSON(`${API_HOST}/getconversations`, params);
if (errcode !== 0) return [];
const list = (data || []).map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
}))
const list = (data || []).map((ele) => ({ ...ele, customer_name: `${ele.whatsapp_name || ''}`.trim(), whatsapp_name: `${ele.whatsapp_name || ''}`.trim() }));
return list;
};
/**
*
* @param {object} params { opisn, whatsappid, colisn, email }
* @param {object} params { opisn, whatsappid, colisn }
* * opisn, colisn : 用于查询
* * whatsappid, email: 用于创建会话
* * whatsappid: 用于创建会话
*/
export const fetchOrderConversationsList = async (params) => {
const { errcode, result: data } = await fetchJSON(`${API_HOST}/getorderconversation`, params);
if (errcode !== 0) return [];
const list = data.map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
}))
const list = data.map((ele) => ({ ...ele, customer_name: `${ele.whatsapp_name || ''}`.trim(), whatsapp_name: `${ele.whatsapp_name || ''}`.trim() }));
return list;
};
export const MESSAGE_PAGE_SIZE = 50;
/**
*
* @param {object} params { coli_sn, opisn, whatsappid, conversationid, lasttime, pagesize }
* @param {object} params { opisn, whatsappid, lasttime, pagesize }
*/
export const fetchMessages = async (params) => {
const defaultParams = {
// opisn: '',
// whatsappid: '',
coli_sn: '',
conversationid: '',
opisn: '',
whatsappid: '',
lasttime: '',
pagesize: MESSAGE_PAGE_SIZE,
};
@ -175,8 +69,7 @@ export const fetchConversationItemClose = async (body) => {
/**
* @param {object} body { phone_number, name }
*/
export const postNewOrEditConversationItem = async (body) => {
body.whatsapp_phone_number = `${body.whatsapp_phone_number || ''}`.trim();
export const postNewConversationItem = async (body) => {
const formData = new FormData();
Object.keys(body).forEach(function (key) {
formData.append(key, body[key]);
@ -190,24 +83,11 @@ export const postNewOrEditConversationItem = async (body) => {
...resultItem,
customer_name: `${resultItem.whatsapp_name || ''}`.trim(),
whatsapp_name: `${resultItem.whatsapp_name || ''}`.trim(),
// channels: {},
// tags: [],
// last_message: {},
top_state: 0,
// conversation_memo: resultItem.session_memo,
};
};
/**
* @param {object} params { conversationid, coli_sn }
*/
export const postEditConversationItemColiAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/update_conversation_orderid`, params);
return errcode !== 0 ? {} : result;
};
/**
* @param {object} params { opisn, conversationid }
* @param {object} params { opisn, whatsappid }
*/
export const fetchCleanUnreadMsgCount = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/clean_unread_msg_count`, params);
@ -228,7 +108,7 @@ export const fetchConversationItemUnread = async (body) => {
* @param {object} body { conversationid, top_state }
*/
export const fetchConversationItemTop = async (body) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/set_top_conversation`, body);
const { errcode, result } = await fetchJSON(`${API_HOST}/set_state_top`, body);
return errcode !== 0 ? {} : result;
};
@ -236,6 +116,7 @@ export const fetchConversationItemTop = async (body) => {
* ------------------------------------------------------------------------------------------------
* 历史记录
*/
export const CONVERSATION_PAGE_SIZE = 20;
/**
* @param {object} params { search, from_date, end_date, whatsapp_id, opisn, coli_id, msg_type }
* @todo msg_type
@ -253,10 +134,9 @@ export const fetchConversationsSearch = async (params) => {
opi_sn: ele.OPI_SN || ele.opi_sn || 0,
OPI_Name: `${ele.OPI_Name || ele.opi_name || ''}`.trim(),
opi_name: `${ele.OPI_Name || ele.opi_name || ''}`.trim(),
dateText: dayjs((ele.lasttime)).format('MM-DD HH:mm'),
dateText: dayjs((ele.lasttime || ele.lasttime)).format('MM-DD HH:mm'),
matchMsgList: parseRenderMessageList((ele.msglist_AsJOSN || [])), // .reverse()),
coli_id: '',
show_default: ele.session_memo || ele.whatsapp_name || ele?.whatsapp_phone_number || ele?.guest_email || '',
}));
return list;
};
@ -267,15 +147,14 @@ export const fetchConversationsSearch = async (params) => {
*/
export const fetchMessagesHistory = async (params) => {
const defaultParams = {
// opisn: '',
// whatsappid: '',
conversationid: '',
opisn: '',
whatsappid: '',
lasttime: '2024-01-01T00:00:00',
pagesize: MESSAGE_PAGE_SIZE,
pagedir: 'next',
};
const _params = pick(params, Object.keys(defaultParams));
if (isEmpty(_params.conversationid)) {
if (isEmpty(_params.whatsappid)) {
return [];
}
const { errcode, result } = await fetchJSON(`${API_HOST}/get_item_messages`, {...defaultParams, ..._params});
@ -329,8 +208,12 @@ export const postAssignConversation = async (params) => {
* @param {object} params { opisn, }
*/
export const fetchTags = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/get_opi_tags`, params);
return errcode !== 0 ? [] : result.map(ele => ({ label: ele.tag_label, key: ele.tag_key, value: ele.tag_key, }));
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;
}
/**
* 会话设置标签
@ -341,14 +224,26 @@ export const postConversationTags = async (body) => {
Object.keys(body).forEach(function (key) {
formData.append(key, body[key]);
});
const { errcode, result } = await postForm(`${API_HOST}/set_conversation_tags_add`, formData);
return errcode !== 0 ? {} : result[0];
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}/set_conversation_tags_del`, 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;
}

@ -1,491 +0,0 @@
import { fetchJSON, postForm, postJSON } from '@/utils/request';
import { API_HOST, API_HOST_V3, DATE_FORMAT, DATEEND_FORMAT, DATETIME_FORMAT, EMAIL_HOST, EMAIL_HOST_v3 } from '@/config';
import { buildTree, groupBy, isEmpty, objectMapper, omitEmpty, uniqWith } from '@/utils/commons';
import { readIndexDB, writeIndexDB } from '@/utils/indexedDB';
import dayjs from 'dayjs';
import { internalEventEmitter } from '@/utils/EventEmitterService';
export const parseHTMLString = (html, needText = false) => {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
let bodyContent = doc.body.innerHTML
// bodyContent = bodyContent.replace(/<img/g, '<img onerror="this.onerror=null;this.src=\'https://hiana-crm.oss-accelerate.aliyuncs.com/WAMedia/afe412d4-3acf-4e79-a623-048aeb4d696a.png\';"')
const bodyText = (doc.body.innerText);
return needText ? { html, bodyContent, bodyText } : bodyContent
}
export const EMAIL_CHANNEL_NAME = 'mailbox_changes';
let emailChangesChannel = null;
export function getEmailChangesChannel() {
if (!emailChangesChannel) {
emailChangesChannel = new BroadcastChannel(EMAIL_CHANNEL_NAME)
}
return emailChangesChannel
}
// 通知邮件列表数据更新
const notifyMailboxUpdate = (payload) => {
const notificationPayload = payload
// - 多个tab
const channel = getEmailChangesChannel()
channel.postMessage(notificationPayload)
// - 当前tab
internalEventEmitter.emit(EMAIL_CHANNEL_NAME, notificationPayload)
}
/**
* 获取顾问签名
* @param {object} { opi_sn }
*/
export const getSalesSignatureAction = async (params) => {
try {
const { result } = await fetchJSON(`${EMAIL_HOST}/email_sign`, params)
const { SignContent: html } = result
const bodyContent = parseHTMLString(html);
return bodyContent;
} catch (error) {
return '';
}
};
/**
* 发送邮件
*/
export const postSendEmail = async (body) => {
const { attaList=[], atta, content, ...bodyData } = body;
bodyData.ordertype = 227001;
const formData = new FormData();
Object.keys(bodyData).forEach(function (key) {
formData.append(key, bodyData[key]);
});
attaList.forEach(function (item) {
formData.append('attachment', item);
});
const { result } = await postForm(`${EMAIL_HOST}/sendmail`, formData);
return result;
};
/**
* 重发邮件
* @param {object} { mai_sn }
*/
export const postResendEmailAction = async (body) => {
const { attaList, atta, content, ...bodyData } = body;
const formData = new FormData();
Object.keys(bodyData).forEach(function (key) {
formData.append(key, bodyData[key]);
});
return await postForm(`${EMAIL_HOST}/email_resend`, formData);
};
const encodeEmailInfo = (info) => {
const encodeQuote = (str = '') => str.replace(/"/g, ''); //.replace(/</g,'&lt;').replace(/>/g,'&gt;')
const CSsClean = encodeQuote(info.MAI_CS).includes(',') ? encodeQuote(info.MAI_CS).split(',') : encodeQuote(info.MAI_CS).split(';');
const tosClean = (encodeQuote(info.MAI_To).includes(',') ? encodeQuote(info.MAI_To).split(',') : encodeQuote(info.MAI_To).split(';')).concat(CSsClean).filter(s => s);
const replyTo = info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From;
const replyToAll = (tosClean.length > 1) ?
(info.MAI_Direction === 1 ? tosClean.join(',') : [...tosClean, info.MAI_From].join(','))
: (info.MAI_Direction === 1 ? info.MAI_To : info.MAI_From)
return {
...info,
MAI_From: encodeQuote(info.MAI_From),
MAI_To: encodeQuote(info.MAI_To),
tos: [...new Set(tosClean)],
replyToAll,
replyTo,
}
};
/**
* 邮件详情
* @param {object} { mai_sn }
*/
export const getEmailDetailAction = async (params) => {
// const cacheKey = params.mai_sn;
// const readCache = await readIndexDB(cacheKey, 'mailinfo', 'mailbox');
// if (!isEmpty(readCache)) { // todo: 除了草稿
// return readCache.data;
// }
const { result } = await fetchJSON(`${EMAIL_HOST}/getmail`, params);
let mailType = result.MailInfo?.[0]?.MAI_ContentType || '';
mailType = mailType === '' && (result.MailContent||'').includes('<html') ? 'text/html' : mailType;
const emailInfo = encodeEmailInfo(result.MailInfo?.[0] || {});
const isFromHub = emailInfo.MAI_From.includes('info@chinahighlights.net');
const delLinefeed = mailType === 'text/html' ? (result.MailContent||'').includes('<html') ? true : false : true;
const cleanContent = (result.MailContent || '').replace(/\r\n/g, delLinefeed ? '' : (isFromHub ? '<br>' : ''));
const { html, bodyContent, bodyText } = mailType === 'text/html' ? parseHTMLString(cleanContent, true) : { html: '', bodyContent: '', bodyText: '' };
const attachments = (isEmpty(result?.AttachList) ? [] : result.AttachList).filter(ele => isEmpty(ele.ATI_ContentID) || ele.ATI_ContentID == '0');
const insideAttachments = (isEmpty(result?.AttachList) ? [] : result.AttachList).filter(ele => !isEmpty(ele.ATI_ContentID) && ele.ATI_ContentID != '0');
const ret = {
info: { ...encodeEmailInfo(result.MailInfo?.[0] || {}), mailType },
content: mailType === 'text/html' ? html : result.MailContent || '',
abstract: bodyText || result.MailContent || '',
attachments,
insideAttachments,
AttachList: isEmpty(result?.AttachList) ? [] : result.AttachList,
}
// writeIndexDB([{key: cacheKey, data: ret}], 'mailinfo', 'mailbox')
return ret;
}
export const getEmailOrderAction = async ({ colisn }) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getorderinfo`, { colisn })
return errcode === 0 ? { ...result[0], customerDetail: result[0].contact[0] } : {}
}
/**
* 主动收邮件, 单个账户
* @param {object} { opi_sn, }
*/
export const getEmailFetchAction = async (params) => {
const { opi_sn, } = params
const { result } = await fetchJSON(`${EMAIL_HOST}/email_fetch`, {
opi_sn,
})
return result
};
/**
* 报价信邮件草稿
* @param {object} { sfi_sn, coli_sn, lgc }
*/
export const getEmailQuotationDraftAction = async (params) => {
const { result } = await fetchJSON(`${EMAIL_HOST}/QuotationLetter`, params)
return { subject: (result.Subject || ''), content: parseHTMLString((result.MailContent || '').replace(/\r\n/g, '')) }
}
/**
* 单个邮件绑定订单
* @param {object} { conversationid, mai_sn, coli_sn, coli_id, sourcetype }
*/
export const fetchEmailBindOrderAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/mailinfo_bindorder`, params)
return errcode === 0 ? true : false;
}
const todoTypes = {
// 1新订单2WhatsApp未读消息3需一催4需二催5需三催6未处理邮件入境提醒coli_ordertype=7余款提醒coli_ordertype=8
1: '新订单',
2: '未读WhatsApp',
3: '一催',
4: '二催',
5: '三催',
6: '老邮件',
7: '入境提醒',
8: '余款提醒',
}
/**
* 顾问的邮箱目录
* @param {object} params { opi_sn, year, by_start_date, by_success, important, if_want_book, if_thinking }
* @param {boolean} retOrder 是否直接返回订单列表 -- 忽略
*/
export const getEmailDirAction = async (params = { opi_sn: '' }, retOrder=false) => {
const defaultParams = { opi_sn: 0, year: dayjs().year(), by_start_date: -1, by_success: -1, important: -1, if_want_book: -1, if_thinking: -1 }
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/email_dir`, { ...defaultParams, ...params })
const mailboxSort = result //.sort(sortBy('MDR_Order'));
let tree = buildTree(mailboxSort, { key: 'VKey', parent: 'VParent', name: 'VName', iconIndex: 'ImageIndex', rootKeys: [1], ignoreKeys: [-227001, -227002] })
tree = tree.filter((ele) => ele.key !== 1)
const retTree = errcode === 0 ? tree : [];
const orderList = groupBy(result, row => `${row.IsTrue}`)?.['0'] || [];
return retOrder !== false ? orderList : { [`${params.opi_sn}`]: retTree }
};
export const getMailboxCountAction = async (params = { opi_sn: '' }, update = true) => {
const defaultParams = {
opi_sn: 0,
// date1: dayjs().subtract(1, 'year').startOf('year').format(DATE_FORMAT),
date1: dayjs().subtract(180, 'days').format(DATE_FORMAT),
date2: dayjs().format(DATEEND_FORMAT)
}
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/dir_count`, {...defaultParams, ...params})
const ret = errcode !== 0 ? { [`${params.opi_sn}`]: {} } : { [`${params.opi_sn}`]: result }
// 更新数量
if (update !== false) {
const readCacheDir = (await readIndexDB(Number(params.opi_sn), 'dirs', 'mailbox')) || {};
const mailboxDir = isEmpty(readCacheDir) ? [] : readCacheDir.tree.filter(node => node?._raw?.IsTrue === 1);
const _MapDir = new Map(mailboxDir.map((obj) => [obj.key, obj]))
Object.keys(result).map(dirKey => {
_MapDir.set(Number(dirKey), {..._MapDir.get(Number(dirKey)), count: result[dirKey]});
})
const _newToUpdate = Array.from(_MapDir.values());
const _MapRoot = new Map((readCacheDir?.tree || []).map((obj) => [obj.key, obj]))
_newToUpdate.forEach((row) => {
_MapRoot.set(row.key, row)
})
const _newRoot = Array.from(_MapRoot.values())
writeIndexDB([{ ...readCacheDir, key: Number(params.opi_sn), tree: _newRoot }], 'dirs', 'mailbox')
notifyMailboxUpdate({ type: 'dirs', key: Number(params.opi_sn) })
}
return ret;
};
export const getTodoOrdersAction = async (params) => {
const opi_arr = params.opisn.split(',')
const defaultStickyTree = opi_arr.reduce(
(a, ele) => ({
...a,
[ele]: [
{
key: ele + '-today',
title: '今日任务',
getMails: false,
iconIndex: 'star',
children: [],
COLI_SN: 0,
},
{
key: ele + '-todo',
title: '待办任务',
getMails: false,
iconIndex: 'calendar',
children: [],
COLI_SN: 0,
},
// {
// key: ele.OPI_DEI_SN + '-reminder',
// title: '催信',
// getMails: false,iconIndex: 'reminder',
// icon: <BellTwoTone />,
// children: [], COLI_SN: 0,
// },
],
}),
{},
)
const { errcode, result } = await fetchJSON(`${API_HOST}/getwlorder`, params)
// 订单重复时, 取后一个状态, 因此翻转两次
const _result_unique = uniqWith(result.reverse(), (a, b) => a.COLI_SN === b.COLI_SN).reverse();
const orderList = errcode === 0 ? _result_unique : []
const byOPI = groupBy(orderList, 'OPI_SN')
const byState = Object.keys(byOPI).reduce((acc, key) => {
const sticky = groupBy(byOPI[key], (ele) => ([1, 6].includes(ele.coli_ordertype) ? 0 : [2, 3, 4, 5].includes(ele.coli_ordertype) ? 1 : 2))
const treeNode = [
{
key: key + '-today',
title: '今日任务',
getMails: false,
iconIndex: 'star',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[0] || []).map((o) => ({
key: `today-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-today',
parentTitle: '今日任务',
parentIconIndex: 'star',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-today', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-today' },
})),
},
{
key: key + '-todo',
title: '待办任务',
getMails: false,
iconIndex: 'calendar',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[2] || []).map((o) => ({
key: `todo-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-todo',
parentTitle: '待办任务',
parentIconIndex: 'calendar',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-todo', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-todo' },
})),
},
...(!isEmpty(sticky[1] || [])
? [
{
key: key + '-reminder',
title: '催信',
getMails: false,
iconIndex: 'reminder',
_raw: { COLI_SN: 0, IsTrue: 0 },
children: (sticky[1] || []).map((o) => ({
key: `reminder-${o.COLI_SN}`,
title: `(${todoTypes[o.coli_ordertype] || o.COLI_State}) ${o.COLI_ID}`,
iconIndex: 13,
parent: key + '-reminder',
parentTitle: '催信',
parentIconIndex: 'reminder',
_raw: { ...o, VKey: o.COLI_SN, VName: o.COLI_ID, VParent: key + '-reminder', IsTrue: 0, ApplyDate: '', OrderSourceType: 227001, parent: key + '-reminder' },
})),
},
]
: []),
]
return { ...acc, [key]: treeNode }
}, defaultStickyTree)
return errcode === 0 ? byState : defaultStickyTree
};
/**
* 获取待办目录和邮箱目录
* @param {object} params { opi_sn, userIdStr }
* @param {number} params.opi_sn
* @param {string} params.userIdStr - 用户ID字符串默认为空
*/
export const getRootMailboxDirAction = async ({ opi_sn = 0, userIdStr = '' } = {}) => {
const [stickyTree, ...mailboxDir] = await Promise.all([
getTodoOrdersAction({ opisn: userIdStr || String(opi_sn), otype: 'today' }),
...(userIdStr.split(',').map(_opi => getEmailDirAction({ opi_sn: _opi }))),
])
const mailBoxCount = await Promise.all(userIdStr.split(',').map(_opi => getMailboxCountAction({ opi_sn: _opi }, false)));
const mailboxDirCountByOPI = mailBoxCount.reduce((a, c) => ({ ...a, ...c, }), {})
const mailboxDirByOPI = mailboxDir.reduce((a, c) => ({ ...a, ...(Object.keys(c).reduce((a, opi) => ({...a, [opi]: c[`${opi}`].map((dir) => ({ ...dir, count: mailboxDirCountByOPI[opi][`${dir.key}`] })) }), {} )) }), {})
const rootTree = Object.keys(stickyTree).map((opi) => ({ key: Number(opi), tree: [...stickyTree[opi], ...(mailboxDirByOPI?.[opi] || [])], treeTimestamp: Date.now() }))
writeIndexDB(rootTree, 'dirs', 'mailbox')
const _mapped = groupBy(rootTree, 'key')
return _mapped[opi_sn]?.[0]?.tree || []
}
/**
* 获取邮件列表
* @usage 邮件目录下的邮件列表
* @usage 订单的邮件列表
*/
export const queryEmailListAction = async ({ opi_sn = '', pagesize = 10, last_id = '', node = {}, } = {}) => {
const _params = {
vkey: 0,
vparent: 0,
order_source_type: 0,
// mai_senddate1: dayjs().subtract(1, 'year').startOf('year').format(DATE_FORMAT),
mai_senddate1: dayjs().subtract(180, 'days').format(DATE_FORMAT),
mai_senddate2: dayjs().format(DATEEND_FORMAT),
...omitEmpty({
...node,
opi_sn,
}),
}
_params.mai_senddate1 = dayjs(_params.mai_senddate1).format(DATE_FORMAT)
const cacheKey = isEmpty(_params.coli_sn) ? `dir-${node.vkey}` : `order-${node.vkey}`;
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/mail_list`, _params)
const ret = errcode === 0 ? result : []
if (!isEmpty(ret)) {
const listids = [...new Set(ret.map(ele => ele.MAI_SN))];
writeIndexDB([{key: cacheKey, data: listids}], 'maillist', 'mailbox')
writeIndexDB(ret.map(ele => ({ data: {...ele, listKey: cacheKey }, key: ele.MAI_SN})), 'listrow', 'mailbox')
}
return ret;
}
export const searchEmailListAction = async ({opi_sn = '', mailboxtype = 'ALL', sender = '', receiver = '', subject = '', content=''}={}) => {
const formData = new FormData()
formData.append('opi_sn', opi_sn)
formData.append('mailboxtype', mailboxtype)
formData.append('sender', sender)
formData.append('receiver', receiver)
formData.append('subject', subject)
// formData.append('content', content)
const { errcode, result } = await postForm(`${API_HOST_V3}/mail_search`, formData)
const ret = errcode === 0 ? result : []
notifyMailboxUpdate({ type: 'maillist-search-result', query: [sender, receiver, subject].filter(s => s).join(' '), data: ret.map(ele => ({...ele, key: ele.MAI_SN, showFolder: true })) })
return ret;
}
const removeFromCurrentList = async (params) => {
const readRow0 = await readIndexDB(params.mai_sn_list[0], 'listrow', 'mailbox')
const listKey = readRow0?.data?.listKey || ''
if (listKey) {
const readCache = await readIndexDB(listKey, 'maillist', 'mailbox')
const updatedMailList = readCache.data.filter((mai_sn) => !params.mai_sn_list.includes(mai_sn))
writeIndexDB([{ key: listKey, data: updatedMailList }], 'maillist', 'mailbox')
notifyMailboxUpdate({ type: 'listrow', listKey, affectKeys: params.mai_sn_list })
}
}
const updateEmailKeyMap = { read: 'MOI_ReadState' };
const updateEmailKeyFun = {
read: async (params) => {
const readCache = await readIndexDB(params.mai_sn_list, 'listrow', 'mailbox')
const updateField = Object.keys(params.set).reduce((a, c) => ({ ...a, [updateEmailKeyMap[c]]: params.set[c] }), {})
writeIndexDB(
params.mai_sn_list.map((ele) => ({ data: { ...(readCache.get(ele)?.data || {}), ...updateField }, key: ele })),
'listrow',
'mailbox',
)
// 通知邮件列表数据更新
const listKey = readCache.get(params.mai_sn_list[0])?.data?.listKey || '';
const notificationPayload = { type: 'listrow', listKey, affectKeys: params.mai_sn_list }
notifyMailboxUpdate(notificationPayload)
},
processed: removeFromCurrentList,
delete: removeFromCurrentList
}
/**
* 更新邮件属性
*/
export const updateEmailAction = async (params = { opi_sn: 0, mai_sn_list: [], set: {} }) => {
if (isEmpty(params.mai_sn_list)) {
throw new Error('没有需要更新的邮件');
}
const { errcode, result } = await postJSON(`${API_HOST_V3}/mail_update`, params)
if (errcode === 0 ) {
for (const [key, value] of Object.entries(params.set)) {
const updateFun = updateEmailKeyFun[key] || (() => {});
updateFun(params)
}
}
getMailboxCountAction({ opi_sn: params.opi_sn });
return errcode === 0 ? result : {}
}
/**
* 获取邮件模板
* @param {object} params - Parameters for the email template request.
* @param {number} [params.coli_sn] - Customer order line item serial number.
* @param {number} [params.lgc] - Language code.
* @param {number} [params.opi_sn] - Order product item serial number.
* @param {string} [params.remind_type] - Type of reminder.
* @param {number} [params.remind_index] - Index of the reminder.
*/
export const getReminderEmailTemplateAction = async (params = { coli_sn: 0, lgc: 1, opi_sn: 0, remind_type: '', remind_index: 0 }) => {
const { errcode, result } = await fetchJSON(`${API_HOST_V3}/reminder_letter`, params)
const { html, bodyContent, bodyText } = parseHTMLString(result?.MailContent, true) ;
return errcode === 0 ? {...result, bodyContent }: {}
}
/**
* 保存邮件草稿
* @param {object} body - The body of the email.
* @param {boolean} [isDraft=false] - Whether the email is a draft.
*/
export const saveEmailDraftOrSendAction = async (body, isDraft = false) => {
const url = isDraft !== false ? `${API_HOST_V3}/email_draft_save` : `${EMAIL_HOST_v3}/sendmail`;
const { attaList=[], atta, content, ...bodyData } = body;
bodyData.ordertype = 227001;
const formData = new FormData();
Object.keys(bodyData).forEach(function (key) {
formData.append(key, bodyData[key] || '');
});
// 附件只传新增的
attaList.filter(ele => !ele.fullPath).forEach(function (item) {
formData.append('attachment', item);
});
const { errcode, result } = await postForm(url, formData);
return errcode === 0 ? (result || {}) : {}
};
/**
* 删除邮件附件
*/
export const deleteEmailAttachmentAction = async (ati_sn_list) => {
const { errcode, result } = await postJSON(`${API_HOST_V3}/mail_attachment_delete`, { ati_sn_list })
return errcode === 0 ? result : {}
};
export const queryHTOrderListAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/query_order`, params)
return errcode !== 0 ? [] : result
}
export const queryOPIOrderAction = async (params) => {
const { errcode, result } = await fetchJSON(`${API_HOST}/getdvancedwlorder`, params)
return errcode !== 0 ? [] : result
};

@ -1,25 +0,0 @@
import { fetchJSON, postForm, postJSON } from '@/utils/request'
import { usingStorage } from '@/utils/usingStorage'
const WAI_SERVER_KEY = 'G-STR:WAI_SERVER'
const WAI_API_VER = '/api/v1'
export const postSendMsg = async (body) => {
const { waiServer } = usingStorage(WAI_SERVER_KEY)
// const { attaList = [], atta, content, ...bodyData } = body
// const formData = new FormData()
// Object.keys(bodyData).forEach(function (key) {
// formData.append(key, bodyData[key])
// })
// attaList.forEach(function (item) {
// formData.append('attachment', item)
// })
const { result } = await postJSON(`${waiServer}${WAI_API_VER}/messages/send`, body)
return result
}
export const fetchQRCode = (phone) => {
const { waiServer } = usingStorage(WAI_SERVER_KEY)
return fetchJSON(`${waiServer}${WAI_API_VER}/channels/qrcode`, { phone })
}

@ -3,4 +3,4 @@
height: 68px;
margin: 0 6px 0 0;
background: rgba(255, 255, 255, 0.3);
}
}

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 20V7L20 3H4L2 7.00353V20C2 20.5523 2.44772 21 3 21H21C21.5523 21 22 20.5523 22 20ZM4 9H20V19H4V9ZM5.236 5H18.764L19.764 7H4.237L5.236 5ZM15 11H9V13H15V11Z"></path></svg>

Before

Width:  |  Height:  |  Size: 262 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966ZM15.6567 14.5113L19.1922 10.9758L12.8283 4.61185L9.29275 8.14738L15.6567 14.5113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 442 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.58564 8.85449L3.63589 13.8042L8.83021 18.9985L9.99985 18.9978V18.9966H11.1714L14.9496 15.2184L8.58564 8.85449ZM9.99985 7.44027L16.3638 13.8042L19.1922 10.9758L12.8283 4.61185L9.99985 7.44027ZM13.9999 18.9966H20.9999V20.9966H11.9999L8.00229 20.9991L1.51457 14.5113C1.12405 14.1208 1.12405 13.4877 1.51457 13.0971L12.1212 2.49053C12.5117 2.1 13.1449 2.1 13.5354 2.49053L21.3136 10.2687C21.7041 10.6592 21.7041 11.2924 21.3136 11.6829L13.9999 18.9966Z"></path></svg>

Before

Width:  |  Height:  |  Size: 555 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12.6512 14.0654L11.6047 20H9.57389L10.9247 12.339L3.51465 4.92892L4.92886 3.51471L20.4852 19.0711L19.071 20.4853L12.6512 14.0654ZM11.7727 7.53009L12.0425 5.99999H10.2426L8.24257 3.99999H19.9999V5.99999H14.0733L13.4991 9.25652L11.7727 7.53009Z"></path></svg>

Before

Width:  |  Height:  |  Size: 347 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13.3414C21.3744 13.1203 20.7013 13 20 13C16.6863 13 14 15.6863 14 19C14 19.7013 14.1203 20.3744 14.3414 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13.3414ZM12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L19.6544 7.75616L18.3456 6.24384L12.0606 11.6829ZM21 18H24V20H21V23H19V20H16V18H19V15H21V18Z"></path></svg>

Before

Width:  |  Height:  |  Size: 460 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13H20V7.23792L12.0718 14.338L4 7.21594V19H14V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13ZM4.51146 5L12.0619 11.662L19.501 5H4.51146ZM21 18H24V20H21V23H19V20H16V18H19V15H21V18Z"></path></svg>

Before

Width:  |  Height:  |  Size: 328 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 13.3414C21.3744 13.1203 20.7013 13 20 13C16.6863 13 14 15.6863 14 19C14 19.7013 14.1203 20.3744 14.3414 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V13.3414ZM12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L19.6544 7.75616L18.3456 6.24384L12.0606 11.6829ZM19 22L15.4645 18.4645L16.8787 17.0503L19 19.1716L22.5355 15.636L23.9497 17.0503L19 22Z"></path></svg>

Before

Width:  |  Height:  |  Size: 504 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 14H20V7.23792L12.0718 14.338L4 7.21594V19H14V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V14ZM4.51146 5L12.0619 11.662L19.501 5H4.51146ZM19 22L15.4645 18.4645L16.8787 17.0503L19 19.1716L22.5355 15.636L23.9497 17.0503L19 22Z"></path></svg>

Before

Width:  |  Height:  |  Size: 372 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20 7.23792L12.0718 14.338L4 7.21594V19H13V21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V12H20V7.23792ZM19.501 5H4.51146L12.0619 11.662L19.501 5ZM20 18H23L19 22L15 18H18V14H20V18Z"></path></svg>

Before

Width:  |  Height:  |  Size: 323 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.8032 8.4928C19.4663 8.81764 20.2118 9 21 9C21.3425 9 21.6769 8.96557 22 8.89998V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H16.1C16.0344 3.32311 16 3.65753 16 4C16 5.23672 16.449 6.36857 17.1929 7.24142L12.0606 11.6829L5.64722 6.2377L4.35278 7.7623L12.0731 14.3171L18.8032 8.4928ZM21 7C19.3431 7 18 5.65685 18 4C18 2.34315 19.3431 1 21 1C22.6569 1 24 2.34315 24 4C24 5.65685 22.6569 7 21 7Z"></path></svg>

Before

Width:  |  Height:  |  Size: 539 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M16.1 3C16.0344 3.32311 16 3.65753 16 4C16 4.34247 16.0344 4.67689 16.1 5H4.51146L12.0619 11.662L17.1098 7.14141C17.5363 7.66888 18.0679 8.10787 18.6728 8.42652L12.0718 14.338L4 7.21594V19H20V8.89998C20.3231 8.96557 20.6575 9 21 9C21.3425 9 21.6769 8.96557 22 8.89998V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H16.1ZM21 1C22.6569 1 24 2.34315 24 4C24 5.65685 22.6569 7 21 7C19.3431 7 18 5.65685 18 4C18 2.34315 19.3431 1 21 1Z"></path></svg>

Before

Width:  |  Height:  |  Size: 572 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5 4.9967V7.9967H19V4.9967H5ZM4 2.9967H20C20.5523 2.9967 21 3.44442 21 3.9967V8.9967C21 9.54899 20.5523 9.9967 20 9.9967H4C3.44772 9.9967 3 9.54899 3 8.9967V3.9967C3 3.44442 3.44772 2.9967 4 2.9967ZM6 11.9967H12C12.5523 11.9967 13 12.4444 13 12.9967V15.9967H14V21.9967H10V15.9967H11V13.9967H5C4.44772 13.9967 4 13.549 4 12.9967V10.9967H6V11.9967ZM17.7322 13.7289L19.5 11.9612L21.2678 13.7289C22.2441 14.7052 22.2441 16.2882 21.2678 17.2645C20.2915 18.2408 18.7085 18.2408 17.7322 17.2645C16.7559 16.2882 16.7559 14.7052 17.7322 13.7289Z"></path></svg>

Before

Width:  |  Height:  |  Size: 640 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M21 1.99669C6 1.99669 4 15.9967 3 21.9967C3.66667 21.9967 4.33275 21.9967 4.99824 21.9967C5.66421 18.6636 7.33146 16.8303 10 16.4967C14 15.9967 17 12.4967 18 9.49669L16.5 8.49669C16.8333 8.16336 17.1667 7.83002 17.5 7.49669C18.5 6.49669 19.5042 4.99669 21 1.99669Z"></path></svg>

Before

Width:  |  Height:  |  Size: 368 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6.93912 14.0328C6.7072 14.6563 6.51032 15.2331 6.33421 15.8155C7.29345 15.1189 8.43544 14.6767 9.75193 14.5121C12.2652 14.198 14.4976 12.5385 15.6279 10.4537L14.1721 8.99888L15.5848 7.58417C15.9185 7.25004 16.2521 6.91614 16.5858 6.58248C17.0151 6.15312 17.5 5.35849 18.0129 4.2149C12.4197 5.08182 8.99484 8.50647 6.93912 14.0328ZM17 8.99739L18 9.99669C17 12.9967 14 15.9967 10 16.4967C7.33146 16.8303 5.66421 18.6636 4.99824 21.9967H3C4 15.9967 6 1.99669 21 1.99669C20.0009 4.99402 19.0018 6.99313 18.0027 7.99402C17.6662 8.33049 17.3331 8.66382 17 8.99739Z"></path></svg>

Before

Width:  |  Height:  |  Size: 663 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 12C22 17.5228 17.5229 22 12 22C6.4772 22 2 17.5228 2 12C2 6.47715 6.4772 2 12 2V4C7.5817 4 4 7.58172 4 12C4 16.4183 7.5817 20 12 20C16.4183 20 20 16.4183 20 12C20 9.53614 18.8862 7.33243 17.1346 5.86492L15 8V2L21 2L18.5535 4.44656C20.6649 6.28002 22 8.9841 22 12Z"></path></svg>

Before

Width:  |  Height:  |  Size: 371 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M22 12C22 17.5228 17.5229 22 12 22C6.4772 22 2 17.5228 2 12C2 6.47715 6.4772 2 12 2V4C7.5817 4 4 7.58172 4 12C4 16.4183 7.5817 20 12 20C16.4183 20 20 16.4183 20 12C20 9.25022 18.6127 6.82447 16.4998 5.38451L16.5 8H14.5V2L20.5 2V4L18.0008 3.99989C20.4293 5.82434 22 8.72873 22 12Z"></path></svg>

Before

Width:  |  Height:  |  Size: 383 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM16.8201 17.0761C18.1628 15.8007 19 13.9981 19 12C19 8.13401 15.866 5 12 5C8.13401 5 5 8.13401 5 12C5 15.866 8.13401 19 12 19C13.0609 19 14.0666 18.764 14.9676 18.3417L13.9928 16.5871C13.3823 16.8527 12.7083 17 12 17C9.23858 17 7 14.7614 7 12C7 9.23858 9.23858 7 12 7C14.7614 7 17 9.23858 17 12H14L16.8201 17.0761Z"></path></svg>

Before

Width:  |  Height:  |  Size: 531 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path></svg>

Before

Width:  |  Height:  |  Size: 421 B

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6V21H11V6H5V4H19V6H13Z"></path></svg>

Before

Width:  |  Height:  |  Size: 130 B

@ -2,7 +2,6 @@ import { webSocket } from 'rxjs/webSocket';
import { of, timer, concatMap, EMPTY, takeWhile, concat } from 'rxjs';
import { filter, buffer, map, tap, retryWhen, retry, delay, take, catchError } from 'rxjs/operators';
import { v4 as uuid } from 'uuid';
import { logWebsocket } from '@/utils/indexedDB';
export class RealTimeAPI {
constructor(param, onOpenCallback, onCloseCallback, onRetryCallback) {
@ -105,12 +104,6 @@ export class RealTimeAPI {
}
sendMessage(messageObject) {
// console.log(
// `%c websocket Message OUT ⬆`,
// 'background:#41b883 ; padding: 1px; border-radius: 3px; color: #fff',
// JSON.stringify(messageObject, null, 2),
// );
logWebsocket(messageObject, 'O');
this.webSocket.next(messageObject);
}

@ -1,66 +1,7 @@
import { cloneDeep, isEmpty, olog, fixTo2Decimals, pick, objectMapper } from "@/utils/commons";
import { cloneDeep, isEmpty, olog, fixTo2Decimals } from "@/utils/commons";
import dayjs from "dayjs";
import { v4 as uuid } from "uuid";
function removeFirstPlus(str) {
if (str.startsWith('+')) {
return str.slice(1);
}
return str;
}
export const WABAccounts = [
{
"id": "217973041403372",
"phoneNumber": "+8618174165365",
"wabaId": "190290134156880",
"verifiedName": "Global Highlights Multilanguage",
"qualityRating": "GREEN",
"messagingLimit": "TIER_1K",
"isOfficialBusinessAccount": false,
"codeVerificationStatus": "EXPIRED",
"status": "CONNECTED",
"displayPhoneNumber": "+86 181 7416 5365",
"nameStatus": "APPROVED",
"newNameStatus": "NONE"
},
{
"id": "160079783860667",
"phoneNumber": "+8617607730395",
"wabaId": "190290134156880",
"verifiedName": "Global Highlights",
"qualityRating": "GREEN",
"messagingLimit": "TIER_1K",
"isOfficialBusinessAccount": false,
"codeVerificationStatus": "EXPIRED",
"status": "CONNECTED",
"displayPhoneNumber": "+86 176 0773 0395",
"nameStatus": "APPROVED",
"newNameStatus": "NONE",
"decision": "DEFERRED",
"requestedVerifiedName": "Global Highlights",
"rejectionReason": "NONE"
},
{
"id": "563254206874812",
"phoneNumber": "+639454682947",
"wabaId": "190290134156880",
"verifiedName": "Customer Relation Specialist",
"qualityRating": "UNKNOWN",
"messagingLimit": "TIER_1K",
"isOfficialBusinessAccount": false,
"codeVerificationStatus": "VERIFIED",
"status": "CONNECTED",
"displayPhoneNumber": "+63 945 468 2947",
"nameStatus": "DECLINED",
"newNameStatus": "NONE",
"decision": "DEFERRED",
"requestedVerifiedName": "Customer Relation Specialist",
"rejectionReason": "NONE"
},
];
export const WABAccountsMapped = WABAccounts.reduce((a, c) => ({ ...a, [removeFirstPlus(c.phoneNumber)]: c, [c.phoneNumber]: c }), {})
export const replaceTemplateString = (str, replacements) => {
let result = str;
let keys = str.match(/{{(.*?)}}/g).map(key => key.replace(/{{|}}/g, ''));
@ -74,11 +15,6 @@ export const replaceTemplateString = (str, replacements) => {
return result;
}
export const whatsappTemplateBtnParamTypesMapped = {
'copy_code': 'coupon_code',
// 'quick_reply': 'payload',
};
/**
* @deprecated 在渲染时处理
*/
@ -103,9 +39,7 @@ const mediaMsg = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgcontent: {
[msg.type]: {
link: msg.data.dataUri,
@ -139,9 +73,7 @@ export const sentMsgTypeMapped = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgtype: 'text',
msgcontent: { body: msg.text, preview_url: true, ...(msg.context ? { context: msg.context, message_origin: msg.message_origin?.msgOrigin || msg.message_origin } : {}) },
}),
@ -223,26 +155,24 @@ export const sentMsgTypeMapped = {
action: 'message',
actionId: msg.id,
renderId: msg.id,
externalId: msg.externalId,
to: msg.to,
from: msg.from,
msgtype: 'template',
msgcontent: {
...msg.template,
components: [
...msg.template.components.filter((com) => !['footer', 'buttons'].includes(com.type.toLowerCase())),
// ...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
// ? msg.template.components
// .filter((com) => 'buttons' === com.type.toLowerCase())[0]
// // .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
// .buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
// .map((btn, btnI) => ({
// type: 'button',
// sub_type: btn.type.toLowerCase(),
// index: btnI,
// // parameters: [{ text: 'lq1FTtA8', type: 'text' }]
// }))
// : []),
...(msg.template.components.filter((com) => 'buttons' === com.type.toLowerCase()).length > 0
? msg.template.components
.filter((com) => 'buttons' === com.type.toLowerCase())[0]
// .buttons.filter((btns) => ! ['phone_number', 'url'].includes( btns.type.toLowerCase()))
.buttons.filter((btns) => !isEmpty(btns.example)) // 静态按钮不发
.map((btn, btnI) => ({
type: 'button',
sub_type: btn.type.toLowerCase(),
index: btnI,
// parameters: [{ text: 'lq1FTtA8', type: 'text' }]
}))
: []),
],
},
}),
@ -276,55 +206,27 @@ export const sentMsgTypeMapped = {
type: 'text',
title: msg.template.name, // || msg.template_origin.components.header?.[0]?.text || '',
text: autoLinkText(templateDataMapped?.body?.text || ''), // msg.template_origin.components.body?.[0]?.text || '',
whatsapp_msg_type: 'template',
};
},
},
email: {
type: 'email',
// contentToSend: (msg) => ({
// action: 'message',
// actionId: msg.id,
// renderId: msg.id,
// to: msg.to,
// msgtype: 'email',
// msgcontent: { body: msg.text, preview_url: true, },
// }),
contentToRender: (msg) => ({
...msg,
whatsapp_msg_type: '',
actionId: msg.id,
conversationid: msg.conversationid,
originText: msg.text,
text: (msg.text),
msgtext: {
...msg,
},
msgOrigin: {
...msg,
},
}),
},
};
const whatsappMsgMapped = {
'whatsapp.inbound_message.received': {
getMsg: (result) => {
// console.log('whatsapp.inbound_message.received', result);
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id'])
return isEmpty(result?.whatsappInboundMessage) ? null : { ...result.whatsappInboundMessage, ...data1, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'inbound' }
console.log('whatsapp.inbound_message.received', result);
return isEmpty(result?.whatsappInboundMessage) ? null : { ...result.whatsappInboundMessage, conversationid: result.conversationid, messageorigin: result.messageorigin };
},
contentToRender: (contentObj) => {
// console.log('whatsapp.inbound_message.received to render', contentObj);
return parseRenderMessageItem(contentObj)
console.log('whatsapp.inbound_message.received to render', contentObj);
// const contentObj = result?.whatsappInboundMessage || result; // debug:
return parseRenderMessageItem(contentObj);
},
contentToUpdate: () => null,
},
'whatsapp.message.updated': {
getMsg: (result) => {
// console.log('getMsg', result);
return isEmpty(result?.whatsappMessage)
? null
: { ...result.whatsappMessage, conversationid: result.conversationid, messageorigin: result.messageorigin, msg_source: 'WABA', msg_direction: 'outbound' }
return isEmpty(result?.whatsappMessage) ? null : { ...result.whatsappMessage, conversationid: result.conversationid, messageorigin: result.messageorigin };
},
contentToRender: (contentObj) => {
if (contentObj?.status === 'failed' && ['130472', 'BAD_REQUEST'].includes(contentObj.errorCode)) {
@ -334,184 +236,28 @@ const whatsappMsgMapped = {
text: { body: `${whatsappError?.[contentObj.errorCode] || contentObj.errorMessage}` }, // contentObj.errorMessage // Message failed to send.
id: contentObj.id,
wamid: contentObj.id,
}
return parseRenderMessageItem(contentObj)
}
// * 仅更新消息状态, 没有输出
return null
},
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem(msgcontent),
id: msgcontent.wamid,
status: msgStatusRenderMapped[msgcontent?.status || 'accepted'],
sender: 'me',
dateString: msgcontent.status === 'failed' ? `发送失败 ${whatsappError?.[msgcontent.errorCode] || msgcontent.errorMessage || ''}` : '',
}),
},
'wai.message.received': {
getMsg: (result) => {
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id'])
return isEmpty(result?.waiMessage)
? null
: { ...result.waiMessage, ...data1, messageorigin: result.messageorigin, msg_source: 'wai', ...objectMapper(result.waiMessage, { direction: { key: 'msg_direction' } }) }
},
contentToRender: (contentObj) => {
return parseRenderMessageItem(contentObj)
},
contentToUpdate: () => null,
},
'wai.message.updated': {
getMsg: (result) => {
return isEmpty(result?.waiMessage)
? null
: {
...result.waiMessage,
conversationid: result.conversationid,
messageorigin: result.messageorigin,
msg_source: 'wai',
...objectMapper(result.waiMessage, { direction: { key: 'msg_direction' } }),
}
},
contentToRender: (contentObj) => {
if (contentObj?.status === 'failed') {
contentObj = {
...contentObj,
type: 'error',
text: { body: `` }, // contentObj.errorMessage // Message failed to send.
id: contentObj.id,
wamid: contentObj.id,
}
return parseRenderMessageItem(contentObj)
};
return parseRenderMessageItem(contentObj);
}
// * 仅更新消息状态, 没有输出
return null
return null;
},
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem(msgcontent),
id: msgcontent.wamid,
status: msgcontent.msg_direction === 'outbound' ? msgStatusRenderMapped[msgcontent?.status || 'accepted'] : '',
sender: msgcontent.msg_direction === 'outbound' ? 'me' : msgcontent?.customerProfile?.name || '',
dateString: msgcontent.status === 'failed' ? `发送失败 ❌` : '',
}),
},
'wai.creds.update': {
getMsg: (result) => {
return isEmpty(result?.waiMessage)
? {}
: { ...result.waiMessage, conversationid: result.conversationid, msg_source: 'wai', }
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
contentToNotify: (contentObj) => {
return {
...contentObj,
status: contentObj?.status || '',
key: contentObj.to || '',
content: `WhatsApp号码: ${contentObj.to}`,
title: (contentObj.status === 'offline') ? `WhatsApp 断开连接` : '',
type: (contentObj.status === 'offline') ? 'warning' : 'info',
};
},
},
}
const emailMsgMapped = {
'email.inbound.received': {
getMsg: (result) => {
// console.log('email.inbound.received', result);
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id']);
return isEmpty(result?.emailMessage) ? null : { ...result.emailMessage, ...data1, msg_source: 'email', msg_direction: 'inbound' };
},
contentToRender: (contentObj) => {
// console.log('email.inbound.received to render', contentObj);
return parseRenderMessageItem(contentObj);
},
contentToUpdate: (msgcontent) => null,
},
'email.updated': {
getMsg: (result) => {
// console.log('email.updated', result);
const { emailMessage } = result;
const data1 = pick(result, ['conversationid', 'opi_sn', 'coli_sn', 'coli_id']);
return isEmpty(result?.emailMessage) ? null : { ...emailMessage, ...data1, msg_source: 'email', msg_direction: 'outbound' };
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => ({
...msgcontent,
...parseRenderMessageItem({...msgcontent, }),
id: msgcontent.id,
status: msgStatusRenderMapped[(msgcontent?.status || 'accepted')],
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
sender: 'me',
dateString: msgcontent.status==='failed' ? `发送失败 ` : '',
dateString: msgcontent.status==='failed' ? `发送失败 ${whatsappError?.[msgcontent.errorCode] || msgcontent.errorMessage || ''}` : '',
}),
},
'email.action.received': {
getMsg: (result) => {
return isEmpty(result?.emailMessage) ? null : { ...result.emailMessage, id: result.id };
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
contentToNotify: (contentObj) => {
return {
...contentObj,
status: contentObj?.status || 'failed',
key: contentObj.email || contentObj.from || '',
content: `${contentObj.email || contentObj.from || '未知邮箱'} ${contentObj?.error?.message || ''}`,
title: (contentObj.status === 'failed') ? `接收邮件失败` : '',
type: (contentObj.status === 'failed') ? 'warning' : 'info',
};
},
}
}
const sessionMsgMapped = {
'session.new': {
getMsg: (result) => {
// sessionItem 是数组
return isEmpty(result?.sessionItem)
? null
: result.sessionItem.map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
msg_source: 'session',
msg_direction: 'inbound',
}))
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
},
'session.updated': {
getMsg: (result) => {
// sessionItem 是数组
return isEmpty(result?.sessionItem)
? null
: result.sessionItem.map((ele) => ({
...ele,
customer_name: `${ele.whatsapp_name || ''}`.trim(),
whatsapp_name: `${ele.whatsapp_name || ''}`.trim(),
show_default: ele.conversation_memo || ele.whatsapp_name || ele?.channels?.whatsapp_phone_number || ele?.channels?.phone_number || ele?.channels?.email || '',
// coli_id: ele.COLI_ID,
top_state: ele.top_state || 0,
msg_source: 'session',
msg_direction: 'inbound',
// last_message: {...ele.last_message, text: { body: ele.last_message?.text_body || '', preview_url: null }},
}))
},
contentToRender: (contentObj) => null,
contentToUpdate: (msgcontent) => null,
},
}
};
export const msgStatusRenderMapped = {
'accepted': 'waiting', // 'sent', // 接口的发送请求
'sent': 'sent',
'delivered': 'received',
'read': 'read',
'failed': 'failed',
'send': 'sent',
};
export const msgStatusRenderMappedCN = {
'accepted': '[发送ing]',
@ -530,7 +276,7 @@ export const receivedMsgTypeMapped = {
...msgcontent,
actionId: msgcontent.actionId,
id: msgcontent.wamid,
status: msgStatusRenderMapped[(msgcontent?.status || 'accepted')],
status: msgStatusRenderMapped[(msgcontent?.status || 'failed')],
conversationid: msgcontent.actionId.split('.')[0], // msgcontent.conversation.sn,
date: msgcontent.createTime,
sender: 'me',
@ -541,8 +287,6 @@ export const receivedMsgTypeMapped = {
getMsg: (result) => result,
contentToRender: () => null,
contentToUpdate: (msgcontent) => {
if (isEmpty(msgcontent)) return null;
if (isEmpty(msgcontent.error)) return null;
let apiErrorCode,
apiErrorMsg = '';
const waCode = msgcontent.error.message.match(/\(#(\d+)\)/);
@ -563,45 +307,36 @@ export const receivedMsgTypeMapped = {
};
},
},
...cloneDeep(emailMsgMapped),
...cloneDeep(sessionMsgMapped),
};
/**
* 消息类型处理, 合并各渠道类型
* * WABA: error, system, text, image, video, audio, sticker, contact, location, document, template, interactive, order, list, button, reaction,
* * Email: email
*/
export const whatsappMsgTypeMapped = {
error: {
type: (_m) => ({ type: 'system' }),
data: (msg) => ({ id: msg.wamid, text: msg.errorCode ? msg.errorMessage : msg.text.body }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.errorCode ? msg.errorMessage : msg.text.body }),
},
system: {
type: 'system',
data: (msg) => ({ id: msg.wamid, text: msg.system?.body }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg?.text?.body || msg?.text }),
data: (msg) => ({ id: msg.wamid, text: msg.system.body }),
},
text: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: autoLinkText(msg?.text?.body), originText: msg?.text?.body, title: msg?.customerProfile?.name || '', }), // msg?.from ||
renderForReply: (msg) => ({ id: msg.wamid, message: msg?.text?.body || msg?.text }),
data: (msg) => ({ id: msg.wamid, text: autoLinkText(msg.text.body), originText: msg.text.body, title: msg?.customerProfile?.name || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.text.body }),
},
image: {
type: 'photo',
data: (msg) => ({
id: msg.wamid,
text: msg.image?.caption,
text: msg.image.caption,
onPhotoError: ({ currentTarget }) => {
currentTarget.onerror = null;
currentTarget.src="https://hiana-crm.oss-accelerate.aliyuncs.com/WAMedia/afe412d4-3acf-4e79-a623-048aeb4d696a.png";
},
data: {
id: msg.wamid,
uri: msg.image?.link,
uri: msg.image.link,
width: 'auto',
height: 200,
alt: msg.image?.caption || '',
alt: msg.image.caption || '',
status: {
click: true,
loading: 0,
@ -612,11 +347,11 @@ export const whatsappMsgTypeMapped = {
}),
renderForReply: (msg) => ({
id: msg.wamid,
photoURL: msg.image?.link,
photoURL: msg.image.link,
width: 'auto',
height: 200,
alt: msg.image?.caption || '',
message: msg.image?.caption || '[图片]',
message: msg.image?.caption || '',
}),
},
sticker: {
@ -625,7 +360,7 @@ export const whatsappMsgTypeMapped = {
id: msg.wamid,
data: {
id: msg.wamid,
uri: msg.sticker?.link,
uri: msg.sticker.link,
width: '100%',
height: 120,
alt: '',
@ -691,23 +426,12 @@ export const whatsappMsgTypeMapped = {
// unsupported: { type: 'system', data: (msg) => ({ text: 'Message type is currently not supported.' }) },
unsupported: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: `[对方删除消息](${msg.wamid})`, dateString: `${dayjs(msg.sendTime).format('MM-DD HH:mm')} [ WhatsApp未提供消息内容 ] 客人删除消息/会话` }),
renderForReply: (msg) => ({ id: msg?.wamid || msg.id, message: `[Message type unsupported](${msg.wamid})` }),
},
unresolvable: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: `[无法解析](${msg.wamid})`, }),
renderForReply: (msg) => ({ id: msg?.wamid || msg.id, message: `[无法解析](${msg.wamid})` }),
data: (msg) => ({ id: msg.wamid, text: `[暂不支持此消息类型](${msg.wamid})`, dateString: `${dayjs(msg.sendTime).format('MM-DD HH:mm')} [ WhatsApp未提供消息内容 ] 可能是客人删除消息/会话, \n可询问客人截图/详细内容 或 忽略 📌` }),
renderForReply: (msg) => ({ id: msg?.wamid || msg.id, text: `[Message type unsupported](${msg.wamid})` }),
},
reaction: {
type: 'text',
data: (msg) => ({ id: msg.wamid, text: msg.reaction?.emoji || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.reaction?.emoji || '' }),
},
button: {
type: 'text', // todo: 后端返回 type='button' button: { payload, text }
data: (msg) => ({ id: msg.wamid, text: msg.button?.payload || msg.button?.text || '' }),
renderForReply: (msg) => ({ id: msg.wamid, message: msg.button?.payload || msg.button?.text || '' }),
},
document: {
type: 'file',
@ -715,7 +439,7 @@ export const whatsappMsgTypeMapped = {
id: msg.wamid,
title: msg.document?.filename || '',
text: msg.document?.caption || msg.document?.filename || '',
data: { uri: msg.document?.link, status: { click: false, download: true, loading: 0 } },
data: { uri: msg.document.link, status: { click: false, download: true, loading: 0 } },
originText: msg.document?.caption || msg.document?.filename || '',
}),
renderForReply: (msg) => ({
@ -728,25 +452,25 @@ export const whatsappMsgTypeMapped = {
data: (msg) => ({
id: msg.wamid,
meetingID: msg.wamid,
title: (msg.contacts || []).length === 1 ? `联系人` : `${(msg.contacts || []).length} 位联系人`,
text: (msg.contacts || []).map((ele) => `${(ele?.org?.company || '') +' '+ele.name.formatted_name}: ${ele.phones[0].wa_id || ele.phones[0].phone}`).join('\n'),
data: (msg.contacts || []).map((ele) => ({ id: ele.phones[0]?.wa_id || ele.phones[0].phone, wa_id: ele.phones[0]?.wa_id || '', phone: ele.phones[0].phone, name: (ele?.org?.company || '') +' '+ele.name.formatted_name })),
waBtn: (msg.contacts || []).some(ele => ele.phones.some(p => p.wa_id)),
title: msg.contacts.length === 1 ? `联系人` : `${msg.contacts.length} 位联系人`,
text: msg.contacts.map((ele) => `${(ele?.org?.company || '') +' '+ele.name.formatted_name}: ${ele.phones[0].wa_id || ele.phones[0].phone}`).join('\n'),
data: msg.contacts.map((ele) => ({ id: ele.phones[0]?.wa_id || ele.phones[0].phone, wa_id: ele.phones[0]?.wa_id || '', phone: ele.phones[0].phone, name: (ele?.org?.company || '') +' '+ele.name.formatted_name })),
waBtn: msg.contacts.some(ele => ele.phones.some(p => p.wa_id)),
}),
renderForReply: (msg) => ({
id: msg.wamid,
message: '[联系人] ' + msg.contacts?.[0].name.formatted_name + '...',
message: '[联系人] ' + msg.contacts[0].name.formatted_name + '...',
}),
},
location: {
type: 'location',
data: (msg) => ({
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`,
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://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`,
href: `https://uri.amap.com/marker?position=${msg.location.longitude},${msg.location.latitude}&callnative=1`,
data: {
longitude: msg.location?.longitude,
latitude: msg.location?.latitude,
@ -761,23 +485,12 @@ export const whatsappMsgTypeMapped = {
template: {
type: 'text',
data: (msg) => {
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [(v.type).toLowerCase()]: v }), {}) : {};
return { id: msg.wamid, text: autoLinkText(templateDataMapped?.body?.text || `......${(templateDataMapped?.body?.parameters || []).map(pv => pv?.text || '').join('......')}......`), title: '模板消息', }; // msg.template.name
},
renderForReply: (msg) => {
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [(v.type).toLowerCase()]: v }), {}) : null;
return { id: msg.wamid, message: templateDataMapped?.body?.text || templateDataMapped?.body?.parameters?.[0]?.text || '', title: '模板消息', }; // `${msg.template.name}`
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [v.type]: v }), {}) : {};
return { id: msg.wamid, text: autoLinkText(templateDataMapped?.body?.text || `......${(templateDataMapped?.body?.parameters || []).map(pv => pv?.text || '').join('......')}......`), title: msg.template.name };
},
},
email: {
type: 'email',
data: (msg) => ({ id: msg.id || msg.uid, subject: msg.subject, }),
renderForReply: (msg) => {
const _msg = { ...msg, ...msg.email };
return {
id: _msg.id,
message: `[邮件] ${_msg.subject}`,
};
const templateDataMapped = msg.template?.components ? msg.template.components.reduce((r, v) => ({ ...r, [v.type]: v }), {}) : null;
return { id: msg.wamid, message: templateDataMapped?.body?.text || templateDataMapped?.body?.parameters?.[0]?.text || '', title: `${msg.template.name}` };
},
},
};
@ -785,11 +498,9 @@ export const whatsappMsgTypeMapped = {
* render received msg
*/
export const parseRenderMessageItem = (msg) => {
// console.log('parseRenderMessageItem', msg);
console.log('parseRenderMessageItem', msg);
const thisMsgType = Object.keys(whatsappMsgTypeMapped).includes(msg.type) ? msg.type : 'unsupported';
return {
...msg,
opi_sn: msg.opi_sn || '',
msgOrigin: msg,
date: msg?.sendTime || msg?.createTime || '',
...(whatsappMsgTypeMapped?.[thisMsgType]?.data(msg) || {}),
@ -797,18 +508,17 @@ export const parseRenderMessageItem = (msg) => {
...(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', ' '),
dateString: dayjs(msg?.sendTime || msg.createTime).format('MM-DD HH:mm'),
from: msg.from,
sender: msg.from,
senderName: msg.msg_direction === 'outbound' ? 'me' : msg?.customerProfile?.name || msg?.fromName || msg?.from || '',
senderName: msg?.customerProfile?.name || 'me', // msg.from,
// title: msg.customerProfile.name,
customer_name: msg?.customerProfile?.name || '',
whatsapp_name: msg?.customerProfile?.name || '',
whatsapp_phone_number: isEmpty(msg?.customerProfile) ? msg.to : msg.from,
// whatsapp_msg_type: msg.msg_source==='WABA' ? msg.type : '',
// whatsapp_msg_type: (msg.msg_source || 'WABA') === 'WABA' ? msg.type : '', // 1.0接口没有msg_source
statusCN: msgStatusRenderMappedCN[msg?.status || 'accepted'],
statusTitle: msgStatusRenderMappedCN[msg?.status || 'accepted'],
replyButton: !['accepted', 'waiting', 'failed'].includes(msg?.status || '') ,
whatsapp_msg_type: msg.type,
statusCN: msgStatusRenderMappedCN[msg?.status || 'failed'],
statusTitle: msgStatusRenderMappedCN[msg?.status || 'failed'],
replyButton: ['text', 'document', 'image'].includes(msg.type) && (msg?.status || '') !== 'failed',
...((isEmpty(msg.context) && isEmpty(msg.reaction)) || msg.context?.forwarded === true // || isEmpty(msg.messageorigin)
? {}
: {
@ -825,16 +535,6 @@ export const parseRenderMessageItem = (msg) => {
},
origin: msg.context,
}),
msg_source: msg?.msg_source || msg.type,
...((msg.msg_source) === 'WABA' ? {
whatsapp_msg_type: msg.type,
waba: msg.msg_direction === 'outbound' ? msg.from : msg.to,
wabaName: WABAccountsMapped[msg.msg_direction === 'outbound' ? msg.from : msg.to]?.verifiedName,
} : {
whatsapp_msg_type: '',
waba: '',
wabaName: '',
}),
};
};
/**
@ -843,99 +543,75 @@ export const parseRenderMessageItem = (msg) => {
export const parseRenderMessageList = (messages) => {
return messages.map((msg, i) => {
let msgContentString = '';
const msgtext = msg?.msgtext ?? msg?.msgtext_AsJOSN;
const messageorigin = msg?.messageorigin ?? msg?.messageorigin_AsJOSN;
const template = msg?.template_AsJOSN ?? msg?.template_AsJOSN_AsJOSN;
if (typeof msgtext === 'string') {
if (typeof msg.msgtext_AsJOSN === 'string') {
// debug: json 缺少一部分
msgContentString = msgtext.charAt(msgtext.length - 1) !== '}' ? msgtext + '}}' : msgtext;
// if (msg.msgtext.charAt(msg.msgtext.length - 1) === '"') {
// msgContentString = msg.msgtext + '}}';
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 + '"}';
// msgContentString = msg.msgtext_AsJOSN + '"}';
// }
}
const msgContent = typeof msgtext === 'string' ? JSON.parse(msgContentString) : (msgtext || {});
const msgType = isEmpty(msgContent) ? msg.msgtype : (Object.keys(whatsappMsgTypeMapped).includes(msgContent.type) ? msgContent.type : 'unresolvable')
msgContent.template = msg.msgtype === 'template' ? { ...msgContent.template, ...template } : {};
const msgContent = typeof msg.msgtext_AsJOSN === 'string' ? JSON.parse(msgContentString) : msg.msgtext_AsJOSN;
msgContent.template = msg.msgtype === 'template' ? { ...msgContent.template, ...msg.template_AsJOSN } : {};
const msgType = Object.keys(whatsappMsgTypeMapped).includes(msgContent.type) ? msgContent.type : 'unsupported';
// const parseMethod = msgContent.bizType === 'whatsapp' ? cloneDeep(whatsappMsgTypeMapped) : {};
let waCode, waError = '';
if ((msgContent?.status || 'accepted') === 'failed' && (msgContent.errorMessage || msg.errors_code) && msg.msg_direction === 'outbound') {
(waCode = (msgContent.errorMessage || msg.errors_code).match(/\(#(\d+)\)/));
(waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage || whatsappError?.[msg.errors_code]));
if ((msgContent?.status || 'failed') === 'failed' && msgContent.errorMessage && msg.msg_direction === 'outbound') {
(waCode = msgContent.errorMessage.match(/\(#(\d+)\)/));
(waError = (whatsappError?.[waCode?.[1]] || whatsappError?.[msgContent.errorCode] || msgContent.errorMessage));
if (!isEmpty(msgContent.whatsappApiError)) {
waError = whatsappError?.[msgContent.whatsappApiError.code] || msgContent.whatsappApiError.message;
// waError += `\n[${msgContent.errorCode}] ${whatsappError?.[msgContent.errorCode] || msgContent.errorMessage}`;
}
if ((msgContent.errorMessage || msg.errors_code).includes('Invalid E.146 phone number')) {
if (msgContent.errorMessage.includes('Invalid E.146 phone number')) {
waError = whatsappError.INVALID_PHONE_NUMBER;
}
}
const msgTypeData = whatsappMsgTypeMapped?.[msgType]?.data(msgContent) || {};
return {
...msg,
msgOrigin: { ...msgContent, ...msgContent.email },
// id: msg.id || msgContent.wamid || msgContent.id,
...msgTypeData,
id: msgTypeData?.id || msg.sn,
msgOrigin: msgContent,
...(whatsappMsgTypeMapped?.[msgType]?.data(msgContent) || {}),
type: msgContent.type,
...(typeof whatsappMsgTypeMapped[msgType].type === 'function' ? whatsappMsgTypeMapped[msgType].type(msg) : { type: whatsappMsgTypeMapped[msgType].type || 'text' }),
date: msg.msgtime, // msgContent?.sendTime || msg.msgtime || '',
dateText: dayjs(msg.msgtime).format('MM-DD HH:mm'),
dateString: dayjs(msg.msgtime).format('MM-DD HH:mm'),
date: msgContent?.sendTime || msg.msgtime || '',
dateText: dayjs(msgContent?.sendTime || msg.msgtime).format('MM-DD HH:mm'),
localDate: (msg.msgtime || '').replace('T', ' '),
from: msgContent.from,
sender: msgContent.from,
senderName: msgContent?.customerProfile?.name || msgContent.from || 'me',
replyButton: !['accepted', 'waiting', 'failed'].includes(msgContent?.status || '') ,
// 用forwarded表示Resend, 与Reply互斥
forwarded: msg.msg_direction === 'outbound' && msg.msg_source === 'email' && ['email'].includes(msgContent.type) && (msgContent?.status || 'accepted') === 'failed',
senderName: msgContent?.customerProfile?.name || 'me', // msgContent.from,
replyButton: ['text', 'document', 'image'].includes(msgContent.type) && (msgContent?.status || '') !== 'failed',
...(msg.msg_direction === 'outbound'
? {
sender: 'me',
senderName: 'me',
status: msgStatusRenderMapped[msgContent?.status || 'accepted'],
dateString: msgStatusRenderMapped[msgContent?.status || 'accepted'] === 'failed' ? `${(msg.msgtime || '').replace('T', ' ')} 发送失败 ${waError}` : '',
statusCN: msgStatusRenderMappedCN[msgContent?.status || 'accepted'],
statusTitle: msgStatusRenderMappedCN[msgContent?.status || 'accepted'],
id: (msgContent?.status || 'accepted') === 'failed' ? (msgContent.actionId || msgContent.id) : (msgTypeData.id || msg.id || msg.sn),
actionId: msgContent.actionId,
replyButton: !['accepted', 'waiting', 'failed'].includes(msgContent?.status || 'accepted') ,
status: msgStatusRenderMapped[msgContent?.status || 'failed'],
dateString: msgStatusRenderMapped[msgContent?.status || 'failed'] === 'failed' ? `${(msg.msgtime || '').replace('T', ' ')} 发送失败 ${waError}` : '',
statusCN: msgStatusRenderMappedCN[msgContent?.status || 'failed'],
statusTitle: msgStatusRenderMappedCN[msgContent?.status || 'failed'],
}
: {}),
...(isEmpty(messageorigin) && (isEmpty(msgContent.context) || msgContent.context?.forwarded === true)
? // ...((isEmpty(msg.context) && isEmpty(msg.reaction)) || msg.context?.forwarded === true || isEmpty(messageorigin)
// ...((isEmpty(messageorigin) || isEmpty(msgContent.context))
{}
...((isEmpty(msg.messageorigin_AsJOSN) && (isEmpty(msgContent.context) || msgContent.context?.forwarded === true))
// ...((isEmpty(msg.context) && isEmpty(msg.reaction)) || msg.context?.forwarded === true || isEmpty(msg.messageorigin)
// ...((isEmpty(msg.messageorigin_AsJOSN) || isEmpty(msgContent.context))
? {}
: {
reply: {
message: messageorigin?.text?.body || messageorigin?.text,
title: messageorigin?.customerProfile?.name || messageorigin?.senderName || 'me',
...(typeof whatsappMsgTypeMapped[messageorigin?.type || 'unsupported']?.renderForReply === 'function'
? whatsappMsgTypeMapped[messageorigin?.type || 'unsupported'].renderForReply(messageorigin)
message: msg.messageorigin_AsJOSN?.text?.body || msg.messageorigin_AsJOSN?.text,
title: msg.messageorigin_AsJOSN?.customerProfile?.name || msg.messageorigin_AsJOSN?.senderName || 'me',
...(typeof whatsappMsgTypeMapped[(msg.messageorigin_AsJOSN?.type || 'unsupported')]?.renderForReply === 'function'
? whatsappMsgTypeMapped[(msg.messageorigin_AsJOSN?.type || 'unsupported')].renderForReply(msg.messageorigin_AsJOSN)
: {}),
// titleColor: messageorigin?.customerProfile?.name ? '#a791ff' : '#128c7e',
titleColor: msg.messageorigin_direction === 'inbound' ? '#a791ff' : '#128c7e',
id: msgContent.context?.id || msgContent.context?.message_id || msgContent.reaction?.message_id || messageorigin?.wamid,
titleColor: msg.messageorigin_AsJOSN?.customerProfile?.name ? '#a791ff' : "#128c7e",
// titleColor: msg.messageorigin_direction === 'inbound' ? '#a791ff' : "#128c7e",
id: msgContent.context?.id || msgContent.context?.message_id || msgContent.reaction?.message_id,
},
origin: messageorigin,
origin: msg.messageorigin_AsJOSN,
}),
// conversationid: conversationid,
// title: msg.customerProfile.name,
// whatsapp_msg_type: (msg.msg_source || 'WABA') === 'WABA' ? msgContent.type : '', // 1.0接口没有msg_source
whatsapp_msg_type: '',
waba: '',
wabaName: '',
...((msg.msg_source) === 'WABA' ? {
whatsapp_msg_type: msgContent.type,
waba: msg.msg_direction === 'outbound' ? msgContent.from : msgContent.to,
wabaName: WABAccountsMapped[msg.msg_direction === 'outbound' ? msgContent.from : msgContent.to]?.verifiedName,
} : {}),
...((msg.msg_source) === 'wai' ? {
whatsapp_msg_type: msgContent.type,
wabaName: '个人号',
} : {}),
}
whatsapp_msg_type: msgContent.type,
};
});
};
export const whatsappError = {
@ -947,12 +623,12 @@ export const whatsappError = {
'100': '参数错误, 请联系技术组',
'FORBIDDEN': '[FORBIDDEN] ',
'4': '[4] 无法连接WhatsApp.\n请稍后重试', // (#4) Application request limit reached
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用 邮件/个人WhatsApp 联系',
'131026': '[131026] 消息无法投递(未同意WhatsApp 的隐私政策).\n请使用邮件联系',
'131047': '[131047] 会话未激活. \n请使用模板消息💬发送',
'131053': '[131053] 文件上传失败.',
'131048': '[131048] 账户被风控.', // 消息发送太多, 达到垃圾数量限制
'131049': '[131049] 号码触发风控. \n请暂停发送营销消息, 使用"触达率高"模板\n或引导客户主动发起会话.', // 消息发送太多, 营销限制
'131031': '[131031] 账户已被禁用.',
'131049': '[131049] 号码触发风控. \n请暂停发送营销消息, 引导客户主动发起会话.', // 消息发送太多, 营销限制
'131031': '[131031] 账户已锁定.',
'130472': '[130472] 此号码不接收商业号消息\n请使用邮件联系 或 引导客户主动发起会话.',
};

@ -1,4 +1,4 @@
import { ReactNode } from 'react'
import React, { ReactNode } from 'react'
export type ConditionalProps = {
/**
@ -15,10 +15,6 @@ export type ConditionalProps = {
whenFalse: ReactNode
}
export function Conditional({
condition,
whenFalse,
whenTrue,
}: ConditionalProps) {
return condition ? whenTrue : whenFalse
}
export function Conditional({ condition, whenFalse, whenTrue }: ConditionalProps) {
return condition ? whenTrue : whenFalse;
}

@ -46,15 +46,15 @@ const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial
maskClosable={false}
// theme='dark'
// className={'!border !border-solid !border-indigo-500 rounded !p-2' }
titleBarClassName={`!bg-neutral-100 !rounded !rounded-b-none !border-none !p-3 !font-bold !text-slate-600 ${props.titleClassName}`}
className='!rounded-t !rounded-b-none !border !border-solid !border-indigo-300 !shadow-heavy '
titleBarClassName='!bg-neutral-100 !rounded !rounded-b-none !border-none !p-3 !font-bold !text-slate-600'
contentClassName='!p-2'
footerClassName='!p-2'
className={`!rounded-t !rounded-b-none !border !border-solid !shadow-heavy ${props.rootClassName}`}
zIndex={2}
initialWidth={(mobile ? window.innerWidth : (initial.width ?? 680))} // window.innerWidth < 680
initialHeight={(mobile ? window.innerHeight : (initial.height ?? 600))} // window.innerHeight < 700
initialTop={mobile ? 0 : (initial.top ?? 74)}
initialLeft={mobile ? 0 : (initial.left ?? (window.innerWidth - 300))}
initialWidth={(mobile ? window.innerWidth : (initial.width || 680))} // window.innerWidth < 680
initialHeight={(mobile ? window.innerHeight : (initial.height || 600))} // window.innerHeight < 700
initialTop={mobile ? 0 : (initial.top || 74)}
initialLeft={mobile ? 0 : (initial.left || (window.innerWidth - 700))}
title={title}
minimizeButton={<></>}
onMove={onHandleMove}
@ -63,7 +63,6 @@ const DnDModal = ({ children, open, setOpen, onCancel, onMove, onResize, initial
// onOk={onHandleOk}
onStageChange={onStageChange}
footer={footer}
{...props}
{...(mobile ? { maximizeButton: <></> } : {})}>
<>{children}</>
</Modal>

@ -1,43 +0,0 @@
import { useState, useEffect } from 'react'
import LexicalEditor from '@/components/LexicalEditor'
import { isEmpty } from '@/utils/commons';
/**
* 封装的编辑组件, 用于在Form.Item 中使用
*/
const LexicalEditorInput = (props) => {
const { id, value = {}, onChange } = props
const [defaultHtml, setDefaultHtml] = useState('')
useEffect(() => {
if (typeof value === 'string') {
setDefaultHtml(value)
}
return () => {}
}, [value])
/**
* 触发onChange
* changedValue: { editorStateJSON, htmlContent, textContent }
*/
const triggerChange = (changedValue) => {
onChange?.({
...changedValue,
})
}
return (
<LexicalEditor
id={id}
{...{ isRichText: true }}
onChange={(val) => {
triggerChange(val)
}}
defaultValue={defaultHtml}
/>
)
}
export default LexicalEditorInput

@ -15,9 +15,7 @@ class ErrorBoundary extends PureComponent {
componentDidCatch(error, info) {
console.error('Sorry, Something went wrong.')
console.error(error)
if (import.meta.env.PROD && window.$pageSpy) {
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
}
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
this.setState({ hasError: true, info: error.message })
}

@ -1,16 +1,16 @@
import { useRouteError } from 'react-router-dom'
import { Result } from 'antd'
import { useRouteError } from "react-router-dom";
import { Card, Typography, Flex, Result, Button } from 'antd'
export default function ErrorPage() {
const errorResponse = useRouteError()
if (import.meta.env.PROD && window.$pageSpy) {
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload')
}
const errorResponse = useRouteError();
console.info('error: ');
console.dir(errorResponse.message);
window.$pageSpy.triggerPlugins('onOfflineLog', 'upload');
return (
<Result
status="404"
title="Sorry, an unexpected error has occurred."
subTitle={errorResponse?.message || errorResponse.error?.message}
/>
)
status="404"
title="Sorry, an unexpected error has occurred."
subTitle={errorResponse?.message || errorResponse.error?.message}
/>
);
}

@ -1,7 +1,7 @@
import Icon from '@ant-design/icons';
import ReplyLineSVG from '@/assets/icons/reply-line.svg?react';
import ReplyAllLineSVG from '@/assets/icons/reply-all-fill.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';
@ -10,15 +10,6 @@ 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';
import ResendLineSVG from '@/assets/icons/reset-left-line.svg?react';
import EditLineSVG from '@/assets/icons/quill-pen-line.svg?react';
import MailDownloadLineSVG from '@/assets/icons/mail-download-line.svg?react';
import MailOpenLineSVG from '@/assets/icons/mail-open-line.svg?react';
import MailAddLineSVG from '@/assets/icons/mail-add-line.svg?react';
import MailCheckSVG from '@/assets/icons/mail-check-line.svg?react';
import MailUnreadSVG from '@/assets/icons/mail-unread-line.svg?react';
import MailArchiveSVG from '@/assets/icons/archive-2-line.svg?react';
import TextSVG from '@/assets/icons/text.svg?react';
export const ReplyIcon = (props) => <Icon component={ReplyLineSVG} {...props} />;
@ -30,16 +21,6 @@ export const InboxIcon = (props) => <Icon component={InboxSVG} {...props} />;
export const MailSendIcon = (props) => <Icon component={MailSendFillSVG} {...props} />;
export const SendPlaneFillIcon = (props) => <Icon component={SendPlaneFillSVG} {...props} />;
export const SendPlaneLineIcon = (props) => <Icon component={SendPlaneLineSVG} {...props} />;
export const ResendIcon = (props) => <Icon component={ResendLineSVG} {...props} />;
export const EditIcon = (props) => <Icon component={EditLineSVG} {...props} />;
export const MailDownloadIcon = (props) => <Icon component={MailDownloadLineSVG} {...props} />;
export const MailOpenIcon = (props) => <Icon component={MailOpenLineSVG} {...props} />;
export const MailAddloadIcon = (props) => <Icon component={MailAddLineSVG} {...props} />;
export const MailCheckIcon = (props) => <Icon component={MailCheckSVG} {...props} />;
export const MailUnreadIcon = (props) => <Icon component={MailUnreadSVG} {...props} />;
export const MailArchiveIcon = (props) => <Icon component={MailArchiveSVG} {...props} />;
export const TextIcon = (props) => <Icon component={TextSVG} {...props} />;
const WABSvg = () => (
<svg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' width='16' height='16'>
@ -70,21 +51,7 @@ const Sent = () => (
)
export const SentIcon = (props) => <Icon component={Sent} {...props} />;
const Waiting = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" height="1em" width="1em"><path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM13 12H17V14H11V7H13V12Z" stroke="none"></path></svg>
)
export const WaitingIcon = (props) => <Icon component={Waiting} {...props} />;
const Failed = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" color='#ED1C24' height="1em" width="1em"><path d="M11.9997 10.5865L16.9495 5.63672L18.3637 7.05093L13.4139 12.0007L18.3637 16.9504L16.9495 18.3646L11.9997 13.4149L7.04996 18.3646L5.63574 16.9504L10.5855 12.0007L5.63574 7.05093L7.04996 5.63672L11.9997 10.5865Z" stroke="none"></path></svg>
)
export const FailedIcon = (props) => <Icon component={Failed} {...props} />;
const Filter = () => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="1em" width="1em" fill="currentColor"><path d="M6.17071 18C6.58254 16.8348 7.69378 16 9 16C10.3062 16 11.4175 16.8348 11.8293 18H22V20H11.8293C11.4175 21.1652 10.3062 22 9 22C7.69378 22 6.58254 21.1652 6.17071 20H2V18H6.17071ZM12.1707 11C12.5825 9.83481 13.6938 9 15 9C16.3062 9 17.4175 9.83481 17.8293 11H22V13H17.8293C17.4175 14.1652 16.3062 15 15 15C13.6938 15 12.5825 14.1652 12.1707 13H2V11H12.1707ZM6.17071 4C6.58254 2.83481 7.69378 2 9 2C10.3062 2 11.4175 2.83481 11.8293 4H22V6H11.8293C11.4175 7.16519 10.3062 8 9 8C7.69378 8 6.58254 7.16519 6.17071 6H2V4H6.17071Z"></path></svg>
)
export const FilterIcon = (props) => <Icon component={Filter} {...props} />;
const Expand = () => (<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="1em" width="1em" fill="currentColor"><path d="M18.2072 9.0428 12.0001 2.83569 5.793 9.0428 7.20721 10.457 12.0001 5.66412 16.793 10.457 18.2072 9.0428ZM5.79285 14.9572 12 21.1643 18.2071 14.9572 16.7928 13.543 12 18.3359 7.20706 13.543 5.79285 14.9572Z"></path></svg>);
export const ExpandIcon = (props) => <Icon component={Expand} {...props} />;

@ -0,0 +1,145 @@
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 { 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 { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $getSelection, } from 'lexical';
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html';
import './styles.css';
function Placeholder() {
return <div className="editor-placeholder">Enter some rich text...</div>;
}
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,
]
};
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);
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 }) {
return (
<LexicalComposer initialConfig={editorConfig}>
<div className='editor-container'>
{isRichText && <ToolbarPlugin />}
<div className='editor-inner'>
{/* <LexicalPlainText /> */}
{isRichText ? (
<RichTextPlugin contentEditable={<ContentEditable className='editor-input' />} placeholder={<Placeholder />} ErrorBoundary={LexicalErrorBoundary} />
) : (
<PlainTextPlugin contentEditable={<ContentEditable className='editor-pure-input' />} ErrorBoundary={LexicalErrorBoundary} />
)}
<HistoryPlugin />
{import.meta.env.DEV && <TreeViewPlugin />}
<LexicalDefaultValuePlugin value={initialValue} />
<AutoFocusPlugin />
<CodeHighlightPlugin />
<ListPlugin />
<ListMaxIndentLevelPlugin maxDepth={7} />
<LinkPlugin />
<AutoLinkPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<TabFocusPlugin />
<TabIndentationPlugin />
<HorizontalRulePlugin />
<MyOnChangePlugin onChange={onChange}/>
</div>
</div>
</LexicalComposer>
);
}

@ -1,40 +0,0 @@
/**
* 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<SettingName, boolean> = {
...DEFAULT_SETTINGS,
};
export type SettingName = keyof typeof DEFAULT_SETTINGS;
export type Settings = typeof INITIAL_SETTINGS;

@ -1,71 +0,0 @@
/**
* 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<SettingName, boolean>;
};
const Context: React.Context<SettingsContextShape> = 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 <Context.Provider value={contextValue}>{children}</Context.Provider>;
};
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());
}

@ -1,35 +0,0 @@
/**
* 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<ContextShape> = createContext({});
export const SharedHistoryContext = ({
children,
}: {
children: ReactNode;
}): JSX.Element => {
const historyContext = useMemo(
() => ({historyState: createEmptyHistoryState()}),
[],
);
return <Context.Provider value={historyContext}>{children}</Context.Provider>;
};
export const useSharedHistoryContext = (): ContextShape => {
return useContext(Context);
};

@ -1,16 +0,0 @@
/**
* 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 ShowFlashMessage,
useFlashMessageContext,
} from '../context/FlashMessageContext';
export default function useFlashMessage(): ShowFlashMessage {
return useFlashMessageContext();
}

@ -1,60 +0,0 @@
/**
* 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 {useCallback, useMemo, useState} from 'react';
import * as React from 'react';
import Modal from './../ui/Modal';
export default function useModal(): [
JSX.Element | null,
(title: string, showModal: (onClose: () => void) => JSX.Element) => void,
] {
const [modalContent, setModalContent] = useState<null | {
closeOnClickOutside: boolean;
content: JSX.Element;
title: string;
}>(null);
const onClose = useCallback(() => {
setModalContent(null);
}, []);
const modal = useMemo(() => {
if (modalContent === null) {
return null;
}
const {title, content, closeOnClickOutside} = modalContent;
return (
<Modal
onClose={onClose}
title={title}
closeOnClickOutside={closeOnClickOutside}>
{content}
</Modal>
);
}, [modalContent, onClose]);
const showModal = useCallback(
(
title: string,
// eslint-disable-next-line no-shadow
getContent: (onClose: () => void) => JSX.Element,
closeOnClickOutside = false,
) => {
setModalContent({
closeOnClickOutside,
content: getContent(onClose),
title,
});
},
[onClose],
);
return [modal, showModal];
}

@ -1,67 +0,0 @@
/**
* 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 {useCallback, useEffect, useRef} from 'react';
const getElement = (): HTMLElement => {
let element = document.getElementById('report-container');
if (element === null) {
element = document.createElement('div');
element.id = 'report-container';
element.style.position = 'fixed';
element.style.top = '50%';
element.style.left = '50%';
element.style.fontSize = '32px';
element.style.transform = 'translate(-50%, -50px)';
element.style.padding = '20px';
element.style.background = 'rgba(240, 240, 240, 0.4)';
element.style.borderRadius = '20px';
if (document.body) {
document.body.appendChild(element);
}
}
return element;
};
export default function useReport(): (
arg0: string,
) => ReturnType<typeof setTimeout> {
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cleanup = useCallback(() => {
if (timer.current !== null) {
clearTimeout(timer.current);
timer.current = null;
}
if (document.body) {
document.body.removeChild(getElement());
}
}, []);
useEffect(() => {
return cleanup;
}, [cleanup]);
return useCallback(
(content) => {
// eslint-disable-next-line no-console
console.log(content);
const element = getElement();
if (timer.current !== null) {
clearTimeout(timer.current);
}
element.innerHTML = content;
timer.current = setTimeout(cleanup, 1000);
return timer.current;
},
[cleanup],
);
}

@ -1,214 +0,0 @@
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 EditorRefPlugin from './plugins/EditorRefPlugin'
import ImagesPlugin from './plugins/ImagesPlugin';
import InlineImagePlugin from './plugins/InlineImagePlugin';
import DragDropPaste from './plugins/DragDropPastePlugin';
import { ImageNode } from './nodes/ImageNode';
import {InlineImageNode} from './nodes/InlineImageNode/InlineImageNode';
import { ExtendedTextNode } from './nodes/ExtendedTextNode';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
// import { useLexicalEditable } from '@lexical/react/useLexicalEditable';
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
import TableCellResizer from './plugins/TableCellResizer';
// import TableHoverActionsPlugin from './plugins/TableHoverActionsPlugin';
// import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import FormatPaintPlugin from './plugins/FormatPaint';
import { TextNode, $getRoot, $getSelection, $createParagraphNode } from 'lexical';
import { $generateHtmlFromNodes, $generateNodesFromDOM, } from '@lexical/html';
// import { } from '@lexical/clipboard';
import { isEmpty } from '@/utils/commons';
import {useSettings} from './context/SettingsContext';
import './styles.css';
function Placeholder() {
return <div className="editor-placeholder">Enter some rich text...</div>;
}
const editorConfig = {
// The editor theme
// theme: {},
theme: ExampleTheme,
// Handling of errors during update
onError(error) {
console.log(error)
throw error;
},
// Any custom nodes go here
nodes: [
ExtendedTextNode,
{
replace: TextNode,
with: (node) => new ExtendedTextNode(node.__text),
withKlass: ExtendedTextNode,
},
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
TableNode,
TableCellNode,
TableRowNode,
AutoLinkNode,
LinkNode,
HorizontalRuleNode,
ImageNode,InlineImageNode,
]
};
function LexicalDefaultValuePlugin({ value = '' }= {}) {
const [editor] = useLexicalComposerContext();
const updateHTML = (editor, value, clear) => {
const root = $getRoot();
if (clear) {
root.clear();
}
if (isEmpty(value)) {
root.clear();
} else {
const parser = new DOMParser();
const dom = parser.parseFromString(value, "text/html");
const nodes = $generateNodesFromDOM(editor, dom);
nodes.filter(n => n.__size !== 0).forEach((n) => {
const paragraphNode = $createParagraphNode();
paragraphNode.append(n);
root.append(paragraphNode);
});
}
};
useEffect(() => {
if (editor) {
editor.update(() => {
updateHTML(editor, value, true);
});
}
}, [editor, value]);
return null;
}
function MyOnChangePlugin({ ignoreHistoryMergeTagChange = true, ignoreSelectionChange = true, onChange }) {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (typeof onChange === 'function') {
return editor.registerUpdateListener(({editorState, dirtyElements, dirtyLeaves, prevEditorState, tags}) => {
if (
(ignoreSelectionChange &&
dirtyElements.size === 0 &&
dirtyLeaves.size === 0) ||
(ignoreHistoryMergeTagChange && tags.has('history-merge')) ||
prevEditorState.isEmpty()
) {
return;
}
editorState.read(() => {
const root = $getRoot();
const textContent = root.getTextContent();
const html = $generateHtmlFromNodes(editor);
onChange({ editorStateJSON: editorState.toJSON(), editor, tags, htmlContent: html, textContent });
});
});
}
}, [editor, ignoreHistoryMergeTagChange, ignoreSelectionChange, onChange]);
return null;
}
export default function Editor({ isRichText, isDebug, editorRef, onChange, defaultValue, stateJson, ...props }) {
const {
settings: {
isCollab,
isAutocomplete,
isMaxLength,
isCharLimit,
isCharLimitUtf8,
// isRichText,
showTreeView,
showTableOfContents,
shouldUseLexicalContextMenu,
shouldPreserveNewLinesInMarkdown,
tableCellMerge,
tableCellBackgroundColor,
},
} = useSettings();
return (
<LexicalComposer initialConfig={editorConfig}>
<div className='editor-container'>
{isRichText && <ToolbarPlugin />}
<div className='editor-inner'>
{/* <LexicalPlainText /> */}
{isRichText ? (
<RichTextPlugin contentEditable={<ContentEditable className='editor-input' />} placeholder={<Placeholder />} ErrorBoundary={LexicalErrorBoundary} />
) : (
<PlainTextPlugin contentEditable={<ContentEditable className='editor-pure-input' />} ErrorBoundary={LexicalErrorBoundary} />
)}
<HistoryPlugin />
{(import.meta.env.DEV && isDebug) && <TreeViewPlugin />}
<LexicalDefaultValuePlugin value={defaultValue} />
<DragDropPaste />
<AutoFocusPlugin />
<CodeHighlightPlugin />
<ListPlugin />
<ListMaxIndentLevelPlugin maxDepth={7} />
<AutoLinkPlugin />
<LinkPlugin />
<MarkdownShortcutPlugin transformers={TRANSFORMERS} />
<TablePlugin hasCellMerge={tableCellMerge} hasCellBackgroundColor={tableCellBackgroundColor} />
<TableCellResizer />
{/* <TableHoverActionsPlugin /> */}
<TableCellActionMenuPlugin
// anchorElem={floatingAnchorElem}
cellMerge={true}
/>
<TabFocusPlugin />
<TabIndentationPlugin />
<HorizontalRulePlugin />
<EditorRefPlugin editorRef={editorRef} />
<ImagesPlugin />
<InlineImagePlugin />
<FormatPaintPlugin />
<MyOnChangePlugin onChange={onChange}/>
</div>
</div>
</LexicalComposer>
);
}

@ -1,129 +0,0 @@
import {
$applyNodeReplacement,
$isTextNode,
DOMConversion,
DOMConversionMap,
DOMConversionOutput,
NodeKey,
TextNode,
SerializedTextNode,
LexicalNode
} from 'lexical';
export class ExtendedTextNode extends TextNode {
constructor(text: string, key?: NodeKey) {
super(text, key);
}
static getType(): string {
return 'extended-text';
}
static clone(node: ExtendedTextNode): ExtendedTextNode {
return new ExtendedTextNode(node.__text, node.__key);
}
static importDOM(): DOMConversionMap | null {
const importers = TextNode.importDOM();
return {
...importers,
code: () => ({
conversion: patchStyleConversion(importers?.code),
priority: 1
}),
em: () => ({
conversion: patchStyleConversion(importers?.em),
priority: 1
}),
span: () => ({
conversion: patchStyleConversion(importers?.span),
priority: 1
}),
strong: () => ({
conversion: patchStyleConversion(importers?.strong),
priority: 1
}),
sub: () => ({
conversion: patchStyleConversion(importers?.sub),
priority: 1
}),
sup: () => ({
conversion: patchStyleConversion(importers?.sup),
priority: 1
}),
};
}
static importJSON(serializedNode: SerializedTextNode): TextNode {
return TextNode.importJSON(serializedNode);
}
isSimpleText() {
return this.__type === 'extended-text' && this.__mode === 0;
}
exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: 'extended-text',
version: 1,
}
}
}
export function $createExtendedTextNode(text: string): ExtendedTextNode {
return $applyNodeReplacement(new ExtendedTextNode(text));
}
export function $isExtendedTextNode(node: LexicalNode | null | undefined): node is ExtendedTextNode {
return node instanceof ExtendedTextNode;
}
function patchStyleConversion(
originalDOMConverter?: (node: HTMLElement) => DOMConversion | null
): (node: HTMLElement) => DOMConversionOutput | null {
return (node) => {
const original = originalDOMConverter?.(node);
if (!original) {
return null;
}
const originalOutput = original.conversion(node);
if (!originalOutput) {
return originalOutput;
}
const background = node.style.background;
const backgroundColor = node.style.backgroundColor;
const color = node.style.color;
const fontFamily = node.style.fontFamily;
const fontWeight = node.style.fontWeight;
const fontSize = node.style.fontSize;
const textDecoration = node.style.textDecoration;
return {
...originalOutput,
forChild: (lexicalNode, parent) => {
const originalForChild = originalOutput?.forChild ?? ((x) => x);
const result = originalForChild(lexicalNode, parent);
if ($isTextNode(result)) {
const style = [
background ? `background: ${background}` : null,
backgroundColor ? `background-color: ${backgroundColor}` : null,
color ? `color: ${color}` : null,
fontFamily ? `font-family: ${fontFamily}` : null,
fontWeight ? `font-weight: ${fontWeight}` : null,
fontSize ? `font-size: ${fontSize}` : null,
textDecoration ? `text-decoration: ${textDecoration}` : null,
]
.filter((value) => value != null)
.join('; ');
if (style.length) {
return result.setStyle(style);
}
}
return result;
}
};
};
}

@ -1,487 +0,0 @@
/**
* 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<MouseEvent> =
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 (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
style={{
height,
maxWidth,
width,
}}
onError={onError}
draggable="false"
/>
);
}
function BrokenImage(): JSX.Element {
return (
<img
// src={brokenImage}
src=''
style={{
height: 200,
opacity: 0.2,
width: 200,
}}
draggable="false"
/>
);
}
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 | HTMLImageElement>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [isResizing, setIsResizing] = useState<boolean>(false);
const {isCollabActive} = useCollaborationContext();
const [editor] = useLexicalComposerContext();
const [selection, setSelection] = useState<BaseSelection | null>(null);
const activeEditorRef = useRef<LexicalEditor | null>(null);
const [isLoadError, setIsLoadError] = useState<boolean>(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<MouseEvent>(
CLICK_COMMAND,
onClick,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<MouseEvent>(
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 (
<Suspense fallback={null}>
<>
<div draggable={draggable}>
{isLoadError ? (
<BrokenImage />
) : (
<LazyImage
className={
isFocused
? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
: null
}
src={src}
altText={altText}
imageRef={imageRef}
width={width}
height={height}
maxWidth={maxWidth}
onError={() => setIsLoadError(true)}
/>
)}
</div>
{showCaption && (
<div className="image-caption-container">
<LexicalNestedComposer
initialEditor={caption}
initialNodes={[
RootNode,
TextNode,
LineBreakNode,
ParagraphNode,
LinkNode,
// EmojiNode,
HashtagNode,
// KeywordNode,
]}>
<AutoFocusPlugin />
{/* <MentionsPlugin /> */}
<LinkPlugin />
{/* <EmojisPlugin /> */}
<HashtagPlugin />
{/* <KeywordsPlugin /> */}
{/* {isCollabActive ? (
<CollaborationPlugin
id={caption.getKey()}
providerFactory={createWebsocketProvider}
shouldBootstrap={true}
/>
) : (
<HistoryPlugin externalHistoryState={historyState} />
)} */}
<HistoryPlugin externalHistoryState={historyState} />
<RichTextPlugin
contentEditable={
<ContentEditable
placeholder="Enter a caption..."
placeholderClassName="ImageNode__placeholder"
className="ImageNode__contentEditable"
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
{/* {showNestedEditorTreeView === true ? <TreeViewPlugin /> : null} */}
</LexicalNestedComposer>
</div>
)}
{resizable && $isNodeSelection(selection) && isFocused && (
<ImageResizer
showCaption={showCaption}
setShowCaption={setShowCaption}
editor={editor}
buttonRef={buttonRef}
imageRef={imageRef}
maxWidth={maxWidth}
onResizeStart={onResizeStart}
onResizeEnd={onResizeEnd}
captionsEnabled={!isLoadError && captionsEnabled}
/>
)}
</>
</Suspense>
);
}

@ -1,43 +0,0 @@
/**
* 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;
}

@ -1,266 +0,0 @@
/**
* 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<JSX.Element> {
__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 (
<Suspense fallback={null}>
<ImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
maxWidth={this.__maxWidth}
nodeKey={this.getKey()}
showCaption={this.__showCaption}
caption={this.__caption}
captionsEnabled={this.__captionsEnabled}
resizable={true}
/>
</Suspense>
);
}
}
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;
}

@ -1,410 +0,0 @@
/**
* 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 {Position} from './InlineImageNode';
import type {BaseSelection, LexicalEditor, NodeKey} from 'lexical';
import './InlineImageNode.css';
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
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,
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
DRAGSTART_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import * as React from 'react';
import {Suspense, useCallback, useEffect, useRef, useState} from 'react';
import useModal from '../../hooks/useModal';
import LinkPlugin from '../../plugins/LinkPlugin';
import Button from '../../ui/Button';
import ContentEditable from '../../ui/ContentEditable';
import {DialogActions} from '../../ui/Dialog';
import Select from '../../ui/Select';
import TextInput from '../../ui/TextInput';
import {$isInlineImageNode, InlineImageNode} from './InlineImageNode';
const imageCache = new Set();
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);
};
});
}
}
function LazyImage({
altText,
className,
imageRef,
src,
width,
height,
position,
}: {
altText: string;
className: string | null;
height: 'inherit' | number;
imageRef: {current: null | HTMLImageElement};
src: string;
width: 'inherit' | number;
position: Position;
}): JSX.Element {
useSuspenseImage(src);
return (
<img
className={className || undefined}
src={src}
alt={altText}
ref={imageRef}
data-position={position}
style={{
display: 'block',
height,
width,
}}
draggable="false"
/>
);
}
export function UpdateInlineImageDialog({
activeEditor,
nodeKey,
onClose,
}: {
activeEditor: LexicalEditor;
nodeKey: NodeKey;
onClose: () => void;
}): JSX.Element {
const editorState = activeEditor.getEditorState();
const node = editorState.read(
() => $getNodeByKey(nodeKey) as InlineImageNode,
);
const [altText, setAltText] = useState(node.getAltText());
const [showCaption, setShowCaption] = useState(node.getShowCaption());
const [position, setPosition] = useState<Position>(node.getPosition());
const handleShowCaptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowCaption(e.target.checked);
};
const handlePositionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPosition(e.target.value as Position);
};
const handleOnConfirm = () => {
const payload = {altText, position, showCaption};
if (node) {
activeEditor.update(() => {
node.update(payload);
});
}
onClose();
};
return (
<>
<div style={{marginBottom: '1em'}}>
<TextInput
label="Alt Text"
placeholder="Descriptive alternative text"
onChange={setAltText}
value={altText}
data-test-id="image-modal-alt-text-input"
/>
</div>
<Select
style={{marginBottom: '1em', width: '208px'}}
value={position}
label="Position"
name="position"
id="position-select"
onChange={handlePositionChange}>
<option value="left">Left</option>
<option value="right">Right</option>
<option value="full">Full Width</option>
</Select>
<div className="Input__wrapper">
<input
id="caption"
type="checkbox"
checked={showCaption}
onChange={handleShowCaptionChange}
/>
<label htmlFor="caption">Show Caption</label>
</div>
<DialogActions>
<Button
data-test-id="image-modal-file-upload-btn"
onClick={() => handleOnConfirm()}>
Confirm
</Button>
</DialogActions>
</>
);
}
export default function InlineImageComponent({
src,
altText,
nodeKey,
width,
height,
showCaption,
caption,
position,
}: {
altText: string;
caption: LexicalEditor;
height: 'inherit' | number;
nodeKey: NodeKey;
showCaption: boolean;
src: string;
width: 'inherit' | number;
position: Position;
}): JSX.Element {
const [modal, showModal] = useModal();
const imageRef = useRef<null | HTMLImageElement>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const [isSelected, setSelected, clearSelection] =
useLexicalNodeSelection(nodeKey);
const [editor] = useLexicalComposerContext();
const [selection, setSelection] = useState<BaseSelection | null>(null);
const activeEditorRef = useRef<LexicalEditor | null>(null);
const $onDelete = useCallback(
(payload: KeyboardEvent) => {
const deleteSelection = $getSelection();
if (isSelected && $isNodeSelection(deleteSelection)) {
const event: KeyboardEvent = payload;
event.preventDefault();
if (isSelected && $isNodeSelection(deleteSelection)) {
editor.update(() => {
deleteSelection.getNodes().forEach((node) => {
if ($isInlineImageNode(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],
);
useEffect(() => {
let isMounted = true;
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<MouseEvent>(
CLICK_COMMAND,
(payload) => {
const event = payload;
if (event.target === imageRef.current) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
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,
),
);
return () => {
isMounted = false;
unregister();
};
}, [
clearSelection,
editor,
isSelected,
nodeKey,
$onDelete,
$onEnter,
$onEscape,
setSelected,
]);
const draggable = isSelected && $isNodeSelection(selection);
const isFocused = isSelected;
return (
<Suspense fallback={null}>
<>
<span draggable={draggable}>
{/* <button
className="image-edit-button"
ref={buttonRef}
onClick={() => {
showModal('Update Inline Image', (onClose) => (
<UpdateInlineImageDialog
activeEditor={editor}
nodeKey={nodeKey}
onClose={onClose}
/>
));
}}>
Edit
</button> */}
<LazyImage
className={
isFocused
? `focused ${$isNodeSelection(selection) ? 'draggable' : ''}`
: null
}
src={src}
altText={altText}
imageRef={imageRef}
width={width}
height={height}
position={position}
/>
</span>
{showCaption && (
<span className="image-caption-container">
<LexicalNestedComposer initialEditor={caption}>
<AutoFocusPlugin />
<LinkPlugin />
<RichTextPlugin
contentEditable={
<ContentEditable
placeholder="Enter a caption..."
placeholderClassName="InlineImageNode__placeholder"
className="InlineImageNode__contentEditable"
/>
}
ErrorBoundary={LexicalErrorBoundary}
/>
</LexicalNestedComposer>
</span>
)}
</>
{modal}
</Suspense>
);
}

@ -1,94 +0,0 @@
/**
* 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.
*
*
*/
.InlineImageNode__contentEditable {
min-height: 20px;
border: 0px;
resize: none;
cursor: text;
caret-color: rgb(5, 5, 5);
display: block;
position: relative;
tab-size: 1;
outline: 0px;
padding: 10px;
user-select: text;
font-size: 14px;
line-height: 1.4em;
width: calc(100% - 20px);
white-space: pre-wrap;
word-break: break-word;
}
.InlineImageNode__placeholder {
font-size: 12px;
color: #888;
overflow: hidden;
position: absolute;
text-overflow: ellipsis;
bottom: 10px;
left: 10px;
user-select: none;
white-space: nowrap;
display: inline-block;
pointer-events: none;
}
.InlineImageNode_Checkbox:checked,
.InlineImageNode_Checkbox:not(:checked) {
position: absolute;
left: -9999px;
}
.InlineImageNode_Checkbox:checked + label,
.InlineImageNode_Checkbox:not(:checked) + label {
position: absolute;
padding-right: 55px;
cursor: pointer;
line-height: 20px;
display: inline-block;
color: #666;
}
.InlineImageNode_Checkbox:checked + label:before,
.InlineImageNode_Checkbox:not(:checked) + label:before {
content: '';
position: absolute;
right: 0;
top: 0;
width: 18px;
height: 18px;
border: 1px solid #666;
background: #fff;
}
.InlineImageNode_Checkbox:checked + label:after,
.InlineImageNode_Checkbox:not(:checked) + label:after {
content: '';
width: 8px;
height: 8px;
background: #222222;
position: absolute;
top: 6px;
right: 6px;
-webkit-transition: all 0.2s ease;
transition: all 0.2s ease;
}
.InlineImageNode_Checkbox:not(:checked) + label:after {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
.InlineImageNode_Checkbox:checked + label:after {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}

@ -1,294 +0,0 @@
/**
* 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 InlineImageComponent = React.lazy(() => import('./InlineImageComponent'));
export type Position = 'left' | 'right' | 'full' | undefined;
export interface InlineImagePayload {
altText: string;
caption?: LexicalEditor;
height?: number;
key?: NodeKey;
showCaption?: boolean;
src: string;
width?: number;
position?: Position;
}
export interface UpdateInlineImagePayload {
altText?: string;
showCaption?: boolean;
position?: Position;
}
function $convertInlineImageElement(domNode: Node): null | DOMConversionOutput {
if (domNode instanceof HTMLImageElement) {
const {alt: altText, src, width, height} = domNode;
const node = $createInlineImageNode({altText, height, src, width});
return {node};
}
return null;
}
export type SerializedInlineImageNode = Spread<
{
altText: string;
caption: SerializedEditor;
height?: number;
showCaption: boolean;
src: string;
width?: number;
position?: Position;
},
SerializedLexicalNode
>;
export class InlineImageNode extends DecoratorNode<JSX.Element> {
__src: string;
__altText: string;
__width: 'inherit' | number;
__height: 'inherit' | number;
__showCaption: boolean;
__caption: LexicalEditor;
__position: Position;
static getType(): string {
return 'inline-image';
}
static clone(node: InlineImageNode): InlineImageNode {
return new InlineImageNode(
node.__src,
node.__altText,
node.__position,
node.__width,
node.__height,
node.__showCaption,
node.__caption,
node.__key,
);
}
static importJSON(
serializedNode: SerializedInlineImageNode,
): InlineImageNode {
const {altText, height, width, caption, src, showCaption, position} =
serializedNode;
const node = $createInlineImageNode({
altText,
height,
position,
showCaption,
src,
width,
});
const nestedEditor = node.__caption;
const editorState = nestedEditor.parseEditorState(caption.editorState);
if (!editorState.isEmpty()) {
nestedEditor.setEditorState(editorState);
}
return node;
}
static importDOM(): DOMConversionMap | null {
return {
img: (node: Node) => ({
conversion: $convertInlineImageElement,
priority: 0,
}),
};
}
constructor(
src: string,
altText: string,
position: Position,
width?: 'inherit' | number,
height?: 'inherit' | number,
showCaption?: boolean,
caption?: LexicalEditor,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__altText = altText;
this.__width = width || 'inherit';
this.__height = height || 'inherit';
this.__showCaption = showCaption || false;
this.__caption = caption || createEditor();
this.__position = position;
}
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};
}
exportJSON(): SerializedInlineImageNode {
return {
altText: this.getAltText(),
caption: this.__caption.toJSON(),
height: this.__height === 'inherit' ? 0 : this.__height,
position: this.__position,
showCaption: this.__showCaption,
src: this.getSrc(),
type: 'inline-image',
version: 1,
width: this.__width === 'inherit' ? 0 : this.__width,
};
}
getSrc(): string {
return this.__src;
}
getAltText(): string {
return this.__altText;
}
setAltText(altText: string): void {
const writable = this.getWritable();
writable.__altText = altText;
}
setWidthAndHeight(
width: 'inherit' | number,
height: 'inherit' | number,
): void {
const writable = this.getWritable();
writable.__width = width;
writable.__height = height;
}
getShowCaption(): boolean {
return this.__showCaption;
}
setShowCaption(showCaption: boolean): void {
const writable = this.getWritable();
writable.__showCaption = showCaption;
}
getPosition(): Position {
return this.__position;
}
setPosition(position: Position): void {
const writable = this.getWritable();
writable.__position = position;
}
update(payload: UpdateInlineImagePayload): void {
const writable = this.getWritable();
const {altText, showCaption, position} = payload;
if (altText !== undefined) {
writable.__altText = altText;
}
if (showCaption !== undefined) {
writable.__showCaption = showCaption;
}
if (position !== undefined) {
writable.__position = position;
}
}
// View
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const className = `${config.theme.inlineImage} position-${this.__position}`;
if (className !== undefined) {
span.className = className;
}
return span;
}
updateDOM(
prevNode: InlineImageNode,
dom: HTMLElement,
config: EditorConfig,
): false {
const position = this.__position;
if (position !== prevNode.__position) {
const className = `${config.theme.inlineImage} position-${position}`;
if (className !== undefined) {
dom.className = className;
}
}
return false;
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<InlineImageComponent
src={this.__src}
altText={this.__altText}
width={this.__width}
height={this.__height}
nodeKey={this.getKey()}
showCaption={this.__showCaption}
caption={this.__caption}
position={this.__position}
/>
</Suspense>
);
}
}
export function $createInlineImageNode({
altText,
position,
height,
src,
width,
showCaption,
caption,
key,
}: InlineImagePayload): InlineImageNode {
return $applyNodeReplacement(
new InlineImageNode(
src,
altText,
position,
width,
height,
showCaption,
caption,
key,
),
);
}
export function $isInlineImageNode(
node: LexicalNode | null | undefined,
): node is InlineImageNode {
return node instanceof InlineImageNode;
}

@ -16,17 +16,17 @@ const MATCHERS = [
}
);
},
// (text) => {
// const match = EMAIL_MATCHER.exec(text);
// return (
// match && {
// index: match.index,
// length: match[0].length,
// text: match[0],
// url: `mailto:${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() {

@ -1,51 +0,0 @@
/**
* 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;
}

@ -1,24 +0,0 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useEffect } from 'react'
/**
*
* Use this plugin to access the editor instance outside of the
* LexicalComposer. This can help with things like buttons or other
* UI components that need to update or read EditorState but need to
* be positioned outside the LexicalComposer in the React tree.
*/
export default function EditorRefPlugin({ editorRef }) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (typeof editorRef === 'function') {
editorRef(editor)
} else if (typeof editorRef === 'object') {
editorRef.current = editor
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editor])
return null
}

@ -1,41 +0,0 @@
.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;
}

@ -1,393 +0,0 @@
/**
* 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<boolean>;
anchorElem: HTMLElement;
isLinkEditMode: boolean;
setIsLinkEditMode: Dispatch<boolean>;
}): JSX.Element {
const editorRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [linkUrl, setLinkUrl] = useState('');
const [editedLinkUrl, setEditedLinkUrl] = useState('https://');
const [lastSelection, setLastSelection] = useState<BaseSelection | null>(
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<HTMLInputElement>,
) => {
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 (
<div ref={editorRef} className="link-editor">
{!isLink ? null : isLinkEditMode ? (
<>
<input
ref={inputRef}
className="link-input"
value={editedLinkUrl}
onChange={(event) => {
setEditedLinkUrl(event.target.value);
}}
onKeyDown={(event) => {
monitorInputInteraction(event);
}}
/>
<div>
<div
className="link-cancel"
role="button"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setIsLinkEditMode(false);
}}
/>
<div
className="link-confirm"
role="button"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={handleLinkSubmission}
/>
</div>
</>
) : (
<div className="link-view">
<a
href={sanitizeUrl(linkUrl)}
target="_blank"
rel="noopener noreferrer">
{linkUrl}
</a>
<div
className="link-edit"
role="button"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setEditedLinkUrl(linkUrl);
setIsLinkEditMode(true);
}}
/>
<div
className="link-trash"
role="button"
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
}}
/>
</div>
)}
</div>
);
}
function useFloatingLinkEditorToolbar(
editor: LexicalEditor,
anchorElem: HTMLElement,
isLinkEditMode: boolean,
setIsLinkEditMode: Dispatch<boolean>,
): 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(
<FloatingLinkEditor
editor={activeEditor}
isLink={isLink}
anchorElem={anchorElem}
setIsLink={setIsLink}
isLinkEditMode={isLinkEditMode}
setIsLinkEditMode={setIsLinkEditMode}
/>,
anchorElem,
);
}
export default function FloatingLinkEditorPlugin({
anchorElem = document.body,
isLinkEditMode,
setIsLinkEditMode,
}: {
anchorElem?: HTMLElement;
isLinkEditMode: boolean;
setIsLinkEditMode: Dispatch<boolean>;
}): JSX.Element | null {
const [editor] = useLexicalComposerContext();
return useFloatingLinkEditorToolbar(
editor,
anchorElem,
isLinkEditMode,
setIsLinkEditMode,
);
}

@ -1,27 +0,0 @@
import { LexicalCommand, createCommand, TextFormatType } from 'lexical';
export interface CopiedFormat {
textFormatFlags: number; // 从node.getFormat()
style: string; // 从node.getStyle()
// todo: p 标签的样式
}
export interface ActivateFormatPainterPayload {
sticky: boolean;
}
// activate the format painter and copy the current selection's format
export const ACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand<ActivateFormatPainterPayload> =
createCommand('ACTIVATE_FORMAT_PAINTER_COMMAND');
// deactivate the format painter
export const DEACTIVATE_FORMAT_PAINTER_COMMAND: LexicalCommand<void> =
createCommand('DEACTIVATE_FORMAT_PAINTER_COMMAND');
// dispatched by the plugin to inform UI about state changes
export interface FormatPainterState {
isActive: boolean;
isSticky: boolean;
}
export const FORMAT_PAINTER_STATE_UPDATE_COMMAND: LexicalCommand<FormatPainterState> =
createCommand('FORMAT_PAINTER_STATE_UPDATE_COMMAND');

@ -1,86 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getSelection, $isRangeSelection, LexicalEditor, COMMAND_PRIORITY_NORMAL } from 'lexical';
import {
ACTIVATE_FORMAT_PAINTER_COMMAND,
DEACTIVATE_FORMAT_PAINTER_COMMAND,
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
FormatPainterState,
} from './FormatPainterCommands';
const PaintBrushIcon = () => <i className='format painter' />;
export function FormatPainterToolbarButton() {
const [editor] = useLexicalComposerContext();
const [isActive, setIsActive] = useState(false);
const [isSticky, setIsSticky] = useState(false);
const [canCopy, setCanCopy] = useState(false);
// 插件状态
useEffect(() => {
return editor.registerCommand<FormatPainterState>(
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
(payload) => {
setIsActive(payload.isActive);
setIsSticky(payload.isSticky);
return true;
},
COMMAND_PRIORITY_NORMAL,
);
}, [editor]);
// 选区状态
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
setCanCopy(true);
} else {
setCanCopy(false);
}
});
});
}, [editor]);
const handleClick = () => {
if (isActive) {
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
} else if (canCopy) {
editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: false });
}
// * !isActive and !canCopy 什么也不做
};
// 双击 保持激活
const handleDoubleClick = () => {
if (isActive && isSticky) {
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
} else if (canCopy) {
editor.dispatchCommand(ACTIVATE_FORMAT_PAINTER_COMMAND, { sticky: true });
}
};
return (
<button
type="button"
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className={`toolbar-item spaced ${isActive ? 'active' : ''}`}
// title={isActive ? (isSticky ? 'Format Painter (Sticky)' : 'Format Painter (Active)') : 'Format Painter'}
title={'格式刷'}
aria-label={isActive ? (isSticky ? 'Deactivate Format Painter (Sticky)' : 'Deactivate Format Painter (Active)') : 'Activate Format Painter'}
disabled={!isActive && !canCopy}
>
<PaintBrushIcon />
{/* <span style={{wordBreak: 'keep-all'}}>格式刷</span> */}
</button>
);
}
{/* <button type='button'
className={'toolbar-item spaced ' + (isActive ? 'active' : '')}
aria-label='Format Painter'>
<i className='format painter' />
</button> */}
export default FormatPainterToolbarButton;

@ -1,267 +0,0 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import {
$getSelection,
$isRangeSelection,
$isTextNode,
TextFormatType,
LexicalEditor,
COMMAND_PRIORITY_NORMAL,
COMMAND_PRIORITY_LOW,
} from 'lexical';
import { $patchStyleText, } from '@lexical/selection';
// $patchStyleText is more efficient for merging styles.
import {
CopiedFormat,
ACTIVATE_FORMAT_PAINTER_COMMAND,
DEACTIVATE_FORMAT_PAINTER_COMMAND,
FORMAT_PAINTER_STATE_UPDATE_COMMAND,
ActivateFormatPainterPayload,
FormatPainterState,
} from './FormatPainterCommands';
// parse style string to object for $patchStyleText
function parseStyleText(style: string): Record<string, string> {
const styleObj: Record<string, string> = {};
style.split(';').forEach((rule) => {
const [key, value] = rule.split(':');
if (key && value && key.trim() && value.trim()) {
styleObj[key.trim()] = value.trim();
}
});
return styleObj;
}
// map format flags to TextFormatType
const textFormatTypeMap: { flag: number; type: TextFormatType }[] = [
{ flag: 1, type: 'bold' },
{ flag: 2, type: 'italic' },
{ flag: 4, type: 'strikethrough' },
{ flag: 8, type: 'underline' },
{ flag: 16, type: 'code' },
{ flag: 32, type: 'subscript' },
{ flag: 64, type: 'superscript' },
];
export function FormatPainterPlugin(): null {
const [editor] = useLexicalComposerContext();
const [copiedFormat, setCopiedFormat] = useState<CopiedFormat | null>(null);
const [isActive, setIsActive] = useState(false);
const [isSticky, setIsSticky] = useState(false);
// 避免多次复制
const isPickingUpRef = useRef(false);
const broadcastState = useCallback(() => {
editor.dispatchCommand(FORMAT_PAINTER_STATE_UPDATE_COMMAND, {
isActive,
isSticky,
});
}, [editor, isActive, isSticky]);
// Update broadcast whenever state changes
useEffect(() => {
broadcastState();
}, [isActive, isSticky, broadcastState]);
// Activate Format Painter (Copy Format)
useEffect(() => {
return editor.registerCommand<ActivateFormatPainterPayload>(
ACTIVATE_FORMAT_PAINTER_COMMAND,
(payload) => {
isPickingUpRef.current = true;
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
const anchorNode = selection.anchor.getNode();
let formatToCopy: CopiedFormat | null = null;
if ($isTextNode(anchorNode)) {
formatToCopy = {
textFormatFlags: anchorNode.getFormat(),
style: anchorNode.getStyle(),
};
} else {
// ? todo: 从第一个字符获取格式
const nodes = selection.getNodes();
for (const node of nodes) {
if ($isTextNode(node)) {
formatToCopy = {
textFormatFlags: node.getFormat(),
style: node.getStyle(),
};
break;
}
}
}
if (formatToCopy) {
setCopiedFormat(formatToCopy);
setIsActive(true);
setIsSticky(payload.sticky);
// console.log('Format Painter Activated. Sticky:', payload.sticky, 'Format:', formatToCopy);
}
}
});
// 鼠标抬起
setTimeout(() => { isPickingUpRef.current = false; }, 50);
return true;
},
COMMAND_PRIORITY_NORMAL,
);
}, [editor]);
// Deactivate Format Painter
useEffect(() => {
return editor.registerCommand<void>(
DEACTIVATE_FORMAT_PAINTER_COMMAND,
() => {
if (!isActive) return false;
setIsActive(false);
setIsSticky(false);
// 不保留
setCopiedFormat(null);
// console.log('Format Painter Deactivated.');
return true;
},
COMMAND_PRIORITY_NORMAL,
);
}, [editor, isActive]);
// 应用复制的格式
const applyFormat = useCallback(() => {
if (!isActive || !copiedFormat || isPickingUpRef.current) {
return false;
}
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) && copiedFormat) {
// console.log('copiedFormat:', copiedFormat, '\ntextFormatTypeMap:', textFormatTypeMap);
// TextNode (bold, italic, ...)
textFormatTypeMap.forEach(fmt => {
if (copiedFormat.textFormatFlags & fmt.flag) {
selection.formatText(fmt.type);
} else {
const currentSelection = $getSelection();
if ($isRangeSelection(currentSelection)) {
textFormatTypeMap.forEach(fmt => {
const shouldHaveFormat = (copiedFormat.textFormatFlags & fmt.flag) > 0;
if (currentSelection.hasFormat(fmt.type) !== shouldHaveFormat) {
currentSelection.formatText(fmt.type);
}
});
}
}
});
// ensure applied
let newSelection = $getSelection();
if ($isRangeSelection(newSelection)) {
textFormatTypeMap.forEach(fmt => {
if (copiedFormat.textFormatFlags & fmt.flag) {
if (!newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type);
} else {
if (newSelection!.hasFormat(fmt.type)) newSelection!.formatText(fmt.type);
}
});
}
// inline styles (font-family, color, font-size, ...)
const stylesToApply = parseStyleText(copiedFormat.style);
// console.log('inline style', stylesToApply);
if (Object.keys(stylesToApply).length > 0) {
newSelection = $getSelection();
if ($isRangeSelection(newSelection)) {
$patchStyleText(newSelection as any, stylesToApply);
}
} else {
// 清除格式
const selectedNodes = newSelection.getNodes();
selectedNodes.forEach(node => {
if ($isTextNode(node)) {
if (node.getStyle() !== "") {
node.setStyle(""); // 清除
}
}
// todo: <p> node
});
}
// console.log('Format Applied. Sticky:', isSticky);
if (!isSticky) {
setIsActive(false);
}
}
});
return true;
}, [editor, isActive, isSticky, copiedFormat]);
// 鼠标抬起
useEffect(() => {
if (!isActive || !copiedFormat) return;
const editorElement = editor.getRootElement();
if (!editorElement) return;
const handleMouseUp = (event: MouseEvent) => {
if (isPickingUpRef.current) return;
if (editorElement.contains(event.target as Node)) {
// todo: 改为在下一帧更新
setTimeout(() => {
const selection = editor.getEditorState().read($getSelection);
if ($isRangeSelection(selection) && !selection.isCollapsed()) {
applyFormat();
} else if ($isRangeSelection(selection) && selection.isCollapsed()) {
// 折叠的选区, 也应用
// applyFormat();
}
}, 0);
}
};
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mouseup', handleMouseUp);
};
}, [editor, isActive, copiedFormat, applyFormat]);
// 按 esc 键取消格式
useEffect(() => {
if (!isActive) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
editor.dispatchCommand(DEACTIVATE_FORMAT_PAINTER_COMMAND, undefined);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [editor, isActive]);
// 鼠标样式
useEffect(() => {
const editorElement = editor.getRootElement();
if (editorElement) {
editorElement.style.cursor = isActive ? 'copy' : 'auto';
}
return () => {
if (editorElement) {
editorElement.style.cursor = 'auto';
}
};
}, [editor, isActive]);
return null;
}
export default FormatPainterPlugin;

@ -1,408 +0,0 @@
/**
* 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';
import { postUploadFileItem } from '../../../../actions/CommonActions.js';
export type InsertImagePayload = Readonly<ImagePayload>;
// 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<InsertImagePayload> =
createCommand('INSERT_IMAGE_COMMAND');
export function InsertImageUriDialogBody({
onClick,
}: {
onClick: (payload: InsertImagePayload) => void;
}) {
const [src, setSrc] = useState('');
const [altText, setAltText] = useState('');
const isDisabled = src === '';
return (
<>
<TextInput
label="Image URL"
placeholder="i.e. https://source.unsplash.com/random"
onChange={setSrc}
value={src}
data-test-id="image-modal-url-input"
/>
{/* <TextInput
label="Alt Text"
placeholder="Random unsplash image"
onChange={setAltText}
value={altText}
data-test-id="image-modal-alt-text-input"
/> */}
<DialogActions>
<Button
data-test-id="image-modal-confirm-btn"
disabled={isDisabled}
onClick={() => onClick({altText, src})}>
Confirm
</Button>
</DialogActions>
</>
);
}
export function InsertImageUploadedDialogBody({
onClick,
}: {
onClick: (payload: InsertImagePayload) => void;
}) {
const [src, setSrc] = useState('');
const [altText, setAltText] = useState('');
const isDisabled = src === '';
const [uploading, setUploading] = useState(false);
const loadImage = async (files: FileList | null) => {
setUploading(true);
const _tmpFile = files[0];
const suffix = _tmpFile.name.slice(_tmpFile.name.lastIndexOf('.') + 1).toLocaleLowerCase();
const newName = `${Date.now().toString(32)}.${suffix}`;
const { file_url } = await postUploadFileItem(_tmpFile, newName);
setUploading(false);
if (file_url) {
setSrc(file_url);
return file_url;
}
// const reader = new FileReader();
// reader.onload = function () {
// if (typeof reader.result === 'string') {
// setSrc(reader.result);
// }
// return '';
// };
// if (files !== null) {
// reader.readAsDataURL(files[0]);
// }
};
return (
<>
<FileInput
label="Image Upload"
onChange={loadImage}
accept="image/*"
data-test-id="image-modal-file-upload"
/>
{/* <TextInput
label="Alt Text"
placeholder="Descriptive alternative text"
onChange={setAltText}
value={altText}
data-test-id="image-modal-alt-text-input"
/> */}
<DialogActions>
<Button
data-test-id="image-modal-file-upload-btn"
disabled={isDisabled}
onClick={() => onClick({altText, src})}>
{uploading ? 'Pls wait...' : 'Confirm'}
</Button>
</DialogActions>
</>
);
}
export function InsertImageDialog({
activeEditor,
onClose,
}: {
activeEditor: LexicalEditor;
onClose: () => void;
}): JSX.Element {
const [mode, setMode] = useState<null | 'url' | 'file'>(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) => {
// console.log('payload', payload);
activeEditor.dispatchCommand(INSERT_IMAGE_COMMAND, payload);
onClose();
};
return (
<>
{!mode && (
<DialogButtonsList>
{/* <Button
data-test-id="image-modal-option-sample"
onClick={() =>
onClick(
hasModifier.current
? {
altText:
'Daylight fir trees forest glacier green high ice landscape',
src: landscapeImage,
}
: {
altText: 'Yellow flower in tilt shift lens',
src: yellowFlowerImage,
},
)
}>
Sample
</Button> */}
<Button
data-test-id="image-modal-option-url"
onClick={() => setMode('url')}>
URL
</Button>
<Button
data-test-id="image-modal-option-file"
onClick={() => setMode('file')}>
File
</Button>
</DialogButtonsList>
)}
{mode === 'url' && <InsertImageUriDialogBody onClick={onClick} />}
{mode === 'file' && <InsertImageUploadedDialogBody onClick={onClick} />}
</>
);
}
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<InsertImagePayload>(
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<DragEvent>(
DRAGSTART_COMMAND,
(event) => {
return $onDragStart(event);
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand<DragEvent>(
DRAGOVER_COMMAND,
(event) => {
return $onDragover(event);
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<DragEvent>(
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;
}

@ -1,359 +0,0 @@
/**
* 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 {Position} from '../../nodes/InlineImageNode/InlineImageNode';
import '../../nodes/InlineImageNode/InlineImageNode.css';
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 * as React from 'react';
import {useEffect, useRef, useState} from 'react';
// import {CAN_USE_DOM} from 'shared/canUseDOM';
import {
$createInlineImageNode,
$isInlineImageNode,
InlineImageNode,
InlineImagePayload,
} from '../../nodes/InlineImageNode/InlineImageNode';
import Button from '../../ui/Button';
import {DialogActions} from '../../ui/Dialog';
import FileInput from '../../ui/FileInput';
// import Select from '../../ui/Select';
import TextInput from '../../ui/TextInput';
import { postUploadFileItem } from '../../../../actions/CommonActions.js';
export type InsertInlineImagePayload = Readonly<InlineImagePayload>;
const getDOMSelection = (targetWindow: Window | null): Selection | null =>
// CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
(targetWindow || window).getSelection() ;
export const INSERT_INLINE_IMAGE_COMMAND: LexicalCommand<InlineImagePayload> =
createCommand('INSERT_INLINE_IMAGE_COMMAND');
export function InsertInlineImageDialog({
activeEditor,
onClose,
}: {
activeEditor: LexicalEditor;
onClose: () => void;
}): JSX.Element {
const hasModifier = useRef(false);
const [src, setSrc] = useState('');
const [altText, setAltText] = useState('');
const [showCaption, setShowCaption] = useState(false);
const [position, setPosition] = useState<Position>('left');
const isDisabled = src === '';
const handleShowCaptionChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setShowCaption(e.target.checked);
};
const handlePositionChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setPosition(e.target.value as Position);
};
const [uploading, setUploading] = useState(false);
const loadImage = async (files: FileList | null) => {
setUploading(true);
const _tmpFile = files[0];
const suffix = _tmpFile.name.slice(_tmpFile.name.lastIndexOf('.') + 1).toLocaleLowerCase();
const newName = `${Date.now().toString(32)}.${suffix}`;
const { file_url } = await postUploadFileItem(_tmpFile, newName);
setUploading(false);
if (file_url) {
setSrc(file_url);
return file_url;
}
// const reader = new FileReader();
// reader.onload = function () {
// if (typeof reader.result === 'string') {
// setSrc(reader.result);
// }
// return '';
// };
// if (files !== null) {
// reader.readAsDataURL(files[0]);
// }
};
useEffect(() => {
hasModifier.current = false;
const handler = (e: KeyboardEvent) => {
hasModifier.current = e.altKey;
};
document.addEventListener('keydown', handler);
return () => {
document.removeEventListener('keydown', handler);
};
}, [activeEditor]);
const handleOnClick = () => {
const payload = {altText, position, showCaption, src};
// console.log('payload', payload, activeEditor);
activeEditor.dispatchCommand(INSERT_INLINE_IMAGE_COMMAND, payload);
onClose();
};
return (
<>
<div style={{marginBottom: '1em'}}>
<FileInput
label="Image Upload"
onChange={loadImage}
accept="image/*"
data-test-id="image-modal-file-upload"
/>
</div>
{/* <div style={{marginBottom: '1em'}}>
<TextInput
label="Alt Text"
placeholder="Descriptive alternative text"
onChange={setAltText}
value={altText}
data-test-id="image-modal-alt-text-input"
/>
</div> */}
{/* <Select
style={{marginBottom: '1em', width: '290px'}}
label="Position"
name="position"
id="position-select"
onChange={handlePositionChange}>
<option value="left">Left</option>
<option value="right">Right</option>
<option value="full">Full Width</option>
</Select> */}
{/* <div className="Input__wrapper">
<input
id="caption"
className="InlineImageNode_Checkbox"
type="checkbox"
checked={showCaption}
onChange={handleShowCaptionChange}
/>
<label htmlFor="caption">Show Caption</label>
</div> */}
<DialogActions>
<Button
data-test-id="image-modal-file-upload-btn"
disabled={isDisabled}
onClick={() => handleOnClick()}>
{uploading ? 'Uploading, Pls wait...' : 'Confirm'}
</Button>
</DialogActions>
</>
);
}
export default function InlineImagePlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([InlineImageNode])) {
throw new Error('ImagesPlugin: ImageNode not registered on editor');
}
return mergeRegister(
editor.registerCommand<InsertInlineImagePayload>(
INSERT_INLINE_IMAGE_COMMAND,
(payload) => {
const imageNode = $createInlineImageNode(payload);
$insertNodes([imageNode]);
if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
$wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand<DragEvent>(
DRAGSTART_COMMAND,
(event) => {
return $onDragStart(event);
},
COMMAND_PRIORITY_HIGH,
),
editor.registerCommand<DragEvent>(
DRAGOVER_COMMAND,
(event) => {
return $onDragover(event);
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<DragEvent>(
DROP_COMMAND,
(event) => {
return $onDrop(event, editor);
},
COMMAND_PRIORITY_HIGH,
),
);
}, [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(),
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_INLINE_IMAGE_COMMAND, data);
}
return true;
}
function $getImageNodeInSelection(): InlineImageNode | null {
const selection = $getSelection();
if (!$isNodeSelection(selection)) {
return null;
}
const nodes = selection.getNodes();
const node = nodes[0];
return $isInlineImageNode(node) ? node : null;
}
function getDragImageData(event: DragEvent): null | InsertInlineImagePayload {
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;
}

@ -1,16 +0,0 @@
/**
* 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 <LexicalLinkPlugin validateUrl={validateUrl} />;
}

@ -1,773 +0,0 @@
/**
* 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 {ElementNode, LexicalEditor} from 'lexical';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import {
$deleteTableColumn__EXPERIMENTAL,
$deleteTableRow__EXPERIMENTAL,
$getNodeTriplet,
$getTableCellNodeFromLexicalNode,
$getTableColumnIndexFromTableCellNode,
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$insertTableColumn__EXPERIMENTAL,
$insertTableRow__EXPERIMENTAL,
$isTableCellNode,
$isTableRowNode,
$isTableSelection,
$unmergeCell,
getTableObserverFromTableElement,
HTMLTableElementWithWithTableSelectionState,
TableCellHeaderStates,
TableCellNode,
TableRowNode,
TableSelection,
} from '@lexical/table';
import {
$createParagraphNode,
$getRoot,
$getSelection,
$isElementNode,
$isParagraphNode,
$isRangeSelection,
$isTextNode,
} from 'lexical';
import * as React from 'react';
import {ReactPortal, useCallback, useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import useModal from '../../hooks/useModal';
import ColorPicker from '../../ui/ColorPicker';
function computeSelectionCount(selection: TableSelection): {
columns: number;
rows: number;
} {
const selectionShape = selection.getShape();
return {
columns: selectionShape.toX - selectionShape.fromX + 1,
rows: selectionShape.toY - selectionShape.fromY + 1,
};
}
function $canUnmerge(): boolean {
const selection = $getSelection();
if (
($isRangeSelection(selection) && !selection.isCollapsed()) ||
($isTableSelection(selection) && !selection.anchor.is(selection.focus)) ||
(!$isRangeSelection(selection) && !$isTableSelection(selection))
) {
return false;
}
const [cell] = $getNodeTriplet(selection.anchor);
return cell.__colSpan > 1 || cell.__rowSpan > 1;
}
function $cellContainsEmptyParagraph(cell: TableCellNode): boolean {
if (cell.getChildrenSize() !== 1) {
return false;
}
const firstChild = cell.getFirstChildOrThrow();
if (!$isParagraphNode(firstChild) || !firstChild.isEmpty()) {
return false;
}
return true;
}
function $selectLastDescendant(node: ElementNode): void {
const lastDescendant = node.getLastDescendant();
if ($isTextNode(lastDescendant)) {
lastDescendant.select();
} else if ($isElementNode(lastDescendant)) {
lastDescendant.selectEnd();
} else if (lastDescendant !== null) {
lastDescendant.selectNext();
}
}
function currentCellBackgroundColor(editor: LexicalEditor): null | string {
return editor.getEditorState().read(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
const [cell] = $getNodeTriplet(selection.anchor);
if ($isTableCellNode(cell)) {
return cell.getBackgroundColor();
}
}
return null;
});
}
type TableCellActionMenuProps = Readonly<{
contextRef: {current: null | HTMLElement};
onClose: () => void;
setIsMenuOpen: (isOpen: boolean) => void;
showColorPickerModal: (
title: string,
showModal: (onClose: () => void) => JSX.Element,
) => void;
tableCellNode: TableCellNode;
cellMerge: boolean;
}>;
function TableActionMenu({
onClose,
tableCellNode: _tableCellNode,
setIsMenuOpen,
contextRef,
cellMerge,
showColorPickerModal,
}: TableCellActionMenuProps) {
const [editor] = useLexicalComposerContext();
const dropDownRef = useRef<HTMLDivElement | null>(null);
const [tableCellNode, updateTableCellNode] = useState(_tableCellNode);
const [selectionCounts, updateSelectionCounts] = useState({
columns: 1,
rows: 1,
});
const [canMergeCells, setCanMergeCells] = useState(false);
const [canUnmergeCell, setCanUnmergeCell] = useState(false);
const [backgroundColor, setBackgroundColor] = useState(
() => currentCellBackgroundColor(editor) || '',
);
useEffect(() => {
return editor.registerMutationListener(
TableCellNode,
(nodeMutations) => {
const nodeUpdated =
nodeMutations.get(tableCellNode.getKey()) === 'updated';
if (nodeUpdated) {
editor.getEditorState().read(() => {
updateTableCellNode(tableCellNode.getLatest());
});
setBackgroundColor(currentCellBackgroundColor(editor) || '');
}
},
{skipInitialization: true},
);
}, [editor, tableCellNode]);
useEffect(() => {
editor.getEditorState().read(() => {
const selection = $getSelection();
// Merge cells
if ($isTableSelection(selection)) {
const currentSelectionCounts = computeSelectionCount(selection);
updateSelectionCounts(computeSelectionCount(selection));
setCanMergeCells(
currentSelectionCounts.columns > 1 || currentSelectionCounts.rows > 1,
);
}
// Unmerge cell
setCanUnmergeCell($canUnmerge());
});
}, [editor]);
useEffect(() => {
const menuButtonElement = contextRef.current;
const dropDownElement = dropDownRef.current;
const rootElement = editor.getRootElement();
if (
menuButtonElement != null &&
dropDownElement != null &&
rootElement != null
) {
const rootEleRect = rootElement.getBoundingClientRect();
const menuButtonRect = menuButtonElement.getBoundingClientRect();
dropDownElement.style.opacity = '1';
const dropDownElementRect = dropDownElement.getBoundingClientRect();
const margin = 5;
let leftPosition = menuButtonRect.right + margin;
if (
leftPosition + dropDownElementRect.width > window.innerWidth ||
leftPosition + dropDownElementRect.width > rootEleRect.right
) {
const position =
menuButtonRect.left - dropDownElementRect.width - margin;
leftPosition = (position < 0 ? margin : position) + window.pageXOffset;
}
dropDownElement.style.left = `${leftPosition + window.pageXOffset}px`;
let topPosition = menuButtonRect.top;
if (topPosition + dropDownElementRect.height > window.innerHeight) {
const position = menuButtonRect.bottom - dropDownElementRect.height;
topPosition = (position < 0 ? margin : position) + window.pageYOffset;
}
dropDownElement.style.top = `${topPosition + +window.pageYOffset}px`;
}
}, [contextRef, dropDownRef, editor]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropDownRef.current != null &&
contextRef.current != null &&
!dropDownRef.current.contains(event.target as Node) &&
!contextRef.current.contains(event.target as Node)
) {
setIsMenuOpen(false);
}
}
window.addEventListener('click', handleClickOutside);
return () => window.removeEventListener('click', handleClickOutside);
}, [setIsMenuOpen, contextRef]);
const clearTableSelection = useCallback(() => {
editor.update(() => {
if (tableCellNode.isAttached()) {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
const tableElement = editor.getElementByKey(
tableNode.getKey(),
) as HTMLTableElementWithWithTableSelectionState;
if (!tableElement) {
throw new Error('Expected to find tableElement in DOM');
}
const tableObserver = getTableObserverFromTableElement(tableElement);
if (tableObserver !== null) {
tableObserver.clearHighlight();
}
tableNode.markDirty();
updateTableCellNode(tableCellNode.getLatest());
}
const rootNode = $getRoot();
rootNode.selectStart();
});
}, [editor, tableCellNode]);
const mergeTableCellsAtSelection = () => {
editor.update(() => {
const selection = $getSelection();
if ($isTableSelection(selection)) {
const {columns, rows} = computeSelectionCount(selection);
const nodes = selection.getNodes();
let firstCell: null | TableCellNode = null;
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isTableCellNode(node)) {
if (firstCell === null) {
node.setColSpan(columns).setRowSpan(rows);
firstCell = node;
const isEmpty = $cellContainsEmptyParagraph(node);
let firstChild;
if (
isEmpty &&
$isParagraphNode((firstChild = node.getFirstChild()))
) {
firstChild.remove();
}
} else if ($isTableCellNode(firstCell)) {
const isEmpty = $cellContainsEmptyParagraph(node);
if (!isEmpty) {
firstCell.append(...node.getChildren());
}
node.remove();
}
}
}
if (firstCell !== null) {
if (firstCell.getChildrenSize() === 0) {
firstCell.append($createParagraphNode());
}
$selectLastDescendant(firstCell);
}
onClose();
}
});
};
const unmergeTableCellsAtSelection = () => {
editor.update(() => {
$unmergeCell();
});
};
const insertTableRowAtSelection = useCallback(
(shouldInsertAfter: boolean) => {
editor.update(() => {
$insertTableRow__EXPERIMENTAL(shouldInsertAfter);
onClose();
});
},
[editor, onClose],
);
const insertTableColumnAtSelection = useCallback(
(shouldInsertAfter: boolean) => {
editor.update(() => {
for (let i = 0; i < selectionCounts.columns; i++) {
$insertTableColumn__EXPERIMENTAL(shouldInsertAfter);
}
onClose();
});
},
[editor, onClose, selectionCounts.columns],
);
const deleteTableRowAtSelection = useCallback(() => {
editor.update(() => {
$deleteTableRow__EXPERIMENTAL();
onClose();
});
}, [editor, onClose]);
const deleteTableAtSelection = useCallback(() => {
editor.update(() => {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
tableNode.remove();
clearTableSelection();
onClose();
});
}, [editor, tableCellNode, clearTableSelection, onClose]);
const deleteTableColumnAtSelection = useCallback(() => {
editor.update(() => {
$deleteTableColumn__EXPERIMENTAL();
onClose();
});
}, [editor, onClose]);
const toggleTableRowIsHeader = useCallback(() => {
editor.update(() => {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
const tableRowIndex = $getTableRowIndexFromTableCellNode(tableCellNode);
const tableRows = tableNode.getChildren();
if (tableRowIndex >= tableRows.length || tableRowIndex < 0) {
throw new Error('Expected table cell to be inside of table row.');
}
const tableRow = tableRows[tableRowIndex];
if (!$isTableRowNode(tableRow)) {
throw new Error('Expected table row');
}
const newStyle =
tableCellNode.getHeaderStyles() ^ TableCellHeaderStates.ROW;
tableRow.getChildren().forEach((tableCell) => {
if (!$isTableCellNode(tableCell)) {
throw new Error('Expected table cell');
}
tableCell.setHeaderStyles(newStyle, TableCellHeaderStates.ROW);
});
clearTableSelection();
onClose();
});
}, [editor, tableCellNode, clearTableSelection, onClose]);
const toggleTableColumnIsHeader = useCallback(() => {
editor.update(() => {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
const tableColumnIndex =
$getTableColumnIndexFromTableCellNode(tableCellNode);
const tableRows = tableNode.getChildren<TableRowNode>();
const maxRowsLength = Math.max(
...tableRows.map((row) => row.getChildren().length),
);
if (tableColumnIndex >= maxRowsLength || tableColumnIndex < 0) {
throw new Error('Expected table cell to be inside of table row.');
}
const newStyle =
tableCellNode.getHeaderStyles() ^ TableCellHeaderStates.COLUMN;
for (let r = 0; r < tableRows.length; r++) {
const tableRow = tableRows[r];
if (!$isTableRowNode(tableRow)) {
throw new Error('Expected table row');
}
const tableCells = tableRow.getChildren();
if (tableColumnIndex >= tableCells.length) {
// if cell is outside of bounds for the current row (for example various merge cell cases) we shouldn't highlight it
continue;
}
const tableCell = tableCells[tableColumnIndex];
if (!$isTableCellNode(tableCell)) {
throw new Error('Expected table cell');
}
tableCell.setHeaderStyles(newStyle, TableCellHeaderStates.COLUMN);
}
clearTableSelection();
onClose();
});
}, [editor, tableCellNode, clearTableSelection, onClose]);
const toggleRowStriping = useCallback(() => {
editor.update(() => {
if (tableCellNode.isAttached()) {
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
if (tableNode) {
tableNode.setRowStriping(!tableNode.getRowStriping());
}
}
clearTableSelection();
onClose();
});
}, [editor, tableCellNode, clearTableSelection, onClose]);
const handleCellBackgroundColor = useCallback(
(value: string) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
const [cell] = $getNodeTriplet(selection.anchor);
if ($isTableCellNode(cell)) {
cell.setBackgroundColor(value);
}
if ($isTableSelection(selection)) {
const nodes = selection.getNodes();
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if ($isTableCellNode(node)) {
node.setBackgroundColor(value);
}
}
}
}
});
},
[editor],
);
let mergeCellButton: null | JSX.Element = null;
if (cellMerge) {
if (canMergeCells) {
mergeCellButton = (
<button
type="button"
className="item"
onClick={() => mergeTableCellsAtSelection()}
data-test-id="table-merge-cells">
Merge cells
</button>
);
} else if (canUnmergeCell) {
mergeCellButton = (
<button
type="button"
className="item"
onClick={() => unmergeTableCellsAtSelection()}
data-test-id="table-unmerge-cells">
Unmerge cells
</button>
);
}
}
return createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="dropdown"
ref={dropDownRef}
onClick={(e) => {
e.stopPropagation();
}}>
{mergeCellButton}
<button
type="button"
className="item"
onClick={() =>
showColorPickerModal('Cell background color', () => (
<ColorPicker
color={backgroundColor}
onChange={handleCellBackgroundColor}
/>
))
}
data-test-id="table-background-color">
<span className="text">Background color</span>
</button>
<button
type="button"
className="item"
onClick={() => toggleRowStriping()}
data-test-id="table-row-striping">
<span className="text">Toggle Row Striping</span>
</button>
<hr />
<button
type="button"
className="item"
onClick={() => insertTableRowAtSelection(false)}
data-test-id="table-insert-row-above">
<span className="text">
Insert{' '}
{selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`}{' '}
above
</span>
</button>
<button
type="button"
className="item"
onClick={() => insertTableRowAtSelection(true)}
data-test-id="table-insert-row-below">
<span className="text">
Insert{' '}
{selectionCounts.rows === 1 ? 'row' : `${selectionCounts.rows} rows`}{' '}
below
</span>
</button>
<hr />
<button
type="button"
className="item"
onClick={() => insertTableColumnAtSelection(false)}
data-test-id="table-insert-column-before">
<span className="text">
Insert{' '}
{selectionCounts.columns === 1
? 'column'
: `${selectionCounts.columns} columns`}{' '}
left
</span>
</button>
<button
type="button"
className="item"
onClick={() => insertTableColumnAtSelection(true)}
data-test-id="table-insert-column-after">
<span className="text">
Insert{' '}
{selectionCounts.columns === 1
? 'column'
: `${selectionCounts.columns} columns`}{' '}
right
</span>
</button>
<hr />
<button
type="button"
className="item"
onClick={() => deleteTableColumnAtSelection()}
data-test-id="table-delete-columns">
<span className="text">Delete column</span>
</button>
<button
type="button"
className="item"
onClick={() => deleteTableRowAtSelection()}
data-test-id="table-delete-rows">
<span className="text">Delete row</span>
</button>
<button
type="button"
className="item"
onClick={() => deleteTableAtSelection()}
data-test-id="table-delete">
<span className="text">Delete table</span>
</button>
<hr />
<button
type="button"
className="item"
onClick={() => toggleTableRowIsHeader()}>
<span className="text">
{(tableCellNode.__headerState & TableCellHeaderStates.ROW) ===
TableCellHeaderStates.ROW
? 'Remove'
: 'Add'}{' '}
row header
</span>
</button>
<button
type="button"
className="item"
onClick={() => toggleTableColumnIsHeader()}
data-test-id="table-column-header">
<span className="text">
{(tableCellNode.__headerState & TableCellHeaderStates.COLUMN) ===
TableCellHeaderStates.COLUMN
? 'Remove'
: 'Add'}{' '}
column header
</span>
</button>
</div>,
document.body,
);
}
function TableCellActionMenuContainer({
anchorElem,
cellMerge,
}: {
anchorElem: HTMLElement;
cellMerge: boolean;
}): JSX.Element {
const [editor] = useLexicalComposerContext();
const menuButtonRef = useRef(null);
const menuRootRef = useRef(null);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [tableCellNode, setTableMenuCellNode] = useState<TableCellNode | null>(
null,
);
const [colorPickerModal, showColorPickerModal] = useModal();
const $moveMenu = useCallback(() => {
const menu = menuButtonRef.current;
const selection = $getSelection();
const nativeSelection = window.getSelection();
const activeElement = document.activeElement;
if (selection == null || menu == null) {
setTableMenuCellNode(null);
return;
}
const rootElement = editor.getRootElement();
if (
$isRangeSelection(selection) &&
rootElement !== null &&
nativeSelection !== null &&
rootElement.contains(nativeSelection.anchorNode)
) {
const tableCellNodeFromSelection = $getTableCellNodeFromLexicalNode(
selection.anchor.getNode(),
);
if (tableCellNodeFromSelection == null) {
setTableMenuCellNode(null);
return;
}
const tableCellParentNodeDOM = editor.getElementByKey(
tableCellNodeFromSelection.getKey(),
);
if (tableCellParentNodeDOM == null) {
setTableMenuCellNode(null);
return;
}
setTableMenuCellNode(tableCellNodeFromSelection);
} else if (!activeElement) {
setTableMenuCellNode(null);
}
}, [editor]);
useEffect(() => {
return editor.registerUpdateListener(() => {
editor.getEditorState().read(() => {
$moveMenu();
});
});
});
useEffect(() => {
const menuButtonDOM = menuButtonRef.current as HTMLButtonElement | null;
if (menuButtonDOM != null && tableCellNode != null) {
const tableCellNodeDOM = editor.getElementByKey(tableCellNode.getKey());
if (tableCellNodeDOM != null) {
const tableCellRect = tableCellNodeDOM.getBoundingClientRect();
const menuRect = menuButtonDOM.getBoundingClientRect();
const anchorRect = anchorElem.getBoundingClientRect();
const top = tableCellRect.top - anchorRect.top + 4;
const left =
tableCellRect.right - menuRect.width - 10 - anchorRect.left;
menuButtonDOM.style.opacity = '1';
menuButtonDOM.style.transform = `translate(${left}px, ${top}px)`;
} else {
menuButtonDOM.style.opacity = '0';
menuButtonDOM.style.transform = 'translate(-10000px, -10000px)';
}
}
}, [menuButtonRef, tableCellNode, editor, anchorElem]);
const prevTableCellDOM = useRef(tableCellNode);
useEffect(() => {
if (prevTableCellDOM.current !== tableCellNode) {
setIsMenuOpen(false);
}
prevTableCellDOM.current = tableCellNode;
}, [prevTableCellDOM, tableCellNode]);
return (
<div className="table-cell-action-button-container" ref={menuButtonRef}>
{tableCellNode != null && (
<>
<button
type="button"
className="table-cell-action-button chevron-down"
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(!isMenuOpen);
}}
ref={menuRootRef}>
<i className="chevron-down" />
</button>
{colorPickerModal}
{isMenuOpen && (
<TableActionMenu
contextRef={menuRootRef}
setIsMenuOpen={setIsMenuOpen}
onClose={() => setIsMenuOpen(false)}
tableCellNode={tableCellNode}
cellMerge={cellMerge}
showColorPickerModal={showColorPickerModal}
/>
)}
</>
)}
</div>
);
}
export default function TableActionMenuPlugin({
anchorElem = document.body,
cellMerge = false,
}: {
anchorElem?: HTMLElement;
cellMerge?: boolean;
}): null | ReactPortal {
const isEditable = useLexicalEditable();
return createPortal(
isEditable ? (
<TableCellActionMenuContainer
anchorElem={anchorElem}
cellMerge={cellMerge}
/>
) : null,
anchorElem,
);
}

@ -1,13 +0,0 @@
/**
* 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.
*
*
*/
.TableCellResizer__resizer {
position: absolute;
z-index: 1202;
}

@ -1,439 +0,0 @@
/**
* 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 {TableCellNode, TableDOMCell, TableMapType} from '@lexical/table';
import type {LexicalEditor} from 'lexical';
import './index.css';
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
import {
$computeTableMapSkipCellCheck,
$getTableNodeFromLexicalNodeOrThrow,
$getTableRowIndexFromTableCellNode,
$isTableCellNode,
$isTableRowNode,
getDOMCellFromTarget,
TableNode,
} from '@lexical/table';
import {calculateZoomLevel} from '@lexical/utils';
import {$getNearestNodeFromDOMNode} from 'lexical';
import * as React from 'react';
import {
MouseEventHandler,
ReactPortal,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import {createPortal} from 'react-dom';
type MousePosition = {
x: number;
y: number;
};
type MouseDraggingDirection = 'right' | 'bottom';
const MIN_ROW_HEIGHT = 33;
const MIN_COLUMN_WIDTH = 92;
function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
const targetRef = useRef<HTMLElement | null>(null);
const resizerRef = useRef<HTMLDivElement | null>(null);
const tableRectRef = useRef<ClientRect | null>(null);
const mouseStartPosRef = useRef<MousePosition | null>(null);
const [mouseCurrentPos, updateMouseCurrentPos] =
useState<MousePosition | null>(null);
const [activeCell, updateActiveCell] = useState<TableDOMCell | null>(null);
const [isMouseDown, updateIsMouseDown] = useState<boolean>(false);
const [draggingDirection, updateDraggingDirection] =
useState<MouseDraggingDirection | null>(null);
const resetState = useCallback(() => {
updateActiveCell(null);
targetRef.current = null;
updateDraggingDirection(null);
mouseStartPosRef.current = null;
tableRectRef.current = null;
}, []);
const isMouseDownOnEvent = (event: MouseEvent) => {
return (event.buttons & 1) === 1;
};
useEffect(() => {
return editor.registerNodeTransform(TableNode, (tableNode) => {
// console.dir(TableNode);
// console.dir(tableNode);
if (tableNode.getColWidths()) {
return tableNode;
}
const numColumns = tableNode.getColumnCount();
const columnWidth = MIN_COLUMN_WIDTH;
tableNode.setColWidths(Array(numColumns).fill(columnWidth));
return tableNode;
});
}, [editor]);
useEffect(() => {
const onMouseMove = (event: MouseEvent) => {
setTimeout(() => {
const target = event.target;
if (draggingDirection) {
updateMouseCurrentPos({
x: event.clientX,
y: event.clientY,
});
return;
}
updateIsMouseDown(isMouseDownOnEvent(event));
if (resizerRef.current && resizerRef.current.contains(target as Node)) {
return;
}
if (targetRef.current !== target) {
targetRef.current = target as HTMLElement;
const cell = getDOMCellFromTarget(target as HTMLElement);
if (cell && activeCell !== cell) {
editor.update(() => {
const tableCellNode = $getNearestNodeFromDOMNode(cell.elem);
if (!tableCellNode) {
throw new Error('TableCellResizer: Table cell node not found.');
}
const tableNode =
$getTableNodeFromLexicalNodeOrThrow(tableCellNode);
const tableElement = editor.getElementByKey(tableNode.getKey());
if (!tableElement) {
throw new Error('TableCellResizer: Table element not found.');
}
targetRef.current = target as HTMLElement;
tableRectRef.current = tableElement.getBoundingClientRect();
updateActiveCell(cell);
});
} else if (cell == null) {
resetState();
}
}
}, 0);
};
const onMouseDown = (event: MouseEvent) => {
setTimeout(() => {
updateIsMouseDown(true);
}, 0);
};
const onMouseUp = (event: MouseEvent) => {
setTimeout(() => {
updateIsMouseDown(false);
}, 0);
};
const removeRootListener = editor.registerRootListener(
(rootElement, prevRootElement) => {
prevRootElement?.removeEventListener('mousemove', onMouseMove);
prevRootElement?.removeEventListener('mousedown', onMouseDown);
prevRootElement?.removeEventListener('mouseup', onMouseUp);
rootElement?.addEventListener('mousemove', onMouseMove);
rootElement?.addEventListener('mousedown', onMouseDown);
rootElement?.addEventListener('mouseup', onMouseUp);
},
);
return () => {
removeRootListener();
};
}, [activeCell, draggingDirection, editor, resetState]);
const isHeightChanging = (direction: MouseDraggingDirection) => {
if (direction === 'bottom') {
return true;
}
return false;
};
const updateRowHeight = useCallback(
(heightChange: number) => {
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.');
}
editor.update(
() => {
const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem);
if (!$isTableCellNode(tableCellNode)) {
throw new Error('TableCellResizer: Table cell node not found.');
}
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
const tableRowIndex =
$getTableRowIndexFromTableCellNode(tableCellNode) +
tableCellNode.getRowSpan() -
1;
const tableRows = tableNode.getChildren();
if (tableRowIndex >= tableRows.length || tableRowIndex < 0) {
throw new Error('Expected table cell to be inside of table row.');
}
const tableRow = tableRows[tableRowIndex];
if (!$isTableRowNode(tableRow)) {
throw new Error('Expected table row');
}
let height = tableRow.getHeight();
if (height === undefined) {
const rowCells = tableRow.getChildren<TableCellNode>();
height = Math.min(
...rowCells.map(
(cell) => getCellNodeHeight(cell, editor) ?? Infinity,
),
);
}
const newHeight = Math.max(height + heightChange, MIN_ROW_HEIGHT);
tableRow.setHeight(newHeight);
},
{tag: 'skip-scroll-into-view'},
);
},
[activeCell, editor],
);
const getCellNodeHeight = (
cell: TableCellNode,
activeEditor: LexicalEditor,
): number | undefined => {
const domCellNode = activeEditor.getElementByKey(cell.getKey());
return domCellNode?.clientHeight;
};
const getCellColumnIndex = (
tableCellNode: TableCellNode,
tableMap: TableMapType,
) => {
for (let row = 0; row < tableMap.length; row++) {
for (let column = 0; column < tableMap[row].length; column++) {
if (tableMap[row][column].cell === tableCellNode) {
return column;
}
}
}
};
const updateColumnWidth = useCallback(
(widthChange: number) => {
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.');
}
editor.update(
() => {
const tableCellNode = $getNearestNodeFromDOMNode(activeCell.elem);
if (!$isTableCellNode(tableCellNode)) {
throw new Error('TableCellResizer: Table cell node not found.');
}
const tableNode = $getTableNodeFromLexicalNodeOrThrow(tableCellNode);
const [tableMap] = $computeTableMapSkipCellCheck(
tableNode,
null,
null,
);
const columnIndex = getCellColumnIndex(tableCellNode, tableMap);
if (columnIndex === undefined) {
throw new Error('TableCellResizer: Table column not found.');
}
const colWidths = tableNode.getColWidths();
if (!colWidths) {
return;
}
const width = colWidths[columnIndex];
if (width === undefined) {
return;
}
const newColWidths = [...colWidths];
const newWidth = Math.max(width + widthChange, MIN_COLUMN_WIDTH);
newColWidths[columnIndex] = newWidth;
tableNode.setColWidths(newColWidths);
},
{tag: 'skip-scroll-into-view'},
);
},
[activeCell, editor],
);
const mouseUpHandler = useCallback(
(direction: MouseDraggingDirection) => {
const handler = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.');
}
if (mouseStartPosRef.current) {
const {x, y} = mouseStartPosRef.current;
if (activeCell === null) {
return;
}
const zoom = calculateZoomLevel(event.target as Element);
if (isHeightChanging(direction)) {
const heightChange = (event.clientY - y) / zoom;
updateRowHeight(heightChange);
} else {
const widthChange = (event.clientX - x) / zoom;
updateColumnWidth(widthChange);
}
resetState();
document.removeEventListener('mouseup', handler);
}
};
return handler;
},
[activeCell, resetState, updateColumnWidth, updateRowHeight],
);
const toggleResize = useCallback(
(direction: MouseDraggingDirection): MouseEventHandler<HTMLDivElement> =>
(event) => {
event.preventDefault();
event.stopPropagation();
if (!activeCell) {
throw new Error('TableCellResizer: Expected active cell.');
}
mouseStartPosRef.current = {
x: event.clientX,
y: event.clientY,
};
updateMouseCurrentPos(mouseStartPosRef.current);
updateDraggingDirection(direction);
document.addEventListener('mouseup', mouseUpHandler(direction));
},
[activeCell, mouseUpHandler],
);
const getResizers = useCallback(() => {
if (activeCell) {
const {height, width, top, left} =
activeCell.elem.getBoundingClientRect();
const zoom = calculateZoomLevel(activeCell.elem);
const zoneWidth = 10; // Pixel width of the zone where you can drag the edge
const styles = {
bottom: {
backgroundColor: 'none',
cursor: 'row-resize',
height: `${zoneWidth}px`,
left: `${window.pageXOffset + left}px`,
top: `${window.pageYOffset + top + height - zoneWidth / 2}px`,
width: `${width}px`,
},
right: {
backgroundColor: 'none',
cursor: 'col-resize',
height: `${height}px`,
left: `${window.pageXOffset + left + width - zoneWidth / 2}px`,
top: `${window.pageYOffset + top}px`,
width: `${zoneWidth}px`,
},
};
const tableRect = tableRectRef.current;
if (draggingDirection && mouseCurrentPos && tableRect) {
if (isHeightChanging(draggingDirection)) {
styles[draggingDirection].left = `${
window.pageXOffset + tableRect.left
}px`;
styles[draggingDirection].top = `${
window.pageYOffset + mouseCurrentPos.y / zoom
}px`;
styles[draggingDirection].height = '3px';
styles[draggingDirection].width = `${tableRect.width}px`;
} else {
styles[draggingDirection].top = `${
window.pageYOffset + tableRect.top
}px`;
styles[draggingDirection].left = `${
window.pageXOffset + mouseCurrentPos.x / zoom
}px`;
styles[draggingDirection].width = '3px';
styles[draggingDirection].height = `${tableRect.height}px`;
}
styles[draggingDirection].backgroundColor = '#adf';
}
return styles;
}
return {
bottom: null,
left: null,
right: null,
top: null,
};
}, [activeCell, draggingDirection, mouseCurrentPos]);
const resizerStyles = getResizers();
return (
<div ref={resizerRef}>
{activeCell != null && !isMouseDown && (
<>
<div
className="TableCellResizer__resizer TableCellResizer__ui"
style={resizerStyles.right || undefined}
onMouseDown={toggleResize('right')}
/>
<div
className="TableCellResizer__resizer TableCellResizer__ui"
style={resizerStyles.bottom || undefined}
onMouseDown={toggleResize('bottom')}
/>
</>
)}
</div>
);
}
export default function TableCellResizerPlugin(): null | ReactPortal {
const [editor] = useLexicalComposerContext();
const isEditable = useLexicalEditable();
return useMemo(
() =>
isEditable
? createPortal(<TableCellResizer editor={editor} />, document.body)
: null,
[editor, isEditable],
);
}

@ -15,9 +15,8 @@ import {
$isRangeSelection,
$createParagraphNode,
$getNodeByKey,
$isTextNode,
} from 'lexical';
import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link';
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '@lexical/link';
import {
$getSelectionStyleValueForProperty,
$isParentElementRTL,
@ -26,25 +25,14 @@ import {
// $wrapNodes,
$isAtNodeEnd,
} from '@lexical/selection';
import { $findMatchingParent, $getNearestNodeOfType, $getNearestBlockElementAncestorOrThrow, mergeRegister } from '@lexical/utils';
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, $isQuoteNode } from '@lexical/rich-text';
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 {$isDecoratorBlockNode} from '@lexical/react/LexicalDecoratorBlockNode';
import {$isTableSelection} from '@lexical/table';
import DropDown, { DropDownItem } from './../ui/DropDown';
import DropdownColorPicker from '../ui/DropdownColorPicker';
import {
// INSERT_IMAGE_COMMAND,
InsertImageDialog,
// InsertImagePayload,
} from './ImagesPlugin';
import {InsertInlineImageDialog} from './InlineImagePlugin';
import FormatPainterToolbarButton from './FormatPaint/FormatPainterToolbarButton';
import useModal from './../hooks/useModal';
const LowPriority = 1;
@ -74,19 +62,16 @@ const FONT_FAMILY_OPTIONS = [
const FONT_SIZE_OPTIONS = [
['10px', '10px'],
// ['11px', '11px'],
['11px', '11px'],
['12px', '12px'],
['13px', '13px'],
['14px', '14px'],
// ['15px', '15px'],
['15px', '15px'],
['16px', '16px'],
// ['17px', '17px'],
['17px', '17px'],
['18px', '18px'],
// ['19px', '19px'],
['19px', '19px'],
['20px', '20px'],
['24px', '24px'],
['32px', '32px'],
// ['48px', '48px'],
];
const ELEMENT_FORMAT_OPTIONS = {
@ -98,62 +83,6 @@ const ELEMENT_FORMAT_OPTIONS = {
start: { icon: 'left-align', iconRTL: 'right-align', name: 'Start Align' },
};
// toolbar utils
const clearFormatting = (editor) => {
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
const anchor = selection.anchor;
const focus = selection.focus;
const nodes = selection.getNodes();
const extractedNodes = selection.extract();
if (anchor.key === focus.key && anchor.offset === focus.offset) {
return;
}
nodes.forEach((node, idx) => {
// We split the first and last node by the selection
// So that we don't format unselected text inside those nodes
if ($isTextNode(node)) {
// Use a separate variable to ensure TS does not lose the refinement
let textNode = node;
if (idx === 0 && anchor.offset !== 0) {
textNode = textNode.splitText(anchor.offset)[1] || textNode;
}
if (idx === nodes.length - 1) {
textNode = textNode.splitText(focus.offset)[0] || textNode;
}
/**
* If the selected text has one format applied
* selecting a portion of the text, could
* clear the format to the wrong portion of the text.
*
* The cleared text is based on the length of the selected text.
*/
// We need this in case the selected text only has one format
const extractedTextNode = extractedNodes[0];
if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
textNode = extractedTextNode;
}
if (textNode.__style !== '') {
textNode.setStyle('');
}
if (textNode.__format !== 0) {
textNode.setFormat(0);
$getNearestBlockElementAncestorOrThrow(textNode).setFormat('');
}
node = textNode;
} else if ($isHeadingNode(node) || $isQuoteNode(node)) {
node.replace($createParagraphNode(), true);
} else if ($isDecoratorBlockNode(node)) {
node.setFormat('');
}
});
}
});
};
function dropDownActiveClass(active) {
if (active) {
return 'active dropdown-item-active';
@ -490,27 +419,27 @@ function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockO
return (
<div className='dropdown' ref={dropDownRef}>
<button type='button' className='item' onClick={formatParagraph}>
<button className='item' onClick={formatParagraph}>
<span className='icon paragraph' />
<span className='text'>Normal</span>
{blockType === 'paragraph' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatLargeHeading}>
<button className='item' onClick={formatLargeHeading}>
<span className='icon large-heading' />
<span className='text'>Heading 1</span>
{blockType === 'h1' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatSmallHeading}>
<button className='item' onClick={formatSmallHeading}>
<span className='icon small-heading' />
<span className='text'>Heading 2</span>
{blockType === 'h2' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatSmallHeading3}>
<button className='item' onClick={formatSmallHeading3}>
<span className='icon h3' />
<span className='text'>Heading 3</span>
{blockType === 'h3' && <span className='active' />}
</button>
<button type='button' className='item' onClick={formatBulletList}>
<button className='item' onClick={formatBulletList}>
<span className='icon bullet-list' />
<span className='text'>Bullet List</span>
{blockType === 'ul' && <span className='active' />}
@ -534,7 +463,12 @@ function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockO
);
}
function FontDropDown({ editor, value, style, disabled = false }) {
function FontDropDown({
editor,
value,
style,
disabled = false,
}) {
const handleClick = useCallback(
(option) => {
editor.update(() => {
@ -546,27 +480,35 @@ function FontDropDown({ editor, value, style, disabled = false }) {
}
});
},
[editor, style]
[editor, style],
);
const buttonAriaLabel = style === 'font-family' ? 'Formatting options for font family' : 'Formatting options for font size';
const buttonAriaLabel =
style === 'font-family'
? 'Formatting options for font family'
: 'Formatting options for font size';
return (
<DropDown
disabled={disabled}
buttonClassName={'toolbar-item ' + style}
buttonLabel={value}
buttonIconClassName={style === 'font-family' ? 'icon block-type font-family' : ''}
// buttonLabel={value}
buttonIconClassName={
style === 'font-family' ? 'icon block-type font-family' : ''
}
buttonAriaLabel={buttonAriaLabel}>
{(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(([option, text]) => (
<DropDownItem
className={`item font-m-${option.replace(/\s+/g, '_')} ${dropDownActiveClass(value === option)} ${style === 'font-size' ? 'fontsize-item' : ''}`}
style={{ fontFamily: style === 'font-family' ? option : undefined, fontSize: style === 'font-size' ? option : undefined }}
onClick={() => handleClick(option)}
key={option}>
<span className='text'>{text}</span>
</DropDownItem>
))}
{(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(
([option, text]) => (
<DropDownItem
className={`item font-m-${option.replace(/\s+/g, '_')} ${dropDownActiveClass(value === option)} ${
style === 'font-size' ? 'fontsize-item' : ''
}`}
onClick={() => handleClick(option)}
key={option}>
<span className="text">{text}</span>
</DropDownItem>
),
)}
</DropDown>
);
}
@ -636,7 +578,7 @@ function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) {
}}
className='item'>
<i className={'icon ' + (isRTL ? 'indent' : 'outdent')} />
<span className='text'>Outdent (Shift+Tab)</span>
<span className='text'>Outdent</span>
</DropDownItem>
<DropDownItem
onClick={() => {
@ -644,7 +586,7 @@ function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) {
}}
className='item'>
<i className={'icon ' + (isRTL ? 'outdent' : 'indent')} />
<span className='text'>Indent (Tab)</span>
<span className='text'>Indent</span>
</DropDownItem>
</DropDown>
);
@ -652,7 +594,6 @@ function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) {
export default function ToolbarPlugin() {
const [editor] = useLexicalComposerContext();
const [activeEditor, setActiveEditor] = useState(editor);
const toolbarRef = useRef(null);
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
const [canUndo, setCanUndo] = useState(false);
@ -669,16 +610,10 @@ export default function ToolbarPlugin() {
const [isStrikethrough, setIsStrikethrough] = useState(false);
const [isCode, setIsCode] = useState(false);
const [fontFamily, setFontFamily] = useState('Arial');
const [fontSize, setFontSize] = useState('16px');
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 [modal, showModal] = useModal();
const applyStyleText = useCallback(
(styles, skipHistoryStack = null) => {
editor.update(
@ -757,9 +692,7 @@ export default function ToolbarPlugin() {
setFontFamily(
$getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'),
);
setFontSize(
$getSelectionStyleValueForProperty(selection, 'font-size', '16px'),
);
let matchingParent;
if ($isLinkNode(parent)) {
// If node is a link, we need to fetch the parent paragraph node to set format
@ -780,12 +713,10 @@ export default function ToolbarPlugin() {
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
(_payload, newEditor) => {
setActiveEditor(newEditor);
updateToolbar();
return false;
},
LowPriority
// COMMAND_PRIORITY_CRITICAL,
),
editor.registerCommand(
CAN_UNDO_COMMAND,
@ -834,8 +765,8 @@ export default function ToolbarPlugin() {
}, [editor]);
return (
<div className='toolbar sticky top-[-10px] z-10' ref={toolbarRef}>
<button type='button'
<div className='toolbar' ref={toolbarRef}>
<button
disabled={!canUndo}
onClick={() => {
editor.dispatchCommand(UNDO_COMMAND);
@ -844,7 +775,7 @@ export default function ToolbarPlugin() {
aria-label='Undo'>
<i className='format undo' />
</button>
<button type='button'
<button
disabled={!canRedo}
onClick={() => {
editor.dispatchCommand(REDO_COMMAND);
@ -853,18 +784,10 @@ export default function ToolbarPlugin() {
aria-label='Redo'>
<i className='format redo' />
</button>
<FormatPainterToolbarButton />
<button type='button'
onClick={() => clearFormatting(activeEditor)}
className='toolbar-item'
title="清除格式"
aria-label='Clear'>
<i className='format clear' />
</button>
<Divider />
{supportedBlockTypes.has(blockType) && (
<>
<button type='button' className='toolbar-item block-controls' onClick={() => setShowBlockOptionsDropDown(!showBlockOptionsDropDown)} aria-label='Formatting Options'>
<button className='toolbar-item block-controls' onClick={() => setShowBlockOptionsDropDown(!showBlockOptionsDropDown)} aria-label='Formatting Options'>
<span className={'icon block-type ' + blockType} />
<span className='text'>{blockTypeToBlockName[blockType]}</span>
<i className='chevron-down' />
@ -890,14 +813,8 @@ export default function ToolbarPlugin() {
value={fontFamily}
editor={editor}
/>
<FontDropDown
disabled={!isEditable}
style={'font-size'}
value={fontSize}
editor={editor}
/>
<Divider />
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
}}
@ -905,7 +822,7 @@ export default function ToolbarPlugin() {
aria-label='Format Bold'>
<i className='format bold' />
</button>
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
}}
@ -913,7 +830,7 @@ export default function ToolbarPlugin() {
aria-label='Format Italics'>
<i className='format italic' />
</button>
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
}}
@ -921,7 +838,7 @@ export default function ToolbarPlugin() {
aria-label='Format Underline'>
<i className='format underline' />
</button>
<button type='button'
<button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
}}
@ -929,7 +846,7 @@ export default function ToolbarPlugin() {
aria-label='Format Strikethrough'>
<i className='format strikethrough' />
</button>
{/* <button type='button'
{/* <button
onClick={() => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code");
}}
@ -938,14 +855,14 @@ export default function ToolbarPlugin() {
>
<i className="format code" />
</button> */}
<button type='button' onClick={insertLink} className={'toolbar-item spaced ' + (isLink ? 'active' : '')} aria-label='Insert Link'>
<button onClick={insertLink} className={'toolbar-item spaced ' + (isLink ? 'active' : '')} aria-label='Insert Link'>
<i className='format link' />
</button>
{isLink && createPortal(<FloatingLinkEditor editor={editor} />, document.body)}
<button type='button' onClick={insertHorizontalRule}
// onClick={() => {
// editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);
// }}
<button
onClick={() => {
editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined);
}}
className={'toolbar-item spaced '}
aria-label='Insert Horizontal Rule'>
<i className='format icon horizontal-rule' />
@ -971,44 +888,44 @@ export default function ToolbarPlugin() {
/>
<Divider />
<ElementFormatDropdown disabled={!isEditable} value={elementFormat} editor={editor} isRTL={isRTL} />
<Divider />
<DropDown
disabled={!isEditable}
buttonClassName="toolbar-item spaced"
buttonLabel="Insert"
buttonAriaLabel="Insert specialized editor node"
buttonIconClassName="icon plus">
<DropDownItem
onClick={() => {
showModal('Insert Image', (onClose) => (
<InsertImageDialog
activeEditor={activeEditor}
onClose={onClose}
/>
));
}}
className="item">
<i className="icon image" />
<span className="text">Image</span>
</DropDownItem>
<DropDownItem
onClick={() => {
showModal('Insert Inline Image', (onClose) => (
<InsertInlineImageDialog
activeEditor={activeEditor}
onClose={onClose}
/>
));
}}
className="item">
<i className="icon image" />
<span className="text">Inline Image</span>
</DropDownItem>
</DropDown>
{/* <button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "left");
}}
className="toolbar-item spaced"
aria-label="Left Align"
>
<i className="format left-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "center");
}}
className="toolbar-item spaced"
aria-label="Center Align"
>
<i className="format center-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "right");
}}
className="toolbar-item spaced"
aria-label="Right Align"
>
<i className="format right-align" />
</button>
<button
onClick={() => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, "justify");
}}
className="toolbar-item"
aria-label="Justify Align"
>
<i className="format justify-align" />
</button> */}
</>
)}
{modal}
</div>
);
}

@ -1,21 +0,0 @@
{
"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
}

@ -1,24 +0,0 @@
/**
* 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 || ''),
);
}

@ -1,12 +0,0 @@
/**
* 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';

@ -1,40 +0,0 @@
/**
* 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;
}
}

@ -1,56 +0,0 @@
/**
* 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;

@ -1,26 +0,0 @@
/**
* 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,
);
}

@ -1,21 +0,0 @@
/**
* 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<typeof undefined | boolean | null | string>
): Array<string> {
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;
}

@ -1,18 +0,0 @@
/**
* 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;

@ -1,22 +0,0 @@
/**
* 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();
}
}

@ -1,49 +0,0 @@
/**
* 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,
};
}

@ -1,19 +0,0 @@
/**
* 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;

@ -1,20 +0,0 @@
/**
* 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;
};
}

@ -1,88 +0,0 @@
/**
* 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);
}

@ -46,7 +46,7 @@
text-align: center;
} */
.email-editor-wrapper h1 {
h1 {
font-size: 24px;
color: #333;
}
@ -80,7 +80,7 @@
.editor-input {
min-height: 150px;
resize: none;
font-size: 16px;
font-size: 15px;
caret-color: rgb(5, 5, 5);
position: relative;
tab-size: 1;
@ -145,10 +145,6 @@
color: rgb(33, 111, 219);
text-decoration: none;
}
.editor-link:hover{
text-decoration: underline;
cursor: pointer;
}
.tree-view-output {
display: block;
@ -357,7 +353,7 @@ pre::-webkit-scrollbar-thumb {
text-decoration: underline;
}
.email-editor-wrapper .emoji {
.emoji {
color: transparent;
background-size: 16px 16px;
background-position: center;
@ -404,7 +400,6 @@ pre::-webkit-scrollbar-thumb {
padding: 8px;
cursor: pointer;
vertical-align: middle;
word-break: keep-all;
}
.toolbar button.toolbar-item:disabled {
@ -431,8 +426,7 @@ pre::-webkit-scrollbar-thumb {
}
.toolbar button.toolbar-item.active {
/* background-color: rgba(223, 232, 250, 0.8); */
background-color: #eef2ff;
background-color: rgba(223, 232, 250, 0.3);
}
.toolbar button.toolbar-item.active i {
@ -477,12 +471,12 @@ pre::-webkit-scrollbar-thumb {
.toolbar .toolbar-item .text {
display: flex;
line-height: 20px;
/* width: 200px; */
width: 200px;
vertical-align: middle;
font-size: 14px;
color: #777;
text-overflow: ellipsis;
width: 3rem;
width: 70px;
overflow: hidden;
height: 20px;
text-align: left;
@ -570,7 +564,7 @@ i.chevron-down {
}
.dropdown {
z-index: 1201; /* Ant Modal z-index: 1000, Drawer z-index: 1200, 大于它才能使用在 Modal */
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),
@ -606,8 +600,7 @@ i.chevron-down {
background-size: contain;
}
button.item.dropdown-item-active {
/* background-color: #dfe8fa4d; */
background-color: #eef2ff;
background-color: #dfe8fa4d;
}
.dropdown .item:first-child {
@ -647,9 +640,6 @@ button.item.dropdown-item-active {
.dropdown .item.font-m-Georgia {
font-family: Georgia, serif;
}
.dropdown .item.font-m-Roboto {
font-family: Roboto;
}
.dropdown .item.font-m-Times_New_Roman {
font-family: 'Times New Roman', serif;
}
@ -665,7 +655,7 @@ button.item.dropdown-item-active {
top: -10000px;
left: -10000px;
margin-top: -6px;
max-width: 400px;
max-width: 300px;
width: 100%;
opacity: 0;
background-color: #fff;
@ -703,19 +693,6 @@ button.item.dropdown-item-active {
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);
@ -764,9 +741,6 @@ i.undo {
i.redo {
background-image: url(/images/icons/arrow-clockwise.svg);
}
i.clear{
background-image: url(/images/icons/eraser-line.svg);
}
.icon.paragraph {
background-image: url(/images/icons/text-paragraph.svg);
@ -802,6 +776,7 @@ i.clear{
.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")
}
@ -829,12 +804,7 @@ i.clear{
.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")
}
.icon.plus {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z'%3E%3C/path%3E%3C/svg%3E");
}
.icon.image{
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='currentColor'%3E%3Cpath d='M20 5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V5ZM2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z'%3E%3C/path%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")
}
@ -842,10 +812,6 @@ 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.painter {
background-image: url(/images/icons/brush-3-line.svg);
}
i.bold {
background-image: url(/images/icons/type-bold.svg);
}
@ -888,292 +854,3 @@ i.right-align {
i.justify-align {
background-image: url(/images/icons/justify.svg);
}
.editor-container span.editor-image {
cursor: default;
display: inline-block;
position: relative;
user-select: none;
}
.editor-container .editor-image img, .editor-paragraph img {
max-width: 100%;
cursor: default;
}
.editor-container .editor-image img.focused {
outline: 2px solid rgb(60, 132, 244);
user-select: none;
}
.editor-container .editor-image img.focused.draggable {
cursor: grab;
}
.editor-container .editor-image img.focused.draggable:active {
cursor: grabbing;
}
.editor-container .editor-image .image-caption-container .tree-view-output {
margin: 0;
border-radius: 0;
}
.editor-container .editor-image .image-caption-container {
display: block;
position: absolute;
bottom: 4px;
left: 0;
right: 0;
padding: 0;
margin: 0;
border-top: 1px solid #fff;
background-color: rgba(255, 255, 255, 0.9);
min-width: 100px;
color: #000;
overflow: hidden;
}
.editor-container .editor-image .image-caption-button {
display: block;
position: absolute;
bottom: 20px;
left: 0;
right: 0;
width: 30%;
padding: 10px;
margin: 0 auto;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.5);
min-width: 100px;
color: #fff;
cursor: pointer;
user-select: none;
}
.editor-container .editor-image .image-caption-button:hover {
background-color: rgba(60, 132, 244, 0.5);
}
.editor-container .editor-image .image-edit-button {
border: 1px solid rgba(0, 0, 0, 0.3);
border-radius: 5px;
background-image: url(/src/images/icons/pencil-fill.svg);
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
width: 35px;
height: 35px;
vertical-align: -0.25em;
position: absolute;
right: 4px;
top: 4px;
cursor: pointer;
user-select: none;
}
.editor-container .editor-image .image-edit-button:hover {
background-color: rgba(60, 132, 244, 0.1);
}
.editor-container .editor-image .image-resizer {
display: block;
width: 7px;
height: 7px;
position: absolute;
background-color: rgb(60, 132, 244);
border: 1px solid #fff;
}
.editor-container .editor-image .image-resizer.image-resizer-n {
top: -6px;
left: 48%;
cursor: n-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-ne {
top: -6px;
right: -6px;
cursor: ne-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-e {
bottom: 48%;
right: -6px;
cursor: e-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-se {
bottom: -2px;
right: -6px;
cursor: nwse-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-s {
bottom: -2px;
left: 48%;
cursor: s-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-sw {
bottom: -2px;
left: -6px;
cursor: sw-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-w {
bottom: 48%;
left: -6px;
cursor: w-resize;
}
.editor-container .editor-image .image-resizer.image-resizer-nw {
top: -6px;
left: -6px;
cursor: nw-resize;
}
.editor-container span.inline-editor-image {
cursor: default;
display: inline-block;
position: relative;
z-index: 1;
}
.editor-container .inline-editor-image img {
max-width: 100%;
cursor: default;
}
.editor-container .inline-editor-image img.focused {
outline: 2px solid rgb(60, 132, 244);
}
.editor-container .inline-editor-image img.focused.draggable {
cursor: grab;
}
.editor-container .inline-editor-image img.focused.draggable:active {
cursor: grabbing;
}
.editor-container .inline-editor-image .image-caption-container .tree-view-output {
margin: 0;
border-radius: 0;
}
.editor-container .inline-editor-image.position-full {
margin: 1em 0 1em 0;
}
.editor-container .inline-editor-image.position-left {
float: left;
width: 50%;
margin: 1em 1em 0 0;
}
.editor-container .inline-editor-image.position-right {
float: right;
width: 50%;
margin: 1em 0 0 1em;
}
.editor-container .inline-editor-image .image-edit-button {
display: block;
position: absolute;
top: 12px;
right: 12px;
padding: 6px 8px;
margin: 0 auto;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 5px;
background-color: rgba(0, 0, 0, 0.5);
min-width: 60px;
color: #fff;
cursor: pointer;
user-select: none;
}
.editor-container .inline-editor-image .image-edit-button:hover {
background-color: rgba(60, 132, 244, 0.5);
}
.editor-container .inline-editor-image .image-caption-container {
display: block;
background-color: #f4f4f4;
min-width: 100%;
color: #000;
overflow: hidden;
}
.PlaygroundEditorTheme__table,
.editor-table {
border-collapse: collapse;
border-spacing: 0;
overflow-y: scroll;
overflow-x: scroll;
table-layout: fixed;
width: fit-content;
margin: 0px 25px 30px 0px;
}
.PlaygroundEditorTheme__tableCell,
.editor-tableCell {
border: 1px solid #bbb;
/* width: 75px; */
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
outline: none;
}
.table-cell-action-button-container {
position: absolute;
top: 5px;
left: 15px;
will-change: transform;
z-index: 1201;
}
.table-cell-action-button {
background-color: none;
display: flex;
justify-content: center;
align-items: center;
border: 0;
position: relative;
border-radius: 15px;
color: #222;
display: inline-block;
cursor: pointer;
}
.action-button {
background-color: #eee;
border: 0;
padding: 8px 12px;
position: relative;
margin-left: 5px;
border-radius: 15px;
color: #222;
display: inline-block;
cursor: pointer;
}
.action-button:hover {
background-color: #ddd;
color: #000;
}
.action-button-mic.active {
animation: mic-pulsate-color 3s infinite;
}
button.action-button:disabled {
opacity: 0.6;
background: #eee;
cursor: not-allowed;
}

@ -1,6 +1,3 @@
import './PlaygroundEditorTheme.css';
const exampleTheme = {
ltr: "ltr",
rtl: "rtl",
@ -24,7 +21,6 @@ const exampleTheme = {
listitem: "editor-listitem"
},
image: "editor-image",
inlineImage: 'inline-editor-image',
link: "editor-link",
text: {
bold: "editor-text-bold",
@ -68,25 +64,7 @@ const exampleTheme = {
tag: "editor-tokenProperty",
url: "editor-tokenOperator",
variable: "editor-tokenVariable"
},
// table: 'editor-table',
// tableCell: 'editor-tableCell',
table: 'PlaygroundEditorTheme__table',
tableCell: 'PlaygroundEditorTheme__tableCell',
tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton',
tableCellActionButtonContainer:
'PlaygroundEditorTheme__tableCellActionButtonContainer',
tableCellEditing: 'PlaygroundEditorTheme__tableCellEditing',
tableCellHeader: 'PlaygroundEditorTheme__tableCellHeader',
tableCellPrimarySelected: 'PlaygroundEditorTheme__tableCellPrimarySelected',
tableCellResizer: 'PlaygroundEditorTheme__tableCellResizer',
tableCellSelected: 'PlaygroundEditorTheme__tableCellSelected',
tableCellSortedIndicator: 'PlaygroundEditorTheme__tableCellSortedIndicator',
tableResizeRuler: 'PlaygroundEditorTheme__tableCellResizeRuler',
tableRowStriping: 'PlaygroundEditorTheme__tableRowStriping',
tableScrollableWrapper: 'PlaygroundEditorTheme__tableScrollableWrapper',
tableSelected: 'PlaygroundEditorTheme__tableSelected',
tableSelection: 'PlaygroundEditorTheme__tableSelection',
}
};
export default exampleTheme;

@ -1,467 +0,0 @@
/**
* 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.
*
*
*/
.PlaygroundEditorTheme__ltr {
text-align: left;
}
.PlaygroundEditorTheme__rtl {
text-align: right;
}
.PlaygroundEditorTheme__paragraph {
margin: 0;
position: relative;
}
.PlaygroundEditorTheme__quote {
margin: 0;
margin-left: 20px;
margin-bottom: 10px;
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;
}
.PlaygroundEditorTheme__h1 {
font-size: 24px;
color: rgb(5, 5, 5);
font-weight: 400;
margin: 0;
}
.PlaygroundEditorTheme__h2 {
font-size: 15px;
color: rgb(101, 103, 107);
font-weight: 700;
margin: 0;
text-transform: uppercase;
}
.PlaygroundEditorTheme__h3 {
font-size: 12px;
margin: 0;
text-transform: uppercase;
}
.PlaygroundEditorTheme__indent {
--lexical-indent-base-value: 40px;
}
.PlaygroundEditorTheme__textBold {
font-weight: bold;
}
.PlaygroundEditorTheme__textItalic {
font-style: italic;
}
.PlaygroundEditorTheme__textUnderline {
text-decoration: underline;
}
.PlaygroundEditorTheme__textStrikethrough {
text-decoration: line-through;
}
.PlaygroundEditorTheme__textUnderlineStrikethrough {
text-decoration: underline line-through;
}
.PlaygroundEditorTheme__textSubscript {
font-size: 0.8em;
vertical-align: sub !important;
}
.PlaygroundEditorTheme__textSuperscript {
font-size: 0.8em;
vertical-align: super;
}
.PlaygroundEditorTheme__textCode {
background-color: rgb(240, 242, 245);
padding: 1px 0.25rem;
font-family: Menlo, Consolas, Monaco, monospace;
font-size: 94%;
}
.PlaygroundEditorTheme__hashtag {
background-color: rgba(88, 144, 255, 0.15);
border-bottom: 1px solid rgba(88, 144, 255, 0.3);
}
.PlaygroundEditorTheme__link {
color: rgb(33, 111, 219);
text-decoration: none;
}
.PlaygroundEditorTheme__link:hover {
text-decoration: underline;
cursor: pointer;
}
.PlaygroundEditorTheme__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;
overflow-x: auto;
position: relative;
tab-size: 2;
}
.PlaygroundEditorTheme__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;
}
.PlaygroundEditorTheme__tableScrollableWrapper {
overflow-x: auto;
margin: 0px 25px 30px 0px;
}
.PlaygroundEditorTheme__tableScrollableWrapper > .PlaygroundEditorTheme__table {
/* Remove the table's margin and put it on the wrapper */
margin: 0;
}
.PlaygroundEditorTheme__table {
border-collapse: collapse;
border-spacing: 0;
overflow-y: scroll;
overflow-x: scroll;
table-layout: fixed;
width: fit-content;
margin: 0px 25px 30px 0px;
}
.PlaygroundEditorTheme__tableRowStriping tr:nth-child(even) {
background-color: #f2f5fb;
}
.PlaygroundEditorTheme__tableSelection *::selection {
background-color: transparent;
}
.PlaygroundEditorTheme__tableSelected {
outline: 2px solid rgb(60, 132, 244);
}
.PlaygroundEditorTheme__tableCell {
border: 1px solid #bbb;
width: 75px;
vertical-align: top;
text-align: start;
padding: 6px 8px;
position: relative;
outline: none;
}
.PlaygroundEditorTheme__tableCellSortedIndicator {
display: block;
opacity: 0.5;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #999;
}
.PlaygroundEditorTheme__tableCellResizer {
position: absolute;
right: -4px;
height: 100%;
width: 8px;
cursor: ew-resize;
/* z-index: 10; */
top: 0;
z-index: 1202;
}
.PlaygroundEditorTheme__tableCellHeader {
background-color: #f2f3f5;
text-align: start;
}
.PlaygroundEditorTheme__tableCellSelected {
background-color: #c9dbf0;
}
.PlaygroundEditorTheme__tableCellPrimarySelected {
border: 2px solid rgb(60, 132, 244);
display: block;
height: calc(100% - 2px);
position: absolute;
width: calc(100% - 2px);
left: -1px;
top: -1px;
z-index: 2;
}
.PlaygroundEditorTheme__tableCellEditing {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
border-radius: 3px;
}
.PlaygroundEditorTheme__tableAddColumns {
position: absolute;
background-color: #eee;
height: 100%;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
z-index: 1202;
}
.PlaygroundEditorTheme__tableAddColumns:after {
background-image: url(../images/icons/plus.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
display: block;
content: ' ';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.4;
}
.PlaygroundEditorTheme__tableAddColumns:hover,
.PlaygroundEditorTheme__tableAddRows:hover {
background-color: #c9dbf0;
}
.PlaygroundEditorTheme__tableAddRows {
position: absolute;
width: calc(100% - 25px);
background-color: #eee;
animation: table-controls 0.2s ease;
border: 0;
cursor: pointer;
z-index: 1202;
}
.PlaygroundEditorTheme__tableAddRows:after {
background-image: url(/images/icons/plus.svg);
background-size: contain;
background-position: center;
background-repeat: no-repeat;
display: block;
content: ' ';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0.4;
}
@keyframes table-controls {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.PlaygroundEditorTheme__tableCellResizeRuler {
display: block;
position: absolute;
width: 1px;
background-color: rgb(60, 132, 244);
height: 100%;
top: 0;
z-index: 1202;
}
.PlaygroundEditorTheme__tableCellActionButtonContainer {
display: block;
right: 5px;
top: 6px;
position: absolute;
z-index: 4;
width: 20px;
height: 20px;
}
.PlaygroundEditorTheme__tableCellActionButton {
background-color: #eee;
display: block;
border: 0;
border-radius: 20px;
width: 20px;
height: 20px;
color: #222;
cursor: pointer;
}
.PlaygroundEditorTheme__tableCellActionButton:hover {
background-color: #ddd;
}
.PlaygroundEditorTheme__characterLimit {
display: inline;
background-color: #ffbbbb !important;
}
.PlaygroundEditorTheme__ol1 {
padding: 0;
margin: 0;
list-style-position: outside;
}
.PlaygroundEditorTheme__ol2 {
padding: 0;
margin: 0;
list-style-type: upper-alpha;
list-style-position: outside;
}
.PlaygroundEditorTheme__ol3 {
padding: 0;
margin: 0;
list-style-type: lower-alpha;
list-style-position: outside;
}
.PlaygroundEditorTheme__ol4 {
padding: 0;
margin: 0;
list-style-type: upper-roman;
list-style-position: outside;
}
.PlaygroundEditorTheme__ol5 {
padding: 0;
margin: 0;
list-style-type: lower-roman;
list-style-position: outside;
}
.PlaygroundEditorTheme__ul {
padding: 0;
margin: 0;
list-style-position: outside;
}
.PlaygroundEditorTheme__listItem {
margin: 0 32px;
}
.PlaygroundEditorTheme__listItemChecked,
.PlaygroundEditorTheme__listItemUnchecked {
position: relative;
margin-left: 8px;
margin-right: 8px;
padding-left: 24px;
padding-right: 24px;
list-style-type: none;
outline: none;
}
.PlaygroundEditorTheme__listItemChecked {
text-decoration: line-through;
}
.PlaygroundEditorTheme__listItemUnchecked:before,
.PlaygroundEditorTheme__listItemChecked:before {
content: '';
width: 16px;
height: 16px;
top: 2px;
left: 0;
cursor: pointer;
display: block;
background-size: cover;
position: absolute;
}
.PlaygroundEditorTheme__listItemUnchecked[dir='rtl']:before,
.PlaygroundEditorTheme__listItemChecked[dir='rtl']:before {
left: auto;
right: 0;
}
.PlaygroundEditorTheme__listItemUnchecked:focus:before,
.PlaygroundEditorTheme__listItemChecked:focus:before {
box-shadow: 0 0 0 2px #a6cdfe;
border-radius: 2px;
}
.PlaygroundEditorTheme__listItemUnchecked:before {
border: 1px solid #999;
border-radius: 2px;
}
.PlaygroundEditorTheme__listItemChecked:before {
border: 1px solid rgb(61, 135, 245);
border-radius: 2px;
background-color: #3d87f5;
background-repeat: no-repeat;
}
.PlaygroundEditorTheme__listItemChecked:after {
content: '';
cursor: pointer;
border-color: #fff;
border-style: solid;
position: absolute;
display: block;
top: 6px;
width: 3px;
left: 7px;
right: 7px;
height: 6px;
transform: rotate(45deg);
border-width: 0 2px 2px 0;
}
.PlaygroundEditorTheme__nestedListItem {
list-style-type: none;
}
.PlaygroundEditorTheme__nestedListItem:before,
.PlaygroundEditorTheme__nestedListItem:after {
display: none;
}
.PlaygroundEditorTheme__tokenComment {
color: slategray;
}
.PlaygroundEditorTheme__tokenPunctuation {
color: #999;
}
.PlaygroundEditorTheme__tokenProperty {
color: #905;
}
.PlaygroundEditorTheme__tokenSelector {
color: #690;
}
.PlaygroundEditorTheme__tokenOperator {
color: #9a6e3a;
}
.PlaygroundEditorTheme__tokenAttr {
color: #07a;
}
.PlaygroundEditorTheme__tokenVariable {
color: #e90;
}
.PlaygroundEditorTheme__tokenFunction {
color: #dd4a68;
}
.PlaygroundEditorTheme__mark {
background: rgba(255, 212, 0, 0.14);
border-bottom: 2px solid rgba(255, 212, 0, 0.3);
padding-bottom: 2px;
}
.PlaygroundEditorTheme__markOverlap {
background: rgba(255, 212, 0, 0.3);
border-bottom: 2px solid rgba(255, 212, 0, 0.7);
}
.PlaygroundEditorTheme__mark.selected {
background: rgba(255, 212, 0, 0.5);
border-bottom: 2px solid rgba(255, 212, 0, 1);
}
.PlaygroundEditorTheme__markOverlap.selected {
background: rgba(255, 212, 0, 0.7);
border-bottom: 2px solid rgba(255, 212, 0, 0.7);
}
.PlaygroundEditorTheme__embedBlock {
user-select: none;
}
.PlaygroundEditorTheme__embedBlockFocus {
outline: 2px solid rgb(60, 132, 244);
}
.PlaygroundEditorTheme__layoutContainer {
display: grid;
gap: 10px;
margin: 10px 0;
}
.PlaygroundEditorTheme__layoutItem {
border: 1px dashed #ddd;
padding: 8px 16px;
}
.PlaygroundEditorTheme__autocomplete {
color: #ccc;
}
.PlaygroundEditorTheme__hr {
padding: 2px 2px;
border: none;
margin: 1em 0;
cursor: pointer;
}
.PlaygroundEditorTheme__hr:after {
content: '';
display: block;
height: 2px;
background-color: #ccc;
line-height: 2px;
}
.PlaygroundEditorTheme__hr.selected {
outline: 2px solid rgb(60, 132, 244);
user-select: none;
}

@ -1,36 +0,0 @@
/**
* 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;
}

@ -1,49 +0,0 @@
/**
* 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 (
<button
disabled={disabled}
className={joinClasses(
'Button__root',
disabled && 'Button__disabled',
small && 'Button__small',
className,
)}
onClick={onClick}
title={title}
aria-label={title}
{...(dataTestId && {'data-test-id': dataTestId})}>
{children}
</button>
);
}

@ -1,44 +0,0 @@
/**
* 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;
}
}

@ -1,36 +0,0 @@
/**
* 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 (
<ContentEditable
className={className ?? 'ContentEditable__root'}
aria-placeholder={placeholder}
placeholder={
<div className={placeholderClassName ?? 'ContentEditable__placeholder'}>
{placeholder}
</div>
}
/>
);
}

@ -1,25 +0,0 @@
/**
* 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;
}

@ -1,32 +0,0 @@
/**
* 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 <div className="DialogButtonsList">{children}</div>;
}
export function DialogActions({
'data-test-id': dataTestId,
children,
}: Props): JSX.Element {
return (
<div className="DialogActions" data-test-id={dataTestId}>
{children}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save