From d36987dbb1a82fb4a78ccb65bab5142c7f0fb25b Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 24 Jul 2023 10:36:25 +0200 Subject: [PATCH 01/22] lexical first steps --- package-lock.json | 447 ++++++++++++++++++++++++++++ package.json | 4 + pages/blog/implementing-lexical.tsx | 246 +++++++++++++++ 3 files changed, 697 insertions(+) create mode 100644 pages/blog/implementing-lexical.tsx diff --git a/package-lock.json b/package-lock.json index 90a73f8..c8cf1d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "@codemirror/lang-javascript": "^6.1.2", "@codemirror/state": "^6.1.4", "@codemirror/view": "^6.7.1", + "@lexical/list": "^0.11.3", + "@lexical/react": "^0.11.3", + "@lexical/selection": "^0.11.3", "@popperjs/core": "^2.11.8", "@textea/json-viewer": "^2.12.0", "axios": "^1.2.1", @@ -20,6 +23,7 @@ "cors": "^2.8.5", "css-loader": "^6.7.3", "form-data": "^4.0.0", + "lexical": "^0.11.3", "link-preview-js": "^3.0.4", "next": "^13.0.7", "orderedmap": "^2.1.0", @@ -1169,6 +1173,242 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@lexical/clipboard": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.11.3.tgz", + "integrity": "sha512-6xggT8b0hd4OQy25mBH+yiJsr3Bm8APHjDOd3yINCGeiiHXIC+2qKQn3MG70euxQQuyzq++tYHcSsFq42g8Jyw==", + "dependencies": { + "@lexical/html": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/code": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.11.3.tgz", + "integrity": "sha512-BIMPd2op65iP4N9SkKIUVodZoWeSsnk6skNJ8UHBO/Rg0ZxyAqxLpnBhEgHq2QOoTBbEW6OEFtkc7/+f9LINZg==", + "dependencies": { + "@lexical/utils": "0.11.3", + "prismjs": "^1.27.0" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.11.3.tgz", + "integrity": "sha512-S18uwqOOpV2yIAFVWqSvBdhZ5BGadPQO4ejZF15wP8LUuqkxCs+0I/MjLovQ7tx0Cx34KdDaOXtM6XeG74ixYw==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.11.3.tgz", + "integrity": "sha512-7auoaWp2QhsX9/Bq0SxLXatUaSwqoT9HlWNTH2vKsw8tdeUBYacTHLuBNncTGrznXLG0/B5+FWoLuM6Pzqq4Ig==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/history": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.11.3.tgz", + "integrity": "sha512-QLJQRH2rbadRwXd4c/U4TqjLWDQna6Q43nCocIZF+SdVG9TlASp7m6dS7hiHfPtV1pkxJUxPhZY6EsB/Ok5WGA==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/html": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.11.3.tgz", + "integrity": "sha512-+8AYnxxml9PneZLkGfdTenqDjE2yD1ZfCmQLrD/L1TEn22OjZh4uvKVHb13wEhgUZTuLKF0PNdnuecko9ON/aQ==", + "dependencies": { + "@lexical/selection": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/link": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.11.3.tgz", + "integrity": "sha512-stAjIrDrF18dPKK25ExPwMCcMe0KKD0FWVzo3F7ejh9DvrQcLFeBPcs8ze71chS3D5fQDB/CzdwvMjEViKmq2A==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/list": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.11.3.tgz", + "integrity": "sha512-Cs9071wDfqi4j1VgodceiR1jTHj13eCoEJDhr3e/FW0x5we7vfbTMtWlOWbveIoryAh+rQNgiD5e8SrAm6Zs3g==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/mark": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.11.3.tgz", + "integrity": "sha512-0wAtufmaA0rMVFXoiJ0sY/tiJsQbHuDpgywb1Qa8qnZZcg7ZTrQMz9Go0fEWYcbSp8OH2o0cjbDTz3ACS1qCUA==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.11.3.tgz", + "integrity": "sha512-sF8ow32BDme3UvxaKpf+j+vMc4T/XvDEzteZHmvvP7NX/iUtK3yUkTyT7rKuGwiKLYfMBwQaKMGjU3/nlIOzUg==", + "dependencies": { + "@lexical/code": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/offset": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.11.3.tgz", + "integrity": "sha512-3H9X8iqDSk0LrMOHZuqYuqX4EYGb78TIhtjrFbLJi/OgKmHaSeLx59xcMZdgd5kBdRitzQYMmvbRDvbLfMgWrA==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.11.3.tgz", + "integrity": "sha512-ShjCG8lICShOBKwrpP+9PjRFKEBCSUUMjbIGZfLnoL//3hyRtGv5aRgRyfJlRgDhCve0ROt5znLJV88EXzGRyA==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.11.3.tgz", + "integrity": "sha512-cQ5Us+GNzShyjjgRqWTnYv0rC+jHJ96LvBA1aSieM77H8/Im5BeoLl6TgBK2NqPkp8fGpj8JnDEdT8h9Qh1jtA==", + "peerDependencies": { + "@lexical/clipboard": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3", + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/react": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.11.3.tgz", + "integrity": "sha512-Rn0Agnrz3uLIWbNyS9PRlkxOxcIDl2kxaVfgBacqQtYKR0ZVB2Hnoi89Cq6VmWPovauPyryx4Q3FC8Y11X7Otg==", + "dependencies": { + "@lexical/clipboard": "0.11.3", + "@lexical/code": "0.11.3", + "@lexical/dragon": "0.11.3", + "@lexical/hashtag": "0.11.3", + "@lexical/history": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/mark": "0.11.3", + "@lexical/markdown": "0.11.3", + "@lexical/overflow": "0.11.3", + "@lexical/plain-text": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3", + "@lexical/yjs": "0.11.3", + "react-error-boundary": "^3.1.4" + }, + "peerDependencies": { + "lexical": "0.11.3", + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.11.3.tgz", + "integrity": "sha512-fBFs6wMS7GFLbk+mzIWtwpP+EmnTZZ5bHpveuQ5wXONBuUuLcsYF5KO7UhLxXNLmiViV6lxatZPavEzgZdW7oQ==", + "peerDependencies": { + "@lexical/clipboard": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3", + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/selection": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.11.3.tgz", + "integrity": "sha512-15lQpcKT/vd7XZ5pnF1nb+kpKb72e9Yi1dVqieSxTeXkzt1cAZFKP3NB4RlhOKCv1N+glSBnjSxRwgsFfbD+NQ==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/table": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.11.3.tgz", + "integrity": "sha512-EyRnN39CSPsMceADBR7Kf+sBHNpNQlPEkn/52epeDSnakR6s80woyrA3kIzKo6mLB4afvoqdYc7RfR96M9JLIA==", + "dependencies": { + "@lexical/utils": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.11.3.tgz", + "integrity": "sha512-gCEN8lJyR6b+yaOwKWGj79pbOfCQPWU/PHWyoNFUkEJXn3KydCzr2EYb6ta2cvQWRQU4G2BClKCR56jL4NS+qg==", + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-vC4saCrlcmyIJnvrYKw1uYxZojlD1DCIBsFlgmO8kXyRYXjj+o/8PBdn2dsgSQ3rADrC2mUloOm/maekDcYe9Q==", + "dependencies": { + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.11.3.tgz", + "integrity": "sha512-TLDQG2FSEw/aOfppEBb0wRlIuzJ57W//8ImfzyZvckSC12tvU0YKQQX8nQz/rybXdyfRy5eN+8gX5K2EyZx+pQ==", + "dependencies": { + "@lexical/offset": "0.11.3" + }, + "peerDependencies": { + "lexical": "0.11.3", + "yjs": ">=13.5.22" + } + }, "node_modules/@lezer/common": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", @@ -7346,6 +7586,11 @@ "node": ">= 0.8.0" } }, + "node_modules/lexical": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.11.3.tgz", + "integrity": "sha512-xsMKgx/Fa+QHg/nweemU04lCy7TnEr8LyeDtsKUC7fIDN9wH3GqbnQ0+e3Hbg4FmxlhDCiPPt0GcZAROq3R8uw==" + }, "node_modules/lib0": { "version": "0.2.74", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.74.tgz", @@ -13981,6 +14226,21 @@ "react": "^18.2.0" } }, + "node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", @@ -17200,6 +17460,180 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@lexical/clipboard": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.11.3.tgz", + "integrity": "sha512-6xggT8b0hd4OQy25mBH+yiJsr3Bm8APHjDOd3yINCGeiiHXIC+2qKQn3MG70euxQQuyzq++tYHcSsFq42g8Jyw==", + "requires": { + "@lexical/html": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/utils": "0.11.3" + } + }, + "@lexical/code": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/code/-/code-0.11.3.tgz", + "integrity": "sha512-BIMPd2op65iP4N9SkKIUVodZoWeSsnk6skNJ8UHBO/Rg0ZxyAqxLpnBhEgHq2QOoTBbEW6OEFtkc7/+f9LINZg==", + "requires": { + "@lexical/utils": "0.11.3", + "prismjs": "^1.27.0" + } + }, + "@lexical/dragon": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.11.3.tgz", + "integrity": "sha512-S18uwqOOpV2yIAFVWqSvBdhZ5BGadPQO4ejZF15wP8LUuqkxCs+0I/MjLovQ7tx0Cx34KdDaOXtM6XeG74ixYw==", + "requires": {} + }, + "@lexical/hashtag": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.11.3.tgz", + "integrity": "sha512-7auoaWp2QhsX9/Bq0SxLXatUaSwqoT9HlWNTH2vKsw8tdeUBYacTHLuBNncTGrznXLG0/B5+FWoLuM6Pzqq4Ig==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/history": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.11.3.tgz", + "integrity": "sha512-QLJQRH2rbadRwXd4c/U4TqjLWDQna6Q43nCocIZF+SdVG9TlASp7m6dS7hiHfPtV1pkxJUxPhZY6EsB/Ok5WGA==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/html": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.11.3.tgz", + "integrity": "sha512-+8AYnxxml9PneZLkGfdTenqDjE2yD1ZfCmQLrD/L1TEn22OjZh4uvKVHb13wEhgUZTuLKF0PNdnuecko9ON/aQ==", + "requires": { + "@lexical/selection": "0.11.3" + } + }, + "@lexical/link": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.11.3.tgz", + "integrity": "sha512-stAjIrDrF18dPKK25ExPwMCcMe0KKD0FWVzo3F7ejh9DvrQcLFeBPcs8ze71chS3D5fQDB/CzdwvMjEViKmq2A==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/list": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.11.3.tgz", + "integrity": "sha512-Cs9071wDfqi4j1VgodceiR1jTHj13eCoEJDhr3e/FW0x5we7vfbTMtWlOWbveIoryAh+rQNgiD5e8SrAm6Zs3g==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/mark": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.11.3.tgz", + "integrity": "sha512-0wAtufmaA0rMVFXoiJ0sY/tiJsQbHuDpgywb1Qa8qnZZcg7ZTrQMz9Go0fEWYcbSp8OH2o0cjbDTz3ACS1qCUA==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/markdown": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.11.3.tgz", + "integrity": "sha512-sF8ow32BDme3UvxaKpf+j+vMc4T/XvDEzteZHmvvP7NX/iUtK3yUkTyT7rKuGwiKLYfMBwQaKMGjU3/nlIOzUg==", + "requires": { + "@lexical/code": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3" + } + }, + "@lexical/offset": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/offset/-/offset-0.11.3.tgz", + "integrity": "sha512-3H9X8iqDSk0LrMOHZuqYuqX4EYGb78TIhtjrFbLJi/OgKmHaSeLx59xcMZdgd5kBdRitzQYMmvbRDvbLfMgWrA==", + "requires": {} + }, + "@lexical/overflow": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.11.3.tgz", + "integrity": "sha512-ShjCG8lICShOBKwrpP+9PjRFKEBCSUUMjbIGZfLnoL//3hyRtGv5aRgRyfJlRgDhCve0ROt5znLJV88EXzGRyA==", + "requires": {} + }, + "@lexical/plain-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.11.3.tgz", + "integrity": "sha512-cQ5Us+GNzShyjjgRqWTnYv0rC+jHJ96LvBA1aSieM77H8/Im5BeoLl6TgBK2NqPkp8fGpj8JnDEdT8h9Qh1jtA==", + "requires": {} + }, + "@lexical/react": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.11.3.tgz", + "integrity": "sha512-Rn0Agnrz3uLIWbNyS9PRlkxOxcIDl2kxaVfgBacqQtYKR0ZVB2Hnoi89Cq6VmWPovauPyryx4Q3FC8Y11X7Otg==", + "requires": { + "@lexical/clipboard": "0.11.3", + "@lexical/code": "0.11.3", + "@lexical/dragon": "0.11.3", + "@lexical/hashtag": "0.11.3", + "@lexical/history": "0.11.3", + "@lexical/link": "0.11.3", + "@lexical/list": "0.11.3", + "@lexical/mark": "0.11.3", + "@lexical/markdown": "0.11.3", + "@lexical/overflow": "0.11.3", + "@lexical/plain-text": "0.11.3", + "@lexical/rich-text": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3", + "@lexical/text": "0.11.3", + "@lexical/utils": "0.11.3", + "@lexical/yjs": "0.11.3", + "react-error-boundary": "^3.1.4" + } + }, + "@lexical/rich-text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.11.3.tgz", + "integrity": "sha512-fBFs6wMS7GFLbk+mzIWtwpP+EmnTZZ5bHpveuQ5wXONBuUuLcsYF5KO7UhLxXNLmiViV6lxatZPavEzgZdW7oQ==", + "requires": {} + }, + "@lexical/selection": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.11.3.tgz", + "integrity": "sha512-15lQpcKT/vd7XZ5pnF1nb+kpKb72e9Yi1dVqieSxTeXkzt1cAZFKP3NB4RlhOKCv1N+glSBnjSxRwgsFfbD+NQ==", + "requires": {} + }, + "@lexical/table": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.11.3.tgz", + "integrity": "sha512-EyRnN39CSPsMceADBR7Kf+sBHNpNQlPEkn/52epeDSnakR6s80woyrA3kIzKo6mLB4afvoqdYc7RfR96M9JLIA==", + "requires": { + "@lexical/utils": "0.11.3" + } + }, + "@lexical/text": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.11.3.tgz", + "integrity": "sha512-gCEN8lJyR6b+yaOwKWGj79pbOfCQPWU/PHWyoNFUkEJXn3KydCzr2EYb6ta2cvQWRQU4G2BClKCR56jL4NS+qg==", + "requires": {} + }, + "@lexical/utils": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.11.3.tgz", + "integrity": "sha512-vC4saCrlcmyIJnvrYKw1uYxZojlD1DCIBsFlgmO8kXyRYXjj+o/8PBdn2dsgSQ3rADrC2mUloOm/maekDcYe9Q==", + "requires": { + "@lexical/list": "0.11.3", + "@lexical/selection": "0.11.3", + "@lexical/table": "0.11.3" + } + }, + "@lexical/yjs": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.11.3.tgz", + "integrity": "sha512-TLDQG2FSEw/aOfppEBb0wRlIuzJ57W//8ImfzyZvckSC12tvU0YKQQX8nQz/rybXdyfRy5eN+8gX5K2EyZx+pQ==", + "requires": { + "@lexical/offset": "0.11.3" + } + }, "@lezer/common": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.0.2.tgz", @@ -21741,6 +22175,11 @@ "type-check": "~0.4.0" } }, + "lexical": { + "version": "0.11.3", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.11.3.tgz", + "integrity": "sha512-xsMKgx/Fa+QHg/nweemU04lCy7TnEr8LyeDtsKUC7fIDN9wH3GqbnQ0+e3Hbg4FmxlhDCiPPt0GcZAROq3R8uw==" + }, "lib0": { "version": "0.2.74", "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.74.tgz", @@ -26566,6 +27005,14 @@ "scheduler": "^0.23.0" } }, + "react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", diff --git a/package.json b/package.json index d73c822..ef70766 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,9 @@ "@codemirror/lang-javascript": "^6.1.2", "@codemirror/state": "^6.1.4", "@codemirror/view": "^6.7.1", + "@lexical/list": "^0.11.3", + "@lexical/react": "^0.11.3", + "@lexical/selection": "^0.11.3", "@popperjs/core": "^2.11.8", "@textea/json-viewer": "^2.12.0", "axios": "^1.2.1", @@ -63,6 +66,7 @@ "cors": "^2.8.5", "css-loader": "^6.7.3", "form-data": "^4.0.0", + "lexical": "^0.11.3", "link-preview-js": "^3.0.4", "next": "^13.0.7", "orderedmap": "^2.1.0", diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx new file mode 100644 index 0000000..ac0e5cf --- /dev/null +++ b/pages/blog/implementing-lexical.tsx @@ -0,0 +1,246 @@ +import React from "react"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; +import styled from "styled-components"; +import ArticleShareOgTags from "../../features/article/components/ArticleShareOgTags"; +import ArticleHeader from "../../features/article/components/ArticleHeader"; +import ArticleWrapper from "../../features/article/components/ArticleWrapper"; +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + createCommand, + EditorConfig, + ElementNode, + LexicalNode, + RangeSelection, +} from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; +import { $setBlocksType } from "@lexical/selection"; +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + ListItemNode, + ListNode, +} from "@lexical/list"; + +const StyledContentEditable = styled(ContentEditable)` + height: 300px; + width: 500px; + padding: 8px; + border: thin solid red; +`; + +const Placeholder = styled.div` + position: absolute; + top: 36px; + left: 8px; +`; + +function onError(error: Error): void { + console.error(error); +} + +// const MyOnChangePlugin = (props: { +// onChange: (editorState: EditorState) => void; +// }): null => { +// const [editor] = useLexicalComposerContext(); +// const { onChange } = props; +// +// useEffect(() => { +// return editor.registerUpdateListener(({ editorState }) => { +// onChange(editorState); +// }); +// }, [onChange, editor]); +// return null; +// }; + +type HeadingTags = "h1" | "h2" | "h3"; +type ListTags = "ul" | "ol"; + +const TheBestHeadingPlugin = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + const headingTags: HeadingTags[] = ["h1", "h2", "h3"]; + + const headingOnClick = (tag: HeadingTags): void => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(tag)); + } + }); + }; + return ( +
+ {headingTags.map((tag, i) => ( + + ))} +
+ ); +}; + +// TODO: rm node_modules to work +const TheBestListingPlugin = () => { + const [editor] = useLexicalComposerContext(); + const listingTags: ListTags[] = ["ul", "ol"]; + + const listingOnClick = (tag: ListTags): void => { + if (tag === "ol") { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + return; + } + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }; + return ( +
+ {listingTags.map((tag, i) => ( + + ))} +
+ ); +}; + +class BannerNode extends ElementNode { + // constructor(key?: NodeKey) { + // super(key); + // } + + static getType(): string { + return "banner"; + } + + static clone(node: BannerNode): BannerNode { + return new BannerNode(node.__key); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement("div"); + // element.classList.add(config.theme.banner); + element.style.backgroundColor = "lightblue"; + return element; + } + + updateDOM(): false { + return false; + } + + insertNewAfter( + selection: RangeSelection, + restoreSelection?: boolean | undefined, + ): LexicalNode | null { + const newBlock = $createParagraphNode(); + const direction = this.getDirection(); + newBlock.setDirection(direction); + this.inserAfter(newBlock, restoreSelection); + return newBlock; + } + + collapseAtStart(): boolean { + const p = $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => p.append(child)); + this.replace(p); + + return true; + } +} + +function $createBannerNode(): BannerNode { + return new BannerNode(); +} + +// function $isBannerNode(node: LexicalNode): node is BannerNode { +// return node instanceof BannerNode; +// } + +export const INSERT_BANNER_COMMAND = createCommand("insertCommand"); + +const TheBestBannerPlugin = (): null => { + const [editor] = useLexicalComposerContext(); + if (!editor.hasNodes([BannerNode])) + throw new Error("BannerNode is not registered in the editor"); + + editor.registerCommand( + INSERT_BANNER_COMMAND, + () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, $createBannerNode); + } + return true; + }, + COMMAND_PRIORITY_LOW, + ); + return null; +}; + +const BannerToolbarPlugin = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + + const bannerOnClick = (e: React.MouseEvent): void => { + editor.dispatchCommand(INSERT_BANNER_COMMAND, undefined); + }; + return ; +}; + +const ToolbarPlugin = (): JSX.Element => { + return ( +
+ + + +
+ ); +}; + +const theme = { + banner: "banner", +}; + +interface Props {} + +export default function Editor({}: Props): JSX.Element { + const initialConfig = { + namespace: "MyEditor", + theme, + onError, + nodes: [HeadingNode, ListNode, ListItemNode, BannerNode], + }; + + return ( + + + + +
+ + + } + placeholder={Enter some text...} + ErrorBoundary={LexicalErrorBoundary} + /> +
+ + {/* {*/} + {/* console.log(editorState);*/} + {/* }}*/} + {/*/>*/} +
+
+ ); +} From 05e8e734804b70cb08fc139145f51ce8d9629eb2 Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 24 Jul 2023 12:00:16 +0200 Subject: [PATCH 02/22] lexical first steps --- pages/blog/implementing-lexical.tsx | 118 ++++++++++++++-------------- 1 file changed, 58 insertions(+), 60 deletions(-) diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index ac0e5cf..2bed048 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -46,6 +46,10 @@ function onError(error: Error): void { console.error(error); } +const theme = {}; + +interface Props {} + // const MyOnChangePlugin = (props: { // onChange: (editorState: EditorState) => void; // }): null => { @@ -60,55 +64,6 @@ function onError(error: Error): void { // return null; // }; -type HeadingTags = "h1" | "h2" | "h3"; -type ListTags = "ul" | "ol"; - -const TheBestHeadingPlugin = (): JSX.Element => { - const [editor] = useLexicalComposerContext(); - const headingTags: HeadingTags[] = ["h1", "h2", "h3"]; - - const headingOnClick = (tag: HeadingTags): void => { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createHeadingNode(tag)); - } - }); - }; - return ( -
- {headingTags.map((tag, i) => ( - - ))} -
- ); -}; - -// TODO: rm node_modules to work -const TheBestListingPlugin = () => { - const [editor] = useLexicalComposerContext(); - const listingTags: ListTags[] = ["ul", "ol"]; - - const listingOnClick = (tag: ListTags): void => { - if (tag === "ol") { - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); - return; - } - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); - }; - return ( -
- {listingTags.map((tag, i) => ( - - ))} -
- ); -}; - class BannerNode extends ElementNode { // constructor(key?: NodeKey) { // super(key); @@ -124,7 +79,7 @@ class BannerNode extends ElementNode { createDOM(config: EditorConfig): HTMLElement { const element = document.createElement("div"); - // element.classList.add(config.theme.banner); + // element.classList.add(styles.banner.toString()); element.style.backgroundColor = "lightblue"; return element; } @@ -164,7 +119,7 @@ function $createBannerNode(): BannerNode { export const INSERT_BANNER_COMMAND = createCommand("insertCommand"); -const TheBestBannerPlugin = (): null => { +const BannerPlugin = (): null => { const [editor] = useLexicalComposerContext(); if (!editor.hasNodes([BannerNode])) throw new Error("BannerNode is not registered in the editor"); @@ -183,7 +138,7 @@ const TheBestBannerPlugin = (): null => { return null; }; -const BannerToolbarPlugin = (): JSX.Element => { +const TheBestBannerPlugin = (): JSX.Element => { const [editor] = useLexicalComposerContext(); const bannerOnClick = (e: React.MouseEvent): void => { @@ -192,22 +147,65 @@ const BannerToolbarPlugin = (): JSX.Element => { return ; }; +type HeadingTags = "h1" | "h2" | "h3"; +type ListTags = "ul" | "ol"; + +const TheBestHeadingPlugin = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + const headingTags: HeadingTags[] = ["h1", "h2", "h3"]; + + const headingOnClick = (tag: HeadingTags): void => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(tag)); + } + }); + }; + return ( +
+ {headingTags.map((tag, i) => ( + + ))} +
+ ); +}; + +// TODO: rm node_modules to work +const TheBestListingPlugin = () => { + const [editor] = useLexicalComposerContext(); + const listingTags: ListTags[] = ["ul", "ol"]; + + const listingOnClick = (tag: ListTags): void => { + if (tag === "ol") { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + return; + } + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }; + return ( +
+ {listingTags.map((tag, i) => ( + + ))} +
+ ); +}; + const ToolbarPlugin = (): JSX.Element => { return (
- +
); }; -const theme = { - banner: "banner", -}; - -interface Props {} - export default function Editor({}: Props): JSX.Element { const initialConfig = { namespace: "MyEditor", @@ -227,7 +225,7 @@ export default function Editor({}: Props): JSX.Element {
- + } placeholder={Enter some text...} From c92aea699dc2bda3bcfff823be52ea506083ca26 Mon Sep 17 00:00:00 2001 From: csillagk Date: Fri, 28 Jul 2023 10:09:20 +0200 Subject: [PATCH 03/22] implemented features, no css --- .../article/components/OwnLexicalPlugins.tsx | 126 +++++++ .../article/components/OwnLexicalToolbar.tsx | 317 +++++++++++++++++ pages/blog/implementing-lexical.tsx | 328 +++++++----------- utils/lexical.css | 111 ++++++ utils/lexical.ts | 84 +++++ 5 files changed, 773 insertions(+), 193 deletions(-) create mode 100644 features/article/components/OwnLexicalPlugins.tsx create mode 100644 features/article/components/OwnLexicalToolbar.tsx create mode 100644 utils/lexical.css create mode 100644 utils/lexical.ts diff --git a/features/article/components/OwnLexicalPlugins.tsx b/features/article/components/OwnLexicalPlugins.tsx new file mode 100644 index 0000000..3fee740 --- /dev/null +++ b/features/article/components/OwnLexicalPlugins.tsx @@ -0,0 +1,126 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_LOW, + createCommand, + EditorConfig, + ElementNode, + LexicalNode, + RangeSelection, +} from "lexical"; +import { $setBlocksType } from "@lexical/selection"; +import { useEffect } from "react"; +import { registerCodeHighlighting } from "@lexical/code"; + +// const MyOnChangePlugin = (props: { +// onChange: (editorState: EditorState) => void +// }): null => { +// const [editor] = useLexicalComposerContext(); +// const { onChange } = props; +// +// useEffect(() => { +// return editor.registerUpdateListener(({ editorState }) => { +// onChange(editorState); +// }); +// }, [onChange, editor]); +// return null; +// }; + +// TODO: listing: rm node_modules to work: the insert commands have problems +// TODO: undo doesnt work only for keyboard command +// TODO: css is not working for: +// code (or maybe I'm using the wrong one?), underline, strikethrough, link, quote, hashtag +// but I guess all of the others are broken too. + +//////////////////////////////////////////////////////////////// +// 1.) +export class BannerNode extends ElementNode { + // constructor(key?: NodeKey) { + // super(key); + // } + + static getType(): string { + return "banner"; + } + + static clone(node: BannerNode): BannerNode { + return new BannerNode(node.__key); + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement("div"); + element.style.backgroundColor = "lightblue"; + return element; + } + + updateDOM(): false { + return false; + } + + insertNewAfter( + selection: RangeSelection, + restoreSelection?: boolean | undefined, + ): LexicalNode | null { + const newBlock = $createParagraphNode(); + const direction = this.getDirection(); + newBlock.setDirection(direction); + this.inserAfter(newBlock, restoreSelection); + return newBlock; + } + + collapseAtStart(): boolean { + const p = $createParagraphNode(); + const children = this.getChildren(); + children.forEach((child) => p.append(child)); + this.replace(p); + + return true; + } +} + +export function $createBannerNode(): BannerNode { + return new BannerNode(); +} + +// function $isBannerNode(node: LexicalNode): node is BannerNode { +// return node instanceof BannerNode; +// } + +//////////////////////////////////////////////////////////////// +// 2.) +export const INSERT_BANNER_COMMAND = createCommand("insertCommand"); + +//////////////////////////////////////////////////////////////// +// 3.) +export const BannerPlugin = (): null => { + const [editor] = useLexicalComposerContext(); + if (!editor.hasNodes([BannerNode])) + throw new Error("BannerNode is not registered in the editor"); + + editor.registerCommand( + INSERT_BANNER_COMMAND, + () => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, $createBannerNode); + } + return true; + }, + COMMAND_PRIORITY_LOW, + ); + return null; +}; + +//////////////////////////////////////////////////////////////// + +export default function CodeHighlightPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + + return null; +} diff --git a/features/article/components/OwnLexicalToolbar.tsx b/features/article/components/OwnLexicalToolbar.tsx new file mode 100644 index 0000000..9e4a6f0 --- /dev/null +++ b/features/article/components/OwnLexicalToolbar.tsx @@ -0,0 +1,317 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import React, { useCallback, useState } from "react"; +import { ToolbarItem } from "../../../utils/lexical"; +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + FORMAT_TEXT_COMMAND, + REDO_COMMAND, + UNDO_COMMAND, +} from "lexical"; +import { $patchStyleText, $setBlocksType } from "@lexical/selection"; +import { + INSERT_CHECK_LIST_COMMAND, + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, +} from "@lexical/list"; +import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/react/LexicalHorizontalRuleNode"; +import { INSERT_BANNER_COMMAND } from "./OwnLexicalPlugins"; +import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text"; +import { $createLinkNode } from "@lexical/link"; + +export const DoOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const undoRedo = (e: React.MouseEvent) => { + const target = e.currentTarget; + const id = target.id; + + if (id === "undo") { + console.log("ajdi"); + editor.dispatchCommand(UNDO_COMMAND, undefined); + } + editor.dispatchCommand(REDO_COMMAND, undefined); + }; + + return ( + <> + undoRedo(e)}> + {"<<"} + + undoRedo(e)}> + {">>"} + + + ); +}; + +export const NormalPOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const normalPOnClick = () => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createParagraphNode()); + } + }); + }; + + return Normal P; +}; +export const BannerOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + + const bannerOnClick = (e: React.MouseEvent): void => { + editor.dispatchCommand(INSERT_BANNER_COMMAND, undefined); + }; + return Banner; +}; + +type HeadingTags = "h1" | "h2" | "h3"; +type ListTags = "ul" | "ol" | "checklist"; + +export const HeadingOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + const headingTags: HeadingTags[] = ["h1", "h2", "h3"]; + + const headingOnClick = (tag: HeadingTags): void => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createHeadingNode(tag)); + } + }); + }; + + return ( + <> + {headingTags.map((tag, i) => ( + headingOnClick(tag)}> + {tag} + + ))} + + ); +}; + +export const ListingOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + const listingTags: ListTags[] = ["ul", "ol", "checklist"]; + + const listingOnClick = (tag: ListTags): void => { + if (tag === "ol") { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + return; + } else if (tag === "checklist") { + editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined); + return; + } + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + }; + return ( + <> + {listingTags.map((tag, i) => ( + listingOnClick(tag)}> + {tag} + + ))} + + ); +}; + +export const BlockquoteOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + + const blockquoteOnClick = () => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createQuoteNode()); + } + }); + }; + + return quote; +}; + +export const MonocodeOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + + const codeOnClick = (): void => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code"); + }; + return mono code; +}; + +export const LinkOnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + const [isLink, setIsLink] = useState(false); + const [link, setLink] = useState(""); + const [isSeen, setIsSeen] = useState(false); + + const linkOnClick = useCallback(() => { + setIsLink(!isLink); + setIsSeen(true); + }, [editor, isLink]); + + const handleLinkEnter = useCallback( + (link: string) => { + if (link === "") { + console.error("link is empty"); + return; + } + + setIsSeen(false); + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $setBlocksType(selection, () => $createLinkNode(link)); + } + }); + }, + [editor, isLink], + ); + + return ( + <> + Link + + {isSeen && ( +
+ ) => { + setLink(e.target.value); + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleLinkEnter(link); + } + }} + /> +
+ )} + + ); +}; + +export const FormatThings = () => { + const [editor] = useLexicalComposerContext(); + const formattingTextOptions = [ + "bold", + "italic", + "underline", + "strikethrough", + "highlight", + "subscript", + "superscript", + ]; + + const formattingTextOnClick = (tag: string): void => { + switch (tag) { + case "bold": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"); + break; + case "italic": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"); + break; + case "underline": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"); + break; + case "strikethrough": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough"); + break; + case "highlight": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "highlight"); + break; + case "subscript": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "subscript"); + break; + case "superscript": + editor.dispatchCommand(FORMAT_TEXT_COMMAND, "superscript"); + break; + default: + console.log("no bro"); + break; + } + }; + + return ( + <> + {formattingTextOptions.map((tag, i) => ( + formattingTextOnClick(tag)}> + {tag} + + ))} + + ); +}; + +export const ColoringOnToolbar = () => { + const [editor] = useLexicalComposerContext(); + const [fontColor, setFontColor] = useState("#000000"); + const [backgrColor, setBackgrColor] = useState("#000000"); + + const applyStyleText = useCallback( + (styles: Record) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $patchStyleText(selection, styles); + } + }); + }, + [editor], + ); + + const onFontColorSelect = useCallback( + (value: string) => { + setFontColor(value); + applyStyleText({ color: value }); + }, + [applyStyleText], + ); + + const onBackgrColorSelect = useCallback( + (value: string) => { + setBackgrColor(value); + applyStyleText({ "background-color": value }); + }, + [applyStyleText], + ); + + return ( + <> + + Text: + onFontColorSelect(e.target.value)} + /> + + + Backgr.: + onBackgrColorSelect(e.target.value)} + /> + + + ); +}; + +export const HROnToolbar = (): JSX.Element => { + const [editor] = useLexicalComposerContext(); + + const hrOnClick = (): void => { + editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined); + }; + return --- hr ---; +}; diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index 2bed048..b24789c 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -1,219 +1,152 @@ -import React from "react"; +import React, { useState } from "react"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; -import { ContentEditable } from "@lexical/react/LexicalContentEditable"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; -import styled from "styled-components"; import ArticleShareOgTags from "../../features/article/components/ArticleShareOgTags"; import ArticleHeader from "../../features/article/components/ArticleHeader"; import ArticleWrapper from "../../features/article/components/ArticleWrapper"; + import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; -import { - $createParagraphNode, - $getSelection, - $isRangeSelection, - COMMAND_PRIORITY_LOW, - createCommand, - EditorConfig, - ElementNode, - LexicalNode, - RangeSelection, -} from "lexical"; -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { LinkNode } from "@lexical/link"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; -import { $createHeadingNode, HeadingNode } from "@lexical/rich-text"; -import { $setBlocksType } from "@lexical/selection"; -import { - INSERT_ORDERED_LIST_COMMAND, - INSERT_UNORDERED_LIST_COMMAND, - ListItemNode, - ListNode, -} from "@lexical/list"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; +import { HashtagNode } from "@lexical/hashtag"; -const StyledContentEditable = styled(ContentEditable)` - height: 300px; - width: 500px; - padding: 8px; - border: thin solid red; -`; - -const Placeholder = styled.div` - position: absolute; - top: 36px; - left: 8px; -`; +// import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import { + BannerOnToolbar, + BlockquoteOnToolbar, + ColoringOnToolbar, + DoOnToolbar, + FormatThings, + HeadingOnToolbar, + HROnToolbar, + LinkOnToolbar, + ListingOnToolbar, + MonocodeOnToolbar, + NormalPOnToolbar, +} from "../../features/article/components/OwnLexicalToolbar"; +import CodeHighlightPlugin, { + BannerNode, + BannerPlugin, +} from "../../features/article/components/OwnLexicalPlugins"; +import { + Dropdown, + Placeholder, + StyledContentEditable, + Toolbar, + ToolbarItem, +} from "../../utils/lexical"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; +import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; +import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { ParagraphNode } from "lexical"; +import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; function onError(error: Error): void { console.error(error); } -const theme = {}; - -interface Props {} - -// const MyOnChangePlugin = (props: { -// onChange: (editorState: EditorState) => void; -// }): null => { -// const [editor] = useLexicalComposerContext(); -// const { onChange } = props; -// -// useEffect(() => { -// return editor.registerUpdateListener(({ editorState }) => { -// onChange(editorState); -// }); -// }, [onChange, editor]); -// return null; -// }; - -class BannerNode extends ElementNode { - // constructor(key?: NodeKey) { - // super(key); - // } - - static getType(): string { - return "banner"; - } - - static clone(node: BannerNode): BannerNode { - return new BannerNode(node.__key); - } - - createDOM(config: EditorConfig): HTMLElement { - const element = document.createElement("div"); - // element.classList.add(styles.banner.toString()); - element.style.backgroundColor = "lightblue"; - return element; - } - - updateDOM(): false { - return false; - } - - insertNewAfter( - selection: RangeSelection, - restoreSelection?: boolean | undefined, - ): LexicalNode | null { - const newBlock = $createParagraphNode(); - const direction = this.getDirection(); - newBlock.setDirection(direction); - this.inserAfter(newBlock, restoreSelection); - return newBlock; - } - - collapseAtStart(): boolean { - const p = $createParagraphNode(); - const children = this.getChildren(); - children.forEach((child) => p.append(child)); - this.replace(p); - - return true; - } -} - -function $createBannerNode(): BannerNode { - return new BannerNode(); -} - -// function $isBannerNode(node: LexicalNode): node is BannerNode { -// return node instanceof BannerNode; -// } - -export const INSERT_BANNER_COMMAND = createCommand("insertCommand"); - -const BannerPlugin = (): null => { - const [editor] = useLexicalComposerContext(); - if (!editor.hasNodes([BannerNode])) - throw new Error("BannerNode is not registered in the editor"); - - editor.registerCommand( - INSERT_BANNER_COMMAND, - () => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $setBlocksType(selection, $createBannerNode); - } - return true; - }, - COMMAND_PRIORITY_LOW, - ); - return null; -}; - -const TheBestBannerPlugin = (): JSX.Element => { - const [editor] = useLexicalComposerContext(); - - const bannerOnClick = (e: React.MouseEvent): void => { - editor.dispatchCommand(INSERT_BANNER_COMMAND, undefined); - }; - return ; +const myTheme = { + text: { + bold: "textBold", + code: "textCode", + italic: "textItalic", + strikethrough: "textStrikethrough", + subscript: "textSubscript", + superscript: "textSuperscript", + underline: "textUnderline", + underlineStrikethrough: "textUnderlineStrikethrough", + }, + list: { + listitem: "listItem", + listitemChecked: "listItemChecked", + listitemUnchecked: "listItemUnchecked", + }, + quote: "quote", + link: "linkkkk", + hashtag: "hashtag", }; -type HeadingTags = "h1" | "h2" | "h3"; -type ListTags = "ul" | "ol"; - -const TheBestHeadingPlugin = (): JSX.Element => { - const [editor] = useLexicalComposerContext(); - const headingTags: HeadingTags[] = ["h1", "h2", "h3"]; - - const headingOnClick = (tag: HeadingTags): void => { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createHeadingNode(tag)); - } - }); - }; - return ( -
- {headingTags.map((tag, i) => ( - - ))} -
- ); -}; +interface Props {} -// TODO: rm node_modules to work -const TheBestListingPlugin = () => { - const [editor] = useLexicalComposerContext(); - const listingTags: ListTags[] = ["ul", "ol"]; +// TODO: doesnt close when a tag is clicked on +const ToolbarPlugin = (): JSX.Element => { + const [isStylingPOpen, setIsStylingPOpen] = useState(false); + const [isFormattingTextOpen, setIsFormattingTextOpen] = useState(false); + const [isColoringOpen, setIsColoringOpen] = useState(false); + const [isInsertingThingsOpen, setIsInsertingThingsOpen] = useState(false); - const listingOnClick = (tag: ListTags): void => { - if (tag === "ol") { - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); - return; - } - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); - }; return ( -
- {listingTags.map((tag, i) => ( - - ))} -
+ + + + setIsStylingPOpen(!isStylingPOpen)}> + Style P ⬇️ + + + + + + + + + setIsFormattingTextOpen(!isFormattingTextOpen)} + > + Format text ⬇️ + + + + + + + setIsColoringOpen(!isColoringOpen)}> + Coloring ⬇️ + + + + + + setIsInsertingThingsOpen(!isInsertingThingsOpen)} + > + Insert things ⬇️ + + + + + + + ); }; -const ToolbarPlugin = (): JSX.Element => { - return ( -
- - - -
- ); +const initialConfig = { + namespace: "MyEditor", + theme: myTheme, + onError, + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + BannerNode, + HorizontalRuleNode, + CodeNode, + LinkNode, + QuoteNode, + HashtagNode, + CodeHighlightNode, + ParagraphNode, + ], }; export default function Editor({}: Props): JSX.Element { - const initialConfig = { - namespace: "MyEditor", - theme, - onError, - nodes: [HeadingNode, ListNode, ListItemNode, BannerNode], - }; - return ( @@ -224,15 +157,24 @@ export default function Editor({}: Props): JSX.Element { />
+ + + + + + + + + + } placeholder={Enter some text...} ErrorBoundary={LexicalErrorBoundary} />
- {/* {*/} {/* console.log(editorState);*/} diff --git a/utils/lexical.css b/utils/lexical.css new file mode 100644 index 0000000..421195d --- /dev/null +++ b/utils/lexical.css @@ -0,0 +1,111 @@ +.textBold { + font-weight: bold; +} +.textItalic { + font-style: italic; +} +.textUnderline { + text-decoration: underline; +} +.textStrikethrough { + text-decoration: line-through; +} +.textSubscript { + font-size: 0.8em; + vertical-align: sub !important; +} +.textSuperscript { + font-size: 0.8em; + vertical-align: super; +} +.textCode { + background-color: rgb(240, 242, 245); + padding: 1px 0.25rem; + font-family: Menlo, Consolas, Monaco, monospace; + font-size: 94%; +} + +.listItem { + margin: 0 32px; +} +.listItemChecked, +.listItemUnchecked { + position: relative; + margin-left: 8px; + margin-right: 8px; + padding-left: 24px; + padding-right: 24px; + list-style-type: none; + outline: none; +} +.listItemChecked { + text-decoration: line-through; +} +.listItemUnchecked:before, +.listItemChecked:before { + content: ''; + width: 16px; + height: 16px; + top: 2px; + left: 0; + cursor: pointer; + display: block; + background-size: cover; + position: absolute; +} + +.listItemUnchecked:focus:before, +.listItemChecked:focus:before { + box-shadow: 0 0 0 2px #a6cdfe; + border-radius: 2px; +} +.listItemUnchecked:before { + border: 1px solid #999; + border-radius: 2px; +} +.listItemChecked:before { + border: 1px solid rgb(61, 135, 245); + border-radius: 2px; + background-color: #3d87f5; + background-repeat: no-repeat; +} +.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; +} + +.quote { + margin: 0 0 10px 20px; + font-size: 15px; + /*color: rgb(101, 103, 107);*/ + color: green; + border-left-color: rgb(206, 208, 212); + border-left-width: 4px; + border-left-style: solid; + padding-left: 16px; +} + +.linkkkk > span{ + color: greenyellow; !important + text-decoration: underline; +} +.linkkkk:hover { + text-decoration: underline; + cursor: pointer; +} + +.hashtag { + background-color: rgba(88, 144, 255, 0.15); + border-bottom: 1px solid rgba(88, 144, 255, 0.3); +} \ No newline at end of file diff --git a/utils/lexical.ts b/utils/lexical.ts new file mode 100644 index 0000000..0d57c41 --- /dev/null +++ b/utils/lexical.ts @@ -0,0 +1,84 @@ +import styled from "styled-components"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; + +export const Toolbar = styled.div` + display: flex; + gap: 5px; + align-items: center; + padding: 3px 6px; + border: 2px dotted darkmagenta; + border-radius: 30px; + margin-bottom: 10px; +`; + +export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` + display: ${({ isOpen }) => (isOpen ? "flex" : "none")}; + flex-direction: column; + align-items: center; + gap: 5px; + padding: 3px 6px; + border: 2px dashed lightcoral; + border-radius: 10px; + background-color: lightcyan; + z-index: 10; + width: 100px; + justify-content: center; + text-align: center; + + position: absolute; + top: 35px; + left: ${({ id }) => { + switch (id) { + case "s": + return "190px"; + case "f": + return "270px"; + case "c": + return "370px"; + case "i": + return "460px"; + default: + return 0; + } + }}; +`; + +export const ToolbarItem = styled.button` + padding: 3px 6px; + border: none; + background: none; + cursor: pointer; + color: dimgray; + min-width: 50px; + :hover { + background: lightpink; + border-radius: 5px; + } +`; + +export const StyledContentEditable = styled(ContentEditable)` + height: 300px; + width: 100%; + padding: 8px; + border: thick solid darkslateblue; + border-radius: 20px; + + :focus { + outline: none; + } +`; + +export const Placeholder = styled.div` + position: absolute; + top: 60px; + left: 16px; +`; + +export const Styleddiv = styled.div` + display: flex; + position: absolute; + top: 0; + left: 150px; + width: 200px; + height: 200px; +`; From ffef3fad708fbd4c39bedcc755fddc0f9ed572a4 Mon Sep 17 00:00:00 2001 From: csillagk Date: Fri, 28 Jul 2023 13:45:25 +0200 Subject: [PATCH 04/22] implemented features, no css --- features/article/components/OwnLexicalToolbar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/features/article/components/OwnLexicalToolbar.tsx b/features/article/components/OwnLexicalToolbar.tsx index 9e4a6f0..ead4560 100644 --- a/features/article/components/OwnLexicalToolbar.tsx +++ b/features/article/components/OwnLexicalToolbar.tsx @@ -63,7 +63,7 @@ export const NormalPOnToolbar = () => { export const BannerOnToolbar = (): JSX.Element => { const [editor] = useLexicalComposerContext(); - const bannerOnClick = (e: React.MouseEvent): void => { + const bannerOnClick = (): void => { editor.dispatchCommand(INSERT_BANNER_COMMAND, undefined); }; return Banner; @@ -159,7 +159,7 @@ export const LinkOnToolbar = (): JSX.Element => { const handleLinkEnter = useCallback( (link: string) => { if (link === "") { - console.error("link is empty"); + alert("link is empty"); return; } From 468cba54c3f112000e09a87c8a33a2639f32c5f6 Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 31 Jul 2023 12:11:33 +0200 Subject: [PATCH 05/22] new css, new functions --- .../article/components/OwnLexicalToolbar.tsx | 11 +- pages/blog/implementing-lexical.tsx | 160 ++++++++++++------ utils/lexical.ts | 55 +++--- 3 files changed, 155 insertions(+), 71 deletions(-) diff --git a/features/article/components/OwnLexicalToolbar.tsx b/features/article/components/OwnLexicalToolbar.tsx index ead4560..6796b38 100644 --- a/features/article/components/OwnLexicalToolbar.tsx +++ b/features/article/components/OwnLexicalToolbar.tsx @@ -150,6 +150,8 @@ export const LinkOnToolbar = (): JSX.Element => { const [isLink, setIsLink] = useState(false); const [link, setLink] = useState(""); const [isSeen, setIsSeen] = useState(false); + const linkRegex = + /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; const linkOnClick = useCallback(() => { setIsLink(!isLink); @@ -160,9 +162,14 @@ export const LinkOnToolbar = (): JSX.Element => { (link: string) => { if (link === "") { alert("link is empty"); + setIsSeen(false); + return; + } + if (!linkRegex.test(link)) { + alert("invalid link"); + setIsSeen(false); return; } - setIsSeen(false); editor.update(() => { const selection = $getSelection(); @@ -307,7 +314,7 @@ export const ColoringOnToolbar = () => { ); }; -export const HROnToolbar = (): JSX.Element => { +export const HROnToolbar = () => { const [editor] = useLexicalComposerContext(); const hrOnClick = (): void => { diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index b24789c..54c8f4d 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -1,8 +1,6 @@ -import React, { useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; -import ArticleShareOgTags from "../../features/article/components/ArticleShareOgTags"; -import ArticleHeader from "../../features/article/components/ArticleHeader"; import ArticleWrapper from "../../features/article/components/ArticleWrapper"; import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; @@ -36,6 +34,7 @@ import CodeHighlightPlugin, { } from "../../features/article/components/OwnLexicalPlugins"; import { Dropdown, + LeftToolbar, Placeholder, StyledContentEditable, Toolbar, @@ -80,53 +79,116 @@ const ToolbarPlugin = (): JSX.Element => { const [isStylingPOpen, setIsStylingPOpen] = useState(false); const [isFormattingTextOpen, setIsFormattingTextOpen] = useState(false); const [isColoringOpen, setIsColoringOpen] = useState(false); - const [isInsertingThingsOpen, setIsInsertingThingsOpen] = useState(false); + + const styleRef = useRef(null); + const formatRef = useRef(null); + const colorRef = useRef(null); + + useEffect(() => { + const handler = (event: MouseEvent) => { + const target = event.target; + + if ( + isStylingPOpen && + styleRef.current && + !styleRef.current.contains(target as HTMLDivElement) + ) { + setIsStylingPOpen(false); + } else if ( + isFormattingTextOpen && + formatRef.current && + !formatRef.current.contains(target as HTMLDivElement) + ) { + setIsFormattingTextOpen(false); + } else if ( + isColoringOpen && + colorRef.current && + !colorRef.current.contains(target as HTMLDivElement) + ) { + setIsColoringOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => { + document.removeEventListener("mousedown", handler); + }; + }, [isStylingPOpen, isColoringOpen, isFormattingTextOpen]); return ( - - setIsStylingPOpen(!isStylingPOpen)}> - Style P ⬇️ - - - - - - - - - setIsStylingPOpen(!isStylingPOpen)}> + Style P ⬇️ + + + + + + +
+ +
setIsFormattingTextOpen(!isFormattingTextOpen)} > - Format text ⬇️ - - - - - - - setIsColoringOpen(!isColoringOpen)}> - Coloring ⬇️ - - - - - - setIsInsertingThingsOpen(!isInsertingThingsOpen)} - > - Insert things ⬇️ - - - - + Format text ⬇️ + + + + +
+ +
setIsColoringOpen(!isColoringOpen)}> + Coloring ⬇️ + + + +
); }; +const ToolbarPluginOnTheLeft = ({ show }: { show: boolean }): JSX.Element => { + const [isInsertingThingsOpen, setIsInsertingThingsOpen] = useState(false); + const insertRef = useRef(null); + + useEffect(() => { + const handler = (event: MouseEvent) => { + const target = event.target; + if ( + isInsertingThingsOpen && + insertRef.current && + !insertRef.current.contains(target as HTMLDivElement) + ) { + setIsInsertingThingsOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => { + document.removeEventListener("mousedown", handler); + }; + }, [isInsertingThingsOpen]); + + return ( + + + +
setIsInsertingThingsOpen(!isInsertingThingsOpen)} + > + Insert things ⬇️ + + + +
+
+ ); +}; + const initialConfig = { namespace: "MyEditor", theme: myTheme, @@ -146,15 +208,11 @@ const initialConfig = { ], }; -export default function Editor({}: Props): JSX.Element { +const Editor = ({}: Props): JSX.Element => { + const [show, setShow] = useState(false); + return ( - -
@@ -163,6 +221,8 @@ export default function Editor({}: Props): JSX.Element { + + @@ -170,8 +230,10 @@ export default function Editor({}: Props): JSX.Element { } - placeholder={Enter some text...} + contentEditable={ + setShow(!show)} /> + } + placeholder={Let's start with a title...} ErrorBoundary={LexicalErrorBoundary} />
@@ -183,4 +245,6 @@ export default function Editor({}: Props): JSX.Element {
); -} +}; + +export default Editor; diff --git a/utils/lexical.ts b/utils/lexical.ts index 0d57c41..2005808 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -6,9 +6,23 @@ export const Toolbar = styled.div` gap: 5px; align-items: center; padding: 3px 6px; - border: 2px dotted darkmagenta; + border-style: dashed; + border-color: magenta; + border-width: 2px; border-radius: 30px; margin-bottom: 10px; + background-color: white; +`; + +export const LeftToolbar = styled(Toolbar)<{ show: boolean }>` + flex-direction: column; + border: 2px dashed magenta; + padding: 8px; + display: ${({ show }) => (show ? "flex" : "none")}; + + position: fixed; + top: 180px; + left: 80px; `; export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` @@ -26,17 +40,17 @@ export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` text-align: center; position: absolute; - top: 35px; + top: ${({ id }) => (id === "i" ? "110px" : "35px")}; left: ${({ id }) => { switch (id) { case "s": - return "190px"; + return "80px"; case "f": - return "270px"; + return "170px"; case "c": - return "370px"; + return "270px"; case "i": - return "460px"; + return "10px"; default: return 0; } @@ -49,36 +63,35 @@ export const ToolbarItem = styled.button` background: none; cursor: pointer; color: dimgray; + align-items: baseline; + min-width: 50px; + font-size: 14px; + :hover { background: lightpink; - border-radius: 5px; + border-radius: 15px; } `; export const StyledContentEditable = styled(ContentEditable)` - height: 300px; + height: 800px; width: 100%; padding: 8px; - border: thick solid darkslateblue; - border-radius: 20px; :focus { outline: none; } -`; -export const Placeholder = styled.div` - position: absolute; - top: 60px; - left: 16px; + ::first-line { + font-size: 24px; + font-weight: bold; + } `; -export const Styleddiv = styled.div` - display: flex; +export const Placeholder = styled.div` position: absolute; - top: 0; - left: 150px; - width: 200px; - height: 200px; + top: 62px; + left: 10px; + color: #dddddd; `; From 54b511709568c7dad7d228079d23909e3e83d839 Mon Sep 17 00:00:00 2001 From: csillagk Date: Tue, 1 Aug 2023 17:16:49 +0200 Subject: [PATCH 06/22] working css, link preview --- .../{OwnLexicalPlugins.tsx => Banner.tsx} | 35 +-- features/article/components/LinkPreview.tsx | 36 +++ .../article/components/LinkPreviewPlugin.js | 274 ++++++++++++++++++ .../article/components/OwnLexicalToolbar.tsx | 44 ++- .../article/components/PluginPlayground.tsx | 103 +++++++ features/article/components/ToolbarPlugin.tsx | 160 ++++++++++ .../components/ToolbarPluginOnTheLeft.tsx | 43 +++ pages/_document.tsx | 16 +- pages/blog/implementing-lexical.tsx | 189 +++--------- {utils => public}/lexical.css | 36 +++ public/linkprev.jpg | Bin 0 -> 85518 bytes utils/lexical.ts | 2 + 12 files changed, 749 insertions(+), 189 deletions(-) rename features/article/components/{OwnLexicalPlugins.tsx => Banner.tsx} (70%) create mode 100644 features/article/components/LinkPreview.tsx create mode 100644 features/article/components/LinkPreviewPlugin.js create mode 100644 features/article/components/PluginPlayground.tsx create mode 100644 features/article/components/ToolbarPlugin.tsx create mode 100644 features/article/components/ToolbarPluginOnTheLeft.tsx rename {utils => public}/lexical.css (78%) create mode 100644 public/linkprev.jpg diff --git a/features/article/components/OwnLexicalPlugins.tsx b/features/article/components/Banner.tsx similarity index 70% rename from features/article/components/OwnLexicalPlugins.tsx rename to features/article/components/Banner.tsx index 3fee740..f15e90b 100644 --- a/features/article/components/OwnLexicalPlugins.tsx +++ b/features/article/components/Banner.tsx @@ -1,4 +1,3 @@ -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $createParagraphNode, $getSelection, @@ -10,30 +9,8 @@ import { LexicalNode, RangeSelection, } from "lexical"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import { $setBlocksType } from "@lexical/selection"; -import { useEffect } from "react"; -import { registerCodeHighlighting } from "@lexical/code"; - -// const MyOnChangePlugin = (props: { -// onChange: (editorState: EditorState) => void -// }): null => { -// const [editor] = useLexicalComposerContext(); -// const { onChange } = props; -// -// useEffect(() => { -// return editor.registerUpdateListener(({ editorState }) => { -// onChange(editorState); -// }); -// }, [onChange, editor]); -// return null; -// }; - -// TODO: listing: rm node_modules to work: the insert commands have problems -// TODO: undo doesnt work only for keyboard command -// TODO: css is not working for: -// code (or maybe I'm using the wrong one?), underline, strikethrough, link, quote, hashtag -// but I guess all of the others are broken too. - //////////////////////////////////////////////////////////////// // 1.) export class BannerNode extends ElementNode { @@ -114,13 +91,3 @@ export const BannerPlugin = (): null => { }; //////////////////////////////////////////////////////////////// - -export default function CodeHighlightPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return registerCodeHighlighting(editor); - }, [editor]); - - return null; -} diff --git a/features/article/components/LinkPreview.tsx b/features/article/components/LinkPreview.tsx new file mode 100644 index 0000000..4f7842f --- /dev/null +++ b/features/article/components/LinkPreview.tsx @@ -0,0 +1,36 @@ +import { EditorConfig } from "lexical"; +import { AutoLinkNode } from "@lexical/link"; + +export class LinkPreviewNode extends AutoLinkNode { + static getType() { + return "linkPreview"; + } + static clone(node: LinkPreviewNode): LinkPreviewNode { + return new LinkPreviewNode(node.__key); + } + createDOM(config: EditorConfig): HTMLAnchorElement { + let previewImage = document.createElement("img"); + previewImage.className = config.theme.previewImage; + previewImage.setAttribute("src", "/linkprev.jpg"); + + const previewDescription = document.createElement("div"); + previewDescription.className = config.theme.previewDescription; + previewDescription.innerText = "Lorem Ipsum"; + + const previewBox = document.createElement("div"); + previewBox.className = config.theme.previewBox; + previewBox.appendChild(previewImage); + previewBox.appendChild(previewDescription); + + const linkPreviewContainer = super.createDOM(config); + linkPreviewContainer.className = config.theme.linkPreviewContainer; + linkPreviewContainer.target = "_blank"; + linkPreviewContainer.appendChild(previewBox); + + return linkPreviewContainer; + } + + updateDOM(prevNode: AutoLinkNode, dom: HTMLAnchorElement): boolean { + return false; + } +} diff --git a/features/article/components/LinkPreviewPlugin.js b/features/article/components/LinkPreviewPlugin.js new file mode 100644 index 0000000..96f72fb --- /dev/null +++ b/features/article/components/LinkPreviewPlugin.js @@ -0,0 +1,274 @@ +"use strict"; + +var link = require("@lexical/link"); +var LexicalComposerContext = require("@lexical/react/LexicalComposerContext"); +var utils = require("@lexical/utils"); +var lexical = require("lexical"); +var react = require("react"); + +function createLinkMatcherWithRegExp(regExp, urlTransformer = (text) => text) { + return (text) => { + const match = regExp.exec(text); + if (match === null) return null; + return { + index: match.index, + length: match[0].length, + text: match[0], + url: urlTransformer(text), + }; + }; +} + +function findFirstMatch(text, matchers) { + for (let i = 0; i < matchers.length; i++) { + const match = matchers[i](text); + + if (match) { + return match; + } + } + + return null; +} + +const PUNCTUATION_OR_SPACE = /[.,;\s]/; + +function isSeparator(char) { + return PUNCTUATION_OR_SPACE.test(char); +} + +function endsWithSeparator(textContent) { + return isSeparator(textContent[textContent.length - 1]); +} + +function startsWithSeparator(textContent) { + return isSeparator(textContent[0]); +} + +function isPreviousNodeValid(node) { + let previousNode = node.getPreviousSibling(); + + if (lexical.$isElementNode(previousNode)) { + previousNode = previousNode.getLastDescendant(); + } + + return ( + previousNode === null || + lexical.$isLineBreakNode(previousNode) || + (lexical.$isTextNode(previousNode) && + endsWithSeparator(previousNode.getTextContent())) + ); +} + +function isNextNodeValid(node) { + let nextNode = node.getNextSibling(); + + if (lexical.$isElementNode(nextNode)) { + nextNode = nextNode.getFirstDescendant(); + } + + return ( + nextNode === null || + lexical.$isLineBreakNode(nextNode) || + (lexical.$isTextNode(nextNode) && + startsWithSeparator(nextNode.getTextContent())) + ); +} + +function isContentAroundIsValid(matchStart, matchEnd, text, node) { + const contentBeforeIsValid = + matchStart > 0 + ? isSeparator(text[matchStart - 1]) + : isPreviousNodeValid(node); + + if (!contentBeforeIsValid) { + return false; + } + + const contentAfterIsValid = + matchEnd < text.length + ? isSeparator(text[matchEnd]) + : isNextNodeValid(node); + return contentAfterIsValid; +} + +function handleLinkCreation(node, matchers, onChange) { + const nodeText = node.getTextContent(); + let text = nodeText; + let invalidMatchEnd = 0; + let remainingTextNode = node; + let match; + + while ((match = findFirstMatch(text, matchers)) && match !== null) { + const matchStart = match.index; + const matchLength = match.length; + const matchEnd = matchStart + matchLength; + const isValid = isContentAroundIsValid( + invalidMatchEnd + matchStart, + invalidMatchEnd + matchEnd, + nodeText, + node, + ); + + if (isValid) { + let linkTextNode; + + if (invalidMatchEnd + matchStart === 0) { + [linkTextNode, remainingTextNode] = remainingTextNode.splitText( + invalidMatchEnd + matchLength, + ); + } else { + [, linkTextNode, remainingTextNode] = remainingTextNode.splitText( + invalidMatchEnd + matchStart, + invalidMatchEnd + matchStart + matchLength, + ); + } + + const linkNode = link.$createLinkPreviewNode(match.url, match.attributes); + const textNode = lexical.$createTextNode(match.text); + textNode.setFormat(linkTextNode.getFormat()); + textNode.setDetail(linkTextNode.getDetail()); + linkNode.append(textNode); + linkTextNode.replace(linkNode); + onChange(match.url, null); + invalidMatchEnd = 0; + } else { + invalidMatchEnd += matchEnd; + } + + text = text.substring(matchEnd); + } +} + +function handleLinkEdit(linkNode, matchers, onChange) { + // Check children are simple text + const children = linkNode.getChildren(); + const childrenLength = children.length; + + for (let i = 0; i < childrenLength; i++) { + const child = children[i]; + + if (!lexical.$isTextNode(child) || !child.isSimpleText()) { + replaceWithChildren(linkNode); + onChange(null, linkNode.getURL()); + return; + } + } // Check text content fully matches + + const text = linkNode.getTextContent(); + const match = findFirstMatch(text, matchers); + + if (match === null || match.text !== text) { + replaceWithChildren(linkNode); + onChange(null, linkNode.getURL()); + return; + } // Check neighbors + + if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) { + replaceWithChildren(linkNode); + onChange(null, linkNode.getURL()); + return; + } + + const url = linkNode.getURL(); + + if (url !== match.url) { + linkNode.setURL(match.url); + onChange(match.url, url); + } + + if (match.attributes) { + const rel = linkNode.getRel(); + + if (rel !== match.attributes.rel) { + linkNode.setRel(match.attributes.rel || null); + onChange(match.attributes.rel || null, rel); + } + + const target = linkNode.getTarget(); + + if (target !== match.attributes.target) { + linkNode.setTarget(match.attributes.target || null); + onChange(match.attributes.target || null, target); + } + } +} // Bad neighbours are edits in neighbor nodes that make AutoLinks incompatible. +// Given the creation preconditions, these can only be simple text nodes. + +function handleBadNeighbors(textNode, matchers, onChange) { + const previousSibling = textNode.getPreviousSibling(); + const nextSibling = textNode.getNextSibling(); + const text = textNode.getTextContent(); + + if (link.$isLinkPreviewNode(previousSibling) && !startsWithSeparator(text)) { + previousSibling.append(textNode); + handleLinkEdit(previousSibling, matchers, onChange); + onChange(null, previousSibling.getURL()); + } + + if (link.$isLinkPreviewNode(nextSibling) && !endsWithSeparator(text)) { + replaceWithChildren(nextSibling); + handleLinkEdit(nextSibling, matchers, onChange); + onChange(null, nextSibling.getURL()); + } +} + +function replaceWithChildren(node) { + const children = node.getChildren(); + const childrenLength = children.length; + + for (let j = childrenLength - 1; j >= 0; j--) { + node.insertAfter(children[j]); + } + + node.remove(); + return children.map((child) => child.getLatest()); +} + +function useAutoLink(editor, matchers, onChange) { + react.useEffect(() => { + // if (!editor.hasNodes([link.LinkPreviewNode])) { + // { + // throw Error( + // `LexicalAutoLinkPlugin: LinkPreviewNode not registered on editor`, + // ); + // } + // } + + const onChangeWrapped = (url, prevUrl) => { + if (onChange) { + onChange(url, prevUrl); + } + }; + + return utils.mergeRegister( + editor.registerNodeTransform(lexical.TextNode, (textNode) => { + const parent = textNode.getParentOrThrow(); + const previous = textNode.getPreviousSibling(); + + if (link.$isLinkPreviewNode(parent)) { + handleLinkEdit(parent, matchers, onChangeWrapped); + } else if (!link.$isLinkNode(parent)) { + if ( + textNode.isSimpleText() && + (startsWithSeparator(textNode.getTextContent()) || + !link.$isLinkPreviewNode(previous)) + ) { + handleLinkCreation(textNode, matchers, onChangeWrapped); + } + + handleBadNeighbors(textNode, matchers, onChangeWrapped); + } + }), + ); + }, [editor, matchers, onChange]); +} + +function LinkPreviewPlugin({ matchers, onChange }) { + const [editor] = LexicalComposerContext.useLexicalComposerContext(); + useAutoLink(editor, matchers, onChange); + return null; +} + +exports.LinkPreviewPlugin = LinkPreviewPlugin; +exports.createLinkMatcherWithRegExp = createLinkMatcherWithRegExp; diff --git a/features/article/components/OwnLexicalToolbar.tsx b/features/article/components/OwnLexicalToolbar.tsx index 6796b38..7e9a6e6 100644 --- a/features/article/components/OwnLexicalToolbar.tsx +++ b/features/article/components/OwnLexicalToolbar.tsx @@ -16,7 +16,7 @@ import { INSERT_UNORDERED_LIST_COMMAND, } from "@lexical/list"; import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/react/LexicalHorizontalRuleNode"; -import { INSERT_BANNER_COMMAND } from "./OwnLexicalPlugins"; +import { INSERT_BANNER_COMMAND } from "./Banner"; import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text"; import { $createLinkNode } from "@lexical/link"; @@ -146,6 +146,9 @@ export const MonocodeOnToolbar = (): JSX.Element => { }; export const LinkOnToolbar = (): JSX.Element => { + // TODO: paste Event: regex ? : + // +
+ const [editor] = useLexicalComposerContext(); const [isLink, setIsLink] = useState(false); const [link, setLink] = useState(""); @@ -258,6 +261,36 @@ export const FormatThings = () => { ); }; +// +// const FONT_SIZE_OPTIONS: [string, string][] = [ +// ["10px", "10px"], +// ["11px", "11px"], +// ["12px", "12px"], +// ["13px", "13px"], +// ["14px", "14px"], +// ["15px", "15px"], +// ["16px", "16px"], +// ["17px", "17px"], +// ["18px", "18px"], +// ["19px", "19px"], +// ["20px", "20px"], +// ]; + +export const FontSizeOnToolbar = (): JSX.Element => { + // const [editor] = useLexicalComposerContext(); + + const fontSizeOnClick = (): void => { + return; + // editor.update(() => { + // const selection = $getSelection(); + // if ($isRangeSelection(selection)) { + // $setBlocksType(selection, () => {return}); + // } + // }); + }; + + return Font Size; +}; export const ColoringOnToolbar = () => { const [editor] = useLexicalComposerContext(); @@ -322,3 +355,12 @@ export const HROnToolbar = () => { }; return --- hr ---; }; + +// export const LinkPreviewOnToolbar = (): JSX.Element => { +// const [editor] = useLexicalComposerContext(); +// +// const linkPrOnClick = (): void => { +// editor.dispatchCommand(INSERT_LINK_PREVIEW_COMMAND, undefined); +// }; +// return Link Pr; +// }; diff --git a/features/article/components/PluginPlayground.tsx b/features/article/components/PluginPlayground.tsx new file mode 100644 index 0000000..68ca938 --- /dev/null +++ b/features/article/components/PluginPlayground.tsx @@ -0,0 +1,103 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useEffect } from "react"; +import { registerCodeHighlighting } from "@lexical/code"; + +// const MyOnChangePlugin = (props: { +// onChange: (editorState: EditorState) => void +// }): null => { +// const [editor] = useLexicalComposerContext(); +// const { onChange } = props; +// +// useEffect(() => { +// return editor.registerUpdateListener(({ editorState }) => { +// onChange(editorState); +// }); +// }, [onChange, editor]); +// return null; +// }; + +// TODO: listing: rm node_modules to work: the insert commands have problems +// TODO: undo doesnt work only for keyboard command +// TODO: css is not working for: +// code (or maybe I'm using the wrong one?), underline, strikethrough, link, quote, hashtag +// but I guess all of the others are broken too. + +export default function CodeHighlightPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + + return null; +} + +//////////////////////////////////////////////////////////////// + +// export class LinkPreviewNode0 extends ElementNode { +// public pastedLink: string; +// constructor(key: string) { +// super(key); +// this.pastedLink = key; +// } +// +// static getType(): string { +// return "linkPreview"; +// } +// +// static clone(node: LinkPreviewNode0): LinkPreviewNode0 { +// return new LinkPreviewNode0(node.__key); +// } +// +// createDOM(config: EditorConfig): HTMLElement { +// const previewImage = document.createElement("img"); +// previewImage.className = config.theme.previewImage; +// previewImage.setAttribute("src", "/linkprev.jpg"); +// +// const previewDescription = document.createElement("div"); +// previewDescription.className = config.theme.previewDescription; +// previewDescription.innerText = "Lorem Ipsum"; +// +// const previewBox = document.createElement("div"); +// previewBox.className = config.theme.previewBox; +// previewBox.appendChild(previewImage); +// previewBox.appendChild(previewDescription); +// +// const linkPreviewContainer = document.createElement("a"); +// linkPreviewContainer.className = config.theme.linkPreviewContainer; +// linkPreviewContainer.href = "www.telex.hu"; +// linkPreviewContainer.target = "_blank"; +// linkPreviewContainer.innerText = this.pastedLink; +// linkPreviewContainer.appendChild(previewBox); +// +// return linkPreviewContainer; +// } +// +// updateDOM(): false { +// return false; +// } +// +// // insertNewAfter( +// // selection: RangeSelection, +// // restoreSelection?: boolean | undefined, +// // ): LexicalNode | null { +// // const newBlock = $createParagraphNode(); +// // const direction = this.getDirection(); +// // newBlock.setDirection(direction); +// // this.inserAfter(newBlock, restoreSelection); +// // return newBlock; +// // } +// +// collapseAtStart(): boolean { +// const p = $createParagraphNode(); +// const children = this.getChildren(); +// children.forEach((child) => p.append(child)); +// this.replace(p); +// +// return true; +// } +// } + +// export function $createLinkPreviewNode(pastedLink: string): LinkPreviewNode0 { +// return new LinkPreviewNode0(pastedLink); +// } diff --git a/features/article/components/ToolbarPlugin.tsx b/features/article/components/ToolbarPlugin.tsx new file mode 100644 index 0000000..086006b --- /dev/null +++ b/features/article/components/ToolbarPlugin.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Dropdown, Toolbar, ToolbarItem } from "../../../utils/lexical"; +import { + BannerOnToolbar, + BlockquoteOnToolbar, + ColoringOnToolbar, + FontSizeOnToolbar, + FormatThings, + HeadingOnToolbar, + LinkOnToolbar, + ListingOnToolbar, + MonocodeOnToolbar, + NormalPOnToolbar, +} from "./OwnLexicalToolbar"; + +const ToolbarPlugin = (): JSX.Element => { + const [isStylingPOpen, setIsStylingPOpen] = useState(false); + const [isFormattingTextOpen, setIsFormattingTextOpen] = useState(false); + const [isColoringOpen, setIsColoringOpen] = useState(false); + const [isFontSizeOpen, setIsFontSizeOpen] = useState(false); + + const styleRef = useRef(null); + const formatRef = useRef(null); + const colorRef = useRef(null); + const fontSizeRef = useRef(null); + + useEffect(() => { + const handler = (event: MouseEvent) => { + const target = event.target; + + if ( + isStylingPOpen && + !styleRef.current?.contains(target as HTMLDivElement) + ) { + setIsStylingPOpen(false); + } else if ( + isFormattingTextOpen && + !formatRef.current?.contains(target as HTMLDivElement) + ) { + setIsFormattingTextOpen(false); + } else if ( + isColoringOpen && + !colorRef.current?.contains(target as HTMLDivElement) + ) { + setIsColoringOpen(false); + } else if ( + isFontSizeOpen && + !fontSizeRef.current?.contains(target as HTMLDivElement) + ) { + setIsFontSizeOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => { + document.removeEventListener("mousedown", handler); + }; + }, [isStylingPOpen, isColoringOpen, isFormattingTextOpen]); + + // TODO: its not working + + // const handleClick = useCallback( + // ({ + // event, + // ref, + // }: { + // event: React.MouseEvent; + // ref: React.Ref; + // }) => { + // const target = event?.target; + // console.log("00" + isStylingPOpen); + // + // isStylingPOpen && !styleRef.current?.contains(target as HTMLDivElement) + // ? setIsStylingPOpen(false) + // : console.log("b"); + // + // isFormattingTextOpen && + // !formatRef.current?.contains(target as HTMLDivElement) + // ? setIsFormattingTextOpen(false) + // : setIsFormattingTextOpen(true); + // + // isColoringOpen && !colorRef.current?.contains(target as HTMLDivElement) + // ? setIsColoringOpen(false) + // : setIsColoringOpen(true); + // + // isFontSizeOpen && !fontSizeRef.current?.contains(target as HTMLDivElement) + // ? setIsFontSizeOpen(false) + // : setIsFontSizeOpen(true); + + // if ( + // isStylingPOpen || + // isFormattingTextOpen || + // isColoringOpen || + // isFontSizeOpen + // ) { + // ref === styleRef && + // !styleRef.current?.contains(event.target as HTMLDivElement) + // ? console.log("Im in") + // : setIsStylingPOpen(false); + // ref === formatRef && + // formatRef.current?.contains(event.target as HTMLDivElement) + // ? setIsFormattingTextOpen(true) + // : setIsFormattingTextOpen(false); + // ref === colorRef && + // colorRef.current?.contains(event.target as HTMLDivElement) + // ? setIsColoringOpen(true) + // : setIsColoringOpen(false); + // ref === fontSizeRef && + // fontSizeRef.current?.contains(event.target as HTMLDivElement) + // ? setIsFontSizeOpen(true) + // : setIsFontSizeOpen(false); + // } + // }, + // [], + // ); + + return ( + + + +
setIsStylingPOpen(!isStylingPOpen)}> + Style P ⬇️ + + + + + + +
+ +
setIsFormattingTextOpen(!isFormattingTextOpen)} + > + Format text ⬇️ + + + + +
+ +
setIsColoringOpen(!isColoringOpen)}> + Coloring ⬇️ + + + +
+ +
setIsFontSizeOpen(!isFontSizeOpen)}> + Font Size ⬇️ + + + +
+ + +
+ ); +}; + +export default ToolbarPlugin; diff --git a/features/article/components/ToolbarPluginOnTheLeft.tsx b/features/article/components/ToolbarPluginOnTheLeft.tsx new file mode 100644 index 0000000..62d23c5 --- /dev/null +++ b/features/article/components/ToolbarPluginOnTheLeft.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Dropdown, LeftToolbar, ToolbarItem } from "../../../utils/lexical"; +import { DoOnToolbar, HROnToolbar } from "./OwnLexicalToolbar"; + +const ToolbarPluginOnTheLeft = ({ show }: { show: boolean }): JSX.Element => { + const [isInsertingThingsOpen, setIsInsertingThingsOpen] = useState(false); + const insertRef = useRef(null); + + useEffect(() => { + const handler = (event: MouseEvent) => { + const target = event.target; + if ( + isInsertingThingsOpen && + insertRef.current && + !insertRef.current.contains(target as HTMLDivElement) + ) { + setIsInsertingThingsOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => { + document.removeEventListener("mousedown", handler); + }; + }, [isInsertingThingsOpen]); + + return ( + + + +
setIsInsertingThingsOpen(!isInsertingThingsOpen)} + > + Insert things ⬇️ + + + +
+
+ ); +}; + +export default ToolbarPluginOnTheLeft; diff --git a/pages/_document.tsx b/pages/_document.tsx index 579fb95..01e3781 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -2,9 +2,9 @@ import React from "react"; import Document, { DocumentContext, Head, + Html, Main, NextScript, - Html, } from "next/document"; import { ServerStyleSheet } from "styled-components"; @@ -46,6 +46,8 @@ export default class MyDocument extends Document<{ + + + ); } diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index 54c8f4d..da8df55 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useState } from "react"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import ArticleWrapper from "../../features/article/components/ArticleWrapper"; @@ -7,45 +7,32 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; import { TRANSFORMERS } from "@lexical/markdown"; import { CodeHighlightNode, CodeNode } from "@lexical/code"; -import { LinkNode } from "@lexical/link"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { ListItemNode, ListNode } from "@lexical/list"; import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; import { HashtagNode } from "@lexical/hashtag"; - -// import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; import { - BannerOnToolbar, - BlockquoteOnToolbar, - ColoringOnToolbar, - DoOnToolbar, - FormatThings, - HeadingOnToolbar, - HROnToolbar, - LinkOnToolbar, - ListingOnToolbar, - MonocodeOnToolbar, - NormalPOnToolbar, -} from "../../features/article/components/OwnLexicalToolbar"; -import CodeHighlightPlugin, { + AutoLinkPlugin, + createLinkMatcherWithRegExp, +} from "@lexical/react/LexicalAutoLinkPlugin"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import CodeHighlightPlugin from "../../features/article/components/PluginPlayground"; +import { BannerNode, BannerPlugin, -} from "../../features/article/components/OwnLexicalPlugins"; -import { - Dropdown, - LeftToolbar, - Placeholder, - StyledContentEditable, - Toolbar, - ToolbarItem, -} from "../../utils/lexical"; +} from "../../features/article/components/Banner"; +// import {LinkPreviewNode} from "../../features/article/components/LinkPreview" +import { Placeholder, StyledContentEditable } from "../../utils/lexical"; import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; -import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; import { ParagraphNode } from "lexical"; import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; +import ToolbarPlugin from "../../features/article/components/ToolbarPlugin"; +import ToolbarPluginOnTheLeft from "../../features/article/components/ToolbarPluginOnTheLeft"; +import { LinkPreviewNode } from "../../features/article/components/LinkPreview"; function onError(error: Error): void { console.error(error); @@ -68,127 +55,16 @@ const myTheme = { listitemUnchecked: "listItemUnchecked", }, quote: "quote", - link: "linkkkk", + // link: "linkkkk", hashtag: "hashtag", + linkPreviewContainer: "linkPreviewContainer", + previewBox: "previewBox", + previewImage: "previewImage", + previewDescription: "previewDescription", }; interface Props {} -// TODO: doesnt close when a tag is clicked on -const ToolbarPlugin = (): JSX.Element => { - const [isStylingPOpen, setIsStylingPOpen] = useState(false); - const [isFormattingTextOpen, setIsFormattingTextOpen] = useState(false); - const [isColoringOpen, setIsColoringOpen] = useState(false); - - const styleRef = useRef(null); - const formatRef = useRef(null); - const colorRef = useRef(null); - - useEffect(() => { - const handler = (event: MouseEvent) => { - const target = event.target; - - if ( - isStylingPOpen && - styleRef.current && - !styleRef.current.contains(target as HTMLDivElement) - ) { - setIsStylingPOpen(false); - } else if ( - isFormattingTextOpen && - formatRef.current && - !formatRef.current.contains(target as HTMLDivElement) - ) { - setIsFormattingTextOpen(false); - } else if ( - isColoringOpen && - colorRef.current && - !colorRef.current.contains(target as HTMLDivElement) - ) { - setIsColoringOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => { - document.removeEventListener("mousedown", handler); - }; - }, [isStylingPOpen, isColoringOpen, isFormattingTextOpen]); - - return ( - - - -
setIsStylingPOpen(!isStylingPOpen)}> - Style P ⬇️ - - - - - - -
- -
setIsFormattingTextOpen(!isFormattingTextOpen)} - > - Format text ⬇️ - - - - -
- -
setIsColoringOpen(!isColoringOpen)}> - Coloring ⬇️ - - - -
- - -
- ); -}; - -const ToolbarPluginOnTheLeft = ({ show }: { show: boolean }): JSX.Element => { - const [isInsertingThingsOpen, setIsInsertingThingsOpen] = useState(false); - const insertRef = useRef(null); - - useEffect(() => { - const handler = (event: MouseEvent) => { - const target = event.target; - if ( - isInsertingThingsOpen && - insertRef.current && - !insertRef.current.contains(target as HTMLDivElement) - ) { - setIsInsertingThingsOpen(false); - } - }; - document.addEventListener("mousedown", handler); - return () => { - document.removeEventListener("mousedown", handler); - }; - }, [isInsertingThingsOpen]); - - return ( - - - -
setIsInsertingThingsOpen(!isInsertingThingsOpen)} - > - Insert things ⬇️ - - - -
-
- ); -}; - const initialConfig = { namespace: "MyEditor", theme: myTheme, @@ -201,16 +77,33 @@ const initialConfig = { HorizontalRuleNode, CodeNode, LinkNode, + // AutoLinkNode, QuoteNode, HashtagNode, CodeHighlightNode, ParagraphNode, + LinkPreviewNode, + { + replace: AutoLinkNode, + with: (node: AutoLinkNode) => { + return new LinkPreviewNode(""); + }, + }, ], }; const Editor = ({}: Props): JSX.Element => { const [show, setShow] = useState(false); + const URL_REGEX = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + + const MATCHERS = [ + createLinkMatcherWithRegExp(URL_REGEX, (text) => { + return text.startsWith("http") ? text : `https://${text}`; + }), + ]; + return ( @@ -219,13 +112,16 @@ const Editor = ({}: Props): JSX.Element => { + + {/* {}} />*/} - + + @@ -237,11 +133,6 @@ const Editor = ({}: Props): JSX.Element => { ErrorBoundary={LexicalErrorBoundary} />
- {/* {*/} - {/* console.log(editorState);*/} - {/* }}*/} - {/*/>*/}
); diff --git a/utils/lexical.css b/public/lexical.css similarity index 78% rename from utils/lexical.css rename to public/lexical.css index 421195d..f3b54d9 100644 --- a/utils/lexical.css +++ b/public/lexical.css @@ -108,4 +108,40 @@ .hashtag { background-color: rgba(88, 144, 255, 0.15); border-bottom: 1px solid rgba(88, 144, 255, 0.3); +} + +.linkPreviewContainer { + display: flex; + flex-direction: column-reverse; + cursor: pointer; + text-decoration: underline; + color: royalblue; + width: 200px; +} + +.linkPreviewContainer:hover { + opacity: 0.8; +} + +.previewBox { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 20px; + max-width: 200px; + padding: 10px; + background-color: white; + border: thick solid red; + margin-top: 10px; +} + +.previewImage { + width: 150px; + height: 150px; +} + +.previewDescription { + color: black; + padding: 10px; } \ No newline at end of file diff --git a/public/linkprev.jpg b/public/linkprev.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fb6feb66ce29334116d7d1b60329e71d4b3c4b5a GIT binary patch literal 85518 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 NzZ|&V+Klu7002@lu` return "270px"; case "i": return "10px"; + case "fs": + return "340px"; default: return 0; } From 9de338c49c1346ecae7f263e18991881a531ab79 Mon Sep 17 00:00:00 2001 From: csillagk Date: Tue, 1 Aug 2023 17:22:47 +0200 Subject: [PATCH 07/22] ordered files --- .../article/components/LinkPreviewPlugin.js | 274 ------------------ .../{ => lexicalComponents}/Banner.tsx | 0 .../{ => lexicalComponents}/LinkPreview.tsx | 0 .../OwnLexicalToolbar.tsx | 2 +- .../PluginPlayground.tsx | 0 .../{ => lexicalComponents}/ToolbarPlugin.tsx | 2 +- .../ToolbarPluginOnTheLeft.tsx | 2 +- pages/blog/implementing-lexical.tsx | 13 +- 8 files changed, 8 insertions(+), 285 deletions(-) delete mode 100644 features/article/components/LinkPreviewPlugin.js rename features/article/components/{ => lexicalComponents}/Banner.tsx (100%) rename features/article/components/{ => lexicalComponents}/LinkPreview.tsx (100%) rename features/article/components/{ => lexicalComponents}/OwnLexicalToolbar.tsx (99%) rename features/article/components/{ => lexicalComponents}/PluginPlayground.tsx (100%) rename features/article/components/{ => lexicalComponents}/ToolbarPlugin.tsx (98%) rename features/article/components/{ => lexicalComponents}/ToolbarPluginOnTheLeft.tsx (93%) diff --git a/features/article/components/LinkPreviewPlugin.js b/features/article/components/LinkPreviewPlugin.js deleted file mode 100644 index 96f72fb..0000000 --- a/features/article/components/LinkPreviewPlugin.js +++ /dev/null @@ -1,274 +0,0 @@ -"use strict"; - -var link = require("@lexical/link"); -var LexicalComposerContext = require("@lexical/react/LexicalComposerContext"); -var utils = require("@lexical/utils"); -var lexical = require("lexical"); -var react = require("react"); - -function createLinkMatcherWithRegExp(regExp, urlTransformer = (text) => text) { - return (text) => { - const match = regExp.exec(text); - if (match === null) return null; - return { - index: match.index, - length: match[0].length, - text: match[0], - url: urlTransformer(text), - }; - }; -} - -function findFirstMatch(text, matchers) { - for (let i = 0; i < matchers.length; i++) { - const match = matchers[i](text); - - if (match) { - return match; - } - } - - return null; -} - -const PUNCTUATION_OR_SPACE = /[.,;\s]/; - -function isSeparator(char) { - return PUNCTUATION_OR_SPACE.test(char); -} - -function endsWithSeparator(textContent) { - return isSeparator(textContent[textContent.length - 1]); -} - -function startsWithSeparator(textContent) { - return isSeparator(textContent[0]); -} - -function isPreviousNodeValid(node) { - let previousNode = node.getPreviousSibling(); - - if (lexical.$isElementNode(previousNode)) { - previousNode = previousNode.getLastDescendant(); - } - - return ( - previousNode === null || - lexical.$isLineBreakNode(previousNode) || - (lexical.$isTextNode(previousNode) && - endsWithSeparator(previousNode.getTextContent())) - ); -} - -function isNextNodeValid(node) { - let nextNode = node.getNextSibling(); - - if (lexical.$isElementNode(nextNode)) { - nextNode = nextNode.getFirstDescendant(); - } - - return ( - nextNode === null || - lexical.$isLineBreakNode(nextNode) || - (lexical.$isTextNode(nextNode) && - startsWithSeparator(nextNode.getTextContent())) - ); -} - -function isContentAroundIsValid(matchStart, matchEnd, text, node) { - const contentBeforeIsValid = - matchStart > 0 - ? isSeparator(text[matchStart - 1]) - : isPreviousNodeValid(node); - - if (!contentBeforeIsValid) { - return false; - } - - const contentAfterIsValid = - matchEnd < text.length - ? isSeparator(text[matchEnd]) - : isNextNodeValid(node); - return contentAfterIsValid; -} - -function handleLinkCreation(node, matchers, onChange) { - const nodeText = node.getTextContent(); - let text = nodeText; - let invalidMatchEnd = 0; - let remainingTextNode = node; - let match; - - while ((match = findFirstMatch(text, matchers)) && match !== null) { - const matchStart = match.index; - const matchLength = match.length; - const matchEnd = matchStart + matchLength; - const isValid = isContentAroundIsValid( - invalidMatchEnd + matchStart, - invalidMatchEnd + matchEnd, - nodeText, - node, - ); - - if (isValid) { - let linkTextNode; - - if (invalidMatchEnd + matchStart === 0) { - [linkTextNode, remainingTextNode] = remainingTextNode.splitText( - invalidMatchEnd + matchLength, - ); - } else { - [, linkTextNode, remainingTextNode] = remainingTextNode.splitText( - invalidMatchEnd + matchStart, - invalidMatchEnd + matchStart + matchLength, - ); - } - - const linkNode = link.$createLinkPreviewNode(match.url, match.attributes); - const textNode = lexical.$createTextNode(match.text); - textNode.setFormat(linkTextNode.getFormat()); - textNode.setDetail(linkTextNode.getDetail()); - linkNode.append(textNode); - linkTextNode.replace(linkNode); - onChange(match.url, null); - invalidMatchEnd = 0; - } else { - invalidMatchEnd += matchEnd; - } - - text = text.substring(matchEnd); - } -} - -function handleLinkEdit(linkNode, matchers, onChange) { - // Check children are simple text - const children = linkNode.getChildren(); - const childrenLength = children.length; - - for (let i = 0; i < childrenLength; i++) { - const child = children[i]; - - if (!lexical.$isTextNode(child) || !child.isSimpleText()) { - replaceWithChildren(linkNode); - onChange(null, linkNode.getURL()); - return; - } - } // Check text content fully matches - - const text = linkNode.getTextContent(); - const match = findFirstMatch(text, matchers); - - if (match === null || match.text !== text) { - replaceWithChildren(linkNode); - onChange(null, linkNode.getURL()); - return; - } // Check neighbors - - if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) { - replaceWithChildren(linkNode); - onChange(null, linkNode.getURL()); - return; - } - - const url = linkNode.getURL(); - - if (url !== match.url) { - linkNode.setURL(match.url); - onChange(match.url, url); - } - - if (match.attributes) { - const rel = linkNode.getRel(); - - if (rel !== match.attributes.rel) { - linkNode.setRel(match.attributes.rel || null); - onChange(match.attributes.rel || null, rel); - } - - const target = linkNode.getTarget(); - - if (target !== match.attributes.target) { - linkNode.setTarget(match.attributes.target || null); - onChange(match.attributes.target || null, target); - } - } -} // Bad neighbours are edits in neighbor nodes that make AutoLinks incompatible. -// Given the creation preconditions, these can only be simple text nodes. - -function handleBadNeighbors(textNode, matchers, onChange) { - const previousSibling = textNode.getPreviousSibling(); - const nextSibling = textNode.getNextSibling(); - const text = textNode.getTextContent(); - - if (link.$isLinkPreviewNode(previousSibling) && !startsWithSeparator(text)) { - previousSibling.append(textNode); - handleLinkEdit(previousSibling, matchers, onChange); - onChange(null, previousSibling.getURL()); - } - - if (link.$isLinkPreviewNode(nextSibling) && !endsWithSeparator(text)) { - replaceWithChildren(nextSibling); - handleLinkEdit(nextSibling, matchers, onChange); - onChange(null, nextSibling.getURL()); - } -} - -function replaceWithChildren(node) { - const children = node.getChildren(); - const childrenLength = children.length; - - for (let j = childrenLength - 1; j >= 0; j--) { - node.insertAfter(children[j]); - } - - node.remove(); - return children.map((child) => child.getLatest()); -} - -function useAutoLink(editor, matchers, onChange) { - react.useEffect(() => { - // if (!editor.hasNodes([link.LinkPreviewNode])) { - // { - // throw Error( - // `LexicalAutoLinkPlugin: LinkPreviewNode not registered on editor`, - // ); - // } - // } - - const onChangeWrapped = (url, prevUrl) => { - if (onChange) { - onChange(url, prevUrl); - } - }; - - return utils.mergeRegister( - editor.registerNodeTransform(lexical.TextNode, (textNode) => { - const parent = textNode.getParentOrThrow(); - const previous = textNode.getPreviousSibling(); - - if (link.$isLinkPreviewNode(parent)) { - handleLinkEdit(parent, matchers, onChangeWrapped); - } else if (!link.$isLinkNode(parent)) { - if ( - textNode.isSimpleText() && - (startsWithSeparator(textNode.getTextContent()) || - !link.$isLinkPreviewNode(previous)) - ) { - handleLinkCreation(textNode, matchers, onChangeWrapped); - } - - handleBadNeighbors(textNode, matchers, onChangeWrapped); - } - }), - ); - }, [editor, matchers, onChange]); -} - -function LinkPreviewPlugin({ matchers, onChange }) { - const [editor] = LexicalComposerContext.useLexicalComposerContext(); - useAutoLink(editor, matchers, onChange); - return null; -} - -exports.LinkPreviewPlugin = LinkPreviewPlugin; -exports.createLinkMatcherWithRegExp = createLinkMatcherWithRegExp; diff --git a/features/article/components/Banner.tsx b/features/article/components/lexicalComponents/Banner.tsx similarity index 100% rename from features/article/components/Banner.tsx rename to features/article/components/lexicalComponents/Banner.tsx diff --git a/features/article/components/LinkPreview.tsx b/features/article/components/lexicalComponents/LinkPreview.tsx similarity index 100% rename from features/article/components/LinkPreview.tsx rename to features/article/components/lexicalComponents/LinkPreview.tsx diff --git a/features/article/components/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx similarity index 99% rename from features/article/components/OwnLexicalToolbar.tsx rename to features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index 7e9a6e6..1efa0eb 100644 --- a/features/article/components/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -1,6 +1,6 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; import React, { useCallback, useState } from "react"; -import { ToolbarItem } from "../../../utils/lexical"; +import { ToolbarItem } from "../../../../utils/lexical"; import { $createParagraphNode, $getSelection, diff --git a/features/article/components/PluginPlayground.tsx b/features/article/components/lexicalComponents/PluginPlayground.tsx similarity index 100% rename from features/article/components/PluginPlayground.tsx rename to features/article/components/lexicalComponents/PluginPlayground.tsx diff --git a/features/article/components/ToolbarPlugin.tsx b/features/article/components/lexicalComponents/ToolbarPlugin.tsx similarity index 98% rename from features/article/components/ToolbarPlugin.tsx rename to features/article/components/lexicalComponents/ToolbarPlugin.tsx index 086006b..1b629e9 100644 --- a/features/article/components/ToolbarPlugin.tsx +++ b/features/article/components/lexicalComponents/ToolbarPlugin.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { Dropdown, Toolbar, ToolbarItem } from "../../../utils/lexical"; +import { Dropdown, Toolbar, ToolbarItem } from "../../../../utils/lexical"; import { BannerOnToolbar, BlockquoteOnToolbar, diff --git a/features/article/components/ToolbarPluginOnTheLeft.tsx b/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx similarity index 93% rename from features/article/components/ToolbarPluginOnTheLeft.tsx rename to features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx index 62d23c5..6cdc501 100644 --- a/features/article/components/ToolbarPluginOnTheLeft.tsx +++ b/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { Dropdown, LeftToolbar, ToolbarItem } from "../../../utils/lexical"; +import { Dropdown, LeftToolbar, ToolbarItem } from "../../../../utils/lexical"; import { DoOnToolbar, HROnToolbar } from "./OwnLexicalToolbar"; const ToolbarPluginOnTheLeft = ({ show }: { show: boolean }): JSX.Element => { diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index da8df55..6b9bc18 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -18,21 +18,20 @@ import { createLinkMatcherWithRegExp, } from "@lexical/react/LexicalAutoLinkPlugin"; import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; -import CodeHighlightPlugin from "../../features/article/components/PluginPlayground"; +import CodeHighlightPlugin from "../../features/article/components/lexicalComponents/PluginPlayground"; import { BannerNode, BannerPlugin, -} from "../../features/article/components/Banner"; -// import {LinkPreviewNode} from "../../features/article/components/LinkPreview" +} from "../../features/article/components/lexicalComponents/Banner"; import { Placeholder, StyledContentEditable } from "../../utils/lexical"; import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; import { ParagraphNode } from "lexical"; import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; -import ToolbarPlugin from "../../features/article/components/ToolbarPlugin"; -import ToolbarPluginOnTheLeft from "../../features/article/components/ToolbarPluginOnTheLeft"; -import { LinkPreviewNode } from "../../features/article/components/LinkPreview"; +import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; +import ToolbarPluginOnTheLeft from "../../features/article/components/lexicalComponents/ToolbarPluginOnTheLeft"; +import { LinkPreviewNode } from "../../features/article/components/lexicalComponents/LinkPreview"; function onError(error: Error): void { console.error(error); @@ -77,7 +76,6 @@ const initialConfig = { HorizontalRuleNode, CodeNode, LinkNode, - // AutoLinkNode, QuoteNode, HashtagNode, CodeHighlightNode, @@ -113,7 +111,6 @@ const Editor = ({}: Props): JSX.Element => { - {/* {}} />*/} From 1a440ab9411f77cd393b7554ecd133f5994cb869 Mon Sep 17 00:00:00 2001 From: csillagk Date: Thu, 3 Aug 2023 17:17:47 +0200 Subject: [PATCH 08/22] almost working link preview node --- .../lexicalComponents/LinkPreview.tsx | 182 +++++++++++++++--- .../lexicalComponents/LinkPreviewbackup.tsx | 40 ++++ .../lexicalComponents/OwnLexicalToolbar.tsx | 3 - .../lexicalComponents/PluginPlayground.tsx | 5 + .../lexicalComponents/ToolbarPlugin.tsx | 4 +- .../lexicalComponents/TreeViewPlugin.tsx | 18 ++ pages/blog/implementing-lexical.tsx | 27 +-- public/lexical.css | 11 +- utils/lexical.ts | 5 +- 9 files changed, 246 insertions(+), 49 deletions(-) create mode 100644 features/article/components/lexicalComponents/LinkPreviewbackup.tsx create mode 100644 features/article/components/lexicalComponents/TreeViewPlugin.tsx diff --git a/features/article/components/lexicalComponents/LinkPreview.tsx b/features/article/components/lexicalComponents/LinkPreview.tsx index 4f7842f..344067c 100644 --- a/features/article/components/lexicalComponents/LinkPreview.tsx +++ b/features/article/components/lexicalComponents/LinkPreview.tsx @@ -1,36 +1,164 @@ -import { EditorConfig } from "lexical"; -import { AutoLinkNode } from "@lexical/link"; +import { + $insertNodes, + COMMAND_PRIORITY_LOW, + DOMExportOutput, + EditorConfig, + ElementFormatType, + LexicalEditor, + NodeKey, + PASTE_COMMAND, +} from "lexical"; +import React, { FC, useEffect } from "react"; +import { DecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode"; +import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents"; +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { $createAutoLinkNode } from "@lexical/link"; + +type LinkPreviewT = { + className: Readonly<{ + base: string; + focus: string; + }>; + nodeKey: NodeKey; + url: string; + onError?: (error: string) => void; + loadingComponent?: JSX.Element | string; + onLoad?: () => void; +}; + +const LinkPreviewContainer: FC = ({ + className, + nodeKey, + url, +}): JSX.Element => { + return ( + + + {/*{url}*/} +
+ +
Lorem ipsum
+
+
+ + ); +}; + +//////////////////////////////////////////////////////////////// +export class LinkPreviewNode extends DecoratorBlockNode { + __url: string; + constructor(url: string, format?: ElementFormatType, key?: NodeKey) { + super(format, key); + this.__url = url; + } -export class LinkPreviewNode extends AutoLinkNode { static getType() { - return "linkPreview"; + return "LinkPreviewNode"; } static clone(node: LinkPreviewNode): LinkPreviewNode { - return new LinkPreviewNode(node.__key); - } - createDOM(config: EditorConfig): HTMLAnchorElement { - let previewImage = document.createElement("img"); - previewImage.className = config.theme.previewImage; - previewImage.setAttribute("src", "/linkprev.jpg"); - - const previewDescription = document.createElement("div"); - previewDescription.className = config.theme.previewDescription; - previewDescription.innerText = "Lorem Ipsum"; - - const previewBox = document.createElement("div"); - previewBox.className = config.theme.previewBox; - previewBox.appendChild(previewImage); - previewBox.appendChild(previewDescription); - - const linkPreviewContainer = super.createDOM(config); - linkPreviewContainer.className = config.theme.linkPreviewContainer; - linkPreviewContainer.target = "_blank"; - linkPreviewContainer.appendChild(previewBox); - - return linkPreviewContainer; + return new LinkPreviewNode(node.__url, node.__format, node.__key); } - updateDOM(prevNode: AutoLinkNode, dom: HTMLAnchorElement): boolean { + decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { + const linkPreviewStyle = config.theme.linkPreviewContainer; + const className = { + base: linkPreviewStyle.base, + focus: linkPreviewStyle.focus, + }; + + return ( + <> + + + ); + } + updateDOM(): false { return false; } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + return super.exportDOM(editor); + } } + +export function $createLinkPreviewNode(url: string): LinkPreviewNode { + return new LinkPreviewNode(url); +} + +//////////////////////////////////////////////////////////////// + +// export class WorkingLinkNode extends LinkNode { +// __url: string; +// static getType() { +// return "LinkNode"; +// } +// static clone(node: WorkingLinkNode): WorkingLinkNode { +// return new WorkingLinkNode(node.__url); +// } +// +// constructor(url: string, attributes?: LinkAttributes, key?: NodeKey) { +// super(url, attributes, key); +// this.__url = url; +// console.log("im in"); +// } +// +// createDOM(config: EditorConfig): HTMLAnchorElement { +// console.log("Creating"); +// const workingLink = document.createElement("a"); +// workingLink.href = this.__url; +// workingLink.text = this.__url; +// workingLink.target = "__blank"; +// workingLink.className = config.theme.mylink; +// return workingLink; +// } +// +// updateDOM(prevNode: LinkNode, dom: HTMLAnchorElement): boolean { +// return false; +// } +// } +// export function $createWorkingLinkNode(url: string): WorkingLinkNode { +// return new WorkingLinkNode(url); +// } + +//////////////////////////////////////////////////////////////// +export const LinkPreviewPlugin = (): null => { + const [editor] = useLexicalComposerContext(); + if (!editor.hasNodes([LinkPreviewNode])) + throw new Error("LinkPreviewNode is not registered in the editor"); + + const urlRegexp = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + + useEffect(() => { + const removeListener = editor.registerCommand( + PASTE_COMMAND, + (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + const pastedItem = clipboardData?.getData("text"); + + if (typeof pastedItem === "string") { + if (urlRegexp.test(pastedItem)) { + editor.update(() => { + $insertNodes([ + $createAutoLinkNode(pastedItem), + $createLinkPreviewNode(pastedItem), + ]); + }); + return true; + } + return false; + } + return false; + }, + COMMAND_PRIORITY_LOW, + ); + return () => { + removeListener(); + }; + }, [editor]); + return null; +}; diff --git a/features/article/components/lexicalComponents/LinkPreviewbackup.tsx b/features/article/components/lexicalComponents/LinkPreviewbackup.tsx new file mode 100644 index 0000000..d6d0161 --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewbackup.tsx @@ -0,0 +1,40 @@ +import { DOMExportOutput, EditorConfig, LexicalEditor } from "lexical"; +import { AutoLinkNode } from "@lexical/link"; + +export class LinkPreviewNode extends AutoLinkNode { + static getType() { + return "linkPreview"; + } + static clone(node: LinkPreviewNode): LinkPreviewNode { + return new LinkPreviewNode(node.__key); + } + createDOM(config: EditorConfig): HTMLAnchorElement { + let previewImage = document.createElement("img"); + previewImage.className = config.theme.previewImage; + previewImage.setAttribute("src", "/linkprev.jpg"); + + const previewDescription = document.createElement("div"); + previewDescription.className = config.theme.previewDescription; + previewDescription.innerText = "Lorem Ipsum"; + + const previewBox = document.createElement("div"); + previewBox.className = config.theme.previewBox; + previewBox.appendChild(previewImage); + previewBox.appendChild(previewDescription); + + const linkPreviewContainer = super.createDOM(config); + linkPreviewContainer.className = config.theme.linkPreviewContainer; + linkPreviewContainer.target = "_blank"; + linkPreviewContainer.appendChild(previewBox); + + return linkPreviewContainer; + } + + updateDOM(prevNode: AutoLinkNode, dom: HTMLAnchorElement): boolean { + return false; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + return super.exportDOM(editor); + } +} diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index 1efa0eb..0cf8d68 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -146,9 +146,6 @@ export const MonocodeOnToolbar = (): JSX.Element => { }; export const LinkOnToolbar = (): JSX.Element => { - // TODO: paste Event: regex ? : - // +
- const [editor] = useLexicalComposerContext(); const [isLink, setIsLink] = useState(false); const [link, setLink] = useState(""); diff --git a/features/article/components/lexicalComponents/PluginPlayground.tsx b/features/article/components/lexicalComponents/PluginPlayground.tsx index 68ca938..395f088 100644 --- a/features/article/components/lexicalComponents/PluginPlayground.tsx +++ b/features/article/components/lexicalComponents/PluginPlayground.tsx @@ -101,3 +101,8 @@ export default function CodeHighlightPlugin(): JSX.Element | null { // export function $createLinkPreviewNode(pastedLink: string): LinkPreviewNode0 { // return new LinkPreviewNode0(pastedLink); // } + +export const isIt = (url: string) => { + // const mine = new URL(url); + // console.log(mine); +}; diff --git a/features/article/components/lexicalComponents/ToolbarPlugin.tsx b/features/article/components/lexicalComponents/ToolbarPlugin.tsx index 1b629e9..bf09147 100644 --- a/features/article/components/lexicalComponents/ToolbarPlugin.tsx +++ b/features/article/components/lexicalComponents/ToolbarPlugin.tsx @@ -7,7 +7,7 @@ import { FontSizeOnToolbar, FormatThings, HeadingOnToolbar, - LinkOnToolbar, + // LinkOnToolbar, ListingOnToolbar, MonocodeOnToolbar, NormalPOnToolbar, @@ -152,7 +152,7 @@ const ToolbarPlugin = (): JSX.Element => {
- + {/**/} ); }; diff --git a/features/article/components/lexicalComponents/TreeViewPlugin.tsx b/features/article/components/lexicalComponents/TreeViewPlugin.tsx new file mode 100644 index 0000000..05656bb --- /dev/null +++ b/features/article/components/lexicalComponents/TreeViewPlugin.tsx @@ -0,0 +1,18 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { TreeView } from "@lexical/react/LexicalTreeView"; +import * as React from "react"; + +export default function TreeViewPlugin(): JSX.Element { + const [editor] = useLexicalComposerContext(); + return ( + + ); +} diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index 6b9bc18..81ba7f4 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -7,16 +7,12 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; import { TRANSFORMERS } from "@lexical/markdown"; import { CodeHighlightNode, CodeNode } from "@lexical/code"; -import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { LinkNode, AutoLinkNode } from "@lexical/link"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { ListItemNode, ListNode } from "@lexical/list"; import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; import { HashtagNode } from "@lexical/hashtag"; -import { - AutoLinkPlugin, - createLinkMatcherWithRegExp, -} from "@lexical/react/LexicalAutoLinkPlugin"; import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; import CodeHighlightPlugin from "../../features/article/components/lexicalComponents/PluginPlayground"; import { @@ -31,7 +27,15 @@ import { ParagraphNode } from "lexical"; import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; import ToolbarPluginOnTheLeft from "../../features/article/components/lexicalComponents/ToolbarPluginOnTheLeft"; -import { LinkPreviewNode } from "../../features/article/components/lexicalComponents/LinkPreview"; +import { + LinkPreviewNode, + LinkPreviewPlugin, +} from "../../features/article/components/lexicalComponents/LinkPreview"; +import TreeViewPlugin from "../../features/article/components/lexicalComponents/TreeViewPlugin"; +import { + AutoLinkPlugin, + createLinkMatcherWithRegExp, +} from "@lexical/react/LexicalAutoLinkPlugin"; function onError(error: Error): void { console.error(error); @@ -54,7 +58,7 @@ const myTheme = { listitemUnchecked: "listItemUnchecked", }, quote: "quote", - // link: "linkkkk", + link: "mylink", hashtag: "hashtag", linkPreviewContainer: "linkPreviewContainer", previewBox: "previewBox", @@ -81,12 +85,7 @@ const initialConfig = { CodeHighlightNode, ParagraphNode, LinkPreviewNode, - { - replace: AutoLinkNode, - with: (node: AutoLinkNode) => { - return new LinkPreviewNode(""); - }, - }, + AutoLinkNode, ], }; @@ -111,6 +110,7 @@ const Editor = ({}: Props): JSX.Element => { + @@ -129,6 +129,7 @@ const Editor = ({}: Props): JSX.Element => { placeholder={Let's start with a title...} ErrorBoundary={LexicalErrorBoundary} /> + diff --git a/public/lexical.css b/public/lexical.css index f3b54d9..0c9c066 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -96,11 +96,11 @@ padding-left: 16px; } -.linkkkk > span{ +.mylink{ color: greenyellow; !important text-decoration: underline; } -.linkkkk:hover { +.mylink:hover { text-decoration: underline; cursor: pointer; } @@ -112,7 +112,7 @@ .linkPreviewContainer { display: flex; - flex-direction: column-reverse; + flex-direction: column; cursor: pointer; text-decoration: underline; color: royalblue; @@ -123,6 +123,11 @@ opacity: 0.8; } +.linkPreviewContainer:focus{ + outline: 2px solid rgb(60, 132, 244); +} + + .previewBox { display: flex; flex-direction: column; diff --git a/utils/lexical.ts b/utils/lexical.ts index 6d2bd36..c80e926 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -77,9 +77,12 @@ export const ToolbarItem = styled.button` `; export const StyledContentEditable = styled(ContentEditable)` - height: 800px; + height: 400px; width: 100%; padding: 8px; + border-bottom: 5px solid cornflowerblue; + border-radius: 10px; + margin-bottom: 10px; :focus { outline: none; From dc5a226b8c1afe9f0ac2aaacc6eaffc6c164d447 Mon Sep 17 00:00:00 2001 From: csillagk Date: Fri, 4 Aug 2023 09:34:49 +0200 Subject: [PATCH 09/22] working link preview - React --- .../lexicalComponents/LinkPreview.tsx | 50 +++---------------- .../lexicalComponents/OwnLexicalToolbar.tsx | 10 ++++ pages/blog/implementing-lexical.tsx | 14 ++++-- public/lexical.css | 2 +- 4 files changed, 27 insertions(+), 49 deletions(-) diff --git a/features/article/components/lexicalComponents/LinkPreview.tsx b/features/article/components/lexicalComponents/LinkPreview.tsx index 344067c..a7b2fa7 100644 --- a/features/article/components/lexicalComponents/LinkPreview.tsx +++ b/features/article/components/lexicalComponents/LinkPreview.tsx @@ -1,5 +1,5 @@ import { - $insertNodes, + $getRoot, COMMAND_PRIORITY_LOW, DOMExportOutput, EditorConfig, @@ -12,7 +12,6 @@ import React, { FC, useEffect } from "react"; import { DecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode"; import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents"; import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { $createAutoLinkNode } from "@lexical/link"; type LinkPreviewT = { className: Readonly<{ @@ -34,9 +33,8 @@ const LinkPreviewContainer: FC = ({ return (
- {/*{url}*/}
- + {""}
Lorem ipsum
@@ -89,41 +87,6 @@ export function $createLinkPreviewNode(url: string): LinkPreviewNode { return new LinkPreviewNode(url); } -//////////////////////////////////////////////////////////////// - -// export class WorkingLinkNode extends LinkNode { -// __url: string; -// static getType() { -// return "LinkNode"; -// } -// static clone(node: WorkingLinkNode): WorkingLinkNode { -// return new WorkingLinkNode(node.__url); -// } -// -// constructor(url: string, attributes?: LinkAttributes, key?: NodeKey) { -// super(url, attributes, key); -// this.__url = url; -// console.log("im in"); -// } -// -// createDOM(config: EditorConfig): HTMLAnchorElement { -// console.log("Creating"); -// const workingLink = document.createElement("a"); -// workingLink.href = this.__url; -// workingLink.text = this.__url; -// workingLink.target = "__blank"; -// workingLink.className = config.theme.mylink; -// return workingLink; -// } -// -// updateDOM(prevNode: LinkNode, dom: HTMLAnchorElement): boolean { -// return false; -// } -// } -// export function $createWorkingLinkNode(url: string): WorkingLinkNode { -// return new WorkingLinkNode(url); -// } - //////////////////////////////////////////////////////////////// export const LinkPreviewPlugin = (): null => { const [editor] = useLexicalComposerContext(); @@ -143,12 +106,11 @@ export const LinkPreviewPlugin = (): null => { if (typeof pastedItem === "string") { if (urlRegexp.test(pastedItem)) { editor.update(() => { - $insertNodes([ - $createAutoLinkNode(pastedItem), - $createLinkPreviewNode(pastedItem), - ]); + const paragraph = $getRoot().getLastChild(); + paragraph?.append($createLinkPreviewNode(pastedItem)); + return true; }); - return true; + return false; } return false; } diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index 0cf8d68..c3258a6 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -361,3 +361,13 @@ export const HROnToolbar = () => { // }; // return Link Pr; // }; + +// export const SaveToJsonOnToolbar = () => { +// const editor = useLexicalComposerContext(); +// +// const saveOnClick = (): void => { +// const editorState = editor.getEditorState(); +// const json = editorState.toJSON(); +// }; +// return to JSON; +// }; diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index 81ba7f4..d139be8 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -7,7 +7,7 @@ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; import { TRANSFORMERS } from "@lexical/markdown"; import { CodeHighlightNode, CodeNode } from "@lexical/code"; -import { LinkNode, AutoLinkNode } from "@lexical/link"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; import { HeadingNode, QuoteNode } from "@lexical/rich-text"; import { ListItemNode, ListNode } from "@lexical/list"; @@ -23,7 +23,7 @@ import { Placeholder, StyledContentEditable } from "../../utils/lexical"; import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; -import { ParagraphNode } from "lexical"; +import { LineBreakNode, ParagraphNode } from "lexical"; import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; import ToolbarPluginOnTheLeft from "../../features/article/components/lexicalComponents/ToolbarPluginOnTheLeft"; @@ -36,6 +36,10 @@ import { AutoLinkPlugin, createLinkMatcherWithRegExp, } from "@lexical/react/LexicalAutoLinkPlugin"; +// import { +// AutoLinkPlugin, +// createLinkMatcherWithRegExp, +// } from "@lexical/react/LexicalAutoLinkPlugin"; function onError(error: Error): void { console.error(error); @@ -76,16 +80,17 @@ const initialConfig = { HeadingNode, ListNode, ListItemNode, + LinkNode, BannerNode, HorizontalRuleNode, CodeNode, - LinkNode, QuoteNode, HashtagNode, CodeHighlightNode, ParagraphNode, - LinkPreviewNode, AutoLinkNode, + LinkPreviewNode, + LineBreakNode, ], }; @@ -129,6 +134,7 @@ const Editor = ({}: Props): JSX.Element => { placeholder={Let's start with a title...} ErrorBoundary={LexicalErrorBoundary} /> + diff --git a/public/lexical.css b/public/lexical.css index 0c9c066..96b4957 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -116,7 +116,7 @@ cursor: pointer; text-decoration: underline; color: royalblue; - width: 200px; + max-width: 200px; } .linkPreviewContainer:hover { From f9ba1f6f0eba2718dedfd081ef6fffca94f441ea Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 7 Aug 2023 11:27:04 +0200 Subject: [PATCH 10/22] editorState to JSON to editorState --- .../lexicalComponents/LinkPreview.tsx | 41 ++++++++++++++-- .../lexicalComponents/OwnLexicalToolbar.tsx | 33 ++++++------- .../lexicalComponents/ToolbarPlugin.tsx | 3 -- package.json | 5 ++ pages/blog/implementing-lexical.tsx | 47 ++++++++++++++----- utils/lexical.ts | 14 +++++- 6 files changed, 104 insertions(+), 39 deletions(-) diff --git a/features/article/components/lexicalComponents/LinkPreview.tsx b/features/article/components/lexicalComponents/LinkPreview.tsx index a7b2fa7..5f09268 100644 --- a/features/article/components/lexicalComponents/LinkPreview.tsx +++ b/features/article/components/lexicalComponents/LinkPreview.tsx @@ -1,12 +1,13 @@ import { $getRoot, COMMAND_PRIORITY_LOW, - DOMExportOutput, EditorConfig, ElementFormatType, LexicalEditor, NodeKey, PASTE_COMMAND, + SerializedLexicalNode, + Spread, } from "lexical"; import React, { FC, useEffect } from "react"; import { DecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode"; @@ -43,15 +44,24 @@ const LinkPreviewContainer: FC = ({ }; //////////////////////////////////////////////////////////////// + +export type SerializedLinkPreviewNode = Spread< + { + url: string; + format: ElementFormatType; + }, + SerializedLexicalNode +>; export class LinkPreviewNode extends DecoratorBlockNode { __url: string; constructor(url: string, format?: ElementFormatType, key?: NodeKey) { super(format, key); this.__url = url; + if (format) this.__format = format; } static getType() { - return "LinkPreviewNode"; + return "link-preview"; } static clone(node: LinkPreviewNode): LinkPreviewNode { return new LinkPreviewNode(node.__url, node.__format, node.__key); @@ -78,8 +88,31 @@ export class LinkPreviewNode extends DecoratorBlockNode { return false; } - exportDOM(editor: LexicalEditor): DOMExportOutput { - return super.exportDOM(editor); + getURL(): string { + return this.getLatest().__url; + } + + setFormat(format: ElementFormatType): void { + const self = this.getWritable(); + self.__format = format; + } + + exportJSON(): SerializedLinkPreviewNode { + return { + ...super.exportJSON(), + format: this.__format || "", + url: this.getURL(), + type: "link-preview", + version: 1, + }; + } + + static importJSON( + serializedNode: SerializedLinkPreviewNode, + ): LinkPreviewNode { + const node = $createLinkPreviewNode(serializedNode.url); + node.setFormat(serializedNode.format); + return node; } } diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index c3258a6..2ad3c33 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -353,21 +353,18 @@ export const HROnToolbar = () => { return --- hr ---; }; -// export const LinkPreviewOnToolbar = (): JSX.Element => { -// const [editor] = useLexicalComposerContext(); -// -// const linkPrOnClick = (): void => { -// editor.dispatchCommand(INSERT_LINK_PREVIEW_COMMAND, undefined); -// }; -// return Link Pr; -// }; - -// export const SaveToJsonOnToolbar = () => { -// const editor = useLexicalComposerContext(); -// -// const saveOnClick = (): void => { -// const editorState = editor.getEditorState(); -// const json = editorState.toJSON(); -// }; -// return to JSON; -// }; +// TODO: export DOM button on the tree view?? +export const SaveToJsonOnToolbar = ({ onClick }: { onClick: () => void }) => { + return to JSON; +}; + +export const LoadFromJsonOnToolbar = ({ data }: { data: string }) => { + const [editor] = useLexicalComposerContext(); + + const loadOnClick = () => { + const editorState = editor.parseEditorState(data); + editor.setEditorState(editorState); + return null; + }; + return to Editor ; +}; diff --git a/features/article/components/lexicalComponents/ToolbarPlugin.tsx b/features/article/components/lexicalComponents/ToolbarPlugin.tsx index bf09147..8f54c8b 100644 --- a/features/article/components/lexicalComponents/ToolbarPlugin.tsx +++ b/features/article/components/lexicalComponents/ToolbarPlugin.tsx @@ -7,7 +7,6 @@ import { FontSizeOnToolbar, FormatThings, HeadingOnToolbar, - // LinkOnToolbar, ListingOnToolbar, MonocodeOnToolbar, NormalPOnToolbar, @@ -151,8 +150,6 @@ const ToolbarPlugin = (): JSX.Element => { - - {/**/} ); }; diff --git a/package.json b/package.json index ef70766..c2c6c64 100644 --- a/package.json +++ b/package.json @@ -103,5 +103,10 @@ "y-prosemirror": "^1.2.1", "y-protocols": "^1.0.5", "yjs": "^13.6.4" + }, + "browser": { + "fs": false, + "os": false, + "path": false } } diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index d139be8..fdd929e 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; import ArticleWrapper from "../../features/article/components/ArticleWrapper"; @@ -19,11 +19,15 @@ import { BannerNode, BannerPlugin, } from "../../features/article/components/lexicalComponents/Banner"; -import { Placeholder, StyledContentEditable } from "../../utils/lexical"; +import { + JsonButtonContainer, + Placeholder, + StyledContentEditable, +} from "../../utils/lexical"; import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; -import { LineBreakNode, ParagraphNode } from "lexical"; +import { EditorState, LineBreakNode, ParagraphNode } from "lexical"; import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; import ToolbarPluginOnTheLeft from "../../features/article/components/lexicalComponents/ToolbarPluginOnTheLeft"; @@ -36,10 +40,11 @@ import { AutoLinkPlugin, createLinkMatcherWithRegExp, } from "@lexical/react/LexicalAutoLinkPlugin"; -// import { -// AutoLinkPlugin, -// createLinkMatcherWithRegExp, -// } from "@lexical/react/LexicalAutoLinkPlugin"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { + LoadFromJsonOnToolbar, + SaveToJsonOnToolbar, +} from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; function onError(error: Error): void { console.error(error); @@ -72,7 +77,7 @@ const myTheme = { interface Props {} -const initialConfig = { +const thissInitialConfig = { namespace: "MyEditor", theme: myTheme, onError, @@ -92,10 +97,14 @@ const initialConfig = { LinkPreviewNode, LineBreakNode, ], + editorState: + '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}', }; const Editor = ({}: Props): JSX.Element => { const [show, setShow] = useState(false); + const stateRef = useRef(); + const [editorJson, setEditorJson] = useState(""); const URL_REGEX = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; @@ -108,22 +117,21 @@ const Editor = ({}: Props): JSX.Element => { return ( - +
- + - + - @@ -135,6 +143,21 @@ const Editor = ({}: Props): JSX.Element => { ErrorBoundary={LexicalErrorBoundary} /> + { + if (stateRef) stateRef.current = editorState; + }} + /> + + { + if (stateRef.current) + setEditorJson(JSON.stringify(stateRef.current)); + }} + /> + + +
diff --git a/utils/lexical.ts b/utils/lexical.ts index c80e926..789db19 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -77,9 +77,10 @@ export const ToolbarItem = styled.button` `; export const StyledContentEditable = styled(ContentEditable)` - height: 400px; + height: max-content; + min-height: 300px; width: 100%; - padding: 8px; + padding: 8px 8px 24px 8px; border-bottom: 5px solid cornflowerblue; border-radius: 10px; margin-bottom: 10px; @@ -100,3 +101,12 @@ export const Placeholder = styled.div` left: 10px; color: #dddddd; `; + +export const JsonButtonContainer = styled.div` + display: flex; + justify-content: flex-end; + align-items: center; + gap: 5px; + right: 0; + position: absolute; +`; From c2cb17ef860669dda7f55f883e668091e7ab5b48 Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 7 Aug 2023 12:38:15 +0200 Subject: [PATCH 11/22] link prev css --- .../lexicalComponents/OwnLexicalToolbar.tsx | 13 ++++++++- .../lexicalComponents/PluginPlayground.tsx | 14 ---------- pages/blog/implementing-lexical.tsx | 4 +-- public/lexical.css | 28 ++++++++++--------- utils/lexical.ts | 12 ++++---- 5 files changed, 34 insertions(+), 37 deletions(-) diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index 2ad3c33..02f9907 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -1,5 +1,5 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { ToolbarItem } from "../../../../utils/lexical"; import { $createParagraphNode, @@ -19,6 +19,7 @@ import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/react/LexicalHorizontal import { INSERT_BANNER_COMMAND } from "./Banner"; import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text"; import { $createLinkNode } from "@lexical/link"; +import { registerCodeHighlighting } from "@lexical/code"; export const DoOnToolbar = () => { const [editor] = useLexicalComposerContext(); @@ -368,3 +369,13 @@ export const LoadFromJsonOnToolbar = ({ data }: { data: string }) => { }; return to Editor ; }; + +export function CodeHighlightPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + return registerCodeHighlighting(editor); + }, [editor]); + + return null; +} diff --git a/features/article/components/lexicalComponents/PluginPlayground.tsx b/features/article/components/lexicalComponents/PluginPlayground.tsx index 395f088..275057f 100644 --- a/features/article/components/lexicalComponents/PluginPlayground.tsx +++ b/features/article/components/lexicalComponents/PluginPlayground.tsx @@ -1,7 +1,3 @@ -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { useEffect } from "react"; -import { registerCodeHighlighting } from "@lexical/code"; - // const MyOnChangePlugin = (props: { // onChange: (editorState: EditorState) => void // }): null => { @@ -22,16 +18,6 @@ import { registerCodeHighlighting } from "@lexical/code"; // code (or maybe I'm using the wrong one?), underline, strikethrough, link, quote, hashtag // but I guess all of the others are broken too. -export default function CodeHighlightPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - return registerCodeHighlighting(editor); - }, [editor]); - - return null; -} - //////////////////////////////////////////////////////////////// // export class LinkPreviewNode0 extends ElementNode { diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index fdd929e..cf1ae8f 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -14,7 +14,6 @@ import { ListItemNode, ListNode } from "@lexical/list"; import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; import { HashtagNode } from "@lexical/hashtag"; import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; -import CodeHighlightPlugin from "../../features/article/components/lexicalComponents/PluginPlayground"; import { BannerNode, BannerPlugin, @@ -44,6 +43,7 @@ import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; import { LoadFromJsonOnToolbar, SaveToJsonOnToolbar, + CodeHighlightPlugin, } from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; function onError(error: Error): void { @@ -97,8 +97,6 @@ const thissInitialConfig = { LinkPreviewNode, LineBreakNode, ], - editorState: - '{"root":{"children":[{"children":[],"direction":null,"format":"","indent":0,"type":"paragraph","version":1}],"direction":null,"format":"","indent":0,"type":"root","version":1}}', }; const Editor = ({}: Props): JSX.Element => { diff --git a/public/lexical.css b/public/lexical.css index 96b4957..b3e72b1 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -112,11 +112,7 @@ .linkPreviewContainer { display: flex; - flex-direction: column; cursor: pointer; - text-decoration: underline; - color: royalblue; - max-width: 200px; } .linkPreviewContainer:hover { @@ -129,24 +125,30 @@ .previewBox { + width: 60%; + height: 80px; display: flex; - flex-direction: column; - justify-content: center; + flex-direction: row-reverse; + justify-content: space-between; align-items: center; - gap: 20px; - max-width: 200px; - padding: 10px; + padding-left: 10px; background-color: white; - border: thick solid red; + border: 2px solid lightgray; + border-radius: 10px; margin-top: 10px; + cursor: pointer; } .previewImage { - width: 150px; - height: 150px; + width: 160px; + height: 80px; + min-height: 100%; + overflow-y: hidden; + object-fit: cover; + border-radius: 8px; + border: 3px solid orangered; } .previewDescription { color: black; - padding: 10px; } \ No newline at end of file diff --git a/utils/lexical.ts b/utils/lexical.ts index 789db19..e92264d 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -89,16 +89,16 @@ export const StyledContentEditable = styled(ContentEditable)` outline: none; } - ::first-line { - font-size: 24px; - font-weight: bold; - } + //::first-line { + // font-size: 24px; + // font-weight: bold; + //} `; export const Placeholder = styled.div` position: absolute; - top: 62px; - left: 10px; + top: 57px; + left: 12px; color: #dddddd; `; From a89809a908ff2fbe5be7532656d843e880ddb669 Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 7 Aug 2023 12:44:34 +0200 Subject: [PATCH 12/22] left toolbar css --- utils/lexical.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/lexical.ts b/utils/lexical.ts index e92264d..a16790b 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -22,7 +22,7 @@ export const LeftToolbar = styled(Toolbar)<{ show: boolean }>` position: fixed; top: 180px; - left: 80px; + left: 20px; `; export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` From 8d18ec1455dc1e07dc7c9b7add026e7047381534 Mon Sep 17 00:00:00 2001 From: csillagk Date: Wed, 9 Aug 2023 11:53:08 +0200 Subject: [PATCH 13/22] working link preview --- .../lexicalComponents/LinkPreview.tsx | 143 ++++++++++++++---- .../lexicalComponents/PluginPlayground.tsx | 94 ------------ pages/blog/implementing-lexical.tsx | 12 +- public/lexical.css | 34 ++++- 4 files changed, 144 insertions(+), 139 deletions(-) delete mode 100644 features/article/components/lexicalComponents/PluginPlayground.tsx diff --git a/features/article/components/lexicalComponents/LinkPreview.tsx b/features/article/components/lexicalComponents/LinkPreview.tsx index 5f09268..02fbe52 100644 --- a/features/article/components/lexicalComponents/LinkPreview.tsx +++ b/features/article/components/lexicalComponents/LinkPreview.tsx @@ -1,6 +1,7 @@ import { $getRoot, - COMMAND_PRIORITY_LOW, + $insertNodes, + COMMAND_PRIORITY_CRITICAL, EditorConfig, ElementFormatType, LexicalEditor, @@ -21,22 +22,43 @@ type LinkPreviewT = { }>; nodeKey: NodeKey; url: string; + res: ResOfWebsite; onError?: (error: string) => void; loadingComponent?: JSX.Element | string; onLoad?: () => void; }; +export type SerializedLinkPreviewNode = Spread< + { + url: string; + res: ResOfWebsite; + format: ElementFormatType; + }, + SerializedLexicalNode +>; + +type ResOfWebsite = { + url: string; + title: string; + description: string; + images: Array; +}; + const LinkPreviewContainer: FC = ({ className, nodeKey, url, + res, }): JSX.Element => { return (
- {""} -
Lorem ipsum
+ {""} +
+
{res.title}
+
{res.description}
+
@@ -45,26 +67,31 @@ const LinkPreviewContainer: FC = ({ //////////////////////////////////////////////////////////////// -export type SerializedLinkPreviewNode = Spread< - { - url: string; - format: ElementFormatType; - }, - SerializedLexicalNode ->; export class LinkPreviewNode extends DecoratorBlockNode { __url: string; - constructor(url: string, format?: ElementFormatType, key?: NodeKey) { + __res: ResOfWebsite; + constructor( + url: string, + res: ResOfWebsite, + format?: ElementFormatType, + key?: NodeKey, + ) { super(format, key); this.__url = url; + this.__res = res; if (format) this.__format = format; } static getType() { - return "link-preview"; + return "LinkPreviewNode"; } static clone(node: LinkPreviewNode): LinkPreviewNode { - return new LinkPreviewNode(node.__url, node.__format, node.__key); + return new LinkPreviewNode( + node.__url, + node.__res, + node.__format, + node.__key, + ); } decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { @@ -75,13 +102,12 @@ export class LinkPreviewNode extends DecoratorBlockNode { }; return ( - <> - - + ); } updateDOM(): false { @@ -92,6 +118,10 @@ export class LinkPreviewNode extends DecoratorBlockNode { return this.getLatest().__url; } + getRes(): ResOfWebsite { + return this.getLatest().__res; + } + setFormat(format: ElementFormatType): void { const self = this.getWritable(); self.__format = format; @@ -102,6 +132,7 @@ export class LinkPreviewNode extends DecoratorBlockNode { ...super.exportJSON(), format: this.__format || "", url: this.getURL(), + res: this.getRes(), type: "link-preview", version: 1, }; @@ -110,24 +141,49 @@ export class LinkPreviewNode extends DecoratorBlockNode { static importJSON( serializedNode: SerializedLinkPreviewNode, ): LinkPreviewNode { - const node = $createLinkPreviewNode(serializedNode.url); + const node = $createLinkPreviewNode(serializedNode.url, serializedNode.res); node.setFormat(serializedNode.format); return node; } } -export function $createLinkPreviewNode(url: string): LinkPreviewNode { - return new LinkPreviewNode(url); +export function $createLinkPreviewNode( + url: string, + res: ResOfWebsite, +): LinkPreviewNode { + return new LinkPreviewNode(url, res); } +const fetchOgTags = async (link: string): Promise => { + const data = await fetch("/api/link-preview", { + method: "POST", + body: JSON.stringify({ + link, + }), + }); + const { + data: { url, title, description, images }, + } = await data.json(); + return { url, title, description, images }; +}; + //////////////////////////////////////////////////////////////// -export const LinkPreviewPlugin = (): null => { +export const LinkPreviewPlugin = ({ + showLink, +}: { + showLink: boolean; +}): null => { const [editor] = useLexicalComposerContext(); + const result = [ + { url: "", title: "", description: "", images: ["/linkprev.jpg", ""] }, + ]; + let alreadyHaveIt: ResOfWebsite | undefined; + if (!editor.hasNodes([LinkPreviewNode])) - throw new Error("LinkPreviewNode is not registered in the editor"); + throw new Error("LinkPreviewNode" + " is not registered in the editor"); const urlRegexp = - /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; useEffect(() => { const removeListener = editor.registerCommand( @@ -138,18 +194,39 @@ export const LinkPreviewPlugin = (): null => { if (typeof pastedItem === "string") { if (urlRegexp.test(pastedItem)) { - editor.update(() => { - const paragraph = $getRoot().getLastChild(); - paragraph?.append($createLinkPreviewNode(pastedItem)); - return true; - }); - return false; + const createPreview = async () => { + await fetchOgTags(pastedItem).then((res) => { + alreadyHaveIt = result.find( + (website) => res.url === website.url, + ); + if (!alreadyHaveIt) { + result.unshift(res); + } + }); + + if (!alreadyHaveIt) { + editor.update(() => { + if (showLink) { + $getRoot() + .getLastChild() + ?.append($createLinkPreviewNode(pastedItem, result[0])); + } else { + $insertNodes([ + $createLinkPreviewNode(pastedItem, result[0]), + ]); + } + return true; + }); + } + }; + createPreview(); + return !showLink; } return false; } return false; }, - COMMAND_PRIORITY_LOW, + COMMAND_PRIORITY_CRITICAL, ); return () => { removeListener(); diff --git a/features/article/components/lexicalComponents/PluginPlayground.tsx b/features/article/components/lexicalComponents/PluginPlayground.tsx deleted file mode 100644 index 275057f..0000000 --- a/features/article/components/lexicalComponents/PluginPlayground.tsx +++ /dev/null @@ -1,94 +0,0 @@ -// const MyOnChangePlugin = (props: { -// onChange: (editorState: EditorState) => void -// }): null => { -// const [editor] = useLexicalComposerContext(); -// const { onChange } = props; -// -// useEffect(() => { -// return editor.registerUpdateListener(({ editorState }) => { -// onChange(editorState); -// }); -// }, [onChange, editor]); -// return null; -// }; - -// TODO: listing: rm node_modules to work: the insert commands have problems -// TODO: undo doesnt work only for keyboard command -// TODO: css is not working for: -// code (or maybe I'm using the wrong one?), underline, strikethrough, link, quote, hashtag -// but I guess all of the others are broken too. - -//////////////////////////////////////////////////////////////// - -// export class LinkPreviewNode0 extends ElementNode { -// public pastedLink: string; -// constructor(key: string) { -// super(key); -// this.pastedLink = key; -// } -// -// static getType(): string { -// return "linkPreview"; -// } -// -// static clone(node: LinkPreviewNode0): LinkPreviewNode0 { -// return new LinkPreviewNode0(node.__key); -// } -// -// createDOM(config: EditorConfig): HTMLElement { -// const previewImage = document.createElement("img"); -// previewImage.className = config.theme.previewImage; -// previewImage.setAttribute("src", "/linkprev.jpg"); -// -// const previewDescription = document.createElement("div"); -// previewDescription.className = config.theme.previewDescription; -// previewDescription.innerText = "Lorem Ipsum"; -// -// const previewBox = document.createElement("div"); -// previewBox.className = config.theme.previewBox; -// previewBox.appendChild(previewImage); -// previewBox.appendChild(previewDescription); -// -// const linkPreviewContainer = document.createElement("a"); -// linkPreviewContainer.className = config.theme.linkPreviewContainer; -// linkPreviewContainer.href = "www.telex.hu"; -// linkPreviewContainer.target = "_blank"; -// linkPreviewContainer.innerText = this.pastedLink; -// linkPreviewContainer.appendChild(previewBox); -// -// return linkPreviewContainer; -// } -// -// updateDOM(): false { -// return false; -// } -// -// // insertNewAfter( -// // selection: RangeSelection, -// // restoreSelection?: boolean | undefined, -// // ): LexicalNode | null { -// // const newBlock = $createParagraphNode(); -// // const direction = this.getDirection(); -// // newBlock.setDirection(direction); -// // this.inserAfter(newBlock, restoreSelection); -// // return newBlock; -// // } -// -// collapseAtStart(): boolean { -// const p = $createParagraphNode(); -// const children = this.getChildren(); -// children.forEach((child) => p.append(child)); -// this.replace(p); -// -// return true; -// } -// } - -// export function $createLinkPreviewNode(pastedLink: string): LinkPreviewNode0 { -// return new LinkPreviewNode0(pastedLink); -// } - -export const isIt = (url: string) => { - // const mine = new URL(url); - // console.log(mine); -}; diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index cf1ae8f..c3ca5a8 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -100,12 +100,12 @@ const thissInitialConfig = { }; const Editor = ({}: Props): JSX.Element => { - const [show, setShow] = useState(false); + const [showLeftToolbar, setShowLeftToolbar] = useState(false); const stateRef = useRef(); const [editorJson, setEditorJson] = useState(""); const URL_REGEX = - /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; const MATCHERS = [ createLinkMatcherWithRegExp(URL_REGEX, (text) => { @@ -122,9 +122,9 @@ const Editor = ({}: Props): JSX.Element => { - + - + @@ -135,7 +135,9 @@ const Editor = ({}: Props): JSX.Element => { setShow(!show)} /> + setShowLeftToolbar(!showLeftToolbar)} + /> } placeholder={Let's start with a title...} ErrorBoundary={LexicalErrorBoundary} diff --git a/public/lexical.css b/public/lexical.css index b3e72b1..81b6ec2 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -129,9 +129,10 @@ height: 80px; display: flex; flex-direction: row-reverse; + gap: 10px; justify-content: space-between; align-items: center; - padding-left: 10px; + padding: 5px 0 5px 10px; background-color: white; border: 2px solid lightgray; border-radius: 10px; @@ -140,15 +141,34 @@ } .previewImage { - width: 160px; - height: 80px; - min-height: 100%; - overflow-y: hidden; - object-fit: cover; + min-width: 60px; + height: 100%; border-radius: 8px; - border: 3px solid orangered; + object-fit: contain; + padding-right: 10px; +} + + +.previewText{ + display: flex; + flex-direction: column; + justify-content: center; + align-items: start; + gap: 5px; + width: 100%; + height: 100%; +} + +.previewTitle { + color: black; + font-size: 12px; + font-weight: 700; } .previewDescription { color: black; + font-size: 12px; + font-weight: 200; + text-align: start; + } \ No newline at end of file From 98ae939a1e328557db1b1d0296e1f40eef9aa869 Mon Sep 17 00:00:00 2001 From: csillagk Date: Wed, 9 Aug 2023 16:14:13 +0200 Subject: [PATCH 14/22] article: first steps --- articles/lexical-link-preview/index.tsx | 164 ++++++++++++++++++ .../lexicalComponents/OwnLexicalToolbar.tsx | 48 +++-- .../lexicalComponents/ToolbarPlugin.tsx | 10 +- .../lexicalComponents/TreeViewPlugin.tsx | 2 +- pages/blog/implementing-lexical.tsx | 8 +- pages/blog/lexical-link-preview.tsx | 78 +++++++++ public/lexical.css | 8 + utils/lexical.ts | 88 +++++----- 8 files changed, 345 insertions(+), 61 deletions(-) create mode 100644 articles/lexical-link-preview/index.tsx create mode 100644 pages/blog/lexical-link-preview.tsx diff --git a/articles/lexical-link-preview/index.tsx b/articles/lexical-link-preview/index.tsx new file mode 100644 index 0000000..ae1a863 --- /dev/null +++ b/articles/lexical-link-preview/index.tsx @@ -0,0 +1,164 @@ +import React, { useRef, useState } from "react"; +import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary"; + +import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin"; +import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; +import { TRANSFORMERS } from "@lexical/markdown"; +import { CodeHighlightNode, CodeNode } from "@lexical/code"; +import { AutoLinkNode, LinkNode } from "@lexical/link"; +import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin"; +import { HeadingNode, QuoteNode } from "@lexical/rich-text"; +import { ListItemNode, ListNode } from "@lexical/list"; +import { TabIndentationPlugin } from "@lexical/react/LexicalTabIndentationPlugin"; +import { HashtagNode } from "@lexical/hashtag"; +import { CheckListPlugin } from "@lexical/react/LexicalCheckListPlugin"; +import { + BannerNode, + BannerPlugin, +} from "../../features/article/components/lexicalComponents/Banner"; +import { + JsonButtonContainer, + Placeholder, + StyledContentEditable, +} from "../../utils/lexical"; +import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; +import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; +import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; +import { EditorState, LineBreakNode, ParagraphNode } from "lexical"; +import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; +import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; +import { + LinkPreviewNode, + LinkPreviewPlugin, +} from "../../features/article/components/lexicalComponents/LinkPreview"; +import TreeViewPlugin from "../../features/article/components/lexicalComponents/TreeViewPlugin"; +import { + AutoLinkPlugin, + createLinkMatcherWithRegExp, +} from "@lexical/react/LexicalAutoLinkPlugin"; +import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin"; +import { + LoadFromJsonOnToolbar, + SaveToJsonOnToolbar, + CodeHighlightPlugin, +} from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; +import { LexicalComposer } from "@lexical/react/LexicalComposer"; + +function onError(error: Error): void { + console.error(error); +} + +const myTheme = { + text: { + bold: "textBold", + code: "textCode", + italic: "textItalic", + strikethrough: "textStrikethrough", + subscript: "textSubscript", + superscript: "textSuperscript", + underline: "textUnderline", + underlineStrikethrough: "textUnderlineStrikethrough", + }, + list: { + listitem: "listItem", + listitemChecked: "listItemChecked", + listitemUnchecked: "listItemUnchecked", + }, + quote: "quote", + link: "mylink", + hashtag: "hashtag", + linkPreviewContainer: "linkPreviewContainer", + previewBox: "previewBox", + previewImage: "previewImage", + previewDescription: "previewDescription", +}; + +interface Props {} + +const thissInitialConfig = { + namespace: "MyEditor", + theme: myTheme, + onError, + nodes: [ + HeadingNode, + ListNode, + ListItemNode, + LinkNode, + BannerNode, + HorizontalRuleNode, + CodeNode, + QuoteNode, + HashtagNode, + CodeHighlightNode, + ParagraphNode, + AutoLinkNode, + LinkPreviewNode, + LineBreakNode, + ], +}; + +const Editor = ({}: Props): JSX.Element => { + const [showLeftToolbar, setShowLeftToolbar] = useState(false); + const stateRef = useRef(); + const [editorJson, setEditorJson] = useState(""); + // const [showLink, setShowLink] = useState(false); + + const URL_REGEX = + /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; + + const MATCHERS = [ + createLinkMatcherWithRegExp(URL_REGEX, (text) => { + return text.startsWith("http") ? text : `https://${text}`; + }), + ]; + + return ( + +
+ + + + + + + + + + + + + + + + setShowLeftToolbar(!showLeftToolbar)} + /> + } + placeholder={ 🖇 Paste your link!} + ErrorBoundary={LexicalErrorBoundary} + /> + + { + if (stateRef) stateRef.current = editorState; + }} + /> + + { + if (stateRef.current) + setEditorJson(JSON.stringify(stateRef.current)); + }} + /> + + + + +
+
+ ); +}; + +export default Editor; diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index 02f9907..d703d9c 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -275,19 +275,42 @@ export const FormatThings = () => { // ]; export const FontSizeOnToolbar = (): JSX.Element => { - // const [editor] = useLexicalComposerContext(); - - const fontSizeOnClick = (): void => { - return; - // editor.update(() => { - // const selection = $getSelection(); - // if ($isRangeSelection(selection)) { - // $setBlocksType(selection, () => {return}); - // } - // }); - }; + const [editor] = useLexicalComposerContext(); + const fontSizeOptions = [ + "10px", + "12px", + "14px", + "16px", + "18px", + "20px", + "24px", + "28px", + "32px", + ]; + + const fontSizeOnClick = useCallback( + (size: string) => { + editor.update(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + $patchStyleText(selection, { + ["font-size"]: size, + }); + } + }); + }, + [editor], + ); - return Font Size; + return ( + <> + {fontSizeOptions.map((size, i) => ( + fontSizeOnClick(size)}> + {size} + + ))} + + ); }; export const ColoringOnToolbar = () => { @@ -354,7 +377,6 @@ export const HROnToolbar = () => { return --- hr ---; }; -// TODO: export DOM button on the tree view?? export const SaveToJsonOnToolbar = ({ onClick }: { onClick: () => void }) => { return to JSON; }; diff --git a/features/article/components/lexicalComponents/ToolbarPlugin.tsx b/features/article/components/lexicalComponents/ToolbarPlugin.tsx index 8f54c8b..364824b 100644 --- a/features/article/components/lexicalComponents/ToolbarPlugin.tsx +++ b/features/article/components/lexicalComponents/ToolbarPlugin.tsx @@ -53,7 +53,7 @@ const ToolbarPlugin = (): JSX.Element => { return () => { document.removeEventListener("mousedown", handler); }; - }, [isStylingPOpen, isColoringOpen, isFormattingTextOpen]); + }, [isStylingPOpen, isColoringOpen, isFormattingTextOpen, isFontSizeOpen]); // TODO: its not working @@ -117,7 +117,7 @@ const ToolbarPlugin = (): JSX.Element => {
setIsStylingPOpen(!isStylingPOpen)}> - Style P ⬇️ + Style P ⭣ @@ -130,7 +130,7 @@ const ToolbarPlugin = (): JSX.Element => { ref={formatRef} onClick={() => setIsFormattingTextOpen(!isFormattingTextOpen)} > - Format text ⬇️ + Format text ⭣ @@ -138,14 +138,14 @@ const ToolbarPlugin = (): JSX.Element => {
setIsColoringOpen(!isColoringOpen)}> - Coloring ⬇️ + Coloring ⭣
setIsFontSizeOpen(!isFontSizeOpen)}> - Font Size ⬇️ + Font Size ⭣ diff --git a/features/article/components/lexicalComponents/TreeViewPlugin.tsx b/features/article/components/lexicalComponents/TreeViewPlugin.tsx index 05656bb..1076843 100644 --- a/features/article/components/lexicalComponents/TreeViewPlugin.tsx +++ b/features/article/components/lexicalComponents/TreeViewPlugin.tsx @@ -6,7 +6,7 @@ export default function TreeViewPlugin(): JSX.Element { const [editor] = useLexicalComposerContext(); return ( { - - + + @@ -139,7 +139,9 @@ const Editor = ({}: Props): JSX.Element => { onClick={() => setShowLeftToolbar(!showLeftToolbar)} /> } - placeholder={Let's start with a title...} + placeholder={ + 😎 Let's start with a title... + } ErrorBoundary={LexicalErrorBoundary} /> diff --git a/pages/blog/lexical-link-preview.tsx b/pages/blog/lexical-link-preview.tsx new file mode 100644 index 0000000..f7df7df --- /dev/null +++ b/pages/blog/lexical-link-preview.tsx @@ -0,0 +1,78 @@ +import React from "react"; +import styled from "styled-components"; +import dynamic from "next/dynamic"; + +import ArticleWrapper from "../../features/article/components/ArticleWrapper"; +import { ArticleIntro } from "../../features/article/types"; +import Markdown from "../../features/article/components/Markdown"; +import ArticleShareOgTags from "../../features/article/components/ArticleShareOgTags"; +import SalesBox from "../../features/article/components/SalesBox"; +import ArticleHeader from "../../features/article/components/ArticleHeader"; + +const DynamicEditor = dynamic( + () => import("../../articles/lexical-link-preview"), + { ssr: false }, +); + +const EditorStyling = styled.div` + flex: 1; +`; + +export const article17Metadata: ArticleIntro = { + title: "lexical-link-preview: Link preview for Lexical", + author: "Kata", + authorLink: null, + introText: /* language=md */ `An open source link preview plugin for lexical made by Emergence-Engineering.`, + postId: "lexical-link-preview", + timestamp: 1691532000000, + imgSrc: "https://lexical.dev/img/logo.svg", + url: "https://emergence-engineering.com/blog/lexical-link-preview", +}; + +const MD0 = /* language=md */ ` + +## Introduction +The **prosemirror-link-preview** plugin adds Discord and Slack like link previews to your Prosemirror editor. The plugin catches pasted links and renders a preview automatically. Follow along to learn more about the plugin and how to use it in your project. + +## Features + +1. **Dynamic Link Previews**: Whenever a valid URL is pasted into a ProseMirror document, the plugin automatically calls **your callback** function that fetches the necessary metadata, and the plugin renders a preview, providing a quick glimpse into the content behind the link. + +2. **Rich Preview Styles**: By default the plugin creates a preview that resembles the style of most social media platforms link sharing cards. It makes it easier to differentiate between regular text and linked content. The preview includes information such as the title, description, and an image associated with the link, where available based on Open Graph metadata. + +3. **Configurable Behavior**: The plugin provides configuration options, allowing users to customize the behavior and appearance of link previews according to their specific needs. From adjusting the preview size to defining custom CSS styles, the plugin offers flexibility to match the desired editing environment. + +`; + +const MD1 = /* language=md */ ` + +# Caveat +Because of CORS you can't fetch the link preview from the client directly. You need to have a custom backend that will fetch the link preview for you. You either use payed OpenGraph fetcher API or use \`link-preview-js\` library on your backend to do this. In this article we will use \`link-preview-js\` with Next.js API to do this. Since the Next.js API is very similar to Express.js, you can just copy and paste the code to Express.js servers as well. + +# How to use it + +You can check out the docs at +`; + +const Article = () => ( + + + + + + + + + + +); +export default Article; diff --git a/public/lexical.css b/public/lexical.css index 81b6ec2..44638ce 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -171,4 +171,12 @@ font-weight: 200; text-align: start; +} + +.custom-tree-view-output { + overflow-x: scroll; + object-fit: contain; + background-color: #f1f1f1; + padding: 10px; + border-radius: 10px; } \ No newline at end of file diff --git a/utils/lexical.ts b/utils/lexical.ts index a16790b..cd09f74 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -1,17 +1,45 @@ import styled from "styled-components"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { TreeView } from "@lexical/react/LexicalTreeView"; + +export const StyledContentEditable = styled(ContentEditable)` + height: max-content; + min-height: 300px; + width: 100%; + padding: 8px 8px 24px 8px; + border: 1px solid #c7c7c7; + //border: 1px solid cornflowerblue; + //border-bottom: 5px solid cornflowerblue; + border-radius: 0 0 10px 10px; + margin-bottom: 10px; + + :focus { + outline: none; + } + + //::first-line { + // font-size: 24px; + // font-weight: bold; + //} +`; + +export const Placeholder = styled.div` + position: absolute; + top: 57px; + left: 12px; + color: #bbbbbb; +`; export const Toolbar = styled.div` display: flex; gap: 5px; align-items: center; padding: 3px 6px; - border-style: dashed; - border-color: magenta; - border-width: 2px; - border-radius: 30px; + border: 1px solid #c7c7c7; + //border: 1px solid cornflowerblue; + //border-bottom: 5px solid cornflowerblue; + border-radius: 10px 10px 0 0; margin-bottom: 10px; - background-color: white; `; export const LeftToolbar = styled(Toolbar)<{ show: boolean }>` @@ -31,9 +59,11 @@ export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` align-items: center; gap: 5px; padding: 3px 6px; - border: 2px dashed lightcoral; border-radius: 10px; - background-color: lightcyan; + border: 2px solid #c7c7c7; + background-color: #f8f8f8; + //border: 2px dashed lightcoral; + //background-color: lightcyan; z-index: 10; width: 100px; justify-content: center; @@ -46,13 +76,13 @@ export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` case "s": return "80px"; case "f": - return "170px"; + return "160px"; case "c": return "270px"; case "i": return "10px"; case "fs": - return "340px"; + return "360px"; default: return 0; } @@ -64,49 +94,29 @@ export const ToolbarItem = styled.button` border: none; background: none; cursor: pointer; - color: dimgray; + color: black; + //color: dimgray; align-items: baseline; min-width: 50px; font-size: 14px; :hover { - background: lightpink; - border-radius: 15px; + background: #d1e3ff; + border-radius: 5px; } `; -export const StyledContentEditable = styled(ContentEditable)` - height: max-content; - min-height: 300px; - width: 100%; - padding: 8px 8px 24px 8px; - border-bottom: 5px solid cornflowerblue; - border-radius: 10px; - margin-bottom: 10px; - - :focus { - outline: none; - } - - //::first-line { - // font-size: 24px; - // font-weight: bold; - //} -`; - -export const Placeholder = styled.div` - position: absolute; - top: 57px; - left: 12px; - color: #dddddd; -`; - export const JsonButtonContainer = styled.div` display: flex; justify-content: flex-end; align-items: center; gap: 5px; - right: 0; + right: 5px; position: absolute; `; + +export const StyledTreeView = styled(TreeView)` + border: thin solid red; + width: 10px; +`; From dd43cd9be2b1db279d7c0efe886e8e4bfcc20f14 Mon Sep 17 00:00:00 2001 From: csillagk Date: Thu, 10 Aug 2023 10:56:24 +0200 Subject: [PATCH 15/22] code block --- .../lexicalComponents/OwnLexicalToolbar.tsx | 29 +++++++++++++- .../lexicalComponents/ToolbarPlugin.tsx | 2 + pages/blog/implementing-lexical.tsx | 5 +-- public/lexical.css | 40 ++++++++++++++++--- utils/lexical.ts | 5 ++- 5 files changed, 70 insertions(+), 11 deletions(-) diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index d703d9c..b231371 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -19,7 +19,7 @@ import { INSERT_HORIZONTAL_RULE_COMMAND } from "@lexical/react/LexicalHorizontal import { INSERT_BANNER_COMMAND } from "./Banner"; import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text"; import { $createLinkNode } from "@lexical/link"; -import { registerCodeHighlighting } from "@lexical/code"; +import { registerCodeHighlighting, $createCodeNode } from "@lexical/code"; export const DoOnToolbar = () => { const [editor] = useLexicalComposerContext(); @@ -401,3 +401,30 @@ export function CodeHighlightPlugin(): JSX.Element | null { return null; } + +export const CodeBlockOnToolbar = () => { + // const codeLanguages = ["JavaScript", "HTML", "CSS", "Python", "TypeScript"]; + + const [editor] = useLexicalComposerContext(); + + const codeBlockOnClick = () => { + editor.update(() => { + let selection = $getSelection(); + + if ($isRangeSelection(selection)) { + if (selection.isCollapsed()) { + $setBlocksType(selection, () => $createCodeNode()); + } else { + const textContent = selection.getTextContent(); + const codeNode = $createCodeNode(); + selection.insertNodes([codeNode]); + selection = $getSelection(); + if ($isRangeSelection(selection)) + selection.insertRawText(textContent); + } + } + }); + }; + + return CodeBlock; +}; diff --git a/features/article/components/lexicalComponents/ToolbarPlugin.tsx b/features/article/components/lexicalComponents/ToolbarPlugin.tsx index 364824b..8e10126 100644 --- a/features/article/components/lexicalComponents/ToolbarPlugin.tsx +++ b/features/article/components/lexicalComponents/ToolbarPlugin.tsx @@ -3,6 +3,7 @@ import { Dropdown, Toolbar, ToolbarItem } from "../../../../utils/lexical"; import { BannerOnToolbar, BlockquoteOnToolbar, + CodeBlockOnToolbar, ColoringOnToolbar, FontSizeOnToolbar, FormatThings, @@ -123,6 +124,7 @@ const ToolbarPlugin = (): JSX.Element => { +
diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index 0ef61f1..0d49f3f 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -73,6 +73,7 @@ const myTheme = { previewBox: "previewBox", previewImage: "previewImage", previewDescription: "previewDescription", + code: "codeBlock", }; interface Props {} @@ -139,9 +140,7 @@ const Editor = ({}: Props): JSX.Element => { onClick={() => setShowLeftToolbar(!showLeftToolbar)} /> } - placeholder={ - 😎 Let's start with a title... - } + placeholder={🫣 Let's see if it works...} ErrorBoundary={LexicalErrorBoundary} /> diff --git a/public/lexical.css b/public/lexical.css index 44638ce..d212633 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -1,3 +1,13 @@ + +.custom-tree-view-output { + overflow-x: scroll; + object-fit: contain; + background-color: #f1f1f1; + padding: 10px; + border-radius: 10px; +} + + .textBold { font-weight: bold; } @@ -173,10 +183,28 @@ } -.custom-tree-view-output { - overflow-x: scroll; - object-fit: contain; - background-color: #f1f1f1; - padding: 10px; - border-radius: 10px; +.codeBlock { + 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: 8px 0; + overflow-x: auto; + position: relative; + tab-size: 2; +} +.codeBlock: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; } \ No newline at end of file diff --git a/utils/lexical.ts b/utils/lexical.ts index cd09f74..5490eff 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -44,7 +44,8 @@ export const Toolbar = styled.div` export const LeftToolbar = styled(Toolbar)<{ show: boolean }>` flex-direction: column; - border: 2px dashed magenta; + border: 1px solid #c7c7c7; + border-radius: 10px 0 0 10px; padding: 8px; display: ${({ show }) => (show ? "flex" : "none")}; @@ -83,6 +84,8 @@ export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` return "10px"; case "fs": return "360px"; + case "cb": + return "455px"; default: return 0; } From 67eb0a4d289e1fe762efad070c77a3374116f375 Mon Sep 17 00:00:00 2001 From: csillagk Date: Fri, 11 Aug 2023 08:44:42 +0200 Subject: [PATCH 16/22] half working draggable blocks --- articles/lexical-link-preview/index.tsx | 42 +- .../DraggableBlockPlugin.tsx | 465 ++++++++++++++++++ .../lexicalComponents/LinkPreview.tsx | 22 +- .../lexicalComponents/ToolbarPlugin.tsx | 10 +- .../ToolbarPluginOnTheLeft.tsx | 61 ++- .../components/lexicalComponents/point.ts | 55 +++ .../components/lexicalComponents/rect.ts | 158 ++++++ pages/blog/implementing-lexical.tsx | 9 +- public/draggable-block-icon.svg | 1 + public/global.css | 1 + public/lexical.css | 44 +- utils/lexical.ts | 49 +- 12 files changed, 849 insertions(+), 68 deletions(-) create mode 100644 features/article/components/lexicalComponents/DraggableBlockPlugin.tsx create mode 100644 features/article/components/lexicalComponents/point.ts create mode 100644 features/article/components/lexicalComponents/rect.ts create mode 100644 public/draggable-block-icon.svg diff --git a/articles/lexical-link-preview/index.tsx b/articles/lexical-link-preview/index.tsx index ae1a863..5544acd 100644 --- a/articles/lexical-link-preview/index.tsx +++ b/articles/lexical-link-preview/index.tsx @@ -30,6 +30,7 @@ import ToolbarPlugin from "../../features/article/components/lexicalComponents/T import { LinkPreviewNode, LinkPreviewPlugin, + ResOfWebsite, } from "../../features/article/components/lexicalComponents/LinkPreview"; import TreeViewPlugin from "../../features/article/components/lexicalComponents/TreeViewPlugin"; import { @@ -43,6 +44,7 @@ import { CodeHighlightPlugin, } from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; +import DraggableBlockPlugin from "../../features/article/components/lexicalComponents/DraggableBlockPlugin"; function onError(error: Error): void { console.error(error); @@ -97,11 +99,31 @@ const thissInitialConfig = { ], }; +async function thisFetchingFunction(link: string): Promise { + const data = await fetch("/api/link-preview", { + method: "POST", + body: JSON.stringify({ + link, + }), + }); + const { + data: { url, title, description, images }, + } = await data.json(); + return { url, title, description, images }; +} + const Editor = ({}: Props): JSX.Element => { const [showLeftToolbar, setShowLeftToolbar] = useState(false); const stateRef = useRef(); const [editorJson, setEditorJson] = useState(""); - // const [showLink, setShowLink] = useState(false); + const [floatingAnchorElem, setFloatingAnchorElem] = + useState(null); + + const onRef = (_floatingAnchorElem: HTMLDivElement) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem); + } + }; const URL_REGEX = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; @@ -111,7 +133,6 @@ const Editor = ({}: Props): JSX.Element => { return text.startsWith("http") ? text : `https://${text}`; }), ]; - return (
@@ -119,9 +140,16 @@ const Editor = ({}: Props): JSX.Element => { + {floatingAnchorElem && ( + + )} + - + @@ -132,9 +160,11 @@ const Editor = ({}: Props): JSX.Element => { setShowLeftToolbar(!showLeftToolbar)} - /> +
+ setShowLeftToolbar(!showLeftToolbar)} + /> +
} placeholder={ 🖇 Paste your link!} ErrorBoundary={LexicalErrorBoundary} diff --git a/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx b/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx new file mode 100644 index 0000000..5464f40 --- /dev/null +++ b/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx @@ -0,0 +1,465 @@ +/** + * 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 { eventFiles } from "@lexical/rich-text"; +import { mergeRegister } from "@lexical/utils"; +import { + $getNearestNodeFromDOMNode, + $getNodeByKey, + $getRoot, + COMMAND_PRIORITY_HIGH, + COMMAND_PRIORITY_LOW, + DRAGOVER_COMMAND, + DROP_COMMAND, + LexicalEditor, +} from "lexical"; +import * as React from "react"; +import { + DragEvent as ReactDragEvent, + useEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; + +import { Point } from "./point"; +import { Rect } from "./rect"; + +const SPACE = 4; +const TARGET_LINE_HALF_HEIGHT = 2; +const DRAGGABLE_BLOCK_MENU_CLASSNAME = "draggable-block-menu"; +const DRAG_DATA_FORMAT = "application/x-lexical-drag-block"; +const TEXT_BOX_HORIZONTAL_PADDING = 28; + +const Downward = 1; +const Upward = -1; +const Indeterminate = 0; + +let prevIndex = Infinity; + +export function isHTMLElement(x: unknown): x is HTMLElement { + return x instanceof HTMLElement; +} + +function getCurrentIndex(keysLength: number): number { + if (keysLength === 0) { + return Infinity; + } + if (prevIndex >= 0 && prevIndex < keysLength) { + return prevIndex; + } + + return Math.floor(keysLength / 2); +} + +function getTopLevelNodeKeys(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => $getRoot().getChildrenKeys()); +} + +function getCollapsedMargins(elem: HTMLElement): { + marginTop: number; + marginBottom: number; +} { + const getMargin = ( + element: Element | null, + margin: "marginTop" | "marginBottom", + ): number => + element ? parseFloat(window.getComputedStyle(element)[margin]) : 0; + + const { marginTop, marginBottom } = window.getComputedStyle(elem); + const prevElemSiblingMarginBottom = getMargin( + elem.previousElementSibling, + "marginBottom", + ); + const nextElemSiblingMarginTop = getMargin( + elem.nextElementSibling, + "marginTop", + ); + const collapsedTopMargin = Math.max( + parseFloat(marginTop), + prevElemSiblingMarginBottom, + ); + const collapsedBottomMargin = Math.max( + parseFloat(marginBottom), + nextElemSiblingMarginTop, + ); + + return { marginBottom: collapsedBottomMargin, marginTop: collapsedTopMargin }; +} + +function getBlockElement( + anchorElem: HTMLElement, + editor: LexicalEditor, + event: MouseEvent, + useEdgeAsDefault = false, +): HTMLElement | null { + const anchorElementRect = anchorElem.getBoundingClientRect(); + const topLevelNodeKeys = getTopLevelNodeKeys(editor); + + let blockElem: HTMLElement | null = null; + + editor.getEditorState().read(() => { + if (useEdgeAsDefault) { + const [firstNode, lastNode] = [ + editor.getElementByKey(topLevelNodeKeys[0]), + editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]), + ]; + + const [firstNodeRect, lastNodeRect] = [ + firstNode?.getBoundingClientRect(), + lastNode?.getBoundingClientRect(), + ]; + + if (firstNodeRect && lastNodeRect) { + if (event.y < firstNodeRect.top) { + blockElem = firstNode; + } else if (event.y > lastNodeRect.bottom) { + blockElem = lastNode; + } + + if (blockElem) { + return; + } + } + } + + let index = getCurrentIndex(topLevelNodeKeys.length); + let direction = Indeterminate; + + while (index >= 0 && index < topLevelNodeKeys.length) { + const key = topLevelNodeKeys[index]; + const elem = editor.getElementByKey(key); + if (elem === null) { + break; + } + const point = new Point(event.x, event.y); + const domRect = Rect.fromDOM(elem); + const { marginTop, marginBottom } = getCollapsedMargins(elem); + + const rect = domRect.generateNewRect({ + bottom: domRect.bottom + marginBottom, + left: anchorElementRect.left, + right: anchorElementRect.right, + top: domRect.top - marginTop, + }); + + const { + result, + reason: { isOnTopSide, isOnBottomSide }, + } = rect.contains(point); + + if (result) { + blockElem = elem; + prevIndex = index; + break; + } + + if (direction === Indeterminate) { + if (isOnTopSide) { + direction = Upward; + } else if (isOnBottomSide) { + direction = Downward; + } else { + // stop search block element + direction = Infinity; + } + } + + index += direction; + } + }); + + return blockElem; +} + +function isOnMenu(element: HTMLElement): boolean { + return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`); +} + +function setMenuPosition( + targetElem: HTMLElement | null, + floatingElem: HTMLElement, + anchorElem: HTMLElement, +) { + if (!targetElem) { + floatingElem.style.opacity = "0"; + floatingElem.style.transform = "translate(-100000000px, 10000000px)"; + // floatingElem.style.opacity = "1"; + // floatingElem.style.transform = "translate(4px, 34px)"; + // floatingElem.style.border = "thin solid red"; + return; + } + + const targetRect = targetElem.getBoundingClientRect(); + const targetStyle = window.getComputedStyle(targetElem); + const floatingElemRect = floatingElem.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + + const left = SPACE; + + const top = + targetRect.top + + (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - + anchorElementRect.top; + // const top = + // targetRect.top + + // (113 - floatingElemRect.height) / 2 - + // anchorElementRect.top; + + console.log("-----------------------------"); + console.log(targetElem); + console.log("targetRect top: " + targetRect.top); + console.log("targetStyle lineHeight: " + targetStyle.lineHeight); + console.log("floatingElemRect height: " + floatingElemRect.height); + console.log("anchorElemRect top: " + anchorElementRect.top); + console.log( + "tehat " + + targetRect.top + + " + (" + + targetStyle.lineHeight + + " - " + + floatingElemRect.height + + ") /2 - " + + anchorElementRect.top, + ); + + floatingElem.style.opacity = "1"; + floatingElem.style.transform = `translate(${left}px, ${top}px)`; + + floatingElem.style.border = "thin solid blue"; +} + +function setDragImage( + dataTransfer: DataTransfer, + draggableBlockElem: HTMLElement, +) { + const { transform } = draggableBlockElem.style; + + // Remove dragImage borders + draggableBlockElem.style.transform = "translateZ(0)"; + dataTransfer.setDragImage(draggableBlockElem, 0, 0); + + setTimeout(() => { + draggableBlockElem.style.transform = transform; + }); +} + +function setTargetLine( + targetLineElem: HTMLElement, + targetBlockElem: HTMLElement, + mouseY: number, + anchorElem: HTMLElement, +) { + const { top: targetBlockElemTop, height: targetBlockElemHeight } = + targetBlockElem.getBoundingClientRect(); + const { top: anchorTop, width: anchorWidth } = + anchorElem.getBoundingClientRect(); + + const { marginTop, marginBottom } = getCollapsedMargins(targetBlockElem); + let lineTop = targetBlockElemTop; + if (mouseY >= targetBlockElemTop) { + lineTop += targetBlockElemHeight + marginBottom / 2; + } else { + lineTop -= marginTop / 2; + } + + const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT; + const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE; + + targetLineElem.style.transform = `translate(${left}px, ${top}px)`; + targetLineElem.style.width = `${ + anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2 + }px`; + targetLineElem.style.opacity = ".4"; +} + +function hideTargetLine(targetLineElem: HTMLElement | null) { + if (targetLineElem) { + targetLineElem.style.opacity = "0"; + targetLineElem.style.transform = "translate(-10000px, -10000px)"; + } +} + +function useDraggableBlockMenu( + editor: LexicalEditor, + anchorElem: HTMLElement, + isEditable: boolean, +): JSX.Element { + const scrollerElem = anchorElem.parentElement; + + const menuRef = useRef(null); + const targetLineRef = useRef(null); + const isDraggingBlockRef = useRef(false); + const [draggableBlockElem, setDraggableBlockElem] = + useState(null); + + useEffect(() => { + function onMouseMove(event: MouseEvent) { + const target = event.target; + + if (!isHTMLElement(target)) { + setDraggableBlockElem(null); + return; + } + + if (isOnMenu(target)) { + return; + } + + const _draggableBlockElem = getBlockElement(anchorElem, editor, event); + + setDraggableBlockElem(_draggableBlockElem); + } + + function onMouseLeave() { + setDraggableBlockElem(null); + } + + scrollerElem?.addEventListener("mousemove", onMouseMove); + scrollerElem?.addEventListener("mouseleave", onMouseLeave); + + return () => { + scrollerElem?.removeEventListener("mousemove", onMouseMove); + scrollerElem?.removeEventListener("mouseleave", onMouseLeave); + }; + }, [scrollerElem, anchorElem, editor]); + + useEffect(() => { + if (menuRef.current) { + setMenuPosition(draggableBlockElem, menuRef.current, anchorElem); + } + }, [anchorElem, draggableBlockElem]); + + useEffect(() => { + function onDragover(event: DragEvent): boolean { + if (!isDraggingBlockRef.current) { + return false; + } + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const { pageY, target } = event; + if (!isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event, true); + const targetLineElem = targetLineRef.current; + if (targetBlockElem === null || targetLineElem === null) { + return false; + } + setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem); + // Prevent default event to be able to trigger onDrop events + event.preventDefault(); + return true; + } + + function onDrop(event: DragEvent): boolean { + if (!isDraggingBlockRef.current) { + return false; + } + const [isFileTransfer] = eventFiles(event); + if (isFileTransfer) { + return false; + } + const { target, dataTransfer, pageY } = event; + const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || ""; + const draggedNode = $getNodeByKey(dragData); + if (!draggedNode) { + return false; + } + if (!isHTMLElement(target)) { + return false; + } + const targetBlockElem = getBlockElement(anchorElem, editor, event, true); + if (!targetBlockElem) { + return false; + } + const targetNode = $getNearestNodeFromDOMNode(targetBlockElem); + if (!targetNode) { + return false; + } + if (targetNode === draggedNode) { + return true; + } + const targetBlockElemTop = targetBlockElem.getBoundingClientRect().top; + if (pageY >= targetBlockElemTop) { + targetNode.insertAfter(draggedNode); + } else { + targetNode.insertBefore(draggedNode); + } + setDraggableBlockElem(null); + + return true; + } + + return mergeRegister( + editor.registerCommand( + DRAGOVER_COMMAND, + (event) => { + return onDragover(event); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerCommand( + DROP_COMMAND, + (event) => { + return onDrop(event); + }, + COMMAND_PRIORITY_HIGH, + ), + ); + }, [anchorElem, editor]); + + function onDragStart(event: ReactDragEvent): void { + const dataTransfer = event.dataTransfer; + if (!dataTransfer || !draggableBlockElem) { + return; + } + setDragImage(dataTransfer, draggableBlockElem); + let nodeKey = ""; + editor.update(() => { + const node = $getNearestNodeFromDOMNode(draggableBlockElem); + if (node) { + nodeKey = node.getKey(); + } + }); + isDraggingBlockRef.current = true; + dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey); + } + + function onDragEnd(): void { + isDraggingBlockRef.current = false; + hideTargetLine(targetLineRef.current); + } + + return createPortal( + <> +
+
+
+
+ , + anchorElem, + ); +} + +export default function DraggableBlockPlugin({ + anchorElem = document.body, +}: { + anchorElem?: HTMLElement; +}): JSX.Element { + const [editor] = useLexicalComposerContext(); + return useDraggableBlockMenu(editor, anchorElem, editor._editable); +} diff --git a/features/article/components/lexicalComponents/LinkPreview.tsx b/features/article/components/lexicalComponents/LinkPreview.tsx index 02fbe52..4958d9e 100644 --- a/features/article/components/lexicalComponents/LinkPreview.tsx +++ b/features/article/components/lexicalComponents/LinkPreview.tsx @@ -37,7 +37,7 @@ export type SerializedLinkPreviewNode = Spread< SerializedLexicalNode >; -type ResOfWebsite = { +export type ResOfWebsite = { url: string; title: string; description: string; @@ -154,24 +154,13 @@ export function $createLinkPreviewNode( return new LinkPreviewNode(url, res); } -const fetchOgTags = async (link: string): Promise => { - const data = await fetch("/api/link-preview", { - method: "POST", - body: JSON.stringify({ - link, - }), - }); - const { - data: { url, title, description, images }, - } = await data.json(); - return { url, title, description, images }; -}; - //////////////////////////////////////////////////////////////// export const LinkPreviewPlugin = ({ showLink, + fetchingFunction, }: { showLink: boolean; + fetchingFunction: (link: string) => Promise; }): null => { const [editor] = useLexicalComposerContext(); const result = [ @@ -191,11 +180,12 @@ export const LinkPreviewPlugin = ({ (event: ClipboardEvent) => { const clipboardData = event.clipboardData; const pastedItem = clipboardData?.getData("text"); + console.log(fetchingFunction); if (typeof pastedItem === "string") { if (urlRegexp.test(pastedItem)) { const createPreview = async () => { - await fetchOgTags(pastedItem).then((res) => { + await fetchingFunction(pastedItem).then((res) => { alreadyHaveIt = result.find( (website) => res.url === website.url, ); @@ -231,6 +221,6 @@ export const LinkPreviewPlugin = ({ return () => { removeListener(); }; - }, [editor]); + }, [editor, fetchingFunction]); return null; }; diff --git a/features/article/components/lexicalComponents/ToolbarPlugin.tsx b/features/article/components/lexicalComponents/ToolbarPlugin.tsx index 8e10126..3ced5ff 100644 --- a/features/article/components/lexicalComponents/ToolbarPlugin.tsx +++ b/features/article/components/lexicalComponents/ToolbarPlugin.tsx @@ -118,7 +118,7 @@ const ToolbarPlugin = (): JSX.Element => {
setIsStylingPOpen(!isStylingPOpen)}> - Style P ⭣ + Style P ⭣ @@ -132,7 +132,7 @@ const ToolbarPlugin = (): JSX.Element => { ref={formatRef} onClick={() => setIsFormattingTextOpen(!isFormattingTextOpen)} > - Format text ⭣ + Format text ⭣ @@ -140,14 +140,16 @@ const ToolbarPlugin = (): JSX.Element => {
setIsColoringOpen(!isColoringOpen)}> - Coloring ⭣ + + Coloring ⭣ +
setIsFontSizeOpen(!isFontSizeOpen)}> - Font Size ⭣ + Font Size ⭣ diff --git a/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx b/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx index 6cdc501..3b5915b 100644 --- a/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx +++ b/features/article/components/lexicalComponents/ToolbarPluginOnTheLeft.tsx @@ -1,10 +1,21 @@ import React, { useEffect, useRef, useState } from "react"; -import { Dropdown, LeftToolbar, ToolbarItem } from "../../../../utils/lexical"; +import { + BtnForLeftToolbar, + Dropdown, + LeftToolbar, + ToolbarItem, +} from "../../../../utils/lexical"; import { DoOnToolbar, HROnToolbar } from "./OwnLexicalToolbar"; -const ToolbarPluginOnTheLeft = ({ show }: { show: boolean }): JSX.Element => { +const OpenToolbarOnTheLeft = ({ + showLeftMenu, +}: { + showLeftMenu: boolean; +}): JSX.Element => { const [isInsertingThingsOpen, setIsInsertingThingsOpen] = useState(false); const insertRef = useRef(null); + const [leftMenuOpen, setLeftMenuOpen] = useState(false); + const leftMenuRef = useRef(null); useEffect(() => { const handler = (event: MouseEvent) => { @@ -15,6 +26,12 @@ const ToolbarPluginOnTheLeft = ({ show }: { show: boolean }): JSX.Element => { !insertRef.current.contains(target as HTMLDivElement) ) { setIsInsertingThingsOpen(false); + } else if ( + leftMenuOpen && + leftMenuRef.current && + !leftMenuRef.current.contains(target as HTMLDivElement) + ) { + setLeftMenuOpen(false); } }; document.addEventListener("mousedown", handler); @@ -24,20 +41,34 @@ const ToolbarPluginOnTheLeft = ({ show }: { show: boolean }): JSX.Element => { }, [isInsertingThingsOpen]); return ( - - + <> + {!leftMenuOpen && ( + { + setLeftMenuOpen(!leftMenuOpen); + }} + > + {"< "}Open menu + + )} + {leftMenuOpen && ( + + -
setIsInsertingThingsOpen(!isInsertingThingsOpen)} - > - Insert things ⬇️ - - - -
-
+
setIsInsertingThingsOpen(!isInsertingThingsOpen)} + > + Insert things ⬇️ + + + +
+
+ )} + ); }; -export default ToolbarPluginOnTheLeft; +export default OpenToolbarOnTheLeft; diff --git a/features/article/components/lexicalComponents/point.ts b/features/article/components/lexicalComponents/point.ts new file mode 100644 index 0000000..e84bf3e --- /dev/null +++ b/features/article/components/lexicalComponents/point.ts @@ -0,0 +1,55 @@ +/** + * 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 class Point { + private readonly _x: number; + private readonly _y: number; + + constructor(x: number, y: number) { + this._x = x; + this._y = y; + } + + get x(): number { + return this._x; + } + + get y(): number { + return this._y; + } + + public equals({ x, y }: Point): boolean { + return this.x === x && this.y === y; + } + + public calcDeltaXTo({ x }: Point): number { + return this.x - x; + } + + public calcDeltaYTo({ y }: Point): number { + return this.y - y; + } + + public calcHorizontalDistanceTo(point: Point): number { + return Math.abs(this.calcDeltaXTo(point)); + } + + public calcVerticalDistance(point: Point): number { + return Math.abs(this.calcDeltaYTo(point)); + } + + public calcDistanceTo(point: Point): number { + return Math.sqrt( + Math.pow(this.calcDeltaXTo(point), 2) + + Math.pow(this.calcDeltaYTo(point), 2), + ); + } +} + +export function isPoint(x: unknown): x is Point { + return x instanceof Point; +} diff --git a/features/article/components/lexicalComponents/rect.ts b/features/article/components/lexicalComponents/rect.ts new file mode 100644 index 0000000..bccc230 --- /dev/null +++ b/features/article/components/lexicalComponents/rect.ts @@ -0,0 +1,158 @@ +/** + * 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 { isPoint, Point } from "./point"; + +type ContainsPointReturn = { + result: boolean; + reason: { + isOnTopSide: boolean; + isOnBottomSide: boolean; + isOnLeftSide: boolean; + isOnRightSide: boolean; + }; +}; + +export class Rect { + private readonly _left: number; + private readonly _top: number; + private readonly _right: number; + private readonly _bottom: number; + + constructor(left: number, top: number, right: number, bottom: number) { + const [physicTop, physicBottom] = + top <= bottom ? [top, bottom] : [bottom, top]; + + const [physicLeft, physicRight] = + left <= right ? [left, right] : [right, left]; + + this._top = physicTop; + this._right = physicRight; + this._left = physicLeft; + this._bottom = physicBottom; + } + + get top(): number { + return this._top; + } + + get right(): number { + return this._right; + } + + get bottom(): number { + return this._bottom; + } + + get left(): number { + return this._left; + } + + get width(): number { + return Math.abs(this._left - this._right); + } + + get height(): number { + return Math.abs(this._bottom - this._top); + } + + public equals({ top, left, bottom, right }: Rect): boolean { + return ( + top === this._top && + bottom === this._bottom && + left === this._left && + right === this._right + ); + } + + public contains({ x, y }: Point): ContainsPointReturn; + public contains({ top, left, bottom, right }: Rect): boolean; + public contains(target: Point | Rect): boolean | ContainsPointReturn { + if (isPoint(target)) { + const { x, y } = target; + + const isOnTopSide = y < this._top; + const isOnBottomSide = y > this._bottom; + const isOnLeftSide = x < this._left; + const isOnRightSide = x > this._right; + + const result = + !isOnTopSide && !isOnBottomSide && !isOnLeftSide && !isOnRightSide; + + return { + reason: { + isOnBottomSide, + isOnLeftSide, + isOnRightSide, + isOnTopSide, + }, + result, + }; + } else { + const { top, left, bottom, right } = target; + + return ( + top >= this._top && + top <= this._bottom && + bottom >= this._top && + bottom <= this._bottom && + left >= this._left && + left <= this._right && + right >= this._left && + right <= this._right + ); + } + } + + public intersectsWith(rect: Rect): boolean { + const { left: x1, top: y1, width: w1, height: h1 } = rect; + const { left: x2, top: y2, width: w2, height: h2 } = this; + const maxX = x1 + w1 >= x2 + w2 ? x1 + w1 : x2 + w2; + const maxY = y1 + h1 >= y2 + h2 ? y1 + h1 : y2 + h2; + const minX = x1 <= x2 ? x1 : x2; + const minY = y1 <= y2 ? y1 : y2; + return maxX - minX <= w1 + w2 && maxY - minY <= h1 + h2; + } + + public generateNewRect({ + left = this.left, + top = this.top, + right = this.right, + bottom = this.bottom, + }): Rect { + return new Rect(left, top, right, bottom); + } + + static fromLTRB( + left: number, + top: number, + right: number, + bottom: number, + ): Rect { + return new Rect(left, top, right, bottom); + } + + static fromLWTH( + left: number, + width: number, + top: number, + height: number, + ): Rect { + return new Rect(left, top, left + width, top + height); + } + + static fromPoints(startPoint: Point, endPoint: Point): Rect { + const { y: top, x: left } = startPoint; + const { y: bottom, x: right } = endPoint; + return Rect.fromLTRB(left, top, right, bottom); + } + + static fromDOM(dom: HTMLElement): Rect { + const { top, width, left, height } = dom.getBoundingClientRect(); + return Rect.fromLWTH(left, width, top, height); + } +} diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index 0d49f3f..1b5c5b2 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -29,7 +29,7 @@ import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; import { EditorState, LineBreakNode, ParagraphNode } from "lexical"; import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; -import ToolbarPluginOnTheLeft from "../../features/article/components/lexicalComponents/ToolbarPluginOnTheLeft"; +import OpenToolbarOnTheLeft from "../../features/article/components/lexicalComponents/ToolbarPluginOnTheLeft"; import { LinkPreviewNode, LinkPreviewPlugin, @@ -101,7 +101,7 @@ const thissInitialConfig = { }; const Editor = ({}: Props): JSX.Element => { - const [showLeftToolbar, setShowLeftToolbar] = useState(false); + const [showBtnForLeftMenu, setShowBtnForLeftMenu] = useState(false); const stateRef = useRef(); const [editorJson, setEditorJson] = useState(""); @@ -123,7 +123,8 @@ const Editor = ({}: Props): JSX.Element => { - + + @@ -137,7 +138,7 @@ const Editor = ({}: Props): JSX.Element => { setShowLeftToolbar(!showLeftToolbar)} + onClick={() => setShowBtnForLeftMenu(!showBtnForLeftMenu)} /> } placeholder={🫣 Let's see if it works...} diff --git a/public/draggable-block-icon.svg b/public/draggable-block-icon.svg new file mode 100644 index 0000000..7086d29 --- /dev/null +++ b/public/draggable-block-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/global.css b/public/global.css index 1c99af0..ee6d01c 100644 --- a/public/global.css +++ b/public/global.css @@ -10,6 +10,7 @@ h5, h6 { font-family: "Oswald", sans-serif; font-weight: bold; + line-height: 1.2; } .articleWrapper h1 { font-size: 2em; diff --git a/public/lexical.css b/public/lexical.css index d212633..d014e40 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -1,4 +1,6 @@ - +p, pre, span { + line-height: 1.2; +} .custom-tree-view-output { overflow-x: scroll; object-fit: contain; @@ -7,7 +9,6 @@ border-radius: 10px; } - .textBold { font-weight: bold; } @@ -207,4 +208,43 @@ white-space: pre-wrap; text-align: right; min-width: 25px; +} + +.draggable-block-menu { + border-radius: 4px; + padding: 2px 1px; + cursor: grab; + opacity: 0; + position: absolute; + left: 0; + top: 0; + will-change: transform; +} + +.draggable-block-menu .icon { + width: 16px; + height: 16px; + opacity: 0.3; + background-image: url("./draggable-block-icon.svg"); + justify-self: center; + align-self: center; +} + +.draggable-block-menu:active { + cursor: grabbing; +} + +.draggable-block-menu:hover { + background-color: #efefef; +} + +.draggable-block-target-line { + pointer-events: none; + background: deepskyblue; + height: 4px; + position: absolute; + left: 0; + top: 0; + opacity: 0; + will-change: transform; } \ No newline at end of file diff --git a/utils/lexical.ts b/utils/lexical.ts index 5490eff..34c4cc4 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -6,7 +6,7 @@ export const StyledContentEditable = styled(ContentEditable)` height: max-content; min-height: 300px; width: 100%; - padding: 8px 8px 24px 8px; + padding: 8px 8px 24px 24px; border: 1px solid #c7c7c7; //border: 1px solid cornflowerblue; //border-bottom: 5px solid cornflowerblue; @@ -25,8 +25,8 @@ export const StyledContentEditable = styled(ContentEditable)` export const Placeholder = styled.div` position: absolute; - top: 57px; - left: 12px; + top: 58px; + left: 28px; color: #bbbbbb; `; @@ -42,6 +42,31 @@ export const Toolbar = styled.div` margin-bottom: 10px; `; +export const ToolbarItem = styled.button` + padding: 3px 6px; + border: none; + background: none; + cursor: pointer; + color: black; + //color: dimgray; + align-items: baseline; + + min-width: 50px; + font-size: 14px; + + :hover { + background: #d1e3ff; + border-radius: 5px; + } +`; + +export const BtnForLeftToolbar = styled(ToolbarItem)<{ showLeftMenu: boolean }>` + display: ${({ showLeftMenu }) => (showLeftMenu ? "flex" : "none")}; + position: absolute; + top: 55px; + left: -110px; +`; + export const LeftToolbar = styled(Toolbar)<{ show: boolean }>` flex-direction: column; border: 1px solid #c7c7c7; @@ -92,24 +117,6 @@ export const Dropdown = styled.div<{ isOpen: boolean; id: string }>` }}; `; -export const ToolbarItem = styled.button` - padding: 3px 6px; - border: none; - background: none; - cursor: pointer; - color: black; - //color: dimgray; - align-items: baseline; - - min-width: 50px; - font-size: 14px; - - :hover { - background: #d1e3ff; - border-radius: 5px; - } -`; - export const JsonButtonContainer = styled.div` display: flex; justify-content: flex-end; From 26bb2de1883442cd08f5214e9372c388c7bcb86d Mon Sep 17 00:00:00 2001 From: csillagk Date: Fri, 11 Aug 2023 12:53:07 +0200 Subject: [PATCH 17/22] working draggable blocks --- articles/lexical-link-preview/index.tsx | 10 +- .../DraggableBlockPlugin.tsx | 29 +- .../lexicalComponents/OwnLexicalToolbar.tsx | 12 +- .../README-lexical-github.md | 169 ++ .../lexicalComponents/TreeViewPlugin.tsx | 2 +- public/lexical.css | 1611 +++++++++++++++-- utils/lexical.ts | 22 +- 7 files changed, 1660 insertions(+), 195 deletions(-) create mode 100644 features/article/components/lexicalComponents/README-lexical-github.md diff --git a/articles/lexical-link-preview/index.tsx b/articles/lexical-link-preview/index.tsx index 5544acd..c3de846 100644 --- a/articles/lexical-link-preview/index.tsx +++ b/articles/lexical-link-preview/index.tsx @@ -160,10 +160,12 @@ const Editor = ({}: Props): JSX.Element => { - setShowLeftToolbar(!showLeftToolbar)} - /> +
+
+ setShowLeftToolbar(!showLeftToolbar)} + /> +
} placeholder={ 🖇 Paste your link!} diff --git a/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx b/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx index 5464f40..a396d87 100644 --- a/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx +++ b/features/article/components/lexicalComponents/DraggableBlockPlugin.tsx @@ -189,9 +189,7 @@ function setMenuPosition( if (!targetElem) { floatingElem.style.opacity = "0"; floatingElem.style.transform = "translate(-100000000px, 10000000px)"; - // floatingElem.style.opacity = "1"; - // floatingElem.style.transform = "translate(4px, 34px)"; - // floatingElem.style.border = "thin solid red"; + return; } @@ -204,34 +202,11 @@ function setMenuPosition( const top = targetRect.top + - (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 - + (parseInt(targetStyle.height, 10) - floatingElemRect.height) / 2 - anchorElementRect.top; - // const top = - // targetRect.top + - // (113 - floatingElemRect.height) / 2 - - // anchorElementRect.top; - - console.log("-----------------------------"); - console.log(targetElem); - console.log("targetRect top: " + targetRect.top); - console.log("targetStyle lineHeight: " + targetStyle.lineHeight); - console.log("floatingElemRect height: " + floatingElemRect.height); - console.log("anchorElemRect top: " + anchorElementRect.top); - console.log( - "tehat " + - targetRect.top + - " + (" + - targetStyle.lineHeight + - " - " + - floatingElemRect.height + - ") /2 - " + - anchorElementRect.top, - ); floatingElem.style.opacity = "1"; floatingElem.style.transform = `translate(${left}px, ${top}px)`; - - floatingElem.style.border = "thin solid blue"; } function setDragImage( diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index b231371..c637b1c 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -378,7 +378,11 @@ export const HROnToolbar = () => { }; export const SaveToJsonOnToolbar = ({ onClick }: { onClick: () => void }) => { - return to JSON; + return ( + + to JSON + + ); }; export const LoadFromJsonOnToolbar = ({ data }: { data: string }) => { @@ -389,7 +393,11 @@ export const LoadFromJsonOnToolbar = ({ data }: { data: string }) => { editor.setEditorState(editorState); return null; }; - return to Editor ; + return ( + + to Editor{" "} + + ); }; export function CodeHighlightPlugin(): JSX.Element | null { diff --git a/features/article/components/lexicalComponents/README-lexical-github.md b/features/article/components/lexicalComponents/README-lexical-github.md new file mode 100644 index 0000000..db36c41 --- /dev/null +++ b/features/article/components/lexicalComponents/README-lexical-github.md @@ -0,0 +1,169 @@ +# Emergence Engineering nextjs-firebase starter kit + +## What this kit includes + +- Next.js 9.0 +- Linting with `npm run lint` or `npm run lint:fix`. + Uses eslint with typescript, extended by airbnb, prettier and react-hooks, with some additional magic + to keep the coding style as strict as possible +- styled-components with server side rendering enabled + +## How to set up + +- Run `npm i` in the root folder. +- Check `scripts/config/README.md` to set up firebase, or just ask for a .env file.. + +### How to set up firebase. + +1. [Register to firebase](firebase.google.com) + +2. [Go to the firebase console](https://console.firebase.google.com), and create + a new Firebase project + +3. Add your app to your project: + - Click the gear icon and go to the project settings. + - Scroll down and, in the `Your apps` section, click on the `< \>` icon to add your first app. + - Set the name ( Best practice: Set it to the GitLab project name ). + - Hosting is NOT required for development. + - After clicking on Register App you will see the config settings. Use it to fill in the created `.env` file: + ``` + FIREBASE_API_KEY= + FIREBASE_PROJECT_ID= + FIREBASE_SENDER_ID= + FIREBASE_APP_ID= + ``` + - After exiting the app registration, you will arive on the project main screen. + +4. Create your database: + - In your project, on the sidebar, under `Development`, click `Database`. + - On top, in `Cloud Firestore`, click on `Create Database`. + - Choose `Start in test mode` and select a location. + - Click on next and done. + - Here you can see al the data you stored. + +5. Auth set-up: + - In your project, on the sidebar, under `Development`, click `Authentication`. + - Click the `Sign-in Metod` tab. + - Enable `Email/Password` and `Google` + +## Storybook +Storybook is used to test componens in a separate environment, it's also a way of providing sample +codes for using common components. + +To start it just type `npm run storybook` into the console, and it should +come up on `http://localhost:6006` + +## How it works + +### Redux-Next-Firebase integration + +The store setup mostly follows +https://github.com/piotrwitek/react-redux-typescript-guide +except for the Next.js integration, which is not discussed in the link above. +Redux is integrated using the `next-redux-wrapper` package. + +## Left to do + +- Database typings / figure out the best way to type firestore/firebase +- Figure out best way to add pwa functionality +- Testing setup with jest / enzyme +- Integrate redux-form +- Set up server: + - Re-send password +- Upgrade react-redux-firebase: latest throws `regeneratorRuntime is not defined` babel error. + +## Webstorm tips + +- Enable eslint in settings to see linting issues on the fly + +## Deploying the project as a static website on Firebase Hosting + +### First steps +To host your site with Firebase Hosting, you need the Firebase CLI (a command line tool). + +Run the following npm command to install the CLI or update to the latest CLI version. + +#### Set up Firebase and Firebase hosting +```shell script +npm install -g firebase-tools +``` + +Open a terminal window and navigate to or create a root directory for your web app + +Sign in to Google + +```shell script +firebase login +``` + +Initiate your project +This step is needed if the project folder is not associated with a Firebase project. If you want to change to another +Firebase project then run `firebase use `. To list available projects run `firebase projects:list` +Run this command from your app’s root directory: + +```shell script +firebase init +``` + +When initializing the project choose "hosting" and for the hosting questions answer the following: +``` +? What do you want to use as your public directory? static-export +? Configure as a single-page app (rewrite all urls to /index.html)? No +? File static-export/404.html already exists. Overwrite? No +? File static-export/index.html already exists. Overwrite? No +``` + +#### Building for static deployment +First run +``` +npm run build +``` +After that just run + +```shell script +npm run export-static +``` + +This command will create a the static-export folder and populate it with the statically rendered pages. + +When you’re ready, deploy your web app +Put your static files (e.g., HTML, CSS, JS) in your app’s deploy directory (the default is “public”). Then, run this command from your app’s root directory: + +```shell script +npm run deploy-static +``` + +(This uses shell `firebase deploy` under the hood) + +After deploying, view your app at https://.firebaseapp.com + +## Gitlab CI setup + +Install firebase tools globally: +```shell script +sudo npm i -g firebase-tools +``` +Log into firebase with +```shell script +firebase login +``` +Get your firebase token. A browser will pop up, where you should log in, and accept that firebase-cli will +have the right permissions. +```shell script +firebase login:ci +``` +Copy the token, go to gitlab/settings/CI/CD and add a new variable named `FIREBASE_TOKEN` with +the token value as protected and masked. + +Set up a new firebase project for the staing/prod environment and define the following variables +in gitlab: +- `STAGING_API_KEY` / `PROD_API_KEY` +- `STAGING_PROJECT_ID` / `PROD_PROJECT_ID` +- `STAGING_SENDER_ID` / `PROD_SENDER_ID` +- `STAGING_APP_ID` / `PROD_APP_ID` + +## Error tracking, logging + +By default, we use Sentry.js for errors. Please read the +specific readme file [here]("/features/sentry/README.md") + diff --git a/features/article/components/lexicalComponents/TreeViewPlugin.tsx b/features/article/components/lexicalComponents/TreeViewPlugin.tsx index 1076843..05656bb 100644 --- a/features/article/components/lexicalComponents/TreeViewPlugin.tsx +++ b/features/article/components/lexicalComponents/TreeViewPlugin.tsx @@ -6,7 +6,7 @@ export default function TreeViewPlugin(): JSX.Element { const [editor] = useLexicalComposerContext(); return ( ` border-radius: 10px; border: 2px solid #c7c7c7; background-color: #f8f8f8; - //border: 2px dashed lightcoral; - //background-color: lightcyan; + z-index: 10; width: 100px; justify-content: center; @@ -124,9 +116,5 @@ export const JsonButtonContainer = styled.div` gap: 5px; right: 5px; position: absolute; -`; - -export const StyledTreeView = styled(TreeView)` - border: thin solid red; - width: 10px; + z-index: 1000; `; From 63160d594909aaf441374d08dab745c402bc304b Mon Sep 17 00:00:00 2001 From: csillagk Date: Fri, 11 Aug 2023 12:54:32 +0200 Subject: [PATCH 18/22] working draggable blocks --- .../lexicalComponents/OwnLexicalToolbar.tsx | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index c637b1c..9cfe66f 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -152,7 +152,7 @@ export const LinkOnToolbar = (): JSX.Element => { const [link, setLink] = useState(""); const [isSeen, setIsSeen] = useState(false); const linkRegex = - /[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; + /[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; const linkOnClick = useCallback(() => { setIsLink(!isLink); @@ -259,20 +259,6 @@ export const FormatThings = () => { ); }; -// -// const FONT_SIZE_OPTIONS: [string, string][] = [ -// ["10px", "10px"], -// ["11px", "11px"], -// ["12px", "12px"], -// ["13px", "13px"], -// ["14px", "14px"], -// ["15px", "15px"], -// ["16px", "16px"], -// ["17px", "17px"], -// ["18px", "18px"], -// ["19px", "19px"], -// ["20px", "20px"], -// ]; export const FontSizeOnToolbar = (): JSX.Element => { const [editor] = useLexicalComposerContext(); From bbf00d47055cb007f0adc139a7c2320d53fd827f Mon Sep 17 00:00:00 2001 From: csillagk Date: Fri, 11 Aug 2023 13:07:14 +0200 Subject: [PATCH 19/22] working draggable blocks --- public/lexical.css | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/public/lexical.css b/public/lexical.css index f1920ec..14cfef2 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -1173,11 +1173,11 @@ button.action-button:disabled { border: 0; padding: 0; font-size: 12px; - top: 10px; - right: 15px; + top: 2px; + right: 250px; position: absolute; background: none; - color: #fff; + color: #000000; } .debug-timetravel-button:hover { @@ -1185,6 +1185,7 @@ button.action-button:disabled { } .debug-treetype-button { + border: 0; padding: 0; font-size: 12px; top: 2px; @@ -1192,8 +1193,6 @@ button.action-button:disabled { position: absolute; background: none; color: #000000; - margin-bottom: 10px; - border: 0; } .debug-treetype-button:hover { From 5d085ad80fb9d65f37ba2d37adf7638a1ded7389 Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 14 Aug 2023 08:45:50 +0200 Subject: [PATCH 20/22] readme added --- articles/lexical-link-preview/index.tsx | 19 +- .../README-lexical-github.md | 278 ++++++++---------- pages/blog/implementing-lexical.tsx | 62 ++-- public/lexical-link-preview.gif | Bin 0 -> 39072 bytes public/lexical.css | 31 +- 5 files changed, 190 insertions(+), 200 deletions(-) create mode 100644 public/lexical-link-preview.gif diff --git a/articles/lexical-link-preview/index.tsx b/articles/lexical-link-preview/index.tsx index c3de846..80a93b0 100644 --- a/articles/lexical-link-preview/index.tsx +++ b/articles/lexical-link-preview/index.tsx @@ -70,9 +70,9 @@ const myTheme = { link: "mylink", hashtag: "hashtag", linkPreviewContainer: "linkPreviewContainer", - previewBox: "previewBox", - previewImage: "previewImage", - previewDescription: "previewDescription", + // previewBox: "previewBox", + // previewImage: "previewImage", + // previewDescription: "previewDescription", }; interface Props {} @@ -113,8 +113,7 @@ async function thisFetchingFunction(link: string): Promise { } const Editor = ({}: Props): JSX.Element => { - const [showLeftToolbar, setShowLeftToolbar] = useState(false); - const stateRef = useRef(); + const editorStateRef = useRef(); const [editorJson, setEditorJson] = useState(""); const [floatingAnchorElem, setFloatingAnchorElem] = useState(null); @@ -162,9 +161,7 @@ const Editor = ({}: Props): JSX.Element => { contentEditable={
- setShowLeftToolbar(!showLeftToolbar)} - /> +
} @@ -174,14 +171,14 @@ const Editor = ({}: Props): JSX.Element => { { - if (stateRef) stateRef.current = editorState; + if (editorStateRef) editorStateRef.current = editorState; }} /> { - if (stateRef.current) - setEditorJson(JSON.stringify(stateRef.current)); + if (editorStateRef.current) + setEditorJson(JSON.stringify(editorStateRef.current)); }} /> diff --git a/features/article/components/lexicalComponents/README-lexical-github.md b/features/article/components/lexicalComponents/README-lexical-github.md index db36c41..81e1612 100644 --- a/features/article/components/lexicalComponents/README-lexical-github.md +++ b/features/article/components/lexicalComponents/README-lexical-github.md @@ -1,169 +1,139 @@ -# Emergence Engineering nextjs-firebase starter kit +# lexical-link-preview-react -## What this kit includes +![made by Emergence Engineering](https://emergence-engineering.com/ee-logo.svg) -- Next.js 9.0 -- Linting with `npm run lint` or `npm run lint:fix`. - Uses eslint with typescript, extended by airbnb, prettier and react-hooks, with some additional magic - to keep the coding style as strict as possible -- styled-components with server side rendering enabled -## How to set up +[**Made by Emergence-Engineering**](https://emergence-engineering.com/) -- Run `npm i` in the root folder. -- Check `scripts/config/README.md` to set up firebase, or just ask for a .env file.. +## Features -### How to set up firebase. +![feature-gif](https://emergence-engineering.com/lexical-link-preview.gif) -1. [Register to firebase](firebase.google.com) -2. [Go to the firebase console](https://console.firebase.google.com), and create - a new Firebase project +This easy-to-use plugin for lexical editors enhances the user experience by allowing them to see the page behind the link. -3. Add your app to your project: - - Click the gear icon and go to the project settings. - - Scroll down and, in the `Your apps` section, click on the `< \>` icon to add your first app. - - Set the name ( Best practice: Set it to the GitLab project name ). - - Hosting is NOT required for development. - - After clicking on Register App you will see the config settings. Use it to fill in the created `.env` file: - ``` - FIREBASE_API_KEY= - FIREBASE_PROJECT_ID= - FIREBASE_SENDER_ID= - FIREBASE_APP_ID= - ``` - - After exiting the app registration, you will arive on the project main screen. +The plugin takes two properties that allow you to: +- set whether to display the inserted link as well, next to the preview, and +- use **your async callback function** to retrieve metadata from the site -4. Create your database: - - In your project, on the sidebar, under `Development`, click `Database`. - - On top, in `Cloud Firestore`, click on `Create Database`. - - Choose `Start in test mode` and select a location. - - Click on next and done. - - Here you can see al the data you stored. +The plugin applies the data to the preview box and shows you the name, the description and, if available, the image of the website. +You can easily customize the preview - use the popular 'card' design, stick to the 'bookmark' style, or create your own - whichever fits better to your UI. -5. Auth set-up: - - In your project, on the sidebar, under `Development`, click `Authentication`. - - Click the `Sign-in Metod` tab. - - Enable `Email/Password` and `Google` -## Storybook -Storybook is used to test componens in a separate environment, it's also a way of providing sample -codes for using common components. +## How to use? -To start it just type `npm run storybook` into the console, and it should -come up on `http://localhost:6006` - -## How it works - -### Redux-Next-Firebase integration - -The store setup mostly follows -https://github.com/piotrwitek/react-redux-typescript-guide -except for the Next.js integration, which is not discussed in the link above. -Redux is integrated using the `next-redux-wrapper` package. - -## Left to do - -- Database typings / figure out the best way to type firestore/firebase -- Figure out best way to add pwa functionality -- Testing setup with jest / enzyme -- Integrate redux-form -- Set up server: - - Re-send password -- Upgrade react-redux-firebase: latest throws `regeneratorRuntime is not defined` babel error. - -## Webstorm tips - -- Enable eslint in settings to see linting issues on the fly - -## Deploying the project as a static website on Firebase Hosting - -### First steps -To host your site with Firebase Hosting, you need the Firebase CLI (a command line tool). - -Run the following npm command to install the CLI or update to the latest CLI version. - -#### Set up Firebase and Firebase hosting -```shell script -npm install -g firebase-tools -``` - -Open a terminal window and navigate to or create a root directory for your web app - -Sign in to Google - -```shell script -firebase login +1. **Installation**: Install the plugin from your preferred package manager. For example, using npm, run the following command: `npm i -S lexical-link-preview-react` +2. **Import**: Import the node and the plugin into your project. +```typescript +import { LinkPreviewNode, LinkPreviewPlugin } from "lexical-link-preview-react"; ``` -Initiate your project -This step is needed if the project folder is not associated with a Firebase project. If you want to change to another -Firebase project then run `firebase use `. To list available projects run `firebase projects:list` -Run this command from your app’s root directory: - -```shell script -firebase init -``` - -When initializing the project choose "hosting" and for the hosting questions answer the following: +3. Don't forget to include the classname in your theme config: +```html + linkPreviewContainer: "linkPreviewContainer", ``` -? What do you want to use as your public directory? static-export -? Configure as a single-page app (rewrite all urls to /index.html)? No -? File static-export/404.html already exists. Overwrite? No -? File static-export/index.html already exists. Overwrite? No -``` - -#### Building for static deployment -First run -``` -npm run build -``` -After that just run - -```shell script -npm run export-static -``` - -This command will create a the static-export folder and populate it with the statically rendered pages. - -When you’re ready, deploy your web app -Put your static files (e.g., HTML, CSS, JS) in your app’s deploy directory (the default is “public”). Then, run this command from your app’s root directory: - -```shell script -npm run deploy-static -``` - -(This uses shell `firebase deploy` under the hood) - -After deploying, view your app at https://.firebaseapp.com - -## Gitlab CI setup - -Install firebase tools globally: -```shell script -sudo npm i -g firebase-tools -``` -Log into firebase with -```shell script -firebase login -``` -Get your firebase token. A browser will pop up, where you should log in, and accept that firebase-cli will -have the right permissions. -```shell script -firebase login:ci -``` -Copy the token, go to gitlab/settings/CI/CD and add a new variable named `FIREBASE_TOKEN` with -the token value as protected and masked. - -Set up a new firebase project for the staing/prod environment and define the following variables -in gitlab: -- `STAGING_API_KEY` / `PROD_API_KEY` -- `STAGING_PROJECT_ID` / `PROD_PROJECT_ID` -- `STAGING_SENDER_ID` / `PROD_SENDER_ID` -- `STAGING_APP_ID` / `PROD_APP_ID` - -## Error tracking, logging - -By default, we use Sentry.js for errors. Please read the -specific readme file [here]("/features/sentry/README.md") - +???????????????????????? +You can use your custom css to style the preview, here is an example(which is the actual css used by default) + + + - basic card structure + + ```html +
+
+
+
+
+ ``` + +4. Add the node to the nodes array in your config: LinkPreviewNode + + ```typescript + nodes: [LinkPreviewNode,] + ``` + +5. Initialize the editor with the plugin + + ```typescript + const Editor = (): JSX.Element => { + return ( + + + + + } + placeholder={placeholder} + ErrorBoundary={LexicalErrorBoundary} + /> + + ) + } + ``` + +6. `LinkPreviewPlugin` requires 2 parameters: + + - `showLink`: takes a boolean value + - if true, inserts the link inline and puts the preview at the bottom of the editor + - if false, inserts the preview as a block and nothing else + + + - `fetchingFunction`: `(link: string) => Promise<{url: string, title: string, description: string, images: string[]}>` a function that takes a link and returns a `Promise` that resolves to the link preview data, you can easily do this using next.js API routes + ??????????????? or just using `link-preview-js` library on your custom backend + + ```typescript + import type { NextApiRequest, NextApiResponse } from "next"; + import Cors from "cors"; + import { getLinkPreview } from "link-preview-js"; + // Initializing the cors middleware + // You can read more about the available options here: https://github.com/expressjs/cors#configuration-options + const cors = Cors({ + methods: ["POST", "GET", "HEAD"], + }); + // Helper method to wait for a middleware to execute before continuing + // And to throw an error when an error happens in a middleware + function runMiddleware( + req: NextApiRequest, + res: NextApiResponse, + fn: Function + ) { + return new Promise((resolve, reject) => { + fn(req, res, (result: any) => { + if (result instanceof Error) { + return reject(result); + } + return resolve(result); + }); + }); + } + export default async function handler( + req: NextApiRequest, + res: NextApiResponse + ) { + // Run the middleware + await runMiddleware(req, res, cors); + + const { link } = JSON.parse(req.body); + console.log({ link }); + + const data = await getLinkPreview(link); + + // Rest of the API logic + res.json({ data }); + } + ``` + + + + +## Fetching preview data + +**this does not happen automatically**, you need to handle it yourself by providing the `fetchingFunction` callback function + +- this usually requires a backend using a 3rd party library like `link-preview-js` +- you can use `linkpreview.net` API endpoint to fetch your preview data from the frontend +- in case you are using nextjs, you can easily use our example above +- or any other tool you see fit \ No newline at end of file diff --git a/pages/blog/implementing-lexical.tsx b/pages/blog/implementing-lexical.tsx index 1b5c5b2..317ba28 100644 --- a/pages/blog/implementing-lexical.tsx +++ b/pages/blog/implementing-lexical.tsx @@ -33,6 +33,7 @@ import OpenToolbarOnTheLeft from "../../features/article/components/lexicalCompo import { LinkPreviewNode, LinkPreviewPlugin, + ResOfWebsite, } from "../../features/article/components/lexicalComponents/LinkPreview"; import TreeViewPlugin from "../../features/article/components/lexicalComponents/TreeViewPlugin"; import { @@ -45,6 +46,7 @@ import { SaveToJsonOnToolbar, CodeHighlightPlugin, } from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; +import DraggableBlockPlugin from "../../features/article/components/lexicalComponents/DraggableBlockPlugin"; function onError(error: Error): void { console.error(error); @@ -70,9 +72,9 @@ const myTheme = { link: "mylink", hashtag: "hashtag", linkPreviewContainer: "linkPreviewContainer", - previewBox: "previewBox", - previewImage: "previewImage", - previewDescription: "previewDescription", + // previewBox: "previewBox", + // previewImage: "previewImage", + // previewDescription: "previewDescription", code: "codeBlock", }; @@ -100,10 +102,31 @@ const thissInitialConfig = { ], }; +async function thisFetchingFunction(link: string): Promise { + const data = await fetch("/api/link-preview", { + method: "POST", + body: JSON.stringify({ + link, + }), + }); + const { + data: { url, title, description, images }, + } = await data.json(); + return { url, title, description, images }; +} + const Editor = ({}: Props): JSX.Element => { const [showBtnForLeftMenu, setShowBtnForLeftMenu] = useState(false); - const stateRef = useRef(); + const editorStateRef = useRef(); const [editorJson, setEditorJson] = useState(""); + const [floatingAnchorElem, setFloatingAnchorElem] = + useState(null); + + const onRef = (_floatingAnchorElem: HTMLDivElement) => { + if (_floatingAnchorElem !== null) { + setFloatingAnchorElem(_floatingAnchorElem); + } + }; const URL_REGEX = /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; @@ -121,12 +144,16 @@ const Editor = ({}: Props): JSX.Element => { - + {floatingAnchorElem && ( + + )} - - + @@ -134,32 +161,33 @@ const Editor = ({}: Props): JSX.Element => { - setShowBtnForLeftMenu(!showBtnForLeftMenu)} - /> +
+
+ setShowBtnForLeftMenu(!showBtnForLeftMenu)} + /> +
+
} - placeholder={🫣 Let's see if it works...} + placeholder={ 🖇 Paste your link!} ErrorBoundary={LexicalErrorBoundary} /> - { - if (stateRef) stateRef.current = editorState; + if (editorStateRef) editorStateRef.current = editorState; }} /> { - if (stateRef.current) - setEditorJson(JSON.stringify(stateRef.current)); + if (editorStateRef.current) + setEditorJson(JSON.stringify(editorStateRef.current)); }} /> -
diff --git a/public/lexical-link-preview.gif b/public/lexical-link-preview.gif new file mode 100644 index 0000000000000000000000000000000000000000..0af62b3b39c976875c604417f464bb54e9d9f980 GIT binary patch literal 39072 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 literal 0 HcmV?d00001 diff --git a/public/lexical.css b/public/lexical.css index 14cfef2..20f2b26 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -3,8 +3,6 @@ p, pre, span { line-height: 1.2; } - - .linkPreviewContainer { display: flex; cursor: pointer; @@ -18,7 +16,6 @@ p, pre, span { outline: 2px solid rgb(60, 132, 244); } - .previewBox { width: 60%; height: 80px; @@ -194,7 +191,7 @@ header h1 { border-bottom-right-radius: 10px; } -.editor-shell +.editor-shell, .editor-container.tree-view { border-radius: 20px; @@ -271,7 +268,7 @@ pre::-webkit-scrollbar-thumb { border: none; cursor: pointer; outline: none; - box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.3); + box-shadow: 0 1px 10px rgba(0, 0, 0, 0.3); background-color: #444; } @@ -367,7 +364,7 @@ pre::-webkit-scrollbar-thumb { .typeahead-popover { background: #fff; - box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3); + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); border-radius: 8px; margin-top: 25px; } @@ -427,11 +424,11 @@ pre::-webkit-scrollbar-thumb { } .typeahead-popover li:first-child { - border-radius: 8px 8px 0px 0px; + border-radius: 8px 8px 0 0; } .typeahead-popover li:last-child { - border-radius: 0px 0px 8px 8px; + border-radius: 0 0 8px 8px; } .typeahead-popover li:hover { @@ -607,7 +604,7 @@ pre::-webkit-scrollbar-thumb { } .mention:focus { - box-shadow: rgb(180 213 255) 0px 0px 0px 2px; + box-shadow: rgb(180 213 255) 0 0 0 2px; outline: none; } @@ -786,8 +783,8 @@ pre::-webkit-scrollbar-thumb { } .switch button span { - top: 0px; - left: 0px; + top: 0; + left: 0; display: block; position: absolute; width: 20px; @@ -830,8 +827,7 @@ pre::-webkit-scrollbar-thumb { cursor: grabbing; } -.editor-shell .editor-image .image-caption-container -.tree-view-output +.editor-shell .editor-image .image-caption-container .tree-view-output { margin: 0; border-radius: 0; @@ -975,8 +971,7 @@ pre::-webkit-scrollbar-thumb { cursor: grabbing; } -.editor-shell .inline-editor-image .image-caption-container -.tree-view-output +.editor-shell .inline-editor-image .image-caption-container .tree-view-output { margin: 0; border-radius: 0; @@ -1357,7 +1352,7 @@ button.action-button:disabled { padding: 20px 10px; position: relative; border: 1px solid #e8e8e8; - font-family: 'Reenie Beanie'; + /*font-family: 'Reenie Beanie';*/ font-size: 24px; border-bottom-right-radius: 60px 5px; display: block; @@ -1534,12 +1529,12 @@ hr.selected { .TableNode__contentEditable { min-height: 20px; - border: 0px; + border: 0; resize: none; cursor: text; display: block; position: relative; - outline: 0px; + outline: 0; padding: 0; user-select: text; font-size: 15px; From a3c2df1ecf1254df6b8fe09b18dd606979192643 Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 14 Aug 2023 10:01:58 +0200 Subject: [PATCH 21/22] readme changed --- .../README-lexical-github.md | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/features/article/components/lexicalComponents/README-lexical-github.md b/features/article/components/lexicalComponents/README-lexical-github.md index 81e1612..e262a96 100644 --- a/features/article/components/lexicalComponents/README-lexical-github.md +++ b/features/article/components/lexicalComponents/README-lexical-github.md @@ -32,18 +32,24 @@ import { LinkPreviewNode, LinkPreviewPlugin } from "lexical-link-preview-react"; ```html linkPreviewContainer: "linkPreviewContainer", ``` -???????????????????????? +???????????????????????? where do I put this component to be editable? You can use your custom css to style the preview, here is an example(which is the actual css used by default) - basic card structure ```html -
-
-
-
-
+ + +
+ {""} +
+
{res.title}
+
{res.description}
+
+
+
+
``` 4. Add the node to the nodes array in your config: LinkPreviewNode @@ -76,9 +82,9 @@ You can use your custom css to style the preview, here is an example(which is th 6. `LinkPreviewPlugin` requires 2 parameters: - - `showLink`: takes a boolean value - - if true, inserts the link inline and puts the preview at the bottom of the editor - - if false, inserts the preview as a block and nothing else +- `showLink`: takes a boolean value + - if true, inserts the link inline and puts the preview to the bottom of the editor + - if false, inserts the preview as an inline-block element and nothing else - `fetchingFunction`: `(link: string) => Promise<{url: string, title: string, description: string, images: string[]}>` a function that takes a link and returns a `Promise` that resolves to the link preview data, you can easily do this using next.js API routes From 145b910490d7530a3b5b7a5a1b6b28e2c21c705e Mon Sep 17 00:00:00 2001 From: csillagk Date: Mon, 14 Aug 2023 17:49:23 +0200 Subject: [PATCH 22/22] article done --- articles/lexical-link-preview/index.tsx | 26 +- .../lexicalComponents/LinkPreview.tsx | 226 ------------------ .../lexicalComponents/LinkPreviewBox.tsx | 26 ++ .../lexicalComponents/LinkPreviewNode.tsx | 127 ++++++++++ .../lexicalComponents/LinkPreviewPlugin.tsx | 87 +++++++ .../lexicalComponents/OwnLexicalToolbar.tsx | 4 +- .../README-lexical-github.md | 1 + .../lexicalComponents/TreeViewPlugin.tsx | 4 +- pages/blog/implementing-lexical.tsx | 4 +- pages/blog/lexical-link-preview.tsx | 74 +++++- public/lexical.css | 62 ++++- utils/lexical.ts | 16 +- 12 files changed, 387 insertions(+), 270 deletions(-) delete mode 100644 features/article/components/lexicalComponents/LinkPreview.tsx create mode 100644 features/article/components/lexicalComponents/LinkPreviewBox.tsx create mode 100644 features/article/components/lexicalComponents/LinkPreviewNode.tsx create mode 100644 features/article/components/lexicalComponents/LinkPreviewPlugin.tsx diff --git a/articles/lexical-link-preview/index.tsx b/articles/lexical-link-preview/index.tsx index 80a93b0..97baa05 100644 --- a/articles/lexical-link-preview/index.tsx +++ b/articles/lexical-link-preview/index.tsx @@ -16,11 +16,7 @@ import { BannerNode, BannerPlugin, } from "../../features/article/components/lexicalComponents/Banner"; -import { - JsonButtonContainer, - Placeholder, - StyledContentEditable, -} from "../../utils/lexical"; +import { JsonButtonContainer } from "../../utils/lexical"; import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode"; import { HorizontalRulePlugin } from "@lexical/react/LexicalHorizontalRulePlugin"; import { AutoFocusPlugin } from "@lexical/react/LexicalAutoFocusPlugin"; @@ -29,9 +25,9 @@ import { HashtagPlugin } from "@lexical/react/LexicalHashtagPlugin"; import ToolbarPlugin from "../../features/article/components/lexicalComponents/ToolbarPlugin"; import { LinkPreviewNode, - LinkPreviewPlugin, ResOfWebsite, -} from "../../features/article/components/lexicalComponents/LinkPreview"; +} from "../../features/article/components/lexicalComponents/LinkPreviewNode"; +import { LinkPreviewPlugin } from "../../features/article/components/lexicalComponents/LinkPreviewPlugin"; import TreeViewPlugin from "../../features/article/components/lexicalComponents/TreeViewPlugin"; import { AutoLinkPlugin, @@ -45,6 +41,7 @@ import { } from "../../features/article/components/lexicalComponents/OwnLexicalToolbar"; import { LexicalComposer } from "@lexical/react/LexicalComposer"; import DraggableBlockPlugin from "../../features/article/components/lexicalComponents/DraggableBlockPlugin"; +import { ContentEditable } from "@lexical/react/LexicalContentEditable"; function onError(error: Error): void { console.error(error); @@ -70,14 +67,9 @@ const myTheme = { link: "mylink", hashtag: "hashtag", linkPreviewContainer: "linkPreviewContainer", - // previewBox: "previewBox", - // previewImage: "previewImage", - // previewDescription: "previewDescription", }; -interface Props {} - -const thissInitialConfig = { +const initialConfig = { namespace: "MyEditor", theme: myTheme, onError, @@ -112,7 +104,7 @@ async function thisFetchingFunction(link: string): Promise { return { url, title, description, images }; } -const Editor = ({}: Props): JSX.Element => { +const Editor = (): JSX.Element => { const editorStateRef = useRef(); const [editorJson, setEditorJson] = useState(""); const [floatingAnchorElem, setFloatingAnchorElem] = @@ -133,7 +125,7 @@ const Editor = ({}: Props): JSX.Element => { }), ]; return ( - +
@@ -161,11 +153,11 @@ const Editor = ({}: Props): JSX.Element => { contentEditable={
- +
} - placeholder={ 🖇 Paste your link!} + placeholder={
🖇 Paste your link!
} ErrorBoundary={LexicalErrorBoundary} /> diff --git a/features/article/components/lexicalComponents/LinkPreview.tsx b/features/article/components/lexicalComponents/LinkPreview.tsx deleted file mode 100644 index 4958d9e..0000000 --- a/features/article/components/lexicalComponents/LinkPreview.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { - $getRoot, - $insertNodes, - COMMAND_PRIORITY_CRITICAL, - EditorConfig, - ElementFormatType, - LexicalEditor, - NodeKey, - PASTE_COMMAND, - SerializedLexicalNode, - Spread, -} from "lexical"; -import React, { FC, useEffect } from "react"; -import { DecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode"; -import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents"; -import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; - -type LinkPreviewT = { - className: Readonly<{ - base: string; - focus: string; - }>; - nodeKey: NodeKey; - url: string; - res: ResOfWebsite; - onError?: (error: string) => void; - loadingComponent?: JSX.Element | string; - onLoad?: () => void; -}; - -export type SerializedLinkPreviewNode = Spread< - { - url: string; - res: ResOfWebsite; - format: ElementFormatType; - }, - SerializedLexicalNode ->; - -export type ResOfWebsite = { - url: string; - title: string; - description: string; - images: Array; -}; - -const LinkPreviewContainer: FC = ({ - className, - nodeKey, - url, - res, -}): JSX.Element => { - return ( - - -
- {""} -
-
{res.title}
-
{res.description}
-
-
-
-
- ); -}; - -//////////////////////////////////////////////////////////////// - -export class LinkPreviewNode extends DecoratorBlockNode { - __url: string; - __res: ResOfWebsite; - constructor( - url: string, - res: ResOfWebsite, - format?: ElementFormatType, - key?: NodeKey, - ) { - super(format, key); - this.__url = url; - this.__res = res; - if (format) this.__format = format; - } - - static getType() { - return "LinkPreviewNode"; - } - static clone(node: LinkPreviewNode): LinkPreviewNode { - return new LinkPreviewNode( - node.__url, - node.__res, - node.__format, - node.__key, - ); - } - - decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { - const linkPreviewStyle = config.theme.linkPreviewContainer; - const className = { - base: linkPreviewStyle.base, - focus: linkPreviewStyle.focus, - }; - - return ( - - ); - } - updateDOM(): false { - return false; - } - - getURL(): string { - return this.getLatest().__url; - } - - getRes(): ResOfWebsite { - return this.getLatest().__res; - } - - setFormat(format: ElementFormatType): void { - const self = this.getWritable(); - self.__format = format; - } - - exportJSON(): SerializedLinkPreviewNode { - return { - ...super.exportJSON(), - format: this.__format || "", - url: this.getURL(), - res: this.getRes(), - type: "link-preview", - version: 1, - }; - } - - static importJSON( - serializedNode: SerializedLinkPreviewNode, - ): LinkPreviewNode { - const node = $createLinkPreviewNode(serializedNode.url, serializedNode.res); - node.setFormat(serializedNode.format); - return node; - } -} - -export function $createLinkPreviewNode( - url: string, - res: ResOfWebsite, -): LinkPreviewNode { - return new LinkPreviewNode(url, res); -} - -//////////////////////////////////////////////////////////////// -export const LinkPreviewPlugin = ({ - showLink, - fetchingFunction, -}: { - showLink: boolean; - fetchingFunction: (link: string) => Promise; -}): null => { - const [editor] = useLexicalComposerContext(); - const result = [ - { url: "", title: "", description: "", images: ["/linkprev.jpg", ""] }, - ]; - let alreadyHaveIt: ResOfWebsite | undefined; - - if (!editor.hasNodes([LinkPreviewNode])) - throw new Error("LinkPreviewNode" + " is not registered in the editor"); - - const urlRegexp = - /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; - - useEffect(() => { - const removeListener = editor.registerCommand( - PASTE_COMMAND, - (event: ClipboardEvent) => { - const clipboardData = event.clipboardData; - const pastedItem = clipboardData?.getData("text"); - console.log(fetchingFunction); - - if (typeof pastedItem === "string") { - if (urlRegexp.test(pastedItem)) { - const createPreview = async () => { - await fetchingFunction(pastedItem).then((res) => { - alreadyHaveIt = result.find( - (website) => res.url === website.url, - ); - if (!alreadyHaveIt) { - result.unshift(res); - } - }); - - if (!alreadyHaveIt) { - editor.update(() => { - if (showLink) { - $getRoot() - .getLastChild() - ?.append($createLinkPreviewNode(pastedItem, result[0])); - } else { - $insertNodes([ - $createLinkPreviewNode(pastedItem, result[0]), - ]); - } - return true; - }); - } - }; - createPreview(); - return !showLink; - } - return false; - } - return false; - }, - COMMAND_PRIORITY_CRITICAL, - ); - return () => { - removeListener(); - }; - }, [editor, fetchingFunction]); - return null; -}; diff --git a/features/article/components/lexicalComponents/LinkPreviewBox.tsx b/features/article/components/lexicalComponents/LinkPreviewBox.tsx new file mode 100644 index 0000000..3a8bde8 --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewBox.tsx @@ -0,0 +1,26 @@ +import React, { FC } from "react"; +import { BlockWithAlignableContents } from "@lexical/react/LexicalBlockWithAlignableContents"; +import { LinkPreviewT } from "./LinkPreviewNode"; + +const LinkPreviewBox: FC = ({ + className, + nodeKey, + url, + res, +}): JSX.Element => { + return ( + + +
+ {""} +
+
{res.title}
+
{res.description}
+
+
+
+
+ ); +}; + +export default LinkPreviewBox; diff --git a/features/article/components/lexicalComponents/LinkPreviewNode.tsx b/features/article/components/lexicalComponents/LinkPreviewNode.tsx new file mode 100644 index 0000000..00d7bad --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewNode.tsx @@ -0,0 +1,127 @@ +import { + EditorConfig, + ElementFormatType, + LexicalEditor, + NodeKey, + SerializedLexicalNode, + Spread, +} from "lexical"; +import React from "react"; +import { DecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode"; +import LinkPreviewBox from "./LinkPreviewBox"; + +export type LinkPreviewT = { + className: Readonly<{ + base: string; + focus: string; + }>; + nodeKey: NodeKey; + url: string; + res: ResOfWebsite; + onError?: (error: string) => void; + loadingComponent?: JSX.Element | string; + onLoad?: () => void; +}; + +export type SerializedLinkPreviewNode = Spread< + { + url: string; + res: ResOfWebsite; + format: ElementFormatType; + }, + SerializedLexicalNode +>; + +export type ResOfWebsite = { + url: string; + title: string; + description: string; + images: Array; +}; + +export class LinkPreviewNode extends DecoratorBlockNode { + __url: string; + __res: ResOfWebsite; + constructor( + url: string, + res: ResOfWebsite, + format?: ElementFormatType, + key?: NodeKey, + ) { + super(format, key); + this.__url = url; + this.__res = res; + if (format) this.__format = format; + } + + static getType() { + return "LinkPreviewNode"; + } + static clone(node: LinkPreviewNode): LinkPreviewNode { + return new LinkPreviewNode( + node.__url, + node.__res, + node.__format, + node.__key, + ); + } + + decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element { + const linkPreviewStyle = config.theme.linkPreviewContainer; + const className = { + base: linkPreviewStyle.base, + focus: linkPreviewStyle.focus, + }; + + return ( + + ); + } + updateDOM(): false { + return false; + } + + getURL(): string { + return this.getLatest().__url; + } + + getRes(): ResOfWebsite { + return this.getLatest().__res; + } + + setFormat(format: ElementFormatType): void { + const self = this.getWritable(); + self.__format = format; + } + + exportJSON(): SerializedLinkPreviewNode { + return { + ...super.exportJSON(), + format: this.__format || "", + url: this.getURL(), + res: this.getRes(), + type: "link-preview", + version: 1, + }; + } + + static importJSON( + serializedNode: SerializedLinkPreviewNode, + ): LinkPreviewNode { + const node = $createLinkPreviewNode(serializedNode.url, serializedNode.res); + node.setFormat(serializedNode.format); + return node; + } +} + +export function $createLinkPreviewNode( + url: string, + res: ResOfWebsite, +): LinkPreviewNode { + return new LinkPreviewNode(url, res); +} diff --git a/features/article/components/lexicalComponents/LinkPreviewPlugin.tsx b/features/article/components/lexicalComponents/LinkPreviewPlugin.tsx new file mode 100644 index 0000000..ab99a8a --- /dev/null +++ b/features/article/components/lexicalComponents/LinkPreviewPlugin.tsx @@ -0,0 +1,87 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { useEffect } from "react"; +import { + $getRoot, + $insertNodes, + COMMAND_PRIORITY_CRITICAL, + PASTE_COMMAND, +} from "lexical"; +import { + $createLinkPreviewNode, + LinkPreviewNode, + ResOfWebsite, +} from "./LinkPreviewNode"; + +export const LinkPreviewPlugin = ({ + showLink, + fetchingFunction, +}: { + showLink: boolean; + fetchingFunction: (link: string) => Promise; +}): null => { + const [editor] = useLexicalComposerContext(); + const result = [{ url: "", title: "", description: "", images: ["", ""] }]; + let alreadyHaveIt: ResOfWebsite | undefined; + + if (!editor.hasNodes([LinkPreviewNode])) + throw new Error("LinkPreviewNode" + " is not registered in the editor"); + + const urlRegexp = + /((http(s)?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&/=]*)/; + + useEffect(() => { + const removeListener = editor.registerCommand( + PASTE_COMMAND, + (event: ClipboardEvent) => { + const clipboardData = event.clipboardData; + const pastedItem = clipboardData?.getData("text"); + + if (typeof pastedItem === "string") { + if (urlRegexp.test(pastedItem)) { + const createPreview = async () => { + try { + await fetchingFunction(pastedItem).then((res) => { + alreadyHaveIt = result.find( + (website) => res.url === website.url, + ); + if (!alreadyHaveIt) { + result.unshift(res); + } + }); + + if (!alreadyHaveIt) { + editor.update(() => { + if (showLink) { + $getRoot() + .getLastChild() + ?.append($createLinkPreviewNode(pastedItem, result[0])); + } else { + $insertNodes([ + $createLinkPreviewNode(pastedItem, result[0]), + ]); + } + return true; + }); + } + } catch (err) { + alert( + `Cannot show the preview of this link (${pastedItem}) as it is either not correct or is redirecting to another URL. \n\nTips: Try to leave the 'www.' part and use 'https://' instead of 'http://'`, + ); + } + }; + createPreview(); + return !showLink; + } + return false; + } + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + return () => { + removeListener(); + }; + }, [editor, fetchingFunction]); + return null; +}; diff --git a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx index 9cfe66f..ceff4d1 100644 --- a/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx +++ b/features/article/components/lexicalComponents/OwnLexicalToolbar.tsx @@ -29,10 +29,10 @@ export const DoOnToolbar = () => { const id = target.id; if (id === "undo") { - console.log("ajdi"); editor.dispatchCommand(UNDO_COMMAND, undefined); + } else { + editor.dispatchCommand(REDO_COMMAND, undefined); } - editor.dispatchCommand(REDO_COMMAND, undefined); }; return ( diff --git a/features/article/components/lexicalComponents/README-lexical-github.md b/features/article/components/lexicalComponents/README-lexical-github.md index e262a96..e7c48ab 100644 --- a/features/article/components/lexicalComponents/README-lexical-github.md +++ b/features/article/components/lexicalComponents/README-lexical-github.md @@ -19,6 +19,7 @@ The plugin takes two properties that allow you to: The plugin applies the data to the preview box and shows you the name, the description and, if available, the image of the website. You can easily customize the preview - use the popular 'card' design, stick to the 'bookmark' style, or create your own - whichever fits better to your UI. +Try it out at http://emergence-engineering.com/blog/lexical-link-preview ## How to use? diff --git a/features/article/components/lexicalComponents/TreeViewPlugin.tsx b/features/article/components/lexicalComponents/TreeViewPlugin.tsx index 05656bb..d83cfd0 100644 --- a/features/article/components/lexicalComponents/TreeViewPlugin.tsx +++ b/features/article/components/lexicalComponents/TreeViewPlugin.tsx @@ -1,11 +1,11 @@ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; -import { TreeView } from "@lexical/react/LexicalTreeView"; import * as React from "react"; +import { StyledTreeView } from "../../../../utils/lexical"; export default function TreeViewPlugin(): JSX.Element { const [editor] = useLexicalComposerContext(); return ( - { + return ( + + + + + } + placeholder={placeholder} + ErrorBoundary={LexicalErrorBoundary} + /> + + ) + } +\`\`\` + +Here we are; now you can paste any valid link into your editor and capture the details of the page: \n +`; +const MD2 = /* language=md */ ` +If the look doesn't work for you, you can easily customize the CSS of the box as it uses classnames. Toggle the 'showLink' property on and off to find the look that best suits your design. +And if you have any questions or want to leave feedback, please feel free to contact us! -You can check out the docs at +You can find some more info and check out the docs at `; const Article = () => ( @@ -72,6 +118,10 @@ const Article = () => ( +
+ {""} +
+ ); diff --git a/public/lexical.css b/public/lexical.css index 20f2b26..8cd8a0f 100644 --- a/public/lexical.css +++ b/public/lexical.css @@ -1,8 +1,9 @@ -p, pre, span { - line-height: 1.2; +p, span { + line-height: 1.5; } + .linkPreviewContainer { display: flex; cursor: pointer; @@ -14,10 +15,12 @@ p, pre, span { .linkPreviewContainer:focus{ outline: 2px solid rgb(60, 132, 244); + padding: 8px; } .previewBox { - width: 60%; + width: 600px; + /*width: 60%;*/ height: 80px; display: flex; flex-direction: row-reverse; @@ -40,8 +43,7 @@ p, pre, span { padding-right: 10px; } - -.previewText{ +.previewTextWrapper{ display: flex; flex-direction: column; justify-content: center; @@ -62,7 +64,10 @@ p, pre, span { font-size: 12px; font-weight: 200; text-align: start; - + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; } .draggable-block-menu { @@ -104,6 +109,16 @@ p, pre, span { will-change: transform; } +.loading { + position: absolute; + top: 200px; + left: 0; + width: 100px; + height: 50px; + padding: 10px; + background-color: red; + border: thin solid gray; +} /* original below */ /** @@ -220,12 +235,45 @@ header h1 { z-index: -1; } +.ContentEditable__root { + font-size: 15px; + display: block; + position: relative; + outline: 0; + + height: max-content; + min-height: 300px; + width: 100%; + padding: 8px 8px 24px 24px; + border: 1px solid #c7c7c7; + border-radius: 0 0 10px 10px; + margin-bottom: 10px; +} + +.ContentEditable__root:focus { + outline: 0; +} + +@media (max-width: 1025px) { + .ContentEditable__root { + padding-left: 8px; + padding-right: 8px; + } +} + +.placeholder { + position: absolute; + top: 56px; + left: 28px; + color: #bbbbbb; +} + .test-recorder-output { margin: 20px auto 20px auto; width: 100%; } -pre { +div > pre { line-height: 1.1; background: #f1f1f1; color: #000000; diff --git a/utils/lexical.ts b/utils/lexical.ts index 1c199b9..41bea03 100644 --- a/utils/lexical.ts +++ b/utils/lexical.ts @@ -1,5 +1,6 @@ import styled from "styled-components"; import { ContentEditable } from "@lexical/react/LexicalContentEditable"; +import { TreeView } from "@lexical/react/LexicalTreeView"; export const StyledContentEditable = styled(ContentEditable)` height: max-content; @@ -19,11 +20,23 @@ export const StyledContentEditable = styled(ContentEditable)` export const Placeholder = styled.div` position: absolute; - top: 58px; + top: 56px; left: 28px; color: #bbbbbb; `; +export const StyledTreeView = styled(TreeView)` + line-height: 1.1; + background: #f1f1f1; + color: #000000; + margin: 0; + padding: 10px; + font-size: 12px; + overflow: auto; + max-height: 400px; + border-radius: 10px; +`; + export const Toolbar = styled.div` display: flex; gap: 5px; @@ -41,7 +54,6 @@ export const ToolbarItem = styled.button` background: none; cursor: pointer; color: black; - //color: dimgray; align-items: baseline; min-width: 50px;