From 03ad55f949d97569c457f463f1766c2d91758172 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 10 Aug 2026 23:48:23 +0200 Subject: [PATCH] feat(editor): replace Slate with ProseMirror in the composer --- package.json | 10 +- pnpm-lock.yaml | 200 +++-- src/app/components/editor/Editor.css.ts | 47 +- src/app/components/editor/Editor.test.tsx | 750 +++--------------- src/app/components/editor/Editor.tsx | 688 ++++------------ src/app/components/editor/Elements.tsx | 174 ---- src/app/components/editor/MarkdownToolbar.tsx | 68 +- .../editor/ProseMirrorEditable.test.tsx | 71 ++ .../components/editor/ProseMirrorEditable.tsx | 86 ++ .../editor/ProseMirrorEditorSurface.tsx | 52 ++ .../editor/autocomplete/AutocompleteMenu.tsx | 13 +- .../autocomplete/EmoticonAutocomplete.tsx | 20 +- .../autocomplete/RoomMentionAutocomplete.tsx | 26 +- .../autocomplete/UserMentionAutocomplete.tsx | 29 +- .../editor/autocomplete/autocompleteQuery.ts | 37 +- src/app/components/editor/getLinks.test.ts | 22 +- src/app/components/editor/index.ts | 3 +- src/app/components/editor/input.ts | 19 +- src/app/components/editor/keyboard.ts | 66 +- .../editor/matrixMentionMarkdown.ts | 53 +- src/app/components/editor/model.ts | 63 ++ src/app/components/editor/output.test.ts | 4 +- src/app/components/editor/output.ts | 86 +- .../editor/prosemirrorController.test.tsx | 236 ++++++ .../editor/prosemirrorController.ts | 257 ++++++ .../editor/prosemirrorNodeViews.test.tsx | 187 +++++ .../components/editor/prosemirrorNodeViews.ts | 182 +++++ .../editor/prosemirrorSchema.test.ts | 103 +++ .../components/editor/prosemirrorSchema.ts | 200 +++++ src/app/components/editor/slate.d.ts | 66 -- .../editor/useEditorRenderContext.ts | 25 + src/app/components/editor/utils.test.ts | 42 +- src/app/components/editor/utils.ts | 138 +--- .../upload-card/UploadDescriptionEditor.tsx | 56 +- src/app/features/room/CommandAutocomplete.tsx | 24 +- src/app/features/room/RoomInput.test.tsx | 17 +- src/app/features/room/RoomInput.tsx | 151 ++-- src/app/features/room/RoomTimeline.test.tsx | 2 +- src/app/features/room/RoomTimeline.tsx | 4 +- src/app/features/room/RoomView.tsx | 12 +- src/app/features/room/composerMessage.test.ts | 3 +- src/app/features/room/composerMessage.ts | 50 +- .../features/room/message/MessageEditor.tsx | 67 +- .../room/outgoingMessageTransforms.ts | 6 +- src/app/features/room/settingsLinkMessage.ts | 54 +- .../features/settings/account/BioEditor.tsx | 58 +- src/app/hooks/timeline/useTimelineActions.ts | 13 +- src/app/hooks/useMessageEdit.ts | 11 +- .../markdown/extensions/matrix-emoticon.ts | 2 +- .../plugins/markdown/htmlToMarkdown.test.ts | 2 +- src/app/plugins/markdown/htmlToMarkdown.ts | 2 +- src/app/state/room/roomInputDrafts.ts | 5 +- tests/e2e/live-timeline.spec.ts | 66 +- 53 files changed, 2336 insertions(+), 2292 deletions(-) delete mode 100644 src/app/components/editor/Elements.tsx create mode 100644 src/app/components/editor/ProseMirrorEditable.test.tsx create mode 100644 src/app/components/editor/ProseMirrorEditable.tsx create mode 100644 src/app/components/editor/ProseMirrorEditorSurface.tsx create mode 100644 src/app/components/editor/model.ts create mode 100644 src/app/components/editor/prosemirrorController.test.tsx create mode 100644 src/app/components/editor/prosemirrorController.ts create mode 100644 src/app/components/editor/prosemirrorNodeViews.test.tsx create mode 100644 src/app/components/editor/prosemirrorNodeViews.ts create mode 100644 src/app/components/editor/prosemirrorSchema.test.ts create mode 100644 src/app/components/editor/prosemirrorSchema.ts delete mode 100644 src/app/components/editor/slate.d.ts create mode 100644 src/app/components/editor/useEditorRenderContext.ts diff --git a/package.json b/package.json index 1d9a5c1036..e73ab26178 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,12 @@ "matrix-widget-api": "^1.17.0", "nanoid": "^6.0.0", "pdfjs-dist": "^6.1.200", + "prosemirror-commands": "^1.7.2", + "prosemirror-history": "^1.5.0", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-state": "^1.4.4", + "prosemirror-view": "^1.42.2", "react": "^18.3.1", "react-aria": "^3.50.0", "react-blurhash": "^0.3.0", @@ -119,10 +125,6 @@ "react-leaflet": "^4.2.1", "react-range": "^1.10.0", "react-router-dom": "^6.30.4", - "slate": "^0.124.1", - "slate-dom": "^0.124.1", - "slate-history": "^0.113.1", - "slate-react": "^0.125.1", "tauri-plugin-android-fs-api": "28.4.0", "ua-parser-js": "^2.0.10", "virtua": "^0.50.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a16ad4a08..7e9c7bacbc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,6 +223,24 @@ importers: pdfjs-dist: specifier: ^6.1.200 version: 6.1.200 + prosemirror-commands: + specifier: ^1.7.2 + version: 1.7.2 + prosemirror-history: + specifier: ^1.5.0 + version: 1.5.0 + prosemirror-keymap: + specifier: ^1.2.3 + version: 1.2.3 + prosemirror-model: + specifier: ^1.25.11 + version: 1.25.11 + prosemirror-state: + specifier: ^1.4.4 + version: 1.4.4 + prosemirror-view: + specifier: ^1.42.2 + version: 1.42.2 react: specifier: ^18.3.1 version: 18.3.1 @@ -253,18 +271,6 @@ importers: react-router-dom: specifier: ^6.30.4 version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - slate: - specifier: ^0.124.1 - version: 0.124.1 - slate-dom: - specifier: ^0.124.1 - version: 0.124.1(slate@0.124.1) - slate-history: - specifier: ^0.113.1 - version: 0.113.1(slate@0.124.1) - slate-react: - specifier: ^0.125.1 - version: 0.125.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.124.1(slate@0.124.1))(slate@0.124.1) tauri-plugin-android-fs-api: specifier: 28.4.0 version: 28.4.0 @@ -1653,9 +1659,6 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@juggle/resize-observer@3.4.0': - resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} - '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -3640,9 +3643,6 @@ packages: resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} engines: {node: '>= 14'} - compute-scroll-into-view@3.1.1: - resolution: {integrity: sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==} - confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -3779,10 +3779,6 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - direction@1.0.4: - resolution: {integrity: sha512-GYqKi1aH7PJXxdhTeZBFrg8vUBeKXi+cNprXsC1kpJcbcVnV9wBsrOu1cQEdG0WeQwlfHiy3XvnKfIrJ2R0NzQ==} - hasBin: true - docker-compose@1.4.2: resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} engines: {node: '>= 6.0.0'} @@ -4316,10 +4312,6 @@ packages: resolution: {integrity: sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==} engines: {node: '>=0.10.0'} - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -4410,8 +4402,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.4: - resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + jose@6.2.8: + resolution: {integrity: sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==} jotai@2.20.1: resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} @@ -4768,6 +4760,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + 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'} @@ -4937,6 +4932,27 @@ packages: resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} engines: {node: '>=18'} + prosemirror-commands@1.7.2: + resolution: {integrity: sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==} + + 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.11: + resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + + prosemirror-state@1.4.4: + resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==} + + prosemirror-transform@1.12.0: + resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==} + + prosemirror-view@1.42.2: + resolution: {integrity: sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==} + protobufjs@7.6.5: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} @@ -5136,6 +5152,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rope-sequence@1.3.4: + resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + rxjs@7.8.2: resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} @@ -5167,9 +5186,6 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - scroll-into-view-if-needed@3.1.0: - resolution: {integrity: sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==} - sdp-transform@2.15.0: resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==} hasBin: true @@ -5248,27 +5264,6 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} - slate-dom@0.124.1: - resolution: {integrity: sha512-D3yVibjLZM4Oj4MmXxOEXbjrlf4wJez3OvGABBNYrAP7gXb0d96tKNtWZ0hGm/5y84idw/LHjZ7W1uTYqFR9rQ==} - peerDependencies: - slate: '>=0.121.0' - - slate-history@0.113.1: - resolution: {integrity: sha512-J9NSJ+UG2GxoW0lw5mloaKcN0JI0x2IA5M5FxyGiInpn+QEutxT1WK7S/JneZCMFJBoHs1uu7S7e6pxQjubHmQ==} - peerDependencies: - slate: '>=0.65.3' - - slate-react@0.125.1: - resolution: {integrity: sha512-Cb9CROi4zp/p+a+f94srWPRhVCxTqQ+78ExSN7EBN/XoYf6GjGwafKtyTjpZCbr6OLY+raeDC+s3Rgxf1MFiEw==} - peerDependencies: - react: '>=18.2.0' - react-dom: '>=18.2.0' - slate: '>=0.121.0' - slate-dom: '>=0.119.1' - - slate@0.124.1: - resolution: {integrity: sha512-ii7DwezgvbLAyKtHBIunjTR1kzbNfYLCUKLMzJELlbTZkvHzX4DzN7HKIwcakf6dPxO6AoeT/P7kHOcyTym/hA==} - smob@1.6.2: resolution: {integrity: sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==} engines: {node: '>=20.0.0'} @@ -5449,9 +5444,6 @@ packages: text-decoder@1.2.7: resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} - tiny-invariant@1.3.1: - resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} - tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -5815,6 +5807,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'} @@ -7225,8 +7220,6 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@juggle/resize-observer@3.4.0': {} - '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3 @@ -7246,7 +7239,7 @@ snapshots: '@livekit/components-core': 0.12.14(livekit-client@2.21.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1) clsx: 2.1.1 events: 3.3.0 - jose: 6.2.4 + jose: 6.2.8 livekit-client: 2.21.0(@types/dom-mediacapture-record@1.0.22) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) @@ -8873,8 +8866,6 @@ snapshots: normalize-path: 3.0.0 readable-stream: 4.7.0 - compute-scroll-into-view@3.1.1: {} - confbox@0.1.8: {} content-type@2.0.0: {} @@ -8995,8 +8986,6 @@ snapshots: detect-libc@2.1.2: {} - direction@1.0.4: {} - docker-compose@1.4.2: dependencies: yaml: 2.9.0 @@ -9640,8 +9629,6 @@ snapshots: is-obj@1.0.1: {} - is-plain-object@5.0.0: {} - is-potential-custom-element-name@1.0.1: {} is-regex@1.2.1: @@ -9728,7 +9715,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.4: {} + jose@6.2.8: {} jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@18.3.1): optionalDependencies: @@ -9881,7 +9868,7 @@ snapshots: '@livekit/protocol': 1.50.4 '@types/dom-mediacapture-record': 1.0.22 events: 3.3.0 - jose: 6.2.4 + jose: 6.2.8 loglevel: 1.9.2 sdp-transform: 2.15.0 tslib: 2.8.1 @@ -10051,6 +10038,8 @@ snapshots: dependencies: wrappy: 1.0.2 + orderedmap@2.1.1: {} + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -10280,6 +10269,44 @@ snapshots: transitivePeerDependencies: - supports-color + prosemirror-commands@1.7.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + + prosemirror-history@1.5.0: + dependencies: + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + 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.11: + dependencies: + orderedmap: 2.1.1 + + prosemirror-state@1.4.4: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-transform: 1.12.0 + prosemirror-view: 1.42.2 + + prosemirror-transform@1.12.0: + dependencies: + prosemirror-model: 1.25.11 + + prosemirror-view@1.42.2: + dependencies: + prosemirror-model: 1.25.11 + prosemirror-state: 1.4.4 + prosemirror-transform: 1.12.0 + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -10553,6 +10580,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.62.0 fsevents: 2.3.3 + rope-sequence@1.3.4: {} + rxjs@7.8.2: dependencies: tslib: 2.8.1 @@ -10590,10 +10619,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - scroll-into-view-if-needed@3.1.0: - dependencies: - compute-scroll-into-view: 3.1.1 - sdp-transform@2.15.0: {} sdp-transform@3.0.0: {} @@ -10705,37 +10730,6 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 - slate-dom@0.124.1(slate@0.124.1): - dependencies: - '@juggle/resize-observer': 3.4.0 - direction: 1.0.4 - is-hotkey: 0.2.0 - is-plain-object: 5.0.0 - lodash: 4.18.1 - scroll-into-view-if-needed: 3.1.0 - slate: 0.124.1 - tiny-invariant: 1.3.1 - - slate-history@0.113.1(slate@0.124.1): - dependencies: - is-plain-object: 5.0.0 - slate: 0.124.1 - - slate-react@0.125.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(slate-dom@0.124.1(slate@0.124.1))(slate@0.124.1): - dependencies: - '@juggle/resize-observer': 3.4.0 - direction: 1.0.4 - is-hotkey: 0.2.0 - lodash: 4.18.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - scroll-into-view-if-needed: 3.1.0 - slate: 0.124.1 - slate-dom: 0.124.1(slate@0.124.1) - tiny-invariant: 1.3.1 - - slate@0.124.1: {} - smob@1.6.2: {} smol-toml@1.6.1: {} @@ -10998,8 +10992,6 @@ snapshots: transitivePeerDependencies: - react-native-b4a - tiny-invariant@1.3.1: {} - tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -11292,6 +11284,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/Editor.css.ts b/src/app/components/editor/Editor.css.ts index aca4cb5901..97053431e3 100644 --- a/src/app/components/editor/Editor.css.ts +++ b/src/app/components/editor/Editor.css.ts @@ -66,6 +66,9 @@ export const EditorTextarea = style([ flexGrow: 1, height: 'auto', padding: `${toRem(13)} 0 0`, + position: 'relative', + whiteSpace: 'pre-wrap', + wordWrap: 'break-word', selectors: { [`${EditorTextareaScroll}:first-child &`]: { paddingLeft: toRem(13), @@ -76,6 +79,27 @@ export const EditorTextarea = style([ '&:focus': { outline: 'none', }, + // ProseMirror owns the editable's children, so draw the placeholder as an + // overlay; data-placeholder-visible is recomputed per transaction. + '&[data-placeholder-visible="true"]::before': { + content: 'attr(data-placeholder)', + position: 'absolute', + top: toRem(13), + left: 0, + right: 0, + opacity: config.opacity.Placeholder, + pointerEvents: 'none', + userSelect: 'none', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + [`${EditorTextareaScroll}:first-child &[data-placeholder-visible="true"]::before`]: { + left: toRem(13), + }, + [`${EditorTextareaScroll}:last-child &[data-placeholder-visible="true"]::before`]: { + right: toRem(13), + }, }, }, ]); @@ -93,29 +117,6 @@ export const EditorResponsiveAfterMultiline = style([ }, ]); -export const EditorPlaceholderContainer = style([ - DefaultReset, - { - opacity: config.opacity.Placeholder, - pointerEvents: 'none', - userSelect: 'none', - }, -]); - -export const EditorPlaceholderTextVisual = style([ - DefaultReset, - { - display: 'block', - paddingTop: toRem(13), - paddingLeft: toRem(1), - selectors: { - [`${EditorTextareaScroll}:first-child &`]: { - paddingLeft: toRem(13), - }, - }, - }, -]); - export const EditorToolbarBase = style({ padding: `0 ${config.borderWidth.B300}`, }); diff --git a/src/app/components/editor/Editor.test.tsx b/src/app/components/editor/Editor.test.tsx index f60206f29c..f61d0ad4e3 100644 --- a/src/app/components/editor/Editor.test.tsx +++ b/src/app/components/editor/Editor.test.tsx @@ -1,13 +1,9 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { useState } from 'react'; -import { Node, Transforms } from 'slate'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { useEditor, CustomEditor } from './Editor'; -import { BlockType } from './types'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { CustomEditor } from './Editor'; +import { ProseMirrorEditorController } from './prosemirrorController'; import * as css from './Editor.css'; -let shouldWrapToggleHarness = false; -let measurementCacheScrollHeightReads = 0; let isIosApp = false; let nativeClipboardText = ''; @@ -21,697 +17,163 @@ vi.mock(import('$utils/dom'), async (importOriginal) => ({ readClipboardText: () => Promise.resolve(nativeClipboardText), })); -function EditorHarness() { - const editor = useEditor(); - - return ( - <> - - Attach} - after={} - responsiveAfter={
Recorder
} - /> - - ); -} - -function ToggleRecorderHarness() { - const editor = useEditor(); - const [showRecorder, setShowRecorder] = useState(false); - - return ( - <> - - - Attach} - after={} - responsiveAfter={ - showRecorder ?
Recorder
: undefined - } - /> - - ); -} +const emptyClientRects = () => [] as unknown as DOMRectList; +beforeAll(() => { + Element.prototype.getClientRects ??= emptyClientRects; + (Text.prototype as unknown as Element).getClientRects ??= emptyClientRects; +}); -function ForcedFooterHarness() { - const editor = useEditor(); +beforeEach(() => { + isIosApp = false; + nativeClipboardText = ''; +}); - return ( +// vanilla-extract composed styles resolve to several class names, so a plain +// `.${style}` selector would read as a descendant selector. +const byStyle = (container: HTMLElement, className: string) => + container.querySelector(`.${className.trim().split(/\s+/).join('.')}`); + +const renderEditor = (props: Partial[0]> = {}) => { + const editor = props.editor ?? new ProseMirrorEditorController(); + const result = render( Attach} - after={} - responsiveAfter={
Recorder
} - forceMultilineLayout + placeholder="Write a message" + {...props} /> ); -} - -function PasteWrapHarness() { - const editor = useEditor(); - - return ( - <> - - - - ); -} - -function NearThresholdWrapHarness() { - const editor = useEditor(); - - return ( - <> - - - - ); -} - -function TrailingSpacesWrapHarness() { - const editor = useEditor(); - - return ( - <> - - - - ); -} - -function PasteNoWrapHarness() { - const editor = useEditor(); - - return ( - <> - - - - ); -} - -function MeasurementCacheHarness() { - const editor = useEditor(); - - return ( - <> - - - - ); -} - -let pasteFallbackEditor: ReturnType | undefined; - -function PasteFallbackHarness() { - const editor = useEditor(); - pasteFallbackEditor = editor; - - return ; -} - -const pastedLines = () => (pasteFallbackEditor?.children ?? []).map((node) => Node.string(node)); - -const pasteIntoFallbackHarness = () => { - render(); - const editable = document.querySelector('[data-editable-name="PasteFallbackHarness"]'); - const editor = pasteFallbackEditor; - if (!editable || !editor) throw new Error('paste fallback harness did not mount'); - - act(() => { - Transforms.select(editor, { path: [0, 0], offset: 0 }); - }); - fireEvent.paste(editable, { clipboardData: { files: [], getData: () => '' } }); + return { editor, ...result }; }; -const createResizeObserverStub = ( - observedElements: Set, - onCreate: (callback: ResizeObserverCallback) => void -) => - function ResizeObserverStub(callback: ResizeObserverCallback) { - onCreate(callback); - - return { - observe(target: Element) { - observedElements.add(target); - }, - unobserve(target: Element) { - observedElements.delete(target); - }, - disconnect() { - observedElements.clear(); - }, - }; - } as unknown as typeof ResizeObserver; - -const nativeIsContentEditable = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - 'isContentEditable' -); -const nativeScrollHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollHeight'); -const nativeOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); -const nativeClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth'); -const nativeRequestAnimationFrame = window.requestAnimationFrame; -const nativeCancelAnimationFrame = window.cancelAnimationFrame; -const nativeGlobalRequestAnimationFrame = globalThis.requestAnimationFrame; -const nativeGlobalCancelAnimationFrame = globalThis.cancelAnimationFrame; -const nativeResizeObserver = globalThis.ResizeObserver; +const row = (container: HTMLElement) => byStyle(container, css.EditorRow)!; +const maxHeightOf = (container: HTMLElement) => + (byStyle(container, css.EditorTextareaScroll) as HTMLElement).style.maxHeight; -beforeEach(() => { - shouldWrapToggleHarness = false; - measurementCacheScrollHeightReads = 0; - isIosApp = false; - nativeClipboardText = ''; - pasteFallbackEditor = undefined; +describe('CustomEditor', () => { + it('mounts a ProseMirror editable surface with the placeholder', () => { + const { container } = renderEditor(); - Object.defineProperty(HTMLElement.prototype, 'isContentEditable', { - configurable: true, - get(): boolean { - return (this as HTMLElement).getAttribute('contenteditable') === 'true'; - }, + expect(container.querySelector('[aria-label="Write a message"]')).toBeTruthy(); + expect(container.querySelector('.ProseMirror')).toBeTruthy(); }); +}); - Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { - configurable: true, - get() { - if (this instanceof HTMLElement) { - const measurerName = this.getAttribute('data-editor-measurer'); - const measuredText = this.textContent ?? ''; - const hasMeasuredText = measuredText.length > 0; - const isSingleLineProbe = measuredText === 'M'; - - if (measurerName === 'ToggleRecorderHarness') { - if (!hasMeasuredText || isSingleLineProbe) return 20; - return shouldWrapToggleHarness ? 40 : 20; - } - - if (measurerName === 'PasteWrapHarness') { - if (isSingleLineProbe) return 20; - return hasMeasuredText ? 40 : 20; - } - - if (measurerName === 'NearThresholdWrapHarness') { - if (!hasMeasuredText || isSingleLineProbe) return 20; - return this.style.width === '319px' ? 29 : 20; - } - - if (measurerName === 'TrailingSpacesWrapHarness') { - if (!hasMeasuredText || isSingleLineProbe) return 20; - return measuredText.endsWith('\u200B') ? 29 : 20; - } - - if (measurerName === 'PasteNoWrapHarness') { - if (!hasMeasuredText || isSingleLineProbe) return 20; - return 20; - } - - if (measurerName === 'MeasurementCacheHarness') { - if (!hasMeasuredText || isSingleLineProbe) return 20; - measurementCacheScrollHeightReads += 1; - return 40; - } - } - - return nativeScrollHeight?.get?.call(this) ?? 0; - }, - }); +describe('CustomEditor layout', () => { + it('keeps buttons inline for a single line', () => { + const { container } = renderEditor({ after: }); - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { - configurable: true, - get() { - if (this instanceof HTMLElement && this.classList.contains(css.EditorRow)) { - return 320; - } - return nativeOffsetWidth?.get?.call(this) ?? 0; - }, + expect(row(container)).not.toHaveClass(css.EditorRowMultiline); }); - Object.defineProperty(HTMLElement.prototype, 'clientWidth', { - configurable: true, - get() { - if (this instanceof HTMLElement && this.classList.contains(css.EditorTextareaScroll)) { - if (this.querySelector('[data-editable-name="NearThresholdWrapHarness"]')) { - return 319; - } + it('keeps buttons inline however long the text is', async () => { + const { container, editor } = renderEditor({ after: }); - return 320; - } + act(() => editor.insertText('text long enough to wrap several times over in the composer')); - return nativeClientWidth?.get?.call(this) ?? 0; - }, + await waitFor(() => expect(editor.isEmpty()).toBe(false)); + expect(row(container)).not.toHaveClass(css.EditorRowMultiline); }); -}); -afterEach(() => { - shouldWrapToggleHarness = false; - measurementCacheScrollHeightReads = 0; - window.requestAnimationFrame = nativeRequestAnimationFrame; - window.cancelAnimationFrame = nativeCancelAnimationFrame; - globalThis.requestAnimationFrame = nativeGlobalRequestAnimationFrame; - globalThis.cancelAnimationFrame = nativeGlobalCancelAnimationFrame; - globalThis.ResizeObserver = nativeResizeObserver; - if (nativeIsContentEditable) { - Object.defineProperty(HTMLElement.prototype, 'isContentEditable', nativeIsContentEditable); - } else { - Reflect.deleteProperty(HTMLElement.prototype, 'isContentEditable'); - } - - if (nativeScrollHeight) { - Object.defineProperty(HTMLElement.prototype, 'scrollHeight', nativeScrollHeight); - } else { - Reflect.deleteProperty(HTMLElement.prototype, 'scrollHeight'); - } - - if (nativeOffsetWidth) { - Object.defineProperty(HTMLElement.prototype, 'offsetWidth', nativeOffsetWidth); - } else { - Reflect.deleteProperty(HTMLElement.prototype, 'offsetWidth'); - } - - if (nativeClientWidth) { - Object.defineProperty(HTMLElement.prototype, 'clientWidth', nativeClientWidth); - } else { - Reflect.deleteProperty(HTMLElement.prototype, 'clientWidth'); - } -}); + it('keeps buttons inline across many paragraphs', async () => { + const { container, editor } = renderEditor({ after: }); -describe('CustomEditor', () => { - it('moves responsive after content into the multiline footer without keeping the textarea max height', async () => { - render(); - const editable = document.querySelector('[data-editable-name="EditorHarness"]'); - const scroll = editable?.parentElement as HTMLElement | null; - const editorRoot = scroll?.parentElement?.parentElement as HTMLElement | null; - const measurer = document.querySelector('[data-editor-measurer="EditorHarness"]'); - - expect(scroll).not.toBeNull(); - expect(editorRoot).not.toBeNull(); - expect(editorRoot?.contains(measurer)).toBe(true); - expect(measurer?.parentElement).not.toBe(document.body); - expect(scroll?.style.maxHeight).toBe('50dvh'); - expect(screen.getByText('Attach')).toBeVisible(); - expect(screen.getByText('Send')).toBeVisible(); - expect(screen.getByTestId('recorder').parentElement).toHaveClass(css.EditorOptions); - - fireEvent.click(screen.getByRole('button', { name: 'Make multiline' })); - - await waitFor(() => { - expect(screen.getByTestId('recorder').parentElement).toHaveClass( - css.EditorResponsiveAfterMultiline - ); - expect(scroll?.style.maxHeight).toBe(''); - }); + act(() => editor.insertText('one')); + act(() => editor.insertNewline()); + act(() => editor.insertText('two')); - expect(screen.getByText('Attach')).toBeVisible(); - expect(screen.getByText('Send')).toBeVisible(); + await waitFor(() => expect(editor.getText()).toBe('one\ntwo')); + expect(row(container)).not.toHaveClass(css.EditorRowMultiline); }); - it('recomputes multiline layout when inline responsive content makes existing text wrap', async () => { - render(); + it('never installs a hidden measurer', () => { + const { container, editor } = renderEditor({ after: }); - fireEvent.click(screen.getByRole('button', { name: 'Add text' })); - fireEvent.click(screen.getByRole('button', { name: 'Start recorder' })); + act(() => editor.insertText('some text')); - await waitFor(() => { - expect(screen.getByTestId('toggle-recorder').parentElement).toHaveClass( - css.EditorResponsiveAfterMultiline - ); - }); + expect(container.querySelector('[data-editor-measurer]')).toBeNull(); }); - it('supports forcing multiline layout so responsive content moves into the footer immediately', () => { - render(); + it('stacks the layout and moves responsive content into the footer when forced', () => { + const { container } = renderEditor({ + after: , + responsiveAfter:
Recorder
, + forceMultilineLayout: true, + }); - expect(screen.getByTestId('forced-footer-recorder').parentElement).toHaveClass( - css.EditorResponsiveAfterMultiline + expect(row(container)).toHaveClass(css.EditorRowMultiline); + expect(row(container)).toHaveClass(css.EditorRowMultilineWithResponsiveAfter); + expect(byStyle(container, css.EditorResponsiveAfterMultiline)).toContainElement( + screen.getByTestId('recorder') ); + expect(maxHeightOf(container)).toBe(''); }); - it('detects pasted text that exceeds the single-line width after the deferred layout measurement', async () => { - render(); - const editable = document.querySelector('[data-editable-name="PasteWrapHarness"]'); - const scroll = editable?.parentElement as HTMLElement | null; - - expect(scroll).not.toBeNull(); - expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); - - fireEvent.click(screen.getByRole('button', { name: 'Paste wrapped text' })); - - await waitFor(() => { - expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); - }); - }); - - it('does not oscillate back to single-line when multiline layout slightly increases the available width', async () => { - const queuedFrames = new Map(); - let nextFrameId = 1; - let resizeObserverCallback: ResizeObserverCallback | undefined; - const observedElements = new Set(); - const flushQueuedFrames = () => { - const pendingFrames = Array.from(queuedFrames.entries()); - queuedFrames.clear(); - pendingFrames.forEach(([, callback]) => { - callback(performance.now()); - }); - }; - - const requestAnimationFrameStub = ((callback: FrameRequestCallback) => { - const frameId = nextFrameId; - nextFrameId += 1; - queuedFrames.set(frameId, callback); - return frameId; - }) as typeof window.requestAnimationFrame; - const cancelAnimationFrameStub = ((frameId: number) => { - queuedFrames.delete(frameId); - }) as typeof window.cancelAnimationFrame; - - window.requestAnimationFrame = requestAnimationFrameStub; - window.cancelAnimationFrame = cancelAnimationFrameStub; - globalThis.requestAnimationFrame = requestAnimationFrameStub; - globalThis.cancelAnimationFrame = cancelAnimationFrameStub; - globalThis.ResizeObserver = createResizeObserverStub(observedElements, (callback) => { - resizeObserverCallback = callback; + it('keeps responsive content inline when not forced', () => { + const { container } = renderEditor({ + after: , + responsiveAfter:
Recorder
, }); - render(); - const editable = document.querySelector('[data-editable-name="NearThresholdWrapHarness"]'); - const scroll = editable?.parentElement as HTMLElement | null; - - expect(scroll).not.toBeNull(); - expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); - - fireEvent.click(screen.getByRole('button', { name: 'Paste near-threshold wrap' })); - - await waitFor(() => { - expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); - }); - expect(resizeObserverCallback).toBeDefined(); - - act(() => { - resizeObserverCallback?.( - Array.from(observedElements).map((target) => ({ target }) as ResizeObserverEntry), - {} as ResizeObserver - ); - }); - - act(() => { - flushQueuedFrames(); - }); - - expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); + expect(byStyle(container, css.EditorResponsiveAfterMultiline)).toBeNull(); + expect(screen.getByTestId('recorder')).toBeInTheDocument(); + expect(maxHeightOf(container)).toBe('50dvh'); }); +}); - it('counts trailing spaces toward the single-line wrap threshold', async () => { - render(); - const editable = document.querySelector('[data-editable-name="TrailingSpacesWrapHarness"]'); - const scroll = editable?.parentElement as HTMLElement | null; - - expect(scroll).not.toBeNull(); - expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); - - fireEvent.click(screen.getByRole('button', { name: 'Paste trailing spaces' })); - - await waitFor(() => { - expect(scroll).toHaveClass(css.EditorTextareaScrollMultiline); - }); - }); - - it('keeps fitting pasted text in single-line mode without deferring to the next frame', () => { - const queuedFrames = new Map(); - let nextFrameId = 1; - - const requestAnimationFrameStub = ((callback: FrameRequestCallback) => { - const frameId = nextFrameId; - nextFrameId += 1; - queuedFrames.set(frameId, callback); - return frameId; - }) as typeof window.requestAnimationFrame; - const cancelAnimationFrameStub = ((frameId: number) => { - queuedFrames.delete(frameId); - }) as typeof window.cancelAnimationFrame; - window.requestAnimationFrame = requestAnimationFrameStub; - window.cancelAnimationFrame = cancelAnimationFrameStub; - globalThis.requestAnimationFrame = requestAnimationFrameStub; - globalThis.cancelAnimationFrame = cancelAnimationFrameStub; - - render(); - const editable = document.querySelector('[data-editable-name="PasteNoWrapHarness"]'); - const scroll = editable?.parentElement as HTMLElement | null; - - expect(scroll).not.toBeNull(); - expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); - - act(() => { - fireEvent.click(screen.getByRole('button', { name: 'Paste short text' })); - }); - - expect(queuedFrames.size).toBe(0); - expect(scroll).not.toHaveClass(css.EditorTextareaScrollMultiline); +const pasteWith = (container: HTMLElement, clipboardData: Record) => { + fireEvent.paste(container.querySelector('.ProseMirror')!, { + clipboardData: { + getData: (format: string) => clipboardData[format] ?? '', + files: [], + types: Object.keys(clipboardData), + }, }); +}; +describe('CustomEditor paste', () => { it('reads the native clipboard when the ios webview delivers an empty paste event', async () => { isIosApp = true; - nativeClipboardText = 'first line\nsecond line'; + nativeClipboardText = 'from the native clipboard'; + const { container, editor } = renderEditor(); - pasteIntoFallbackHarness(); + pasteWith(container, { 'text/plain': '' }); - await waitFor(() => { - expect(pastedLines()).toEqual(['first line', 'second line']); - }); + await waitFor(() => expect(editor.getText()).toBe('from the native clipboard')); }); it('leaves an empty paste event alone outside the ios webview', async () => { - nativeClipboardText = 'should not be pasted'; - - pasteIntoFallbackHarness(); + isIosApp = false; + nativeClipboardText = 'from the native clipboard'; + const { container, editor } = renderEditor(); - await waitFor(() => { - expect(pastedLines()).toEqual(['']); - }); - }); + pasteWith(container, { 'text/plain': '' }); - it('only reads clipboard formats advertised by the data transfer', () => { - render(); - const editor = pasteFallbackEditor; - if (!editor) throw new Error('paste fallback harness did not mount'); - - const getData = vi.fn<(type: string) => string>((type) => - type === 'text/plain' ? 'pasted text' : '' - ); - const data = { - files: [], - getData, - types: ['text/plain'], - } as unknown as DataTransfer; - - act(() => { - Transforms.select(editor, { path: [0, 0], offset: 0 }); - editor.insertData(data); - }); - - expect(getData).toHaveBeenCalledTimes(1); - expect(getData).toHaveBeenCalledWith('text/plain'); - expect(pastedLines()).toEqual(['pasted text']); + await Promise.resolve(); + expect(editor.getText()).toBe(''); }); - it('keeps Slate fragment pastes intact without reading unrelated formats', () => { - render(); - const editor = pasteFallbackEditor; - if (!editor) throw new Error('paste fallback harness did not mount'); - - const fragment = btoa(encodeURIComponent(JSON.stringify([{ text: 'fragment text' }]))); - const getData = vi.fn<(type: string) => string>((type) => - type === 'application/x-slate-fragment' ? fragment : '' - ); - const data = { - files: [], - getData, - types: ['application/x-slate-fragment', 'text/plain'], - } as unknown as DataTransfer; - - act(() => { - Transforms.select(editor, { path: [0, 0], offset: 0 }); - editor.insertData(data); - }); - - expect(getData).toHaveBeenCalledTimes(1); - expect(getData).toHaveBeenCalledWith('application/x-slate-fragment'); - expect(pastedLines()).toEqual(['fragment text']); - }); - - it('keeps HTML-embedded Slate fragments intact without reading unrelated formats', () => { - render(); - const editor = pasteFallbackEditor; - if (!editor) throw new Error('paste fallback harness did not mount'); - - const fragment = btoa(encodeURIComponent(JSON.stringify([{ text: 'HTML fragment text' }]))); - const getData = vi.fn<(type: string) => string>((type) => - type === 'text/html' ? `fragment` : '' - ); - const data = { - files: [], - getData, - types: ['text/html', 'text/plain'], - } as unknown as DataTransfer; - - act(() => { - Transforms.select(editor, { path: [0, 0], offset: 0 }); - editor.insertData(data); - }); - - expect(getData).toHaveBeenCalledTimes(1); - expect(getData).toHaveBeenCalledWith('text/html'); - expect(pastedLines()).toEqual(['HTML fragment text']); - }); - - it('falls back to plain text when HTML does not contain a Slate fragment', () => { - render(); - const editor = pasteFallbackEditor; - if (!editor) throw new Error('paste fallback harness did not mount'); - - const getData = vi.fn<(type: string) => string>((type) => - type === 'text/html' ? 'pasted text' : 'pasted text' - ); - const data = { - files: [], - getData, - types: ['text/html', 'text/plain'], - } as unknown as DataTransfer; + it('does not read the native clipboard when the event already carries text', async () => { + isIosApp = true; + nativeClipboardText = 'from the native clipboard'; + const { container, editor } = renderEditor(); - act(() => { - Transforms.select(editor, { path: [0, 0], offset: 0 }); - editor.insertData(data); - }); + pasteWith(container, { 'text/plain': 'real clipboard text' }); - expect(getData).toHaveBeenNthCalledWith(1, 'text/html'); - expect(getData).toHaveBeenNthCalledWith(2, 'text/plain'); - expect(pastedLines()).toEqual(['pasted text']); + await Promise.resolve(); + expect(editor.getText()).not.toBe('from the native clipboard'); }); - it('reuses the cached measurement when resize observer fires without changing the single-line width', async () => { - const queuedFrames = new Map(); - let nextFrameId = 1; - let resizeObserverCallback: ResizeObserverCallback | undefined; - const observedElements = new Set(); - const flushQueuedFrames = () => { - let safetyCounter = 0; - while (queuedFrames.size > 0 && safetyCounter < 10) { - const pendingFrames = Array.from(queuedFrames.entries()); - queuedFrames.clear(); - pendingFrames.forEach(([, callback]) => { - callback(performance.now()); - }); - safetyCounter += 1; - } - }; - - const requestAnimationFrameStub = ((callback: FrameRequestCallback) => { - const frameId = nextFrameId; - nextFrameId += 1; - queuedFrames.set(frameId, callback); - return frameId; - }) as typeof window.requestAnimationFrame; - const cancelAnimationFrameStub = ((frameId: number) => { - queuedFrames.delete(frameId); - }) as typeof window.cancelAnimationFrame; - - window.requestAnimationFrame = requestAnimationFrameStub; - window.cancelAnimationFrame = cancelAnimationFrameStub; - globalThis.requestAnimationFrame = requestAnimationFrameStub; - globalThis.cancelAnimationFrame = cancelAnimationFrameStub; - globalThis.ResizeObserver = createResizeObserverStub(observedElements, (callback) => { - resizeObserverCallback = callback; - }); - - render(); - - act(() => { - fireEvent.click(screen.getByRole('button', { name: 'Add cached text' })); - }); - - await waitFor(() => { - expect(measurementCacheScrollHeightReads).toBe(1); - }); - expect(resizeObserverCallback).toBeDefined(); - - act(() => { - resizeObserverCallback?.( - Array.from(observedElements).map((target) => ({ target }) as ResizeObserverEntry), - {} as ResizeObserver - ); + it('lets a consumer handler pre-empt the native clipboard fallback', async () => { + isIosApp = true; + nativeClipboardText = 'from the native clipboard'; + const { container, editor } = renderEditor({ + onPaste: (event) => event.preventDefault(), }); - act(() => { - flushQueuedFrames(); - }); + pasteWith(container, { 'text/plain': '' }); - expect(measurementCacheScrollHeightReads).toBe(1); + await Promise.resolve(); + expect(editor.getText()).toBe(''); }); }); diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index 481e427cfc..c007212d76 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -1,599 +1,215 @@ import type { ClipboardEventHandler, - FocusEvent, KeyboardEventHandler, MutableRefObject, ReactNode, } from 'react'; -import { forwardRef, memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { Box, Scroll, Text } from 'folds'; -import type { Descendant, Editor } from 'slate'; -import { Node, Transforms, createEditor } from 'slate'; -import type { RenderLeafProps, RenderElementProps, RenderPlaceholderProps } from 'slate-react'; -import { Slate, Editable, withReact, ReactEditor } from 'slate-react'; -import { withHistory } from 'slate-history'; +import { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Scroll } from 'folds'; import { iosApp, isMobileOrTablet } from '$utils/platform'; import { readClipboardText } from '$utils/dom'; import { createLogger } from '$utils/debug'; -import { BlockType } from './types'; -import { RenderElement, RenderLeaf } from './Elements'; -import type { CustomElement } from './slate'; -import * as css from './Editor.css'; -import { toggleKeyboardShortcut } from './keyboard'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import type { EditorDocument } from './model'; +import { ProseMirrorEditable } from './ProseMirrorEditable'; +import { ProseMirrorEditorController } from './prosemirrorController'; +import { toggleProseMirrorKeyboardShortcut } from './keyboard'; +import { useEditorRenderContext } from './useEditorRenderContext'; +import * as css from './Editor.css'; -const withInline = (editor: Editor): Editor => { - const { isInline } = editor; - - editor.isInline = (element) => - [BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes( - element.type - ) || isInline(element); - - return editor; -}; - -const withVoid = (editor: Editor): Editor => { - const { isVoid } = editor; - - editor.isVoid = (element) => - [BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) || - isVoid(element); - - return editor; -}; - -const SLATE_FRAGMENT_TYPE = 'application/x-slate-fragment'; -const slateFragmentAttribute = /data-slate-fragment="(.+?)"/m; - -const insertSlateFragment = (editor: Editor, fragment: string): boolean => { - if (!fragment) return false; - - const decoded = decodeURIComponent(window.atob(fragment)); - editor.insertFragment(JSON.parse(decoded)); - return true; -}; - -const withEfficientClipboardReads = (editor: Editor): Editor => { - const { insertData } = editor; - - editor.insertData = (data) => { - const types = new Set(Array.from(data.types ?? [], (type) => type.toLowerCase())); - if (types.size === 0) { - insertData(data); - return; - } - - if ( - types.has(SLATE_FRAGMENT_TYPE) && - insertSlateFragment(editor, data.getData(SLATE_FRAGMENT_TYPE)) - ) { - return; - } - - if (types.has('text/html')) { - const [, fragment] = data.getData('text/html').match(slateFragmentAttribute) || []; - if (fragment && insertSlateFragment(editor, fragment)) return; - } - - if (types.has('text/plain')) editor.insertTextData(data); - }; - - return editor; -}; - -export const useEditor = (): Editor => { - const [editor] = useState(() => - withInline(withVoid(withEfficientClipboardReads(withReact(withHistory(createEditor()))))) - ); +export const useEditor = (): ProseMirrorEditorController => { + const [editor] = useState(() => new ProseMirrorEditorController()); + const renderContext = useEditorRenderContext(); + useEffect(() => { + editor.setRenderContext(renderContext); + }, [editor, renderContext]); return editor; }; const log = createLogger('Editor'); -const hasPasteData = (data: DataTransfer): boolean => - data.files.length > 0 || data.getData('text/plain') !== '' || data.getData('text/html') !== ''; - -const insertPastedText = (editor: Editor, text: string): void => { - text.split(/\r\n|\r|\n/).forEach((line, index) => { - if (index > 0) Transforms.splitNodes(editor, { always: true }); - Transforms.insertText(editor, line); - }); -}; - -const MemoizedEditable = memo(Editable); -const EDITABLE_STYLE = { boxShadow: 'none' }; - -type EditorChangeHandler = (value: Descendant[]) => void; -const MAX_MULTILINE_MEASURE_RETRIES = 2; -const MULTILINE_HEIGHT_EPSILON = 1; -const TRAILING_SPACE_SENTINEL = '\u200B'; - -const normalizeMeasurementText = (text: string): string => - /[ \t]+$/.test(text) ? `${text}${TRAILING_SPACE_SENTINEL}` : text; - -type MultilineMeasurementCache = { - result: boolean; - singleLineWidth: number; - styleKey: string; - text: string; -}; - type CustomEditorProps = { - editableName?: string; - top?: ReactNode; - bottom?: ReactNode; - before?: ReactNode; after?: ReactNode; - responsiveAfter?: ReactNode; + before?: ReactNode; + bottom?: ReactNode; + className?: string; + editableName?: string; + editor: ProseMirrorEditorController; + enterKeyHint?: 'enter' | 'send'; forceMultilineLayout?: boolean; maxHeight?: string; - editor: Editor; - placeholder?: string; + onChange?: (value: EditorDocument) => void; onKeyDown?: KeyboardEventHandler; onKeyUp?: KeyboardEventHandler; - onChange?: EditorChangeHandler; onPaste?: ClipboardEventHandler; - className?: string; - variant?: 'Surface' | 'SurfaceVariant' | 'Background'; - enterKeyHint?: 'enter' | 'send'; + placeholder?: string; + responsiveAfter?: ReactNode; suppressBlurRefocusRef?: MutableRefObject; + top?: ReactNode; + variant?: 'Surface' | 'SurfaceVariant' | 'Background'; }; + +/** + * Visual shell for the Sable editor. Its interface is engine-neutral: callers + * interact with a controller and Sable documents, never an editor engine state. + */ export const CustomEditor = forwardRef( ( { - editableName, - top, - bottom, - before, after, - responsiveAfter, + before, + bottom, + className, + editableName, + editor, + enterKeyHint, forceMultilineLayout = false, maxHeight = '50dvh', - editor, - placeholder, + onChange, onKeyDown, onKeyUp, - onChange, onPaste, - className, - variant = 'SurfaceVariant', - enterKeyHint, + placeholder, + responsiveAfter, suppressBlurRefocusRef, + variant = 'SurfaceVariant', + top, }, ref ) => { const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); - // Each instance must receive its own fresh node objects. - // Sharing a module-level constant causes Slate's global NODE_TO_ELEMENT - // WeakMap to be overwritten when multiple editors are mounted at the same - // time (e.g. RoomInput + MessageEditor in the thread drawer), leading to - // "Unable to find the path for Slate node" crashes. - const [slateInitialValue] = useState(() => [ - { type: BlockType.Paragraph, children: [{ text: '' }] }, - ]); + const [alwaysInlineEditor] = useSetting(settingsAtom, 'alwaysInlineEditor'); const rootRef = useRef(null); - const editableRef = useRef(null); const focusScrollTimerRef = useRef(); - const rowRef = useRef(null); - const beforeRef = useRef(null); - const afterRef = useRef(null); - const textMeasurerRef = useRef(null); - const measurementCacheRef = useRef(null); - const multilineMeasureFrameRef = useRef(null); - const multilineMeasureRetryRef = useRef(0); - const singleLineWidthOffsetRef = useRef(0); - const latestValueRef = useRef(editor.children); - const isMultilineRef = useRef(false); - const [isMultiline, setIsMultiline] = useState(false); - const [measurementVersion, setMeasurementVersion] = useState(0); - const hasBefore = Boolean(before); - const hasAfter = Boolean(after); - const hasResponsiveAfter = Boolean(responsiveAfter); - const [alwaysInlineEditor] = useSetting(settingsAtom, 'alwaysInlineEditor'); - const layoutIsMultiline = !alwaysInlineEditor && (isMultiline || forceMultilineLayout); - const showResponsiveAfterInFooter = hasResponsiveAfter && layoutIsMultiline; - const showResponsiveAfterInline = hasResponsiveAfter && !showResponsiveAfterInFooter; - const setRootRef = useCallback( - (node: HTMLDivElement | null) => { - rootRef.current = node; - if (typeof ref === 'function') { - ref(node); - } else if (ref) { - Reflect.set(ref, 'current', node); + // Buttons stay inline however tall the composer grows; only the audio + // recorder stacks, because its controls need a row of their own. + const layoutIsMultiline = !alwaysInlineEditor && forceMultilineLayout; + const showResponsiveAfterInFooter = Boolean(responsiveAfter) && layoutIsMultiline; + + useEffect(() => () => window.clearTimeout(focusScrollTimerRef.current), []); + const handleKeyDown: KeyboardEventHandler = useCallback( + (event) => { + onKeyDown?.(event); + if ( + !event.defaultPrevented && + toggleProseMirrorKeyboardShortcut(editor, event, shortcutOverrides) + ) { + event.preventDefault(); } }, - [ref] + [editor, onKeyDown, shortcutOverrides] ); - - const updateMultilineLayout = useCallback( - (value: Descendant[] = editor.children) => { - const hasMultipleBlocks = value.length > 1; - const text = value.map((node) => Node.string(node)).join(''); - const hasExplicitNewlines = text.includes('\n'); - - const editable = editableRef.current; - const row = rowRef.current; - const textMeasurer = textMeasurerRef.current; - if (editable && row && textMeasurer) { - const scroll = editable.parentElement as HTMLDivElement | null; - const computedStyle = getComputedStyle(editable); - const beforeWidth = beforeRef.current?.offsetWidth ?? 0; - const afterWidth = afterRef.current?.offsetWidth ?? 0; - const rowSingleLineWidth = row.offsetWidth - beforeWidth - afterWidth; - const isRenderedSingleLine = !layoutIsMultiline; - - if (isRenderedSingleLine && scroll) { - // Scroll.clientWidth is the width the editable actually gets after padding and - // scrollbar math. Cache that delta while we are rendered single-line so later - // hidden measurements can compare against the same usable width. - const renderedSingleLineWidth = scroll.clientWidth; - if (renderedSingleLineWidth > 0) { - singleLineWidthOffsetRef.current = Math.max( - 0, - rowSingleLineWidth - renderedSingleLineWidth - ); - } - } - - const singleLineWidth = Math.max( - 0, - rowSingleLineWidth - singleLineWidthOffsetRef.current + const handlePaste: ClipboardEventHandler = useCallback( + (event) => { + onPaste?.(event); + if (event.defaultPrevented || !iosApp() || event.clipboardData.getData('text/plain')) + return; + event.preventDefault(); + readClipboardText() + .then((text) => text && editor.insertText(text)) + .catch((error: unknown) => + log.warn('Failed to read the native clipboard on paste:', error) ); - - if ( - text.length > 0 && - singleLineWidth <= 0 && - multilineMeasureRetryRef.current < MAX_MULTILINE_MEASURE_RETRIES - ) { - multilineMeasureRetryRef.current += 1; - if (multilineMeasureFrameRef.current !== null) { - cancelAnimationFrame(multilineMeasureFrameRef.current); - } - multilineMeasureFrameRef.current = requestAnimationFrame(() => { - multilineMeasureFrameRef.current = null; - updateMultilineLayout(); - }); - return; - } - - multilineMeasureRetryRef.current = 0; - let nextMultiline = hasMultipleBlocks || hasExplicitNewlines; - if (!nextMultiline && text.length > 0) { - const styleKey = [ - computedStyle.font, - computedStyle.lineHeight, - computedStyle.letterSpacing, - computedStyle.fontKerning, - computedStyle.fontFeatureSettings, - computedStyle.fontVariationSettings, - computedStyle.textTransform, - computedStyle.textIndent, - computedStyle.tabSize, - ].join('|'); - const cachedMeasurement = measurementCacheRef.current; - - if ( - cachedMeasurement?.text === text && - cachedMeasurement.singleLineWidth === singleLineWidth && - cachedMeasurement.styleKey === styleKey - ) { - nextMultiline = cachedMeasurement.result; - } else { - textMeasurer.style.font = computedStyle.font; - textMeasurer.style.lineHeight = computedStyle.lineHeight; - textMeasurer.style.letterSpacing = computedStyle.letterSpacing; - textMeasurer.style.fontKerning = computedStyle.fontKerning; - textMeasurer.style.fontFeatureSettings = computedStyle.fontFeatureSettings; - textMeasurer.style.fontVariationSettings = computedStyle.fontVariationSettings; - textMeasurer.style.textTransform = computedStyle.textTransform; - textMeasurer.style.textIndent = computedStyle.textIndent; - textMeasurer.style.tabSize = computedStyle.tabSize; - // Measure against a hidden clone instead of the live editable so we can ask - // "would this wrap at single-line width?" without the current layout feeding - // back into the answer. - const measureHeight = (content: string, width: string): number => { - textMeasurer.style.width = width; - textMeasurer.textContent = normalizeMeasurementText(content); - return textMeasurer.scrollHeight; - }; - const singleLineHeight = measureHeight('M', 'max-content'); - const measuredHeight = measureHeight(text, `${Math.max(singleLineWidth, 0)}px`); - nextMultiline = measuredHeight > singleLineHeight + MULTILINE_HEIGHT_EPSILON; - measurementCacheRef.current = { - result: nextMultiline, - singleLineWidth, - styleKey, - text, - }; - } - } else { - measurementCacheRef.current = null; - } - - isMultilineRef.current = nextMultiline; - setIsMultiline(nextMultiline); - } else { - const nextMultiline = hasMultipleBlocks || hasExplicitNewlines; - isMultilineRef.current = nextMultiline; - setIsMultiline(nextMultiline); - } }, - [editor, layoutIsMultiline] + [editor, onPaste] ); - useEffect(() => { - const root = rootRef.current; - if (!root) { - return undefined; - } - - const measurerHost = document.createElement('div'); - const textMeasurer = document.createElement('div'); - measurerHost.setAttribute('aria-hidden', 'true'); - textMeasurer.setAttribute('aria-hidden', 'true'); - if (editableName) { - textMeasurer.dataset.editorMeasurer = editableName; - } - Object.assign(measurerHost.style, { - position: 'absolute', - inset: '0', - width: '0', - height: '0', - overflow: 'hidden', - pointerEvents: 'none', - visibility: 'hidden', - zIndex: '-1', - }); - Object.assign(textMeasurer.style, { - padding: '0', - border: '0', - margin: '0', - whiteSpace: 'pre-wrap', - overflowWrap: 'break-word', - wordBreak: 'break-word', - boxSizing: 'border-box', - }); - measurerHost.appendChild(textMeasurer); - root.appendChild(measurerHost); - textMeasurerRef.current = textMeasurer; - - return () => { - measurementCacheRef.current = null; - textMeasurerRef.current = null; - measurerHost.remove(); - }; - }, [editableName]); - - useEffect( - () => () => { - if (multilineMeasureFrameRef.current !== null) { - cancelAnimationFrame(multilineMeasureFrameRef.current); - } - measurementCacheRef.current = null; - multilineMeasureRetryRef.current = 0; - }, - [] - ); - - const cancelFocusScroll = useCallback(() => { - window.clearTimeout(focusScrollTimerRef.current); - }, []); - - useEffect(() => cancelFocusScroll, [cancelFocusScroll]); - - const queueMultilineMeasurement = useCallback( - (resetRetry = true) => { - if (multilineMeasureFrameRef.current !== null) { - cancelAnimationFrame(multilineMeasureFrameRef.current); - } - if (resetRetry) { - multilineMeasureRetryRef.current = 0; - } - multilineMeasureFrameRef.current = requestAnimationFrame(() => { - multilineMeasureFrameRef.current = null; - updateMultilineLayout(); - }); + const setRootRef = useCallback( + (element: HTMLDivElement | null) => { + rootRef.current = element; + if (typeof ref === 'function') ref(element); + else if (ref) ref.current = element; }, - [updateMultilineLayout] + [ref] ); - - useEffect(() => { - if (typeof ResizeObserver === 'undefined') { - return undefined; - } - - const observer = new ResizeObserver(() => { - queueMultilineMeasurement(); - }); - const observedElements = [rowRef.current, beforeRef.current, afterRef.current].filter( - (element): element is HTMLDivElement => element !== null - ); - - observedElements.forEach((element) => observer.observe(element)); - - return () => observer.disconnect(); - }, [ - queueMultilineMeasurement, - updateMultilineLayout, - hasBefore, - hasAfter, - showResponsiveAfterInline, - ]); - - useLayoutEffect(() => { - updateMultilineLayout(latestValueRef.current); - }, [measurementVersion, updateMultilineLayout]); - - const handleChange = useCallback( - (value: Descendant[]) => { - latestValueRef.current = value; - measurementCacheRef.current = null; - if (multilineMeasureFrameRef.current !== null) { - cancelAnimationFrame(multilineMeasureFrameRef.current); - multilineMeasureFrameRef.current = null; - } - setMeasurementVersion((version) => version + 1); - onChange?.(value); + const handleDocumentChange = useCallback( + (document: EditorDocument) => { + onChange?.(document); }, [onChange] ); - const renderElement = useCallback( - (props: RenderElementProps) => , - [] - ); - - const renderLeaf = useCallback((props: RenderLeafProps) => , []); - - const latestRef = useRef({ editor, onKeyDown, onKeyUp, onPaste, shortcutOverrides }); - latestRef.current = { editor, onKeyDown, onKeyUp, onPaste, shortcutOverrides }; - - const handleKeydown: KeyboardEventHandler = useCallback((evt) => { - const { editor: slate, onKeyDown: keyDown, shortcutOverrides: overrides } = latestRef.current; - keyDown?.(evt); - - const shortcutToggled = toggleKeyboardShortcut(slate, evt, overrides); - if (shortcutToggled) evt.preventDefault(); - }, []); - - const handleKeyUp: KeyboardEventHandler = useCallback((evt) => { - latestRef.current.onKeyUp?.(evt); - }, []); - - const handlePaste: ClipboardEventHandler = useCallback((evt) => { - const { editor: slate, onPaste: paste } = latestRef.current; - paste?.(evt); - if (evt.isDefaultPrevented() || !iosApp() || hasPasteData(evt.clipboardData)) return; - - evt.preventDefault(); - readClipboardText() - .then((text) => { - if (text) insertPastedText(slate, text); - }) - .catch((err: unknown) => { - log.warn('Failed to read the native clipboard on paste:', err); - }); - }, []); - - const handleBlur = useCallback( - (evt: FocusEvent) => { - cancelFocusScroll(); - if (!isMobileOrTablet()) return; - if (suppressBlurRefocusRef?.current) return; - const next = evt.relatedTarget as HTMLElement | null; - if (!next) return; - if (next !== editableRef.current && next.isContentEditable) return; - ReactEditor.focus(latestRef.current.editor); - }, - [cancelFocusScroll, suppressBlurRefocusRef] - ); - - const handleFocus = useCallback(() => { - if (!isMobileOrTablet()) return; - cancelFocusScroll(); - const scrollIn = () => { - if (editableRef.current?.contains(document.activeElement)) { - rootRef.current?.scrollIntoView({ block: 'nearest' }); - } - }; - window.visualViewport?.addEventListener('resize', scrollIn, { once: true }); - focusScrollTimerRef.current = window.setTimeout(scrollIn, 500); - }, [cancelFocusScroll]); - - const renderPlaceholder = useCallback( - ({ attributes, children }: RenderPlaceholderProps) => ( - - {/* Inner component to style the actual text position and appearance */} - - {children} - - - ), - [] - ); - return ( -
- - {top} - + {top} + + {before && ( + + {before} + + )} + - {hasBefore && ( - - {before} - - )} - { + if (!isMobileOrTablet() || suppressBlurRefocusRef?.current) return; + const next = event.relatedTarget as HTMLElement | null; + if (!next || (next !== event.currentTarget && next.isContentEditable)) return; + editor.focus(); + }} + onFocus={() => { + if (!isMobileOrTablet()) return; + const editable = document.activeElement; + window.clearTimeout(focusScrollTimerRef.current); + const scrollIntoView = () => { + if (editable && editable === document.activeElement) { + rootRef.current?.scrollIntoView({ block: 'nearest' }); + } + }; + window.visualViewport?.addEventListener('resize', scrollIntoView, { once: true }); + focusScrollTimerRef.current = window.setTimeout(scrollIntoView, 500); }} - size="300" - visibility="Always" - hideTrack + /> + + {(after || (responsiveAfter && !showResponsiveAfterInFooter)) && ( + + {!showResponsiveAfterInFooter && responsiveAfter} + {after} + + )} + {showResponsiveAfterInFooter && ( + - - {(hasAfter || showResponsiveAfterInline) && ( - - {showResponsiveAfterInline && responsiveAfter} - {after} - - )} - {showResponsiveAfterInFooter && ( - - {responsiveAfter} - - )} - - {bottom} - + {responsiveAfter} + + )} + + {bottom}
); } diff --git a/src/app/components/editor/Elements.tsx b/src/app/components/editor/Elements.tsx deleted file mode 100644 index 2b14ced9d3..0000000000 --- a/src/app/components/editor/Elements.tsx +++ /dev/null @@ -1,174 +0,0 @@ -import { Text } from 'folds'; -import type { RenderElementProps, RenderLeafProps } from 'slate-react'; -import { useFocused, useSelected, useSlate } from 'slate-react'; -import { useAtomValue } from 'jotai'; - -import * as css from '$styles/CustomHtml.css'; -import { Image as MediaImage } from '$components/media'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import { mxcUrlToHttp } from '$utils/matrix'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { nicknamesAtom } from '$state/nicknames'; -import { BlockType } from './types'; -import { formatMentionElementDisplayName, getBeginCommand } from './utils'; -import type { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate'; - -// Put this at the start and end of an inline component to work around this Chromium bug: -// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405 -function InlineChromiumBugfix() { - return ( - - {String.fromCodePoint(160) /* Non-breaking space */} - - ); -} - -function RenderMentionElement({ - attributes, - element, - children, -}: { element: MentionElement } & RenderElementProps) { - const selected = useSelected(); - const focused = useFocused(); - const nicknames = useAtomValue(nicknamesAtom); - - const nickname = nicknames[element.id]; - const displayName = nickname ? `@${nickname}` : formatMentionElementDisplayName(element); - - return ( - - {displayName} - {children} - - ); -} -function RenderCommandElement({ - attributes, - element, - children, -}: { element: CommandElement } & RenderElementProps) { - const selected = useSelected(); - const focused = useFocused(); - const editor = useSlate(); - - return ( - - {`/${element.command}`} - {children} - - ); -} - -function RenderEmoticonElement({ - attributes, - element, - children, -}: { element: EmoticonElement } & RenderElementProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const selected = useSelected(); - const focused = useFocused(); - - return ( - - - {element.key.startsWith('mxc://') ? ( - - ) : ( - element.key - )} - {children} - - - ); -} - -function RenderLinkElement({ - attributes, - element, - children, -}: { element: LinkElement } & RenderElementProps) { - return ( - - - {children} - - ); -} - -export function RenderElement({ attributes, element, children }: RenderElementProps) { - switch (element.type) { - case BlockType.Paragraph: - return ( - - {children} - - ); - case BlockType.Mention: - return ( - - {children} - - ); - case BlockType.Emoticon: - return ( - - {children} - - ); - case BlockType.Link: - return ( - - {children} - - ); - case BlockType.Command: - return ( - - {children} - - ); - default: - return ( - - {children} - - ); - } -} - -export function RenderLeaf({ attributes, children }: RenderLeafProps) { - return {children}; -} diff --git a/src/app/components/editor/MarkdownToolbar.tsx b/src/app/components/editor/MarkdownToolbar.tsx index adda38c2f8..8588cd79d9 100644 --- a/src/app/components/editor/MarkdownToolbar.tsx +++ b/src/app/components/editor/MarkdownToolbar.tsx @@ -7,18 +7,13 @@ import type { MouseEventHandler, ReactNode } from 'react'; import { useEffect, useState } from 'react'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { ReactEditor, useSlate } from 'slate-react'; import { stopPropagation } from '$utils/keyboard'; import { floatingToolbar } from '$styles/overrides/Composer.css'; -import { - applyMarkdownBlockPrefix, - applyMarkdownInline, - BLOCK_PREFIXES, - INLINE_MARKERS, -} from './keyboard'; +import { BLOCK_PREFIXES, INLINE_MARKERS } from './keyboard'; import type { ShortcutId, ShortcutOverrides } from '../../keyboard/shortcuts'; import { formatShortcut, getShortcutBinding } from '../../keyboard/shortcuts'; import * as css from './Editor.css'; +import type { ProseMirrorEditorController } from './prosemirrorController'; import { CaretDown, Code, @@ -61,17 +56,16 @@ const shortcutLabel = (id: ShortcutId, overrides: ShortcutOverrides) => formatShortcut(getShortcutBinding(id, overrides)); type MarkdownInlineButtonProps = { + actions: MarkdownEditorActions; marker: string; icon: PhosphorIcon; tooltip: ReactNode; }; -function MarkdownInlineButton({ marker, icon, tooltip }: MarkdownInlineButtonProps) { - const editor = useSlate(); - +function MarkdownInlineButton({ actions, marker, icon, tooltip }: MarkdownInlineButtonProps) { const handleClick = () => { - applyMarkdownInline(editor, marker); - ReactEditor.focus(editor); + actions.applyInline(marker); + actions.focus(); }; return ( @@ -92,17 +86,16 @@ function MarkdownInlineButton({ marker, icon, tooltip }: MarkdownInlineButtonPro } type MarkdownBlockButtonProps = { + actions: MarkdownEditorActions; prefix: string; icon: PhosphorIcon; tooltip: ReactNode; }; -function MarkdownBlockButton({ prefix, icon, tooltip }: MarkdownBlockButtonProps) { - const editor = useSlate(); - +function MarkdownBlockButton({ actions, prefix, icon, tooltip }: MarkdownBlockButtonProps) { const handleClick = () => { - applyMarkdownBlockPrefix(editor, prefix); - ReactEditor.focus(editor); + actions.applyBlock(prefix); + actions.focus(); }; return ( @@ -122,15 +115,14 @@ function MarkdownBlockButton({ prefix, icon, tooltip }: MarkdownBlockButtonProps ); } -function MarkdownHeadingButton() { - const editor = useSlate(); +function MarkdownHeadingButton({ actions }: { actions: MarkdownEditorActions }) { const [anchor, setAnchor] = useState(); const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const handleMenuSelect = (prefix: string) => { setAnchor(undefined); - applyMarkdownBlockPrefix(editor, prefix); - ReactEditor.focus(editor); + actions.applyBlock(prefix); + actions.focus(); }; const handleMenuOpen: MouseEventHandler = (evt) => { @@ -237,7 +229,13 @@ function MarkdownHeadingButton() { ); } -function MarkdownToolbar() { +type MarkdownEditorActions = { + applyBlock: (prefix: string) => void; + applyInline: (marker: string) => void; + focus: () => void; +}; + +function MarkdownToolbar({ actions }: { actions: MarkdownEditorActions }) { const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); return ( @@ -246,6 +244,7 @@ function MarkdownToolbar() { } /> - + @@ -390,7 +398,11 @@ export function MarkdownFormattingToolbarToggle({ ); } -export function MarkdownFormattingToolbarBottom() { +export function MarkdownFormattingToolbarBottom({ + controller, +}: { + controller: ProseMirrorEditorController; +}) { const [editorToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [composerToolbarOpen] = useSetting(settingsAtom, 'composerToolbarOpen'); @@ -399,7 +411,13 @@ export function MarkdownFormattingToolbarBottom() { return (
- + controller.applyMarkdownInline(marker), + applyBlock: (prefix) => controller.applyMarkdownBlockPrefix(prefix), + focus: () => controller.focus(), + }} + />
); } diff --git a/src/app/components/editor/ProseMirrorEditable.test.tsx b/src/app/components/editor/ProseMirrorEditable.test.tsx new file mode 100644 index 0000000000..1976403397 --- /dev/null +++ b/src/app/components/editor/ProseMirrorEditable.test.tsx @@ -0,0 +1,71 @@ +import { act, render } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import type { EditorDocument } from './model'; +import { BlockType } from './types'; +import { ProseMirrorEditable, type ProseMirrorEditableHandle } from './ProseMirrorEditable'; +import { ProseMirrorEditorController } from './prosemirrorController'; + +describe('ProseMirrorEditable', () => { + it('renders and updates the Sable document through its handle', () => { + const controller = new ProseMirrorEditorController(); + const ref = createRef(); + const { container } = render(); + + act(() => { + ref.current?.setDocument([ + { type: BlockType.Paragraph, children: [{ text: 'hello from ProseMirror' }] }, + ]); + }); + + expect(container.querySelector('.ProseMirror')).toHaveTextContent('hello from ProseMirror'); + expect(container.firstElementChild).toHaveClass('ProseMirror'); + expect(ref.current?.getDocument()).toEqual([ + { type: BlockType.Paragraph, children: [{ text: 'hello from ProseMirror' }] }, + ]); + }); + + it('emits one document change for a programmatic replacement while mounted', () => { + const controller = new ProseMirrorEditorController(); + const onDocumentChange = vi.fn<(document: EditorDocument) => void>(); + const ref = createRef(); + render( + + ); + + act(() => { + ref.current?.setDocument([ + { type: BlockType.Paragraph, children: [{ text: 'replacement' }] }, + ]); + }); + + expect(onDocumentChange).toHaveBeenCalledTimes(1); + expect(onDocumentChange).toHaveBeenLastCalledWith([ + { type: BlockType.Paragraph, children: [{ text: 'replacement' }] }, + ]); + }); + + it('removes browser paragraph margins from the editable document', () => { + const { container } = render( + + ); + + expect(container.querySelector('.ProseMirror > p')).toHaveStyle({ margin: '0px' }); + }); + + it('places editable semantics on the ProseMirror document', () => { + const { container } = render( + + ); + + const editable = container.querySelector('.ProseMirror')!; + expect(editable).toHaveAttribute('role', 'textbox'); + expect(editable).toHaveAttribute('data-editable-name', 'RoomInput'); + expect(editable).toHaveAttribute('enterkeyhint', 'send'); + expect(container.firstElementChild).toBe(editable); + }); +}); diff --git a/src/app/components/editor/ProseMirrorEditable.tsx b/src/app/components/editor/ProseMirrorEditable.tsx new file mode 100644 index 0000000000..92d7edb7af --- /dev/null +++ b/src/app/components/editor/ProseMirrorEditable.tsx @@ -0,0 +1,86 @@ +import type { HTMLAttributes, KeyboardEventHandler } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; +import type { EditorDocument } from './model'; +import type { ProseMirrorEditorController } from './prosemirrorController'; + +export type ProseMirrorEditableHandle = { + clear: () => void; + focus: () => void; + getDocument: () => EditorDocument; + setDocument: (document: EditorDocument) => void; +}; + +type ProseMirrorEditableProps = Omit, 'onChange'> & { + controller: ProseMirrorEditorController; + editableName?: string; + editorClassName?: string; + onHostChange?: (element: HTMLDivElement | null) => void; + onDocumentChange?: (document: EditorDocument) => void; + placeholder?: string; +}; + +/** React host for the private ProseMirror controller seam. */ +export const ProseMirrorEditable = forwardRef( + ( + { + controller, + editorClassName, + onHostChange, + onDocumentChange, + onKeyDown, + placeholder, + enterKeyHint, + editableName, + ...props + }, + ref + ) => { + const rootRef = useRef(null); + + // ProseMirror does not bind Enter; a consumer that sends calls + // preventDefault, so anything left over is a line break. + const handleKeyDown: KeyboardEventHandler = (event) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.key !== 'Enter') return; + if (event.nativeEvent.isComposing) return; + event.preventDefault(); + controller.insertNewline(); + }; + + const setRootRef = (element: HTMLDivElement | null) => { + rootRef.current = element; + onHostChange?.(element); + }; + + useImperativeHandle( + ref, + () => ({ + clear: () => controller.clear(), + focus: () => controller.focus(), + getDocument: () => controller.getDocument(), + setDocument: (document) => controller.setDocument(document), + }), + [controller] + ); + + useEffect(() => { + const root = rootRef.current; + if (!root) return undefined; + // ProseMirror owns these; React must not also render them. + return controller.mount(root, { + ...(editorClassName ? { class: editorClassName } : {}), + ...(placeholder ? { 'data-placeholder': placeholder, 'aria-label': placeholder } : {}), + ...(editableName ? { 'data-editable-name': editableName } : {}), + ...(enterKeyHint ? { enterkeyhint: enterKeyHint } : {}), + role: 'textbox', + }); + }, [controller, editableName, editorClassName, enterKeyHint, placeholder]); + + useEffect( + () => controller.subscribe((document) => onDocumentChange?.(document)), + [controller, onDocumentChange] + ); + + return
; + } +); diff --git a/src/app/components/editor/ProseMirrorEditorSurface.tsx b/src/app/components/editor/ProseMirrorEditorSurface.tsx new file mode 100644 index 0000000000..1a259fc4cc --- /dev/null +++ b/src/app/components/editor/ProseMirrorEditorSurface.tsx @@ -0,0 +1,52 @@ +import type { KeyboardEventHandler, ReactNode } from 'react'; +import { Scroll } from 'folds'; +import type { EditorDocument } from './model'; +import { ProseMirrorEditable } from './ProseMirrorEditable'; +import type { ProseMirrorEditorController } from './prosemirrorController'; +import * as css from './Editor.css'; + +type ProseMirrorEditorSurfaceProps = { + bottom?: ReactNode; + controller: ProseMirrorEditorController; + maxHeight?: string; + onDocumentChange?: (document: EditorDocument) => void; + onKeyDown?: KeyboardEventHandler; + onKeyUp?: KeyboardEventHandler; + placeholder: string; + variant?: 'Surface' | 'SurfaceVariant' | 'Background'; +}; + +/** Shared visual host for ProseMirror editors; feature code only sees the controller seam. */ +export function ProseMirrorEditorSurface({ + bottom, + controller, + maxHeight = '50dvh', + onDocumentChange, + onKeyDown, + onKeyUp, + placeholder, + variant = 'SurfaceVariant', +}: ProseMirrorEditorSurfaceProps) { + return ( +
+ + + + {bottom} +
+ ); +} diff --git a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx index 84acf2e213..3641ec7ee1 100644 --- a/src/app/components/editor/autocomplete/AutocompleteMenu.tsx +++ b/src/app/components/editor/autocomplete/AutocompleteMenu.tsx @@ -1,13 +1,11 @@ import type { ReactNode } from 'react'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; import { Header, Menu, Scroll, config } from 'folds'; import { preventScrollWithArrowKey, stopPropagation } from '$utils/keyboard'; import { useAlive } from '$hooks/useAlive'; -import type { Editor } from 'slate'; -import { focusEditor } from '$components/editor/utils'; import * as css from './AutocompleteMenu.css'; import { BaseAutocompleteMenu } from './BaseAutocompleteMenu'; @@ -15,14 +13,8 @@ type AutocompleteMenuProps = { requestClose: () => void; headerContent: ReactNode; children: ReactNode; - editor: Editor; }; -export function AutocompleteMenu({ - headerContent, - requestClose, - children, - editor, -}: AutocompleteMenuProps) { +export function AutocompleteMenu({ headerContent, requestClose, children }: AutocompleteMenuProps) { const alive = useAlive(); const itemsRef = useRef(null); @@ -33,7 +25,6 @@ export function AutocompleteMenu({ } }; const [isActive, setIsActive] = useState(true); - useEffect(() => focusEditor(editor), [editor, isActive]); function handleInput(evt: KeyboardEvent) { if (!evt) return; if ( diff --git a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx index d2532efb69..bab4457d69 100644 --- a/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx @@ -1,6 +1,5 @@ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'; import { useEffect, useMemo } from 'react'; -import type { Editor } from 'slate'; import { Box, MenuItem, Text, toRem } from 'folds'; import type { Room } from '$types/matrix-sdk'; @@ -20,9 +19,12 @@ import { ImageUsage } from '$plugins/custom-emoji'; import { getEmoticonSearchStr } from '$plugins/utils'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { createEmoticonElement, moveCursor, replaceWithElement } from '$components/editor/utils'; +import { createEmoticonElement } from '$components/editor/utils'; +import type { + EditorAutocompleteQuery, + ProseMirrorEditorController, +} from '$components/editor/prosemirrorController'; import { AutocompleteMenu } from './AutocompleteMenu'; -import type { AutocompleteQuery } from './autocompleteQuery'; type EmoticonCompleteHandler = (key: string, shortcode: string) => void; @@ -31,8 +33,8 @@ type EmoticonSearchItem = PackImageReader | IEmoji; type EmoticonAutocompleteProps = { title?: string; imagePackRooms: Room[]; - editor: Editor; - query: AutocompleteQuery; + controller: ProseMirrorEditorController; + query: EditorAutocompleteQuery; requestClose: () => void; // this allows you to override the default behaviour of inserting the selection // used to implement the +: reaction shortcut @@ -48,7 +50,7 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = { export function EmoticonAutocomplete({ title, imagePackRooms, - editor, + controller, query, requestClose, onEmoticonSelected, @@ -89,9 +91,8 @@ export function EmoticonAutocomplete({ const handleAutocomplete: EmoticonCompleteHandler = onEmoticonSelected ?? ((key, shortcode) => { - const emoticonEl = createEmoticonElement(key, shortcode); - replaceWithElement(editor, query.range, emoticonEl); - moveCursor(editor, true); + controller.insertInline(createEmoticonElement(key, shortcode), query.from, query.to); + controller.insertText(' '); requestClose(); }); @@ -108,7 +109,6 @@ export function EmoticonAutocomplete({ {title ?? 'Emojis'}} requestClose={requestClose} - editor={editor} > {autoCompleteEmoticon.map((emoticon) => { const isCustomEmoji = 'url' in emoticon; diff --git a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx index 7a2df9761f..68f55d495c 100644 --- a/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx @@ -1,6 +1,5 @@ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'; import { useCallback, useEffect, useMemo } from 'react'; -import type { Editor } from 'slate'; import { Avatar, MenuItem, Text } from 'folds'; import { Hash, sizedIcon } from '$components/icons/phosphor'; import type { MatrixClient } from '$types/matrix-sdk'; @@ -19,10 +18,13 @@ 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 { createMentionElement, moveCursor, replaceWithElement } from '$components/editor/utils'; +import { createMentionElement } from '$components/editor/utils'; import { getMxIdServer } from '$utils/mxIdHelper'; import { AutocompleteMenu } from './AutocompleteMenu'; -import type { AutocompleteQuery } from './autocompleteQuery'; +import type { + EditorAutocompleteQuery, + ProseMirrorEditorController, +} from '../prosemirrorController'; type MentionAutoCompleteHandler = (roomAliasOrId: string, name: string) => void; @@ -35,7 +37,7 @@ function UnknownRoomMentionItem({ query, handleAutocomplete, }: { - query: AutocompleteQuery; + query: EditorAutocompleteQuery; handleAutocomplete: MentionAutoCompleteHandler; }) { const mx = useMatrixClient(); @@ -61,8 +63,8 @@ function UnknownRoomMentionItem({ type RoomMentionAutocompleteProps = { roomId: string; - editor: Editor; - query: AutocompleteQuery; + controller: ProseMirrorEditorController; + query: EditorAutocompleteQuery; requestClose: () => void; }; @@ -74,7 +76,7 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = { export function RoomMentionAutocomplete({ roomId, - editor, + controller, query, requestClose, }: RoomMentionAutocompleteProps) { @@ -119,8 +121,8 @@ export function RoomMentionAutocomplete({ undefined, viaServers ); - replaceWithElement(editor, query.range, mentionEl); - moveCursor(editor, true); + controller.insertInline(mentionEl, query.from, query.to); + controller.insertText(' '); requestClose(); }; @@ -139,11 +141,7 @@ export function RoomMentionAutocomplete({ }); return ( - Rooms} - requestClose={requestClose} - editor={editor} - > + Rooms} requestClose={requestClose}> {autoCompleteRoomIds.length === 0 ? ( ) : ( diff --git a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx index e051977232..8ba8ec4897 100644 --- a/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx +++ b/src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx @@ -1,6 +1,5 @@ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'; import { useCallback, useEffect } from 'react'; -import type { Editor } from 'slate'; import { Avatar, MenuItem, Text } from 'folds'; import { userFallbackIcon } from '$components/icons/phosphor'; import type { MatrixClient, Room, RoomMember } from '$types/matrix-sdk'; @@ -18,15 +17,13 @@ import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useAtomValue } from 'jotai'; import { nicknamesAtom } from '$state/nicknames'; -import { - createMentionElement, - mentionNameForUserAutocomplete, - moveCursor, - replaceWithElement, -} from '$components/editor/utils'; +import { createMentionElement, mentionNameForUserAutocomplete } from '$components/editor/utils'; import { getMxIdServer } from '$utils/mxIdHelper'; import { AutocompleteMenu } from './AutocompleteMenu'; -import type { AutocompleteQuery } from './autocompleteQuery'; +import type { + EditorAutocompleteQuery, + ProseMirrorEditorController, +} from '../prosemirrorController'; import { KnownMembership } from '$types/matrix-sdk'; type MentionAutoCompleteHandler = (userId: string, name: string) => void; @@ -69,8 +66,8 @@ function UnknownMentionItem({ type UserMentionAutocompleteProps = { room: Room; - editor: Editor; - query: AutocompleteQuery; + controller: ProseMirrorEditorController; + query: EditorAutocompleteQuery; requestClose: () => void; }; @@ -90,7 +87,7 @@ const mxIdToName = (mxId: string) => getMxIdLocalPart(mxId) ?? mxId; export function UserMentionAutocomplete({ room, - editor, + controller, query, requestClose, }: UserMentionAutocompleteProps) { @@ -124,8 +121,8 @@ export function UserMentionAutocomplete({ mentionNameForUserAutocomplete(id, displayName, { room, nicknames }), isRoomPing ? isCurrentRoom : mx.getUserId() === id || isCurrentRoom ); - replaceWithElement(editor, query.range, mentionEl); - moveCursor(editor, true); + controller.insertInline(mentionEl, query.from, query.to); + controller.insertText(' '); requestClose(); }; @@ -154,11 +151,7 @@ export function UserMentionAutocomplete({ }); return ( - Mentions} - requestClose={requestClose} - editor={editor} - > + Mentions} requestClose={requestClose}> {query.text === 'room' && ( = { - range: BaseRange; - prefix: TPrefix; - text: string; -}; - -const getAutocompletePrefix = ( - editor: Editor, - queryRange: BaseRange, - validPrefixes: readonly TPrefix[] -): TPrefix | undefined => { - const world = Editor.string(editor, queryRange); - return validPrefixes.find((p) => world.startsWith(p)); -}; - -const getAutocompleteQueryText = (editor: Editor, queryRange: BaseRange, prefix: string): string => - Editor.string(editor, queryRange).slice(prefix.length); - -export const getAutocompleteQuery = ( - editor: Editor, - queryRange: BaseRange, - validPrefixes: readonly TPrefix[] -): AutocompleteQuery | undefined => { - const prefix = getAutocompletePrefix(editor, queryRange, validPrefixes); - if (!prefix) return undefined; - return { - range: queryRange, - prefix, - text: getAutocompleteQueryText(editor, queryRange, prefix), - }; -}; diff --git a/src/app/components/editor/getLinks.test.ts b/src/app/components/editor/getLinks.test.ts index a184ec55ae..d396d7c7ae 100644 --- a/src/app/components/editor/getLinks.test.ts +++ b/src/app/components/editor/getLinks.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { Descendant } from 'slate'; -import { getLinks, toPlainText } from './output'; -import type { ParagraphElement } from './slate'; +import { getDocumentLinks, toPlainText } from './output'; +import type { EditorDocument } from './model'; +import type { EditorParagraph as ParagraphElement } from './model'; import { BlockType } from './types'; describe('getLinks', () => { @@ -10,7 +10,7 @@ describe('getLinks', () => { type: BlockType.Paragraph, children: [{ text: 'Check out https://example.com for more info' }], }; - const links = getLinks([node]); + const links = getDocumentLinks([node]); expect(links).toContain('https://example.com'); }); @@ -19,7 +19,7 @@ describe('getLinks', () => { type: BlockType.Paragraph, children: [{ text: 'Check out for more info' }], }; - const links = getLinks([node]); + const links = getDocumentLinks([node]); expect(links).toEqual([]); }); @@ -28,7 +28,7 @@ describe('getLinks', () => { type: BlockType.Paragraph, children: [{ text: 'Check [my link](https://example.com) for more info' }], }; - const links = getLinks([node]); + const links = getDocumentLinks([node]); expect(links).toContain('https://example.com'); }); @@ -37,7 +37,7 @@ describe('getLinks', () => { type: BlockType.Paragraph, children: [{ text: '[https://example.com/](https://example.com/)' }], }; - const links = getLinks([node]); + const links = getDocumentLinks([node]); expect(links).toEqual(['https://example.com/']); }); @@ -46,17 +46,17 @@ describe('getLinks', () => { type: BlockType.Paragraph, children: [{ text: 'Do not visit `https://example.com` please' }], }; - const links = getLinks([node]); + const links = getDocumentLinks([node]); expect(links).toEqual([]); }); it('excludes URLs inside markdown code blocks spanning multiple paragraphs', () => { - const nodes: Descendant[] = [ + const nodes: EditorDocument = [ { type: BlockType.Paragraph, children: [{ text: '```' }] }, { type: BlockType.Paragraph, children: [{ text: 'https://example.com' }] }, { type: BlockType.Paragraph, children: [{ text: '```' }] }, ]; - const links = getLinks(nodes); + const links = getDocumentLinks(nodes); expect(links).toEqual([]); }); }); @@ -91,7 +91,7 @@ describe('toPlainText spoiler handling', () => { }, ], }; - const links = getLinks([node]); + const links = getDocumentLinks([node]); expect(links).toContain('https://visible.com'); expect(links).not.toContain('https://hidden.com'); }); diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts index 46ca31a11f..28cc24bd6d 100644 --- a/src/app/components/editor/index.ts +++ b/src/app/components/editor/index.ts @@ -1,10 +1,11 @@ export * from './autocomplete'; export * from './utils'; export * from './Editor'; -export * from './Elements'; export * from './keyboard'; export * from './output'; export * from './input'; export * from './MarkdownToolbar'; +export * from './ProseMirrorEditable'; +export * from './ProseMirrorEditorSurface'; export * from './types'; diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index f2553b1af1..67f85792dc 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -1,5 +1,3 @@ -import type { Descendant } from 'slate'; - import type { MentionResolveOptions } from './utils'; import { MX_EMOTICON_MD_END, @@ -8,8 +6,7 @@ import { validateMxcUrl, } from '$plugins/markdown/extensions/matrix-emoticon'; import { BlockType } from './types'; -import type { ParagraphElement } from './slate'; -import { createEmoticonElement } from './utils'; +import type { EditorDocument, EditorParagraph } from './model'; import { expandMatrixMentionMarkdownInText } from './matrixMentionMarkdown'; /** Matches placeholders emitted by htmlToMarkdown for <img data-mx-emoticon>. */ @@ -19,9 +16,9 @@ const MX_EMOTICON_MD_TOKEN = new RegExp( ); function mergeAdjacentTextNodes( - children: ParagraphElement['children'] -): ParagraphElement['children'] { - const out: ParagraphElement['children'] = []; + children: EditorParagraph['children'] +): EditorParagraph['children'] { + const out: EditorParagraph['children'] = []; for (const c of children) { if ('type' in c) { out.push(c); @@ -40,9 +37,9 @@ function mergeAdjacentTextNodes( function lineToParagraphChildren( line: string, mentionOptions?: MentionResolveOptions -): ParagraphElement['children'] { +): EditorParagraph['children'] { MX_EMOTICON_MD_TOKEN.lastIndex = 0; - const parts: ParagraphElement['children'] = []; + const parts: EditorParagraph['children'] = []; let last = 0; let match: RegExpExecArray | null; while ((match = MX_EMOTICON_MD_TOKEN.exec(line)) !== null) { @@ -53,7 +50,7 @@ function lineToParagraphChildren( } const [, src, shortcode] = match; if (src && shortcode && validateMxcUrl(src)) { - parts.push(createEmoticonElement(src, shortcode)); + parts.push({ type: BlockType.Emoticon, key: src, shortcode, children: [{ text: '' }] }); } else if (shortcode) { parts.push({ text: `:${shortcode.replace(/^:|:$/g, '')}:` }); } @@ -68,7 +65,7 @@ function lineToParagraphChildren( export const plainToEditorInput = ( text: string, mentionOptions?: MentionResolveOptions -): Descendant[] => { +): EditorDocument => { const lines = text.split('\n'); return lines.map((lineText) => ({ type: BlockType.Paragraph, diff --git a/src/app/components/editor/keyboard.ts b/src/app/components/editor/keyboard.ts index 12d31963fc..4b748f7a61 100644 --- a/src/app/components/editor/keyboard.ts +++ b/src/app/components/editor/keyboard.ts @@ -1,6 +1,5 @@ import type { KeyboardEvent } from 'react'; -import { Editor, Range, Transforms } from 'slate'; -import { HistoryEditor } from 'slate-history'; +import type { ProseMirrorEditorController } from './prosemirrorController'; import type { ShortcutId, ShortcutOverrides } from '../../keyboard/shortcuts'; import { matchesShortcut } from '../../keyboard/shortcuts'; @@ -26,60 +25,29 @@ export const BLOCK_PREFIXES = { const INLINE_ACTIONS = Object.entries(INLINE_MARKERS) as [ShortcutId, string][]; const BLOCK_ACTIONS = Object.entries(BLOCK_PREFIXES) as [ShortcutId, string][]; -export const applyMarkdownInline = (editor: Editor, marker: string) => { - if (editor.selection && Range.isExpanded(editor.selection)) { - const text = Editor.string(editor, editor.selection); - Transforms.insertText(editor, `${marker}${text}${marker}`); - } else { - Transforms.insertText(editor, `${marker}${marker}`); - Transforms.move(editor, { distance: marker.length, reverse: true }); - } -}; - -export const applyMarkdownBlockPrefix = (editor: Editor, prefix: string) => { - if (editor.selection) { - const path = editor.selection.anchor.path; - const startPoint = Editor.start(editor, path); - Transforms.insertText(editor, prefix, { at: startPoint }); - } -}; - -/** - * @return boolean true if shortcut is toggled. - */ -export const toggleKeyboardShortcut = ( - editor: Editor, +/** Same user-configurable Markdown shortcut policy for the ProseMirror adapter. */ +export const toggleProseMirrorKeyboardShortcut = ( + controller: ProseMirrorEditorController, event: KeyboardEvent, overrides: ShortcutOverrides ): boolean => { if (matchesShortcut('composer.undo', event, overrides)) { - event.preventDefault(); - HistoryEditor.undo(editor as HistoryEditor); + controller.undo(); return true; } if (matchesShortcut('composer.redo', event, overrides)) { - event.preventDefault(); - HistoryEditor.redo(editor as HistoryEditor); + controller.redo(); return true; } - - const blockToggled = BLOCK_ACTIONS.find(([id, prefix]) => { - if (matchesShortcut(id, event, overrides)) { - event.preventDefault(); - applyMarkdownBlockPrefix(editor, prefix); - return true; - } - return false; - }); - if (blockToggled) return true; - - const inlineToggled = INLINE_ACTIONS.find(([id, marker]) => { - if (matchesShortcut(id, event, overrides)) { - event.preventDefault(); - applyMarkdownInline(editor, marker); - return true; - } - return false; - }); - return !!inlineToggled; + const block = BLOCK_ACTIONS.find(([id]) => matchesShortcut(id, event, overrides)); + if (block) { + controller.applyMarkdownBlockPrefix(block[1]); + return true; + } + const inline = INLINE_ACTIONS.find(([id]) => matchesShortcut(id, event, overrides)); + if (inline) { + controller.applyMarkdownInline(inline[1]); + return true; + } + return false; }; diff --git a/src/app/components/editor/matrixMentionMarkdown.ts b/src/app/components/editor/matrixMentionMarkdown.ts index 619da1880f..cad1eab489 100644 --- a/src/app/components/editor/matrixMentionMarkdown.ts +++ b/src/app/components/editor/matrixMentionMarkdown.ts @@ -1,4 +1,5 @@ -import type { InlineElement } from './slate'; +import type { InlineToken } from './model'; +import { BlockType } from './types'; import { parseMatrixToRoom, parseMatrixToRoomEvent, @@ -7,7 +8,6 @@ import { } from '$plugins/matrix-to'; import type { MentionResolveOptions } from './utils'; import { - createMentionElement, getMarkdownCodeSpanRanges, isInsideMarkdownCodeSpan, resolveRoomMentionHighlight, @@ -23,39 +23,44 @@ export const mentionFromMatrixToMarkdownLink = ( label: string, href: string, options?: MentionResolveOptions -): InlineElement | null => { +): InlineToken | null => { const trimmedHref = href.trim(); if (!isMatrixToMentionHref(trimmedHref)) return null; const userId = parseMatrixToUser(trimmedHref); if (userId) { - return createMentionElement( - userId, - resolveUserMentionName(userId, options), - resolveUserMentionHighlight(userId, options) - ); + return { + type: BlockType.Mention, + id: userId, + name: resolveUserMentionName(userId, options), + highlight: resolveUserMentionHighlight(userId, options), + children: [{ text: '' }], + }; } const roomEvent = parseMatrixToRoomEvent(trimmedHref); if (roomEvent) { - return createMentionElement( - roomEvent.roomIdOrAlias, - resolveRoomMentionName(roomEvent.roomIdOrAlias, label, options), - resolveRoomMentionHighlight(roomEvent.roomIdOrAlias, options), - roomEvent.eventId, - roomEvent.viaServers - ); + return { + type: BlockType.Mention, + id: roomEvent.roomIdOrAlias, + name: resolveRoomMentionName(roomEvent.roomIdOrAlias, label, options), + highlight: resolveRoomMentionHighlight(roomEvent.roomIdOrAlias, options), + eventId: roomEvent.eventId, + viaServers: roomEvent.viaServers, + children: [{ text: '' }], + }; } const room = parseMatrixToRoom(trimmedHref); if (room) { - return createMentionElement( - room.roomIdOrAlias, - resolveRoomMentionName(room.roomIdOrAlias, label, options), - resolveRoomMentionHighlight(room.roomIdOrAlias, options), - undefined, - room.viaServers - ); + return { + type: BlockType.Mention, + id: room.roomIdOrAlias, + name: resolveRoomMentionName(room.roomIdOrAlias, label, options), + highlight: resolveRoomMentionHighlight(room.roomIdOrAlias, options), + viaServers: room.viaServers, + children: [{ text: '' }], + }; } return null; @@ -64,9 +69,9 @@ export const mentionFromMatrixToMarkdownLink = ( export const expandMatrixMentionMarkdownInText = ( text: string, options?: MentionResolveOptions -): InlineElement[] => { +): InlineToken[] => { const codeSpanRanges = getMarkdownCodeSpanRanges(text); - const parts: InlineElement[] = []; + const parts: InlineToken[] = []; let last = 0; MD_INLINE_LINK.lastIndex = 0; diff --git a/src/app/components/editor/model.ts b/src/app/components/editor/model.ts new file mode 100644 index 0000000000..72f8e2bcb9 --- /dev/null +++ b/src/app/components/editor/model.ts @@ -0,0 +1,63 @@ +import { BlockType } from './types'; + +/** Editor-engine-neutral document stored by Sable drafts and serializers. */ +export type EditorText = { text: string }; +export type LinkToken = { type: BlockType.Link; href: string; children: EditorText[] }; +export type MentionToken = { + type: BlockType.Mention; + id: string; + eventId?: string; + viaServers?: string[]; + highlight: boolean; + name: string; + children: EditorText[]; +}; +export type EmoticonToken = { + type: BlockType.Emoticon; + key: string; + shortcode: string; + children: EditorText[]; +}; +export type CommandToken = { type: BlockType.Command; command: string; children: EditorText[] }; +export type InlineToken = EditorText | LinkToken | MentionToken | EmoticonToken | CommandToken; +export type EditorParagraph = { type: BlockType.Paragraph; children: InlineToken[] }; +export type EditorDocument = EditorParagraph[]; + +export const isEditorText = (token: InlineToken | EditorParagraph): token is EditorText => + !('type' in token); +export const emptyEditorDocument = (): EditorDocument => [ + { type: BlockType.Paragraph, children: [{ text: '' }] }, +]; + +export const getDocumentBeginCommand = (document: EditorDocument): string | undefined => { + const paragraph = document[0]; + if (!paragraph) return undefined; + for (const token of paragraph.children) { + if (isEditorText(token) && token.text.trim() === '') continue; + return !isEditorText(token) && token.type === BlockType.Command ? token.command : undefined; + } + return undefined; +}; + +export const editorDocumentText = (document: EditorDocument): string => + document + .map((paragraph) => + paragraph.children + .map((token) => { + if (isEditorText(token)) return token.text; + switch (token.type) { + case BlockType.Link: + return `[${token.children.map((child) => child.text).join('')}](${token.href})`; + case BlockType.Mention: + return token.name === '@room' ? token.name : token.id; + case BlockType.Emoticon: + return token.key.startsWith('mxc://') ? `:${token.shortcode}:` : token.key; + case BlockType.Command: + return `/${token.command}`; + default: + return ''; + } + }) + .join('') + ) + .join('\n'); diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts index 6798cc6e5b..8366098dc2 100644 --- a/src/app/components/editor/output.test.ts +++ b/src/app/components/editor/output.test.ts @@ -85,7 +85,7 @@ describe('toMatrixCustomHTML matrix.to', () => { expect(html).not.toMatch(/]*matrix\.to/i); }); - it('serializes user mentions using room membership display name, not private Slate node.name', () => { + it('serializes user mentions using room membership display name, not private token names', () => { const room = roomWithMember('@alice:example.org', 'Alice'); const html = trimCustomHtml( toMatrixCustomHTML( @@ -232,7 +232,7 @@ describe('toMatrixCustomHTML angle bracket escapes', () => { }); describe('toMatrixCustomHTML single-newline markdown blocks', () => { - it('parses -# on a second Slate paragraph joined with a single newline', () => { + it('parses -# on a second editor paragraph joined with a single newline', () => { const html = trimCustomHtml( toMatrixCustomHTML( [ diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 04e2de3481..0afba26ecc 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -1,16 +1,17 @@ -import type { Descendant, Editor } from 'slate'; -import { Text } from 'slate'; +import type { EditorDocument, EditorParagraph, EditorText, InlineToken } from './model'; +import { editorDocumentText, isEditorText } from './model'; import type { MatrixClient, Room } from '$types/matrix-sdk'; import { sanitizeText } from '$utils/sanitize'; import { markdownToHtml, injectDataMd } from '$plugins/markdown'; import { sanitizeForRegex } from '$utils/regex'; import { getMxIdLocalPart, isUserId } from '$utils/matrix'; import { getMemberDisplayName } from '$utils/room/display'; -import type { CustomElement } from './slate'; import { BlockType } from './types'; import { getMarkdownCodeSpanRanges, isInsideMarkdownCodeSpan } from './utils'; import { MATRIX_TO_BASE, testMatrixTo } from '$plugins/matrix-to'; +type EditorNode = EditorParagraph | InlineToken; + export type OutputOptions = { /** * if true it will remove the nickname of the person from the message @@ -25,7 +26,7 @@ export type OutputOptions = { room?: Room; }; -const textToCustomHtml = (node: Text): string => sanitizeText(node.text); +const textToCustomHtml = (node: EditorText): string => sanitizeText(node.text); const markdownInlineLinkLabel = (label: string, fallback: string): string => { const t = label.trim(); @@ -45,7 +46,7 @@ const userMentionMarkdownLinkLabel = (userId: string, room: Room | undefined): s }; const elementToCustomHtml = ( - node: CustomElement, + node: Exclude, children: string, opts: OutputOptions ): string => { @@ -91,17 +92,17 @@ const elementToCustomHtml = ( }; /** - * convert slate internal representation to a custom HTML string that can be sent to the server - * @param node slate node + * Convert Sable's engine-neutral representation to Matrix custom HTML. + * @param node Sable editor document or token * @param opts options for output * @returns custom HTML string */ export const toMatrixCustomHTML = ( - node: Descendant | Descendant[], + node: EditorDocument | EditorParagraph | InlineToken, opts: OutputOptions ): string => { let markdownLines = ''; - const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => { + const parseNode = (n: EditorNode, index: number, targetNodes: readonly EditorNode[]) => { if ('type' in n && n.type === BlockType.Paragraph) { let line = toMatrixCustomHTML(n, opts); @@ -129,15 +130,18 @@ export const toMatrixCustomHTML = ( }; if (Array.isArray(node)) return node.map((element, index, array) => parseNode(element, index, array)).join(''); - if (Text.isText(node)) return textToCustomHtml(node); + if (isEditorText(node)) return textToCustomHtml(node); const children = node.children - .map((element, index, array) => parseNode(element, index, array)) + .map((element, index, array) => parseNode(element, index, array as readonly EditorNode[])) .join(''); return elementToCustomHtml(node, children, opts); }; -const elementToPlainText = (node: CustomElement, children: string): string => { +const elementToPlainText = ( + node: Exclude, + children: string +): string => { switch (node.type) { case BlockType.Paragraph: return `${children}\n`; @@ -160,15 +164,15 @@ export const LINKINPUTREGEX = new RegExp(`\\(?(${LINK_URL})\\)?`, 'g'); const SPOILEREDLINKINPUTREGEX = new RegExp(`<(${LINK_URL})>`, 'g'); const SPOILEREDLINKDIRECTREGEX = new RegExp(`\\|\\|(${LINK_URL})\\|\\|`, 'g'); /** - * convert slate internal representation to a plain text string that can be sent to the server - * @param node the slate node + * Convert Sable's engine-neutral representation to a plain text string that can be sent to the server. + * @param node the Sable editor document or token * @param isMarkdown set true if it's a markdown formatted text * @param stripNickname whether to strip nicknames * @param nickNameReplacement the nickname replacement * @returns the plain text we want to send */ export const toPlainText = ( - node: Descendant | Descendant[], + node: EditorDocument | EditorParagraph | InlineToken, stripNickname = false, stripSpoilers = true, nickNameReplacement?: Map @@ -177,7 +181,7 @@ export const toPlainText = ( return node .map((n) => toPlainText(n, stripNickname, stripSpoilers, nickNameReplacement)) .join(''); - if (Text.isText(node)) { + if (isEditorText(node)) { let { text } = node; if (stripSpoilers) { @@ -199,31 +203,6 @@ export const toPlainText = ( return elementToPlainText(node, children); }; -/** - * Convert slate internal representation to a raw plain text string without any replacements. - * This is used for link extraction to ensure we have the full context for markdown blocks. - */ -const toRawText = (node: Descendant | Descendant[]): string => { - if (Array.isArray(node)) return node.map(toRawText).join(''); - if (Text.isText(node)) return node.text; - - const children = node.children.map(toRawText).join(''); - switch (node.type) { - case BlockType.Paragraph: - return `${children}\n`; - case BlockType.Link: - return `[${children}](${node.href})`; - case BlockType.Emoticon: - return node.key?.startsWith('mxc://') ? `:${node.shortcode}:` : (node.key ?? ''); - case BlockType.Mention: - return node.name === '@room' ? node.name : node.id; - case BlockType.Command: - return `/${node.command}`; - default: - return children; - } -}; - /** * Check if customHtml is equals to plainText * by replacing `
` with `/n` in customHtml @@ -266,17 +245,25 @@ export type MentionsData = { * get the mentions in a message * @param mx the matrix client * @param roomId the room id we will send the message in - * @param editor the slate editor + * @param document the current Sable editor document * @returns the mentions in a message {@link MentionsData} */ -export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): MentionsData => { +export const getMentions = ( + mx: MatrixClient, + roomId: string, + document: { children: EditorDocument } +): MentionsData => { const mentionData: MentionsData = { room: false, users: new Set(), }; - const parseMentions = (node: Descendant): void => { - if (Text.isText(node)) return; + const parseMentions = (node: InlineToken | EditorDocument[number]): void => { + if (!('type' in node)) return; + if (node.type === BlockType.Paragraph) { + node.children.forEach(parseMentions); + return; + } if (node.type === BlockType.Mention) { if (node.name === '@room') { @@ -293,13 +280,16 @@ export const getMentions = (mx: MatrixClient, roomId: string, editor: Editor): M node.children.forEach(parseMentions); }; - editor.children.forEach(parseMentions); + document.children.forEach(parseMentions); return mentionData; }; -export const getLinks = (serialized: Descendant | Descendant[]): string[] | undefined => { - const text = toRawText(serialized); +/** Link extraction for the engine-neutral document used by outgoing messages. */ +export const getDocumentLinks = (document: EditorDocument): string[] | undefined => + linksFromText(editorDocumentText(document)); + +const linksFromText = (text: string): string[] | undefined => { const finalList = new Set(); // 1. Find all potential URLs diff --git a/src/app/components/editor/prosemirrorController.test.tsx b/src/app/components/editor/prosemirrorController.test.tsx new file mode 100644 index 0000000000..0ccedaa2c5 --- /dev/null +++ b/src/app/components/editor/prosemirrorController.test.tsx @@ -0,0 +1,236 @@ +import { act, fireEvent, render } from '@testing-library/react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { Selection } from 'prosemirror-state'; +import type { EditorView } from 'prosemirror-view'; +import type { EditorDocument } from './model'; +import { ProseMirrorEditable } from './ProseMirrorEditable'; +import { ProseMirrorEditorController } from './prosemirrorController'; +import { BlockType } from './types'; + +// ProseMirror scrolls the selection into view after a transaction, which needs +// client rects jsdom does not implement. +const emptyClientRects = () => [] as unknown as DOMRectList; +beforeAll(() => { + Element.prototype.getClientRects ??= emptyClientRects; + (Text.prototype as unknown as Element).getClientRects ??= emptyClientRects; +}); + +const doc = (...texts: string[]): EditorDocument => + texts.map((text) => ({ type: BlockType.Paragraph, children: [{ text }] })); + +const mount = (initial: EditorDocument = doc('')) => { + const controller = new ProseMirrorEditorController(initial); + const result = render(); + const editable = result.container.querySelector('.ProseMirror') as HTMLElement; + const view = (controller as unknown as { view: EditorView }).view; + const caretToEnd = () => + view.dispatch(view.state.tr.setSelection(Selection.atEnd(view.state.doc))); + return { caretToEnd, controller, editable, view }; +}; + +describe('ProseMirrorEditorController empty-document invariant', () => { + it('reports empty after the user deletes every character', () => { + const { controller, view } = mount(doc('hi')); + + view.dispatch(view.state.tr.delete(1, view.state.doc.content.size - 1)); + + expect(controller.getDocument()).toEqual(doc('')); + expect(controller.isEmpty()).toBe(true); + }); + + it('reports empty after clear() on a document that had content', () => { + const { controller } = mount(doc('sent message')); + + controller.clear(); + + expect(controller.getDocument()).toEqual(doc('')); + expect(controller.isEmpty()).toBe(true); + }); + + it('keeps one text token per paragraph so serializers see a well-formed document', () => { + const { controller, view } = mount(doc('a', 'b')); + + view.dispatch(view.state.tr.delete(1, 2)); + + expect(controller.getDocument()).toEqual(doc('', 'b')); + }); +}); + +describe('ProseMirrorEditorController appendDocument', () => { + it('appends after existing content instead of before it', () => { + const { controller } = mount(doc('existing')); + + controller.appendDocument(doc('restored')); + + expect(controller.getDocument()).toEqual(doc('existing', 'restored')); + }); + + it('replaces the document outright when the composer is empty', () => { + const { controller } = mount(doc('')); + + controller.appendDocument(doc('draft text')); + + expect(controller.getDocument()).toEqual(doc('draft text')); + }); + + it('appends multiple paragraphs in order without inserting a blank one', () => { + const { controller } = mount(doc('existing')); + + controller.appendDocument(doc('one', 'two')); + + expect(controller.getDocument()).toEqual(doc('existing', 'one', 'two')); + }); + + it('ignores an empty append', () => { + const { controller } = mount(doc('existing')); + + controller.appendDocument([]); + + expect(controller.getDocument()).toEqual(doc('existing')); + }); +}); + +describe('ProseMirrorEditorController getText', () => { + it('returns the full text so callers can slice a leading prefix', () => { + const { controller } = mount(doc('+#hello')); + + expect(controller.getText()).toBe('+#hello'); + expect(controller.getText().slice(0, 2)).toBe('+#'); + }); + + it('joins paragraphs with newlines', () => { + const { controller } = mount(doc('one', 'two')); + + expect(controller.getText()).toBe('one\ntwo'); + }); +}); + +describe('ProseMirrorEditorController autocomplete queries', () => { + it('only returns beginning-only prefixes from the first paragraph', () => { + const { caretToEnd, controller } = mount(doc('message +:wave')); + caretToEnd(); + + expect(controller.getAutocompleteQuery(['+:'], true)).toBeUndefined(); + }); + + it('allows beginning-only prefixes after leading whitespace', () => { + const { caretToEnd, controller } = mount(doc(' +:wave')); + caretToEnd(); + + expect(controller.getAutocompleteQuery(['+:'], true)).toMatchObject({ + prefix: '+:', + text: 'wave', + }); + }); +}); + +describe('placeholder', () => { + it('exposes the placeholder to assistive tech and to the overlay', () => { + const controller = new ProseMirrorEditorController(doc('')); + const { container } = render( + + ); + const editable = container.querySelector('.ProseMirror')!; + + expect(editable).toHaveAttribute('aria-label', 'Write a message'); + expect(editable).toHaveAttribute('data-placeholder', 'Write a message'); + expect(editable).toHaveAttribute('data-placeholder-visible', 'true'); + }); + + it('hides the placeholder once the document has content', () => { + const controller = new ProseMirrorEditorController(doc('')); + const { container } = render( + + ); + + act(() => controller.insertText('typed')); + + expect(container.querySelector('.ProseMirror')).toHaveAttribute( + 'data-placeholder-visible', + 'false' + ); + }); + + it('shows the placeholder again after the composer is cleared', () => { + const controller = new ProseMirrorEditorController(doc('sent')); + const { container } = render( + + ); + + act(() => controller.clear()); + + expect(container.querySelector('.ProseMirror')).toHaveAttribute( + 'data-placeholder-visible', + 'true' + ); + }); +}); + +describe('Enter handling', () => { + it('does not split the paragraph when the host treats Enter as send', () => { + const controller = new ProseMirrorEditorController(doc('hello')); + const onKeyDown = vi.fn<(event: { preventDefault: () => void }) => void>((event) => + event.preventDefault() + ); + const { container } = render( + + ); + + fireEvent.keyDown(container.querySelector('.ProseMirror')!, { key: 'Enter' }); + + expect(onKeyDown).toHaveBeenCalled(); + expect(controller.getDocument()).toEqual(doc('hello')); + }); + + it('breaks the line when the host leaves Enter unhandled', () => { + const { caretToEnd, controller, editable } = mount(doc('hello')); + caretToEnd(); + + fireEvent.keyDown(editable, { key: 'Enter', keyCode: 13 }); + + expect(controller.getDocument()).toEqual(doc('hello', '')); + }); + + it('breaks the line on Shift+Enter', () => { + const { caretToEnd, controller, editable } = mount(doc('hello')); + caretToEnd(); + + fireEvent.keyDown(editable, { key: 'Enter', keyCode: 13, shiftKey: true }); + + expect(controller.getDocument()).toEqual(doc('hello', '')); + }); + + it('leaves an in-flight IME composition alone', () => { + const { controller, editable } = mount(doc('hello')); + + fireEvent.keyDown(editable, { key: 'Enter', isComposing: true }); + + expect(controller.getDocument()).toEqual(doc('hello')); + }); +}); + +describe('clipboard', () => { + it('uses plain text instead of a structural HTML clipboard payload', () => { + const { caretToEnd, controller, editable } = mount(doc('hello')); + caretToEnd(); + + fireEvent.paste(editable, { + clipboardData: { + getData: (type: string) => + type === 'text/plain' ? ' copied text' : '

copied text

unexpected line

', + }, + }); + + expect(controller.getDocument()).toEqual(doc('hello copied text')); + }); + + it('preserves pasted line breaks', () => { + const { controller, editable } = mount(); + + fireEvent.paste(editable, { + clipboardData: { getData: () => 'first\n\nthird' }, + }); + + expect(controller.getDocument()).toEqual(doc('first', '', 'third')); + }); +}); diff --git a/src/app/components/editor/prosemirrorController.ts b/src/app/components/editor/prosemirrorController.ts new file mode 100644 index 0000000000..bc2c2f05bf --- /dev/null +++ b/src/app/components/editor/prosemirrorController.ts @@ -0,0 +1,257 @@ +import { baseKeymap, splitBlock } from 'prosemirror-commands'; +import { history, redo, undo } from 'prosemirror-history'; +import { keymap } from 'prosemirror-keymap'; +import { EditorState, Selection, TextSelection, type Transaction } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { Fragment, Slice, type Node as ProseMirrorNode } from 'prosemirror-model'; +import type { EditorDocument, InlineToken } from './model'; +import { emptyEditorDocument, isEditorText } from './model'; +import type { EditorRenderContext } from './prosemirrorNodeViews'; +import { + beginCommandPlugin, + buildEditorNodeViews, + defaultEditorRenderContext, +} from './prosemirrorNodeViews'; +import { + editorSchema, + fromProseMirrorDocument, + toProseMirrorDocument, + toProseMirrorInline, +} from './prosemirrorSchema'; + +const isProseMirrorDocumentEmpty = (doc: ProseMirrorNode): boolean => + doc.childCount === 1 && doc.firstChild?.content.size === 0; + +export type EditorAutocompleteQuery = { + from: number; + prefix: TPrefix; + text: string; + to: number; +}; + +/** + * The sole editor-engine seam. Consumers exchange Sable documents and never + * retain an EditorState or EditorView. + */ +export class ProseMirrorEditorController { + private document: EditorDocument; + private listeners = new Set<(document: EditorDocument) => void>(); + private renderContext: EditorRenderContext = defaultEditorRenderContext; + private view: EditorView | undefined; + + constructor(initialDocument: EditorDocument = emptyEditorDocument()) { + this.document = structuredClone(initialDocument); + } + + /** Rebuilding node views is a full redraw, so only react to a real change. */ + setRenderContext(context: EditorRenderContext): void { + if (context === this.renderContext) return; + this.renderContext = context; + this.view?.setProps({ nodeViews: buildEditorNodeViews(() => this.renderContext) }); + } + + get children(): EditorDocument { + return this.getDocument(); + } + + getDocument(): EditorDocument { + return structuredClone(this.document); + } + + isEmpty(): boolean { + const firstParagraph = this.document[0]; + const firstToken = firstParagraph?.children[0]; + return ( + this.document.length === 1 && + firstParagraph?.children.length === 1 && + !!firstToken && + isEditorText(firstToken) && + firstToken.text === '' + ); + } + + isSelectionAtStart(): boolean { + const view = this.view; + return !!view && view.state.selection.empty && view.state.selection.from === 1; + } + + getText(): string { + const view = this.view; + if (view) return view.state.doc.textBetween(0, view.state.doc.content.size, '\n', '\0'); + return this.document + .map((paragraph) => + paragraph.children.map((child) => (isEditorText(child) ? child.text : '')).join('') + ) + .join('\n'); + } + + setDocument(document: EditorDocument): void { + this.document = structuredClone(document.length ? document : emptyEditorDocument()); + if (this.view) { + const nextDocument = toProseMirrorDocument(this.document); + if (this.view.state.doc.eq(nextDocument)) return; + const transaction = this.view.state.tr.replaceWith( + 0, + this.view.state.doc.content.size, + nextDocument.content + ); + // Put the caret after the new content rather than wherever the old ended. + transaction.setSelection(Selection.atEnd(transaction.doc)); + this.view.dispatch(transaction); + return; + } + this.notify(); + } + + appendDocument(document: EditorDocument): void { + if (!document.length) return; + this.setDocument(this.isEmpty() ? document : [...this.document, ...document]); + } + + mount(element: HTMLElement, attributes?: Record): () => void { + this.view?.destroy(); + const state = EditorState.create({ + doc: toProseMirrorDocument(this.document), + plugins: [ + beginCommandPlugin, + history(), + keymap({ 'Mod-z': undo, 'Mod-Shift-z': redo, 'Mod-y': redo }), + // Enter is withheld on purpose: the host decides send vs newline. + // Binding it here also splits the paragraph, on top of whatever it did. + keymap(Object.fromEntries(Object.entries(baseKeymap).filter(([key]) => key !== 'Enter'))), + ], + schema: editorSchema, + }); + this.view = new EditorView( + { mount: element }, + { + attributes: (viewState) => ({ + ...attributes, + 'data-placeholder-visible': String(isProseMirrorDocumentEmpty(viewState.doc)), + }), + handlePaste: (view, event) => { + const text = event.clipboardData?.getData('text/plain'); + if (text === undefined || text === '') return false; + + const paragraphs = text + .split(/\r\n?|\n/) + .map((line) => + editorSchema.nodes.paragraph.create( + undefined, + line ? editorSchema.text(line) : undefined + ) + ); + view.dispatch( + view.state.tr.replaceSelection(Slice.maxOpen(Fragment.from(paragraphs), true)) + ); + return true; + }, + nodeViews: buildEditorNodeViews(() => this.renderContext), + state, + dispatchTransaction: (transaction: Transaction) => { + const view = this.view; + if (!view) return; + view.updateState(view.state.apply(transaction)); + if (transaction.docChanged) { + this.document = fromProseMirrorDocument(view.state.doc); + this.notify(); + } + }, + } + ); + return () => { + this.view?.destroy(); + this.view = undefined; + }; + } + + focus(): void { + this.view?.focus(); + } + + clear(): void { + this.setDocument(emptyEditorDocument()); + } + + blur(): void { + (this.view?.dom as HTMLElement | undefined)?.blur(); + } + + undo(): void { + if (this.view) undo(this.view.state, this.view.dispatch); + } + + redo(): void { + if (this.view) redo(this.view.state, this.view.dispatch); + } + + insertText(text: string): void { + const view = this.view; + if (view) view.dispatch(view.state.tr.insertText(text)); + } + + insertNewline(): void { + const view = this.view; + if (view) splitBlock(view.state, view.dispatch); + } + + insertInline(token: InlineToken, from?: number, to?: number): void { + const view = this.view; + const node = toProseMirrorInline(token); + if (!view || !node) return; + const start = from ?? view.state.selection.from; + const end = to ?? view.state.selection.to; + const transaction = view.state.tr.replaceWith(start, end, node); + transaction.setSelection(TextSelection.create(transaction.doc, start + node.nodeSize)); + view.dispatch(transaction); + } + + getAutocompleteQuery( + prefixes: readonly TPrefix[], + atDocumentStart = false + ): EditorAutocompleteQuery | undefined { + const view = this.view; + if (!view || !view.state.selection.empty) return undefined; + const { $from, from } = view.state.selection; + const precedingText = view.state.doc.textBetween($from.start(), from, '\n', '\0'); + const word = precedingText.match(/(?:^|\s)(\S*)$/)?.[1]; + if (!word) return undefined; + const prefix = prefixes.find((candidate) => word.startsWith(candidate)); + if (!prefix) return undefined; + const queryFrom = from - word.length; + if ( + atDocumentStart && + ($from.parent !== view.state.doc.firstChild || + view.state.doc.textBetween(1, queryFrom, '\n', '\0').trim() !== '') + ) { + return undefined; + } + return { from: queryFrom, to: from, prefix, text: word.slice(prefix.length) }; + } + + applyMarkdownInline(marker: string): void { + const view = this.view; + if (!view) return; + const { from, to, empty } = view.state.selection; + const selectedText = empty ? '' : view.state.doc.textBetween(from, to, '\n', '\0'); + const transaction = view.state.tr.insertText(`${marker}${selectedText}${marker}`, from, to); + transaction.setSelection( + TextSelection.create(transaction.doc, from + marker.length + selectedText.length) + ); + view.dispatch(transaction); + } + + applyMarkdownBlockPrefix(prefix: string): void { + const view = this.view; + if (view) view.dispatch(view.state.tr.insertText(prefix, view.state.selection.$from.start())); + } + + subscribe(listener: (document: EditorDocument) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private notify(): void { + this.listeners.forEach((listener) => listener(this.getDocument())); + } +} diff --git a/src/app/components/editor/prosemirrorNodeViews.test.tsx b/src/app/components/editor/prosemirrorNodeViews.test.tsx new file mode 100644 index 0000000000..4374c5839f --- /dev/null +++ b/src/app/components/editor/prosemirrorNodeViews.test.tsx @@ -0,0 +1,187 @@ +import { act, render } from '@testing-library/react'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { DOMParser as ProseMirrorDOMParser, DOMSerializer } from 'prosemirror-model'; +import type { EditorDocument } from './model'; +import { ProseMirrorEditable } from './ProseMirrorEditable'; +import { ProseMirrorEditorController } from './prosemirrorController'; +import { editorSchema, fromProseMirrorDocument, toProseMirrorDocument } from './prosemirrorSchema'; +import { BlockType } from './types'; + +const emptyClientRects = () => [] as unknown as DOMRectList; +beforeAll(() => { + Element.prototype.getClientRects ??= emptyClientRects; + (Text.prototype as unknown as Element).getClientRects ??= emptyClientRects; +}); + +const paragraph = (...children: EditorDocument[number]['children']): EditorDocument => [ + { type: BlockType.Paragraph, children }, +]; + +const mention = (overrides: Record = {}) => ({ + type: BlockType.Mention as const, + id: '@bob:example.org', + highlight: false, + name: 'bob', + children: [{ text: '' }], + ...overrides, +}); + +const mount = (document: EditorDocument) => { + const controller = new ProseMirrorEditorController(document); + const { container } = render(); + return { container, controller }; +}; + +describe('atom node views', () => { + it('renders a mention as a styled, non-editable pill', () => { + const { container } = mount(paragraph({ text: '' }, mention())); + const pill = container.querySelector('.ProseMirror > p > span')!; + + expect(pill).toHaveAttribute('contenteditable', 'false'); + expect(pill.className).not.toBe(''); + expect(pill).toHaveTextContent('bob'); + }); + + it('renders an mxc emoticon as an image via the render context', () => { + const controller = new ProseMirrorEditorController( + paragraph({ + type: BlockType.Emoticon, + key: 'mxc://example.org/abc', + shortcode: 'party', + children: [{ text: '' }], + }) + ); + controller.setRenderContext({ + emoticonSrc: () => 'https://example.org/_matrix/media/abc', + mentionDisplayName: (token) => token.name, + }); + const { container } = render(); + + const img = container.querySelector('img[alt="party"]'); + expect(img).toHaveAttribute('src', 'https://example.org/_matrix/media/abc'); + }); + + it('renders a unicode emoticon as text rather than an image', () => { + const { container } = mount( + paragraph({ + type: BlockType.Emoticon, + key: '🎉', + shortcode: 'party', + children: [{ text: '' }], + }) + ); + + expect(container.querySelector('img[alt="party"]')).toBeNull(); + expect(container.querySelector('.ProseMirror')).toHaveTextContent('🎉'); + }); + + it('drops the command’s active styling once text precedes it', () => { + const command = { + type: BlockType.Command as const, + command: 'shrug', + children: [{ text: '' }], + }; + const { container, controller } = mount(paragraph(command)); + const activeClass = (container.querySelector('.ProseMirror > p > span') as HTMLElement) + .className; + + act(() => controller.setDocument(paragraph({ text: 'hi ' }, command))); + + const pill = container.querySelector('.ProseMirror > p > span') as HTMLElement; + expect(pill).toHaveTextContent('/shrug'); + expect(pill.className).not.toBe(activeClass); + }); + + it('keeps the command active when only whitespace precedes it', () => { + const command = { + type: BlockType.Command as const, + command: 'shrug', + children: [{ text: '' }], + }; + const { container } = mount(paragraph(command)); + const activeClass = (container.querySelector('.ProseMirror > p > span') as HTMLElement) + .className; + const { container: spaced } = mount(paragraph({ text: ' ' }, command)); + + expect((spaced.querySelector('.ProseMirror > p > span') as HTMLElement).className).toBe( + activeClass + ); + }); + + it('renders a command as a non-editable pill', () => { + const { container } = mount( + paragraph( + { text: '' }, + { type: BlockType.Command, command: 'shrug', children: [{ text: '' }] } + ) + ); + const pill = container.querySelector('.ProseMirror > p > span')!; + + expect(pill).toHaveAttribute('contenteditable', 'false'); + expect(pill).toHaveTextContent('/shrug'); + }); +}); + +/** Serializes a document to clipboard HTML and parses it back. */ +const roundTrip = (document: EditorDocument): EditorDocument => { + const source = toProseMirrorDocument(document); + const html = window.document.createElement('div'); + html.append(DOMSerializer.fromSchema(editorSchema).serializeFragment(source.content)); + return fromProseMirrorDocument(ProseMirrorDOMParser.fromSchema(editorSchema).parse(html)); +}; + +describe('clipboard round-trip', () => { + it('preserves a mention with all of its routing attributes', () => { + const document = paragraph( + mention({ eventId: '$event:example.org', viaServers: ['a.org', 'b.org'], highlight: true }) + ); + + expect(roundTrip(document)).toEqual(document); + }); + + it('preserves a plain mention', () => { + const document = paragraph(mention()); + + expect(roundTrip(document)).toEqual(document); + }); + + it('preserves an emoticon', () => { + const document = paragraph({ + type: BlockType.Emoticon, + key: 'mxc://example.org/abc', + shortcode: 'party', + children: [{ text: '' }], + }); + + expect(roundTrip(document)).toEqual(document); + }); + + it('preserves a command', () => { + const document = paragraph({ + type: BlockType.Command, + command: 'shrug', + children: [{ text: '' }], + }); + + expect(roundTrip(document)).toEqual(document); + }); + + it('preserves a link and its text', () => { + const document = paragraph({ + type: BlockType.Link, + href: 'https://example.org/', + children: [{ text: 'example' }], + }); + + expect(roundTrip(document)).toEqual(document); + }); + + it('preserves multiple paragraphs', () => { + const document: EditorDocument = [ + { type: BlockType.Paragraph, children: [{ text: 'one' }] }, + { type: BlockType.Paragraph, children: [{ text: 'two' }] }, + ]; + + expect(roundTrip(document)).toEqual(document); + }); +}); diff --git a/src/app/components/editor/prosemirrorNodeViews.ts b/src/app/components/editor/prosemirrorNodeViews.ts new file mode 100644 index 0000000000..e55079cb14 --- /dev/null +++ b/src/app/components/editor/prosemirrorNodeViews.ts @@ -0,0 +1,182 @@ +import type { Node as ProseMirrorNode } from 'prosemirror-model'; +import { Plugin } from 'prosemirror-state'; +import type { Decoration, NodeView, NodeViewConstructor } from 'prosemirror-view'; +import { Decoration as NodeDecoration, DecorationSet } from 'prosemirror-view'; +import * as css from '$styles/CustomHtml.css'; +import { BlockType } from './types'; +import type { MentionToken } from './model'; +import { formatMentionElementDisplayName } from './utils'; + +/** + * App data the atom node views render from. Plain functions, so the views stay + * out of React and nothing inside the editable re-renders during composition. + */ +export type EditorRenderContext = { + emoticonSrc: (key: string) => string | undefined; + mentionDisplayName: (token: MentionToken) => string; +}; + +export const defaultEditorRenderContext: EditorRenderContext = { + emoticonSrc: () => undefined, + mentionDisplayName: (token) => formatMentionElementDisplayName(token), +}; + +const mentionTokenOf = (node: ProseMirrorNode): MentionToken => ({ + type: BlockType.Mention, + id: node.attrs.id as string, + eventId: (node.attrs.eventId as string | null) ?? undefined, + viaServers: (node.attrs.viaServers as string[] | null) ?? undefined, + highlight: Boolean(node.attrs.highlight), + name: node.attrs.name as string, + children: [{ text: '' }], +}); + +abstract class AtomNodeView implements NodeView { + dom: HTMLElement; + + protected selected = false; + + constructor() { + this.dom = document.createElement('span'); + // Otherwise the caret enters the pill and an IME can anchor inside it. + this.dom.setAttribute('contenteditable', 'false'); + } + + selectNode(): void { + this.selected = true; + this.render(); + } + + deselectNode(): void { + this.selected = false; + this.render(); + } + + stopEvent(): boolean { + return false; + } + + protected abstract render(): void; +} + +class MentionNodeView extends AtomNodeView { + constructor( + private node: ProseMirrorNode, + private context: () => EditorRenderContext + ) { + super(); + this.render(); + } + + protected render(): void { + const token = mentionTokenOf(this.node); + this.dom.className = css.Mention({ highlight: token.highlight, focus: this.selected }); + this.dom.textContent = this.context().mentionDisplayName(token); + } +} + +class CommandNodeView extends AtomNodeView { + private active: boolean; + + constructor( + private node: ProseMirrorNode, + decorations: readonly Decoration[] + ) { + super(); + this.active = hasBeginCommandDecoration(decorations); + this.render(); + } + + // Active depends on surrounding text, not the node, so it arrives as a + // decoration — which is what makes this run on edits elsewhere. + update(node: ProseMirrorNode, decorations: readonly Decoration[]): boolean { + if (node.type !== this.node.type) return false; + this.node = node; + this.active = hasBeginCommandDecoration(decorations); + this.render(); + return true; + } + + protected render(): void { + this.dom.className = css.Command({ focus: this.selected, active: this.active }); + this.dom.textContent = `/${this.node.attrs.command as string}`; + } +} + +const BEGIN_COMMAND_DECORATION = 'sableBeginCommand'; + +const hasBeginCommandDecoration = (decorations: readonly Decoration[]): boolean => + decorations.some((decoration) => decoration.spec[BEGIN_COMMAND_DECORATION] === true); + +/** Mirrors getDocumentBeginCommand: first paragraph, only whitespace before. */ +export const beginCommandPlugin = new Plugin({ + props: { + decorations: (state) => { + const firstParagraph = state.doc.firstChild; + if (!firstParagraph) return null; + let offset = 1; + let prefix = ''; + for (let index = 0; index < firstParagraph.childCount; index += 1) { + const child = firstParagraph.child(index); + if (child.type.name === 'command') { + return prefix.trim() === '' + ? DecorationSet.create(state.doc, [ + NodeDecoration.node( + offset, + offset + child.nodeSize, + {}, + { [BEGIN_COMMAND_DECORATION]: true } + ), + ]) + : null; + } + if (!child.isText) return null; + prefix += child.text ?? ''; + offset += child.nodeSize; + } + return null; + }, + }, +}); + +class EmoticonNodeView extends AtomNodeView { + constructor( + private node: ProseMirrorNode, + private context: () => EditorRenderContext + ) { + super(); + this.dom.className = css.EmoticonBase; + this.render(); + } + + protected render(): void { + const key = this.node.attrs.key as string; + const shortcode = this.node.attrs.shortcode as string; + const inner = document.createElement('span'); + inner.className = css.Emoticon({ focus: this.selected }); + inner.setAttribute('contenteditable', 'false'); + + const src = key.startsWith('mxc://') ? this.context().emoticonSrc(key) : undefined; + if (src) { + const img = document.createElement('img'); + img.className = css.EmoticonImg; + img.style.width = 'auto'; + img.style.height = '1em'; + img.src = src; + img.alt = shortcode; + inner.append(img); + } else { + inner.textContent = key.startsWith('mxc://') ? `:${shortcode}:` : key; + } + + this.dom.replaceChildren(inner); + } +} + +export const buildEditorNodeViews = ( + context: () => EditorRenderContext +): Record => ({ + mention: (node) => new MentionNodeView(node, context), + emoticon: (node) => new EmoticonNodeView(node, context), + command: (node, _view, _getPos, decorations) => new CommandNodeView(node, decorations), +}); diff --git a/src/app/components/editor/prosemirrorSchema.test.ts b/src/app/components/editor/prosemirrorSchema.test.ts new file mode 100644 index 0000000000..fc4c2690b2 --- /dev/null +++ b/src/app/components/editor/prosemirrorSchema.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { BlockType } from './types'; +import type { EditorDocument } from './model'; +import { ProseMirrorEditorController } from './prosemirrorController'; +import { toMatrixCustomHTML, toPlainText } from './output'; +import { fromProseMirrorDocument, toProseMirrorDocument } from './prosemirrorSchema'; + +describe('ProseMirror editor schema', () => { + it('round-trips Sable inline tokens without exposing engine JSON', () => { + const document: EditorDocument = [ + { + type: BlockType.Paragraph, + children: [ + { text: 'Hi ' }, + { + type: BlockType.Mention, + id: '@alice:example.org', + name: '@Alice', + highlight: false, + children: [{ text: '' }], + }, + { text: ' ' }, + { + type: BlockType.Emoticon, + key: 'mxc://example.org/emote', + shortcode: 'wave', + children: [{ text: '' }], + }, + ], + }, + ]; + + expect(fromProseMirrorDocument(toProseMirrorDocument(document))).toEqual(document); + }); + + it('keeps draft state behind the controller seam', () => { + const controller = new ProseMirrorEditorController(); + const changes: EditorDocument[] = []; + controller.subscribe((document) => changes.push(document)); + + controller.setDocument([{ type: BlockType.Paragraph, children: [{ text: 'draft' }] }]); + const draft = controller.getDocument(); + draft[0]!.children[0] = { text: 'mutated outside the controller' }; + + expect(controller.getDocument()[0]!.children).toEqual([{ text: 'draft' }]); + expect(changes).toHaveLength(1); + }); + + it('keeps insertion and autocomplete behind the controller seam', () => { + const controller = new ProseMirrorEditorController(); + const root = document.createElement('div'); + const unmount = controller.mount(root); + + controller.insertText(':wave'); + const query = controller.getAutocompleteQuery([':'] as const); + expect(query).toEqual({ from: 1, to: 6, prefix: ':', text: 'wave' }); + + controller.insertInline( + { + type: BlockType.Emoticon, + key: '👋', + shortcode: 'wave', + children: [{ text: '' }], + }, + query?.from, + query?.to + ); + + expect(controller.getDocument()).toEqual([ + { + type: BlockType.Paragraph, + children: [ + { + type: BlockType.Emoticon, + key: '👋', + shortcode: 'wave', + children: [{ text: '' }], + }, + ], + }, + ]); + unmount(); + }); + + it('feeds the existing Matrix serializers with an engine-neutral document', () => { + const document: EditorDocument = [ + { + type: BlockType.Paragraph, + children: [ + { text: 'See ' }, + { + type: BlockType.Link, + href: 'https://example.org', + children: [{ text: 'example' }], + }, + ], + }, + ]; + + expect(toPlainText(document)).toBe('See [example](https://example.org)\n'); + expect(toMatrixCustomHTML(document, {})).toContain('https://example.org'); + }); +}); diff --git a/src/app/components/editor/prosemirrorSchema.ts b/src/app/components/editor/prosemirrorSchema.ts new file mode 100644 index 0000000000..10cb821737 --- /dev/null +++ b/src/app/components/editor/prosemirrorSchema.ts @@ -0,0 +1,200 @@ +import { Schema, type Node as ProseMirrorNode } from 'prosemirror-model'; +import type { EditorDocument, InlineToken } from './model'; +import { emptyEditorDocument, isEditorText } from './model'; +import { BlockType } from './types'; + +export const editorSchema = new Schema({ + nodes: { + doc: { content: 'paragraph+' }, + // Reset UA margins so a one-line composer stays one line high. + paragraph: { + content: 'inline*', + group: 'block', + parseDOM: [{ tag: 'p' }], + toDOM: () => ['p', { style: 'margin: 0' }, 0], + }, + text: { group: 'inline' }, + link: { + inline: true, + content: 'text*', + group: 'inline', + attrs: { href: {} }, + parseDOM: [{ tag: 'a[href]', getAttrs: (dom) => ({ href: dom.getAttribute('href') }) }], + toDOM: (node) => ['a', { href: node.attrs.href }, 0], + }, + // Node views render these in-editor; toDOM is the clipboard shape, so it + // must carry every attribute parseDOM needs to rebuild the node. + mention: { + inline: true, + atom: true, + group: 'inline', + selectable: true, + attrs: { + id: {}, + eventId: { default: null }, + viaServers: { default: null }, + highlight: { default: false }, + name: {}, + }, + parseDOM: [ + { + tag: 'span[data-sable-mention]', + getAttrs: (dom) => ({ + id: dom.getAttribute('data-sable-mention'), + name: dom.getAttribute('data-mention-name') ?? dom.textContent ?? '', + eventId: dom.getAttribute('data-mention-event-id'), + viaServers: dom.getAttribute('data-mention-via')?.split(',') ?? null, + highlight: dom.getAttribute('data-mention-highlight') === 'true', + }), + }, + ], + toDOM: (node) => [ + 'span', + { + 'data-sable-mention': node.attrs.id, + 'data-mention-name': node.attrs.name, + ...(node.attrs.eventId ? { 'data-mention-event-id': node.attrs.eventId } : {}), + ...((node.attrs.viaServers as string[] | null)?.length + ? { 'data-mention-via': (node.attrs.viaServers as string[]).join(',') } + : {}), + ...(node.attrs.highlight ? { 'data-mention-highlight': 'true' } : {}), + }, + node.attrs.name, + ], + }, + emoticon: { + inline: true, + atom: true, + group: 'inline', + selectable: true, + attrs: { key: {}, shortcode: {} }, + parseDOM: [ + { + tag: 'span[data-sable-emoticon]', + getAttrs: (dom) => ({ + key: dom.getAttribute('data-sable-emoticon'), + shortcode: dom.getAttribute('data-emoticon-shortcode') ?? '', + }), + }, + ], + toDOM: (node) => [ + 'span', + { + 'data-sable-emoticon': node.attrs.key, + 'data-emoticon-shortcode': node.attrs.shortcode, + }, + `:${node.attrs.shortcode}:`, + ], + }, + command: { + inline: true, + atom: true, + group: 'inline', + selectable: true, + attrs: { command: {} }, + parseDOM: [ + { + tag: 'span[data-sable-command]', + getAttrs: (dom) => ({ command: dom.getAttribute('data-sable-command') }), + }, + ], + toDOM: (node) => [ + 'span', + { 'data-sable-command': node.attrs.command }, + `/${node.attrs.command}`, + ], + }, + }, +}); + +export const toProseMirrorInline = (token: InlineToken): ProseMirrorNode | null => { + if (isEditorText(token)) return token.text ? editorSchema.text(token.text) : null; + switch (token.type) { + case BlockType.Link: + return editorSchema.nodes.link.create( + { href: token.href }, + token.children + .map(toProseMirrorInline) + .filter((child): child is ProseMirrorNode => child !== null) + ); + case BlockType.Mention: + return editorSchema.nodes.mention.create({ + id: token.id, + eventId: token.eventId ?? null, + viaServers: token.viaServers ?? null, + highlight: token.highlight, + name: token.name, + }); + case BlockType.Emoticon: + return editorSchema.nodes.emoticon.create({ key: token.key, shortcode: token.shortcode }); + case BlockType.Command: + return editorSchema.nodes.command.create({ command: token.command }); + default: + return null; + } +}; + +export const toProseMirrorDocument = (document: EditorDocument) => + editorSchema.node( + 'doc', + undefined, + (document.length ? document : emptyEditorDocument()).map((paragraph) => + editorSchema.nodes.paragraph.create( + undefined, + paragraph.children + .map(toProseMirrorInline) + .filter((child): child is ProseMirrorNode => child !== null) + ) + ) + ); + +const fromProseMirrorInline = (node: ProseMirrorNode): InlineToken => { + if (node.isText) return { text: node.text ?? '' }; + switch (node.type.name) { + case 'link': + return { + type: BlockType.Link, + href: node.attrs.href as string, + children: node.content.content.map(fromProseMirrorInline) as { text: string }[], + }; + case 'mention': + return { + type: BlockType.Mention, + id: node.attrs.id as string, + eventId: node.attrs.eventId ?? undefined, + viaServers: node.attrs.viaServers ?? undefined, + highlight: Boolean(node.attrs.highlight), + name: node.attrs.name as string, + children: [{ text: '' }], + }; + case 'emoticon': + return { + type: BlockType.Emoticon, + key: node.attrs.key as string, + shortcode: node.attrs.shortcode as string, + children: [{ text: '' }], + }; + case 'command': + return { + type: BlockType.Command, + command: node.attrs.command as string, + children: [{ text: '' }], + }; + default: + return { text: node.textContent }; + } +}; + +export const fromProseMirrorDocument = (doc: ProseMirrorNode): EditorDocument => { + const document: EditorDocument = []; + doc.forEach((paragraph) => { + const children = paragraph.content.content.map(fromProseMirrorInline); + // ProseMirror has no empty text nodes; isEmpty and the serializers require + // at least one text token per paragraph. + document.push({ + type: BlockType.Paragraph, + children: children.length ? children : [{ text: '' }], + }); + }); + return document.length ? document : emptyEditorDocument(); +}; diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts deleted file mode 100644 index 7cca186234..0000000000 --- a/src/app/components/editor/slate.d.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { BaseEditor } from 'slate'; -import type { ReactEditor } from 'slate-react'; -import type { HistoryEditor } from 'slate-history'; -import type { BlockType } from './types'; - -export type Editor = BaseEditor & HistoryEditor & ReactEditor; - -export type Text = { - text: string; -}; - -export type FormattedText = Text; - -export type LinkElement = { - type: BlockType.Link; - href: string; - children: Text[]; -}; - -export type MentionElement = { - type: BlockType.Mention; - id: string; - eventId?: string; - viaServers?: string[]; - highlight: boolean; - name: string; - children: Text[]; -}; -export type EmoticonElement = { - type: BlockType.Emoticon; - key: string; - shortcode: string; - children: Text[]; -}; -export type CommandElement = { - type: BlockType.Command; - command: string; - children: Text[]; -}; - -export type InlineElement = - | FormattedText - | LinkElement - | MentionElement - | EmoticonElement - | CommandElement; - -export type ParagraphElement = { - type: BlockType.Paragraph; - children: InlineElement[]; -}; - -export type CustomElement = - | LinkElement - | MentionElement - | EmoticonElement - | CommandElement - | ParagraphElement; - -declare module 'slate' { - interface CustomTypes { - Editor: Editor; - Element: CustomElement; - Text: FormattedText; - } -} diff --git a/src/app/components/editor/useEditorRenderContext.ts b/src/app/components/editor/useEditorRenderContext.ts new file mode 100644 index 0000000000..7ade4c70d5 --- /dev/null +++ b/src/app/components/editor/useEditorRenderContext.ts @@ -0,0 +1,25 @@ +import { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { mxcUrlToHttp } from '$utils/matrix'; +import { nicknamesAtom } from '$state/nicknames'; +import type { EditorRenderContext } from './prosemirrorNodeViews'; +import { formatMentionElementDisplayName } from './utils'; + +export const useEditorRenderContext = (): EditorRenderContext => { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const nicknames = useAtomValue(nicknamesAtom); + + return useMemo( + () => ({ + emoticonSrc: (key) => mxcUrlToHttp(mx, key, useAuthentication) ?? undefined, + mentionDisplayName: (token) => { + const nickname = nicknames[token.id]; + return nickname ? `@${nickname}` : formatMentionElementDisplayName(token); + }, + }), + [mx, nicknames, useAuthentication] + ); +}; diff --git a/src/app/components/editor/utils.test.ts b/src/app/components/editor/utils.test.ts index a45fedf7a0..d2d28330af 100644 --- a/src/app/components/editor/utils.test.ts +++ b/src/app/components/editor/utils.test.ts @@ -1,40 +1,10 @@ -import { createEditor, Editor, Transforms } from 'slate'; import { describe, expect, it } from 'vitest'; -import { getPrevWorldRange } from './utils'; -import { BlockType } from './types'; +import { getMarkdownCodeSpanRanges, isInsideMarkdownCodeSpan } from './utils'; -const createTestEditor = (text: string) => { - const editor = createEditor(); - editor.children = [{ type: BlockType.Paragraph, children: [{ text }] }]; - return editor; -}; - -const selectAt = (editor: Editor, offset: number) => { - Transforms.select(editor, { path: [0, 0], offset }); -}; - -describe('getPrevWorldRange', () => { - it('returns the word before the cursor', () => { - const editor = createTestEditor('hello :smile world'); - selectAt(editor, 12); - - const range = getPrevWorldRange(editor); - expect(range && Editor.string(editor, range)).toBe(':smile'); - }); - - it('returns the whole word when the cursor sits inside it', () => { - const editor = createTestEditor('hello :smile world'); - selectAt(editor, 9); - - const range = getPrevWorldRange(editor); - expect(range && Editor.string(editor, range)).toBe(':smile'); - }); - - it('returns the whole word when the cursor sits inside the last word', () => { - const editor = createTestEditor('hello :smile'); - selectAt(editor, 9); - - const range = getPrevWorldRange(editor); - expect(range && Editor.string(editor, range)).toBe(':smile'); +describe('Markdown code ranges', () => { + it('finds matching backtick runs', () => { + const ranges = getMarkdownCodeSpanRanges('before `code` after'); + expect(ranges).toEqual([[7, 13]]); + expect(isInsideMarkdownCodeSpan(8, 12, ranges)).toBe(true); }); }); diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts index bb34f5d7d8..5fbc73b46f 100644 --- a/src/app/components/editor/utils.ts +++ b/src/app/components/editor/utils.ts @@ -1,18 +1,9 @@ -import type { BasePoint, BaseRange } from 'slate'; -import { Editor, Element, Point, Range, Text, Transforms } from 'slate'; -import { ReactEditor } from 'slate-react'; import type { Room } from '$types/matrix-sdk'; import type { Nicknames } from '$state/nicknames'; import { getMxIdLocalPart, isUserId } from '$utils/matrix'; import { getMemberDisplayName } from '$utils/room/display'; import { BlockType } from './types'; -import type { - CommandElement, - EmoticonElement, - FormattedText, - LinkElement, - MentionElement, -} from './slate'; +import type { CommandToken, EditorText, EmoticonToken, LinkToken, MentionToken } from './model'; export type MentionResolveOptions = { room?: Room; @@ -80,7 +71,7 @@ export const resolveRoomMentionHighlight = ( return roomId === roomIdOrAlias || alias === roomIdOrAlias; }; -export const formatMentionElementDisplayName = (element: MentionElement): string => { +export const formatMentionElementDisplayName = (element: MentionToken): string => { if (isUserId(element.id)) { return formatUserMentionDisplayName(element.name); } @@ -88,31 +79,13 @@ export const formatMentionElementDisplayName = (element: MentionElement): string return formatRoomMentionDisplayName(element.name); }; -export const resetEditor = (editor: Editor) => { - Transforms.delete(editor, { - at: { - anchor: Editor.start(editor, []), - focus: Editor.end(editor, []), - }, - }); - - Transforms.setNodes(editor, { type: BlockType.Paragraph }); -}; - -export const resetEditorHistory = (editor: Editor) => { - editor.history = { - undos: [], - redos: [], - }; -}; - export const createMentionElement = ( id: string, name: string, highlight: boolean, eventId?: string, viaServers?: string[] -): MentionElement => ({ +): MentionToken => ({ type: BlockType.Mention, id, eventId, @@ -122,124 +95,25 @@ export const createMentionElement = ( children: [{ text: '' }], }); -export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({ +export const createEmoticonElement = (key: string, shortcode: string): EmoticonToken => ({ type: BlockType.Emoticon, key, shortcode, children: [{ text: '' }], }); -export const createLinkElement = ( - href: string, - children: string | FormattedText[] -): LinkElement => ({ +export const createLinkElement = (href: string, children: string | EditorText[]): LinkToken => ({ type: BlockType.Link, href, children: typeof children === 'string' ? [{ text: children }] : children, }); -export const createCommandElement = (command: string): CommandElement => ({ +export const createCommandElement = (command: string): CommandToken => ({ type: BlockType.Command, command, children: [{ text: '' }], }); -export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => { - Transforms.select(editor, selectRange); - Transforms.insertNodes(editor, element); - Transforms.collapse(editor, { - edge: 'end', - }); -}; - -export const moveCursor = (editor: Editor, withSpace?: boolean) => { - Transforms.move(editor); - if (withSpace) editor.insertText(' '); - Transforms.collapse(editor, { edge: 'end' }); -}; - -export const focusEditor = (editor: Editor) => { - requestAnimationFrame(() => { - try { - ReactEditor.focus(editor); - } catch { - // Slate DOM may not reflect the latest selection yet. - } - }); -}; - -interface PointUntilCharOptions { - match: (char: string) => boolean; - reverse?: boolean; -} -const getPointUntilChar = ( - editor: Editor, - cursorPoint: BasePoint, - options: PointUntilCharOptions -): BasePoint | undefined => { - let targetPoint: BasePoint | undefined; - let prevPoint: BasePoint | undefined; - let char: string | undefined; - - const startPoint = Editor.point(editor, cursorPoint, { edge: 'start' }); - const pointItr = Editor.positions(editor, { - at: options.reverse - ? { anchor: Editor.start(editor, []), focus: startPoint } - : { anchor: startPoint, focus: Editor.end(editor, []) }, - unit: 'character', - reverse: options.reverse, - }); - - for (const point of pointItr) { - if (!Point.equals(point, cursorPoint) && prevPoint) { - char = Editor.string(editor, { anchor: point, focus: prevPoint }); - - if (options.match(char)) break; - targetPoint = point; - } - prevPoint = point; - } - return targetPoint; -}; - -// line breaks produce empty chars, not \n -const isWorldBoundary = (char: string) => /\s|^$/.test(char); - -export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => { - const { selection } = editor; - if (!selection || !Range.isCollapsed(selection)) return undefined; - const [cursorPoint] = Range.edges(selection); - const worldStartPoint = getPointUntilChar(editor, cursorPoint, { - reverse: true, - match: isWorldBoundary, - }); - if (!worldStartPoint) return undefined; - const worldEndPoint = - getPointUntilChar(editor, cursorPoint, { match: isWorldBoundary }) ?? cursorPoint; - return Editor.range(editor, worldStartPoint, worldEndPoint); -}; - -export const isEmptyEditor = (editor: Editor): boolean => { - const firstChildren = editor.children[0]; - if (firstChildren && Element.isElement(firstChildren)) { - return editor.children.length === 1 && Editor.isEmpty(editor, firstChildren); - } - return false; -}; - -export const getBeginCommand = (editor: Editor): string | undefined => { - const lineBlock = editor.children[0]; - if (!Element.isElement(lineBlock)) return undefined; - if (lineBlock.type !== BlockType.Paragraph) return undefined; - - const [firstInline, secondInline] = lineBlock.children; - const isEmptyText = Text.isText(firstInline) && firstInline.text.trim() === ''; - if (!isEmptyText) return undefined; - if (Element.isElement(secondInline) && secondInline.type === BlockType.Command) - return secondInline.command; - return undefined; -}; - export const getMarkdownCodeSpanRanges = (text: string): [number, number][] => { const ranges: [number, number][] = []; let openRun: { start: number; length: number } | undefined; diff --git a/src/app/components/upload-card/UploadDescriptionEditor.tsx b/src/app/components/upload-card/UploadDescriptionEditor.tsx index 9a0443ee23..9741a972ad 100644 --- a/src/app/components/upload-card/UploadDescriptionEditor.tsx +++ b/src/app/components/upload-card/UploadDescriptionEditor.tsx @@ -1,28 +1,24 @@ import type { KeyboardEventHandler } from 'react'; -import { useCallback, useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Room } from '$types/matrix-sdk'; import type { RectCords } from 'folds'; import { Box, Chip, IconButton, Spinner, Text, config } from 'folds'; import { PopOut } from '$components/overlay-stack'; import { Smiley, sizedIcon } from '$components/icons/phosphor'; -import { Editor, Transforms } from 'slate'; -import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; -import type { AutocompleteQuery } from '$components/editor'; +import type { EditorAutocompleteQuery } from '$components/editor/prosemirrorController'; import { AutocompletePrefix, - CustomEditor, EmoticonAutocomplete, MarkdownFormattingToolbarBottom, MarkdownFormattingToolbarToggle, createEmoticonElement, - getAutocompleteQuery, - getPrevWorldRange, plainToEditorInput, - moveCursor, + ProseMirrorEditorSurface, toMatrixCustomHTML, toPlainText, trimCustomHtml, + toggleProseMirrorKeyboardShortcut, useEditor, } from '$components/editor'; import { htmlToMarkdown } from '$plugins/markdown'; @@ -30,7 +26,6 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { UseStateProvider } from '$components/UseStateProvider'; import { EmojiBoard } from '$components/emoji-board'; -import { isMobileOrTablet } from '$utils/platform'; import * as css from './UploadDescriptionEditor.css'; type DescriptionEditorProps = { @@ -50,16 +45,17 @@ export function DescriptionEditor({ }: Readonly) { const editor = useEditor(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const [autocompleteQuery, setAutocompleteQuery] = - useState>(); + useState>(); const prevValue = useRef(value); const initialized = useRef(false); const handleSave = useCallback(() => { - const plainText = toPlainText(editor.children).trim(); + const plainText = toPlainText(editor.getDocument()).trim(); - const customHtml = trimCustomHtml(toMatrixCustomHTML(editor.children, {})); + const customHtml = trimCustomHtml(toMatrixCustomHTML(editor.getDocument(), {})); onSave(plainText, customHtml || plainText); }, [editor, onSave]); @@ -85,7 +81,7 @@ export function DescriptionEditor({ const incomingPlainText = toPlainText( plainToEditorInput(safeValue.includes('<') ? htmlToMarkdown(safeValue) : safeValue) ).trim(); - const currentPlainText = toPlainText(editor.children).trim(); + const currentPlainText = toPlainText(editor.getDocument()).trim(); if (currentPlainText === incomingPlainText && initialized.current) return; @@ -94,9 +90,7 @@ export function DescriptionEditor({ ? plainToEditorInput(htmlToMarkdown(safeValue)) : plainToEditorInput(safeValue); - editor.children = initialValue; - Editor.normalize(editor, { force: true }); - Transforms.select(editor, Editor.start(editor, [])); + editor.setDocument(initialValue); initialized.current = true; } @@ -104,12 +98,16 @@ export function DescriptionEditor({ const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { + if (toggleProseMirrorKeyboardShortcut(editor, evt, shortcutOverrides)) { + evt.preventDefault(); + return; + } if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) { evt.preventDefault(); handleSave(); } }, - [handleSave, enterForNewline] + [editor, enterForNewline, handleSave, shortcutOverrides] ); const handleKeyUp: KeyboardEventHandler = useCallback( @@ -119,23 +117,19 @@ export function DescriptionEditor({ onCancel(); return; } - const prevWordRange = getPrevWorldRange(editor); - const query = prevWordRange - ? getAutocompleteQuery(editor, prevWordRange, [AutocompletePrefix.Emoticon]) - : undefined; - setAutocompleteQuery(query); + setAutocompleteQuery(editor.getAutocompleteQuery([AutocompletePrefix.Emoticon])); }, [editor, onCancel] ); const handleCloseAutocomplete = useCallback(() => { - ReactEditor.focus(editor); + editor.focus(); setAutocompleteQuery(undefined); }, [editor]); const handleEmoticonSelect = (key: string, shortcode: string) => { - editor.insertNode(createEmoticonElement(key, shortcode)); - moveCursor(editor); + editor.insertInline(createEmoticonElement(key, shortcode)); + editor.insertText(' '); }; return ( @@ -148,13 +142,13 @@ export function DescriptionEditor({ {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( )} - - + setAnchor((v) => { if (v) { - if (!isMobileOrTablet()) ReactEditor.focus(editor); + editor.focus(); return undefined; } return v; diff --git a/src/app/features/room/CommandAutocomplete.tsx b/src/app/features/room/CommandAutocomplete.tsx index 99b49043cf..c204ec98b5 100644 --- a/src/app/features/room/CommandAutocomplete.tsx +++ b/src/app/features/room/CommandAutocomplete.tsx @@ -1,17 +1,14 @@ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react'; import { useCallback, useEffect, useMemo } from 'react'; -import type { Editor } from 'slate'; import { Box, config, MenuItem, Text } from 'folds'; import type { Room } from '$types/matrix-sdk'; import type { Command } from '$hooks/useCommands'; import { useCommands } from '$hooks/useCommands'; -import type { AutocompleteQuery } from '$components/editor'; -import { - AutocompleteMenu, - createCommandElement, - moveCursor, - replaceWithElement, -} from '$components/editor'; +import type { + EditorAutocompleteQuery, + ProseMirrorEditorController, +} from '$components/editor/prosemirrorController'; +import { AutocompleteMenu, createCommandElement } from '$components/editor'; import type { UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; import { useAsyncSearch } from '$hooks/useAsyncSearch'; import { useMatrixClient } from '$hooks/useMatrixClient'; @@ -23,8 +20,8 @@ const GIF_COMMAND = 'gif'; type CommandAutocompleteProps = { room: Room; - editor: Editor; - query: AutocompleteQuery; + controller: ProseMirrorEditorController; + query: EditorAutocompleteQuery; requestClose: () => void; }; @@ -36,7 +33,7 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = { export function CommandAutocomplete({ room, - editor, + controller, query, requestClose, }: CommandAutocompleteProps) { @@ -62,8 +59,8 @@ export function CommandAutocomplete({ const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => { const cmdEl = createCommandElement(commandName); - replaceWithElement(editor, query.range, cmdEl); - moveCursor(editor, true); + controller.insertInline(cmdEl, query.from, query.to); + controller.insertText(' '); requestClose(); }; @@ -85,7 +82,6 @@ export function CommandAutocomplete({ } requestClose={requestClose} - editor={editor} > {autoCompleteNames.map((commandName) => ( { }, ANYWHERE_AUTOCOMPLETE_PREFIXES: [], BEGINNING_AUTOCOMPLETE_PREFIXES: [], - BlockType: { Paragraph: 'paragraph' }, + BlockType: { Paragraph: 'paragraph', Command: 'command' }, Command: { Poll: 'poll', Location: 'location' }, CustomEditor, EmoticonAutocomplete: passthrough, @@ -187,6 +187,7 @@ vi.mock('$components/editor', () => { focusEditor: vi.fn(), getAutocompleteQuery: vi.fn(), getBeginCommand: (editor: any) => editor.children[0]?.children?.[1]?.command, + getDocumentLinks: () => [], getLinks: () => [], getMentions: () => ({ users: new Set(), room: undefined }), getPrevWorldRange: () => undefined, @@ -546,9 +547,7 @@ function RoomInputHarness({ threadRootId?: string; }) { const editor = useMemo(() => { - const nextEditor = createEditor(); - nextEditor.children = [{ type: 'paragraph' as any, children: [{ text: '' }] }]; - return nextEditor; + return new ProseMirrorEditorController(); }, []); const [, setEditorRevision] = useState(0); const fileDropContainerRef = useMemo(() => ({ current: null }), []); @@ -580,14 +579,12 @@ function RoomInputHarness({ setSelectedFiles, ]); const setText = (text = 'retry me') => { - editor.children = [{ type: 'paragraph' as any, children: [{ text: '' }] }]; - Transforms.select(editor, { path: [0, 0], offset: 0 }); - Transforms.insertText(editor, text); + editor.setDocument([{ type: 'paragraph' as any, children: [{ text }] }]); fireEvent.input(screen.getByTestId('room-input-editor')); setEditorRevision((prev) => prev + 1); }; const setCommand = (command: 'poll' | 'location') => { - editor.children = [ + editor.setDocument([ { type: 'paragraph' as any, children: [ @@ -596,7 +593,7 @@ function RoomInputHarness({ { text: '' }, ], }, - ]; + ]); fireEvent.input(screen.getByTestId('room-input-editor')); }; return ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index bc3b09fed4..2f60d031d3 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -31,8 +31,6 @@ import type { import { MatrixError } from '$types/matrix-sdk'; import { EventType, RelationType } from '$types/matrix-sdk'; import { M_POLL_START } from 'matrix-js-sdk'; -import { ReactEditor } from 'slate-react'; -import { Editor, Point, Range, Transforms } from 'slate'; import type { RectCords } from 'folds'; import { Box, @@ -52,33 +50,29 @@ import { import { Overlay, PopOut } from '$components/overlay-stack'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import type { AutocompleteQuery } from '$components/editor'; +import type { + EditorAutocompleteQuery, + ProseMirrorEditorController, +} from '$components/editor/prosemirrorController'; import { AutocompletePrefix, createEmoticonElement, CustomEditor, customHtmlEqualsPlainText, - getAutocompleteQuery, - getPrevWorldRange, - resetEditor, RoomMentionAutocomplete, toMatrixCustomHTML, toPlainText, trimCustomHtml, UserMentionAutocomplete, EmoticonAutocomplete, - moveCursor, - resetEditorHistory, - isEmptyEditor, ANYWHERE_AUTOCOMPLETE_PREFIXES, BEGINNING_AUTOCOMPLETE_PREFIXES, MarkdownFormattingToolbarBottom, MarkdownFormattingToolbarToggle, - focusEditor, - replaceWithElement, } from '$components/editor'; import { stripMarkdownEscapesForHiddenPreviews } from './message/hiddenLinkPreviews'; import { plainToEditorInput } from '$components/editor/input'; +import type { EditorDocument } from '$components/editor/model'; import type { GifData } from '$components/emoji-board'; import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; import { UseStateProvider } from '$components/UseStateProvider'; @@ -287,7 +281,7 @@ interface ReplyClaim { } interface Submission { - children: Editor['children']; + children: EditorDocument; epoch: number; replyClaim: ReplyClaim | undefined; } @@ -303,7 +297,7 @@ interface SendContentsOptions { } interface RoomInputProps { - editor: Editor; + editor: ProseMirrorEditorController; fileDropContainerRef: RefObject; roomId: string; room: Room; @@ -467,7 +461,7 @@ export const RoomInput = forwardRef( [] ); const [autocompleteQuery, setAutocompleteQuery] = - useState>(); + useState>(); const [isQuickTextReact, setQuickTextReact] = useState(false); const replyDraftBase = useMemo( @@ -633,15 +627,7 @@ export const RoomInput = forwardRef( const [hasText, setHasText] = useState(false); const lastEncryptionPreparationAt = useRef(0); const detectAutocomplete = useCallback(() => { - const firstPosition = Editor.start(editor, []); - const secondChar = Editor.after(editor, firstPosition, { - distance: 2, - unit: 'character', - }); - const quickReactPrefix = Editor.string( - editor, - Editor.range(editor, firstPosition, secondChar) - ); + const quickReactPrefix = editor.getText().slice(0, 2); if (quickReactPrefix === '+#') { setQuickTextReact(true); setAutocompleteQuery(undefined); @@ -649,24 +635,15 @@ export const RoomInput = forwardRef( } setQuickTextReact(false); - const prevWordRange = getPrevWorldRange(editor); - if (!prevWordRange) { - setAutocompleteQuery(undefined); - return; - } - - const isRangeAtBeginning = !Point.isAfter(Range.start(prevWordRange), firstPosition); const query = - (isRangeAtBeginning - ? getAutocompleteQuery(editor, prevWordRange, BEGINNING_AUTOCOMPLETE_PREFIXES) - : undefined) ?? - getAutocompleteQuery(editor, prevWordRange, ANYWHERE_AUTOCOMPLETE_PREFIXES); + editor.getAutocompleteQuery(BEGINNING_AUTOCOMPLETE_PREFIXES, true) ?? + editor.getAutocompleteQuery(ANYWHERE_AUTOCOMPLETE_PREFIXES); setAutocompleteQuery(query); }, [editor]); const handleEditorChange = useCallback(() => { - setHasText(!isEmptyEditor(editor)); + setHasText(!editor.isEmpty()); detectAutocomplete(); if (!room.hasEncryptionStateEvent()) return; @@ -789,23 +766,22 @@ export const RoomInput = forwardRef( }, [threadRootId, setReplyDraft, mx]); useEffect(() => { - Transforms.insertFragment(editor, msgDraft); + editor.appendDocument(msgDraft); }, [editor, msgDraft]); const editingStateRef = useRef(false); - const preEditDraftRef = useRef(); + const preEditDraftRef = useRef(); useEffect( () => () => { if (editingStateRef.current) { - setMsgDraft(structuredClone(preEditDraftRef.current ?? [])); - } else if (isEmptyEditor(editor)) { + setMsgDraft(structuredClone(preEditDraftRef.current ?? []) as EditorDocument); + } else if (editor.isEmpty()) { setMsgDraft([]); } else { const parsedDraft = structuredClone(editor.children); - setMsgDraft(parsedDraft); + setMsgDraft(parsedDraft as EditorDocument); } - resetEditor(editor); - resetEditorHistory(editor); + editor.clear(); }, [draftKey, editor, setMsgDraft] ); @@ -890,14 +866,11 @@ export const RoomInput = forwardRef( mentionOptions ); - resetEditor(editor); - resetEditorHistory(editor); - Transforms.insertFragment(editor, initialValue); + editor.setDocument(initialValue); scheduleEditorRaf(() => { try { - ReactEditor.focus(editor); - moveCursor(editor); + editor.focus(); } catch { // Ignore focus error } @@ -908,9 +881,7 @@ export const RoomInput = forwardRef( editingStateRef.current = false; const previousDraft = preEditDraftRef.current; if (prevEditingEventId.current && previousDraft) { - resetEditor(editor); - resetEditorHistory(editor); - Transforms.insertFragment(editor, previousDraft); + editor.setDocument(previousDraft); } preEditDraftRef.current = undefined; if ( @@ -919,8 +890,7 @@ export const RoomInput = forwardRef( ) { scheduleEditorRaf(() => { try { - const domNode = ReactEditor.toDOMNode(editor, editor); - domNode.blur(); + editor.blur(); (document.activeElement as HTMLElement)?.blur(); } catch { // Ignore blur error @@ -978,8 +948,7 @@ export const RoomInput = forwardRef( if (newId && newId !== threadRootId) { scheduleEditorRaf(() => { try { - ReactEditor.focus(editor); - moveCursor(editor); + editor.focus(); } catch { // Ignore focus errors } @@ -987,8 +956,7 @@ export const RoomInput = forwardRef( } else if (!newId && prevId && prevId !== threadRootId && !editId) { scheduleEditorRaf(() => { try { - const domNode = ReactEditor.toDOMNode(editor, editor); - domNode.blur(); + editor.blur(); (document.activeElement as HTMLElement)?.blur(); } catch { // Ignore blur errors @@ -1096,8 +1064,7 @@ export const RoomInput = forwardRef( replyClaim: claimReplyDraft ? claimReply() : undefined, }; if (clearEditor) { - resetEditor(editor); - resetEditorHistory(editor); + editor.clear(); setInputKey((prev) => prev + 1); imagePacksUsedRef.current.clear(); sendTypingStatus(false); @@ -1112,11 +1079,11 @@ export const RoomInput = forwardRef( if ( !mountedRef.current || submission.epoch !== draftEpochRef.current || - !isEmptyEditor(editor) + !editor.isEmpty() ) { return; } - Transforms.insertFragment(editor, submission.children); + editor.appendDocument(submission.children); }, [editor, restoreReplyClaim] ); @@ -1129,7 +1096,7 @@ export const RoomInput = forwardRef( eventType, onContentSent, }: SendContentsOptions) => { - const plainText = toPlainText(submission.children).trim(); + const plainText = toPlainText(submission.children as EditorDocument).trim(); const submittedReplyDraft = submission.replyClaim?.snapshot; const submittedSilentReply = submission.replyClaim?.silentReply ?? silentReply; @@ -1301,10 +1268,10 @@ export const RoomInput = forwardRef( submission: Submission, isLive: () => boolean ): Promise => { - const plainText = toPlainText(submission.children).trim(); + const plainText = toPlainText(submission.children as EditorDocument).trim(); const caption = plainText.length > 0 ? plainText : undefined; let customHtml = trimCustomHtml( - toMatrixCustomHTML(submission.children, { + toMatrixCustomHTML(submission.children as EditorDocument, { stripNickname: true, room, }) @@ -1408,7 +1375,7 @@ export const RoomInput = forwardRef( const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery((prev) => { if (prev !== undefined) { - focusEditor(editor); + editor.focus(); } return undefined; }); @@ -1436,8 +1403,7 @@ export const RoomInput = forwardRef( } } - resetEditor(editor); - resetEditorHistory(editor); + editor.clear(); sendTypingStatus(false); handleCloseAutocomplete(); }, @@ -1458,7 +1424,7 @@ export const RoomInput = forwardRef( const submittedReplyDraft = submission.replyClaim?.snapshot; const submittedSilentReply = submission.replyClaim?.silentReply ?? silentReply; if (editingEvent && isMobile) { - const content = buildEditReplacement(submission.children, { + const content = buildEditReplacement(submission.children as EditorDocument, { mx, room, roomId, @@ -1500,7 +1466,7 @@ export const RoomInput = forwardRef( } } - const outgoing = await buildOutgoingMessage(submission.children, { + const outgoing = await buildOutgoingMessage(submission.children as EditorDocument, { mx, room, roomId, @@ -1761,9 +1727,8 @@ export const RoomInput = forwardRef( } } - if (isKeyHotkey('arrowup', evt) && isEmptyEditor(editor)) { - const { selection } = editor; - if (selection && Editor.isStart(editor, selection.anchor, [])) { + if (isKeyHotkey('arrowup', evt) && editor.isEmpty()) { + if (editor.isSelectionAtStart()) { evt.preventDefault(); onEditLastMessage?.(); return; @@ -1784,8 +1749,7 @@ export const RoomInput = forwardRef( evt.preventDefault(); if (editingEvent && isMobileOrTablet()) { onCancelEdit?.(); - resetEditor(editor); - resetEditorHistory(editor); + editor.clear(); return; } if (showAudioRecorder) { @@ -1829,7 +1793,7 @@ export const RoomInput = forwardRef( } if (!hideActivity) { - sendTypingStatus(!isEmptyEditor(editor)); + sendTypingStatus(!editor.isEmpty()); } detectAutocomplete(); @@ -1840,15 +1804,15 @@ export const RoomInput = forwardRef( const handleEmoticonSelect = (key: string, shortcode: string) => { const emoticonEl = createEmoticonElement(key, shortcode); if (autocompleteQuery) { - replaceWithElement(editor, autocompleteQuery.range, emoticonEl); + editor.insertInline(emoticonEl, autocompleteQuery.from, autocompleteQuery.to); } else { - editor.insertNode(emoticonEl); + editor.insertInline(emoticonEl); } if (!imagePacksUsedRef.current.has(key)) { const imgPkRef = getImagePackReferencesForMxc(key, mx, ImageUsage.Emoticon, room); if (imgPkRef?.room_id && imgPkRef?.shortcode) imagePacksUsedRef.current.set(key, imgPkRef); } - moveCursor(editor); + editor.insertText(' '); handleCloseAutocomplete(); }; @@ -1932,7 +1896,11 @@ export const RoomInput = forwardRef( const sent = await handleSendContents({ contents: [content], submission, isLive }); // When the editor has text, the reply is not attached to the GIF, so hand the // claim back for the follow-up message to carry it. - if (sent && submission.replyClaim && toPlainText(submission.children).trim().length > 0) + if ( + sent && + submission.replyClaim && + toPlainText(submission.children as EditorDocument).trim().length > 0 + ) restoreReplyClaim(submission.replyClaim); return sent; } catch (error) { @@ -1971,24 +1939,24 @@ export const RoomInput = forwardRef( {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( @@ -1996,10 +1964,10 @@ export const RoomInput = forwardRef( {autocompleteQuery?.prefix === AutocompletePrefix.Reaction && (canSendReaction ? ( @@ -2011,8 +1979,8 @@ export const RoomInput = forwardRef( {autocompleteQuery?.prefix === AutocompletePrefix.Command && ( )} @@ -2139,8 +2107,7 @@ export const RoomInput = forwardRef( { onCancelEdit?.(); - resetEditor(editor); - resetEditorHistory(editor); + editor.clear(); }} variant="SurfaceVariant" style={{ background: 'transparent' }} @@ -2720,7 +2687,7 @@ export const RoomInput = forwardRef( )} } - bottom={} + bottom={} /> {showSchedulePicker && !threadRootId && ( void; onEditLastMessageRef?: React.MutableRefObject<(() => void) | undefined>; editId?: string; diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index a0512db6f1..bff5826ba0 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -1,15 +1,14 @@ import { useCallback, useRef, useState } from 'react'; import { useAtomValue } from 'jotai'; -import { Transforms } from 'slate'; import { Box, Text, config } from 'folds'; import { EventType } from '$types/matrix-sdk'; -import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; import { useStateEvent } from '$hooks/useStateEvent'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { useEditor, resetEditor } from '$components/editor'; +import { useEditor } from '$components/editor'; +import { BlockType } from '$components/editor'; import { Page } from '$components/page'; import { useKeyDown } from '$hooks/useKeyDown'; import { editableActiveElement } from '$utils/dom'; @@ -109,9 +108,8 @@ export function RoomView({ eventId }: { eventId?: string }) { const handleEditMessage = useCallback( (body: string) => { - resetEditor(editor); - if (body) Transforms.insertText(editor, body); - ReactEditor.focus(editor); + editor.setDocument(body ? [{ type: BlockType.Paragraph, children: [{ text: body }] }] : []); + editor.focus(); }, [editor] ); @@ -126,7 +124,7 @@ export function RoomView({ eventId }: { eventId?: string }) { return; } if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) { - ReactEditor.focus(editor); + editor.focus(); } }, [editor] diff --git a/src/app/features/room/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts index aee137f90c..70b90b7d3d 100644 --- a/src/app/features/room/composerMessage.test.ts +++ b/src/app/features/room/composerMessage.test.ts @@ -44,12 +44,11 @@ const profile = (id: string, displayname: string): PerMessageProfileMsc4461 => ( trigger: { prefix: [] }, }); -/** Mirrors how the editor represents a typed command: empty text node, then a command node. */ +/** Mirrors a command selected from autocomplete in the engine-neutral document. */ const commandInput = (command: Command, rest = '') => [ { type: BlockType.Paragraph as const, children: [ - { text: '' }, { type: BlockType.Command as const, command, children: [{ text: '' }] }, { text: rest }, ], diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index 77d156cd3c..ba1d7d9a92 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -1,12 +1,11 @@ -import type { Editor } from 'slate'; import type { IContent, MatrixEvent, Room } from '$types/matrix-sdk'; +import { getDocumentBeginCommand, type EditorDocument } from '$components/editor/model'; import { MsgType } from '$types/matrix-sdk'; import type { MatrixClient, RoomMessageEventContent } from '$types/matrix-sdk'; import { BlockType, customHtmlEqualsPlainText, - getBeginCommand, - getLinks, + getDocumentLinks, getMentions, plainToEditorInput, toMatrixCustomHTML, @@ -77,7 +76,7 @@ const resolveNickname = ( // Nicknames are local-only, so they are swapped back to real display names before the // body goes out, keeping server-side mention processing correct. const buildNicknameReplacement = ( - children: Editor['children'], + children: EditorDocument, { mx, room, roomId, nicknames, replyEvent }: BuildOutgoingMessageDeps ): Map => { const replacement = new Map(); @@ -89,20 +88,23 @@ const buildNicknameReplacement = ( const senderId = replyEvent?.getSender(); if (senderId) add(senderId); - getMentions(mx, roomId, { children } as Editor)?.users?.forEach(add); + getMentions(mx, roomId, { children })?.users?.forEach(add); return replacement; }; -const stripCommandNode = (children: Editor['children']): Editor['children'] => { +const stripCommandNode = (children: EditorDocument): EditorDocument => { const firstPara = children[0]; - if ( - firstPara && - 'type' in firstPara && - firstPara.type === BlockType.Paragraph && - firstPara.children.length >= 2 - ) { - return [{ ...firstPara, children: firstPara.children.slice(2) }, ...children.slice(1)]; + if (firstPara?.type === BlockType.Paragraph) { + const commandIndex = firstPara.children.findIndex( + (token) => !('text' in token) && token.type === BlockType.Command + ); + if (commandIndex !== -1) { + return [ + { ...firstPara, children: firstPara.children.slice(commandIndex + 1) }, + ...children.slice(1), + ]; + } } return children; }; @@ -137,7 +139,7 @@ const applyPerMessageProfileFallback = ( }; export async function buildOutgoingMessage( - children: Editor['children'], + children: EditorDocument, deps: BuildOutgoingMessageDeps ): Promise { const { @@ -157,11 +159,11 @@ export async function buildOutgoingMessage( imagePacksUsed, } = deps; - const commandName = getBeginCommand({ children } as Editor); + const commandName = getDocumentBeginCommand(children); const nicknameReplacement = buildNicknameReplacement(children, deps); const transformContext = { isMarkdown: true, settingsLinkBaseUrl }; - const runTransforms = (input: Editor['children']): Editor['children'] => { + const runTransforms = (input: EditorDocument): EditorDocument => { let output = input; outgoingMessageTransforms.forEach((transform) => { if (!transform.shouldApply(output, transformContext)) return; @@ -170,7 +172,7 @@ export async function buildOutgoingMessage( return output; }; const forEmote = commandName === Command.Me || commandName === Command.RainbowMe; - const serializeHtml = (input: Editor['children']) => + const serializeHtml = (input: EditorDocument) => trimCustomHtml( toMatrixCustomHTML(input, { stripNickname: true, @@ -180,7 +182,9 @@ export async function buildOutgoingMessage( }) ); - let serializedChildren = runTransforms(commandName ? stripCommandNode(children) : children); + let serializedChildren = runTransforms( + (commandName ? stripCommandNode(children) : children) as EditorDocument + ); let plainText = toPlainText(serializedChildren, true, true, nicknameReplacement).trim(); let customHtml = serializeHtml(serializedChildren); let msgType = MsgType.Text; @@ -242,14 +246,14 @@ export async function buildOutgoingMessage( } } - const mentionData = getMentions(mx, roomId, { children } as Editor); + const mentionData = getMentions(mx, roomId, { children }); if (replyDraft && !silentReply) mentionData.users.add(replyDraft.userId); const content: MessageContent = { msgtype: msgType, body: plainText }; content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsed.toJSON(); content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = ( - getLinks(serializedChildren) ?? [] + getDocumentLinks(serializedChildren) ?? [] ).map((matched_url) => ({ matched_url })); if (replyDraft || !customHtmlEqualsPlainText(customHtml, plainText)) { @@ -291,7 +295,7 @@ export interface BuildEditReplacementDeps { /** Returns undefined when there is nothing to send, which cancels the edit. */ export function buildEditReplacement( - children: Editor['children'], + children: EditorDocument, { mx, room, roomId, editingEvent, currentContent, pmpNoFallback }: BuildEditReplacementDeps ): IContent | undefined { const plainText = toPlainText(children).trim(); @@ -308,7 +312,7 @@ export function buildEditReplacement( }) ); - const mentionData = getMentions(mx, roomId, { children } as Editor); + const mentionData = getMentions(mx, roomId, { children }); const previousMentions = currentContent['m.mentions']; if ( previousMentions && @@ -327,7 +331,7 @@ export function buildEditReplacement( customHtml, eventId, getMentionContent(Array.from(mentionData.users), mentionData.room), - (getLinks(children) ?? []).map((matched_url) => ({ matched_url })), + (getDocumentLinks(children) ?? []).map((matched_url) => ({ matched_url })), // An edit belongs to the identity used for the original event, not the currently selected one. currentContent['com.beeper.per_message_profile'] ?? oldContent['com.beeper.per_message_profile'], diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index dfe26f27bd..af79693675 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -5,8 +5,6 @@ import type { RectCords } from 'folds'; import { Box, Chip, IconButton, OverlayBackdrop, Spinner, Text, as, config } from 'folds'; import { Overlay, PopOut } from '$components/overlay-stack'; import { composerIcon, Smiley } from '$components/icons/phosphor'; -import { Editor, Transforms } from 'slate'; -import { ReactEditor } from 'slate-react'; import type { IContent, IMentions, @@ -17,7 +15,8 @@ import type { } from '$types/matrix-sdk'; import { MsgType } from '$types/matrix-sdk'; import { isKeyHotkey } from 'is-hotkey'; -import type { AutocompleteQuery } from '$components/editor'; +import type { EditorDocument } from '$components/editor/model'; +import type { EditorAutocompleteQuery } from '$components/editor/prosemirrorController'; import { AutocompletePrefix, CustomEditor, @@ -27,11 +26,7 @@ import { RoomMentionAutocomplete, UserMentionAutocomplete, createEmoticonElement, - focusEditor, customHtmlEqualsPlainText, - getAutocompleteQuery, - getPrevWorldRange, - moveCursor, plainToEditorInput, toMatrixCustomHTML, toPlainText, @@ -39,7 +34,7 @@ import { useEditor, getMentions, ANYWHERE_AUTOCOMPLETE_PREFIXES, - getLinks, + getDocumentLinks, LINKINPUTREGEX, } from '$components/editor'; import { htmlToMarkdown } from '$plugins/markdown'; @@ -121,7 +116,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( const isComposing = useComposingCheck(); const [autocompleteQuery, setAutocompleteQuery] = - useState>(); + useState>(); const getPrevBodyAndFormattedBody = useCallback((): [ string | undefined, @@ -223,9 +218,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( useCallback(async () => { const oldContent = mEvent.getContent(); const msgtype = mEvent.getContent().msgtype as RoomMessageTextEventContent['msgtype']; - let plainText = toPlainText(editor.children).trim(); + let plainText = toPlainText(editor.children as EditorDocument).trim(); let customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { + toMatrixCustomHTML(editor.children as EditorDocument, { forEmote: msgtype === MsgType.Emote, room, }) @@ -261,7 +256,9 @@ export const MessageEditor = as<'div', MessageEditorProps>( editedEvent?.getContent()?.['m.new_content']?.['com.beeper.per_message_profile'] ?? mEvent.getContent()?.['com.beeper.per_message_profile']; - const mentionData = getMentions(mx, roomId, editor); + const mentionData = getMentions(mx, roomId, { + children: editor.children as EditorDocument, + }); prevMentions?.user_ids?.forEach((prevMentionId) => { mentionData.users.add(prevMentionId); @@ -270,7 +267,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); const linkPreviews = - getLinks(editor.children)?.map((matchedUrl) => ({ + getDocumentLinks(editor.children as EditorDocument)?.map((matchedUrl) => ({ matched_url: matchedUrl, })) ?? []; @@ -309,12 +306,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !isComposing(evt) ) { - const prevWordRange = getPrevWorldRange(editor); - if ( - prevWordRange && - getAutocompleteQuery(editor, prevWordRange, ANYWHERE_AUTOCOMPLETE_PREFIXES) - ) - return; + if (editor.getAutocompleteQuery(ANYWHERE_AUTOCOMPLETE_PREFIXES)) return; evt.preventDefault(); handleSave(); @@ -328,11 +320,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( ); const detectAutocomplete = useCallback(() => { - const prevWordRange = getPrevWorldRange(editor); - const query = prevWordRange - ? getAutocompleteQuery(editor, prevWordRange, ANYWHERE_AUTOCOMPLETE_PREFIXES) - : undefined; - setAutocompleteQuery(query); + setAutocompleteQuery(editor.getAutocompleteQuery(ANYWHERE_AUTOCOMPLETE_PREFIXES)); }, [editor]); const handleKeyUp: KeyboardEventHandler = useCallback( @@ -350,15 +338,15 @@ export const MessageEditor = as<'div', MessageEditorProps>( const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery((prev) => { if (prev !== undefined) { - focusEditor(editor); + editor.focus(); } return undefined; }); }, [editor]); const handleEmoticonSelect = (key: string, shortcode: string) => { - editor.insertNode(createEmoticonElement(key, shortcode)); - moveCursor(editor); + editor.insertInline(createEmoticonElement(key, shortcode)); + editor.insertText(' '); }; useEffect(() => { @@ -378,13 +366,8 @@ export const MessageEditor = as<'div', MessageEditorProps>( mentionOptions ); - Transforms.select(editor, { - anchor: Editor.start(editor, []), - focus: Editor.end(editor, []), - }); - - editor.insertFragment(initialValue); - if (!isMobileOrTablet()) ReactEditor.focus(editor); + editor.setDocument(initialValue); + if (!isMobileOrTablet()) editor.focus(); }, [editor, getPrevBodyAndFormattedBody, room, nicknames, mx]); useEffect(() => { @@ -440,24 +423,24 @@ export const MessageEditor = as<'div', MessageEditorProps>( {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( )} @@ -514,7 +497,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( onKeyUp={handleKeyUp} bottom={ <> - + ( requestClose={() => { setAnchor((v) => { if (v) { - if (!isMobileOrTablet()) ReactEditor.focus(editor); + if (!isMobileOrTablet()) editor.focus(); return undefined; } return v; diff --git a/src/app/features/room/outgoingMessageTransforms.ts b/src/app/features/room/outgoingMessageTransforms.ts index a1b41f0613..d336605f81 100644 --- a/src/app/features/room/outgoingMessageTransforms.ts +++ b/src/app/features/room/outgoingMessageTransforms.ts @@ -1,4 +1,4 @@ -import type { Descendant } from 'slate'; +import type { EditorDocument } from '$components/editor/model'; import { hasSettingsLinksToRewrite, rewriteSettingsLinks } from './settingsLinkMessage'; export type OutgoingMessageTransformContext = { @@ -6,8 +6,8 @@ export type OutgoingMessageTransformContext = { }; export type OutgoingMessageTransform = { - apply: (children: Descendant[], context: OutgoingMessageTransformContext) => Descendant[]; - shouldApply: (children: Descendant[], context: OutgoingMessageTransformContext) => boolean; + apply: (children: EditorDocument, context: OutgoingMessageTransformContext) => EditorDocument; + shouldApply: (children: EditorDocument, context: OutgoingMessageTransformContext) => boolean; }; export const outgoingMessageTransforms: OutgoingMessageTransform[] = [ diff --git a/src/app/features/room/settingsLinkMessage.ts b/src/app/features/room/settingsLinkMessage.ts index e3f25523ef..5ed6e08ffe 100644 --- a/src/app/features/room/settingsLinkMessage.ts +++ b/src/app/features/room/settingsLinkMessage.ts @@ -1,13 +1,13 @@ import { find as findLinks } from 'linkifyjs'; -import type { Descendant } from 'slate'; -import { Text } from 'slate'; -import type { FormattedText, InlineElement, ParagraphElement } from '$components/editor/slate'; +import type { + EditorDocument, + EditorParagraph, + EditorText, + InlineToken, +} from '$components/editor/model'; +import { isEditorText } from '$components/editor/model'; import { BlockType } from '$components/editor/types'; -import { - createLinkElement, - getMarkdownCodeSpanRanges, - isInsideMarkdownCodeSpan, -} from '$components/editor/utils'; +import { getMarkdownCodeSpanRanges, isInsideMarkdownCodeSpan } from '$components/editor/utils'; import { getSettingsLinkLabel, parseSettingsLink } from '$features/settings/settingsLink'; type RewritableSettingsLinkMatch = { @@ -72,24 +72,24 @@ const getRewritableSettingsLinkMatches = ( }; const hasRewritableSettingsLinksInInlineChildren = ( - children: InlineElement[], + children: InlineToken[], baseUrl: string ): boolean => children.some( (child) => - Text.isText(child) && getRewritableSettingsLinkMatches(child.text, baseUrl).length > 0 + isEditorText(child) && getRewritableSettingsLinkMatches(child.text, baseUrl).length > 0 ); -const createTextSegment = (node: FormattedText, text: string): FormattedText => ({ +const createTextSegment = (node: EditorText, text: string): EditorText => ({ ...node, text, }); -const rewriteInlineText = (node: FormattedText, baseUrl: string): InlineElement[] => { +const rewriteInlineText = (node: EditorText, baseUrl: string): InlineToken[] => { const matches = getRewritableSettingsLinkMatches(node.text, baseUrl); if (matches.length === 0) return [node]; - const rewritten: InlineElement[] = []; + const rewritten: InlineToken[] = []; let cursor = 0; matches.forEach((match) => { @@ -97,7 +97,11 @@ const rewriteInlineText = (node: FormattedText, baseUrl: string): InlineElement[ rewritten.push(createTextSegment(node, node.text.slice(cursor, match.start))); } - rewritten.push(createLinkElement(match.href, [createTextSegment(node, match.label)])); + rewritten.push({ + type: BlockType.Link, + href: match.href, + children: [createTextSegment(node, match.label)], + }); cursor = match.end; }); @@ -107,22 +111,18 @@ const rewriteInlineText = (node: FormattedText, baseUrl: string): InlineElement[ rewritten.push(createTextSegment(node, node.text.slice(cursor))); } - return rewritten.filter((child) => !Text.isText(child) || child.text.length > 0); + return rewritten.filter((child) => !isEditorText(child) || child.text.length > 0); }; -const rewriteInlineChildren = (children: InlineElement[], baseUrl: string): InlineElement[] => - children.flatMap((child) => (Text.isText(child) ? rewriteInlineText(child, baseUrl) : [child])); +const rewriteInlineChildren = (children: InlineToken[], baseUrl: string): InlineToken[] => + children.flatMap((child) => (isEditorText(child) ? rewriteInlineText(child, baseUrl) : [child])); -const rewriteInlineContainer = (node: ParagraphElement, baseUrl: string): ParagraphElement => ({ +const rewriteInlineContainer = (node: EditorParagraph, baseUrl: string): EditorParagraph => ({ ...node, children: rewriteInlineChildren(node.children, baseUrl), }); -const hasSettingsLinksToRewriteInNode = (node: Descendant, baseUrl: string): boolean => { - if (Text.isText(node)) { - return getRewritableSettingsLinkMatches(node.text, baseUrl).length > 0; - } - +const hasSettingsLinksToRewriteInNode = (node: EditorParagraph, baseUrl: string): boolean => { switch (node.type) { case BlockType.Paragraph: return hasRewritableSettingsLinksInInlineChildren(node.children, baseUrl); @@ -131,9 +131,7 @@ const hasSettingsLinksToRewriteInNode = (node: Descendant, baseUrl: string): boo } }; -const rewriteNode = (node: Descendant, baseUrl: string): Descendant => { - if (Text.isText(node)) return node; - +const rewriteNode = (node: EditorParagraph, baseUrl: string): EditorParagraph => { switch (node.type) { case BlockType.Paragraph: return rewriteInlineContainer(node, baseUrl); @@ -142,8 +140,8 @@ const rewriteNode = (node: Descendant, baseUrl: string): Descendant => { } }; -export const hasSettingsLinksToRewrite = (nodes: Descendant[], baseUrl: string): boolean => +export const hasSettingsLinksToRewrite = (nodes: EditorDocument, baseUrl: string): boolean => nodes.some((node) => hasSettingsLinksToRewriteInNode(node, baseUrl)); -export const rewriteSettingsLinks = (nodes: Descendant[], baseUrl: string): Descendant[] => +export const rewriteSettingsLinks = (nodes: EditorDocument, baseUrl: string): EditorDocument => nodes.map((node) => rewriteNode(node, baseUrl)); diff --git a/src/app/features/settings/account/BioEditor.tsx b/src/app/features/settings/account/BioEditor.tsx index c012cd4149..793d6f01a5 100644 --- a/src/app/features/settings/account/BioEditor.tsx +++ b/src/app/features/settings/account/BioEditor.tsx @@ -1,28 +1,24 @@ import type { KeyboardEventHandler } from 'react'; -import { useCallback, useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { Room } from '$types/matrix-sdk'; import type { RectCords } from 'folds'; import { Box, Chip, IconButton, Spinner, Text, config } from 'folds'; import { PopOut } from '$components/overlay-stack'; import { composerIcon, Smiley } from '$components/icons/phosphor'; -import { Editor, Transforms } from 'slate'; -import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; -import type { AutocompleteQuery } from '$components/editor'; +import type { EditorAutocompleteQuery } from '$components/editor/prosemirrorController'; import { AutocompletePrefix, - CustomEditor, EmoticonAutocomplete, MarkdownFormattingToolbarBottom, MarkdownFormattingToolbarToggle, createEmoticonElement, - getAutocompleteQuery, - getPrevWorldRange, plainToEditorInput, - moveCursor, + ProseMirrorEditorSurface, toMatrixCustomHTML, toPlainText, trimCustomHtml, + toggleProseMirrorKeyboardShortcut, useEditor, } from '$components/editor'; import { htmlToMarkdown } from '$plugins/markdown'; @@ -30,7 +26,6 @@ import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { UseStateProvider } from '$components/UseStateProvider'; import { EmojiBoard } from '$components/emoji-board'; -import { isMobileOrTablet } from '$utils/platform'; import { SettingTile } from '$components/setting-tile'; import * as css from './BioEditor.css'; @@ -44,18 +39,19 @@ type BioEditorProps = { export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditorProps) { const editor = useEditor(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); + const [shortcutOverrides] = useSetting(settingsAtom, 'shortcutOverrides'); const [autocompleteQuery, setAutocompleteQuery] = - useState>(); + useState>(); const [hasChanged, setHasChanged] = useState(false); const prevValue = useRef(value); const initialized = useRef(false); const handleSave = useCallback(() => { - const plainText = toPlainText(editor.children).trim(); + const plainText = toPlainText(editor.getDocument()).trim(); - const customHtml = trimCustomHtml(toMatrixCustomHTML(editor.children, {})); + const customHtml = trimCustomHtml(toMatrixCustomHTML(editor.getDocument(), {})); onSave(customHtml || plainText, plainText); setHasChanged(false); @@ -82,7 +78,7 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor const incomingPlainText = toPlainText( plainToEditorInput(safeValue.includes('<') ? htmlToMarkdown(safeValue) : safeValue) ).trim(); - const currentPlainText = toPlainText(editor.children).trim(); + const currentPlainText = toPlainText(editor.getDocument()).trim(); if (currentPlainText === incomingPlainText && initialized.current) return; @@ -91,9 +87,7 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor ? plainToEditorInput(htmlToMarkdown(safeValue)) : plainToEditorInput(safeValue); - editor.children = initialValue; - Editor.normalize(editor, { force: true }); - Transforms.select(editor, Editor.start(editor, [])); + editor.setDocument(initialValue); initialized.current = true; setHasChanged(false); @@ -102,12 +96,16 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { + if (toggleProseMirrorKeyboardShortcut(editor, evt, shortcutOverrides)) { + evt.preventDefault(); + return; + } if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) { evt.preventDefault(); handleSave(); } }, - [handleSave, enterForNewline] + [editor, enterForNewline, handleSave, shortcutOverrides] ); const handleKeyUp: KeyboardEventHandler = useCallback( @@ -116,23 +114,19 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor evt.preventDefault(); return; } - const prevWordRange = getPrevWorldRange(editor); - const query = prevWordRange - ? getAutocompleteQuery(editor, prevWordRange, [AutocompletePrefix.Emoticon]) - : undefined; - setAutocompleteQuery(query); + setAutocompleteQuery(editor.getAutocompleteQuery([AutocompletePrefix.Emoticon])); }, [editor] ); const handleCloseAutocomplete = useCallback(() => { - ReactEditor.focus(editor); + editor.focus(); setAutocompleteQuery(undefined); }, [editor]); const handleEmoticonSelect = (key: string, shortcode: string) => { - editor.insertNode(createEmoticonElement(key, shortcode)); - moveCursor(editor); + editor.insertInline(createEmoticonElement(key, shortcode)); + editor.insertText(' '); setHasChanged(true); }; @@ -143,15 +137,15 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( )} - { + onDocumentChange={() => { if (!hasChanged) setHasChanged(true); }} onKeyDown={handleKeyDown} @@ -160,7 +154,7 @@ export function BioEditor({ value, isSaving, imagePackRooms, onSave }: BioEditor variant="Background" bottom={ - + setAnchor((v) => { if (v) { - if (!isMobileOrTablet()) ReactEditor.focus(editor); + editor.focus(); return undefined; } return v; diff --git a/src/app/hooks/timeline/useTimelineActions.ts b/src/app/hooks/timeline/useTimelineActions.ts index aa4010fb49..7e5eef150d 100644 --- a/src/app/hooks/timeline/useTimelineActions.ts +++ b/src/app/hooks/timeline/useTimelineActions.ts @@ -3,13 +3,12 @@ import { useCallback } from 'react'; import type { MatrixClient, Room, MatrixEvent } from '$types/matrix-sdk'; import type { UserProfile } from '$hooks/useUserProfile'; import { EventStatus, RelationType } from '$types/matrix-sdk'; -import type { Editor } from 'slate'; -import { ReactEditor } from 'slate-react'; import { getMxIdLocalPart, toggleReaction } from '$utils/matrix'; import { getMemberDisplayName } from '$utils/room/display'; import { extractReplyDraftBody, resolveReplyDraftTarget } from '$utils/room/relations'; -import { createMentionElement, moveCursor } from '$components/editor'; +import { createMentionElement } from '$components/editor'; +import type { ProseMirrorEditorController } from '$components/editor/prosemirrorController'; import * as prefix from '$unstable/prefixes'; import type { Persona } from '$hooks/usePerMessageProfile'; import { convertBeeperFormatToOurPerMessageProfile } from '$hooks/usePerMessageProfile'; @@ -49,7 +48,7 @@ export const buildCachedProfilePayload = (cachedData: UserProfile | undefined) = export interface UseTimelineActionsOptions { room: Room; mx: MatrixClient; - editor: Editor; + editor: ProseMirrorEditorController; nicknames: Record; getGlobalProfile: (userId: string) => UserProfile | undefined; spaceId?: string; @@ -140,15 +139,15 @@ export function useTimelineActions({ const name = getMemberDisplayName(room, userId, nicknames) ?? getMxIdLocalPart(userId) ?? userId; - editor.insertNode( + editor.insertInline( createMentionElement( userId, name.startsWith('@') ? name : `@${name}`, userId === mx.getUserId() ) ); - ReactEditor.focus(editor); - moveCursor(editor); + editor.insertText(' '); + editor.focus(); }, [mx, room, editor, nicknames] ); diff --git a/src/app/hooks/useMessageEdit.ts b/src/app/hooks/useMessageEdit.ts index 4480ba4161..b4bd4b13f9 100644 --- a/src/app/hooks/useMessageEdit.ts +++ b/src/app/hooks/useMessageEdit.ts @@ -1,7 +1,5 @@ import { useState, useCallback, useRef } from 'react'; -import type { Editor } from 'slate'; -import { ReactEditor } from 'slate-react'; -import { isEmptyEditor, moveCursor } from '$components/editor'; +import type { ProseMirrorEditorController } from '$components/editor/prosemirrorController'; export interface UseMessageEditOptions { onReset?: () => void; @@ -18,7 +16,7 @@ export interface UseMessageEditOptions { * editor and returns focus. */ export function useMessageEdit( - editor: Editor, + editor: ProseMirrorEditorController, options?: UseMessageEditOptions ): { editId: string | undefined; handleEdit: (editId?: string) => void } { const [editId, setEditId] = useState(undefined); @@ -41,9 +39,8 @@ export function useMessageEdit( if (!focusOnCancelRef.current) return; requestAnimationFrame(() => { if (aliveRef.current && !aliveRef.current()) return; - if (onResetRef.current && isEmptyEditor(editor)) onResetRef.current(); - ReactEditor.focus(editor); - moveCursor(editor); + if (onResetRef.current && editor.isEmpty()) onResetRef.current(); + editor.focus(); }); }, [editor] diff --git a/src/app/plugins/markdown/extensions/matrix-emoticon.ts b/src/app/plugins/markdown/extensions/matrix-emoticon.ts index 12e8bd007d..301a2dc6aa 100644 --- a/src/app/plugins/markdown/extensions/matrix-emoticon.ts +++ b/src/app/plugins/markdown/extensions/matrix-emoticon.ts @@ -1,6 +1,6 @@ import type { TokenizerExtension, RendererExtension } from 'marked'; -/** Delimiters for round-tripping Matrix emoticons from HTML through markdown into the Slate composer. */ +/** Delimiters for round-tripping Matrix emoticons from HTML through markdown into the editor. */ export const MX_EMOTICON_MD_START = '\uE000'; export const MX_EMOTICON_MD_SEP = '\uE001'; export const MX_EMOTICON_MD_END = '\uE002'; diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts index 219e67089b..a3b99de6f3 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.test.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts @@ -171,7 +171,7 @@ describe('htmlToMarkdown', () => { expect(htmlToMarkdown('

')).toContain('\\'); }); - it('plainToEditorInput expands emoticon placeholders into Slate emoticon elements', () => { + it('plainToEditorInput expands emoticon placeholders into editor emoticon tokens', () => { const src = 'mxc://matrix.org/emote'; const md = `before${MX_EMOTICON_MD_START}${src}${MX_EMOTICON_MD_SEP}blobcat${MX_EMOTICON_MD_END}after`; const doc = plainToEditorInput(md); diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index 0e3756d8db..ed9d50a85b 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -67,7 +67,7 @@ function processNodes(nodes: ChildNode[]): string { for (let i = 0; i < filtered.length; i += 1) { const cur = filtered[i]!; const prev = filtered[i - 1]; - // Adjacent

blocks must become \n\n in markdown so the editor gets separate Slate + // Adjacent

blocks must become \n\n in markdown so the editor gets separate paragraphs. // paragraphs and marked emits

per block again on send (single \n would collapse). if (i > 0 && prev && isTag(prev) && isTag(cur)) { const prevTag = prev.name.toLowerCase(); diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 10525f64a5..4ed07f9cfc 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -1,6 +1,6 @@ import { atom } from 'jotai'; import { atomFamily } from 'jotai/utils'; -import type { Descendant } from 'slate'; +import type { EditorDocument } from '$components/editor/model'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import type { IEventRelation } from '$types/matrix-sdk'; import type { TUploadContent } from '$utils/matrix'; @@ -30,7 +30,8 @@ export const roomIdToUploadItemsAtomFamily = atomFamily export const roomUploadAtomFamily = createUploadAtomFamily(); -const createMsgDraftAtom = () => atom([]); +// Room drafts are Sable documents, not a rendering-engine data structure. +const createMsgDraftAtom = () => atom([]); type TMsgDraftAtom = ReturnType; export const roomIdToMsgDraftAtomFamily = atomFamily(() => createMsgDraftAtom() diff --git a/tests/e2e/live-timeline.spec.ts b/tests/e2e/live-timeline.spec.ts index 6dc1def02e..d1fe710557 100644 --- a/tests/e2e/live-timeline.spec.ts +++ b/tests/e2e/live-timeline.spec.ts @@ -15,7 +15,7 @@ test.describe('live timeline', () => { test('renders an editor-sent message as a single server-confirmed row', async ({ page, }, testInfo) => { - test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); + test.skip(testInfo.project.name === 'touch', 'desktop and mobile viewport coverage'); test.setTimeout(300_000); const storageStatePath = testInfo.project.use.storageState as string; const hsBaseUrl = await homeserverBaseUrl(storageStatePath); @@ -34,16 +34,30 @@ test.describe('live timeline', () => { }); await app.openRoom(`${tag} Room`); - const body = `${tag}-hello`; + const body = `${tag}-hello!\n${tag}-again`; const editor = page.locator('[data-editable-name="RoomInput"]'); await editor.click(); - await editor.pressSequentially(body); + await editor.pressSequentially('before #'); + await expect(page.locator('[data-autocomplete-menu="true"]')).toContainText('Rooms'); + await editor.press('Backspace'); + await editor.pressSequentially('@'); + await expect(page.locator('[data-autocomplete-menu="true"]')).toContainText('Mentions'); + await editor.press('Backspace'); + await editor.press('Backspace'); + await editor.press('Backspace'); + await editor.press('Backspace'); + await editor.press('Backspace'); + await editor.press('Backspace'); + await editor.press('Backspace'); + await editor.press('Backspace'); + await editor.pressSequentially(`${tag}-hello`); + await editor.press('Tab'); + await editor.press('Shift+Tab'); + await editor.pressSequentially('!'); + await editor.press('Shift+Enter'); + await editor.pressSequentially(`${tag}-again`); await editor.press('Enter'); - await expect(page.getByText(body, { exact: true })).toBeVisible({ - timeout: 120_000, - }); - let serverId: string | undefined; await expect(async () => { const messages = await getRoomMessages(hsBaseUrl, user.accessToken, room); @@ -51,8 +65,42 @@ test.describe('live timeline', () => { expect(serverId).toMatch(/^\$/); }).toPass({ timeout: 120_000, intervals: [500] }); - await expect(app.messageByEventId(serverId!).getByText(body, { exact: true })).toHaveCount(1); - expect(await page.getByText(body, { exact: true }).count()).toBe(1); + await expect(app.messageByEventId(serverId!)).toBeVisible(); + }); + + test('keeps a copied one-line message on one line when pasted over its selection', async ({ + page, + }, testInfo) => { + test.skip(testInfo.project.name !== 'desktop', 'desktop-focused'); + test.setTimeout(300_000); + const storageStatePath = testInfo.project.use.storageState as string; + const hsBaseUrl = await homeserverBaseUrl(storageStatePath); + const tag = `paste-${process.pid}-${Date.now().toString(36)}`; + const app = new AppShell(page); + const user = await loginAsFreshUser(page, hsBaseUrl, `${tag}-u`); + + const room = await createRoom(hsBaseUrl, user.accessToken, { + name: `${tag} Room`, + preset: 'private_chat', + }); + + await page.goto('/'); + await expect(page.getByText(`${tag} Room`).first()).toBeVisible({ timeout: 180_000 }); + await app.openRoom(`${tag} Room`); + + const body = `${tag}-short`; + const editor = page.locator('[data-editable-name="RoomInput"]'); + await editor.click(); + await editor.pressSequentially(body); + await editor.press('Control+A'); + await editor.press('Control+C'); + await editor.press('Control+V'); + await editor.press('Enter'); + + await expect(async () => { + const messages = await getRoomMessages(hsBaseUrl, user.accessToken, room); + expect(messages.find((message) => message.body === body)?.eventId).toMatch(/^\$/); + }).toPass({ timeout: 120_000, intervals: [500] }); }); test('renders a remote burst in an open room exactly once and in canonical order', async ({