diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 850b14f1..36cf4f9a 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -35,10 +35,21 @@ "docs.discardDraft.error": "Could not discard “{title}”.", "docs.discardDraft.message": "Are you sure you want to discard {title}? It has never been published, so this cannot be undone.", "docs.discardDraft.title": "Discard draft", - "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.callout": "Insert callout", + "docs.editor.contentError": "This page's content could not be opened. Editing is disabled so the stored version is not overwritten.", "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.moreOptions": "More options", + "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.unpinToolbar": "Unpin toolbar", "docs.form.url.edit": "Edit", "docs.form.url.label": "URL:", "docs.genericModal.close": "Close", @@ -190,6 +201,7 @@ "docs.space.menu.settings": "Space settings", "docs.space.more": "More actions", "docs.space.noUnpublishedChanges": "No unpublished changes", + "docs.space.otherEditors": "{count, plural, one {# other editor} other {# other editors}}", "docs.space.pages": "Pages", "docs.space.publish": "Publish", "docs.space.share": "Share", diff --git a/webapp/package-lock.json b/webapp/package-lock.json index f9412fa3..ac571d89 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 c6b39a22..158aff98 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", @@ -120,7 +122,7 @@ "jest-junit" ], "transformIgnorePatterns": [ - "node_modules/(?!react-native|react-router|mattermost-webapp)" + "node_modules/(?!react-native|react-router|mattermost-webapp|@tiptap|prosemirror-|serialize-error)" ], "setupFiles": [ "jest-canvas-mock" diff --git a/webapp/src/client/presence.ts b/webapp/src/client/presence.ts new file mode 100644 index 00000000..ba16399b --- /dev/null +++ b/webapp/src/client/presence.ts @@ -0,0 +1,13 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {PageActiveEditors} from 'types/drafts'; + +import {doFetch} from './rest'; + +export function getPageActiveEditors(spaceId: string, pageId: string, signal?: AbortSignal): Promise { + return doFetch( + `/spaces/${encodeURIComponent(spaceId)}/pages/${encodeURIComponent(pageId)}/active-editors`, + {signal}, + ); +} diff --git a/webapp/src/client/presence_events.ts b/webapp/src/client/presence_events.ts new file mode 100644 index 00000000..09e83d7e --- /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 00000000..961de2d6 --- /dev/null +++ b/webapp/src/components/page_editor/apply_formatting.ts @@ -0,0 +1,90 @@ +// 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'; + +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; + if (!$from.parent.isTextblock) { + return; + } + + const text = $from.parent.textBetween(0, $from.parent.content.size, undefined, '\ufffc'); + 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): 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': + 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 00000000..4e5ea29e --- /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 00000000..dd3d2ec4 --- /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 00000000..185029ef --- /dev/null +++ b/webapp/src/components/page_editor/callout_extension.ts @@ -0,0 +1,60 @@ +// 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) => ({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/docs_extensions.ts b/webapp/src/components/page_editor/docs_extensions.ts new file mode 100644 index 00000000..b4236a21 --- /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/floating_formatting_bar.module.scss b/webapp/src/components/page_editor/floating_formatting_bar.module.scss new file mode 100644 index 00000000..e2da0cf0 --- /dev/null +++ b/webapp/src/components/page_editor/floating_formatting_bar.module.scss @@ -0,0 +1,24 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +.bar { + position: absolute; + z-index: 10; + + 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); +} + +// Wider than the bar that clips to it. The span past the last control is empty, and +// must stay visible so the control menus are not cut off. +.track { + overflow: visible; +} + +.hidden { + 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 00000000..20566033 --- /dev/null +++ b/webapp/src/components/page_editor/floating_formatting_bar.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {autoUpdate, flip, inline, offset, shift, useFloating} from '@floating-ui/react'; +import React, {useCallback, useEffect, useLayoutEffect, useMemo, 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 TRACK_WIDTH = 660; +const TRAILING_PAD = 7; + +const contentWidth = (container: HTMLElement): number | null => { + const laidOut = Array.from(container.children). + filter((child) => window.getComputedStyle(child).position !== 'absolute'); + if (laidOut.length === 0) { + return null; + } + + const left = container.getBoundingClientRect().left; + const right = Math.max(...laidOut.map((child) => child.getBoundingClientRect().right)); + return Math.ceil(right - left) + TRAILING_PAD; +}; + +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 rangeRef = useRef(null); + const [open, setOpen] = useState(false); + const [editorEl, setEditorEl] = useState(null); + const trackRef = useRef(null); + const [width, setWidth] = useState(null); + + useEffect(() => setEditorEl(editorRef.current), [editorRef]); + + const middleware = useMemo(() => [ + inline(), + offset(GAP), + flip({boundary: editorEl?.closest('[data-docs-scroll]') ?? undefined, padding: GAP}), + shift({boundary: editorEl ?? undefined, padding: 0}), + ], [editorEl]); + + const {refs, floatingStyles, update} = useFloating({ + open, + placement: 'top', + strategy: 'absolute', + middleware, + whileElementsMounted: autoUpdate, + }); + + const {setReference, setFloating} = refs; + + const reference = useMemo(() => ({ + contextElement: editorEl ?? undefined, + getBoundingClientRect: () => rangeRef.current?.getBoundingClientRect() ?? new DOMRect(), + getClientRects: () => Array.from(rangeRef.current?.getClientRects() ?? []), + }), [editorEl]); + + useEffect(() => setReference(reference), [setReference, reference]); + + const setWrapper = useCallback((node: HTMLDivElement | null) => { + wrapperRef.current = node; + setFloating(node); + }, [setFloating]); + + useLayoutEffect(() => { + const container = trackRef.current?.firstElementChild as HTMLElement | null; + if (!container) { + return undefined; + } + + const measure = () => setWidth((current) => contentWidth(container) ?? current); + + measure(); + + const observer = new MutationObserver(measure); + observer.observe(container, {childList: true}); + return () => observer.disconnect(); + }, [additionalControls]); + + useEffect(() => { + update(); + }, [width, update]); + + const sync = useCallback(() => { + const surface = editorRef.current; + if (!surface || interactingRef.current || wrapperRef.current?.contains(document.activeElement)) { + return; + } + + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + setOpen(false); + return; + } + + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + if (!surface.contains(range.commonAncestorContainer) || (rect.width === 0 && rect.height === 0)) { + setOpen(false); + return; + } + + rangeRef.current = range; + setOpen(true); + update(); + }, [editorRef, update]); + + useEffect(() => { + document.addEventListener('selectionchange', sync); + return () => document.removeEventListener('selectionchange', sync); + }, [sync]); + + 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_editor.module.scss b/webapp/src/components/page_editor/page_editor.module.scss index 8e72ce6a..32415557 100644 --- a/webapp/src/components/page_editor/page_editor.module.scss +++ b/webapp/src/components/page_editor/page_editor.module.scss @@ -3,34 +3,137 @@ 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 { +.scroll { + flex: 1 1 auto; + min-height: 320px; + overflow-y: auto; +} + +.column { + max-width: 700px; + margin: 0 auto; + padding: 0 24px 96px; +} + + + +.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 { display: flex; + flex-shrink: 0; align-items: center; - justify-content: space-between; + padding: 0 8px; + border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + background: var(--center-channel-bg); + + > * { + width: 100%; + max-width: 700px; + padding-inline: 17px 24px; + } +} + +.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: '⊗'; + } + } +} + +.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: 12px; - text-transform: uppercase; - letter-spacing: 0.04em; } -.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; - color: var(--center-channel-color); - font-size: 14px; - text-align: center; +.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 eeb24e73..1ae5bf5f 100644 --- a/webapp/src/components/page_editor/page_editor.tsx +++ b/webapp/src/components/page_editor/page_editor.tsx @@ -1,21 +1,75 @@ // 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 {usePublishDraft} from 'hooks/drafts'; +import {useHostEditor} from 'hooks/host_editor'; +import {usePageEditing} from 'hooks/page_editing'; +import {usePinnedToolbar} from 'hooks/pinned_toolbar'; +import React, {useCallback, useMemo, useRef} from 'react'; +import {createPortal} from 'react-dom'; +import {FormattedMessage, useIntl} from 'react-intl'; import {hostCanUseEditor, hostGetEditor} from 'webapp_globals'; +import type {PublishedWysiwygEditorHandle} from 'webapp_globals'; +import {DOCS_EXTENSIONS} from './docs_extensions'; +import FloatingFormattingBar from './floating_formatting_bar'; import styles from './page_editor.module.scss'; +import {CalloutControl, OverflowControl, PinToolbarControl} from './toolbar_controls'; +import {useToolbarSlot} from './toolbar_slot'; type Props = { spaceId: string; pageId: string; isDraft: boolean; + editing: boolean; }; -// Placeholder mount for the WYSIWYG editor. This ticket only wires the -// component to the page route and proves the host slice resolves -const PageEditor = ({spaceId, pageId, isDraft}: Props) => { +const PageEditor = ({spaceId, pageId, isDraft, editing}: Props) => { + const {formatMessage} = useIntl(); + const [pinned, togglePinned] = usePinnedToolbar(); + const toolbarSlot = useToolbarSlot(); + + const editorRef = useRef(null); + + const {load, contentError, actionError, onContentChange, onContentError} = usePageEditing({ + spaceId, + pageId, + editing, + editorRef, + }); + + const {formattingBarRef, surfaceRef, getEditor, applyFormatting, documentMode} = + useHostEditor(editorRef, editing && !load.loading && !load.error); + + const publish = usePublishDraft(spaceId); + const onSubmit = useCallback(() => { + publish(pageId); + }, [publish, pageId]); + + const calloutControl = useMemo(() => ( + + ), [getEditor]); + + const floatingControls = useMemo(() => [ + calloutControl, + , + ], [calloutControl, togglePinned]); + + const pinnedControls = useMemo(() => [ + calloutControl, + , + ], [calloutControl, togglePinned]); + if (!hostCanUseEditor()) { return (
@@ -27,35 +81,129 @@ const PageEditor = ({spaceId, pageId, isDraft}: Props) => { ); } - const editor = hostGetEditor(); - const providerCount = editor?.providers ? Object.keys(editor.providers).length : 0; + if (load.loading) { + return ( +
+ +
+ ); + } - return ( -
-
- - {isDraft ? ( - - ) : ( - - )} - + if (load.error) { + return ( +
+
-
+ ); + } + + if (load.notFound) { + return ( +
+ ); + } + + const {WysiwygEditor, FormattingBar} = hostGetEditor() ?? {}; + if (!WysiwygEditor) { + return null; + } + + return ( +
+ {editing && pinned && FormattingBar && toolbarSlot && createPortal( +
+ +
, + toolbarSlot, + )} + +
+
+ {documentMode === false && ( +
+ +
+ )} + + {contentError && ( +
+ +
+ )} + + {actionError != null && ( +
+ +
+ )} + +
+ {editing && !pinned && ( + + )} + + +
+
+
); }; 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 00000000..a4683f33 --- /dev/null +++ b/webapp/src/components/page_editor/toolbar_controls.module.scss @@ -0,0 +1,63 @@ +// 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; + } +} + +.menu { + z-index: 1100; + 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 00000000..0048bef7 --- /dev/null +++ b/webapp/src/components/page_editor/toolbar_controls.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {autoUpdate, flip, FloatingPortal, offset, shift, useFloating} from '@floating-ui/react'; +import type {Editor} from '@tiptap/core'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import { + AlertCircleOutlineIcon, + AlertOutlineIcon, + CheckCircleOutlineIcon, + CloseCircleOutlineIcon, + DotsHorizontalIcon, + 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 MenuControlProps = { + label: string; + icon: React.ReactNode; + children: (close: () => void) => React.ReactNode; +}; + +// The menu is portalled out of the bar: the host formatting bar clips its own +// track, so a menu rendered inside it is cut off at the bar's edge. +const MenuControl = ({label, icon, children}: MenuControlProps) => { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const menuRef = useRef(null); + + const {refs, floatingStyles} = useFloating({ + open, + placement: 'bottom-start', + middleware: [offset(4), flip(), shift({padding: 8})], + whileElementsMounted: autoUpdate, + }); + + const {setReference, setFloating} = refs; + + const setTrigger = useCallback((node: HTMLButtonElement | null) => { + triggerRef.current = node; + setReference(node); + }, [setReference]); + + const setMenu = useCallback((node: HTMLDivElement | null) => { + menuRef.current = node; + setFloating(node); + }, [setFloating]); + + const close = useCallback(() => setOpen(false), []); + + useEffect(() => { + if (!open) { + return undefined; + } + const onDocumentClick = (e: MouseEvent) => { + const target = e.target as globalThis.Node; + if (!menuRef.current?.contains(target) && !triggerRef.current?.contains(target)) { + setOpen(false); + } + }; + document.addEventListener('click', onDocumentClick); + return () => document.removeEventListener('click', onDocumentClick); + }, [open]); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Escape' && open) { + e.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + } + }, [open]); + + return ( + <> + + + {open && ( + +
e.preventDefault()} + > + {children(close)} +
+
+ )} + + ); +}; + +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 OverflowProps = { + onPin: () => void; +}; + +export const OverflowControl = ({onPin}: OverflowProps) => { + const {formatMessage} = useIntl(); + + return ( + } + > + {(close) => ( + + )} + + ); +}; + +type CalloutProps = { + getEditor: () => unknown; +}; + +export const CalloutControl = ({getEditor}: CalloutProps) => { + const {formatMessage} = useIntl(); + + const insert = useCallback((type: CalloutType) => { + const editor = getEditor() as Editor | null; + const chain = editor?.chain().focus(); + + if (chain && typeof chain.toggleCallout === 'function') { + chain.toggleCallout(type).run(); + } + }, [getEditor]); + + return ( + } + > + {(close) => 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/components/page_editor/toolbar_slot.tsx b/webapp/src/components/page_editor/toolbar_slot.tsx new file mode 100644 index 00000000..3b29cf3a --- /dev/null +++ b/webapp/src/components/page_editor/toolbar_slot.tsx @@ -0,0 +1,29 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {createContext, useContext, useMemo, useState} from 'react'; + +// The pinned toolbar belongs above the page heading and outside the scrolling +// content, while the editor that owns it renders below both. The slot marks where +// it goes so the editor can portal it there. +type Slot = { + element: HTMLElement | null; + setElement: (element: HTMLElement | null) => void; +}; + +const SlotContext = createContext({element: null, setElement: () => undefined}); + +export const ToolbarSlotProvider = ({children}: {children: React.ReactNode}) => { + const [element, setElement] = useState(null); + const value = useMemo(() => ({element, setElement}), [element]); + + return {children}; +}; + +export const ToolbarSlot = () => { + const {setElement} = useContext(SlotContext); + + return
; +}; + +export const useToolbarSlot = (): HTMLElement | null => useContext(SlotContext).element; diff --git a/webapp/src/components/space_view/page_content/page_content.module.scss b/webapp/src/components/space_view/page_content/page_content.module.scss index a66eb930..bc583584 100644 --- a/webapp/src/components/space_view/page_content/page_content.module.scss +++ b/webapp/src/components/space_view/page_content/page_content.module.scss @@ -20,8 +20,7 @@ display: flex; flex-direction: column; gap: 8px; - padding: 32px 0 24px; - border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12); + padding: 32px 0 0; &:hover .topActions, &:focus-within .topActions { diff --git a/webapp/src/components/space_view/page_content/page_content.tsx b/webapp/src/components/space_view/page_content/page_content.tsx index 94d4b7dd..aaad12a0 100644 --- a/webapp/src/components/space_view/page_content/page_content.tsx +++ b/webapp/src/components/space_view/page_content/page_content.tsx @@ -94,6 +94,7 @@ const PageContent = ({page, draft, editing}: Props) => { spaceId={subject.spaceId} pageId={subject.id} isDraft={!page} + editing={editing} /> ) : ( diff --git a/webapp/src/components/space_view/page_header.tsx b/webapp/src/components/space_view/page_header.tsx index d64392c6..0eccf4fa 100644 --- a/webapp/src/components/space_view/page_header.tsx +++ b/webapp/src/components/space_view/page_header.tsx @@ -2,9 +2,12 @@ // See LICENSE.txt for license information. import classNames from 'classnames'; +import {useAutosaveStatus} from 'hooks/autosave_status'; import {useFullscreen} from 'hooks/fullscreen'; +import {usePagePresence} from 'hooks/page_presence'; import {useCreateRootPage} from 'hooks/pages'; import {useSidebarWidth} from 'hooks/sidebar_width'; +import {useCurrentUserId} from 'hooks/user'; import React, {useState} from 'react'; import {FormattedMessage, defineMessage, useIntl} from 'react-intl'; import {Timestamp} from 'webapp_globals'; @@ -20,6 +23,7 @@ import PencilOutlineIcon from '@mattermost/compass-icons/components/pencil-outli import PlusIcon from '@mattermost/compass-icons/components/plus'; import {Button} from 'components/form_controls/button'; +import AutosaveIndicator from 'components/page_editor/autosave_indicator'; import PageMenu from 'components/page_menu/page_menu'; import Spacer from 'components/spacer/spacer'; @@ -86,6 +90,7 @@ type Props = { const PageHeader = ({space, page, draft, treeOpen, editing, commentsOpen, onTogglePages, onToggleComments, onToggleEdit, onPublish}: Props) => { const {formatMessage} = useIntl(); const createRootPage = useCreateRootPage(space.id); + const autosaveStatus = useAutosaveStatus(); // Read here rather than passed in: the sidebar it hides belongs to the product // root, which shares no state with this header — see useFullscreen. @@ -131,6 +136,9 @@ const PageHeader = ({space, page, draft, treeOpen, editing, commentsOpen, onTogg // otherwise the space's. const updatedAt = page?.update_at ?? draft?.update_at ?? space.update_at; + const currentUserId = useCurrentUserId(); + const activeEditors = usePagePresence(space.id, subject?.id ?? '', currentUserId); + // Timestamp's `style` is a narrow/short/long format variant, not a DOM style object. /* eslint-disable react/style-prop-object */ const updatedRelative = ( @@ -182,13 +190,26 @@ const PageHeader = ({space, page, draft, treeOpen, editing, commentsOpen, onTogg
- - - + {activeEditors.length > 0 && ( + + + + )} + {autosaveStatus ? ( + + ) : ( + + + + )} {page && (