Compare commits
36 Commits
Author | SHA1 | Date |
---|---|---|
|
a3d0a4f8f9 | 9 months ago |
|
7314895549 | 9 months ago |
|
d36c9d5d43 | 9 months ago |
|
6c29f334bf | 9 months ago |
|
8f3c25723d | 9 months ago |
|
88cc2eb3ae | 9 months ago |
|
4177dd874c | 9 months ago |
|
cae2a353d2 | 9 months ago |
|
b2884ce0a0 | 9 months ago |
|
3f244729f1 | 9 months ago |
|
f7c717c5c1 | 9 months ago |
|
1f91520f1b | 9 months ago |
|
3f36c648f9 | 9 months ago |
|
b0a8c149a1 | 9 months ago |
|
b37fb2c1aa | 9 months ago |
|
18a499c7e2 | 9 months ago |
|
bdedbc4781 | 9 months ago |
|
912c49cddd | 9 months ago |
|
7d951c037a | 9 months ago |
|
8dd49be1cd | 9 months ago |
|
9f37d32972 | 9 months ago |
|
720b388523 | 9 months ago |
|
ce07651e6a | 9 months ago |
|
eea8457c36 | 9 months ago |
|
00b8f940dc | 9 months ago |
|
7f4cafe724 | 9 months ago |
|
ae5c210c28 | 10 months ago |
|
dd6d3e686e | 10 months ago |
|
630c8e78f5 | 10 months ago |
|
305502920d | 10 months ago |
|
f54c1e790a | 10 months ago |
|
065563b0c3 | 10 months ago |
|
0408b796eb | 10 months ago |
|
03e437a075 | 10 months ago |
|
0a90396b6a | 11 months ago |
|
376015d287 | 11 months ago |
@ -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 +0,0 @@
|
||||
npm run build
|
@ -1 +0,0 @@
|
||||
npm version patch
|
@ -1 +0,0 @@
|
||||
npm version prerelease
|
@ -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,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 })
|
||||
}
|
@ -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 |
@ -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
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
@ -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],
|
||||
);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|