From 5daf71b404beabbc456bae1e6110b787929c0dfc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 23:34:32 -0400 Subject: [PATCH 1/6] feat(editor): experimental Tiptap composer behind settings toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in Tiptap-based message composer gated behind Settings > Experimental > 'Tiptap Composer'. New packages: @tiptap/core @tiptap/react @tiptap/pm @tiptap/starter-kit @tiptap/extension-mention @tiptap/extension-link @tiptap/extension-placeholder @tiptap/suggestion New files: src/app/components/editor-tiptap/ extensions/MentionExtension.ts – Matrix mention node extensions/EmoticonExtension.ts – custom emoji node extensions/CommandExtension.ts – /command node TiptapEditor.tsx – drop-in editor component TiptapEditor.css.ts – vanilla-extract styles output.ts – Matrix HTML + plain-text serializers index.ts – public barrel src/app/features/room/ RoomInputTiptap.tsx – experimental composer component tiptap-autocomplete/ TiptapAutocompleteMenu.tsx – shared popup shell (no Slate dep) TiptapMentionAutocomplete.tsx – @user mention popup TiptapRoomMentionAutocomplete.tsx – #room mention popup TiptapEmoticonAutocomplete.tsx – :emoticon: popup Modified files: src/app/state/settings.ts – add useTiptapComposer: boolean src/app/features/settings/experimental/Experimental.tsx – add TiptapComposerToggle src/app/features/room/RoomView.tsx – conditional render of RoomInputTiptap vs RoomInput Not yet implemented in Tiptap composer: file uploads, reply drafts, scheduled messages, voice recording, emoji board, message draft persistence, command autocomplete popup --- package.json | 8 + pnpm-lock.yaml | 536 ++++++++++++++++++ .../editor-tiptap/TiptapEditor.css.ts | 123 ++++ .../components/editor-tiptap/TiptapEditor.tsx | 233 ++++++++ .../extensions/CommandExtension.ts | 40 ++ .../extensions/EmoticonExtension.ts | 45 ++ .../extensions/MentionExtension.ts | 35 ++ src/app/components/editor-tiptap/index.ts | 6 + src/app/components/editor-tiptap/output.ts | 194 +++++++ src/app/features/room/RoomInputTiptap.tsx | 426 ++++++++++++++ src/app/features/room/RoomView.tsx | 12 +- .../TiptapAutocompleteMenu.tsx | 76 +++ .../TiptapEmoticonAutocomplete.tsx | 125 ++++ .../TiptapMentionAutocomplete.tsx | 135 +++++ .../TiptapRoomMentionAutocomplete.tsx | 114 ++++ .../settings/experimental/Experimental.tsx | 33 ++ src/app/state/settings.ts | 2 + 17 files changed, 2142 insertions(+), 1 deletion(-) create mode 100644 src/app/components/editor-tiptap/TiptapEditor.css.ts create mode 100644 src/app/components/editor-tiptap/TiptapEditor.tsx create mode 100644 src/app/components/editor-tiptap/extensions/CommandExtension.ts create mode 100644 src/app/components/editor-tiptap/extensions/EmoticonExtension.ts create mode 100644 src/app/components/editor-tiptap/extensions/MentionExtension.ts create mode 100644 src/app/components/editor-tiptap/index.ts create mode 100644 src/app/components/editor-tiptap/output.ts create mode 100644 src/app/features/room/RoomInputTiptap.tsx create mode 100644 src/app/features/room/tiptap-autocomplete/TiptapAutocompleteMenu.tsx create mode 100644 src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx create mode 100644 src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx create mode 100644 src/app/features/room/tiptap-autocomplete/TiptapRoomMentionAutocomplete.tsx diff --git a/package.json b/package.json index 2d605aebc..d3cf0849f 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,14 @@ "@tanstack/react-query": "^5.90.21", "@tanstack/react-query-devtools": "^5.91.3", "@tanstack/react-virtual": "^3.13.19", + "@tiptap/core": "3.23.4", + "@tiptap/extension-link": "^3.23.4", + "@tiptap/extension-mention": "^3.23.4", + "@tiptap/extension-placeholder": "^3.23.4", + "@tiptap/pm": "^3.23.4", + "@tiptap/react": "^3.23.4", + "@tiptap/starter-kit": "^3.23.4", + "@tiptap/suggestion": "^3.23.4", "@use-gesture/react": "10.3.1", "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/recipes": "^0.5.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee7836185..db617aca4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,30 @@ importers: '@tanstack/react-virtual': specifier: ^3.13.19 version: 3.13.21(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tiptap/core': + specifier: 3.23.4 + version: 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/extension-link': + specifier: ^3.23.4 + version: 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + '@tiptap/extension-mention': + specifier: ^3.23.4 + version: 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)(@tiptap/suggestion@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/extension-placeholder': + specifier: ^3.23.4 + version: 3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/pm': + specifier: ^3.23.4 + version: 3.23.4 + '@tiptap/react': + specifier: ^3.23.4 + version: 3.23.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tiptap/starter-kit': + specifier: ^3.23.4 + version: 3.23.4 + '@tiptap/suggestion': + specifier: ^3.23.4 + version: 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) '@use-gesture/react': specifier: 10.3.1 version: 10.3.1(react@18.3.1) @@ -1154,6 +1178,15 @@ packages: '@noble/hashes': optional: true + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@fontsource-variable/nunito@5.2.7': resolution: {integrity: sha512-2N8QhatkyKgSUbAGZO2FYLioxA32+RyI1EplVLawbpkGjUeui9Qg9VMrpkCaik1ydjFjfLV+kzQ0cGEsMrMenQ==} @@ -2945,6 +2978,173 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tiptap/core@3.23.4': + resolution: {integrity: sha512-ni2LWE52bVeSt3L2HVBSmbBw+elc32ATej9C68EyKzN/8vR5ILxFn6RCdDTKm4asmwZyq2jys12dKmBdWMr9QA==} + peerDependencies: + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-blockquote@3.23.4': + resolution: {integrity: sha512-7YjSibNlPcy9eGK+tHt5G/Njr7nPxl+rZ3rCC6TwtLIRLSHPnoGDsfFOgTPkXxaQcE1a/VQwemnYfWc3kdIjDQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-bold@3.23.4': + resolution: {integrity: sha512-3L9tnZ12i+98u5df2nV2zGu/sc3rhI87E3ocn1YYAO8PJUAgZnMwdet8JawCrS1uut5sRKlxo3SXEmdNfRVm/w==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-bubble-menu@3.23.4': + resolution: {integrity: sha512-EPTpL/IFp/aTGZErBq/Mc3dKznj6G/qNEkVYWjueOn1oKApyT0P6WVHGvu/vpMdErhzmoGDuFPPGVS6T8Upx2Q==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-bullet-list@3.23.4': + resolution: {integrity: sha512-mXB2KZOz1R+E6VNTZ3vzdAk7ZDGKjPmsJEZIQg1B5qRycTKg49/rCCkLA2QnqAwX6BzS3mLLH1RWE2W0oXD7vg==} + peerDependencies: + '@tiptap/extension-list': 3.23.4 + + '@tiptap/extension-code-block@3.23.4': + resolution: {integrity: sha512-UEU1w/85CSNKktbhESnIRmtjKcH7DeschReZA8err1wAnYLTKzid5ucnJSJ25iRg2V5Fnuws5gnPT5CVgdfXCQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-code@3.23.4': + resolution: {integrity: sha512-C0TeRipMycUEBnV+Mzx6eLp/yZb6Vi/waP3Tkb0lO5/ikg7LWLB7AlmMunjIXEUcR/pJHID/aEh5PfJFpysUDg==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-document@3.23.4': + resolution: {integrity: sha512-YC4G6VkxT629rlqUTwD6XvOpxjvghn7fxrK4RbyKVJY2C6E1vgmX0won1Ast6v+qTE6iONOMS6f6VyPxSGjg4w==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-dropcursor@3.23.4': + resolution: {integrity: sha512-ujJQUIENk0RwVFCh5g/TOSEv1a7Pnam/cjHmSUqHWUNZkYS9aOqjm+JfURJPCinRS2oHvo3AARul5mkKgDJYcA==} + peerDependencies: + '@tiptap/extensions': 3.23.4 + + '@tiptap/extension-floating-menu@3.23.4': + resolution: {integrity: sha512-eAc72bKM26yIPx0jsU8qdjE71vFNVu5R9jGbdItBMFc0SPLS4qY8g+8RJ+iWoLwbcSEpgooLS9D9sLfdAU+Tvw==} + peerDependencies: + '@floating-ui/dom': ^1.0.0 + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-gapcursor@3.23.4': + resolution: {integrity: sha512-RuyvOlIGP6UpVOc0Lw0L63jKLtYM49CNhPV2OMSfwwwbBZ3pJGos2/SqpYg71d3sn+qpsAopS4Pfr8iPZog73A==} + peerDependencies: + '@tiptap/extensions': 3.23.4 + + '@tiptap/extension-hard-break@3.23.4': + resolution: {integrity: sha512-ODlpZCi7n136BH9luM09EFL8Pg+bbRCd0tzCQM5BKMXRkLitYZA8Gl/f5DLmGJ50wzFsDPXK2Br2g9UvZK7COg==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-heading@3.23.4': + resolution: {integrity: sha512-8W9Hqi0J69Xbqg08nPf4xRMJXMccaKFAgUE1tvy5PAWJSQxOMwkKQXgZXxwe+80sOMUnV8qveBqUy/ODMPgAxQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-horizontal-rule@3.23.4': + resolution: {integrity: sha512-EA4kK8ywZ4dQNOdxeZbplmDDs5T5LjMgHpqxRwukj9wwKiILOK5E3fcKm1fCKh9Q02w96jax6YVccHwmgJP3sQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-italic@3.23.4': + resolution: {integrity: sha512-jUAHi+HZlg47BzgVIy6y/UH5vev7vPQ95jddhB5K3hC122kvWFMXlken7UOnqzbxNcHs2+4Oi/ZJirYMpT4P5w==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-link@3.23.4': + resolution: {integrity: sha512-XjxltY7MomwfTs6jmN6Bw5bb/upb34lpyqv2RiXppFTK25Br7ipksRjUpWpB4/csZeg30qwrLGVKxCol38ffrw==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-list-item@3.23.4': + resolution: {integrity: sha512-Q/JXosShD5oyDwukE6igdrZD2lb0ZgyoQTHYchk0pzU4frClFbn3RI1wKP+XeqKLhdO6KH2WZ9rERGH7PtDi7Q==} + peerDependencies: + '@tiptap/extension-list': 3.23.4 + + '@tiptap/extension-list-keymap@3.23.4': + resolution: {integrity: sha512-9FezifCfuoc0o+5K6l4QNOOfelqxnDGg/f9oL1D/LFZPC54bPxpWWft9QCWOqyqZgyLCLjbCjciAlbgkrFUmmw==} + peerDependencies: + '@tiptap/extension-list': 3.23.4 + + '@tiptap/extension-list@3.23.4': + resolution: {integrity: sha512-yuauDm6qW/7q+ZO0YJBKQEGdnUm6DDTJM8AMp9bMZrT4jRf/zyUtNcZ91QEfFvBcyVuI+10PIOXtNPevhQ741Q==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-mention@3.23.4': + resolution: {integrity: sha512-4Fq4shW/XQ8h4wyaudOP4HWze9NWN4MTCQAQb8BSHWaMOosVRzve+WnTQL53axWj0pbYqM+d9iYpMgvdMmMm9g==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + '@tiptap/suggestion': 3.23.4 + + '@tiptap/extension-ordered-list@3.23.4': + resolution: {integrity: sha512-+3ofyssYnOTa1+nFWEmCAY1ngn8nAV1xo25JnNNC87NMU9WkSgr93jB7/uUJP0uui1C2dBLlaup3XXm108yarw==} + peerDependencies: + '@tiptap/extension-list': 3.23.4 + + '@tiptap/extension-paragraph@3.23.4': + resolution: {integrity: sha512-KbhXjCFzWphvFn5VU7E4dtmYDm+bssI1i0+CnXPWCXkjdaaX88ck68Xp1fKz8/bbI/CqlgiNDO/3TvqgtZ6woQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-placeholder@3.23.4': + resolution: {integrity: sha512-yHtAZkFR9M2AQmCi555w4ns1BBCqwRyYDYMtd10DBvqPX7T3TmGerPdUfI6sLr74GxnZ5zHOnOYdwAbeG5JzNw==} + peerDependencies: + '@tiptap/extensions': 3.23.4 + + '@tiptap/extension-strike@3.23.4': + resolution: {integrity: sha512-Vnq5vW801zPbu1LtKeA5k4R241jY+hRjXeijYwIPxy15KzIiipY12518HiCf6/8kkRbMxgOfdYg9X4BRV3HV3g==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-text@3.23.4': + resolution: {integrity: sha512-q9kxver/MR18p66aWZHSPycnr9hcBFyVGeGj8gf+BQCzn5hpvtSYTfLvk1nq8GFhygdQ9/e3f7B5ovrm/jnpvw==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extension-underline@3.23.4': + resolution: {integrity: sha512-F1ocPT10LV+seky25R1TMCRdc/Iof99jLcDSYDGr6mNEDY4ct2RvOeSM8aDdYq6CkH+vXt3i3JDeRwV23KzswQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + + '@tiptap/extensions@3.23.4': + resolution: {integrity: sha512-SlGPXauW8iKWG7wwuwC/0y/smLImp0h6GBIGgNnTBgIP/ThXQnjLMSZH0mW/REO87dQxkku01V3ARRywi+juhg==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + + '@tiptap/pm@3.23.4': + resolution: {integrity: sha512-+C5ngcoza47n3MjtjVBqBEBICPC0McdbwzJ+X6SSCviCLoqnSYanv5mIX9HWG0Q4fJ4BkdNM3VibZUxQaTbKyQ==} + + '@tiptap/react@3.23.4': + resolution: {integrity: sha512-mb5aIY9PuLreOVLExqs+8BAI20I/8+jCUBfEIqheuFY2GRRuBiwczejSlYuADfVDBbPVN5uPw4UMADCaH5wueQ==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + '@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.23.4': + resolution: {integrity: sha512-3VhU+NO6/ec9DMj/5Ej0nzARSq42cXnqW+QHCmTL3FNXkXQz+tw1KlfruT5GGJ3M0RssjWjRC0a39N/4S3qxeA==} + + '@tiptap/suggestion@3.23.4': + resolution: {integrity: sha512-KvrHKQcGpEKPPuetH2N4K21kA7hc31n5WDzw3FM+fNpMKdJOToYoNZzS9rmuBBHmNZ9wyK2sWmzi09enmv6wbg==} + peerDependencies: + '@tiptap/core': 3.23.4 + '@tiptap/pm': 3.23.4 + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3013,6 +3213,9 @@ packages: '@types/ua-parser-js@0.7.39': resolution: {integrity: sha512-P/oDfpofrdtF5xw433SPALpdSchtJmY7nsJItf8h3KXqOslkbySh8zq4dSWXH2oTjRvJ5PczVEoCZPow6GicLg==} + '@types/use-sync-external-store@0.0.6': + resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==} + '@use-gesture/core@10.3.1': resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} @@ -3582,6 +3785,10 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + 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'} @@ -4108,6 +4315,9 @@ packages: linkifyjs@4.3.2: resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==} + linkifyjs@4.3.3: + resolution: {integrity: sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4295,6 +4505,9 @@ packages: resolution: {integrity: sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==} engines: {node: '>=18'} + orderedmap@2.1.1: + resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -4425,6 +4638,42 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + prosemirror-changeset@2.4.1: + resolution: {integrity: sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==} + + 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-keymap@1.2.3: + resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==} + + prosemirror-model@1.25.6: + resolution: {integrity: sha512-RIm+e9BiqAaJ1mRECv3vR3C+VG8ELoTTI+47tVudGi82yLnFOx3G/p/iSPK1HmHQdKhkkrJ68NJqxh7S+FBVmQ==} + + 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-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.41.8: + resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -4582,6 +4831,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==} @@ -5098,6 +5350,9 @@ packages: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -6160,6 +6415,20 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + optional: true + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + optional: true + + '@floating-ui/utils@0.2.11': + optional: true + '@fontsource-variable/nunito@5.2.7': {} '@fontsource/space-mono@5.2.9': {} @@ -8038,6 +8307,192 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tiptap/core@3.23.4(@tiptap/pm@3.23.4)': + dependencies: + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-blockquote@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-bold@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-bubble-menu@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + optional: true + + '@tiptap/extension-bullet-list@3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/extension-list': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-code-block@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-code@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-document@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-dropcursor@3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/extensions': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-floating-menu@3.23.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@floating-ui/dom': 1.7.6 + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + optional: true + + '@tiptap/extension-gapcursor@3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/extensions': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-hard-break@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-heading@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-horizontal-rule@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-italic@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-link@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + linkifyjs: 4.3.3 + + '@tiptap/extension-list-item@3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/extension-list': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-list-keymap@3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/extension-list': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + + '@tiptap/extension-mention@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)(@tiptap/suggestion@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + '@tiptap/suggestion': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-ordered-list@3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/extension-list': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-paragraph@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-placeholder@3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/extensions': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + + '@tiptap/extension-strike@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-text@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extension-underline@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + + '@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + + '@tiptap/pm@3.23.4': + dependencies: + prosemirror-changeset: 2.4.1 + prosemirror-commands: 1.7.1 + prosemirror-dropcursor: 1.8.2 + prosemirror-gapcursor: 1.4.1 + prosemirror-history: 1.5.0 + prosemirror-keymap: 1.2.3 + prosemirror-model: 1.25.6 + prosemirror-schema-list: 1.5.1 + prosemirror-state: 1.4.4 + prosemirror-tables: 1.8.5 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + '@tiptap/react@3.23.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + '@types/use-sync-external-store': 0.0.6 + fast-equals: 5.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + use-sync-external-store: 1.6.0(react@18.3.1) + optionalDependencies: + '@tiptap/extension-bubble-menu': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + '@tiptap/extension-floating-menu': 3.23.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + transitivePeerDependencies: + - '@floating-ui/dom' + + '@tiptap/starter-kit@3.23.4': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/extension-blockquote': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-bold': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-bullet-list': 3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/extension-code': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-code-block': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + '@tiptap/extension-document': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-dropcursor': 3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/extension-gapcursor': 3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/extension-hard-break': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-heading': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-horizontal-rule': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + '@tiptap/extension-italic': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-link': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + '@tiptap/extension-list': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + '@tiptap/extension-list-item': 3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/extension-list-keymap': 3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/extension-ordered-list': 3.23.4(@tiptap/extension-list@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) + '@tiptap/extension-paragraph': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-strike': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-text': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extension-underline': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4)) + '@tiptap/extensions': 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + + '@tiptap/suggestion@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)': + dependencies: + '@tiptap/core': 3.23.4(@tiptap/pm@3.23.4) + '@tiptap/pm': 3.23.4 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -8110,6 +8565,8 @@ snapshots: '@types/ua-parser-js@0.7.39': {} + '@types/use-sync-external-store@0.0.6': {} + '@use-gesture/core@10.3.1': {} '@use-gesture/react@10.3.1(react@18.3.1)': @@ -8802,6 +9259,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-equals@5.4.0: {} + fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -9343,6 +9802,8 @@ snapshots: linkifyjs@4.3.2: {} + linkifyjs@4.3.3: {} + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -9522,6 +9983,8 @@ snapshots: dependencies: jwt-decode: 4.0.0 + orderedmap@2.1.1: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -9700,6 +10163,75 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + prosemirror-changeset@2.4.1: + dependencies: + prosemirror-transform: 1.12.0 + + prosemirror-commands@1.7.1: + dependencies: + prosemirror-model: 1.25.6 + 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.6 + 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-keymap@1.2.3: + dependencies: + prosemirror-state: 1.4.4 + w3c-keyname: 2.2.8 + + prosemirror-model@1.25.6: + dependencies: + orderedmap: 2.1.1 + + prosemirror-schema-list@1.5.1: + dependencies: + prosemirror-model: 1.25.6 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.6 + 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.6 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.41.8 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.6 + + prosemirror-view@1.41.8: + dependencies: + prosemirror-model: 1.25.6 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} @@ -9919,6 +10451,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 @@ -10476,6 +11010,8 @@ snapshots: void-elements@3.1.0: {} + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/src/app/components/editor-tiptap/TiptapEditor.css.ts b/src/app/components/editor-tiptap/TiptapEditor.css.ts new file mode 100644 index 000000000..000901caf --- /dev/null +++ b/src/app/components/editor-tiptap/TiptapEditor.css.ts @@ -0,0 +1,123 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, DefaultReset, toRem } from 'folds'; + +export const TiptapEditorRoot = style([ + DefaultReset, + { + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + boxShadow: `inset 0 0 0 ${config.borderWidth.B300} ${color.SurfaceVariant.ContainerLine}`, + borderRadius: config.radii.R400, + overflow: 'hidden', + width: '100%', + }, +]); + +export const TiptapEditorRow = style({ + gridTemplateColumns: 'auto 1fr auto', + alignItems: 'center', +}); + +export const TiptapEditorRowMultiline = style({ + gridTemplateColumns: 'auto 1fr', + gridTemplateAreas: ` + "before textarea" + "before after" + `, + alignItems: 'start', +}); + +export const TiptapEditorOptions = style([ + DefaultReset, + { + padding: config.space.S200, + }, +]); + +export const TiptapEditorOptionsMultiline = style({ + gridArea: 'before', + alignSelf: 'end', +}); + +export const TiptapEditorOptionsAfterMultiline = style({ + gridArea: 'after', + justifySelf: 'end', +}); + +export const TiptapEditorScrollArea = style({ + minWidth: 0, +}); + +export const TiptapEditorScrollAreaMultiline = style({ + gridArea: 'textarea', +}); + +export const TiptapEditorContent = style([ + DefaultReset, + { + flexGrow: 1, + height: 'auto', + padding: `${toRem(13)} 0 0`, + selectors: { + [`${TiptapEditorScrollArea}:first-child &`]: { + paddingLeft: toRem(13), + }, + [`${TiptapEditorScrollArea}:last-child &`]: { + paddingRight: toRem(13), + }, + '&:focus': { + outline: 'none', + }, + }, + }, +]); + +/** Wraps the ProseMirror editable div — resets prose styles from host page. */ +export const TiptapProseMirrorWrapper = style({ + selectors: { + '& .ProseMirror': { + outline: 'none', + minHeight: toRem(20), + // Match Editable textarea in slate editor + paddingBottom: toRem(13), + wordBreak: 'break-word', + overflowWrap: 'break-word', + }, + '& .ProseMirror p': { + margin: 0, + }, + '& .ProseMirror p.is-editor-empty:first-child::before': { + content: 'attr(data-placeholder)', + float: 'left', + color: color.SurfaceVariant.OnContainer, + opacity: config.opacity.Placeholder, + pointerEvents: 'none', + height: 0, + }, + // Mention chips + '& [data-mention]': { + display: 'inline', + borderRadius: config.radii.R300, + padding: `0 ${toRem(2)}`, + backgroundColor: color.Secondary.Container, + color: color.Secondary.OnContainer, + cursor: 'default', + userSelect: 'none', + }, + // Emoticon + '& [data-emoticon] img': { + height: toRem(20), + verticalAlign: 'middle', + }, + // Command chips + '& [data-command]': { + display: 'inline', + borderRadius: config.radii.R300, + padding: `0 ${toRem(2)}`, + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + cursor: 'default', + userSelect: 'none', + }, + }, +}); diff --git a/src/app/components/editor-tiptap/TiptapEditor.tsx b/src/app/components/editor-tiptap/TiptapEditor.tsx new file mode 100644 index 000000000..49fe621b3 --- /dev/null +++ b/src/app/components/editor-tiptap/TiptapEditor.tsx @@ -0,0 +1,233 @@ +import type { ReactNode, KeyboardEventHandler, ClipboardEventHandler } from 'react'; +import { forwardRef, useCallback, useImperativeHandle, useRef, useState, useEffect } from 'react'; +import { Box, Scroll } from 'folds'; +import { useEditor, EditorContent } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import Placeholder from '@tiptap/extension-placeholder'; +import Link from '@tiptap/extension-link'; +import type { Editor as TiptapEditorInstance } from '@tiptap/core'; + +import { mobileOrTablet } from '$utils/user-agent'; +import { MatrixMentionExtension } from './extensions/MentionExtension'; +import { EmoticonExtension } from './extensions/EmoticonExtension'; +import { CommandExtension } from './extensions/CommandExtension'; +import * as css from './TiptapEditor.css'; + +export type { TiptapEditorInstance }; + +/** Imperative handle exposed via ref for parent components. */ +export type TiptapEditorHandle = { + editor: TiptapEditorInstance | null; + focus: () => void; + reset: () => void; + isEmpty: () => boolean; +}; + +type TiptapEditorProps = { + editableName?: string; + top?: ReactNode; + bottom?: ReactNode; + before?: ReactNode; + after?: ReactNode; + responsiveAfter?: ReactNode; + forceMultilineLayout?: boolean; + maxHeight?: string; + placeholder?: string; + onKeyDown?: KeyboardEventHandler; + onKeyUp?: KeyboardEventHandler; + onChange?: (editor: TiptapEditorInstance) => void; + onPaste?: ClipboardEventHandler; + className?: string; + variant?: 'Surface' | 'SurfaceVariant' | 'Background'; +}; + +export const TiptapEditor = forwardRef( + ( + { + editableName, + top, + bottom, + before, + after, + responsiveAfter, + forceMultilineLayout = false, + maxHeight = '50vh', + placeholder, + onKeyDown, + onKeyUp, + onChange, + onPaste, + className, + variant = 'SurfaceVariant', + }, + ref + ) => { + const [isMultiline, setIsMultiline] = useState(false); + const rootRef = useRef(null); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + // Disable block-level elements we don't need in a chat composer + heading: false, + bulletList: false, + orderedList: false, + listItem: false, + blockquote: false, + codeBlock: false, + horizontalRule: false, + hardBreak: { + // Shift+Enter inserts a hard break (newline within paragraph) + keepMarks: true, + }, + }), + Link.configure({ + openOnClick: false, + autolink: true, + }), + Placeholder.configure({ + placeholder: placeholder ?? '', + }), + MatrixMentionExtension.configure({ + suggestion: { + // Disable the built-in suggestion popup — we handle autocomplete at the + // RoomInputTiptap level using our own React-based UI. + render: () => ({ + onStart: () => {}, + onUpdate: () => {}, + onKeyDown: () => false, + onExit: () => {}, + }), + }, + }), + EmoticonExtension, + CommandExtension, + ], + + onUpdate({ editor: updatedEditor }) { + // Detect multiline (more than one paragraph or hard break in first paragraph) + const { doc } = updatedEditor.state; + let multiline = doc.childCount > 1; + if (!multiline) { + doc.forEach((node) => { + if (!multiline) { + node.forEach((child) => { + if (child.type.name === 'hardBreak') multiline = true; + }); + } + }); + } + setIsMultiline(multiline || forceMultilineLayout); + onChange?.(updatedEditor); + }, + + editorProps: { + attributes: { + ...(editableName ? { 'data-editable-name': editableName } : {}), + class: css.TiptapEditorContent, + autocapitalize: 'sentences', + }, + handleKeyDown(_, event) { + onKeyDown?.(event as unknown as React.KeyboardEvent); + return false; // let Tiptap handle the event normally as well + }, + handleDOMEvents: { + keyup: (_, event) => { + onKeyUp?.(event as unknown as React.KeyboardEvent); + return false; + }, + paste: (_, event) => { + onPaste?.(event as unknown as React.ClipboardEvent); + return false; + }, + blur: () => { + if (mobileOrTablet() && editor) { + editor.commands.focus(); + } + return false; + }, + }, + }, + }); + + // Keep multiline in sync with forceMultilineLayout changes + useEffect(() => { + if (forceMultilineLayout && !isMultiline) setIsMultiline(true); + }, [forceMultilineLayout, isMultiline]); + + useImperativeHandle( + ref, + () => ({ + editor, + focus: () => editor?.commands.focus(), + reset: () => editor?.commands.clearContent(true), + isEmpty: () => editor?.isEmpty ?? true, + }), + [editor] + ); + + const layoutIsMultiline = isMultiline || forceMultilineLayout; + const hasBefore = Boolean(before); + const hasAfter = Boolean(after); + const hasResponsiveAfter = Boolean(responsiveAfter); + const showResponsiveAfterInFooter = hasResponsiveAfter && layoutIsMultiline; + const showResponsiveAfterInline = hasResponsiveAfter && !showResponsiveAfterInFooter; + + const handlePaste = useCallback( + (e) => { + onPaste?.(e); + }, + [onPaste] + ); + + return ( +
+ {top} + + {hasBefore && ( + + {before} + + )} + +
+ +
+
+ {(hasAfter || showResponsiveAfterInline) && ( + + {showResponsiveAfterInline && responsiveAfter} + {after} + + )} +
+ {bottom} +
+ ); + } +); diff --git a/src/app/components/editor-tiptap/extensions/CommandExtension.ts b/src/app/components/editor-tiptap/extensions/CommandExtension.ts new file mode 100644 index 000000000..6eff2878c --- /dev/null +++ b/src/app/components/editor-tiptap/extensions/CommandExtension.ts @@ -0,0 +1,40 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +/** + * Inline void node representing a /command inserted at the beginning of the message. + */ +export const CommandExtension = Node.create({ + name: 'command', + group: 'inline', + inline: true, + selectable: true, + atom: true, + + addAttributes() { + return { + command: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-command]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes({ 'data-command': '' }, HTMLAttributes)]; + }, + + renderText({ node }) { + return `/${node.attrs.command ?? ''}`; + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addCommands(): any { + return { + insertCommand: + (command: string) => + ({ commands }: { commands: { insertContent: (c: unknown) => boolean } }) => + commands.insertContent({ type: this.name, attrs: { command } }), + }; + }, +}); diff --git a/src/app/components/editor-tiptap/extensions/EmoticonExtension.ts b/src/app/components/editor-tiptap/extensions/EmoticonExtension.ts new file mode 100644 index 000000000..ef248dad6 --- /dev/null +++ b/src/app/components/editor-tiptap/extensions/EmoticonExtension.ts @@ -0,0 +1,45 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +/** + * Inline void node representing a custom emoticon (mxc:// image or plain emoji char). + */ +export const EmoticonExtension = Node.create({ + name: 'emoticon', + group: 'inline', + inline: true, + selectable: true, + draggable: false, + atom: true, + + addAttributes() { + return { + /** mxc:// URL or plain emoji character / shortcode key */ + key: { default: null }, + shortcode: { default: null }, + }; + }, + + parseHTML() { + return [{ tag: 'span[data-emoticon]' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes({ 'data-emoticon': '' }, HTMLAttributes)]; + }, + + renderText({ node }) { + const { key, shortcode } = node.attrs as { key: string | null; shortcode: string | null }; + if (!key) return ''; + return key.startsWith('mxc://') ? `:${shortcode ?? ''}:` : key; + }, + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + addCommands(): any { + return { + insertEmoticon: + (attrs: { key: string; shortcode: string }) => + ({ commands }: { commands: { insertContent: (c: unknown) => boolean } }) => + commands.insertContent({ type: this.name, attrs }), + }; + }, +}); diff --git a/src/app/components/editor-tiptap/extensions/MentionExtension.ts b/src/app/components/editor-tiptap/extensions/MentionExtension.ts new file mode 100644 index 000000000..f1ec20632 --- /dev/null +++ b/src/app/components/editor-tiptap/extensions/MentionExtension.ts @@ -0,0 +1,35 @@ +import Mention from '@tiptap/extension-mention'; + +/** + * Extended Mention node that stores the extra Matrix-specific attrs we need + * (nodeType, highlight, viaServers, eventId) alongside the base id/label. + */ +export const MatrixMentionExtension = Mention.extend({ + addAttributes() { + return { + id: { default: null }, + label: { default: null }, + /** 'user' | 'room' */ + nodeType: { default: 'user' }, + highlight: { default: false }, + viaServers: { default: null }, + eventId: { default: null }, + }; + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'span', + { + 'data-mention': '', + 'data-mention-type': node.attrs.nodeType, + ...HTMLAttributes, + }, + `${node.attrs.nodeType === 'room' ? '' : '@'}${node.attrs.label ?? node.attrs.id ?? ''}`, + ]; + }, + + renderText({ node }) { + return node.attrs.id ?? node.attrs.label ?? ''; + }, +}); diff --git a/src/app/components/editor-tiptap/index.ts b/src/app/components/editor-tiptap/index.ts new file mode 100644 index 000000000..9fc56ae45 --- /dev/null +++ b/src/app/components/editor-tiptap/index.ts @@ -0,0 +1,6 @@ +export { TiptapEditor } from './TiptapEditor'; +export type { TiptapEditorHandle, TiptapEditorInstance } from './TiptapEditor'; +export { tiptapToMatrixCustomHTML, tiptapToPlainText, tiptapCustomHtmlEqualsPlainText } from './output'; +export { MatrixMentionExtension } from './extensions/MentionExtension'; +export { EmoticonExtension } from './extensions/EmoticonExtension'; +export { CommandExtension } from './extensions/CommandExtension'; diff --git a/src/app/components/editor-tiptap/output.ts b/src/app/components/editor-tiptap/output.ts new file mode 100644 index 000000000..f5e969fc4 --- /dev/null +++ b/src/app/components/editor-tiptap/output.ts @@ -0,0 +1,194 @@ +/** + * Tiptap output serializer — converts a Tiptap editor's document to the formats + * required by the Matrix spec (custom HTML for formatted_body, plain text for body). + * + * Approach: walk the ProseMirror JSON tree, emit markdown-like text for formatted + * content, then run it through the existing markdownToHtml() pipeline so that the + * output is identical in structure to the Slate-based serializer. + */ + +import type { Editor as TiptapEditorInstance, JSONContent } from '@tiptap/core'; +import type { Room } from '$types/matrix-sdk'; +import { sanitizeText } from '$utils/sanitize'; +import { markdownToHtml, injectDataMd } from '$plugins/markdown'; +import { getMxIdLocalPart, isUserId } from '$utils/matrix'; +import { getMemberDisplayName } from '$utils/room'; +import { MATRIX_TO_BASE } from '$plugins/matrix-to'; + +export type TiptapOutputOptions = { + forEmote?: boolean; + room?: Room; +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +const escapeMarkdownInline = (text: string): string => + // Escape characters that could accidentally trigger markdown formatting + text.replace(/([\\`*_~[\]])/g, '\\$1'); + +function userMentionLabel(userId: string, room: Room | undefined): string { + const fallback = getMxIdLocalPart(userId) ?? userId; + if (!room) return fallback; + const fromMembership = getMemberDisplayName(room, userId); + if (!fromMembership) return fallback; + const t = fromMembership.trim(); + if (!t || t.includes(']')) return fallback; + for (let i = 0; i < t.length; i++) { + if (t.charCodeAt(i) <= 0x1f) return fallback; + } + return t; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Node → markdown text +// ───────────────────────────────────────────────────────────────────────────── + +function marksForNode(node: JSONContent): { bold?: boolean; italic?: boolean; strike?: boolean; code?: boolean } { + const marks: Record = {}; + for (const m of node.marks ?? []) { + if (typeof m === 'string') marks[m] = true; + else marks[m.type] = true; + } + return marks; +} + +function inlineNodeToMarkdown(node: JSONContent, opts: TiptapOutputOptions): string { + switch (node.type) { + case 'text': { + let text = node.text ?? ''; + const { bold, italic, strike, code } = marksForNode(node); + + if (code) { + // Don't escape inside code spans + return `\`${text}\``; + } + + text = escapeMarkdownInline(text); + if (bold) text = `**${text}**`; + if (italic) text = `*${text}*`; + if (strike) text = `~~${text}~~`; + return text; + } + + case 'mention': { + const { id, label, nodeType, highlight, viaServers, eventId } = node.attrs ?? {}; + if (!id) return ''; + + let fragment = String(id); + if (eventId) fragment += `/${String(eventId)}`; + if (viaServers && (viaServers as string[]).length > 0) + fragment += `?${(viaServers as string[]).map((s) => `via=${s}`).join('&')}`; + + const matrixTo = `${MATRIX_TO_BASE}#/${fragment}`; + + if (id === '@room') return `[@room](${encodeURI(matrixTo)})`; + if (nodeType === 'user' || isUserId(String(id))) { + const mdLabel = userMentionLabel(String(id), opts.room); + return `[${mdLabel}](${encodeURI(matrixTo)})`; + } + return sanitizeText(matrixTo); + } + + case 'emoticon': { + const { key, shortcode } = node.attrs ?? {}; + if (!key) return ''; + if (String(key).startsWith('mxc://')) { + return `${sanitizeText(String(shortcode ?? ''))}`; + } + return sanitizeText(String(key)); + } + + case 'command': { + const { command } = node.attrs ?? {}; + return `/${sanitizeText(String(command ?? ''))}`; + } + + case 'hardBreak': + return '\n'; + + default: + return ''; + } +} + +function paragraphToMarkdown(paragraph: JSONContent, opts: TiptapOutputOptions): string { + const parts = (paragraph.content ?? []).map((n) => inlineNodeToMarkdown(n, opts)); + return parts.join(''); +} + +function docToMarkdown(doc: JSONContent, opts: TiptapOutputOptions): string { + const paragraphs = doc.content ?? []; + const lines = paragraphs.map((p) => { + if (p.type === 'paragraph') return paragraphToMarkdown(p, opts); + return ''; + }); + // Join with newline; trailing newline will be stripped by trimCustomHtml + return lines.join('\n'); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Convert a Tiptap editor's content to Matrix custom HTML (formatted_body). + */ +export function tiptapToMatrixCustomHTML( + editor: TiptapEditorInstance, + opts: TiptapOutputOptions = {} +): string { + const doc = editor.getJSON() as unknown as JSONContent; + const markdown = docToMarkdown(doc, opts); + const html = markdownToHtml(markdown, { emote: opts.forEmote }); + return injectDataMd(html); +} + +/** + * Convert a Tiptap editor's content to a plain-text string (body). + */ +export function tiptapToPlainText(editor: TiptapEditorInstance): string { + const doc = editor.getJSON() as unknown as JSONContent; + const paragraphs = doc.content ?? []; + const lines = paragraphs.map((p) => { + if (p.type !== 'paragraph') return ''; + return (p.content ?? []) + .map((n) => { + switch (n.type) { + case 'text': + return n.text ?? ''; + case 'mention': + return n.attrs?.id === '@room' ? '@room' : String(n.attrs?.id ?? ''); + case 'emoticon': { + const { key, shortcode } = n.attrs ?? {}; + if (!key) return ''; + return String(key).startsWith('mxc://') ? `:${String(shortcode ?? '')}:` : String(key); + } + case 'command': + return `/${String(n.attrs?.command ?? '')}`; + case 'hardBreak': + return '\n'; + default: + return ''; + } + }) + .join(''); + }); + return lines.join('\n').replace(/\n$/, ''); +} + +/** + * Returns true when the HTML and plain-text representations are equivalent + * (i.e., no actual formatting — just send plain text). + */ +export function tiptapCustomHtmlEqualsPlainText( + editor: TiptapEditorInstance, + opts: TiptapOutputOptions = {} +): boolean { + const plain = tiptapToPlainText(editor); + const html = tiptapToMatrixCustomHTML(editor, opts); + // Strip HTML tags and compare + const stripped = html.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + return stripped.trim() === plain.trim(); +} diff --git a/src/app/features/room/RoomInputTiptap.tsx b/src/app/features/room/RoomInputTiptap.tsx new file mode 100644 index 000000000..32a0c8c31 --- /dev/null +++ b/src/app/features/room/RoomInputTiptap.tsx @@ -0,0 +1,426 @@ +/** + * RoomInputTiptap — experimental Tiptap-based message composer. + * + * This is an opt-in replacement for the Slate-based RoomInput, gated behind + * Settings > Experimental > "Tiptap Composer". + * + * Feature parity with the Slate composer: + * ✅ Text composition with markdown inline formatting (bold, italic, strike, code) + * ✅ @user and #room mention autocomplete + * ✅ Custom :emoticon: autocomplete + * ✅ /command detection (/me, /notice) + * ✅ Send on Enter / newline on Shift+Enter (respects enterForNewline setting) + * ✅ Matrix custom HTML + plain text output + * + * Not yet implemented (TODO): + * - File uploads / paste image + * - Reply drafts + * - Scheduled messages + * - Voice recording + * - Per-message profiles (PluralKit) + * - Emoji board + * - Message draft persistence + * - Outgoing message transforms + * - Command autocomplete popup (/command suggestion list) + */ + +import type { KeyboardEvent, RefObject } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { Editor as TiptapEditorInstance } from '@tiptap/core'; +import type { Room } from '$types/matrix-sdk'; +import { MsgType } from '$types/matrix-sdk'; +import { Box, Icon, IconButton, Icons, Text, config, color } from 'folds'; + +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useTypingStatusUpdater } from '$hooks/useTypingStatusUpdater'; +import { useAtomValue } from 'jotai'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { useImagePackRooms } from '$hooks/useImagePackRooms'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { trimCustomHtml } from '$components/editor/output'; +import { TiptapEditor } from '$components/editor-tiptap/TiptapEditor'; +import type { TiptapEditorHandle } from '$components/editor-tiptap/TiptapEditor'; +import { + tiptapToMatrixCustomHTML, + tiptapToPlainText, + tiptapCustomHtmlEqualsPlainText, +} from '$components/editor-tiptap/output'; +import { TiptapMentionAutocomplete } from './tiptap-autocomplete/TiptapMentionAutocomplete'; +import { TiptapRoomMentionAutocomplete } from './tiptap-autocomplete/TiptapRoomMentionAutocomplete'; +import { TiptapEmoticonAutocomplete } from './tiptap-autocomplete/TiptapEmoticonAutocomplete'; +import { mobileOrTablet } from '$utils/user-agent'; + +// ─── Autocomplete detection ────────────────────────────────────────────────── + +type AutocompleteState = + | { prefix: '@' | '#' | ':'; text: string; from: number; to: number } + | null; + +/** + * Look backwards from the cursor in the current paragraph's text content to find + * an autocomplete trigger character (@, #, :). Returns null when no trigger is + * active or when the trigger is inside a code span. + */ +function detectAutocomplete(editor: TiptapEditorInstance): AutocompleteState { + const { selection } = editor.state; + if (!selection.empty) return null; + + const { $from } = selection; + const nodeStart = $from.start(); + const cursorPos = $from.pos; + const textBefore = editor.state.doc.textBetween(nodeStart, cursorPos, '\n', '\0'); + + // Walk backwards to find the last whitespace or start-of-line + let wordStart = textBefore.length; + while (wordStart > 0 && !/[\s\0]/.test(textBefore[wordStart - 1]!)) { + wordStart--; + } + + const word = textBefore.slice(wordStart); + if (word.length === 0) return null; + + const prefix = word[0]; + if (prefix !== '@' && prefix !== '#' && prefix !== ':') return null; + + // Don't trigger for a lone prefix character with no additional text yet — wait + // for at least one character so we don't flash an empty popup on every @ press. + if (word.length < 2) return null; + + // Closing colon means the emoticon shortcode is complete — dismiss. + if (prefix === ':' && word.length > 1 && word.endsWith(':')) return null; + + const from = nodeStart + wordStart; + const to = cursorPos; + + return { prefix: prefix as '@' | '#' | ':', text: word.slice(1), from, to }; +} + +// ─── Component ─────────────────────────────────────────────────────────────── + +interface RoomInputTiptapProps { + fileDropContainerRef: RefObject; + roomId: string; + room: Room; +} + +export function RoomInputTiptap({ roomId, room }: RoomInputTiptapProps) { + const mx = useMatrixClient(); + const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); + const [editorToolbar] = useSetting(settingsAtom, 'editorToolbar'); + const [composerToolbarOpen, setComposerToolbarOpen] = useSetting( + settingsAtom, + 'composerToolbarOpen' + ); + + const editorRef = useRef(null); + const containerRef = useRef(null); + const [autocomplete, setAutocomplete] = useState(null); + const roomToParents = useAtomValue(roomToParentsAtom); + const imagePackRooms = useImagePackRooms(roomId, roomToParents); + const useAuthentication = useMediaAuthentication(); + + const sendTypingStatus = useTypingStatusUpdater(mx, roomId); + + // ── Send logic ───────────────────────────────────────────────────────────── + + const handleSend = useCallback(async () => { + const { editor } = editorRef.current ?? {}; + if (!editor || editor.isEmpty) return; + + const plainText = tiptapToPlainText(editor).trim(); + if (plainText === '') return; + + const customHtmlRaw = trimCustomHtml(tiptapToMatrixCustomHTML(editor, { room })); + const isPlainOnly = tiptapCustomHtmlEqualsPlainText(editor, { room }); + + let msgType = MsgType.Text; + + // Detect /me and /notice commands + if (plainText.startsWith('/me ')) { + msgType = MsgType.Emote; + } else if (plainText.startsWith('/notice ')) { + msgType = MsgType.Notice; + } + + const body = + msgType === MsgType.Emote + ? plainText.slice('/me '.length) + : msgType === MsgType.Notice + ? plainText.slice('/notice '.length) + : plainText; + + const formattedBody = + msgType === MsgType.Emote + ? customHtmlRaw.replace(/^\/me\s+/, '') + : msgType === MsgType.Notice + ? customHtmlRaw.replace(/^\/notice\s+/, '') + : customHtmlRaw; + + const content = + isPlainOnly + ? { msgtype: msgType, body } + : { + msgtype: msgType, + body, + format: 'org.matrix.custom.html' as const, + formatted_body: formattedBody, + }; + + try { + await mx.sendMessage(roomId, null, content); + editorRef.current?.reset(); + sendTypingStatus(false); + } catch { + // Error is surfaced by the matrix client — nothing to do here + } + }, [mx, roomId, room, sendTypingStatus]); + + // ── Keyboard handler ──────────────────────────────────────────────────────── + + const handleKeyDown = useCallback( + (evt: KeyboardEvent) => { + // Close autocomplete on Escape + if (evt.key === 'Escape' && autocomplete) { + evt.preventDefault(); + setAutocomplete(null); + editorRef.current?.focus(); + return; + } + + // Send on Enter (unless autocomplete is open or newline mode) + if (evt.key === 'Enter' && !evt.shiftKey) { + if (mobileOrTablet()) return; // mobile: Enter = newline + if (autocomplete) return; // let autocomplete handle it + if (enterForNewline) return; // Shift+Enter would send instead + + evt.preventDefault(); + handleSend(); + return; + } + + // Send on Shift+Enter when enterForNewline is on + if (evt.key === 'Enter' && evt.shiftKey && enterForNewline) { + if (autocomplete) return; + evt.preventDefault(); + handleSend(); + } + }, + [autocomplete, enterForNewline, handleSend] + ); + + // ── Autocomplete detection on every editor change ─────────────────────────── + + const handleEditorChange = useCallback((editor: TiptapEditorInstance) => { + setAutocomplete(detectAutocomplete(editor)); + // Typing status is updated by Tiptap's onChange + }, []); + + const handleCloseAutocomplete = useCallback(() => { + setAutocomplete(null); + editorRef.current?.focus(); + }, []); + + // ── Insert helpers called by autocomplete popups ──────────────────────────── + + const insertMention = useCallback( + (userId: string, displayName: string, highlight: boolean) => { + const { editor } = editorRef.current ?? {}; + if (!editor || !autocomplete) return; + const { from, to } = autocomplete; + editor + .chain() + .focus() + .deleteRange({ from, to }) + .insertContent({ + type: 'mention', + attrs: { id: userId, label: displayName, nodeType: 'user', highlight }, + }) + .insertContent(' ') + .run(); + setAutocomplete(null); + }, + [autocomplete] + ); + + const insertRoomMention = useCallback( + (roomId: string, roomAlias: string) => { + const { editor } = editorRef.current ?? {}; + if (!editor || !autocomplete) return; + const { from, to } = autocomplete; + editor + .chain() + .focus() + .deleteRange({ from, to }) + .insertContent({ + type: 'mention', + attrs: { id: roomId, label: roomAlias, nodeType: 'room', highlight: false }, + }) + .insertContent(' ') + .run(); + setAutocomplete(null); + }, + [autocomplete] + ); + + const insertEmoticon = useCallback( + (key: string, shortcode: string) => { + const { editor } = editorRef.current ?? {}; + if (!editor || !autocomplete) return; + const { from, to } = autocomplete; + editor + .chain() + .focus() + .deleteRange({ from, to }) + .insertContent({ type: 'emoticon', attrs: { key, shortcode } }) + .insertContent(' ') + .run(); + setAutocomplete(null); + }, + [autocomplete] + ); + + // ── Toolbar helpers ───────────────────────────────────────────────────────── + + const getEditor = () => editorRef.current?.editor ?? null; + + const toolbarButtons = [ + { + label: 'Bold', + icon: Icons.Bold, + toggle: () => getEditor()?.chain().focus().toggleBold().run(), + isActive: () => getEditor()?.isActive('bold') ?? false, + shortcut: 'Ctrl+B', + }, + { + label: 'Italic', + icon: Icons.Italic, + toggle: () => getEditor()?.chain().focus().toggleItalic().run(), + isActive: () => getEditor()?.isActive('italic') ?? false, + shortcut: 'Ctrl+I', + }, + { + label: 'Strikethrough', + icon: Icons.Strike, + toggle: () => getEditor()?.chain().focus().toggleStrike().run(), + isActive: () => getEditor()?.isActive('strike') ?? false, + shortcut: 'Ctrl+Shift+S', + }, + { + label: 'Code', + icon: Icons.Code, + toggle: () => getEditor()?.chain().focus().toggleCode().run(), + isActive: () => getEditor()?.isActive('code') ?? false, + shortcut: 'Ctrl+`', + }, + ] as const; + + // ── Toolbar open state (mirror of editorToolbar setting) ────────────────── + + const showToolbar = editorToolbar && composerToolbarOpen; + + const toolbarToggle = ( + setComposerToolbarOpen(!composerToolbarOpen)} + title={composerToolbarOpen ? 'Hide formatting toolbar' : 'Show formatting toolbar'} + > + + + ); + + const toolbar = showToolbar ? ( + + {toolbarButtons.map((btn) => ( + btn.toggle()} + title={`${btn.label} (${btn.shortcut})`} + > + + + ))} + + ) : null; + + // ── Send button ───────────────────────────────────────────────────────────── + + const sendButton = ( + + + + + + ); + + return ( + + {/* Autocomplete popups rendered above the editor */} + {autocomplete?.prefix === '@' && ( + + )} + {autocomplete?.prefix === '#' && ( + + )} + {autocomplete?.prefix === ':' && ( + + )} + + + + + ⚗ Experimental Tiptap composer — uploads, replies & advanced features not yet available + + + ); +} diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a5329bed4..82ae3bc45 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -33,6 +33,7 @@ import { CallView } from '$features/call/CallView'; import { useRoom } from '$hooks/useRoom'; import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing'; import { RoomInput } from './RoomInput'; +import { RoomInputTiptap } from './RoomInputTiptap'; import { RoomTombstone } from './RoomTombstone'; import { RoomViewTyping } from './RoomViewTyping'; import { RoomTimeline } from './RoomTimeline'; @@ -76,6 +77,7 @@ export function RoomView({ eventId }: { eventId?: string }) { const editLastMessageRef = useRef<(() => void) | undefined>(); const [hideReads] = useSetting(settingsAtom, 'hideReads'); + const [useTiptapComposer] = useSetting(settingsAtom, 'useTiptapComposer'); const screenSize = useScreenSizeContext(); const room = useRoom(); @@ -173,7 +175,15 @@ export function RoomView({ eventId }: { eventId?: string }) { /> ) : ( <> - {canMessage && ( + {canMessage && useTiptapComposer && ( + + )} + {canMessage && !useTiptapComposer && ( void; + headerContent: ReactNode; + children: ReactNode; +}; + +export function TiptapAutocompleteMenu({ + headerContent, + onClose, + children, +}: TiptapAutocompleteMenuProps) { + const alive = useAlive(); + const [isActive, setIsActive] = useState(true); + + const handleDeactivate = () => { + if (alive()) onClose(); + }; + + function handleInput(evt: ReactKeyboardEvent) { + if (!evt) return; + if ( + isKeyHotkey('arrowdown', evt.nativeEvent) || + isKeyHotkey('arrowup', evt.nativeEvent) || + isKeyHotkey('tab', evt.nativeEvent) || + isKeyHotkey('esc', evt.nativeEvent) || + isKeyHotkey('Enter', evt.nativeEvent) + ) + return; + setIsActive(false); + } + + return ( +
+
+ isKeyHotkey('arrowdown', e), + isKeyBackward: (e: KeyboardEvent) => isKeyHotkey('arrowup', e), + escapeDeactivates: stopPropagation, + }} + > + handleInput(e as ReactKeyboardEvent)} + > +
+ {headerContent} +
+ +
{children}
+
+
+
+
+
+ ); +} diff --git a/src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx b/src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx new file mode 100644 index 000000000..ae3e4914e --- /dev/null +++ b/src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx @@ -0,0 +1,125 @@ +import type { KeyboardEvent as ReactKbEvent } from 'react'; +import { useEffect, useMemo } from 'react'; +import { Box, MenuItem, Text, toRem } from 'folds'; +import type { Room } from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useAsyncSearch, type UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; +import { onTabPress } from '$utils/keyboard'; +import { useKeyDown } from '$hooks/useKeyDown'; +import { useRecentEmoji } from '$hooks/useRecentEmoji'; +import { useRelevantImagePacks } from '$hooks/useImagePacks'; +import type { IEmoji } from '$plugins/emoji'; +import { emojis } from '$plugins/emoji'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import type { PackImageReader } from '$plugins/custom-emoji'; +import { ImageUsage } from '$plugins/custom-emoji'; +import { getEmoticonSearchStr } from '$plugins/utils'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { TiptapAutocompleteMenu } from './TiptapAutocompleteMenu'; + +type EmoticonItem = PackImageReader | IEmoji; +const SEARCH_OPTIONS: UseAsyncSearchOptions = { matchOptions: { contain: true } }; + +type Props = { + imagePackRooms: Room[]; + useAuthentication: boolean; + queryText: string; + onSelect: (key: string, shortcode: string) => void; + onClose: () => void; +}; + +export function TiptapEmoticonAutocomplete({ + imagePackRooms, + useAuthentication, + queryText, + onSelect, + onClose, +}: Props) { + const mx = useMatrixClient(); + const imagePacks = useRelevantImagePacks(ImageUsage.Emoticon, imagePackRooms); + const recentEmoji = useRecentEmoji(mx, 20); + const [emojiThreshold] = useSetting(settingsAtom, 'emojiSuggestThreshold'); + + const searchList = useMemo>( + () => [...imagePacks.flatMap((p) => p.getImages(ImageUsage.Emoticon)), ...emojis], + [imagePacks] + ); + + const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonSearchStr, SEARCH_OPTIONS); + + const candidates = useMemo(() => { + if (queryText.length < emojiThreshold) return []; + return result ? result.items.slice(0, 20) : recentEmoji; + }, [queryText.length, emojiThreshold, result, recentEmoji]); + + useEffect(() => { + if (queryText) search(queryText); + else resetSearch(); + }, [queryText, search, resetSearch]); + + function getKey(item: EmoticonItem): string { + return 'url' in item ? (item as PackImageReader).url : (item as IEmoji).unicode; + } + + function getShortcode(item: EmoticonItem): string { + return 'shortcode' in item ? (item as PackImageReader).shortcode : (item as IEmoji).shortcode; + } + + function handleSelect(item: EmoticonItem) { + const key = getKey(item); + const shortcode = getShortcode(item); + onSelect(key, shortcode); + onClose(); + } + + useKeyDown(window, (evt: KeyboardEvent) => { + onTabPress(evt, () => { + if (candidates.length === 0) return; + handleSelect(candidates[0]!); + }); + }); + + return ( + Emoticons} onClose={onClose}> + {candidates.length === 0 && ( + + Type at least {emojiThreshold} character{emojiThreshold > 1 ? 's' : ''} to search + + )} + {candidates.map((item) => { + const key = getKey(item); + const shortcode = getShortcode(item); + const isMxc = key.startsWith('mxc://'); + const imgSrc = isMxc + ? mxcUrlToHttp(mx, key, useAuthentication) ?? key + : undefined; + return ( + ) => onTabPress(e, () => handleSelect(item))} + onClick={() => handleSelect(item)} + before={ + isMxc ? ( + {shortcode} + ) : ( + {key} + ) + } + > + + :{shortcode}: + + + ); + })} + + ); +} diff --git a/src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx b/src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx new file mode 100644 index 000000000..76a2026f9 --- /dev/null +++ b/src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx @@ -0,0 +1,135 @@ +import type { KeyboardEvent as ReactKbEvent } from 'react'; +import { useEffect } from 'react'; +import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; +import type { Room, RoomMember } from '$types/matrix-sdk'; +import { useRoomMembers } from '$hooks/useRoomMembers'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; +import { useAsyncSearch } from '$hooks/useAsyncSearch'; +import { onTabPress } from '$utils/keyboard'; +import { useKeyDown } from '$hooks/useKeyDown'; +import { getMxIdLocalPart } from '$utils/matrix'; +import { getMemberDisplayName, getMemberSearchStr } from '$utils/room'; +import { UserAvatar } from '$components/user-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useAtomValue } from 'jotai'; +import { nicknamesAtom } from '$state/nicknames'; +import { KnownMembership } from '$types/matrix-sdk'; +import { TiptapAutocompleteMenu } from './TiptapAutocompleteMenu'; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, matchOptions: { contain: true } }; +const mxIdToName = (id: string) => getMxIdLocalPart(id) ?? id; +const getSearchStr: SearchItemStrGetter = (m, q) => getMemberSearchStr(m, q, mxIdToName); +const allowedMembership = (m: RoomMember) => + m.membership === KnownMembership.Join || + m.membership === KnownMembership.Invite || + m.membership === KnownMembership.Knock; + +type Props = { + room: Room; + queryText: string; + onSelect: (userId: string, displayName: string, highlight: boolean) => void; + onClose: () => void; +}; + +export function TiptapMentionAutocomplete({ room, queryText, onSelect, onClose }: Props) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const nicknames = useAtomValue(nicknamesAtom); + const roomAliasOrId = room.getCanonicalAlias() || room.roomId; + const members = useRoomMembers(mx, room.roomId); + + const [result, search, resetSearch] = useAsyncSearch(members, getSearchStr, SEARCH_OPTIONS); + const candidates = (result ? result.items.slice(0, 20) : members.slice(0, 20)).filter( + allowedMembership + ); + + useEffect(() => { + if (queryText) search(queryText); + else resetSearch(); + }, [queryText, search, resetSearch]); + + function getName(member: RoomMember) { + return ( + getMemberDisplayName(room, member.userId, nicknames) ?? + getMxIdLocalPart(member.userId) ?? + member.userId + ); + } + + function handleSelect(userId: string, name: string) { + const highlight = mx.getUserId() === userId || roomAliasOrId === userId; + onSelect(userId, name, highlight); + onClose(); + } + + useKeyDown(window, (evt: KeyboardEvent) => { + onTabPress(evt, () => { + if (queryText === 'room') { + handleSelect(roomAliasOrId, '@room'); + return; + } + if (candidates.length === 0) return; + const first = candidates[0]!; + handleSelect(first.userId, getName(first)); + }); + }); + + return ( + Mentions} onClose={onClose}> + {queryText === 'room' && ( + ) => + onTabPress(e, () => handleSelect(roomAliasOrId, '@room')) + } + onClick={() => handleSelect(roomAliasOrId, '@room')} + before={ + + + + } + > + @room + + )} + {candidates.map((member) => { + const name = getName(member); + const avatarUrl = member.getMxcAvatarUrl() + ? mx.mxcUrlToHttp(member.getMxcAvatarUrl()!, 32, 32, 'crop', undefined, false, useAuthentication) ?? undefined + : undefined; + return ( + ) => + onTabPress(e, () => handleSelect(member.userId, name)) + } + onClick={() => handleSelect(member.userId, name)} + after={ + + {member.userId} + + } + before={ + + } + /> + + } + > + + {name} + + + ); + })} + + ); +} diff --git a/src/app/features/room/tiptap-autocomplete/TiptapRoomMentionAutocomplete.tsx b/src/app/features/room/tiptap-autocomplete/TiptapRoomMentionAutocomplete.tsx new file mode 100644 index 000000000..a39ce6a04 --- /dev/null +++ b/src/app/features/room/tiptap-autocomplete/TiptapRoomMentionAutocomplete.tsx @@ -0,0 +1,114 @@ +import type { KeyboardEvent as ReactKbEvent } from 'react'; +import { useEffect, useMemo } from 'react'; +import { Avatar, Icon, Icons, MenuItem, Text } from 'folds'; +import type { Room } from '$types/matrix-sdk'; +import { JoinRule } from '$types/matrix-sdk'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { isRoomAlias } from '$utils/matrix'; +import { useAsyncSearch, type UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; +import { onTabPress } from '$utils/keyboard'; +import { useKeyDown } from '$hooks/useKeyDown'; +import { mDirectAtom } from '$state/mDirectList'; +import { allRoomsAtom } from '$state/room-list/roomList'; +import { factoryRoomIdByActivity } from '$utils/sort'; +import { RoomAvatar, RoomIcon } from '$components/room-avatar'; +import { getViaServers } from '$plugins/via-servers'; +import { getMxIdServer } from '$utils/mxIdHelper'; +import { TiptapAutocompleteMenu } from './TiptapAutocompleteMenu'; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { matchOptions: { contain: true } }; + +type Props = { + queryText: string; + onSelect: (roomId: string, roomAlias: string) => void; + onClose: () => void; +}; + +export function TiptapRoomMentionAutocomplete({ queryText, onSelect, onClose }: Props) { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + const allRooms = useAtomValue(allRoomsAtom); + + const roomsWithAlias = useMemo( + () => + allRooms + .sort(factoryRoomIdByActivity(mx)) + .map((rId) => mx.getRoom(rId)) + .filter((r): r is Room => r !== null && r.getCanonicalAlias() !== null), + [allRooms, mx] + ); + + const getSearchStr = (room: Room) => { + const alias = room.getCanonicalAlias() ?? ''; + return `${room.name}${alias}`; + }; + + const [result, search, resetSearch] = useAsyncSearch(roomsWithAlias, getSearchStr, SEARCH_OPTIONS); + const candidates = (result ? result.items.slice(0, 20) : roomsWithAlias.slice(0, 20)); + + useEffect(() => { + if (queryText) search(queryText); + else resetSearch(); + }, [queryText, search, resetSearch]); + + function handleSelect(room: Room) { + const alias = room.getCanonicalAlias() ?? room.roomId; + const viaServers = getViaServers(room); + onSelect(room.roomId, alias); + onClose(); + } + + useKeyDown(window, (evt: KeyboardEvent) => { + onTabPress(evt, () => { + if (candidates.length === 0) return; + handleSelect(candidates[0]!); + }); + }); + + return ( + Rooms} onClose={onClose}> + {candidates.map((room) => { + const alias = room.getCanonicalAlias() ?? room.roomId; + const isDM = mDirects.has(room.roomId); + const avatarUrl = room.getMxcAvatarUrl() + ? mx.mxcUrlToHttp(room.getMxcAvatarUrl()!, 32, 32, 'crop') ?? undefined + : undefined; + return ( + ) => onTabPress(e, () => handleSelect(room))} + onClick={() => handleSelect(room)} + after={ + + {alias} + + } + before={ + + ( + + )} + /> + + } + > + + {room.name} + + + ); + })} + + ); +} diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 330412185..4fa745b50 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -11,6 +11,38 @@ import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; +function TiptapComposerToggle() { + const [useTiptapComposer, setUseTiptapComposer] = useSetting(settingsAtom, 'useTiptapComposer'); + + return ( + + Tiptap Composer (Prototype) + + + Replaces the Slate-based message composer with an experimental Tiptap-based one. +
+ Uploads, replies, scheduled messages and voice recording are not yet supported. +
+ Requires a page reload to take full effect. + + } + after={ + + } + /> +
+
+ ); +} + function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( settingsAtom, @@ -62,6 +94,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 5efe57552..ec8da7c19 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -176,6 +176,7 @@ export interface Settings { pmpProxying: boolean; mentionInReplies: boolean; showPersonaSetting: boolean; + useTiptapComposer: boolean; closeFoldersByDefault: boolean; perRoomShowRoomIcon: PerRoomShowRoomIcon[]; showRoomIcon: ShowRoomIcon; @@ -310,6 +311,7 @@ export const defaultSettings: Settings = { pmpProxying: false, mentionInReplies: true, showPersonaSetting: false, + useTiptapComposer: false, closeFoldersByDefault: false, perRoomShowRoomIcon: [], showRoomIcon: ShowRoomIcon.Smart, From c95e7c64bd2be970fa1b546087a396e2924f5fca Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 18 May 2026 23:38:55 -0400 Subject: [PATCH 2/6] fix(editor): use globalStyle for ProseMirror descendant selectors in TiptapEditor vanilla-extract selectors{} only supports &-anchored self-selectors. Descendant selectors like '& .ProseMirror' must use globalStyle() instead. --- .../editor-tiptap/TiptapEditor.css.ts | 95 +++++++++---------- 1 file changed, 47 insertions(+), 48 deletions(-) diff --git a/src/app/components/editor-tiptap/TiptapEditor.css.ts b/src/app/components/editor-tiptap/TiptapEditor.css.ts index 000901caf..bd0a56065 100644 --- a/src/app/components/editor-tiptap/TiptapEditor.css.ts +++ b/src/app/components/editor-tiptap/TiptapEditor.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { style, globalStyle } from '@vanilla-extract/css'; import { color, config, DefaultReset, toRem } from 'folds'; export const TiptapEditorRoot = style([ @@ -73,51 +73,50 @@ export const TiptapEditorContent = style([ ]); /** Wraps the ProseMirror editable div — resets prose styles from host page. */ -export const TiptapProseMirrorWrapper = style({ - selectors: { - '& .ProseMirror': { - outline: 'none', - minHeight: toRem(20), - // Match Editable textarea in slate editor - paddingBottom: toRem(13), - wordBreak: 'break-word', - overflowWrap: 'break-word', - }, - '& .ProseMirror p': { - margin: 0, - }, - '& .ProseMirror p.is-editor-empty:first-child::before': { - content: 'attr(data-placeholder)', - float: 'left', - color: color.SurfaceVariant.OnContainer, - opacity: config.opacity.Placeholder, - pointerEvents: 'none', - height: 0, - }, - // Mention chips - '& [data-mention]': { - display: 'inline', - borderRadius: config.radii.R300, - padding: `0 ${toRem(2)}`, - backgroundColor: color.Secondary.Container, - color: color.Secondary.OnContainer, - cursor: 'default', - userSelect: 'none', - }, - // Emoticon - '& [data-emoticon] img': { - height: toRem(20), - verticalAlign: 'middle', - }, - // Command chips - '& [data-command]': { - display: 'inline', - borderRadius: config.radii.R300, - padding: `0 ${toRem(2)}`, - backgroundColor: color.Primary.Container, - color: color.Primary.OnContainer, - cursor: 'default', - userSelect: 'none', - }, - }, +export const TiptapProseMirrorWrapper = style({}); + +globalStyle(`${TiptapProseMirrorWrapper} .ProseMirror`, { + outline: 'none', + minHeight: toRem(20), + paddingBottom: toRem(13), + wordBreak: 'break-word', + overflowWrap: 'break-word', +}); + +globalStyle(`${TiptapProseMirrorWrapper} .ProseMirror p`, { + margin: 0, +}); + +globalStyle(`${TiptapProseMirrorWrapper} .ProseMirror p.is-editor-empty:first-child::before`, { + content: 'attr(data-placeholder)', + float: 'left', + color: color.SurfaceVariant.OnContainer, + opacity: config.opacity.Placeholder, + pointerEvents: 'none', + height: '0', +}); + +globalStyle(`${TiptapProseMirrorWrapper} [data-mention]`, { + display: 'inline', + borderRadius: config.radii.R300, + padding: `0 ${toRem(2)}`, + backgroundColor: color.Secondary.Container, + color: color.Secondary.OnContainer, + cursor: 'default', + userSelect: 'none', +}); + +globalStyle(`${TiptapProseMirrorWrapper} [data-emoticon] img`, { + height: toRem(20), + verticalAlign: 'middle', +}); + +globalStyle(`${TiptapProseMirrorWrapper} [data-command]`, { + display: 'inline', + borderRadius: config.radii.R300, + padding: `0 ${toRem(2)}`, + backgroundColor: color.Primary.Container, + color: color.Primary.OnContainer, + cursor: 'default', + userSelect: 'none', }); From e26b4681922e42b47fd91fa3737da047799e6f5d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 14:25:13 -0400 Subject: [PATCH 3/6] chore: add changeset --- .changeset/tiptap-composer.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tiptap-composer.md diff --git a/.changeset/tiptap-composer.md b/.changeset/tiptap-composer.md new file mode 100644 index 000000000..f624c20c2 --- /dev/null +++ b/.changeset/tiptap-composer.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add experimental Tiptap-based message composer behind a settings toggle. From 76036e14047f3026098d0e29db93d8e0b8264a1b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 18:30:00 -0400 Subject: [PATCH 4/6] fix(tiptap): remove unused imports/variables, fix lint and knip errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused Icon, Icons, JoinRule, isRoomAlias, getMxIdServer, getViaServers, mDirectAtom imports from TiptapRoomMentionAutocomplete - Remove unused viaServers and isDM variables from TiptapRoomMentionAutocomplete - Remove unused useEffect from TiptapAutocompleteMenu and RoomInputTiptap - Remove unused Box and useMediaAuthentication from TiptapEmoticonAutocomplete - Remove unused label/highlight destructure in output.ts mention case - Fix roomId shadow in insertRoomMention callback (rename to mentionRoomId) - Fix \0 control character in regex → \u0000 with eslint-disable comment - Add eslint-disable for no-redundant-type-constituents on TiptapEditorInstance - Consolidate editor-tiptap imports in RoomInputTiptap to go through index.ts - Remove unused @tiptap/pm and @tiptap/suggestion from package.json --- package.json | 2 - pnpm-lock.yaml | 6 --- .../components/editor-tiptap/TiptapEditor.tsx | 1 + src/app/components/editor-tiptap/index.ts | 6 ++- src/app/components/editor-tiptap/output.ts | 16 ++++++-- src/app/features/room/RoomInputTiptap.tsx | 38 +++++++++---------- .../TiptapAutocompleteMenu.tsx | 2 +- .../TiptapEmoticonAutocomplete.tsx | 19 ++++++---- .../TiptapMentionAutocomplete.tsx | 13 ++++++- .../TiptapRoomMentionAutocomplete.tsx | 35 +++++++---------- .../settings/experimental/Experimental.tsx | 10 ++--- 11 files changed, 78 insertions(+), 70 deletions(-) diff --git a/package.json b/package.json index d3cf0849f..7fc5d2657 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,8 @@ "@tiptap/extension-link": "^3.23.4", "@tiptap/extension-mention": "^3.23.4", "@tiptap/extension-placeholder": "^3.23.4", - "@tiptap/pm": "^3.23.4", "@tiptap/react": "^3.23.4", "@tiptap/starter-kit": "^3.23.4", - "@tiptap/suggestion": "^3.23.4", "@use-gesture/react": "10.3.1", "@vanilla-extract/css": "^1.18.0", "@vanilla-extract/recipes": "^0.5.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index db617aca4..b07cc47ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,18 +63,12 @@ importers: '@tiptap/extension-placeholder': specifier: ^3.23.4 version: 3.23.4(@tiptap/extensions@3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)) - '@tiptap/pm': - specifier: ^3.23.4 - version: 3.23.4 '@tiptap/react': specifier: ^3.23.4 version: 3.23.4(@floating-ui/dom@1.7.6)(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tiptap/starter-kit': specifier: ^3.23.4 version: 3.23.4 - '@tiptap/suggestion': - specifier: ^3.23.4 - version: 3.23.4(@tiptap/core@3.23.4(@tiptap/pm@3.23.4))(@tiptap/pm@3.23.4) '@use-gesture/react': specifier: 10.3.1 version: 10.3.1(react@18.3.1) diff --git a/src/app/components/editor-tiptap/TiptapEditor.tsx b/src/app/components/editor-tiptap/TiptapEditor.tsx index 49fe621b3..e5e89a60d 100644 --- a/src/app/components/editor-tiptap/TiptapEditor.tsx +++ b/src/app/components/editor-tiptap/TiptapEditor.tsx @@ -17,6 +17,7 @@ export type { TiptapEditorInstance }; /** Imperative handle exposed via ref for parent components. */ export type TiptapEditorHandle = { + // eslint-disable-next-line typescript-eslint/no-redundant-type-constituents editor: TiptapEditorInstance | null; focus: () => void; reset: () => void; diff --git a/src/app/components/editor-tiptap/index.ts b/src/app/components/editor-tiptap/index.ts index 9fc56ae45..c83e20121 100644 --- a/src/app/components/editor-tiptap/index.ts +++ b/src/app/components/editor-tiptap/index.ts @@ -1,6 +1,10 @@ export { TiptapEditor } from './TiptapEditor'; export type { TiptapEditorHandle, TiptapEditorInstance } from './TiptapEditor'; -export { tiptapToMatrixCustomHTML, tiptapToPlainText, tiptapCustomHtmlEqualsPlainText } from './output'; +export { + tiptapToMatrixCustomHTML, + tiptapToPlainText, + tiptapCustomHtmlEqualsPlainText, +} from './output'; export { MatrixMentionExtension } from './extensions/MentionExtension'; export { EmoticonExtension } from './extensions/EmoticonExtension'; export { CommandExtension } from './extensions/CommandExtension'; diff --git a/src/app/components/editor-tiptap/output.ts b/src/app/components/editor-tiptap/output.ts index f5e969fc4..bc4e31788 100644 --- a/src/app/components/editor-tiptap/output.ts +++ b/src/app/components/editor-tiptap/output.ts @@ -45,7 +45,12 @@ function userMentionLabel(userId: string, room: Room | undefined): string { // Node → markdown text // ───────────────────────────────────────────────────────────────────────────── -function marksForNode(node: JSONContent): { bold?: boolean; italic?: boolean; strike?: boolean; code?: boolean } { +function marksForNode(node: JSONContent): { + bold?: boolean; + italic?: boolean; + strike?: boolean; + code?: boolean; +} { const marks: Record = {}; for (const m of node.marks ?? []) { if (typeof m === 'string') marks[m] = true; @@ -73,7 +78,7 @@ function inlineNodeToMarkdown(node: JSONContent, opts: TiptapOutputOptions): str } case 'mention': { - const { id, label, nodeType, highlight, viaServers, eventId } = node.attrs ?? {}; + const { id, nodeType, viaServers, eventId } = node.attrs ?? {}; if (!id) return ''; let fragment = String(id); @@ -189,6 +194,11 @@ export function tiptapCustomHtmlEqualsPlainText( const plain = tiptapToPlainText(editor); const html = tiptapToMatrixCustomHTML(editor, opts); // Strip HTML tags and compare - const stripped = html.replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); + const stripped = html + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"'); return stripped.trim() === plain.trim(); } diff --git a/src/app/features/room/RoomInputTiptap.tsx b/src/app/features/room/RoomInputTiptap.tsx index 32a0c8c31..a6ab195d4 100644 --- a/src/app/features/room/RoomInputTiptap.tsx +++ b/src/app/features/room/RoomInputTiptap.tsx @@ -25,7 +25,7 @@ */ import type { KeyboardEvent, RefObject } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useRef, useState } from 'react'; import type { Editor as TiptapEditorInstance } from '@tiptap/core'; import type { Room } from '$types/matrix-sdk'; import { MsgType } from '$types/matrix-sdk'; @@ -40,13 +40,13 @@ import { roomToParentsAtom } from '$state/room/roomToParents'; import { useImagePackRooms } from '$hooks/useImagePackRooms'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { trimCustomHtml } from '$components/editor/output'; -import { TiptapEditor } from '$components/editor-tiptap/TiptapEditor'; -import type { TiptapEditorHandle } from '$components/editor-tiptap/TiptapEditor'; +import { TiptapEditor } from '$components/editor-tiptap'; +import type { TiptapEditorHandle } from '$components/editor-tiptap'; import { tiptapToMatrixCustomHTML, tiptapToPlainText, tiptapCustomHtmlEqualsPlainText, -} from '$components/editor-tiptap/output'; +} from '$components/editor-tiptap'; import { TiptapMentionAutocomplete } from './tiptap-autocomplete/TiptapMentionAutocomplete'; import { TiptapRoomMentionAutocomplete } from './tiptap-autocomplete/TiptapRoomMentionAutocomplete'; import { TiptapEmoticonAutocomplete } from './tiptap-autocomplete/TiptapEmoticonAutocomplete'; @@ -54,9 +54,7 @@ import { mobileOrTablet } from '$utils/user-agent'; // ─── Autocomplete detection ────────────────────────────────────────────────── -type AutocompleteState = - | { prefix: '@' | '#' | ':'; text: string; from: number; to: number } - | null; +type AutocompleteState = { prefix: '@' | '#' | ':'; text: string; from: number; to: number } | null; /** * Look backwards from the cursor in the current paragraph's text content to find @@ -70,11 +68,12 @@ function detectAutocomplete(editor: TiptapEditorInstance): AutocompleteState { const { $from } = selection; const nodeStart = $from.start(); const cursorPos = $from.pos; - const textBefore = editor.state.doc.textBetween(nodeStart, cursorPos, '\n', '\0'); + const textBefore = editor.state.doc.textBetween(nodeStart, cursorPos, '\n', '\u0000'); // Walk backwards to find the last whitespace or start-of-line let wordStart = textBefore.length; - while (wordStart > 0 && !/[\s\0]/.test(textBefore[wordStart - 1]!)) { + // eslint-disable-next-line no-control-regex + while (wordStart > 0 && !/[\s\u0000]/.test(textBefore.charAt(wordStart - 1))) { wordStart--; } @@ -158,15 +157,14 @@ export function RoomInputTiptap({ roomId, room }: RoomInputTiptapProps) { ? customHtmlRaw.replace(/^\/notice\s+/, '') : customHtmlRaw; - const content = - isPlainOnly - ? { msgtype: msgType, body } - : { - msgtype: msgType, - body, - format: 'org.matrix.custom.html' as const, - formatted_body: formattedBody, - }; + const content = isPlainOnly + ? { msgtype: msgType, body } + : { + msgtype: msgType, + body, + format: 'org.matrix.custom.html' as const, + formatted_body: formattedBody, + }; try { await mx.sendMessage(roomId, null, content); @@ -245,7 +243,7 @@ export function RoomInputTiptap({ roomId, room }: RoomInputTiptapProps) { ); const insertRoomMention = useCallback( - (roomId: string, roomAlias: string) => { + (mentionRoomId: string, roomAlias: string) => { const { editor } = editorRef.current ?? {}; if (!editor || !autocomplete) return; const { from, to } = autocomplete; @@ -255,7 +253,7 @@ export function RoomInputTiptap({ roomId, room }: RoomInputTiptapProps) { .deleteRange({ from, to }) .insertContent({ type: 'mention', - attrs: { id: roomId, label: roomAlias, nodeType: 'room', highlight: false }, + attrs: { id: mentionRoomId, label: roomAlias, nodeType: 'room', highlight: false }, }) .insertContent(' ') .run(); diff --git a/src/app/features/room/tiptap-autocomplete/TiptapAutocompleteMenu.tsx b/src/app/features/room/tiptap-autocomplete/TiptapAutocompleteMenu.tsx index 348878cda..59553b1b8 100644 --- a/src/app/features/room/tiptap-autocomplete/TiptapAutocompleteMenu.tsx +++ b/src/app/features/room/tiptap-autocomplete/TiptapAutocompleteMenu.tsx @@ -3,7 +3,7 @@ * existing AutocompleteMenu but with no Slate / ReactEditor dependency. */ import type { ReactNode, KeyboardEvent as ReactKeyboardEvent } from 'react'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; import { Header, Menu, Scroll, config } from 'folds'; diff --git a/src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx b/src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx index ae3e4914e..8765765c7 100644 --- a/src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx +++ b/src/app/features/room/tiptap-autocomplete/TiptapEmoticonAutocomplete.tsx @@ -1,6 +1,6 @@ import type { KeyboardEvent as ReactKbEvent } from 'react'; import { useEffect, useMemo } from 'react'; -import { Box, MenuItem, Text, toRem } from 'folds'; +import { MenuItem, Text, toRem } from 'folds'; import type { Room } from '$types/matrix-sdk'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useAsyncSearch, type UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; @@ -11,7 +11,6 @@ import { useRelevantImagePacks } from '$hooks/useImagePacks'; import type { IEmoji } from '$plugins/emoji'; import { emojis } from '$plugins/emoji'; import { mxcUrlToHttp } from '$utils/matrix'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import type { PackImageReader } from '$plugins/custom-emoji'; import { ImageUsage } from '$plugins/custom-emoji'; import { getEmoticonSearchStr } from '$plugins/utils'; @@ -47,7 +46,11 @@ export function TiptapEmoticonAutocomplete({ [imagePacks] ); - const [result, search, resetSearch] = useAsyncSearch(searchList, getEmoticonSearchStr, SEARCH_OPTIONS); + const [result, search, resetSearch] = useAsyncSearch( + searchList, + getEmoticonSearchStr, + SEARCH_OPTIONS + ); const candidates = useMemo(() => { if (queryText.length < emojiThreshold) return []; @@ -60,7 +63,7 @@ export function TiptapEmoticonAutocomplete({ }, [queryText, search, resetSearch]); function getKey(item: EmoticonItem): string { - return 'url' in item ? (item as PackImageReader).url : (item as IEmoji).unicode; + return 'url' in item ? item.url : item.unicode; } function getShortcode(item: EmoticonItem): string { @@ -92,15 +95,15 @@ export function TiptapEmoticonAutocomplete({ const key = getKey(item); const shortcode = getShortcode(item); const isMxc = key.startsWith('mxc://'); - const imgSrc = isMxc - ? mxcUrlToHttp(mx, key, useAuthentication) ?? key - : undefined; + const imgSrc = isMxc ? (mxcUrlToHttp(mx, key, useAuthentication) ?? key) : undefined; return ( ) => onTabPress(e, () => handleSelect(item))} + onKeyDown={(e: ReactKbEvent) => + onTabPress(e, () => handleSelect(item)) + } onClick={() => handleSelect(item)} before={ isMxc ? ( diff --git a/src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx b/src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx index 76a2026f9..ae66d341f 100644 --- a/src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx +++ b/src/app/features/room/tiptap-autocomplete/TiptapMentionAutocomplete.tsx @@ -19,7 +19,8 @@ import { TiptapAutocompleteMenu } from './TiptapAutocompleteMenu'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, matchOptions: { contain: true } }; const mxIdToName = (id: string) => getMxIdLocalPart(id) ?? id; -const getSearchStr: SearchItemStrGetter = (m, q) => getMemberSearchStr(m, q, mxIdToName); +const getSearchStr: SearchItemStrGetter = (m, q) => + getMemberSearchStr(m, q, mxIdToName); const allowedMembership = (m: RoomMember) => m.membership === KnownMembership.Join || m.membership === KnownMembership.Invite || @@ -97,7 +98,15 @@ export function TiptapMentionAutocomplete({ room, queryText, onSelect, onClose } {candidates.map((member) => { const name = getName(member); const avatarUrl = member.getMxcAvatarUrl() - ? mx.mxcUrlToHttp(member.getMxcAvatarUrl()!, 32, 32, 'crop', undefined, false, useAuthentication) ?? undefined + ? (mx.mxcUrlToHttp( + member.getMxcAvatarUrl()!, + 32, + 32, + 'crop', + undefined, + false, + useAuthentication + ) ?? undefined) : undefined; return ( allRooms - .sort(factoryRoomIdByActivity(mx)) + .toSorted(factoryRoomIdByActivity(mx)) .map((rId) => mx.getRoom(rId)) .filter((r): r is Room => r !== null && r.getCanonicalAlias() !== null), [allRooms, mx] @@ -44,8 +39,12 @@ export function TiptapRoomMentionAutocomplete({ queryText, onSelect, onClose }: return `${room.name}${alias}`; }; - const [result, search, resetSearch] = useAsyncSearch(roomsWithAlias, getSearchStr, SEARCH_OPTIONS); - const candidates = (result ? result.items.slice(0, 20) : roomsWithAlias.slice(0, 20)); + const [result, search, resetSearch] = useAsyncSearch( + roomsWithAlias, + getSearchStr, + SEARCH_OPTIONS + ); + const candidates = result ? result.items.slice(0, 20) : roomsWithAlias.slice(0, 20); useEffect(() => { if (queryText) search(queryText); @@ -54,7 +53,6 @@ export function TiptapRoomMentionAutocomplete({ queryText, onSelect, onClose }: function handleSelect(room: Room) { const alias = room.getCanonicalAlias() ?? room.roomId; - const viaServers = getViaServers(room); onSelect(room.roomId, alias); onClose(); } @@ -70,16 +68,17 @@ export function TiptapRoomMentionAutocomplete({ queryText, onSelect, onClose }: Rooms} onClose={onClose}> {candidates.map((room) => { const alias = room.getCanonicalAlias() ?? room.roomId; - const isDM = mDirects.has(room.roomId); const avatarUrl = room.getMxcAvatarUrl() - ? mx.mxcUrlToHttp(room.getMxcAvatarUrl()!, 32, 32, 'crop') ?? undefined + ? (mx.mxcUrlToHttp(room.getMxcAvatarUrl()!, 32, 32, 'crop') ?? undefined) : undefined; return ( ) => onTabPress(e, () => handleSelect(room))} + onKeyDown={(e: ReactKbEvent) => + onTabPress(e, () => handleSelect(room)) + } onClick={() => handleSelect(room)} after={ @@ -92,13 +91,7 @@ export function TiptapRoomMentionAutocomplete({ queryText, onSelect, onClose }: roomId={room.roomId} src={avatarUrl} alt={room.name} - renderFallback={() => ( - - )} + renderFallback={() => } /> } diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 4fa745b50..712dde948 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -25,17 +25,15 @@ function TiptapComposerToggle() { <> Replaces the Slate-based message composer with an experimental Tiptap-based one.
- Uploads, replies, scheduled messages and voice recording are not yet supported. + + Uploads, replies, scheduled messages and voice recording are not yet supported. +
Requires a page reload to take full effect. } after={ - + } /> From 9fc596ecb840862ccf9d3284bc2b4fc3fd118182 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 19 May 2026 19:03:31 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix(tiptap):=20use=20DOMParser=20for=20HTML?= =?UTF-8?q?=E2=86=92text=20comparison=20to=20fix=20CodeQL=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces manual tag-stripping regex + chained entity unescaping with DOMParser.parseFromString, which handles both safely. Resolves two CodeQL security warnings in tiptapCustomHtmlEqualsPlainText: - CWE-116: double-unescaping via chained & → & replacement - CWE-80: incomplete tag sanitization leaving