diff --git a/desktop/package.json b/desktop/package.json index 25848ae1b..8643690ec 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.168.10", @@ -42,11 +43,18 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", + "@tiptap/core": "^3.22.3", + "@tiptap/extension-link": "^3.22.3", + "@tiptap/extension-placeholder": "^3.22.3", + "@tiptap/pm": "^3.22.3", + "@tiptap/react": "^3.22.3", + "@tiptap/starter-kit": "^3.22.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "emoji-mart": "^5.6.0", "jdenticon": "^3.3.0", "lucide-react": "^0.577.0", + "motion": "^12.38.0", "react": "^19.1.0", "react-diff-view": "^3.3.2", "react-dom": "^19.1.0", @@ -55,6 +63,7 @@ "remark-gfm": "^4.0.1", "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", + "tiptap-markdown": "^0.9.0", "yaml": "^2.8.3" }, "devDependencies": { diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 51b5e5d47..96ef23994 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.5) + '@radix-ui/react-toggle': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -62,6 +65,24 @@ importers: '@tauri-apps/plugin-updater': specifier: ^2.10.0 version: 2.10.0 + '@tiptap/core': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/extension-link': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-placeholder': + specifier: ^3.22.3 + version: 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/pm': + specifier: ^3.22.3 + version: 3.22.3 + '@tiptap/react': + specifier: ^3.22.3 + version: 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tiptap/starter-kit': + specifier: ^3.22.3 + version: 3.22.3 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -77,6 +98,9 @@ importers: lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.5) + motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) react: specifier: ^19.1.0 version: 19.2.5 @@ -101,6 +125,9 @@ importers: tailwind-merge: specifier: ^3.5.0 version: 3.5.0 + tiptap-markdown: + specifier: ^0.9.0 + version: 0.9.0(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) yaml: specifier: ^2.8.3 version: 2.8.3 @@ -837,6 +864,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.2.8': resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} peerDependencies: @@ -938,6 +978,9 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@remirror/core-constants@3.0.0': + resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -993,7 +1036,6 @@ packages: resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} @@ -1035,7 +1077,6 @@ packages: resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} @@ -1278,6 +1319,160 @@ packages: '@tauri-apps/plugin-updater@2.10.0': resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==} + '@tiptap/core@3.22.3': + resolution: {integrity: sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==} + peerDependencies: + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-blockquote@3.22.3': + resolution: {integrity: sha512-IaUx3zh7yLHXzIXKL+fw/jzFhsIImdhJyw0lMhe8FfYrefFqXJFYW/sey6+L/e8B3AWvTksPA6VBwefzbH77JA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-bold@3.22.3': + resolution: {integrity: sha512-tysipHla2zCWr8XNIWRaW9O+7i7/SoEqnRqSRUUi2ailcJjlia+RBy3RykhkgyThrQDStu5KGBS/UvrXwA+O1A==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-bubble-menu@3.22.3': + resolution: {integrity: sha512-Y6zQjh0ypDg32HWgICEvmPSKjGLr39k3aDxxt/H0uQEZSfw4smT0hxUyyyjVjx68C6t6MTnwdfz0hPI5lL68vQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-bullet-list@3.22.3': + resolution: {integrity: sha512-xOmW/b1hgECIE6r3IeZvKn4VVlG3+dfTjCWE6lnnyLaqdNkNhKS1CwUmDZdYNLUS2ryIUtgz5ID1W/8A3PhbiA==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-code-block@3.22.3': + resolution: {integrity: sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-code@3.22.3': + resolution: {integrity: sha512-wafWTDQOuMKtXpZEuk1PFQmzopabBciNLryL90MB9S03MNLaQQZYLnmYkDBlzAaLAbgF5QiC+2XZQEBQuTVjFQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-document@3.22.3': + resolution: {integrity: sha512-MCSr1PFPtTd++lA3H1RNgqAczAE59XXJ5wUFIQf2F+/0DPY5q2SU4g5QsNJVxPPft5mrNT4C6ty8xBPrALFEdA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-dropcursor@3.22.3': + resolution: {integrity: sha512-taXq9Tl5aybdFbptJtFRHX9LFJzbXphAbPp4/vutFyTrBu5meXDxuS+B9pEmE+Or0XcolTlW2nDZB0Tqnr18JQ==} + peerDependencies: + '@tiptap/extensions': ^3.22.3 + + '@tiptap/extension-floating-menu@3.22.3': + resolution: {integrity: sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-gapcursor@3.22.3': + resolution: {integrity: sha512-L/Px4UeQEVG/D9WIlcAOIej+4wyIBCMUSYicSR+hW68UsObe4rxVbUas1QgidQKm6DOhoT7U7D4KQHA/Gdg/7A==} + peerDependencies: + '@tiptap/extensions': ^3.22.3 + + '@tiptap/extension-hard-break@3.22.3': + resolution: {integrity: sha512-J0v8I99y9tbvVmgKYKzKP/JYNsWaZYS7avn4rzLft2OhnyTfwt3OoY8DtpHmmi6apSUaCtoWHWta/TmoEfK1nQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-heading@3.22.3': + resolution: {integrity: sha512-XBHuhiEV2EEhZHpOLcplLqAmBIhJciU3I6AtwmqeEqDC0P114uMEfAO7JGlbBZdCYotNer26PKnu44TBTeNtkw==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-horizontal-rule@3.22.3': + resolution: {integrity: sha512-wI2bFzScs+KgWeBH/BtypcVKeYelCyqV0RG8nxsZMWtPrBhqixzNd0Oi3gEKtjSjKUqMQ/kjJAIRuESr5UzlHA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-italic@3.22.3': + resolution: {integrity: sha512-LteA4cb4EGCiUtrK2JHvDF/Zg0/YqV4DUyHhAAho+oGEQDupZlsS6m0ia5wQcclkiTLzsoPrwcSNu6RDGQ16wQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-link@3.22.3': + resolution: {integrity: sha512-S8/P2o9pv6B3kqLjH2TRWwSAximGbciNc6R8/QcN6HWLYxp0N0JoqN3rZHl9VWIBAGRWc4zkt80dhqrl2xmgfQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-list-item@3.22.3': + resolution: {integrity: sha512-80CNf4oO5y8+LdckT4CyMe1t01EyhpRrQC9H45JW20P7559Nrchp5my3vvMtIAJbpTPPZtcB7LwdzWGKsG5drg==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-list-keymap@3.22.3': + resolution: {integrity: sha512-pKuyj5llu35zd/s2u/H9aydKZjmPRAIK5P1q/YXULhhCNln2RnmuRfQ5NklAqTD3yGciQ2lxDwwf7J6iw3ergA==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-list@3.22.3': + resolution: {integrity: sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/extension-ordered-list@3.22.3': + resolution: {integrity: sha512-orAghtmd+K4Euu4BgI1hG+iZDXBYOyl5YTwiLBc2mQn+pqtZ9LqaH2us4ETwEwNP3/IWXGSAimUZ19nuL+eM2w==} + peerDependencies: + '@tiptap/extension-list': ^3.22.3 + + '@tiptap/extension-paragraph@3.22.3': + resolution: {integrity: sha512-oO7rhfyhEuwm+50s9K3GZPjYyEEEvFAvm1wXopvZnhbkBLydIWImBfrZoC5IQh4/sRDlTIjosV2C+ji5y0tUSg==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-placeholder@3.22.3': + resolution: {integrity: sha512-7vbtlDVO00odqCnsMSmA4b6wjL5PFdfExFsdsDO0K0VemqHZ/doIRx/tosNUD1VYSOyKQd8U7efUjkFyVoIPlg==} + peerDependencies: + '@tiptap/extensions': ^3.22.3 + + '@tiptap/extension-strike@3.22.3': + resolution: {integrity: sha512-jY2InoUlKkuk5KHoIDGdML1OCA2n6PRHAtxwHNkAmiYh0Khf0zaVPGFpx4dgQrN7W5Q1WE6oBZnjrvy6qb7w0g==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-text@3.22.3': + resolution: {integrity: sha512-Q9R7JsTdomP5uUjtPjNKxHT1xoh/i9OJZnmgJLe7FcgZEaPOQ3bWxmKZoLZQfDfZjyB8BtH+Hc7nUvhCMOePxw==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extension-underline@3.22.3': + resolution: {integrity: sha512-Ch6CBWRa5w90yYSPUW6x9Py9JdrXMqk3pZ9OIlMYD8A7BqyZGfiHerX7XDMYDS09KjyK3U9XH60/zxYOzXdDLA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + + '@tiptap/extensions@3.22.3': + resolution: {integrity: sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + + '@tiptap/pm@3.22.3': + resolution: {integrity: sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==} + + '@tiptap/react@3.22.3': + resolution: {integrity: sha512-6MNr6z0PxwfJFs+BKhHcvPNvY+UV1PXgqzTiTM4Z9guml84iVZxv7ZOCSj1dFYTr3Bf1MiOs4hT1yvBFlTfIaQ==} + peerDependencies: + '@tiptap/core': ^3.22.3 + '@tiptap/pm': ^3.22.3 + '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 + '@types/react-dom': ^17.0.0 || ^18.0.0 || ^19.0.0 + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tiptap/starter-kit@3.22.3': + resolution: {integrity: sha512-vdW/Oo1fdwTL1VOQ5YYbTov00ANeHLquBVEZyL/EkV7Xv5io9rXQsCysJfTSHhiQlyr2MtWFB4+CPGuwXjQWOQ==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1302,9 +1497,27 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/linkify-it@3.0.5': + resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@13.0.9': + resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@1.0.5': + resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -1325,6 +1538,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -1353,6 +1569,9 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} @@ -1444,6 +1663,9 @@ packages: cookie-es@2.0.1: resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1493,6 +1715,10 @@ packages: emoji-mart@5.6.0: resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -1502,6 +1728,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} @@ -1517,6 +1747,10 @@ packages: extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fast-equals@5.4.0: + resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==} + engines: {node: '>=6.0.0'} + fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -1540,6 +1774,20 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1666,6 +1914,12 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + + linkifyjs@4.3.2: + resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -1684,6 +1938,13 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + markdown-it-task-lists@2.1.1: + resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} + + markdown-it@14.1.1: + resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} + hasBin: true + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -1735,6 +1996,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1827,6 +2091,26 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1870,6 +2154,9 @@ packages: oniguruma-to-es@4.3.5: resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -1963,6 +2250,68 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + prosemirror-changeset@2.4.0: + resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==} + + prosemirror-collab@1.3.1: + resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==} + + prosemirror-commands@1.7.1: + resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} + + prosemirror-dropcursor@1.8.2: + resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==} + + prosemirror-gapcursor@1.4.1: + resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==} + + prosemirror-history@1.5.0: + resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==} + + prosemirror-inputrules@1.5.1: + resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==} + + prosemirror-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-markdown@1.13.4: + resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==} + + prosemirror-menu@1.3.0: + resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==} + + prosemirror-model@1.25.4: + resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==} + + prosemirror-schema-basic@1.2.4: + resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==} + + prosemirror-schema-list@1.5.1: + resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-tables@1.8.5: + resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==} + + prosemirror-trailing-node@3.0.0: + resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==} + peerDependencies: + prosemirror-model: ^1.22.1 + prosemirror-state: ^1.4.2 + prosemirror-view: ^1.33.8 + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2072,6 +2421,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -2159,6 +2511,11 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tiptap-markdown@0.9.0: + resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==} + peerDependencies: + '@tiptap/core': ^3.0.1 + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -2185,6 +2542,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -2290,6 +2650,9 @@ packages: yaml: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + warning@4.0.3: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} @@ -2917,6 +3280,17 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -3002,6 +3376,8 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@remirror/core-constants@3.0.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.59.0': @@ -3281,6 +3657,187 @@ snapshots: dependencies: '@tauri-apps/api': 2.10.1 + '@tiptap/core@3.22.3(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-blockquote@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-bold@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-bubble-menu@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + optional: true + + '@tiptap/extension-bullet-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-code@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-document@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-dropcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-floating-menu@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + optional: true + + '@tiptap/extension-gapcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-hard-break@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-heading@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-horizontal-rule@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-italic@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-link@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + linkifyjs: 4.3.2 + + '@tiptap/extension-list-item@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-list-keymap@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/extension-ordered-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-paragraph@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-placeholder@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + + '@tiptap/extension-strike@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-text@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extension-underline@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + + '@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + + '@tiptap/pm@3.22.3': + dependencies: + prosemirror-changeset: 2.4.0 + prosemirror-collab: 1.3.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-inputrules: 1.5.1 + prosemirror-keymap: 1.2.3 + prosemirror-markdown: 1.13.4 + prosemirror-menu: 1.3.0 + prosemirror-model: 1.25.4 + prosemirror-schema-basic: 1.2.4 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8) + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + '@tiptap/react@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + use-sync-external-store: 1.6.0(react@19.2.5) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-floating-menu': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.22.3': + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@tiptap/extension-blockquote': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-bold': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-bullet-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-code': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-code-block': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-document': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-dropcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-gapcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-hard-break': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-heading': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-horizontal-rule': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-italic': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-link': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/extension-list-item': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-list-keymap': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-ordered-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)) + '@tiptap/extension-paragraph': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-strike': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-text': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extension-underline': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)) + '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3) + '@tiptap/pm': 3.22.3 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.0 @@ -3316,10 +3873,28 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/linkify-it@3.0.5': {} + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@13.0.9': + dependencies: + '@types/linkify-it': 3.0.5 + '@types/mdurl': 1.0.5 + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 + '@types/mdurl@1.0.5': {} + + '@types/mdurl@2.0.0': {} + '@types/ms@2.1.0': {} '@types/node@25.5.2': @@ -3338,6 +3913,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/use-sync-external-store@0.0.6': {} + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))': @@ -3365,6 +3942,8 @@ snapshots: arg@5.0.2: {} + argparse@2.0.1: {} + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -3455,6 +4034,8 @@ snapshots: cookie-es@2.0.1: {} + crelt@1.0.6: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -3487,6 +4068,8 @@ snapshots: emoji-mart@5.6.0: {} + entities@4.5.0: {} + esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -3518,6 +4101,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} esprima@4.0.1: {} @@ -3526,6 +4111,8 @@ snapshots: extend@3.0.2: {} + fast-equals@5.4.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3548,6 +4135,15 @@ snapshots: fraction.js@5.3.4: {} + framer-motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + fsevents@2.3.2: optional: true @@ -3669,6 +4265,12 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + + linkifyjs@4.3.2: {} + lodash@4.17.23: {} longest-streak@3.1.0: {} @@ -3685,6 +4287,17 @@ snapshots: dependencies: react: 19.2.5 + markdown-it-task-lists@2.1.1: {} + + markdown-it@14.1.1: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.0 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + markdown-table@3.0.4: {} mdast-util-find-and-replace@3.0.2: @@ -3845,6 +4458,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdurl@2.0.0: {} + merge2@1.4.1: {} micromark-core-commonmark@2.0.3: @@ -4043,6 +4658,20 @@ snapshots: braces: 3.0.3 picomatch: 2.3.2 + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + framer-motion: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + ms@2.1.3: {} mz@2.7.0: @@ -4083,6 +4712,8 @@ snapshots: regex: 6.1.0 regex-recursion: 6.0.2 + orderedmap@2.1.1: {} + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -4158,6 +4789,111 @@ snapshots: property-information@7.1.0: {} + prosemirror-changeset@2.4.0: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-collab@1.3.1: + dependencies: + prosemirror-state: 1.4.4 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-dropcursor@1.8.2: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-gapcursor@1.4.1: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + rope-sequence: 1.3.4 + + prosemirror-inputrules@1.5.1: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-markdown@1.13.4: + dependencies: + '@types/markdown-it': 14.1.2 + markdown-it: 14.1.1 + prosemirror-model: 1.25.4 + + prosemirror-menu@1.3.0: + dependencies: + crelt: 1.0.6 + prosemirror-commands: 1.7.1 + prosemirror-history: 1.5.0 + prosemirror-state: 1.4.4 + + prosemirror-model@1.25.4: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-basic@1.2.4: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-tables@1.8.5: + dependencies: + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8): + dependencies: + '@remirror/core-constants': 3.0.0 + escape-string-regexp: 4.0.0 + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-view: 1.41.8 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.4 + + prosemirror-view@1.41.8: + dependencies: + prosemirror-model: 1.25.4 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + punycode.js@2.3.1: {} + queue-microtask@1.2.3: {} react-diff-view@3.3.2(react@19.2.5): @@ -4331,6 +5067,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.59.0 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -4440,6 +5178,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tiptap-markdown@0.9.0(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)): + dependencies: + '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3) + '@types/markdown-it': 13.0.9 + markdown-it: 14.1.1 + markdown-it-task-lists: 2.1.1 + prosemirror-markdown: 1.13.4 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -4461,6 +5207,8 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: {} + undici-types@7.18.2: {} unified@11.0.5: @@ -4555,6 +5303,8 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 + w3c-keyname@2.2.8: {} + warning@4.0.3: dependencies: loose-envify: 1.4.0 diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 3a43091de..82eeb6e6a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -103,8 +103,7 @@ export const ChannelPane = React.memo(function ChannelPane({ !activeChannel || !activeChannel.isMember || activeChannel.archivedAt !== null || - activeChannel.channelType === "forum" || - isSending; + activeChannel.channelType === "forum"; return (
diff --git a/desktop/src/features/messages/lib/imageRefExtension.ts b/desktop/src/features/messages/lib/imageRefExtension.ts new file mode 100644 index 000000000..614b2f7f0 --- /dev/null +++ b/desktop/src/features/messages/lib/imageRefExtension.ts @@ -0,0 +1,67 @@ +import { mergeAttributes, Node } from "@tiptap/core"; + +/** + * Custom Tiptap inline node for image reference chips. + * + * Stores the attachment URL and short hash. Renders as a non-editable + * inline pill like `![a3f2]`. On send, the composer resolves these to + * `![image](url)` markdown. + */ +export const ImageRefNode = Node.create({ + name: "imageRef", + group: "inline", + inline: true, + atom: true, // non-editable, treated as a single unit + + addAttributes() { + return { + url: { default: null }, + hash: { default: null }, + mediaType: { default: "image" }, + thumb: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: "span[data-image-ref]" }]; + }, + + addStorage() { + return { + markdown: { + serialize( + state: { write: (text: string) => void }, + node: { attrs: { hash?: string } }, + ) { + state.write(`![${node.attrs.hash ?? "?"}]`); + }, + parse: {}, + }, + }; + }, + + renderHTML({ HTMLAttributes }: { HTMLAttributes: Record }) { + const thumb = HTMLAttributes.thumb || HTMLAttributes.url || ""; + const hash = HTMLAttributes.hash ?? "?"; + + return [ + "span", + mergeAttributes(HTMLAttributes, { + "data-image-ref": "", + class: + "inline-flex items-center rounded-lg overflow-hidden align-baseline cursor-default select-none border border-border/50", + contenteditable: "false", + title: `![${hash}]`, + }), + [ + "img", + { + src: thumb, + alt: `![${hash}]`, + class: "inline-block h-16 max-w-48 rounded-lg object-contain", + draggable: "false", + }, + ], + ]; + }, +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs new file mode 100644 index 000000000..9adec5446 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -0,0 +1,140 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildHighlightPatterns, + findHighlightMatches, +} from "./mentionHighlightExtension.ts"; + +// ── buildHighlightPatterns ──────────────────────────────────────────── + +test("returns empty array when no names or channels provided", () => { + assert.deepEqual(buildHighlightPatterns([], []), []); +}); + +test("builds a single pattern for mentions only", () => { + const patterns = buildHighlightPatterns(["alice"], []); + assert.equal(patterns.length, 1); +}); + +test("builds a single pattern for channels only", () => { + const patterns = buildHighlightPatterns([], ["general"]); + assert.equal(patterns.length, 1); +}); + +test("builds two patterns when both names and channels provided", () => { + const patterns = buildHighlightPatterns(["alice"], ["general"]); + assert.equal(patterns.length, 2); +}); + +test("escapes regex special characters in names", () => { + const patterns = buildHighlightPatterns(["alice (admin)"], []); + // Should not throw when used as regex + const matches = findHighlightMatches("@alice (admin) hello", patterns); + assert.equal(matches.length, 1); + assert.equal(matches[0].match, "@alice (admin)"); +}); + +test("escapes regex special characters in channel names", () => { + const patterns = buildHighlightPatterns([], ["c++ help"]); + const matches = findHighlightMatches("#c++ help", patterns); + assert.equal(matches.length, 1); +}); + +// ── findHighlightMatches — @mentions ────────────────────────────────── + +test("matches @mention at start of text", () => { + const patterns = buildHighlightPatterns(["alice"], []); + const matches = findHighlightMatches("@alice hello", patterns); + assert.equal(matches.length, 1); + assert.equal(matches[0].match, "@alice"); + assert.equal(matches[0].from, 0); + assert.equal(matches[0].to, 6); +}); + +test("matches @mention after whitespace", () => { + const patterns = buildHighlightPatterns(["bob"], []); + const matches = findHighlightMatches("hey @bob", patterns); + assert.equal(matches.length, 1); + assert.equal(matches[0].match, "@bob"); +}); + +test("does not match @mention embedded in a word", () => { + const patterns = buildHighlightPatterns(["bob"], []); + const matches = findHighlightMatches("email@bob.com", patterns); + assert.equal(matches.length, 0); +}); + +test("matches are case-insensitive", () => { + const patterns = buildHighlightPatterns(["Alice"], []); + const matches = findHighlightMatches("@alice @ALICE @Alice", patterns); + assert.equal(matches.length, 3); +}); + +test("matches multiple different mentions in one string", () => { + const patterns = buildHighlightPatterns(["alice", "bob"], []); + const matches = findHighlightMatches("@alice and @bob", patterns); + assert.equal(matches.length, 2); + assert.equal(matches[0].match, "@alice"); + assert.equal(matches[1].match, "@bob"); +}); + +test("longer names matched first (no partial overlap)", () => { + const patterns = buildHighlightPatterns(["al", "alice"], []); + const matches = findHighlightMatches("@alice", patterns); + // Should match "alice" not just "al" + assert.equal(matches.length, 1); + assert.equal(matches[0].match, "@alice"); +}); + +// ── findHighlightMatches — #channels ────────────────────────────────── + +test("matches #channel at start of text", () => { + const patterns = buildHighlightPatterns([], ["general"]); + const matches = findHighlightMatches("#general is cool", patterns); + assert.equal(matches.length, 1); + assert.equal(matches[0].match, "#general"); +}); + +test("matches #channel after whitespace", () => { + const patterns = buildHighlightPatterns([], ["random"]); + const matches = findHighlightMatches("check #random", patterns); + assert.equal(matches.length, 1); +}); + +test("does not match #channel embedded in a word", () => { + const patterns = buildHighlightPatterns([], ["foo"]); + const matches = findHighlightMatches("bar#foo", patterns); + assert.equal(matches.length, 0); +}); + +test("channel matches are case-insensitive", () => { + const patterns = buildHighlightPatterns([], ["General"]); + const matches = findHighlightMatches("#general #GENERAL", patterns); + assert.equal(matches.length, 2); +}); + +// ── findHighlightMatches — mixed ────────────────────────────────────── + +test("matches both @mentions and #channels in the same text", () => { + const patterns = buildHighlightPatterns(["alice"], ["general"]); + const matches = findHighlightMatches("@alice in #general", patterns); + assert.equal(matches.length, 2); +}); + +test("returns empty array for text with no matches", () => { + const patterns = buildHighlightPatterns(["alice"], ["general"]); + const matches = findHighlightMatches("nothing here", patterns); + assert.equal(matches.length, 0); +}); + +test("handles empty text", () => { + const patterns = buildHighlightPatterns(["alice"], []); + const matches = findHighlightMatches("", patterns); + assert.equal(matches.length, 0); +}); + +test("handles empty patterns against non-empty text", () => { + const matches = findHighlightMatches("@alice #general", []); + assert.equal(matches.length, 0); +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts new file mode 100644 index 000000000..fa06ad628 --- /dev/null +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -0,0 +1,135 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; + +export const mentionHighlightKey = new PluginKey("mentionHighlight"); + +/** + * TipTap extension that applies inline `mention-highlight` decorations + * to `@Name` and `#channel-name` patterns in the document. + * + * Accepts `names` (display names) and `channelNames` storage options. + * On every doc update the plugin scans text nodes and decorates matches. + */ +export const MentionHighlightExtension = Extension.create({ + name: "mentionHighlight", + + addStorage() { + return { + names: [] as string[], + channelNames: [] as string[], + }; + }, + + addProseMirrorPlugins() { + const extension = this; + + return [ + new Plugin({ + key: mentionHighlightKey, + state: { + init(_, state) { + return buildDecorations(state.doc, extension.storage.names, extension.storage.channelNames); + }, + apply(tr, oldDecorations) { + if (tr.docChanged || tr.getMeta(mentionHighlightKey)) { + return buildDecorations( + tr.doc, + extension.storage.names, + extension.storage.channelNames, + ); + } + return oldDecorations; + }, + }, + props: { + decorations(state) { + return this.getState(state) ?? DecorationSet.empty; + }, + }, + }), + ]; + }, +}); + +/** + * Build highlight patterns for @Name and #channel-name matching. + * Exported for testing — the patterns are the core logic of this extension. + */ +export function buildHighlightPatterns( + names: string[], + channelNames: string[], +): RegExp[] { + const patterns: RegExp[] = []; + + if (names.length > 0) { + const sortedNames = [...names].sort((a, b) => b.length - a.length); + const escapedNames = sortedNames.map((n) => + n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ); + patterns.push( + new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})`, "gi"), + ); + } + + if (channelNames.length > 0) { + const sortedChannels = [...channelNames].sort((a, b) => b.length - a.length); + const escapedChannels = sortedChannels.map((n) => + n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ); + patterns.push( + new RegExp(`(?:^|(?<=\\s))#(${escapedChannels.join("|")})`, "gi"), + ); + } + + return patterns; +} + +/** + * Find all highlight matches in a text string given a set of patterns. + * Returns an array of { from, to } offsets relative to the text start. + * Exported for testing. + */ +export function findHighlightMatches( + text: string, + patterns: RegExp[], +): { from: number; to: number; match: string }[] { + const results: { from: number; to: number; match: string }[] = []; + for (const pattern of patterns) { + pattern.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = pattern.exec(text)) !== null) { + results.push({ from: m.index, to: m.index + m[0].length, match: m[0] }); + } + } + return results; +} + +function buildDecorations( + doc: Parameters[0], + names: string[], + channelNames: string[], +): DecorationSet { + if (names.length === 0 && channelNames.length === 0) return DecorationSet.empty; + + const decorations: Decoration[] = []; + const patterns = buildHighlightPatterns(names, channelNames); + + doc.descendants((node, pos) => { + if (!node.isText || !node.text) return; + + for (const pattern of patterns) { + pattern.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = pattern.exec(node.text)) !== null) { + const from = pos + match.index; + const to = from + match[0].length; + decorations.push( + Decoration.inline(from, to, { class: "mention-highlight" }), + ); + } + } + }); + + return DecorationSet.create(doc, decorations); +} diff --git a/desktop/src/features/messages/lib/useImageRefSuggestions.ts b/desktop/src/features/messages/lib/useImageRefSuggestions.ts new file mode 100644 index 000000000..721addc52 --- /dev/null +++ b/desktop/src/features/messages/lib/useImageRefSuggestions.ts @@ -0,0 +1,113 @@ +import * as React from "react"; + +import type { BlobDescriptor } from "@/shared/api/tauri"; +import { shortHash } from "./useMediaUpload"; + +export type ImageRefSuggestion = { + url: string; + hash: string; + type: string; + thumb?: string; +}; + +/** + * Detects `![` typed in the editor and shows suggestions from attached + * images. Lightweight alternative to @tiptap/suggestion — works with + * plain text + cursor position from the existing bridge. + */ +export function useImageRefSuggestions(attachments: BlobDescriptor[]) { + const [isOpen, setIsOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [selectedIndex, setSelectedIndex] = React.useState(0); + + const suggestions: ImageRefSuggestion[] = React.useMemo(() => { + const items = attachments.map((a) => ({ + url: a.url, + hash: shortHash(a.sha256), + type: a.type, + thumb: a.thumb, + })); + if (!query) return items; + return items.filter((s) => + s.hash.toLowerCase().includes(query.toLowerCase()), + ); + }, [attachments, query]); + + /** Call on every editor update with the plain text and cursor position. */ + const updateQuery = React.useCallback( + (text: string, cursor: number) => { + if (attachments.length === 0) { + if (isOpen) setIsOpen(false); + return; + } + + // Look for `![` before cursor, not yet closed with `]` + const before = text.slice(0, cursor); + const match = /!\[([^\]]*)$/.exec(before); + + if (match) { + setQuery(match[1]); + setIsOpen(true); + setSelectedIndex(0); + } else if (isOpen) { + setIsOpen(false); + setQuery(""); + } + }, + [attachments.length, isOpen], + ); + + /** Handle keyboard navigation. Returns { handled, suggestion? }. */ + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (!isOpen || suggestions.length === 0) { + return { handled: false } as const; + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex((i) => (i + 1) % suggestions.length); + return { handled: true } as const; + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1)); + return { handled: true } as const; + } + + if (event.key === "Tab" || event.key === "Enter") { + event.preventDefault(); + const suggestion = suggestions[selectedIndex]; + setIsOpen(false); + setQuery(""); + return { handled: true, suggestion } as const; + } + + if (event.key === "Escape") { + event.preventDefault(); + setIsOpen(false); + setQuery(""); + return { handled: true } as const; + } + + return { handled: false } as const; + }, + [isOpen, suggestions, selectedIndex], + ); + + const clear = React.useCallback(() => { + setIsOpen(false); + setQuery(""); + setSelectedIndex(0); + }, []); + + return { + isOpen, + suggestions, + selectedIndex, + updateQuery, + handleKeyDown, + clear, + }; +} diff --git a/desktop/src/features/messages/lib/useMediaUpload.test.mjs b/desktop/src/features/messages/lib/useMediaUpload.test.mjs new file mode 100644 index 000000000..674cce5ff --- /dev/null +++ b/desktop/src/features/messages/lib/useMediaUpload.test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// shortHash is a simple utility: str.slice(0, 4) +// Inline it here to avoid importing from useMediaUpload.ts which has +// unresolvable @/shared path aliases outside the bundler. +function shortHash(hex) { + return hex.slice(0, 4); +} + +// ── shortHash ───────────────────────────────────────────────────────── + +test("shortHash returns first 4 hex characters", () => { + assert.equal(shortHash("abcdef1234567890"), "abcd"); +}); + +test("shortHash handles minimum-length input", () => { + assert.equal(shortHash("abcd"), "abcd"); +}); + +test("shortHash returns empty string for empty input", () => { + assert.equal(shortHash(""), ""); +}); + +test("shortHash returns partial for short input", () => { + assert.equal(shortHash("ab"), "ab"); +}); + +// ── Upload slot ordering (pure state-update logic) ──────────────────── +// The slot system uses reserveSlots → fillSlot to maintain insertion order +// when concurrent uploads finish out of order. We test the state-update +// functions in isolation (they're pure array transforms). + +test("reserveSlots creates null placeholders", () => { + // Simulate: start with empty slots, reserve 3 + const prev = []; + const count = 3; + const next = [...prev, ...new Array(count).fill(null)]; + assert.deepEqual(next, [null, null, null]); +}); + +test("fillSlot places descriptor at correct index", () => { + // Simulate: 3 reserved slots, fill index 1 first (out of order) + const slots = [null, null, null]; + const descriptor = { url: "https://example.com/b.png", sha256: "bbbb" }; + const next = [...slots]; + next[1] = descriptor; + assert.equal(next[0], null); + assert.deepEqual(next[1], descriptor); + assert.equal(next[2], null); +}); + +test("concurrent uploads filling out of order preserves slot positions", () => { + // Simulate: reserve 3 slots, uploads finish in order 2, 0, 1 + const slots = [null, null, null]; + const a = { url: "a.png", sha256: "aaaa" }; + const b = { url: "b.png", sha256: "bbbb" }; + const c = { url: "c.png", sha256: "cccc" }; + + // Upload 2 finishes first + const step1 = [...slots]; + step1[2] = c; + assert.deepEqual(step1, [null, null, c]); + + // Upload 0 finishes second + const step2 = [...step1]; + step2[0] = a; + assert.deepEqual(step2, [a, null, c]); + + // Upload 1 finishes last + const step3 = [...step2]; + step3[1] = b; + assert.deepEqual(step3, [a, b, c]); + + // Filter nulls — final order matches original slot order + const result = step3.filter((d) => d !== null); + assert.deepEqual(result, [a, b, c]); +}); + +test("removing an attachment nulls the slot instead of compacting", () => { + const a = { url: "a.png", sha256: "aaaa" }; + const b = { url: "b.png", sha256: "bbbb" }; + const c = { url: "c.png", sha256: "cccc" }; + const slots = [a, b, c]; + + // Remove b — null out, don't compact + const next = slots.map((d) => (d?.url === "b.png" ? null : d)); + assert.deepEqual(next, [a, null, c]); + // Filtered view (what consumers see) drops nulls + const filtered = next.filter((d) => d !== null); + assert.deepEqual(filtered, [a, c]); +}); + +test("removing mid-upload does not corrupt in-flight slot indices", () => { + // Scenario: 3 images uploading, image 0 finishes, user removes image 0, + // then image 1 and 2 finish — they must land in their original slots. + const a = { url: "a.png", sha256: "aaaa" }; + const b = { url: "b.png", sha256: "bbbb" }; + const c = { url: "c.png", sha256: "cccc" }; + + // Start: 3 reserved slots + let slots = [null, null, null]; + + // Image 0 finishes + slots = [...slots]; + slots[0] = a; + assert.deepEqual(slots, [a, null, null]); + + // User removes image 0 — null out, don't compact + slots = slots.map((d) => (d?.url === "a.png" ? null : d)); + assert.deepEqual(slots, [null, null, null]); + + // Image 1 finishes — fillSlot(1) still works correctly + slots = [...slots]; + slots[1] = b; + assert.deepEqual(slots, [null, b, null]); + + // Image 2 finishes — fillSlot(2) still works correctly + slots = [...slots]; + slots[2] = c; + assert.deepEqual(slots, [null, b, c]); + + // Consumer view filters nulls + const result = slots.filter((d) => d !== null); + assert.deepEqual(result, [b, c]); +}); + +test("reserveSlots pads if slots array is shorter than expected start index", () => { + // Edge case: if somehow prev is shorter than startIndex + const prev = [{ url: "a.png", sha256: "aaaa" }]; + const startIndex = 3; + const count = 2; + const padded = + prev.length < startIndex + ? [...prev, ...new Array(startIndex - prev.length).fill(null)] + : prev; + const next = [...padded, ...new Array(count).fill(null)]; + assert.equal(next.length, 5); + assert.deepEqual(next[0], { url: "a.png", sha256: "aaaa" }); + assert.equal(next[1], null); // padding + assert.equal(next[2], null); // padding + assert.equal(next[3], null); // reserved + assert.equal(next[4], null); // reserved +}); diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts index 981492283..9a6b29626 100644 --- a/desktop/src/features/messages/lib/useMediaUpload.ts +++ b/desktop/src/features/messages/lib/useMediaUpload.ts @@ -6,7 +6,7 @@ import { uploadMediaBytes, } from "@/shared/api/tauri"; -const ALLOWED_TYPES = [ +export const ALLOWED_MEDIA_TYPES = [ "image/jpeg", "image/png", "image/gif", @@ -18,48 +18,101 @@ const ALLOWED_TYPES = [ "video/x-msvideo", ]; +/** + * First 4 hex chars of the sha256 — used as a short display name. + * Note: 4 hex chars = 65,536 possible values. Collision is unlikely + * within a single message's attachments but theoretically possible. + * If collisions become an issue, extend to 6+ chars. + */ +export function shortHash(sha256: string): string { + return sha256.slice(0, 4); +} + type UploadState = { status: "idle" | "uploading" | "error"; message?: string; }; -export function useMediaUpload( - setContent: React.Dispatch>, -) { +export function useMediaUpload() { const [uploadState, setUploadState] = React.useState({ status: "idle", }); - const [pendingImeta, setPendingImeta] = React.useState([]); + /** Number of files currently in-flight. */ + const [uploadingCount, setUploadingCount] = React.useState(0); + /** + * Internal slots array — may contain `null` for reserved-but-pending uploads. + * Consumers see the filtered `pendingImeta` (nulls stripped) so the public + * type stays `BlobDescriptor[]`. + */ + const [imetaSlots, setImetaSlots] = React.useState< + (BlobDescriptor | null)[] + >([]); + + const pendingImeta = React.useMemo( + () => imetaSlots.filter((d): d is BlobDescriptor => d !== null), + [imetaSlots], + ); const pendingImetaRef = React.useRef(pendingImeta); pendingImetaRef.current = pendingImeta; - const onUploaded = React.useCallback( - (descriptor: BlobDescriptor) => { - const isVideo = descriptor.type === "video/mp4"; - const markdown = isVideo - ? `\n![video](${descriptor.url})\n` - : `\n![image](${descriptor.url})\n`; - setContent((prev) => prev + markdown); - setPendingImeta((prev) => [...prev, descriptor]); - setUploadState({ status: "idle" }); + /** Monotonic slot counter — ensures each batch gets unique indices even + * before React flushes the state update. */ + const nextSlotRef = React.useRef(0); + + /** Reserve `count` null slots at the end; returns the starting index. */ + const reserveSlots = React.useCallback((count: number): number => { + const startIndex = nextSlotRef.current; + nextSlotRef.current += count; + setImetaSlots((prev) => { + // Pad prev if needed (should already be the right length, but be safe) + const padded = + prev.length < startIndex + ? [...prev, ...new Array(startIndex - prev.length).fill(null)] + : prev; + return [...padded, ...new Array(count).fill(null)]; + }); + return startIndex; + }, []); + + /** Fill a previously-reserved slot by index. */ + const fillSlot = React.useCallback( + (index: number, descriptor: BlobDescriptor) => { + setImetaSlots((prev) => { + const next = [...prev]; + next[index] = descriptor; + return next; + }); + setUploadingCount((c) => Math.max(0, c - 1)); }, - [setContent], + [], ); + /** Append a single descriptor (no pre-reserved slot). */ + const onUploaded = React.useCallback((descriptor: BlobDescriptor) => { + nextSlotRef.current += 1; + setImetaSlots((prev) => [...prev, descriptor]); + setUploadingCount((c) => Math.max(0, c - 1)); + }, []); + + const onUploadError = React.useCallback((err: unknown) => { + setUploadingCount((c) => Math.max(0, c - 1)); + setUploadState({ status: "error", message: String(err) }); + }, []); + const handlePaperclip = React.useCallback(async () => { - setUploadState({ status: "uploading" }); + setUploadingCount((c) => c + 1); try { const descriptor = await pickAndUploadMedia(); if (descriptor) { onUploaded(descriptor); } else { - setUploadState({ status: "idle" }); + setUploadingCount((c) => Math.max(0, c - 1)); } } catch (err) { - setUploadState({ status: "error", message: String(err) }); + onUploadError(err); } - }, [onUploaded]); + }, [onUploaded, onUploadError]); const handleDrop = React.useCallback( async (event: React.DragEvent) => { @@ -67,10 +120,11 @@ export function useMediaUpload( const files = Array.from(event.dataTransfer.files); if (files.length === 0) return; - const file = files[0]; - if (!file) return; + const validFiles = files.filter((f) => + ALLOWED_MEDIA_TYPES.includes(f.type), + ); - if (!ALLOWED_TYPES.includes(file.type)) { + if (validFiles.length === 0) { setUploadState({ status: "error", message: @@ -79,16 +133,27 @@ export function useMediaUpload( return; } - setUploadState({ status: "uploading" }); - try { - const buffer = await file.arrayBuffer(); - const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]); - onUploaded(descriptor); - } catch (err) { - setUploadState({ status: "error", message: String(err) }); + setUploadingCount((c) => c + validFiles.length); + const baseIndex = reserveSlots(validFiles.length); + + for (let i = 0; i < validFiles.length; i++) { + const file = validFiles[i]; + const slotIndex = baseIndex + i; + // Fire-and-forget each upload concurrently — slot preserves order + (async () => { + try { + const buffer = await file.arrayBuffer(); + const descriptor = await uploadMediaBytes([ + ...new Uint8Array(buffer), + ]); + fillSlot(slotIndex, descriptor); + } catch (err) { + onUploadError(err); + } + })(); } }, - [onUploaded], + [reserveSlots, fillSlot, onUploadError], ); const handleDragOver = React.useCallback( @@ -99,28 +164,77 @@ export function useMediaUpload( ); const handlePaste = React.useCallback( - async (event: React.ClipboardEvent) => { + async (event: { + clipboardData: DataTransfer; + preventDefault: () => void; + }) => { const items = Array.from(event.clipboardData.items); - const mediaItem = items.find((item) => ALLOWED_TYPES.includes(item.type)); - if (!mediaItem) return; + const mediaFiles = items + .filter((item) => ALLOWED_MEDIA_TYPES.includes(item.type)) + .map((item) => item.getAsFile()) + .filter((f): f is File => f !== null); + if (mediaFiles.length === 0) return; event.preventDefault(); - const file = mediaItem.getAsFile(); - if (!file) return; - setUploadState({ status: "uploading" }); + setUploadingCount((c) => c + mediaFiles.length); + const baseIndex = reserveSlots(mediaFiles.length); + + for (let i = 0; i < mediaFiles.length; i++) { + const file = mediaFiles[i]; + const slotIndex = baseIndex + i; + (async () => { + try { + const buffer = await file.arrayBuffer(); + const descriptor = await uploadMediaBytes([ + ...new Uint8Array(buffer), + ]); + fillSlot(slotIndex, descriptor); + } catch (err) { + onUploadError(err); + } + })(); + } + }, + [reserveSlots, fillSlot, onUploadError], + ); + + /** Upload a File directly — used by Tiptap's editorProps.handlePaste. */ + const uploadFile = React.useCallback( + async (file: File) => { + if (!ALLOWED_MEDIA_TYPES.includes(file.type)) return; + setUploadingCount((c) => c + 1); try { const buffer = await file.arrayBuffer(); const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]); onUploaded(descriptor); } catch (err) { - setUploadState({ status: "error", message: String(err) }); + onUploadError(err); } }, - [onUploaded], + [onUploaded, onUploadError], + ); + + const removeAttachment = React.useCallback((url: string) => { + setImetaSlots((prev) => + prev.map((d) => (d?.url === url ? null : d)), + ); + }, []); + + /** Public setter — replaces all slots (used by MessageComposer to clear/restore). */ + const setPendingImeta = React.useCallback( + (action: React.SetStateAction) => { + setImetaSlots((prev) => { + const current = prev.filter((d): d is BlobDescriptor => d !== null); + const next = typeof action === "function" ? action(current) : action; + nextSlotRef.current = next.length; + return next; + }); + }, + [], ); - const isUploading = uploadState.status === "uploading"; + const isUploading = uploadingCount > 0; return { handleDragOver, @@ -130,8 +244,11 @@ export function useMediaUpload( isUploading, pendingImeta, pendingImetaRef, + removeAttachment, setPendingImeta, setUploadState, + uploadFile, + uploadingCount, uploadState, }; } diff --git a/desktop/src/features/messages/lib/useRichTextEditor.test.mjs b/desktop/src/features/messages/lib/useRichTextEditor.test.mjs new file mode 100644 index 000000000..0b87d341f --- /dev/null +++ b/desktop/src/features/messages/lib/useRichTextEditor.test.mjs @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +/** + * Pure extraction of the ProseMirror → plain-text cursor mapping logic + * from getTextAndCursor in useRichTextEditor.ts. + * + * Takes a list of "visited nodes" (as the descendants callback would see them) + * and a ProseMirror anchor position, returns the plain-text offset. + */ +function mapAnchorToPlainText(nodes, anchor) { + let offset = 0; + let found = false; + for (const { isText, isBlock, pos, nodeSize } of nodes) { + if (found) break; + if (isText) { + const nodeEnd = pos + nodeSize; + if (anchor <= nodeEnd) { + offset += anchor - pos; + found = true; + break; + } + offset += nodeSize; + } else if (isBlock && pos > 0) { + offset += 1; + } + } + return found ? offset : -1; // -1 means "fell through" +} + +// ── Single paragraph ────────────────────────────────────────────────── + +test("cursor at start of single paragraph", () => { + // doc > paragraph(pos=0) > text "hello"(pos=1, size=5) + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 7 }, + { isText: true, isBlock: false, pos: 1, nodeSize: 5 }, + ]; + // Anchor at pos=1 → plain-text offset 0 + assert.equal(mapAnchorToPlainText(nodes, 1), 0); +}); + +test("cursor at end of single paragraph", () => { + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 7 }, + { isText: true, isBlock: false, pos: 1, nodeSize: 5 }, + ]; + // Anchor at pos=6 → plain-text offset 5 + assert.equal(mapAnchorToPlainText(nodes, 6), 5); +}); + +test("cursor mid-word in single paragraph", () => { + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 7 }, + { isText: true, isBlock: false, pos: 1, nodeSize: 5 }, + ]; + // Anchor at pos=3 → plain-text offset 2 (after "he") + assert.equal(mapAnchorToPlainText(nodes, 3), 2); +}); + +// ── Two paragraphs (the bug scenario) ───────────────────────────────── +// doc structure: doc > p1("hello") > p2("world") +// ProseMirror positions: doc=0, p1=0, "hello"=1..5, /p1=6, p2=7, "world"=8..12, /p2=13 +// textContent = "hello\nworld" (11 chars) + +test("cursor in second paragraph accounts for block boundary newline", () => { + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 7 }, // p1 + { isText: true, isBlock: false, pos: 1, nodeSize: 5 }, // "hello" + { isText: false, isBlock: true, pos: 7, nodeSize: 7 }, // p2 (pos > 0 → newline) + { isText: true, isBlock: false, pos: 8, nodeSize: 5 }, // "world" + ]; + // Anchor at pos=8 → start of "world" → plain-text offset 6 ("hello\n" = 6 chars) + assert.equal(mapAnchorToPlainText(nodes, 8), 6); +}); + +test("cursor mid-word in second paragraph", () => { + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 7 }, + { isText: true, isBlock: false, pos: 1, nodeSize: 5 }, + { isText: false, isBlock: true, pos: 7, nodeSize: 7 }, + { isText: true, isBlock: false, pos: 8, nodeSize: 5 }, + ]; + // Anchor at pos=10 → "wo|rld" → plain-text offset 8 ("hello\nwo" = 8 chars) + assert.equal(mapAnchorToPlainText(nodes, 10), 8); +}); + +// ── Three paragraphs (cumulative drift) ─────────────────────────────── +// "aaa\nbbb\nccc" — without the fix, offset would drift by 1 per boundary + +test("cursor in third paragraph accounts for two block boundaries", () => { + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 5 }, // p1 + { isText: true, isBlock: false, pos: 1, nodeSize: 3 }, // "aaa" + { isText: false, isBlock: true, pos: 5, nodeSize: 5 }, // p2 + { isText: true, isBlock: false, pos: 6, nodeSize: 3 }, // "bbb" + { isText: false, isBlock: true, pos: 10, nodeSize: 5 }, // p3 + { isText: true, isBlock: false, pos: 11, nodeSize: 3 }, // "ccc" + ]; + // Anchor at pos=11 → start of "ccc" → plain-text offset 8 ("aaa\nbbb\n" = 8 chars) + assert.equal(mapAnchorToPlainText(nodes, 11), 8); +}); + +test("cursor at end of third paragraph", () => { + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 5 }, + { isText: true, isBlock: false, pos: 1, nodeSize: 3 }, + { isText: false, isBlock: true, pos: 5, nodeSize: 5 }, + { isText: true, isBlock: false, pos: 6, nodeSize: 3 }, + { isText: false, isBlock: true, pos: 10, nodeSize: 5 }, + { isText: true, isBlock: false, pos: 11, nodeSize: 3 }, + ]; + // Anchor at pos=14 → end of "ccc" → plain-text offset 11 ("aaa\nbbb\nccc" = 11 chars) + assert.equal(mapAnchorToPlainText(nodes, 14), 11); +}); + +// ── First paragraph is unaffected (pos === 0, no newline) ───────────── + +test("first block boundary at pos 0 does not add newline", () => { + const nodes = [ + { isText: false, isBlock: true, pos: 0, nodeSize: 7 }, + { isText: true, isBlock: false, pos: 1, nodeSize: 5 }, + ]; + // Anchor at pos=4 → plain-text offset 3 (no extra newline for first block) + assert.equal(mapAnchorToPlainText(nodes, 4), 3); +}); diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts new file mode 100644 index 000000000..69d9b49a6 --- /dev/null +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -0,0 +1,352 @@ +import * as React from "react"; + +import { Markdown as TiptapMarkdown } from "tiptap-markdown"; +import { useEditor, type Editor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Placeholder from "@tiptap/extension-placeholder"; +import Link from "@tiptap/extension-link"; +import { Extension } from "@tiptap/core"; +import { TextSelection } from "@tiptap/pm/state"; + +import { ImageRefNode } from "./imageRefExtension"; +import { MentionHighlightExtension, mentionHighlightKey } from "./mentionHighlightExtension"; + +export type RichTextEditorOptions = { + placeholder?: string; + onUpdate?: (info: { markdown: string; text: string }) => void; + editable?: boolean; + mentionNames?: string[]; + channelNames?: string[]; +}; + +/** + * Creates and manages a Tiptap editor configured for Markdown output. + * + * The editor uses StarterKit (bold, italic, strike, code, blockquote, lists, + * headings, code blocks, hard breaks) plus Link and the tiptap-markdown + * extension for serialisation. + * + * `getMarkdown()` returns the current document as a Markdown string. + */ +export function useRichTextEditor({ + placeholder, + onUpdate, + editable = true, + mentionNames, + channelNames, +}: RichTextEditorOptions) { + const onUpdateRef = React.useRef(onUpdate); + onUpdateRef.current = onUpdate; + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + // Use hard breaks (Shift+Enter) — Enter submits the message. + hardBreak: { + keepMarks: true, + }, + // Disable heading input rules — in a chat composer, typing "# " + // should keep the literal "#", not convert to a heading node. + // Users type #channel-name and the "#" would get eaten otherwise. + heading: false, + // Disable the trailing-node plugin — it forces an empty paragraph + // after block nodes (lists, blockquotes, code blocks) which creates + // a phantom empty line in the compact message composer. + trailingNode: false, + }), + // Shift+Enter inside lists/blockquotes: split the node instead of + // inserting a hard break so continuation lines keep their formatting. + Extension.create({ + name: "smartShiftEnter", + addKeyboardShortcuts() { + // Exit a list by removing the empty last item and inserting a + // paragraph after the list. Works for both single-item and + // multi-item lists. + const exitListIfEmptyLast = (ed: typeof this.editor): boolean => { + if (!ed.isActive("listItem")) return false; + const { $from } = ed.state.selection; + + // Walk up to find the listItem node (handles nested structures). + let listItemDepth = -1; + for (let d = $from.depth; d >= 1; d--) { + if ($from.node(d).type.name === "listItem") { + listItemDepth = d; + break; + } + } + if (listItemDepth < 1) return false; + + const listItem = $from.node(listItemDepth); + const isEmpty = + listItem.childCount === 1 && + listItem.firstChild?.textContent === ""; + if (!isEmpty) return false; + + // Only trigger on the last item in the list. + const listDepth = listItemDepth - 1; + const list = $from.node(listDepth); + const itemIndex = $from.index(listDepth); + if (itemIndex !== list.childCount - 1) return false; + + const { tr, schema } = ed.state; + if (list.childCount === 1) { + // Only item → replace the entire list with an empty paragraph. + const listStart = $from.before(listDepth); + const listEnd = $from.after(listDepth); + const para = schema.nodes.paragraph.create(); + tr.replaceWith(listStart, listEnd, para); + tr.setSelection( + TextSelection.near(tr.doc.resolve(listStart + 1)), + ); + } else { + // Multiple items → delete the empty item, insert paragraph + // after the list, and move cursor there. + const itemStart = $from.before(listItemDepth); + const itemEnd = $from.after(listItemDepth); + tr.delete(itemStart, itemEnd); + const listEnd = tr.mapping.map($from.after(listDepth)); + const para = schema.nodes.paragraph.create(); + tr.insert(listEnd, para); + tr.setSelection( + TextSelection.near(tr.doc.resolve(listEnd + 1)), + ); + } + ed.view.dispatch(tr); + return true; + }; + + return { + "Shift-Enter": ({ editor: ed }) => { + // Empty last list item → exit list to paragraph below. + if (exitListIfEmptyLast(ed)) return true; + // Non-empty or non-last list item → split. + if (ed.isActive("listItem")) { + return ed.commands.splitListItem("listItem"); + } + if (ed.isActive("blockquote")) { + // Empty blockquote paragraph → exit the blockquote. + const { $from } = ed.state.selection; + if ($from.parent.textContent === "") { + return ed.commands.lift("blockquote"); + } + // Non-empty → split the paragraph within the blockquote. + return ed.chain().splitBlock().focus().run(); + } + // Default: hard break (StarterKit handles it). + return false; + }, + ArrowDown: ({ editor: ed }) => { + // Empty last list item + Down → exit list to paragraph below. + return exitListIfEmptyLast(ed); + }, + }; + }, + }), + MentionHighlightExtension, + Placeholder.configure({ + placeholder: placeholder ?? "Write a message…", + }), + Link.configure({ + openOnClick: false, + autolink: true, + linkOnPaste: true, + HTMLAttributes: { + class: "text-primary underline underline-offset-4 cursor-pointer", + }, + }), + TiptapMarkdown.configure({ + html: false, + transformPastedText: true, + transformCopiedText: true, + breaks: true, + }), + ImageRefNode, + ], + editorProps: { + attributes: { + class: + "min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-none prose-sm max-w-none", + "data-testid": "message-input", + }, + }, + onUpdate: ({ editor: ed }) => { + const markdown = getMarkdownFromEditor(ed); + const text = ed.state.doc.textContent; + onUpdateRef.current?.({ markdown, text }); + }, + }, + [placeholder], + ); + + // Toggle editable without destroying the editor instance. + React.useEffect(() => { + if (editor && editor.isEditable !== editable) { + editor.setEditable(editable); + } + }, [editor, editable]); + + // Keep mention/channel-highlight decorations in sync with known names. + // NOTE: We use `editor.storage.mentionHighlight` (the mutable storage object + // shared with the ProseMirror plugin closure) rather than finding the + // extension instance via extensionManager — the instance's `.storage` getter + // returns a fresh spread-copy on every access, so mutations are silently lost. + React.useEffect(() => { + if (!editor) return; + // biome-ignore lint/suspicious/noExplicitAny: TipTap's Storage type doesn't include dynamic extension keys + const storage = (editor.storage as any).mentionHighlight as + | { names: string[]; channelNames: string[] } + | undefined; + if (storage) { + storage.names = mentionNames ?? []; + storage.channelNames = channelNames ?? []; + // Force the plugin to re-decorate by dispatching a metadata transaction. + const { tr } = editor.state; + editor.view.dispatch(tr.setMeta(mentionHighlightKey, true)); + } + }, [editor, mentionNames, channelNames]); + + const getMarkdown = React.useCallback((): string => { + if (!editor) return ""; + return getMarkdownFromEditor(editor); + }, [editor]); + + const isEmpty = React.useCallback((): boolean => { + if (!editor) return true; + return editor.isEmpty; + }, [editor]); + + const clearContent = React.useCallback(() => { + editor?.commands.clearContent(true); + }, [editor]); + + const setContent = React.useCallback( + (markdown: string) => { + if (!editor) return; + editor.commands.setContent(markdown); + }, + [editor], + ); + + const focus = React.useCallback(() => { + editor?.commands.focus("end"); + }, [editor]); + + /** + * Replace editor content and append a trailing space that survives parsing. + * + * `setContent(markdown)` roundtrips through TipTap's markdown parser which + * strips trailing whitespace from text nodes. TipTap's `insertContent(" ")` + * also normalises it away. This method bypasses both by creating a raw + * ProseMirror text node and inserting it via a direct transaction — the + * only path that reliably preserves a literal trailing space. + * + * Used by mention and channel-link autocomplete insertion. + */ + const setContentWithTrailingSpace = React.useCallback( + (markdown: string) => { + if (!editor) return; + editor.commands.setContent(markdown); + // Insert a literal space via a raw ProseMirror transaction so it + // bypasses TipTap's content parser which strips trailing whitespace. + const { tr, schema, doc } = editor.state; + const endPos = doc.content.size - 1; // before the closing node token + const spaceNode = schema.text(" "); + tr.insert(endPos, spaceNode); + // Place cursor after the inserted space. + const cursorPos = endPos + spaceNode.nodeSize; + tr.setSelection(TextSelection.create(tr.doc, cursorPos)); + editor.view.dispatch(tr); + editor.view.focus(); + }, + [editor], + ); + + /** + * Returns the plain-text content and an approximate cursor offset. + * Used to bridge the existing useMentions / useChannelLinks hooks which + * were designed for a plain