From dbeb5a4b412f92dc4ace1278cd3f2bd2d54361e6 Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Fri, 31 Jul 2026 16:32:09 +0700 Subject: [PATCH 1/4] MM-69893: Mount the host WYSIWYG editor at the page route with draft autosave --- webapp/package-lock.json | 373 +++++++++++++++- webapp/package.json | 2 + webapp/src/client/drafts.ts | 69 +++ webapp/src/client/pages.ts | 13 + webapp/src/client/presence_events.ts | 23 + .../page_editor/apply_formatting.ts | 79 ++++ .../autosave_indicator.module.scss | 9 + .../page_editor/autosave_indicator.tsx | 42 ++ .../page_editor/callout_extension.ts | 54 +++ .../components/page_editor/docs_extensions.ts | 6 + .../exit_editor_dialog.module.scss | 15 + .../page_editor/exit_editor_dialog.tsx | 88 ++++ .../floating_formatting_bar.module.scss | 22 + .../page_editor/floating_formatting_bar.tsx | 137 ++++++ .../page_editor/page_byline.module.scss | 17 + .../components/page_editor/page_byline.tsx | 47 +++ .../page_editor/page_editor.module.scss | 171 +++++++- .../components/page_editor/page_editor.tsx | 399 +++++++++++++++++- .../publish_conflict_dialog.module.scss | 12 + .../page_editor/publish_conflict_dialog.tsx | 82 ++++ .../page_editor/toolbar_controls.module.scss | 71 ++++ .../page_editor/toolbar_controls.tsx | 146 +++++++ .../src/hooks/caret_anchored_suggestions.ts | 96 +++++ webapp/src/hooks/draft_autosave.test.tsx | 281 ++++++++++++ webapp/src/hooks/draft_autosave.ts | 146 +++++++ webapp/src/hooks/page_draft.ts | 84 ++++ webapp/src/hooks/page_presence.ts | 72 ++++ webapp/src/hooks/pinned_toolbar.ts | 35 ++ webapp/src/hooks/user.ts | 4 + webapp/src/index.tsx | 8 + webapp/src/types/drafts.ts | 56 +++ 31 files changed, 2619 insertions(+), 40 deletions(-) create mode 100644 webapp/src/client/drafts.ts create mode 100644 webapp/src/client/pages.ts create mode 100644 webapp/src/client/presence_events.ts create mode 100644 webapp/src/components/page_editor/apply_formatting.ts create mode 100644 webapp/src/components/page_editor/autosave_indicator.module.scss create mode 100644 webapp/src/components/page_editor/autosave_indicator.tsx create mode 100644 webapp/src/components/page_editor/callout_extension.ts create mode 100644 webapp/src/components/page_editor/docs_extensions.ts create mode 100644 webapp/src/components/page_editor/exit_editor_dialog.module.scss create mode 100644 webapp/src/components/page_editor/exit_editor_dialog.tsx create mode 100644 webapp/src/components/page_editor/floating_formatting_bar.module.scss create mode 100644 webapp/src/components/page_editor/floating_formatting_bar.tsx create mode 100644 webapp/src/components/page_editor/page_byline.module.scss create mode 100644 webapp/src/components/page_editor/page_byline.tsx create mode 100644 webapp/src/components/page_editor/publish_conflict_dialog.module.scss create mode 100644 webapp/src/components/page_editor/publish_conflict_dialog.tsx create mode 100644 webapp/src/components/page_editor/toolbar_controls.module.scss create mode 100644 webapp/src/components/page_editor/toolbar_controls.tsx create mode 100644 webapp/src/hooks/caret_anchored_suggestions.ts create mode 100644 webapp/src/hooks/draft_autosave.test.tsx create mode 100644 webapp/src/hooks/draft_autosave.ts create mode 100644 webapp/src/hooks/page_draft.ts create mode 100644 webapp/src/hooks/page_presence.ts create mode 100644 webapp/src/hooks/pinned_toolbar.ts create mode 100644 webapp/src/types/drafts.ts diff --git a/webapp/package-lock.json b/webapp/package-lock.json index f9412fa..ac571d8 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -13,6 +13,8 @@ "@mattermost/compass-icons": "0.1.53", "@mattermost/shared": "11.8.0", "@tanstack/react-form": "1.33.0", + "@tiptap/core": "3.20.0", + "@tiptap/pm": "3.20.0", "classnames": "2.3.1", "core-js": "3.26.0", "mattermost-redux": "11.8.0", @@ -3650,6 +3652,12 @@ "redux": "^3.1.0 || ^4.0.0 || ^5.0.0" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -3875,6 +3883,49 @@ "react-dom": "^18.0.0" } }, + "node_modules/@tiptap/core": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", + "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^3.20.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", + "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.24.1", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.5.0", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.38.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -4053,6 +4104,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.1.2", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", + "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.6", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.6.tgz", @@ -5111,7 +5184,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -6128,6 +6200,12 @@ "node": ">=10" } }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6651,6 +6729,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/envinfo": { "version": "7.21.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", @@ -6894,7 +6984,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10731,6 +10820,25 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -10872,6 +10980,33 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10909,6 +11044,12 @@ } } }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11422,6 +11563,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -11887,6 +12034,201 @@ "react-is": "^16.13.1" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.5.tgz", + "integrity": "sha512-ac8trNQ01ybKDRTcfUc56LZufG3oYyU4N25qSXgp8dS0U4JtzzCj7oQlKu5v09VSmS5IseYoQ2yDkTbo7f7D8Q==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -11910,6 +12252,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -12419,6 +12770,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13668,6 +14025,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -13885,6 +14248,12 @@ "browser-process-hrtime": "^1.0.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", diff --git a/webapp/package.json b/webapp/package.json index c6b39a2..c80b0b5 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -73,6 +73,8 @@ "@mattermost/compass-icons": "0.1.53", "@mattermost/shared": "11.8.0", "@tanstack/react-form": "1.33.0", + "@tiptap/core": "3.20.0", + "@tiptap/pm": "3.20.0", "classnames": "2.3.1", "core-js": "3.26.0", "mattermost-redux": "11.8.0", diff --git a/webapp/src/client/drafts.ts b/webapp/src/client/drafts.ts new file mode 100644 index 0000000..8eb039f --- /dev/null +++ b/webapp/src/client/drafts.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from 'types/docs'; +import type {Draft, DraftPatch, PageActiveEditors, PublishConflict} from 'types/drafts'; + +import {doFetch, RestError} from './rest'; + +const draftPath = (spaceId: string, pageId: string): string => + `/spaces/${encodeURIComponent(spaceId)}/pages/${encodeURIComponent(pageId)}/draft`; + +export function createSpaceDraft(spaceId: string, title = '', parentId = ''): Promise { + return doFetch(`/spaces/${encodeURIComponent(spaceId)}/drafts`, { + method: 'POST', + body: {title, parent_id: parentId}, + }); +} + +export function getPageDraft(spaceId: string, pageId: string, signal?: AbortSignal): Promise { + return doFetch(draftPath(spaceId, pageId), {signal}); +} + +export function updatePageDraft(spaceId: string, pageId: string, patch: DraftPatch, signal?: AbortSignal): Promise { + return doFetch(draftPath(spaceId, pageId), { + method: 'PATCH', + body: patch, + signal, + }); +} + +export function deletePageDraft(spaceId: string, pageId: string): Promise { + return doFetch(draftPath(spaceId, pageId), {method: 'DELETE'}); +} + +export function getPageActiveEditors(spaceId: string, pageId: string, signal?: AbortSignal): Promise { + return doFetch( + `/spaces/${encodeURIComponent(spaceId)}/pages/${encodeURIComponent(pageId)}/active-editors`, + {signal}, + ); +} + +export class PublishConflictError extends Error { + reason: string; + currentPage: Page | null; + + constructor(conflict: PublishConflict) { + super(conflict.error?.message ?? 'Publish conflict'); + this.name = 'PublishConflictError'; + this.reason = conflict.error?.id ?? ''; + this.currentPage = conflict.current_page ?? null; + } +} + +const isPublishConflict = (body: unknown): body is PublishConflict => + Boolean(body) && typeof body === 'object' && 'current_page' in (body as object); + +export async function publishPageDraft(spaceId: string, pageId: string, force = false): Promise { + try { + return await doFetch(`${draftPath(spaceId, pageId)}/publish`, { + method: 'POST', + body: {force}, + }); + } catch (error) { + if (error instanceof RestError && error.status === 409 && isPublishConflict(error.body)) { + throw new PublishConflictError(error.body); + } + throw error; + } +} diff --git a/webapp/src/client/pages.ts b/webapp/src/client/pages.ts new file mode 100644 index 0000000..74ed597 --- /dev/null +++ b/webapp/src/client/pages.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from 'types/docs'; + +import {doFetch} from './rest'; + +export function getPage(spaceId: string, pageId: string, signal?: AbortSignal): Promise { + return doFetch( + `/spaces/${encodeURIComponent(spaceId)}/pages/${encodeURIComponent(pageId)}`, + {signal}, + ); +} diff --git a/webapp/src/client/presence_events.ts b/webapp/src/client/presence_events.ts new file mode 100644 index 0000000..09e83d7 --- /dev/null +++ b/webapp/src/client/presence_events.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PageActiveEditors} from 'types/drafts'; + +export type PagePresenceEvent = PageActiveEditors & { + page_id: string; +}; + +type Listener = (event: PagePresenceEvent) => void; + +const listeners = new Set(); + +export function subscribeToPagePresence(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function publishPagePresence(event: PagePresenceEvent): void { + listeners.forEach((listener) => listener(event)); +} diff --git a/webapp/src/components/page_editor/apply_formatting.ts b/webapp/src/components/page_editor/apply_formatting.ts new file mode 100644 index 0000000..aada3c5 --- /dev/null +++ b/webapp/src/components/page_editor/apply_formatting.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Editor} from '@tiptap/core'; +import type {PublishedMarkdownMode} from 'webapp_globals'; + +// These commands come from the host's StarterKit extensions, which are not +// dependencies here, so the chain they extend is typed structurally. +type FormattingChain = { + toggleBold: () => FormattingChain; + toggleItalic: () => FormattingChain; + toggleStrike: () => FormattingChain; + toggleHeading: (attrs: {level: number}) => FormattingChain; + toggleCodeBlock: () => FormattingChain; + toggleBlockquote: () => FormattingChain; + toggleBulletList: () => FormattingChain; + toggleOrderedList: () => FormattingChain; + run: () => boolean; +}; + +const selectWordUnderCaret = (editor: Editor) => { + const {$from} = editor.state.selection; + const text = $from.parent.textContent; + const offset = $from.parentOffset; + if (!text || offset < 0) { + return; + } + + const isWordChar = /\S/; + let start = offset; + while (start > 0 && isWordChar.test(text[start - 1])) { + start--; + } + let end = offset; + while (end < text.length && isWordChar.test(text[end])) { + end++; + } + + if (start < end) { + const parentStart = $from.pos - offset; + editor.chain().focus().setTextSelection({from: parentStart + start, to: parentStart + end}).run(); + } +}; + +export function applyWysiwygFormatting(editor: Editor, mode: PublishedMarkdownMode): void { + if (mode === 'bold' || mode === 'italic' || mode === 'strike') { + if (editor.state.selection.empty) { + selectWordUnderCaret(editor); + } + } + + const chain = editor.chain().focus() as unknown as FormattingChain; + switch (mode) { + case 'bold': + chain.toggleBold().run(); + break; + case 'italic': + chain.toggleItalic().run(); + break; + case 'strike': + chain.toggleStrike().run(); + break; + case 'heading': + chain.toggleHeading({level: 3}).run(); + break; + case 'code': + chain.toggleCodeBlock().run(); + break; + case 'quote': + chain.toggleBlockquote().run(); + break; + case 'ul': + chain.toggleBulletList().run(); + break; + case 'ol': + chain.toggleOrderedList().run(); + break; + } +} diff --git a/webapp/src/components/page_editor/autosave_indicator.module.scss b/webapp/src/components/page_editor/autosave_indicator.module.scss new file mode 100644 index 0000000..4e5ea29 --- /dev/null +++ b/webapp/src/components/page_editor/autosave_indicator.module.scss @@ -0,0 +1,9 @@ +.root { + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; + white-space: nowrap; + + &[data-status='unsaved'] { + color: var(--away-indicator); + } +} diff --git a/webapp/src/components/page_editor/autosave_indicator.tsx b/webapp/src/components/page_editor/autosave_indicator.tsx new file mode 100644 index 0000000..e8359d7 --- /dev/null +++ b/webapp/src/components/page_editor/autosave_indicator.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {AutosaveStatus} from 'hooks/draft_autosave'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import styles from './autosave_indicator.module.scss'; + +type Props = { + status: AutosaveStatus; +}; + +const AutosaveIndicator = ({status}: Props) => ( + + {status === 'saving' && ( + + )} + {status === 'saved' && ( + + )} + {status === 'unsaved' && ( + + )} + +); + +export default AutosaveIndicator; diff --git a/webapp/src/components/page_editor/callout_extension.ts b/webapp/src/components/page_editor/callout_extension.ts new file mode 100644 index 0000000..d8c5981 --- /dev/null +++ b/webapp/src/components/page_editor/callout_extension.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Node, mergeAttributes} from '@tiptap/core'; + +export const CALLOUT_TYPES = ['info', 'note', 'success', 'warning', 'error'] as const; + +export type CalloutType = (typeof CALLOUT_TYPES)[number]; + +const DEFAULT_TYPE: CalloutType = 'info'; + +declare module '@tiptap/core' { + interface Commands { + callout: { + setCallout: (type: CalloutType) => ReturnType; + toggleCallout: (type: CalloutType) => ReturnType; + }; + } +} + +export const Callout = Node.create({ + name: 'callout', + group: 'block', + content: 'block+', + defining: true, + + addAttributes() { + return { + type: { + default: DEFAULT_TYPE, + parseHTML: (element) => { + const value = element.getAttribute('data-callout-type'); + return CALLOUT_TYPES.includes(value as CalloutType) ? value : DEFAULT_TYPE; + }, + renderHTML: (attributes) => ({'data-callout-type': attributes.type}), + }, + }; + }, + + parseHTML() { + return [{tag: 'div[data-callout-type]'}]; + }, + + renderHTML({HTMLAttributes}) { + return ['div', mergeAttributes(HTMLAttributes, {class: 'docs-callout'}), 0]; + }, + + addCommands() { + return { + setCallout: (type: CalloutType) => ({commands}) => commands.wrapIn(this.name, {type}), + toggleCallout: (type: CalloutType) => ({commands}) => commands.toggleWrap(this.name, {type}), + }; + }, +}); diff --git a/webapp/src/components/page_editor/docs_extensions.ts b/webapp/src/components/page_editor/docs_extensions.ts new file mode 100644 index 0000000..b4236a2 --- /dev/null +++ b/webapp/src/components/page_editor/docs_extensions.ts @@ -0,0 +1,6 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Callout} from './callout_extension'; + +export const DOCS_EXTENSIONS: unknown[] = [Callout]; diff --git a/webapp/src/components/page_editor/exit_editor_dialog.module.scss b/webapp/src/components/page_editor/exit_editor_dialog.module.scss new file mode 100644 index 0000000..2a6244e --- /dev/null +++ b/webapp/src/components/page_editor/exit_editor_dialog.module.scss @@ -0,0 +1,15 @@ +.actions { + display: flex; + align-items: center; + width: 100%; + gap: 8px; +} + +.error { + margin-top: 8px; + color: var(--error-text); +} + +.spacer { + flex: 1; +} diff --git a/webapp/src/components/page_editor/exit_editor_dialog.tsx b/webapp/src/components/page_editor/exit_editor_dialog.tsx new file mode 100644 index 0000000..2326f79 --- /dev/null +++ b/webapp/src/components/page_editor/exit_editor_dialog.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {DestructiveButton, PrimaryButton, SecondaryButton} from 'components/form-controls/button'; +import GenericModal from 'components/generic_modal/generic_modal'; + +import styles from './exit_editor_dialog.module.scss'; + +type Props = { + onPublish: () => void; + onSaveDraft: () => void; + onDiscard: () => void; + onClose: () => void; + busy?: boolean; + failed?: boolean; +}; + +const ExitEditorDialog = ({onPublish, onSaveDraft, onDiscard, onClose, busy = false, failed = false}: Props) => { + const {formatMessage} = useIntl(); + + return ( + + } + footer={ +
+ + + +
+ + + + + + +
+ } + > +

