From 78725d1e88faf44f070bc57f199cb5cefc9712d8 Mon Sep 17 00:00:00 2001 From: codytseng Date: Fri, 23 May 2025 22:47:31 +0800 Subject: [PATCH] refactor: post editor --- package-lock.json | 711 +++++++++++++++++- package.json | 10 +- src/components/Content/index.tsx | 7 +- src/components/Embedded/EmbeddedMention.tsx | 7 +- .../PostEditor/PicturePostContent.tsx | 220 ------ ...{NormalPostContent.tsx => PostContent.tsx} | 92 +-- .../PostEditor/PostEditorProvider.tsx | 26 + .../PostEditor/PostTextarea/CustomMention.ts | 92 +++ .../PostEditor/PostTextarea/FileHandler.ts | 155 ++++ .../PostEditor/PostTextarea/MentionList.tsx | 100 +++ .../PostEditor/PostTextarea/MentionNode.tsx | 20 + .../PostEditor/{ => PostTextarea}/Preview.tsx | 2 +- .../PostEditor/PostTextarea/index.tsx | 110 +++ .../PostEditor/PostTextarea/suggestion.ts | 101 +++ src/components/PostEditor/Uploader.tsx | 5 +- src/components/PostEditor/index.tsx | 39 +- src/components/PostEditor/utils.ts | 13 - src/components/PostTextarea/index.tsx | 279 ------- src/components/PostTextarea/utils.ts | 67 -- src/index.css | 9 + src/lib/draft-event.ts | 13 +- src/lib/event.ts | 4 +- src/lib/pubkey.ts | 7 + src/lib/tiptap.ts | 40 + src/providers/MediaUploadServiceProvider.tsx | 60 +- src/providers/NostrProvider/index.tsx | 3 +- src/services/client.service.ts | 10 +- src/services/media-upload.service.ts | 99 +++ src/services/post-content-cache.service.ts | 44 +- src/services/post-editor.service.ts | 23 + src/types.ts | 1 + 31 files changed, 1603 insertions(+), 766 deletions(-) delete mode 100644 src/components/PostEditor/PicturePostContent.tsx rename src/components/PostEditor/{NormalPostContent.tsx => PostContent.tsx} (64%) create mode 100644 src/components/PostEditor/PostEditorProvider.tsx create mode 100644 src/components/PostEditor/PostTextarea/CustomMention.ts create mode 100644 src/components/PostEditor/PostTextarea/FileHandler.ts create mode 100644 src/components/PostEditor/PostTextarea/MentionList.tsx create mode 100644 src/components/PostEditor/PostTextarea/MentionNode.tsx rename src/components/PostEditor/{ => PostTextarea}/Preview.tsx (89%) create mode 100644 src/components/PostEditor/PostTextarea/index.tsx create mode 100644 src/components/PostEditor/PostTextarea/suggestion.ts delete mode 100644 src/components/PostEditor/utils.ts delete mode 100644 src/components/PostTextarea/index.tsx delete mode 100644 src/components/PostTextarea/utils.ts create mode 100644 src/lib/tiptap.ts create mode 100644 src/services/media-upload.service.ts create mode 100644 src/services/post-editor.service.ts diff --git a/package-lock.json b/package-lock.json index 58c87dbd..225e22b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,13 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", + "@tiptap/extension-history": "^2.12.0", + "@tiptap/extension-mention": "^2.12.0", + "@tiptap/extension-placeholder": "^2.12.0", + "@tiptap/pm": "^2.12.0", + "@tiptap/react": "^2.12.0", + "@tiptap/starter-kit": "^2.12.0", + "@tiptap/suggestion": "^2.12.0", "@webbtc/webln-types": "^3.0.0", "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", @@ -49,6 +56,7 @@ "react-simple-pull-to-refresh": "^1.3.3", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "tippy.js": "^6.3.7", "vaul": "^1.1.2", "yet-another-react-lightbox": "^3.21.7", "zod": "^3.24.1" @@ -2421,6 +2429,15 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.0.tgz", @@ -3391,6 +3408,11 @@ "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==" }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==" + }, "node_modules/@rollup/plugin-node-resolve": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz", @@ -3810,6 +3832,406 @@ "string.prototype.matchall": "^4.0.6" } }, + "node_modules/@tiptap/core": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.12.0.tgz", + "integrity": "sha512-3qX8oGVKFFZzQ0vit+ZolR6AJIATBzmEmjAA0llFhWk4vf3v64p1YcXcJsOBsr5scizJu5L6RYWEFatFwqckRg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.12.0.tgz", + "integrity": "sha512-XUC2A77YAPMJS2SqZ2S62IGcUH8gZ7cdhoWlYQb1pR4ZzXFByeKDJPxfYeAePSiuI01YGrlzgY2c6Ncx/DtO0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.12.0.tgz", + "integrity": "sha512-lAUtoLDLRc5ofD2I9MFY6MQ7d1qBLLqS1rvpwaPjOaoQb/GPVnaHj9qXYG0SY9K3erMtto48bMFpAcscjZHzZQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.12.0.tgz", + "integrity": "sha512-DYijoE0igV0Oi+ZppFsp2UrQsM/4HZtmmpD78BJM9zfCbd1YvAUIxmzmXr8uqU18OHd1uQy+/zvuNoUNYyw67g==", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.12.0.tgz", + "integrity": "sha512-YTCjztB8MaIpwyxFYr81H4+LdKCq1VlaSXQyrPdB44mVdhhRqc46BYQb8/B//XE3UIu3X2QWFjwrqRlUq6vUiw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.12.0.tgz", + "integrity": "sha512-R7RaS+hJeHFim7alImQ9L9CSWSMjWXvz0Ote568x9ea5gdBGUYW8PcH+5a91lh8e1XGYWBM12a8oJZRyxg/tQA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.12.0.tgz", + "integrity": "sha512-1D7cYAjgxEFHdfC/35Ooi4GqWKB5sszbW8iI7N16XILNln26xb0d5KflXqYrwr9CN/ZnZoCl2o6YsP7xEObcZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.12.0.tgz", + "integrity": "sha512-sA1Q+mxDIv0Y3qQTBkYGwknNbDcGFiJ/fyAFholXpqbrcRx3GavwR/o0chBdsJZlFht0x7AWGwUYWvIo7wYilA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.12.0.tgz", + "integrity": "sha512-zcZSOXFj+7LVnmdPWTfKr5AoxYIzFPFlLJe35AdTQC5IhkljLn1Exct8I30ZREojX/00hKYsO7JJmePS6TEVlQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.12.0.tgz", + "integrity": "sha512-BYpyZx/56KCDksWuJJbhki/uNgt9sACuSSZFH5AN1yS1ISD+EzIxqf6Pzzv8QCoNJ+KcRNVaZsOlOFaJGoyzag==", + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.12.0.tgz", + "integrity": "sha512-k8ji5v9YKn7bNjo8UtI9hEfXfl4tKUp1hpJOEmUxGJQa3LIwrwSbReupUTnHszGQelzxikS/l1xO9P0TIGwRoA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.12.0.tgz", + "integrity": "sha512-08MNS2PK5DzdnAfqXn4krmJ/xebKmWpRpYqqN5EM8AvetYKlAJyTVSpo0ZUeGbZ3EZiPm9djgSnrLqpFUDjRCg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.12.0.tgz", + "integrity": "sha512-9DfES4Wd5TX1foI70N9sAL+35NN1UHrtzDYN2+dTHupnmKir9RaMXyZcbkUb4aDVzYrGxIqxJzHBVkquKIlTrw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.12.0.tgz", + "integrity": "sha512-+B9CAf2BFURC6mQiM1OQtahVTzdEOEgT/UUNlRZkeeBc0K5of3dr6UdBqaoaMAefja3jx5PqiQ7mhUBAjSt6AA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.12.0.tgz", + "integrity": "sha512-Vi2+6RIehDSpoJn/7PDuOieUj7W7WrEb4wBxK9TG8PDscihR0mehhhzm/K2xhH4TN48iPJGRsjDFrFjTbXmcnw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.12.0.tgz", + "integrity": "sha512-JKcXK3LmEsmxNzEq5e06rPUGMRLUxmJ2mYtBY4NlJ6yLM9XMDljtgeTnWT0ySLYmfINSFTkX4S7WIRbpl9l4pw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.12.0.tgz", + "integrity": "sha512-4YwZooC8HP+gPxs6YrkB1ayggyYbgVvJx/rWBT6lKSW2MVVg8QXi1zAcSI3MhIhHmqDysXXFPL8JURlbeGjaFA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-mention": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-2.12.0.tgz", + "integrity": "sha512-+b/fqOU+pRWWAo0ZfyInkhkvV0Ub5RpNrYZ45v2nn5PjbXbxyxNQ51zT6cGk2F6Jmc6UBmlR8iqqNTIQY9ieEg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "@tiptap/suggestion": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.12.0.tgz", + "integrity": "sha512-1ys0e/oqk09oXxrB1WzAx5EntK/QreObG/V1yhgihGm429fxHMsxzIYN6dKAYxx0YOPQG7qEZRrrPuWU70Ms7g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.12.0.tgz", + "integrity": "sha512-QNK5cgewCunWFxpLlbvvoO1rrLgEtNKxiY79fctP9toV+e59R+1i1Q9lXC1O5mOfDgVxCb6uFDMsqmKhFjpPog==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-placeholder": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.12.0.tgz", + "integrity": "sha512-K7irDox4P+NLAMjVrJeG72f0sulsCRYpx1Cy4gxKCdi1LTivj5VkXa6MXmi42KTCwBu3pWajBctYIOAES1FTAA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.12.0.tgz", + "integrity": "sha512-nBaa5YtBsLJPZFfSs36sBz4Zgi/c8b3MsmS/Az8uXaHb0R9yPewOVUMDIQbxMct8SXUlIo9VtKlOL+mVJ3Nkpw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.12.0.tgz", + "integrity": "sha512-0ytN9V1tZYTXdiYDQg4FB2SQ56JAJC9r/65snefb9ztl+gZzDrIvih7CflHs1ic9PgyjexfMLeH+VzuMccNyZw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.12.0.tgz", + "integrity": "sha512-Pxwt23ZlvbQUahV0PvHy8Ej6IAuKR1FvHobUvwP3T8AiY7hob66fWRe7tQbESzSAzm5Vv2xkvyHeU8vekMTezA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.12.0.tgz", + "integrity": "sha512-TNzVwpeNzFfHAcYTOKqX9iU4fRxliyoZrCnERR+RRzeg7gWrXrCLubQt1WEx0sojMAfznshSL3M5HGsYjEbYwA==", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/react": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.12.0.tgz", + "integrity": "sha512-D+PR+4kJO9h8AB/7XyQ/Anw8tqeS2ecv5QemBOCHi9JlMAjytauUrj6IfFBO9RbsCowlBjW5GnSpFhzpk2Gghg==", + "dependencies": { + "@tiptap/extension-bubble-menu": "^2.12.0", + "@tiptap/extension-floating-menu": "^2.12.0", + "@types/use-sync-external-store": "^0.0.6", + "fast-deep-equal": "^3", + "use-sync-external-store": "^1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.12.0.tgz", + "integrity": "sha512-wlcEEtexd6u0gbR311/OFZnbtRWU97DUsY6/GsSQzN4rqZ7Ra6YbfHEN5Lutu+I/anomK8vKy8k9NyvfY5Hllg==", + "dependencies": { + "@tiptap/core": "^2.12.0", + "@tiptap/extension-blockquote": "^2.12.0", + "@tiptap/extension-bold": "^2.12.0", + "@tiptap/extension-bullet-list": "^2.12.0", + "@tiptap/extension-code": "^2.12.0", + "@tiptap/extension-code-block": "^2.12.0", + "@tiptap/extension-document": "^2.12.0", + "@tiptap/extension-dropcursor": "^2.12.0", + "@tiptap/extension-gapcursor": "^2.12.0", + "@tiptap/extension-hard-break": "^2.12.0", + "@tiptap/extension-heading": "^2.12.0", + "@tiptap/extension-history": "^2.12.0", + "@tiptap/extension-horizontal-rule": "^2.12.0", + "@tiptap/extension-italic": "^2.12.0", + "@tiptap/extension-list-item": "^2.12.0", + "@tiptap/extension-ordered-list": "^2.12.0", + "@tiptap/extension-paragraph": "^2.12.0", + "@tiptap/extension-strike": "^2.12.0", + "@tiptap/extension-text": "^2.12.0", + "@tiptap/extension-text-style": "^2.12.0", + "@tiptap/pm": "^2.12.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/suggestion": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.12.0.tgz", + "integrity": "sha512-bsXLoZbjUo1oOF1Z+XSfoGzbcnrTcYtJdfylM/FerMLU9T12dhsM/Ri2SKLX4IR5D0HJ07FcsEHCrGEy8Y5y0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3863,6 +4285,25 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==" + }, "node_modules/@types/node": { "version": "22.10.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", @@ -3909,6 +4350,11 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "dev": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.1.tgz", @@ -4232,8 +4678,7 @@ "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/aria-hidden": { "version": "1.2.4", @@ -5091,6 +5536,11 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5361,6 +5811,17 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.23.7", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.7.tgz", @@ -5535,7 +5996,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -5719,8 +6179,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -6933,6 +7392,14 @@ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -7008,6 +7475,22 @@ "sourcemap-codec": "^1.4.8" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7017,6 +7500,11 @@ "node": ">= 0.4" } }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -7248,6 +7736,11 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -7567,6 +8060,183 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prosemirror-changeset": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.3.0.tgz", + "integrity": "sha512-8wRKhlEwEJ4I13Ju54q2NZR1pVKGTgJ/8XsQ8L5A5uUsQ/YQScQJuEAuh8Bn8i6IwAMjjLRABd9lVli+DlIiVw==", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz", + "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz", + "integrity": "sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz", + "integrity": "sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.0.tgz", + "integrity": "sha512-K0xJRCmt+uSw7xesnHmcn72yBGTbY45vm8gXI4LZXbx2Z0jwh5aF9xrGQgrVPu0WbyFVFF3E/o9VhJYz6SQWnA==", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.2.tgz", + "integrity": "sha512-FPD9rHPdA9fqzNmIIDhhnYQ6WgNoSWX9StUZ8LEKapaXU9i6XgykaHKhp6XMyXlOWetmaFgGDS/nu/w9/vUc5g==", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.5.tgz", + "integrity": "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ==", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.1", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz", + "integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz", + "integrity": "sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.7.1.tgz", + "integrity": "sha512-eRQ97Bf+i9Eby99QbyAiyov43iOKgWa7QCGly+lrDt7efZ1v8NWolhXiB43hSDGIXT1UXgbs4KJN3a06FGpr1Q==", + "dependencies": { + "prosemirror-keymap": "^1.2.2", + "prosemirror-model": "^1.25.0", + "prosemirror-state": "^1.4.3", + "prosemirror-transform": "^1.10.3", + "prosemirror-view": "^1.39.1" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.10.4.tgz", + "integrity": "sha512-pwDy22nAnGqNR1feOQKHxoFkkUtepoFAd3r2hbEDsnf4wp57kKA36hXsB3njA9FtONBEwSDnDeCiJe+ItD+ykw==", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.39.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.39.2.tgz", + "integrity": "sha512-BmOkml0QWNob165gyUxXi5K5CVUgVPpqMEAAml/qzgKn9boLUWVPzQ6LtzXw8Cn1GtRQX4ELumPxqtLTDaAKtg==", + "dependencies": { + "prosemirror-model": "^1.20.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7576,6 +8246,14 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode-generator": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz", @@ -7967,6 +8645,11 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -8669,6 +9352,14 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8844,6 +9535,11 @@ "typescript": ">=4.8.4 <5.8.0" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -9153,6 +9849,11 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "node_modules/webidl-conversions": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", diff --git a/package.json b/package.json index 2b3e3c00..ac29732a 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,13 @@ "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.2", "@radix-ui/react-toast": "^1.2.4", + "@tiptap/extension-history": "^2.12.0", + "@tiptap/extension-mention": "^2.12.0", + "@tiptap/extension-placeholder": "^2.12.0", + "@tiptap/pm": "^2.12.0", + "@tiptap/react": "^2.12.0", + "@tiptap/starter-kit": "^2.12.0", + "@tiptap/suggestion": "^2.12.0", "@webbtc/webln-types": "^3.0.0", "blurhash": "^2.0.5", "class-variance-authority": "^0.7.1", @@ -59,6 +66,7 @@ "react-simple-pull-to-refresh": "^1.3.3", "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", + "tippy.js": "^6.3.7", "vaul": "^1.1.2", "yet-another-react-lightbox": "^3.21.7", "zod": "^3.24.1" @@ -82,4 +90,4 @@ "vite": "^6.0.3", "vite-plugin-pwa": "^0.21.1" } -} \ No newline at end of file +} diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index 7e252f8a..33859bd8 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -12,6 +12,7 @@ import { import { extractEmojiInfosFromTags, isNsfwEvent } from '@/lib/event' import { extractImageInfoFromTag } from '@/lib/tag' import { cn } from '@/lib/utils' +import mediaUpload from '@/services/media-upload.service' import { TImageInfo } from '@/types' import { Event } from 'nostr-tools' import { memo } from 'react' @@ -46,7 +47,11 @@ const Content = memo(({ event, className }: { event: Event; className?: string } .map((node) => { if (node.type === 'image') { const imageInfo = imageInfos.find((image) => image.url === node.data) - return imageInfo ?? { url: node.data } + if (imageInfo) { + return imageInfo + } + const tag = mediaUpload.getImetaTagByUrl(node.data) + return tag ? extractImageInfoFromTag(tag) : { url: node.data } } if (node.type === 'images') { const urls = Array.isArray(node.data) ? node.data : [node.data] diff --git a/src/components/Embedded/EmbeddedMention.tsx b/src/components/Embedded/EmbeddedMention.tsx index eb5eb394..2faaed94 100644 --- a/src/components/Embedded/EmbeddedMention.tsx +++ b/src/components/Embedded/EmbeddedMention.tsx @@ -1,3 +1,4 @@ +import { cn } from '@/lib/utils' import Username, { SimpleUsername } from '../Username' export function EmbeddedMention({ userId }: { userId: string }) { @@ -6,6 +7,8 @@ export function EmbeddedMention({ userId }: { userId: string }) { ) } -export function EmbeddedMentionText({ userId }: { userId: string }) { - return +export function EmbeddedMentionText({ userId, className }: { userId: string; className?: string }) { + return ( + + ) } diff --git a/src/components/PostEditor/PicturePostContent.tsx b/src/components/PostEditor/PicturePostContent.tsx deleted file mode 100644 index 7209ab89..00000000 --- a/src/components/PostEditor/PicturePostContent.tsx +++ /dev/null @@ -1,220 +0,0 @@ -import { Button } from '@/components/ui/button' -import { useToast } from '@/hooks/use-toast' -import { createPictureNoteDraftEvent } from '@/lib/draft-event' -import { cn } from '@/lib/utils' -import { useNostr } from '@/providers/NostrProvider' -import postContentCache from '@/services/post-content-cache.service' -import { ChevronDown, Loader, LoaderCircle, Plus, X } from 'lucide-react' -import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import Image from '../Image' -import PostTextarea from '../PostTextarea' -import Mentions from './Mentions' -import PostOptions from './PostOptions' -import SendOnlyToSwitch from './SendOnlyToSwitch' -import Uploader from './Uploader' -import { preprocessContent } from './utils' - -export default function PicturePostContent({ close }: { close: () => void }) { - const { t } = useTranslation() - const { toast } = useToast() - const { publish, checkLogin } = useNostr() - const [content, setContent] = useState('') - const [processedContent, setProcessedContent] = useState('') - const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([]) - const [posting, setPosting] = useState(false) - const [showMoreOptions, setShowMoreOptions] = useState(false) - const [addClientTag, setAddClientTag] = useState(false) - const [mentions, setMentions] = useState([]) - const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState(undefined) - const initializedRef = useRef(false) - const canPost = !!content && !posting && pictureInfos.length > 0 - - useEffect(() => { - const { content, pictureInfos } = postContentCache.getPicturePostCache() - setContent(content) - setPictureInfos(pictureInfos) - setTimeout(() => { - initializedRef.current = true - }, 100) - }, []) - - useEffect(() => { - setProcessedContent(preprocessContent(content)) - if (!initializedRef.current) return - postContentCache.setPicturePostCache(content, pictureInfos) - }, [content, pictureInfos]) - - const post = async (e: React.MouseEvent) => { - e.stopPropagation() - checkLogin(async () => { - if (!canPost) { - close() - return - } - - setPosting(true) - try { - if (!pictureInfos.length) { - throw new Error(t('Picture note requires images')) - } - const draftEvent = await createPictureNoteDraftEvent( - processedContent, - pictureInfos, - mentions, - { - addClientTag, - protectedEvent: !!specifiedRelayUrls - } - ) - await publish(draftEvent, { specifiedRelayUrls }) - setContent('') - setPictureInfos([]) - close() - } catch (error) { - if (error instanceof AggregateError) { - error.errors.forEach((e) => - toast({ - variant: 'destructive', - title: t('Failed to post'), - description: e.message - }) - ) - } else if (error instanceof Error) { - toast({ - variant: 'destructive', - title: t('Failed to post'), - description: error.message - }) - } - console.error(error) - return - } finally { - setPosting(false) - } - toast({ - title: t('Post successful'), - description: t('Your post has been published') - }) - }) - } - - return ( -
-
- {t('A special note for picture-first clients like Olas')} -
- - - -
- -
- -
- - -
-
-
- -
- - -
-
- ) -} - -function PictureUploader({ - pictureInfos, - setPictureInfos -}: { - pictureInfos: { url: string; tags: string[][] }[] - setPictureInfos: Dispatch< - SetStateAction< - { - url: string - tags: string[][] - }[] - > - > -}) { - const [uploading, setUploading] = useState(false) - - return ( -
- {pictureInfos.map(({ url }, index) => ( -
- - -
- ))} - { - setPictureInfos((prev) => [...prev, { url, tags }]) - }} - onUploadingChange={setUploading} - > -
- {uploading ? : } -
-
-
- ) -} diff --git a/src/components/PostEditor/NormalPostContent.tsx b/src/components/PostEditor/PostContent.tsx similarity index 64% rename from src/components/PostEditor/NormalPostContent.tsx rename to src/components/PostEditor/PostContent.tsx index 7b4808f8..ab125122 100644 --- a/src/components/PostEditor/NormalPostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -1,24 +1,22 @@ import Note from '@/components/Note' import { Button } from '@/components/ui/button' import { ScrollArea } from '@/components/ui/scroll-area' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useToast } from '@/hooks/use-toast' import { createCommentDraftEvent, createShortTextNoteDraftEvent } from '@/lib/draft-event' import { useNostr } from '@/providers/NostrProvider' import postContentCache from '@/services/post-content-cache.service' import { ChevronDown, ImageUp, LoaderCircle } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import { useEffect, useRef, useState } from 'react' +import { useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import PostTextarea from '../PostTextarea' import Mentions from './Mentions' +import { usePostEditor } from './PostEditorProvider' import PostOptions from './PostOptions' -import Preview from './Preview' +import PostTextarea, { TPostTextareaHandle } from './PostTextarea' import SendOnlyToSwitch from './SendOnlyToSwitch' import Uploader from './Uploader' -import { preprocessContent } from './utils' -export default function NormalPostContent({ +export default function PostContent({ defaultContent = '', parentEvent, close @@ -30,38 +28,15 @@ export default function NormalPostContent({ const { t } = useTranslation() const { toast } = useToast() const { publish, checkLogin } = useNostr() - const [content, setContent] = useState('') - const [processedContent, setProcessedContent] = useState('') - const [pictureInfos, setPictureInfos] = useState<{ url: string; tags: string[][] }[]>([]) + const { uploadingFiles, setUploadingFiles } = usePostEditor() + const [text, setText] = useState('') + const textareaRef = useRef(null) const [posting, setPosting] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false) const [addClientTag, setAddClientTag] = useState(false) const [specifiedRelayUrls, setSpecifiedRelayUrls] = useState(undefined) - const [uploadingPicture, setUploadingPicture] = useState(false) const [mentions, setMentions] = useState([]) - const [cursorOffset, setCursorOffset] = useState(0) - const initializedRef = useRef(false) - const canPost = !!content && !posting - - useEffect(() => { - const cached = postContentCache.getNormalPostCache({ defaultContent, parentEvent }) - if (cached) { - setContent(cached.content || '') - setPictureInfos(cached.pictureInfos || []) - } - if (defaultContent) { - setCursorOffset(defaultContent.length) - } - setTimeout(() => { - initializedRef.current = true - }, 100) - }, [defaultContent, parentEvent]) - - useEffect(() => { - setProcessedContent(preprocessContent(content)) - if (!initializedRef.current) return - postContentCache.setNormalPostCache({ defaultContent, parentEvent }, content, pictureInfos) - }, [content, pictureInfos]) + const canPost = !!text && !posting && !uploadingFiles const post = async (e: React.MouseEvent) => { e.stopPropagation() @@ -75,17 +50,17 @@ export default function NormalPostContent({ try { const draftEvent = parentEvent && parentEvent.kind !== kinds.ShortTextNote - ? await createCommentDraftEvent(processedContent, parentEvent, pictureInfos, mentions, { + ? await createCommentDraftEvent(text, parentEvent, mentions, { addClientTag, protectedEvent: !!specifiedRelayUrls }) - : await createShortTextNoteDraftEvent(processedContent, pictureInfos, mentions, { + : await createShortTextNoteDraftEvent(text, mentions, { parentEvent, addClientTag, protectedEvent: !!specifiedRelayUrls }) await publish(draftEvent, { specifiedRelayUrls }) - setContent('') + postContentCache.clearPostCache({ defaultContent, parentEvent }) close() } catch (error) { if (error instanceof AggregateError) { @@ -124,29 +99,13 @@ export default function NormalPostContent({ )} - - - {t('Edit')} - {t('Preview')} - - - { - setPictureInfos((prev) => [...prev, { url, tags }]) - }} - /> - - - - - +
{ - setPictureInfos((prev) => [...prev, { url, tags }]) - setContent((prev) => (prev === '' ? url : `${prev}\n${url}`)) + onUploadSuccess={({ url }) => { + textareaRef.current?.appendText(url) }} - onUploadingChange={setUploadingPicture} + onUploadingChange={(uploading) => + setUploadingFiles((prev) => (uploading ? prev + 1 : prev - 1)) + } accept="image/*,video/*,audio/*" > - + ))} + + ) +}) +MentionList.displayName = 'MentionList' +export default MentionList diff --git a/src/components/PostEditor/PostTextarea/MentionNode.tsx b/src/components/PostEditor/PostTextarea/MentionNode.tsx new file mode 100644 index 00000000..a83991f6 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/MentionNode.tsx @@ -0,0 +1,20 @@ +import { useFetchProfile } from '@/hooks' +import { formatUserId } from '@/lib/pubkey' +import { cn } from '@/lib/utils' +import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react' + +export default function MentionNode(props: NodeViewRendererProps & { selected: boolean }) { + const { profile } = useFetchProfile(props.node.attrs.id) + + return ( + + {'@'} + {profile ? profile.username : formatUserId(props.node.attrs.id)} + + ) +} diff --git a/src/components/PostEditor/Preview.tsx b/src/components/PostEditor/PostTextarea/Preview.tsx similarity index 89% rename from src/components/PostEditor/Preview.tsx rename to src/components/PostEditor/PostTextarea/Preview.tsx index 85db71db..11c22a13 100644 --- a/src/components/PostEditor/Preview.tsx +++ b/src/components/PostEditor/PostTextarea/Preview.tsx @@ -1,6 +1,6 @@ import { Card } from '@/components/ui/card' import { createFakeEvent } from '@/lib/event' -import Content from '../Content' +import Content from '../../Content' export default function Preview({ content }: { content: string }) { return ( diff --git a/src/components/PostEditor/PostTextarea/index.tsx b/src/components/PostEditor/PostTextarea/index.tsx new file mode 100644 index 00000000..09ff27c1 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/index.tsx @@ -0,0 +1,110 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { parseEditorJsonToText } from '@/lib/tiptap' +import postContentCache from '@/services/post-content-cache.service' +import Document from '@tiptap/extension-document' +import History from '@tiptap/extension-history' +import Paragraph from '@tiptap/extension-paragraph' +import Placeholder from '@tiptap/extension-placeholder' +import Text from '@tiptap/extension-text' +import { TextSelection } from '@tiptap/pm/state' +import { EditorContent, useEditor } from '@tiptap/react' +import { Event } from 'nostr-tools' +import { Dispatch, forwardRef, SetStateAction, useImperativeHandle } from 'react' +import { useTranslation } from 'react-i18next' +import CustomMention from './CustomMention' +import { FileHandler } from './FileHandler' +import Preview from './Preview' +import suggestion from './suggestion' +import { usePostEditor } from '../PostEditorProvider' + +export type TPostTextareaHandle = { + appendText: (text: string) => void +} + +const PostTextarea = forwardRef< + TPostTextareaHandle, + { + text: string + setText: Dispatch> + defaultContent?: string + parentEvent?: Event + } +>(({ text = '', setText, defaultContent, parentEvent }, ref) => { + const { t } = useTranslation() + const { setUploadingFiles } = usePostEditor() + const editor = useEditor({ + extensions: [ + Document, + Paragraph, + Text, + History, + Placeholder.configure({ + placeholder: t('Write something...') + ' (' + t('Paste or drop media files to upload') + ')' + }), + CustomMention.configure({ + suggestion + }), + FileHandler.configure({ + onUploadStart: () => setUploadingFiles((prev) => prev + 1), + onUploadSuccess: () => setUploadingFiles((prev) => prev - 1), + onUploadError: () => setUploadingFiles((prev) => prev - 1) + }) + ], + editorProps: { + attributes: { + class: + 'border rounded-lg p-3 min-h-52 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring' + } + }, + content: postContentCache.getPostCache({ defaultContent, parentEvent }), + onUpdate(props) { + setText(parseEditorJsonToText(props.editor.getJSON())) + postContentCache.setPostCache({ defaultContent, parentEvent }, props.editor.getJSON()) + }, + onCreate(props) { + setText(parseEditorJsonToText(props.editor.getJSON())) + } + }) + + useImperativeHandle(ref, () => ({ + appendText: (text: string) => { + if (editor) { + editor + .chain() + .focus() + .command(({ tr, dispatch }) => { + if (dispatch) { + const endPos = tr.doc.content.size + const selection = TextSelection.create(tr.doc, endPos) + tr.setSelection(selection) + dispatch(tr) + } + return true + }) + .insertContent(text) + .run() + } + } + })) + + if (!editor) { + return null + } + + return ( + + + {t('Edit')} + {t('Preview')} + + + + + + + + + ) +}) +PostTextarea.displayName = 'PostTextarea' +export default PostTextarea diff --git a/src/components/PostEditor/PostTextarea/suggestion.ts b/src/components/PostEditor/PostTextarea/suggestion.ts new file mode 100644 index 00000000..bc32bde6 --- /dev/null +++ b/src/components/PostEditor/PostTextarea/suggestion.ts @@ -0,0 +1,101 @@ +import client from '@/services/client.service' +import postEditor from '@/services/post-editor.service' +import type { Editor } from '@tiptap/core' +import { ReactRenderer } from '@tiptap/react' +import { SuggestionKeyDownProps } from '@tiptap/suggestion' +import tippy, { GetReferenceClientRect, Instance, Props } from 'tippy.js' +import MentionList, { MentionListHandle, MentionListProps } from './MentionList' + +const suggestion = { + items: async ({ query }: { query: string }) => { + return await client.searchNpubs(query, 20) + }, + + render: () => { + let component: ReactRenderer + let popup: Instance[] + let touchListener: (e: TouchEvent) => void + let closePopup: () => void + + return { + onBeforeStart: () => { + touchListener = (e: TouchEvent) => { + if (popup && popup[0] && postEditor.isSuggestionPopupOpen) { + const popupElement = popup[0].popper + if (popupElement && !popupElement.contains(e.target as Node)) { + popup[0].hide() + } + } + } + document.addEventListener('touchstart', touchListener) + + closePopup = () => { + console.log('closePopup') + if (popup && popup[0]) { + popup[0].hide() + } + } + postEditor.addEventListener('closeSuggestionPopup', closePopup) + }, + onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor + }) + + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect as GetReferenceClientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + hideOnClick: true, + touch: true, + onShow() { + postEditor.isSuggestionPopupOpen = true + }, + onHide() { + postEditor.isSuggestionPopupOpen = false + } + }) + }, + + onUpdate(props: { clientRect?: (() => DOMRect | null) | null | undefined }) { + component.updateProps(props) + + if (!props.clientRect) { + return + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect + } as Partial) + }, + + onKeyDown(props: SuggestionKeyDownProps) { + if (props.event.key === 'Escape') { + popup[0].hide() + return true + } + return component.ref?.onKeyDown(props) ?? false + }, + + onExit() { + postEditor.isSuggestionPopupOpen = false + popup[0].destroy() + component.destroy() + + document.removeEventListener('touchstart', touchListener) + postEditor.removeEventListener('closeSuggestionPopup', closePopup) + } + } + } +} + +export default suggestion diff --git a/src/components/PostEditor/Uploader.tsx b/src/components/PostEditor/Uploader.tsx index 5fed685e..a80b8eb3 100644 --- a/src/components/PostEditor/Uploader.tsx +++ b/src/components/PostEditor/Uploader.tsx @@ -1,5 +1,5 @@ import { useToast } from '@/hooks/use-toast' -import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider' +import mediaUpload from '@/services/media-upload.service' import { useRef } from 'react' export default function Uploader({ @@ -16,7 +16,6 @@ export default function Uploader({ accept?: string }) { const { toast } = useToast() - const { upload } = useMediaUploadService() const fileInputRef = useRef(null) const handleFileChange = async (event: React.ChangeEvent) => { @@ -25,7 +24,7 @@ export default function Uploader({ onUploadingChange?.(true) try { for (const file of event.target.files) { - const result = await upload(file) + const result = await mediaUpload.upload(file) console.log('File uploaded successfully', result) onUploadSuccess(result) } diff --git a/src/components/PostEditor/index.tsx b/src/components/PostEditor/index.tsx index 937ea402..9e976ce7 100644 --- a/src/components/PostEditor/index.tsx +++ b/src/components/PostEditor/index.tsx @@ -14,9 +14,11 @@ import { SheetTitle } from '@/components/ui/sheet' import { useScreenSize } from '@/providers/ScreenSizeProvider' +import postEditor from '@/services/post-editor.service' import { Event } from 'nostr-tools' import { Dispatch, useMemo } from 'react' -import NormalPostContent from './NormalPostContent' +import PostContent from './PostContent' +import { PostEditorProvider } from './PostEditorProvider' import Title from './Title' export default function PostEditor({ @@ -34,18 +36,30 @@ export default function PostEditor({ const content = useMemo(() => { return ( - setOpen(false)} - /> + + setOpen(false)} + /> + ) }, []) if (isSmallScreen) { return ( - + { + if (postEditor.isSuggestionPopupOpen) { + e.preventDefault() + postEditor.closeSuggestionPopup() + } + }} + >
@@ -64,7 +78,16 @@ export default function PostEditor({ return ( - + { + if (postEditor.isSuggestionPopupOpen) { + e.preventDefault() + postEditor.closeSuggestionPopup() + } + }} + >
diff --git a/src/components/PostEditor/utils.ts b/src/components/PostEditor/utils.ts deleted file mode 100644 index 98dfe718..00000000 --- a/src/components/PostEditor/utils.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { nip19 } from 'nostr-tools' - -export function preprocessContent(content: string) { - const regex = /(?<=^|\s)(nevent|naddr|nprofile|npub)[a-zA-Z0-9]+/g - return content.replace(regex, (match) => { - try { - nip19.decode(match) - return `nostr:${match}` - } catch { - return match - } - }) -} diff --git a/src/components/PostTextarea/index.tsx b/src/components/PostTextarea/index.tsx deleted file mode 100644 index c706052d..00000000 --- a/src/components/PostTextarea/index.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { Command, CommandInput, CommandItem, CommandList } from '@/components/ui/command' -import { Textarea } from '@/components/ui/textarea' -import { useToast } from '@/hooks' -import { pubkeyToNpub } from '@/lib/pubkey' -import { cn } from '@/lib/utils' -import { useMediaUploadService } from '@/providers/MediaUploadServiceProvider' -import client from '@/services/client.service' -import { TProfile } from '@/types' -import React, { - ComponentProps, - Dispatch, - SetStateAction, - useCallback, - useEffect, - useRef, - useState -} from 'react' -import Nip05 from '../Nip05' -import { SimpleUserAvatar } from '../UserAvatar' -import { SimpleUsername } from '../Username' -import { getCurrentWord, replaceWord } from './utils' - -export default function PostTextarea({ - textValue, - setTextValue, - cursorOffset = 0, - onUploadImage, - ...props -}: ComponentProps<'textarea'> & { - textValue: string - setTextValue: Dispatch> - cursorOffset?: number - onUploadImage?: ({ url, tags }: { url: string; tags: string[][] }) => void -}) { - const { toast } = useToast() - const textareaRef = useRef(null) - const dropdownRef = useRef(null) - const inputRef = useRef(null) - const { upload } = useMediaUploadService() - const [commandValue, setCommandValue] = useState('') - const [debouncedCommandValue, setDebouncedCommandValue] = useState(commandValue) - const [profiles, setProfiles] = useState([]) - const [dragover, setDragover] = useState(false) - - useEffect(() => { - if (textareaRef.current && cursorOffset !== 0) { - const textarea = textareaRef.current - const newPos = Math.max(0, textarea.selectionStart - cursorOffset) - textarea.setSelectionRange(newPos, newPos) - textarea.focus() - } - }, [cursorOffset]) - - useEffect(() => { - const handler = setTimeout(() => { - setDebouncedCommandValue(commandValue) - }, 500) - - return () => { - clearTimeout(handler) - } - }, [commandValue]) - - useEffect(() => { - setProfiles([]) - if (debouncedCommandValue) { - const fetchProfiles = async () => { - const newProfiles = await client.searchProfilesFromIndex(debouncedCommandValue, 100) - setProfiles(newProfiles) - } - fetchProfiles() - } - }, [debouncedCommandValue]) - - useEffect(() => { - const dropdown = dropdownRef.current - if (!dropdown) return - - if (profiles.length > 0) { - dropdown.classList.remove('hidden') - } else { - dropdown.classList.add('hidden') - } - }, [profiles]) - - const handleBlur = useCallback(() => { - const dropdown = dropdownRef.current - if (dropdown) { - dropdown.classList.add('hidden') - setCommandValue('') - } - }, []) - - const handleKeyDown = useCallback((e: KeyboardEvent) => { - const textarea = textareaRef.current - const input = inputRef.current - const dropdown = dropdownRef.current - if (textarea && input && dropdown) { - const currentWord = getCurrentWord(textarea) - const isDropdownHidden = dropdown.classList.contains('hidden') - if (currentWord.startsWith('@') && !isDropdownHidden) { - if ( - e.key === 'ArrowUp' || - e.key === 'ArrowDown' || - e.key === 'Enter' || - e.key === 'Escape' - ) { - e.preventDefault() - input.dispatchEvent(new KeyboardEvent('keydown', e)) - } - } - } - }, []) - - const onTextValueChange = useCallback( - (e: React.ChangeEvent) => { - const text = e.target.value - const textarea = textareaRef.current - const dropdown = dropdownRef.current - - if (textarea && dropdown) { - const currentWord = getCurrentWord(textarea) - setTextValue(text) - if (currentWord.startsWith('@') && currentWord.length > 1) { - setCommandValue(currentWord.slice(1)) - } else { - // REMINDER: apparently, we need it when deleting - if (commandValue !== '') { - setCommandValue('') - dropdown.classList.add('hidden') - } - } - } - }, - [setTextValue, commandValue] - ) - - const onCommandSelect = useCallback((value: string) => { - const textarea = textareaRef.current - const dropdown = dropdownRef.current - if (textarea && dropdown) { - replaceWord(textarea, `${value} `) - setCommandValue('') - dropdown.classList.add('hidden') - } - }, []) - - const handleMouseDown = useCallback((e: Event) => { - e.preventDefault() - e.stopPropagation() - }, []) - - const handleSectionChange = useCallback(() => { - const textarea = textareaRef.current - const dropdown = dropdownRef.current - if (textarea && dropdown) { - const currentWord = getCurrentWord(textarea) - if (!currentWord.startsWith('@') && commandValue !== '') { - setCommandValue('') - dropdown.classList.add('hidden') - } - } - }, [commandValue]) - - useEffect(() => { - const textarea = textareaRef.current - const dropdown = dropdownRef.current - textarea?.addEventListener('keydown', handleKeyDown) - textarea?.addEventListener('blur', handleBlur) - document?.addEventListener('selectionchange', handleSectionChange) - dropdown?.addEventListener('mousedown', handleMouseDown) - return () => { - textarea?.removeEventListener('keydown', handleKeyDown) - textarea?.removeEventListener('blur', handleBlur) - document?.removeEventListener('selectionchange', handleSectionChange) - dropdown?.removeEventListener('mousedown', handleMouseDown) - } - }, [handleBlur, handleKeyDown, handleMouseDown, handleSectionChange]) - - const uploadImages = async (files: File[]) => { - for (const file of files) { - if (file.type.startsWith('image/') || file.type.startsWith('video/')) { - const placeholder = `[Uploading "${file.name}"...]` - if (textValue.includes(placeholder)) { - continue - } - setTextValue((prev) => (prev === '' ? placeholder : `${prev}\n${placeholder}`)) - try { - const result = await upload(file) - setTextValue((prev) => { - if (prev.includes(placeholder)) { - return prev.replace(placeholder, result.url) - } else { - return prev + `\n${result.url}` - } - }) - onUploadImage?.(result) - } catch (error) { - console.error('Error uploading file', error) - toast({ - variant: 'destructive', - title: 'Failed to upload file', - description: (error as Error).message - }) - setTextValue((prev) => prev.replace(placeholder, '')) - } - } - } - } - - const handlePast = async (e: React.ClipboardEvent) => { - await uploadImages( - Array.from(e.clipboardData.items) - .map((item) => item.getAsFile()) - .filter(Boolean) as File[] - ) - } - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault() - setDragover(false) - await uploadImages(Array.from(e.dataTransfer.files)) - } - - return ( -
-