+ +

+ {failed && ( +

+ +

+ )} + + ); +}; + +export default ExitEditorDialog; diff --git a/webapp/src/components/page_editor/floating_formatting_bar.module.scss b/webapp/src/components/page_editor/floating_formatting_bar.module.scss new file mode 100644 index 0000000..36eef8b --- /dev/null +++ b/webapp/src/components/page_editor/floating_formatting_bar.module.scss @@ -0,0 +1,22 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.bar { + position: absolute; + z-index: 10; + + width: min(660px, 100%); + padding-right: 7px; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: var(--center-channel-bg); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.hidden { + top: 0; + left: 0; + opacity: 0; + pointer-events: none; + visibility: hidden; +} diff --git a/webapp/src/components/page_editor/floating_formatting_bar.tsx b/webapp/src/components/page_editor/floating_formatting_bar.tsx new file mode 100644 index 0000000..9206390 --- /dev/null +++ b/webapp/src/components/page_editor/floating_formatting_bar.tsx @@ -0,0 +1,137 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {hostGetEditor} from 'webapp_globals'; +import type {PublishedFormattingBarHandle, PublishedMarkdownMode} from 'webapp_globals'; + +import styles from './floating_formatting_bar.module.scss'; + +const GAP = 8; + +const boundaryTop = (editorEl: HTMLElement): number => { + const scroller = editorEl.closest('[data-docs-scroll]'); + return scroller ? scroller.getBoundingClientRect().top : 0; +}; + +type Props = { + editorRef: React.RefObject; + applyFormatting: (mode: PublishedMarkdownMode) => void; + getEditor: () => unknown; + barRef: React.Ref; + additionalControls?: React.ReactNode[]; +}; + +const FloatingFormattingBar = ({editorRef, applyFormatting, getEditor, barRef, additionalControls}: Props) => { + const wrapperRef = useRef(null); + const interactingRef = useRef(false); + const [position, setPosition] = useState<{top: number; left: number} | null>(null); + + const reposition = useCallback(() => { + const wrapper = wrapperRef.current; + const editorEl = editorRef.current; + if (!wrapper || !editorEl) { + return; + } + + if (interactingRef.current || wrapper.contains(document.activeElement)) { + return; + } + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + setPosition(null); + return; + } + + const range = selection.getRangeAt(0); + if (!editorEl.contains(range.commonAncestorContainer)) { + setPosition(null); + return; + } + + const rect = range.getClientRects()[0] ?? range.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + setPosition(null); + return; + } + + const originRect = editorEl.getBoundingClientRect(); + const {offsetWidth, offsetHeight} = wrapper; + const selectionCenter = rect.left + (rect.width / 2); + const centered = selectionCenter - originRect.left - (offsetWidth / 2); + const maxLeft = Math.max(0, editorEl.clientWidth - offsetWidth); + + const flipBelow = rect.top - offsetHeight - GAP < boundaryTop(editorEl); + const top = flipBelow ? (rect.bottom - originRect.top) + GAP : rect.top - originRect.top - offsetHeight - GAP; + + setPosition({ + top: Math.round(top), + left: Math.round(Math.min(Math.max(0, centered), maxLeft)), + }); + }, [editorRef]); + + const frameRef = useRef(0); + const schedule = useCallback(() => { + if (frameRef.current) { + return; + } + frameRef.current = requestAnimationFrame(() => { + frameRef.current = 0; + reposition(); + }); + }, [reposition]); + + useEffect(() => { + document.addEventListener('selectionchange', schedule); + window.addEventListener('resize', schedule); + return () => { + document.removeEventListener('selectionchange', schedule); + window.removeEventListener('resize', schedule); + if (frameRef.current) { + cancelAnimationFrame(frameRef.current); + frameRef.current = 0; + } + }; + }, [schedule]); + + const onMouseDown = useCallback((e: React.MouseEvent) => { + interactingRef.current = true; + if (!(e.target as HTMLElement).closest('input, textarea')) { + e.preventDefault(); + } + }, []); + + useEffect(() => { + const release = () => { + interactingRef.current = false; + }; + document.addEventListener('mouseup', release); + return () => document.removeEventListener('mouseup', release); + }, []); + + const {FormattingBar} = hostGetEditor() ?? {}; + if (!FormattingBar) { + return null; + } + + return ( +
+ +
+ ); +}; + +export default FloatingFormattingBar; diff --git a/webapp/src/components/page_editor/page_byline.module.scss b/webapp/src/components/page_editor/page_byline.module.scss new file mode 100644 index 0000000..e477634 --- /dev/null +++ b/webapp/src/components/page_editor/page_byline.module.scss @@ -0,0 +1,17 @@ +.root { + display: flex; + align-items: center; + gap: 8px; +} + +.avatar { + width: 20px; + height: 20px; + border-radius: 50%; + object-fit: cover; +} + +.name { + color: rgba(var(--center-channel-color-rgb), 0.75); + font-size: 12px; +} diff --git a/webapp/src/components/page_editor/page_byline.tsx b/webapp/src/components/page_editor/page_byline.tsx new file mode 100644 index 0000000..880498d --- /dev/null +++ b/webapp/src/components/page_editor/page_byline.tsx @@ -0,0 +1,47 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {siteRoot} from 'client/rest'; +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import type {GlobalState} from '@mattermost/types/store'; + +import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; +import {getUser} from 'mattermost-redux/selectors/entities/users'; +import {displayUsername} from 'mattermost-redux/utils/user_utils'; + +import styles from './page_byline.module.scss'; + +type Props = { + userId: string; +}; + +const PageByline = ({userId}: Props) => { + const author = useSelector((state: GlobalState) => getUser(state, userId)); + const teammateNameDisplay = useSelector(getTeammateNameDisplaySetting) || ''; + + if (!author) { + return null; + } + + return ( +
+ + + + +
+ ); +}; + +export default PageByline; diff --git a/webapp/src/components/page_editor/page_editor.module.scss b/webapp/src/components/page_editor/page_editor.module.scss index 8e72ce6..f84e975 100644 --- a/webapp/src/components/page_editor/page_editor.module.scss +++ b/webapp/src/components/page_editor/page_editor.module.scss @@ -3,34 +3,173 @@ flex: 1 1 0; flex-direction: column; min-width: 0; + + min-height: 0; height: 100%; - padding: 24px 32px; - gap: 16px; - overflow: auto; background: var(--center-channel-bg); } .header { display: flex; + flex-shrink: 0; align-items: center; - justify-content: space-between; + justify-content: flex-end; + height: 52px; + padding: 0 20px; + gap: 12px; +} + +.scroll { + flex: 1 1 auto; + min-height: 320px; + overflow-y: auto; +} + +.column { + max-width: 700px; + margin: 0 auto; + padding: 0 24px 96px; +} + +.titleBlock { + display: flex; + flex-direction: column; + padding: 32px 0 16px; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + gap: 12px; + margin-bottom: 24px; +} + +.title { + width: 100%; + padding: 0; + border: none; + background: transparent; color: var(--center-channel-color); + font-size: 40px; + font-weight: 700; + line-height: 1.2; + + &:focus { + outline: none; + } + + &::placeholder { + color: rgba(var(--center-channel-color-rgb), 0.4); + } +} + +.column { + :global { + .WysiwygEditor .ProseMirror { + min-height: 60vh; + max-height: none; + padding: 0; + font-size: 16px; + line-height: 1.7; + overflow-y: visible; + } + } +} + +.surface { + position: relative; +} + +.pinnedToolbar { + position: sticky; + top: 0; + z-index: 2; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + margin-bottom: 8px; + background: var(--center-channel-bg); +} + +.column :global { + .docs-callout { + position: relative; + padding: 12px 16px 12px 44px; + border-radius: 4px; + margin: 16px 0; + background: rgba(var(--center-channel-color-rgb), 0.04); + + &::before { + position: absolute; + top: 12px; + left: 16px; + font-size: 16px; + line-height: 1.4; + } + + > :last-child { + margin-bottom: 0; + } + } + + .docs-callout[data-callout-type='info'] { + background: rgba(var(--button-bg-rgb), 0.08); + box-shadow: inset 3px 0 0 var(--button-bg); + + &::before { + content: 'ℹ'; + } + } + + .docs-callout[data-callout-type='note'] { + background: rgba(var(--center-channel-color-rgb), 0.08); + box-shadow: inset 3px 0 0 rgba(var(--center-channel-color-rgb), 0.32); + + &::before { + content: '✎'; + } + } + + .docs-callout[data-callout-type='success'] { + background: rgba(var(--online-indicator-rgb), 0.08); + box-shadow: inset 3px 0 0 var(--online-indicator); + + &::before { + content: '✓'; + } + } + + .docs-callout[data-callout-type='warning'] { + background: rgba(var(--away-indicator-rgb), 0.08); + box-shadow: inset 3px 0 0 var(--away-indicator); + + &::before { + content: '⚠'; + } + } + + .docs-callout[data-callout-type='error'] { + background: rgba(var(--dnd-indicator-rgb), 0.08); + box-shadow: inset 3px 0 0 var(--dnd-indicator); + + &::before { + content: '⊗'; + } + } +} + +.presence { + color: rgba(var(--center-channel-color-rgb), 0.64); font-size: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; + white-space: nowrap; } -.stub { - display: flex; - flex: 1; - align-items: center; - justify-content: center; - padding: 32px; - border: 1px dashed rgba(var(--center-channel-color-rgb), 0.24); - border-radius: 8px; +.notice { + padding: 8px 12px; + margin-top: 16px; + border-radius: 4px; + background: rgba(var(--away-indicator-rgb), 0.08); color: var(--center-channel-color); - font-size: 14px; - text-align: center; + font-size: 12px; +} + +.error { + background: rgba(var(--center-channel-color-rgb), 0.04); + color: var(--error-text); } .empty { diff --git a/webapp/src/components/page_editor/page_editor.tsx b/webapp/src/components/page_editor/page_editor.tsx index eeb24e7..55fa3d7 100644 --- a/webapp/src/components/page_editor/page_editor.tsx +++ b/webapp/src/components/page_editor/page_editor.tsx @@ -1,11 +1,32 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import {hostCanUseEditor, hostGetEditor} from 'webapp_globals'; +import {deletePageDraft, publishPageDraft, PublishConflictError} from 'client/drafts'; +import {useCaretAnchoredSuggestions} from 'hooks/caret_anchored_suggestions'; +import {useDraftAutosave} from 'hooks/draft_autosave'; +import {useDocsNavigation} from 'hooks/navigation'; +import {usePageDraft} from 'hooks/page_draft'; +import {usePagePresence} from 'hooks/page_presence'; +import {usePinnedToolbar} from 'hooks/pinned_toolbar'; +import {useCurrentUserId} from 'hooks/user'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {hostCanUseEditor, hostGetEditor, hostSupportsDocumentEditor} from 'webapp_globals'; +import type {PublishedFormattingBarHandle, PublishedMarkdownMode, PublishedWysiwygEditorHandle} from 'webapp_globals'; +import {PrimaryButton, SecondaryButton} from 'components/form-controls/button'; + +import type {Page} from 'types/docs'; + +import {applyWysiwygFormatting} from './apply_formatting'; +import AutosaveIndicator from './autosave_indicator'; +import {DOCS_EXTENSIONS} from './docs_extensions'; +import ExitEditorDialog from './exit_editor_dialog'; +import FloatingFormattingBar from './floating_formatting_bar'; +import PageByline from './page_byline'; import styles from './page_editor.module.scss'; +import PublishConflictDialog from './publish_conflict_dialog'; +import {CalloutControl, PinToolbarControl} from './toolbar_controls'; type Props = { spaceId: string; @@ -13,9 +34,192 @@ type Props = { isDraft: boolean; }; -// Placeholder mount for the WYSIWYG editor. This ticket only wires the -// component to the page route and proves the host slice resolves +type Conflict = { + reason: string; + currentPage: Page | null; +}; + const PageEditor = ({spaceId, pageId, isDraft}: Props) => { + const {formatMessage} = useIntl(); + const currentUserId = useCurrentUserId(); + const {goToPage} = useDocsNavigation(); + const activeEditors = usePagePresence(spaceId, pageId, currentUserId); + const load = usePageDraft(spaceId, pageId); + + const editorRef = useRef(null); + const formattingBarRef = useRef(null); + const editorSurfaceRef = useRef(null); + + const getHostEditor = useCallback(() => editorRef.current?.getEditor?.() ?? null, []); + + const [pinned, togglePinned] = usePinnedToolbar(); + useCaretAnchoredSuggestions(editorSurfaceRef, !load.loading && !load.error); + const [documentMode, setDocumentMode] = useState(null); + const [title, setTitle] = useState(''); + const [showExitDialog, setShowExitDialog] = useState(false); + const [conflict, setConflict] = useState(null); + const [actionError, setActionError] = useState(null); + const [contentError, setContentError] = useState(false); + const [busy, setBusy] = useState(false); + const [baseEditAt, setBaseEditAt] = useState(undefined); + const [draftExists, setDraftExists] = useState(false); + + const onDraftSaved = useCallback(() => { + setDraftExists(true); + }, []); + + const autosave = useDraftAutosave({ + spaceId, + pageId, + enabled: !load.loading && !load.error && !contentError, + baseEditAt, + onSaved: onDraftSaved, + onError: setActionError, + }); + + useEffect(() => { + setTitle(load.title); + }, [load.title]); + + useEffect(() => { + setBaseEditAt(load.baseEditAt); + }, [load.baseEditAt]); + + useEffect(() => { + setContentError(false); + setDraftExists(false); + setActionError(null); + }, [spaceId, pageId]); + + useEffect(() => { + if (load.loading) { + return; + } + setDocumentMode(hostSupportsDocumentEditor(editorRef.current)); + }, [load.loading]); + + const onTitleChange = useCallback((event: React.ChangeEvent) => { + setTitle(event.target.value); + if (contentError) { + return; + } + autosave.queue({title: event.target.value}); + }, [autosave, contentError]); + + const onContentChange = useCallback((content: string) => { + if (contentError || editorRef.current?.hasContentError?.()) { + setContentError(true); + return; + } + autosave.queue({body: content}); + }, [autosave, contentError]); + + const onContentError = useCallback(() => { + setContentError(true); + }, []); + + const applyFormatting = useCallback((mode: PublishedMarkdownMode) => { + const editor = editorRef.current?.getEditor?.() as Parameters[0] | undefined; + if (!editor || editor.isDestroyed) { + return; + } + if (mode === 'link') { + formattingBarRef.current?.openLinkPopover(); + return; + } + applyWysiwygFormatting(editor, mode); + }, []); + + const toolbarControls = useMemo(() => [ + , + , + ], [getHostEditor, pinned, togglePinned]); + + const leave = useCallback(() => goToPage(spaceId, pageId), [goToPage, spaceId, pageId]); + + const publish = useCallback(async (force: boolean, exitAfter = false) => { + if (busy) { + return; + } + setBusy(true); + setActionError(null); + try { + if (!await autosave.flush()) { + return; + } + + const published = await publishPageDraft(spaceId, pageId, force); + setBaseEditAt(published.edit_at); + setConflict(null); + setShowExitDialog(false); + if (exitAfter) { + leave(); + } + } catch (error) { + if (error instanceof PublishConflictError) { + setConflict({reason: error.reason, currentPage: error.currentPage}); + return; + } + setActionError(error); + } finally { + setBusy(false); + } + }, [autosave, spaceId, pageId, leave, busy]); + + const discard = useCallback(async () => { + if (busy) { + return; + } + setBusy(true); + setActionError(null); + autosave.cancel(); + try { + await deletePageDraft(spaceId, pageId); + setShowExitDialog(false); + leave(); + } catch (error) { + setActionError(error); + } finally { + setBusy(false); + } + }, [autosave, spaceId, pageId, leave, busy]); + + const saveDraftAndLeave = useCallback(async () => { + if (busy) { + return; + } + setBusy(true); + setActionError(null); + try { + if (!await autosave.flush()) { + return; + } + setShowExitDialog(false); + leave(); + } finally { + setBusy(false); + } + }, [autosave, leave, busy]); + + const onPublish = useCallback(() => { + publish(false); + }, [publish]); + + const onClose = useCallback(() => { + if (autosave.status === 'saved' && !load.fromDraft && !draftExists) { + leave(); + return; + } + setShowExitDialog(true); + }, [autosave.status, load.fromDraft, draftExists, leave]); + if (!hostCanUseEditor()) { return (
@@ -27,35 +231,186 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { ); } - const editor = hostGetEditor(); - const providerCount = editor?.providers ? Object.keys(editor.providers).length : 0; + if (load.loading) { + return ( +
+ +
+ ); + } + + if (load.error) { + return ( +
+ +
+ ); + } + + const {WysiwygEditor, FormattingBar} = hostGetEditor() ?? {}; + if (!WysiwygEditor) { + return null; + } return (
- - {isDraft ? ( + {activeEditors.length > 0 && ( + + + )} + + + {load.page ? ( + ) : ( )} - + + + +
-
- + +
+
+ {documentMode === false && ( +
+ +
+ )} + + {contentError && ( +
+ +
+ )} + + {actionError != null && ( +
+ +
+ )} + +
+ + {load.page?.user_id ? : null} +
+ +
+ {pinned && FormattingBar ? ( +
+ +
+ ) : ( + + )} + + +
+
+ + {showExitDialog && ( + { + publish(false, true); + }} + onSaveDraft={saveDraftAndLeave} + busy={busy} + failed={actionError != null} + onDiscard={() => { + discard(); + }} + onClose={() => setShowExitDialog(false)} + /> + )} + + {conflict && ( + { + publish(true); + }} + onClose={() => setConflict(null)} + /> + )}
); }; diff --git a/webapp/src/components/page_editor/publish_conflict_dialog.module.scss b/webapp/src/components/page_editor/publish_conflict_dialog.module.scss new file mode 100644 index 0000000..0f992ae --- /dev/null +++ b/webapp/src/components/page_editor/publish_conflict_dialog.module.scss @@ -0,0 +1,12 @@ +.actions { + display: flex; + justify-content: flex-end; + width: 100%; + gap: 8px; +} + +.meta { + margin-top: 8px; + color: rgba(var(--center-channel-color-rgb), 0.64); + font-size: 12px; +} diff --git a/webapp/src/components/page_editor/publish_conflict_dialog.tsx b/webapp/src/components/page_editor/publish_conflict_dialog.tsx new file mode 100644 index 0000000..61f28b8 --- /dev/null +++ b/webapp/src/components/page_editor/publish_conflict_dialog.tsx @@ -0,0 +1,82 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import {PrimaryButton, SecondaryButton} from 'components/form-controls/button'; +import GenericModal from 'components/generic_modal/generic_modal'; + +import type {Page} from 'types/docs'; + +import styles from './publish_conflict_dialog.module.scss'; + +type Props = { + currentPage: Page | null; + + reason: string; + + onForcePublish: () => void; + onClose: () => void; +}; + +const isConcurrentAutosave = (reason: string): boolean => reason.includes('concurrent_autosave'); + +const PublishConflictDialog = ({currentPage, reason, onForcePublish, onClose}: Props) => { + const {formatMessage} = useIntl(); + const autosaveConflict = isConcurrentAutosave(reason); + + return ( + + } + footer={ +
+ + + + + + +
+ } + > +

+ {autosaveConflict ? ( + + ) : ( + + )} +

+ {currentPage ? ( +

+ +

+ ) : null} +
+ ); +}; + +export default PublishConflictDialog; diff --git a/webapp/src/components/page_editor/toolbar_controls.module.scss b/webapp/src/components/page_editor/toolbar_controls.module.scss new file mode 100644 index 0000000..4f85814 --- /dev/null +++ b/webapp/src/components/page_editor/toolbar_controls.module.scss @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.control { + display: flex; + min-width: 32px; + height: 32px; + padding: 0 7px; + border: none; + border-radius: 4px; + background: transparent; + color: rgba(var(--center-channel-color-rgb), var(--icon-opacity)); + place-content: center; + place-items: center; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + color: rgba(var(--center-channel-color-rgb), var(--icon-opacity-hover)); + fill: currentcolor; + } + + &:active, + &.active, + &.active:hover { + background: rgba(var(--button-bg-rgb), 0.08); + color: var(--button-bg); + fill: currentcolor; + } +} + +.menuWrapper { + position: relative; + display: flex; +} + +.menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 20; + min-width: 180px; + padding: 8px 0; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); + border-radius: 4px; + background: var(--center-channel-bg); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); +} + +.menuItem { + display: flex; + width: 100%; + align-items: center; + padding: 8px 20px; + border: none; + background: transparent; + color: var(--center-channel-color); + font-size: 14px; + gap: 12px; + text-align: left; + + &:hover { + background: rgba(var(--center-channel-color-rgb), 0.08); + } +} + +.swatch { + width: 12px; + height: 12px; + border-radius: 2px; + flex-shrink: 0; +} diff --git a/webapp/src/components/page_editor/toolbar_controls.tsx b/webapp/src/components/page_editor/toolbar_controls.tsx new file mode 100644 index 0000000..3e4676d --- /dev/null +++ b/webapp/src/components/page_editor/toolbar_controls.tsx @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Editor} from '@tiptap/core'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import { + AlertCircleOutlineIcon, + AlertOutlineIcon, + CheckCircleOutlineIcon, + CloseCircleOutlineIcon, + InformationOutlineIcon, + PinOutlineIcon, +} from '@mattermost/compass-icons/components'; + +import type {CalloutType} from './callout_extension'; +import {CALLOUT_TYPES} from './callout_extension'; +import styles from './toolbar_controls.module.scss'; + +const CALLOUT_ICONS: Record = { + info: InformationOutlineIcon, + note: AlertCircleOutlineIcon, + success: CheckCircleOutlineIcon, + warning: AlertOutlineIcon, + error: CloseCircleOutlineIcon, +}; + +type PinProps = { + pinned: boolean; + onToggle: () => void; +}; + +export const PinToolbarControl = ({pinned, onToggle}: PinProps) => { + const {formatMessage} = useIntl(); + const label = pinned ? formatMessage({id: 'docs.editor.unpinToolbar', defaultMessage: 'Unpin toolbar'}) : formatMessage({id: 'docs.editor.pinToolbar', defaultMessage: 'Pin toolbar to top'}); + + return ( + + ); +}; + +type CalloutProps = { + getEditor: () => unknown; +}; + +export const CalloutControl = ({getEditor}: CalloutProps) => { + const {formatMessage} = useIntl(); + const [open, setOpen] = useState(false); + const wrapperRef = useRef(null); + const triggerRef = useRef(null); + + useEffect(() => { + if (!open) { + return undefined; + } + const onDocumentClick = (e: MouseEvent) => { + if (!wrapperRef.current?.contains(e.target as globalThis.Node)) { + setOpen(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + } + }; + document.addEventListener('click', onDocumentClick); + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('click', onDocumentClick); + document.removeEventListener('keydown', onKeyDown, true); + }; + }, [open]); + + const insert = useCallback((type: CalloutType) => { + const editor = getEditor() as Editor | null; + editor?.chain().focus().toggleCallout(type).run(); + setOpen(false); + }, [getEditor]); + + const label = formatMessage({id: 'docs.editor.callout', defaultMessage: 'Call out alert'}); + + return ( +
+ + + {open && ( +
+ {CALLOUT_TYPES.map((type) => { + const Icon = CALLOUT_ICONS[type]; + return ( + + ); + })} +
+ )} +
+ ); +}; + +type Formatter = ReturnType['formatMessage']; + +const CALLOUT_LABELS: Record string> = { + info: (f) => f({id: 'docs.editor.calloutInfo', defaultMessage: 'Info'}), + note: (f) => f({id: 'docs.editor.calloutNote', defaultMessage: 'Note'}), + success: (f) => f({id: 'docs.editor.calloutSuccess', defaultMessage: 'Success'}), + warning: (f) => f({id: 'docs.editor.calloutWarning', defaultMessage: 'Warning'}), + error: (f) => f({id: 'docs.editor.calloutError', defaultMessage: 'Error'}), +}; diff --git a/webapp/src/hooks/caret_anchored_suggestions.ts b/webapp/src/hooks/caret_anchored_suggestions.ts new file mode 100644 index 0000000..e5c9954 --- /dev/null +++ b/webapp/src/hooks/caret_anchored_suggestions.ts @@ -0,0 +1,96 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useEffect} from 'react'; + +const GAP = 4; +const ESTIMATED_HEIGHT = 240; +const SELECTOR = '.suggestion-list'; + +export const useCaretAnchoredSuggestions = (surfaceRef: React.RefObject, enabled: boolean) => { + useEffect(() => { + const surface = enabled ? surfaceRef.current : null; + if (!surface) { + return undefined; + } + + let list: HTMLElement | null = null; + let frame = 0; + + const position = () => { + const selection = window.getSelection(); + if (!list || !selection || selection.rangeCount === 0) { + return; + } + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + const caret = rect.height === 0 ? (range.startContainer.parentElement?.getBoundingClientRect() ?? rect) : rect; + + const surfaceRect = surface.getBoundingClientRect(); + const height = list.offsetHeight || ESTIMATED_HEIGHT; + const flipAbove = window.innerHeight - caret.bottom < height && caret.top > height; + const above = caret.top - surfaceRect.top - (height + GAP); + const below = (caret.bottom - surfaceRect.top) + GAP; + + list.style.bottom = 'auto'; + list.style.top = `${flipAbove ? above : below}px`; + list.style.left = `${caret.left - surfaceRect.left}px`; + }; + + const schedule = () => { + if (frame) { + return; + } + frame = requestAnimationFrame(() => { + frame = 0; + position(); + }); + }; + + const onMutation = (records: MutationRecord[]) => { + let touched = false; + for (const record of records) { + for (const node of [...record.addedNodes, ...record.removedNodes]) { + if (node.nodeType === globalThis.Node.ELEMENT_NODE) { + touched = true; + break; + } + } + if (touched) { + break; + } + } + + if (!touched) { + return; + } + + const found = surface.querySelector(SELECTOR); + if (found !== list) { + list = found; + if (list) { + document.addEventListener('selectionchange', schedule); + } else { + document.removeEventListener('selectionchange', schedule); + } + } + + if (!list) { + return; + } + schedule(); + }; + + const observer = new MutationObserver(onMutation); + observer.observe(surface, {childList: true, subtree: true}); + + return () => { + observer.disconnect(); + document.removeEventListener('selectionchange', schedule); + if (frame) { + cancelAnimationFrame(frame); + } + }; + }, [surfaceRef, enabled]); +}; diff --git a/webapp/src/hooks/draft_autosave.test.tsx b/webapp/src/hooks/draft_autosave.test.tsx new file mode 100644 index 0000000..66f7f27 --- /dev/null +++ b/webapp/src/hooks/draft_autosave.test.tsx @@ -0,0 +1,281 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {act, renderHook} from '@testing-library/react'; +import {updatePageDraft} from 'client/drafts'; + +import type {Draft, DraftPatch} from 'types/drafts'; + +import {AUTOSAVE_DEBOUNCE_MS, useDraftAutosave} from './draft_autosave'; + +jest.mock('client/drafts', () => ({ + updatePageDraft: jest.fn(), +})); + +const mockUpdate = updatePageDraft as jest.MockedFunction; + +const savedDraft = {page_id: 'page1'} as Draft; + +const patchesSent = (): DraftPatch[] => mockUpdate.mock.calls.map((call) => call[2]); + +const setup = (overrides: Partial[0]> = {}) => + renderHook((props: Parameters[0]) => useDraftAutosave(props), { + initialProps: { + spaceId: 'space1', + pageId: 'page1', + enabled: true, + ...overrides, + }, + }); + +const runDebounce = async () => { + await act(async () => { + jest.advanceTimersByTime(AUTOSAVE_DEBOUNCE_MS); + }); +}; + +beforeEach(() => { + jest.useFakeTimers(); + mockUpdate.mockReset(); + mockUpdate.mockResolvedValue(savedDraft); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('useDraftAutosave', () => { + it('debounces bursts into a single write', async () => { + const {result} = setup(); + + act(() => { + result.current.queue({body: 'a'}); + result.current.queue({body: 'ab'}); + result.current.queue({body: 'abc'}); + }); + expect(mockUpdate).not.toHaveBeenCalled(); + + await runDebounce(); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(patchesSent()[0]).toEqual({body: 'abc'}); + }); + + it('coalesces different fields rather than replacing the pending patch', async () => { + const {result} = setup(); + + act(() => { + result.current.queue({title: 'Title'}); + result.current.queue({body: 'Body'}); + }); + await runDebounce(); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(patchesSent()[0]).toEqual({title: 'Title', body: 'Body'}); + }); + + it('repeats base_edit_at on every write for an existing page', async () => { + const {result} = setup({baseEditAt: 1234}); + + act(() => { + result.current.queue({body: 'first'}); + }); + await runDebounce(); + + act(() => { + result.current.queue({body: 'second'}); + }); + await runDebounce(); + + expect(patchesSent()).toEqual([ + {body: 'first', base_edit_at: 1234}, + {body: 'second', base_edit_at: 1234}, + ]); + }); + + it('omits base_edit_at for a new-page draft, which has no baseline', async () => { + const {result} = setup(); + + act(() => { + result.current.queue({body: 'new'}); + }); + await runDebounce(); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(patchesSent()[0]).not.toHaveProperty('base_edit_at'); + }); + + it('does not write while disabled, so a new page cannot autosave before its id exists', async () => { + const {result} = setup({enabled: false}); + + act(() => { + result.current.queue({body: 'a'}); + }); + await runDebounce(); + + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('cancel drops a pending save so discard is not undone by the debounce', async () => { + const {result} = setup(); + + act(() => { + result.current.queue({body: 'doomed'}); + result.current.cancel(); + }); + await runDebounce(); + + expect(mockUpdate).not.toHaveBeenCalled(); + expect(result.current.status).toBe('saved'); + }); + + it('ignores an in-flight save that resolves after cancel', async () => { + let resolveSave: (draft: Draft) => void = () => {}; + mockUpdate.mockReturnValueOnce(new Promise((resolve) => { + resolveSave = resolve; + })); + + const onSaved = jest.fn(); + const {result} = setup({onSaved}); + + act(() => { + result.current.queue({body: 'inflight'}); + }); + await runDebounce(); + expect(mockUpdate).toHaveBeenCalledTimes(1); + + act(() => { + result.current.cancel(); + }); + await act(async () => { + resolveSave(savedDraft); + }); + + expect(onSaved).not.toHaveBeenCalled(); + expect(result.current.status).toBe('saved'); + }); + + it('flush writes immediately so publish does not race the debounce', async () => { + const {result} = setup(); + + act(() => { + result.current.queue({body: 'pending'}); + }); + await act(async () => { + await result.current.flush(); + }); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(patchesSent()[0]).toEqual({body: 'pending'}); + }); + + it('keeps the patch for retry when a save fails', async () => { + mockUpdate.mockRejectedValueOnce(new Error('offline')); + const onError = jest.fn(); + const {result} = setup({onError}); + + act(() => { + result.current.queue({body: 'lost'}); + }); + await runDebounce(); + + expect(onError).toHaveBeenCalledTimes(1); + expect(result.current.status).toBe('unsaved'); + + await act(async () => { + await result.current.flush(); + }); + expect(patchesSent()[1]).toEqual({body: 'lost'}); + }); + + it('stays dirty when edits arrive while a save is in flight', async () => { + let resolveSave: (draft: Draft) => void = () => {}; + mockUpdate.mockReturnValueOnce(new Promise((resolve) => { + resolveSave = resolve; + })); + + const {result} = setup(); + + act(() => { + result.current.queue({body: 'first'}); + }); + await runDebounce(); + + act(() => { + result.current.queue({body: 'second'}); + }); + await act(async () => { + resolveSave(savedDraft); + }); + + expect(result.current.status).toBe('unsaved'); + }); + + it('flush reports failure so publish does not proceed on unsaved content', async () => { + mockUpdate.mockRejectedValueOnce(new Error('offline')); + const {result} = setup(); + + act(() => { + result.current.queue({body: 'lost'}); + }); + + let flushed: boolean | undefined; + await act(async () => { + flushed = await result.current.flush(); + }); + + expect(flushed).toBe(false); + expect(result.current.status).toBe('unsaved'); + }); + + it('flush waits for a write already in flight instead of resolving early', async () => { + let resolveSave: (draft: Draft) => void = () => {}; + mockUpdate.mockReturnValueOnce(new Promise((resolve) => { + resolveSave = resolve; + })); + + const {result} = setup(); + + act(() => { + result.current.queue({body: 'inflight'}); + }); + await runDebounce(); + expect(mockUpdate).toHaveBeenCalledTimes(1); + + let settled = false; + let flushed: Promise = Promise.resolve(false); + act(() => { + flushed = result.current.flush().then((ok) => { + settled = true; + return ok; + }); + }); + + await act(async () => { + await Promise.resolve(); + }); + expect(settled).toBe(false); + + await act(async () => { + resolveSave(savedDraft); + await flushed; + }); + expect(settled).toBe(true); + }); + + it('flushes the pending patch to the page being left when the id changes', async () => { + const {result, rerender} = setup(); + + act(() => { + result.current.queue({body: 'typed on page1'}); + }); + + await act(async () => { + rerender({spaceId: 'space1', pageId: 'page2', enabled: true}); + }); + + expect(mockUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdate.mock.calls[0][1]).toBe('page1'); + expect(patchesSent()[0]).toEqual({body: 'typed on page1'}); + }); +}); diff --git a/webapp/src/hooks/draft_autosave.ts b/webapp/src/hooks/draft_autosave.ts new file mode 100644 index 0000000..74d0926 --- /dev/null +++ b/webapp/src/hooks/draft_autosave.ts @@ -0,0 +1,146 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {updatePageDraft} from 'client/drafts'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; + +import type {Draft, DraftPatch} from 'types/drafts'; + +import {useLatest} from './utils'; + +export const AUTOSAVE_DEBOUNCE_MS = 1000; + +export type AutosaveStatus = 'saved' | 'saving' | 'unsaved'; + +type Options = { + spaceId: string; + pageId: string; + + enabled: boolean; + + baseEditAt?: number; + + onSaved?: (draft: Draft) => void; + onError?: (error: unknown) => void; +}; + +export type DraftAutosave = { + status: AutosaveStatus; + + queue: (patch: DraftPatch) => void; + + flush: () => Promise; + + cancel: () => void; +}; + +type Pending = { + spaceId: string; + pageId: string; + patch: DraftPatch; +}; + +export function useDraftAutosave({spaceId, pageId, enabled, baseEditAt, onSaved, onError}: Options): DraftAutosave { + const [status, setStatus] = useState('saved'); + + const pendingRef = useRef(null); + const timerRef = useRef | null>(null); + const abortRef = useRef(null); + const chainRef = useRef>(Promise.resolve(true)); + + const generationRef = useRef(0); + + const latest = useLatest({spaceId, pageId, enabled, baseEditAt, onSaved, onError}); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const doWrite = useCallback(async (): Promise => { + const entry = pendingRef.current; + const {enabled: on, baseEditAt: baseline, onSaved: saved, onError: failed} = latest.current; + if (!entry || !on) { + return true; + } + + pendingRef.current = null; + const generation = generationRef.current; + const controller = new AbortController(); + abortRef.current = controller; + setStatus('saving'); + + try { + const body: DraftPatch = baseline ? {...entry.patch, base_edit_at: baseline} : entry.patch; + const draft = await updatePageDraft(entry.spaceId, entry.pageId, body, controller.signal); + if (generation !== generationRef.current) { + return false; + } + saved?.(draft); + + setStatus(pendingRef.current ? 'unsaved' : 'saved'); + return true; + } catch (error) { + if (generation !== generationRef.current || controller.signal.aborted) { + return false; + } + + const queuedSince = pendingRef.current as Pending | null; + pendingRef.current = { + spaceId: entry.spaceId, + pageId: entry.pageId, + patch: {...entry.patch, ...(queuedSince?.patch ?? {})}, + }; + setStatus('unsaved'); + failed?.(error); + return false; + } finally { + if (abortRef.current === controller) { + abortRef.current = null; + } + } + }, [latest]); + + const write = useCallback((): Promise => { + const next = chainRef.current.then(doWrite, doWrite); + chainRef.current = next; + return next; + }, [doWrite]); + + const queue = useCallback((patch: DraftPatch) => { + const {spaceId: space, pageId: page} = latest.current; + const prior = pendingRef.current?.spaceId === space && pendingRef.current?.pageId === page ? pendingRef.current.patch : {}; + + pendingRef.current = {spaceId: space, pageId: page, patch: {...prior, ...patch}}; + setStatus('unsaved'); + clearTimer(); + timerRef.current = setTimeout(() => { + timerRef.current = null; + + write(); + }, AUTOSAVE_DEBOUNCE_MS); + }, [clearTimer, write, latest]); + + const flush = useCallback((): Promise => { + clearTimer(); + return write(); + }, [clearTimer, write]); + + const cancel = useCallback(() => { + generationRef.current += 1; + clearTimer(); + pendingRef.current = null; + abortRef.current?.abort(); + abortRef.current = null; + setStatus('saved'); + }, [clearTimer]); + + useEffect(() => () => { + clearTimer(); + write(); + }, [clearTimer, write, spaceId, pageId]); + + return useMemo(() => ({status, queue, flush, cancel}), [status, queue, flush, cancel]); +} diff --git a/webapp/src/hooks/page_draft.ts b/webapp/src/hooks/page_draft.ts new file mode 100644 index 0000000..f4f76f3 --- /dev/null +++ b/webapp/src/hooks/page_draft.ts @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getPageDraft} from 'client/drafts'; +import {getPage} from 'client/pages'; +import {RestError} from 'client/rest'; +import {useEffect, useState} from 'react'; + +import type {Page} from 'types/docs'; + +export type PageDraftLoad = { + loading: boolean; + error: unknown; + + title: string; + body: string; + + page: Page | null; + + fromDraft: boolean; + + baseEditAt?: number; +}; + +const initial: PageDraftLoad = { + loading: true, + error: null, + title: '', + body: '', + page: null, + fromDraft: false, +}; + +const isNotFound = (error: unknown): boolean => error instanceof RestError && error.status === 404; + +export function usePageDraft(spaceId: string, pageId: string): PageDraftLoad { + const [state, setState] = useState(initial); + + useEffect(() => { + const controller = new AbortController(); + setState(initial); + + const load = async () => { + const [draftResult, pageResult] = await Promise.allSettled([ + getPageDraft(spaceId, pageId, controller.signal), + getPage(spaceId, pageId, controller.signal), + ]); + + if (controller.signal.aborted) { + return; + } + + const draft = draftResult.status === 'fulfilled' ? draftResult.value : null; + const page = pageResult.status === 'fulfilled' ? pageResult.value : null; + + const fatal = [draftResult, pageResult]. + filter((result): result is PromiseRejectedResult => result.status === 'rejected'). + map((result) => result.reason). + find((reason) => !isNotFound(reason)); + + if (fatal) { + setState({...initial, loading: false, error: fatal}); + return; + } + + setState({ + loading: false, + error: null, + + title: draft?.title || page?.title || '', + body: draft?.body || page?.body || '', + page, + fromDraft: Boolean(draft), + baseEditAt: page?.edit_at, + }); + }; + + load(); + + return () => controller.abort(); + }, [spaceId, pageId]); + + return state; +} diff --git a/webapp/src/hooks/page_presence.ts b/webapp/src/hooks/page_presence.ts new file mode 100644 index 0000000..acafb2d --- /dev/null +++ b/webapp/src/hooks/page_presence.ts @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getPageActiveEditors} from 'client/drafts'; +import {subscribeToPagePresence} from 'client/presence_events'; +import {useEffect, useMemo, useState} from 'react'; + +import type {PageActiveEditors} from 'types/drafts'; + +export function usePagePresence(spaceId: string, pageId: string, currentUserId: string): string[] { + const [snapshot, setSnapshot] = useState(null); + + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const controller = new AbortController(); + setSnapshot(null); + + getPageActiveEditors(spaceId, pageId, controller.signal). + then((next) => { + if (controller.signal.aborted) { + return; + } + setSnapshot((current) => (current && current.snapshot_at >= next.snapshot_at ? current : next)); + }). + catch(() => { + }); + + return () => controller.abort(); + }, [spaceId, pageId]); + + useEffect(() => subscribeToPagePresence((event) => { + if (event.page_id !== pageId) { + return; + } + + setSnapshot((current) => { + if (current && event.snapshot_at < current.snapshot_at) { + return current; + } + return { + active_editors: event.active_editors, + snapshot_at: event.snapshot_at, + active_timeout_ms: event.active_timeout_ms, + }; + }); + }), [pageId]); + + useEffect(() => { + if (!snapshot || snapshot.active_timeout_ms <= 0 || snapshot.active_editors.length === 0) { + return undefined; + } + + const expiresIn = (snapshot.snapshot_at + snapshot.active_timeout_ms) - Date.now(); + if (expiresIn <= 0) { + return undefined; + } + const timer = setTimeout(() => setNow(Date.now()), expiresIn); + return () => clearTimeout(timer); + }, [snapshot]); + + return useMemo(() => { + if (!snapshot) { + return []; + } + if (snapshot.active_timeout_ms > 0 && now - snapshot.snapshot_at > snapshot.active_timeout_ms) { + return []; + } + + return snapshot.active_editors.filter((id) => id !== currentUserId); + }, [snapshot, now, currentUserId]); +} diff --git a/webapp/src/hooks/pinned_toolbar.ts b/webapp/src/hooks/pinned_toolbar.ts new file mode 100644 index 0000000..dde484f --- /dev/null +++ b/webapp/src/hooks/pinned_toolbar.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {useCallback, useEffect, useRef, useState} from 'react'; + +const STORAGE_KEY = 'docs_toolbar_pinned'; + +const readStored = (): boolean => { + try { + return window.localStorage.getItem(STORAGE_KEY) !== 'false'; + } catch { + return true; + } +}; + +export const usePinnedToolbar = (): [boolean, () => void] => { + const [pinned, setPinned] = useState(readStored); + + const toggle = useCallback(() => setPinned((prev) => !prev), []); + + const firstRender = useRef(true); + useEffect(() => { + if (firstRender.current) { + firstRender.current = false; + return; + } + try { + window.localStorage.setItem(STORAGE_KEY, String(pinned)); + } catch { + // Storage unavailable; the preference stays session-only. + } + }, [pinned]); + + return [pinned, toggle]; +}; diff --git a/webapp/src/hooks/user.ts b/webapp/src/hooks/user.ts index d19b0c2..b4272d8 100644 --- a/webapp/src/hooks/user.ts +++ b/webapp/src/hooks/user.ts @@ -25,3 +25,7 @@ export function useCurrentUser(): {name: string} { return {name}; } + +export function useCurrentUserId(): string { + return useSelector((state: GlobalState) => getCurrentUser(state)?.id ?? ''); +} diff --git a/webapp/src/index.tsx b/webapp/src/index.tsx index e6e5884..fdfd594 100644 --- a/webapp/src/index.tsx +++ b/webapp/src/index.tsx @@ -1,6 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {publishPagePresence} from 'client/presence_events'; +import type {PagePresenceEvent} from 'client/presence_events'; import manifest from 'manifest'; import type {Reducer} from 'redux'; import {DOCS_BASE_URL, DOCS_SWITCHER_LINK_URL} from 'routing/paths'; @@ -20,6 +22,8 @@ const SWITCHER_ICON = 'file-text-outline'; const DocsHeaderCentre = () => null; +const PAGE_PRESENCE_EVENT = `custom_${manifest.id}_page_presence_updated`; + export default class Plugin { public async initialize(registry: PluginRegistry) { registry.registerTranslations({ @@ -39,6 +43,10 @@ export default class Plugin { // out). registry.registerReducer(reducer as Reducer); + registry.registerWebSocketEventHandler(PAGE_PRESENCE_EVENT, (msg) => { + publishPagePresence(msg.data); + }); + registry.registerProduct({ baseURL: DOCS_BASE_URL, switcherIcon: SWITCHER_ICON, diff --git a/webapp/src/types/drafts.ts b/webapp/src/types/drafts.ts new file mode 100644 index 0000000..f50c01a --- /dev/null +++ b/webapp/src/types/drafts.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Page} from './docs'; + +export type Draft = { + user_id: string; + space_id: string; + page_id: string; + parent_id: string; + title: string; + body: string; + file_ids: string[]; + props: Record; + create_at: number; + update_at: number; + + last_active_at: number; + + base_edit_at: number; +}; + +export type DraftSummary = Omit; + +export type DraftPatch = { + title?: string; + body?: string; + parent_id?: string; + file_ids?: string[]; + props?: Record; + + base_edit_at?: number; +}; + +export type PageActiveEditors = { + active_editors: string[]; + snapshot_at: number; + + active_timeout_ms: number; +}; + +export const ConflictReason = { + ConcurrentEdit: 'concurrent_edit', + ConcurrentAutosave: 'concurrent_autosave', +} as const; + +export type ConflictReasonType = typeof ConflictReason[keyof typeof ConflictReason]; + +export type PublishConflict = { + error: { + id: string; + message: string; + status_code: number; + }; + current_page: Page | null; +}; From 51fd3d70d2a9935af028a3ee33078946d9c4568c Mon Sep 17 00:00:00 2001 From: Nevyana Angelova Date: Fri, 31 Jul 2026 19:19:43 +0700 Subject: [PATCH 2/4] CoderabbitAI feedback --- webapp/i18n/en.json | 203 ++++-------------- .../page_editor/apply_formatting.ts | 16 +- .../page_editor/callout_extension.ts | 8 +- .../page_editor/floating_formatting_bar.tsx | 6 +- .../components/page_editor/page_byline.tsx | 12 +- .../components/page_editor/page_editor.tsx | 34 ++- .../page_editor/toolbar_controls.tsx | 28 +-- .../src/hooks/caret_anchored_suggestions.ts | 45 ++-- webapp/src/hooks/draft_autosave.test.tsx | 55 +++++ webapp/src/hooks/draft_autosave.ts | 23 +- webapp/src/hooks/page_draft.ts | 4 + webapp/src/hooks/page_presence.ts | 1 + webapp/src/hooks/pinned_toolbar.ts | 15 +- 13 files changed, 231 insertions(+), 219 deletions(-) diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index cce1e1e..737790e 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1,11 +1,4 @@ { - "docs.archiveSpace.confirm": "Yes, archive space", - "docs.archiveSpace.error.generic": "Something went wrong. Please try again.", - "docs.archiveSpace.error.title": "Unable to archive {name}", - "docs.archiveSpace.message": "Are you sure you want to archive the {name} space? Members will lose access until it is restored.", - "docs.archiveSpace.title": "Archive {name}", - "docs.basicInputModal.cancel": "Cancel", - "docs.basicInputModal.confirm": "Save", "docs.confirmModal.cancel": "Cancel", "docs.confirmModal.confirm": "Confirm", "docs.createSpace.cancel": "Cancel", @@ -15,6 +8,10 @@ "docs.createSpace.error.description.tooLong": "Description must be {max} characters or fewer", "docs.createSpace.error.name.required": "Please enter a name for the space", "docs.createSpace.error.name.tooLong": "Name must be {max} characters or fewer", + "docs.createSpace.error.url.invalid": "Use lowercase letters, numbers, and dashes, with no spaces", + "docs.createSpace.error.url.required": "Please enter a URL for the space", + "docs.createSpace.error.url.taken": "That URL is already taken", + "docs.createSpace.error.url.tooLong": "URL must be {max} characters or fewer", "docs.createSpace.nameLabel": "Space name", "docs.createSpace.permissionsNote": "Specific edit and sharing permissions can be defined once the space is created.", "docs.createSpace.private.description": "Only invited members", @@ -23,15 +20,41 @@ "docs.createSpace.public.description": "Any team member can view", "docs.createSpace.public.title": "Public Space", "docs.createSpace.title": "Create a new space", + "docs.createSpace.urlAriaLabel": "Space URL", "docs.createSpace.visibilityLabel": "Space visibility", - "docs.deletePage.confirm": "Delete", - "docs.deletePage.error": "Could not delete “{title}”.", - "docs.deletePage.message": "Are you sure you want to delete {title}? Its subpages will be deleted too.", - "docs.deletePage.title": "Delete page", - "docs.editor.header.draft": "Draft · {spaceId} / {pageId}", - "docs.editor.header.published": "Published · {spaceId} / {pageId}", + "docs.editor.actionFailed": "That action could not be completed. Your draft is unchanged.", + "docs.editor.autosave.saved": "Saved", + "docs.editor.autosave.saving": "Saving...", + "docs.editor.autosave.unsaved": "Unsaved changes", + "docs.editor.bodyPlaceholder": "Start writing…", + "docs.editor.byline": "By {name}", + "docs.editor.callout": "Call out alert", + "docs.editor.close": "Close", + "docs.editor.conflict.autosaveBody": "Your draft was saved from somewhere else after this publish started. Publishing anyway uses the version currently in this editor.", + "docs.editor.conflict.cancel": "Keep editing", + "docs.editor.conflict.currentTitle": "Current published title: {title}", + "docs.editor.conflict.editBody": "Someone else published changes to this page after you started editing. Publishing anyway replaces their version with yours.", + "docs.editor.conflict.force": "Publish anyway", + "docs.editor.conflict.title": "This page changed while you were editing", + "docs.editor.contentError": "This page's content could not be opened. Editing is disabled so the stored version is not overwritten.", + "docs.editor.exit.body": "Publishing makes your changes visible to everyone with access to this space. A draft stays visible only to you.", + "docs.editor.exit.discard": "Discard changes", + "docs.editor.exit.failed": "That did not work. Your changes are still here — try again, or keep editing.", + "docs.editor.exit.publish": "Save and publish", + "docs.editor.exit.saveDraft": "Save as draft", + "docs.editor.exit.title": "Save your changes?", "docs.editor.hostMissing": "This Mattermost build does not publish the Docs editor. Update the server to edit pages here.", - "docs.editor.stub.body": "Editor is available and will mount here. Suggestion providers exposed: {providerCount}.", + "docs.editor.legacyHost": "This server predates structured page content. Formatting beyond basic text may not be saved.", + "docs.editor.loadFailed": "This page could not be loaded. Refresh to try again.", + "docs.editor.loading": "Loading page…", + "docs.editor.notFound": "This page does not exist, or you do not have access to it.", + "docs.editor.pinToolbar": "Pin toolbar to top", + "docs.editor.presence": "{count, plural, one {# other editor} other {# other editors}}", + "docs.editor.publish": "Publish", + "docs.editor.titleLabel": "Page title", + "docs.editor.titlePlaceholder": "Untitled", + "docs.editor.unpinToolbar": "Unpin toolbar", + "docs.editor.update": "Update", "docs.form.url.edit": "Edit", "docs.form.url.label": "URL:", "docs.genericModal.close": "Close", @@ -61,84 +84,21 @@ "docs.home.welcome.body": "Long-form knowledge for your team — documentation, specs, handbooks, decisions, postmortems. Every page lives in a Space which can be linked with Channels for convenience.", "docs.home.welcome.title": "Welcome to Docs.", "docs.leaveSpace.confirm": "Yes, leave space", - "docs.leaveSpace.error.generic": "Something went wrong. Please try again.", - "docs.leaveSpace.error.lastMember": "A space must keep at least one member with access. Add another member before you leave.", - "docs.leaveSpace.error.title": "Unable to leave {name}", "docs.leaveSpace.message": "Are you sure you want to leave the {name} space? You can rejoin later if it is public.", "docs.leaveSpace.title": "Leave {name}", - "docs.page.addEmoji": "Add emoji", - "docs.page.byAuthor": "By {name}", - "docs.page.headerImage": "Header image", - "docs.page.untitled": "Untitled", - "docs.pageMenu.comingSoon": "Coming soon", - "docs.pageMenu.copyLink": "Copy link", - "docs.pageMenu.delete": "Delete page", - "docs.pageMenu.duplicate": "Duplicate page", - "docs.pageMenu.export": "Export", - "docs.pageMenu.export.markdown": "Markdown", - "docs.pageMenu.export.pdf": "PDF", - "docs.pageMenu.favorite": "Favorite", - "docs.pageMenu.label": "Page options for {title}", - "docs.pageMenu.moveTo": "Move to", - "docs.pageMenu.notifications": "Notifications", - "docs.pageMenu.notifications.all": "All activity", - "docs.pageMenu.notifications.mentions": "Mentions", - "docs.pageMenu.notifications.none": "Nothing", - "docs.pageMenu.openInNewWindow": "Open in new window", - "docs.pageMenu.rename": "Rename", - "docs.pageMenu.sharing": "Sharing and Permissions", - "docs.pageMenu.unfavorite": "Remove from favorites", - "docs.pageMenu.versionHistory": "Version history", - "docs.pageTree.add": "Add page", - "docs.pageTree.addFailed": "Could not create the page. Please try again.", - "docs.pageTree.collapse": "Collapse {title}", - "docs.pageTree.expand": "Expand {title}", - "docs.pageTree.favorited": "Favorited", - "docs.pageTree.keyboardHelp": "Use the arrow keys to move through pages. Hold Alt with an arrow key to move the selected page up, down, in, or out.", - "docs.pageTree.menu": "Page options for {title}", - "docs.pageTree.moveBlocked": "Cannot move {title} any further in that direction.", - "docs.pageTree.moveFailed": "Could not move the page. Please try again.", - "docs.pageTree.moved": "Moved {title} to position {position} under {parent}.", - "docs.pageTree.nestBlocked": "Cannot nest {title} deeper than {levels} levels.", - "docs.pageTree.overview": "Overview", - "docs.pageTree.topLevel": "top level", - "docs.pageTree.untitled": "Untitled", - "docs.renamePage.confirm": "Save", - "docs.renamePage.label": "Page name", - "docs.renamePage.title": "Rename page", - "docs.saveChanges.error": "There was an error saving your changes", - "docs.saveChanges.message": "You have unsaved changes", - "docs.saveChanges.reset": "Reset", - "docs.saveChanges.save": "Save", - "docs.share.access.canView": "Can View", - "docs.share.copyLink": "Copy link", - "docs.share.handle": "@{username}", - "docs.share.noResults": "No people found", - "docs.share.remove": "Remove {name}", - "docs.share.role.admin": "Admin", - "docs.share.search": "Add people or groups", - "docs.share.searching": "Searching…", - "docs.share.title": "Share space", - "docs.share.visibility.public": "Public", - "docs.share.visibility.publicHint": "Anyone in Mattermost", - "docs.share.you": "(You)", + "docs.main.spaceOverview": "Space overview", "docs.sidebar.add.browse": "Browse spaces", "docs.sidebar.add.create": "Create a space", "docs.sidebar.add.menu": "Add or browse spaces", - "docs.sidebar.category.favorites": "Favorites", "docs.sidebar.category.spaces": "Spaces", "docs.sidebar.createSpace": "Create a space", "docs.sidebar.favorites.empty": "Drag favorite items here or click the star icon on any space", "docs.sidebar.label": "Spaces", "docs.sidebar.nav.home": "Home", - "docs.sidebar.resize": "Resize pages sidebar", "docs.sidebar.search.placeholder": "Find docs", "docs.sidebar.space.copyLink": "Copy link", - "docs.sidebar.space.favorite": "Add to favorites", - "docs.sidebar.space.favoritePartial": "Some pages are favorited", "docs.sidebar.space.leave": "Leave space", "docs.sidebar.space.menu": "Space options for {name}", - "docs.sidebar.space.unfavorite": "Remove from favorites", "docs.sidebar.team.createTeam": "Create a team", "docs.sidebar.team.invite": "Invite people", "docs.sidebar.team.invite.secondary": "Add or invite people to the team", @@ -147,94 +107,11 @@ "docs.sidebar.team.members": "Manage members", "docs.sidebar.team.menu": "Manage {teamName}", "docs.sidebar.team.settings": "Team settings", - "docs.space.comments": "Comments", - "docs.space.descriptionPlaceholder": "Add a space description here — just a brief summary of the purpose for this space.", - "docs.space.details": "Space details", - "docs.space.edit": "Edit", - "docs.space.expand": "Expand", - "docs.space.favorite": "Favorite this space", - "docs.space.favoritePartial": "Favorite this space (some pages are favorited)", - "docs.space.memberHandle": "@{username}", - "docs.space.membersButton": "Members", - "docs.space.membersOverflow": "+{count}", - "docs.space.menu": "Space options", - "docs.space.menu.archive": "Archive space", - "docs.space.menu.copyLink": "Copy link", - "docs.space.menu.info": "Space info", - "docs.space.menu.leave": "Leave space", - "docs.space.menu.members": "Members", - "docs.space.menu.settings": "Space settings", - "docs.space.more": "More actions", - "docs.space.pages": "Pages", - "docs.space.share": "Share", - "docs.space.stat.members": "Members", - "docs.space.stat.pages": "Pages", - "docs.space.stat.views": "Views", - "docs.space.unfavorite": "Remove from favorites", - "docs.space.updated": "Updated {relative}", - "docs.spaceInfo.back": "Back to space info", - "docs.spaceInfo.close": "Close info", - "docs.spaceInfo.created": "Created", - "docs.spaceInfo.description": "Description", - "docs.spaceInfo.descriptionLabel": "Space description", - "docs.spaceInfo.descriptionPlaceholder": "Add a space description", - "docs.spaceInfo.editDescription": "Edit description", - "docs.spaceInfo.handle": "@{username}", - "docs.spaceInfo.members": "Members", - "docs.spaceInfo.menu.copyLink": "Copy link", - "docs.spaceInfo.menu.members": "Members", - "docs.spaceInfo.menu.settings": "Space settings", - "docs.spaceInfo.menu.title": "Space info actions", - "docs.spaceInfo.noDescription": "No description", - "docs.spaceInfo.pages": "Pages", - "docs.spaceInfo.resize": "Resize space info", - "docs.spaceInfo.title": "Space info", - "docs.spaceSettings.archive.button": "Archive space", - "docs.spaceSettings.archive.confirmBody": "“{title}” and its pages will be archived and members will lose access. This can be undone by an admin.", - "docs.spaceSettings.archive.confirmButton": "Archive", - "docs.spaceSettings.archive.confirmTitle": "Archive this space?", - "docs.spaceSettings.archive.copy": "Archiving removes this space and its pages from the team. Members will lose access. You can ask an admin to restore it later.", - "docs.spaceSettings.archive.heading": "Archive space", - "docs.spaceSettings.configuration.comingSoon": "Space configuration options are coming soon.", - "docs.spaceSettings.configuration.heading": "Configuration", - "docs.spaceSettings.discard.body": "Your unsaved changes to this space will be lost.", - "docs.spaceSettings.discard.confirm": "Discard changes", - "docs.spaceSettings.discard.title": "Discard unsaved changes?", - "docs.spaceSettings.info.descriptionHelper": "Describe how this space should be used. This will show when browsing spaces.", - "docs.spaceSettings.info.descriptionLabel": "Description", - "docs.spaceSettings.info.descriptionPlaceholder": "Enter a description for this space…", - "docs.spaceSettings.info.editUrl": "Edit", - "docs.spaceSettings.info.heading": "Space info", - "docs.spaceSettings.info.landingLabel": "Default landing page", - "docs.spaceSettings.info.landingSpaceHome": "Space home", - "docs.spaceSettings.info.nameLabel": "Space name", - "docs.spaceSettings.info.urlLabel": "Space URL", - "docs.spaceSettings.navLabel": "Space settings sections", - "docs.spaceSettings.permissions.accessHeading": "Space access", - "docs.spaceSettings.permissions.accessLabel": "Space access", - "docs.spaceSettings.permissions.externalSharing.comingSoon": "Coming soon", - "docs.spaceSettings.permissions.externalSharing.description": "Let people outside the team access this space with a link.", - "docs.spaceSettings.permissions.externalSharing.title": "External sharing", - "docs.spaceSettings.permissions.handle": "@{username}", - "docs.spaceSettings.permissions.peopleHeading": "People and groups with access", - "docs.spaceSettings.permissions.private.comingSoon": "Coming soon", - "docs.spaceSettings.permissions.private.description": "Only invited members can view this space.", - "docs.spaceSettings.permissions.private.title": "Private", - "docs.spaceSettings.permissions.public.description": "Anyone in the team can find and view this space.", - "docs.spaceSettings.permissions.public.title": "Public", - "docs.spaceSettings.permissions.role.admin": "Admin", - "docs.spaceSettings.permissions.searchPlaceholder": "Add people, groups or channels", - "docs.spaceSettings.tab.archive": "Archive space", - "docs.spaceSettings.tab.configuration": "Configuration", - "docs.spaceSettings.tab.info": "Info", - "docs.spaceSettings.tab.permissions": "Permissions", - "docs.spaceSettings.title": "Space Settings", - "docs.switcher.group.recent": "Recent", + "docs.switcher.group.recent": "Recent docs", "docs.switcher.group.results": "Spaces and pages", "docs.switcher.group.spaces": "Your spaces", "docs.switcher.noResults": "No spaces or pages found", "docs.switcher.placeholder": "Search all spaces and pages", "docs.switcher.title": "Find docs", - "docs.switcher.title.query": "Find spaces or pages", - "docs.toast.close": "Close" + "docs.switcher.title.query": "Find spaces or pages" } diff --git a/webapp/src/components/page_editor/apply_formatting.ts b/webapp/src/components/page_editor/apply_formatting.ts index aada3c5..11b9af9 100644 --- a/webapp/src/components/page_editor/apply_formatting.ts +++ b/webapp/src/components/page_editor/apply_formatting.ts @@ -4,8 +4,6 @@ import type {Editor} from '@tiptap/core'; import type {PublishedMarkdownMode} from 'webapp_globals'; -// These commands come from the host's StarterKit extensions, which are not -// dependencies here, so the chain they extend is typed structurally. type FormattingChain = { toggleBold: () => FormattingChain; toggleItalic: () => FormattingChain; @@ -20,7 +18,8 @@ type FormattingChain = { const selectWordUnderCaret = (editor: Editor) => { const {$from} = editor.state.selection; - const text = $from.parent.textContent; + + const text = $from.parent.textBetween(0, $from.parent.content.size, undefined, '\ufffc'); const offset = $from.parentOffset; if (!text || offset < 0) { return; @@ -42,13 +41,22 @@ const selectWordUnderCaret = (editor: Editor) => { } }; -export function applyWysiwygFormatting(editor: Editor, mode: PublishedMarkdownMode): void { +export function applyWysiwygFormatting(editor: Editor, mode: PublishedMarkdownMode): boolean { if (mode === 'bold' || mode === 'italic' || mode === 'strike') { if (editor.state.selection.empty) { selectWordUnderCaret(editor); } } + try { + applyMode(editor, mode); + return true; + } catch { + return false; + } +} + +function applyMode(editor: Editor, mode: PublishedMarkdownMode): void { const chain = editor.chain().focus() as unknown as FormattingChain; switch (mode) { case 'bold': diff --git a/webapp/src/components/page_editor/callout_extension.ts b/webapp/src/components/page_editor/callout_extension.ts index d8c5981..185029e 100644 --- a/webapp/src/components/page_editor/callout_extension.ts +++ b/webapp/src/components/page_editor/callout_extension.ts @@ -48,7 +48,13 @@ export const Callout = Node.create({ addCommands() { return { setCallout: (type: CalloutType) => ({commands}) => commands.wrapIn(this.name, {type}), - toggleCallout: (type: CalloutType) => ({commands}) => commands.toggleWrap(this.name, {type}), + + toggleCallout: (type: CalloutType) => ({editor, commands}) => { + if (editor.isActive(this.name) && !editor.isActive(this.name, {type})) { + return commands.updateAttributes(this.name, {type}); + } + return commands.toggleWrap(this.name, {type}); + }, }; }, }); diff --git a/webapp/src/components/page_editor/floating_formatting_bar.tsx b/webapp/src/components/page_editor/floating_formatting_bar.tsx index 9206390..d12d69b 100644 --- a/webapp/src/components/page_editor/floating_formatting_bar.tsx +++ b/webapp/src/components/page_editor/floating_formatting_bar.tsx @@ -83,17 +83,21 @@ const FloatingFormattingBar = ({editorRef, applyFormatting, getEditor, barRef, a }, [reposition]); useEffect(() => { + const scroller = editorRef.current?.closest('[data-docs-scroll]'); + document.addEventListener('selectionchange', schedule); window.addEventListener('resize', schedule); + scroller?.addEventListener('scroll', schedule); return () => { document.removeEventListener('selectionchange', schedule); window.removeEventListener('resize', schedule); + scroller?.removeEventListener('scroll', schedule); if (frameRef.current) { cancelAnimationFrame(frameRef.current); frameRef.current = 0; } }; - }, [schedule]); + }, [schedule, editorRef]); const onMouseDown = useCallback((e: React.MouseEvent) => { interactingRef.current = true; diff --git a/webapp/src/components/page_editor/page_byline.tsx b/webapp/src/components/page_editor/page_byline.tsx index 880498d..bbe630e 100644 --- a/webapp/src/components/page_editor/page_byline.tsx +++ b/webapp/src/components/page_editor/page_byline.tsx @@ -2,12 +2,13 @@ // See LICENSE.txt for license information. import {siteRoot} from 'client/rest'; -import React from 'react'; +import React, {useEffect} from 'react'; import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; +import {useDispatch, useSelector} from 'react-redux'; import type {GlobalState} from '@mattermost/types/store'; +import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; import {getTeammateNameDisplaySetting} from 'mattermost-redux/selectors/entities/preferences'; import {getUser} from 'mattermost-redux/selectors/entities/users'; import {displayUsername} from 'mattermost-redux/utils/user_utils'; @@ -19,9 +20,16 @@ type Props = { }; const PageByline = ({userId}: Props) => { + const dispatch = useDispatch(); const author = useSelector((state: GlobalState) => getUser(state, userId)); const teammateNameDisplay = useSelector(getTeammateNameDisplaySetting) || ''; + useEffect(() => { + if (userId && !author) { + dispatch(getMissingProfilesByIds([userId]) as never); + } + }, [dispatch, userId, author]); + if (!author) { return null; } diff --git a/webapp/src/components/page_editor/page_editor.tsx b/webapp/src/components/page_editor/page_editor.tsx index 55fa3d7..10a9932 100644 --- a/webapp/src/components/page_editor/page_editor.tsx +++ b/webapp/src/components/page_editor/page_editor.tsx @@ -61,6 +61,8 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { const [actionError, setActionError] = useState(null); const [contentError, setContentError] = useState(false); const [busy, setBusy] = useState(false); + + const busyRef = useRef(false); const [baseEditAt, setBaseEditAt] = useState(undefined); const [draftExists, setDraftExists] = useState(false); @@ -89,6 +91,9 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { setContentError(false); setDraftExists(false); setActionError(null); + + setConflict(null); + setShowExitDialog(false); }, [spaceId, pageId]); useEffect(() => { @@ -145,9 +150,10 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { const leave = useCallback(() => goToPage(spaceId, pageId), [goToPage, spaceId, pageId]); const publish = useCallback(async (force: boolean, exitAfter = false) => { - if (busy) { + if (busyRef.current) { return; } + busyRef.current = true; setBusy(true); setActionError(null); try { @@ -169,14 +175,16 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { } setActionError(error); } finally { + busyRef.current = false; setBusy(false); } - }, [autosave, spaceId, pageId, leave, busy]); + }, [autosave, spaceId, pageId, leave]); const discard = useCallback(async () => { - if (busy) { + if (busyRef.current) { return; } + busyRef.current = true; setBusy(true); setActionError(null); autosave.cancel(); @@ -187,14 +195,16 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { } catch (error) { setActionError(error); } finally { + busyRef.current = false; setBusy(false); } - }, [autosave, spaceId, pageId, leave, busy]); + }, [autosave, spaceId, pageId, leave]); const saveDraftAndLeave = useCallback(async () => { - if (busy) { + if (busyRef.current) { return; } + busyRef.current = true; setBusy(true); setActionError(null); try { @@ -204,9 +214,10 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { setShowExitDialog(false); leave(); } finally { + busyRef.current = false; setBusy(false); } - }, [autosave, leave, busy]); + }, [autosave, leave]); const onPublish = useCallback(() => { publish(false); @@ -253,6 +264,17 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { ); } + if (load.notFound) { + return ( +
+ +
+ ); + } + const {WysiwygEditor, FormattingBar} = hostGetEditor() ?? {}; if (!WysiwygEditor) { return null; diff --git a/webapp/src/components/page_editor/toolbar_controls.tsx b/webapp/src/components/page_editor/toolbar_controls.tsx index 3e4676d..867c936 100644 --- a/webapp/src/components/page_editor/toolbar_controls.tsx +++ b/webapp/src/components/page_editor/toolbar_controls.tsx @@ -68,24 +68,25 @@ export const CalloutControl = ({getEditor}: CalloutProps) => { setOpen(false); } }; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.stopPropagation(); - setOpen(false); - triggerRef.current?.focus(); - } - }; document.addEventListener('click', onDocumentClick); - document.addEventListener('keydown', onKeyDown, true); - return () => { - document.removeEventListener('click', onDocumentClick); - document.removeEventListener('keydown', onKeyDown, true); - }; + return () => document.removeEventListener('click', onDocumentClick); }, [open]); + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + } + }, []); + const insert = useCallback((type: CalloutType) => { const editor = getEditor() as Editor | null; - editor?.chain().focus().toggleCallout(type).run(); + const chain = editor?.chain().focus(); + + if (chain && typeof chain.toggleCallout === 'function') { + chain.toggleCallout(type).run(); + } setOpen(false); }, [getEditor]); @@ -95,6 +96,7 @@ export const CalloutControl = ({getEditor}: CalloutProps) => {