diff --git a/app/components/ContentEditable.js b/app/components/ContentEditable.js new file mode 100644 index 00000000..afc21ccf --- /dev/null +++ b/app/components/ContentEditable.js @@ -0,0 +1,105 @@ +// @flow +import isPrintableKeyEvent from "is-printable-key-event"; +import * as React from "react"; +import styled from "styled-components"; + +type Props = {| + disabled?: boolean, + onChange?: (text: string) => void, + onBlur?: (event: SyntheticInputEvent<>) => void, + onInput?: (event: SyntheticInputEvent<>) => void, + onKeyDown?: (event: SyntheticInputEvent<>) => void, + placeholder?: string, + maxLength?: number, + autoFocus?: boolean, + className?: string, + children?: React.Node, + value: string, +|}; + +/** + * Defines a content editable component with the same interface as a native + * HTMLInputElement (or, as close as we can get). + */ +function ContentEditable({ + disabled, + onChange, + onInput, + onBlur, + onKeyDown, + value, + children, + className, + maxLength, + autoFocus, + placeholder, + ...rest +}: Props) { + const ref = React.useRef(); + const [innerHTML, setInnerHTML] = React.useState(value); + const lastValue = React.useRef(""); + + const wrappedEvent = (callback) => ( + event: SyntheticInputEvent + ) => { + const text = ref.current?.innerText || ""; + + if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) { + event.preventDefault(); + return false; + } + + if (text !== lastValue.current) { + lastValue.current = text; + onChange && onChange(text); + } + + callback && callback(event); + }; + + React.useLayoutEffect(() => { + if (autoFocus) { + ref.current?.focus(); + } + }); + + React.useEffect(() => { + if (value !== ref.current?.innerText) { + setInnerHTML(value); + } + }, [value]); + + return ( +
+ + {children} +
+ ); +} + +const Content = styled.span` + &:empty { + display: inline-block; + } + + &:empty::before { + display: inline-block; + color: ${(props) => props.theme.placeholder}; + -webkit-text-fill-color: ${(props) => props.theme.placeholder}; + content: attr(data-placeholder); + pointer-events: none; + height: 0; + } +`; + +export default React.memo(ContentEditable); diff --git a/app/components/Layout.js b/app/components/Layout.js index 40d01959..63d44bde 100644 --- a/app/components/Layout.js +++ b/app/components/Layout.js @@ -143,7 +143,7 @@ class Layout extends React.Component { {this.props.children} - + , }, { diff --git a/app/scenes/Document/components/DataLoader.js b/app/scenes/Document/components/DataLoader.js index d84c06d8..4fd8a371 100644 --- a/app/scenes/Document/components/DataLoader.js +++ b/app/scenes/Document/components/DataLoader.js @@ -88,7 +88,10 @@ class DataLoader extends React.Component { } get isEditing() { - return this.props.match.path === matchDocumentEdit; + return ( + this.props.match.path === matchDocumentEdit || + this.props.auth?.team?.collaborativeEditing + ); } onSearchLink = async (term: string) => { @@ -244,7 +247,9 @@ class DataLoader extends React.Component { return ( <> - {this.isEditing && } + {this.isEditing && !team?.collaborativeEditing && ( + + )} ); } @@ -261,7 +266,9 @@ class DataLoader extends React.Component { return ( - {this.isEditing && } + {this.isEditing && !team.collaborativeEditing && ( + + )} {this.props.children({ document, revision, diff --git a/app/scenes/Document/components/Document.js b/app/scenes/Document/components/Document.js index 66f41a31..2c5bf69b 100644 --- a/app/scenes/Document/components/Document.js +++ b/app/scenes/Document/components/Document.js @@ -357,8 +357,8 @@ class DocumentScene extends React.Component { } }; - onChangeTitle = (event) => { - this.title = event.target.value; + onChangeTitle = (value) => { + this.title = value; this.updateIsDirtyDebounced(); this.autosave(); }; @@ -389,7 +389,8 @@ class DocumentScene extends React.Component { const headings = this.editor.current ? this.editor.current.getHeadings() : []; - const showContents = ui.tocVisible && readOnly; + const showContents = + ui.tocVisible && (readOnly || team?.collaborativeEditing); const collaborativeEditing = team?.collaborativeEditing && @@ -473,7 +474,7 @@ class DocumentScene extends React.Component { shareId={shareId} isRevision={!!revision} isDraft={document.isDraft} - isEditing={!readOnly} + isEditing={!readOnly && !team?.collaborativeEditing} isSaving={this.isSaving} isPublishing={this.isPublishing} publishingIsDisabled={ diff --git a/app/scenes/Document/components/EditableTitle.js b/app/scenes/Document/components/EditableTitle.js new file mode 100644 index 00000000..7a57f110 --- /dev/null +++ b/app/scenes/Document/components/EditableTitle.js @@ -0,0 +1,151 @@ +// @flow +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; +import breakpoint from "styled-components-breakpoint"; +import { MAX_TITLE_LENGTH } from "shared/constants"; +import { light } from "shared/theme"; +import parseTitle from "shared/utils/parseTitle"; +import Document from "models/Document"; +import ContentEditable from "components/ContentEditable"; +import Star, { AnimatedStar } from "components/Star"; +import useStores from "hooks/useStores"; +import { isModKey } from "utils/keyboard"; + +type Props = { + value: string, + document: Document, + readOnly: boolean, + onChange: (text: string) => void, + onGoToNextInput: (insertParagraph?: boolean) => void, + onSave: (options: { publish?: boolean, done?: boolean }) => void, +}; + +function EditableTitle({ + value, + document, + readOnly, + onChange, + onSave, + onGoToNextInput, +}: Props) { + const ref = React.useRef(); + const { policies } = useStores(); + const { t } = useTranslation(); + const can = policies.abilities(document.id); + const { emoji } = parseTitle(value); + const startsWithEmojiAndSpace = !!(emoji && value.startsWith(`${emoji} `)); + const normalizedTitle = + !value && readOnly ? document.titleWithDefault : value; + + const handleKeyDown = React.useCallback( + (event: SyntheticKeyboardEvent<>) => { + if (event.key === "Enter") { + event.preventDefault(); + if (isModKey(event)) { + onSave({ done: true }); + return; + } + + onGoToNextInput(true); + return; + } + if (event.key === "Tab" || event.key === "ArrowDown") { + event.preventDefault(); + onGoToNextInput(); + return; + } + if (event.key === "p" && isModKey(event) && event.shiftKey) { + event.preventDefault(); + onSave({ publish: true, done: true }); + return; + } + if (event.key === "s" && isModKey(event)) { + event.preventDefault(); + onSave({}); + return; + } + }, + [onGoToNextInput, onSave] + ); + + return ( + + {(can.star || can.unstar) && <StarButton document={document} size={32} />} + + ); +} + +const StarButton = styled(Star)` + position: relative; + top: 4px; + left: 4px; +`; + +const Title = styled(ContentEditable)` + line-height: 1.25; + margin-top: 1em; + margin-bottom: 0.5em; + background: ${(props) => props.theme.background}; + transition: ${(props) => props.theme.backgroundTransition}; + color: ${(props) => props.theme.text}; + -webkit-text-fill-color: ${(props) => props.theme.text}; + font-size: 2.25em; + font-weight: 500; + outline: none; + border: 0; + padding: 0; + resize: none; + + > span { + outline: none; + } + + &::placeholder { + color: ${(props) => props.theme.placeholder}; + -webkit-text-fill-color: ${(props) => props.theme.placeholder}; + } + + ${breakpoint("tablet")` + margin-left: ${(props) => (props.$startsWithEmojiAndSpace ? "-1.2em" : 0)}; + `}; + + ${AnimatedStar} { + opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)}; + } + + &:hover { + ${AnimatedStar} { + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + } + + @media print { + color: ${(props) => light.text}; + -webkit-text-fill-color: ${(props) => light.text}; + background: none; + } +`; + +export default observer(EditableTitle); diff --git a/app/scenes/Document/components/Editor.js b/app/scenes/Document/components/Editor.js index 9da6b2b3..56f58ffa 100644 --- a/app/scenes/Document/components/Editor.js +++ b/app/scenes/Document/components/Editor.js @@ -2,13 +2,7 @@ import { observable } from "mobx"; import { inject, observer } from "mobx-react"; import * as React from "react"; -import Textarea from "react-autosize-textarea"; import { type TFunction, withTranslation } from "react-i18next"; -import styled from "styled-components"; -import breakpoint from "styled-components-breakpoint"; -import { MAX_TITLE_LENGTH } from "shared/constants"; -import { light } from "shared/theme"; -import parseTitle from "shared/utils/parseTitle"; import PoliciesStore from "stores/PoliciesStore"; import Document from "models/Document"; import ClickablePadding from "components/ClickablePadding"; @@ -16,14 +10,13 @@ import DocumentMetaWithViews from "components/DocumentMetaWithViews"; import Editor, { type Props as EditorProps } from "components/Editor"; import Flex from "components/Flex"; import HoverPreview from "components/HoverPreview"; -import Star, { AnimatedStar } from "components/Star"; +import EditableTitle from "./EditableTitle"; import MultiplayerEditor from "./MultiplayerEditor"; -import { isModKey } from "utils/keyboard"; import { documentHistoryUrl } from "utils/routeHelpers"; type Props = {| ...EditorProps, - onChangeTitle: (event: SyntheticInputEvent<>) => void, + onChangeTitle: (text: string) => void, title: string, document: Document, isDraft: boolean, @@ -61,35 +54,6 @@ class DocumentEditor extends React.Component { } }; - handleTitleKeyDown = (event: SyntheticKeyboardEvent<>) => { - if (event.key === "Enter") { - event.preventDefault(); - if (isModKey(event)) { - this.props.onSave({ done: true }); - return; - } - - this.insertParagraph(); - this.focusAtStart(); - return; - } - if (event.key === "Tab" || event.key === "ArrowDown") { - event.preventDefault(); - this.focusAtStart(); - return; - } - if (event.key === "p" && isModKey(event) && event.shiftKey) { - event.preventDefault(); - this.props.onSave({ publish: true, done: true }); - return; - } - if (event.key === "s" && isModKey(event)) { - event.preventDefault(); - this.props.onSave({}); - return; - } - }; - handleLinkActive = (event: MouseEvent) => { this.activeLinkEvent = event; }; @@ -98,6 +62,13 @@ class DocumentEditor extends React.Component { this.activeLinkEvent = null; }; + handleGoToNextInput = (insertParagraph: boolean) => { + if (insertParagraph) { + this.insertParagraph(); + } + this.focusAtStart(); + }; + render() { const { document, @@ -115,45 +86,16 @@ class DocumentEditor extends React.Component { } = this.props; const EditorComponent = multiplayer ? MultiplayerEditor : Editor; - const can = policies.abilities(document.id); - const { emoji } = parseTitle(title); - const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `)); - const normalizedTitle = - !title && readOnly ? document.titleWithDefault : title; return ( - {readOnly ? ( - - <span>{normalizedTitle}</span>{" "} - {(can.star || can.unstar) && ( - <StarButton document={document} size={32} /> - )} - - ) : ( - - )} + <EditableTitle + value={title} + readOnly={readOnly} + document={document} + onGoToNextInput={this.handleGoToNextInput} + onChange={onChangeTitle} + /> {!shareId && ( <DocumentMetaWithViews isDraft={isDraft} @@ -191,56 +133,6 @@ class DocumentEditor extends React.Component<Props> { } } -const StarButton = styled(Star)` - position: relative; - top: 4px; -`; - -const Title = styled(Textarea)` - line-height: 1.25; - margin-top: 1em; - margin-bottom: 0.5em; - background: ${(props) => props.theme.background}; - transition: ${(props) => props.theme.backgroundTransition}; - color: ${(props) => props.theme.text}; - -webkit-text-fill-color: ${(props) => props.theme.text}; - font-size: 2.25em; - font-weight: 500; - outline: none; - border: 0; - padding: 0; - resize: none; - - &::placeholder { - color: ${(props) => props.theme.placeholder}; - -webkit-text-fill-color: ${(props) => props.theme.placeholder}; - } - - ${breakpoint("tablet")` - margin-left: ${(props) => (props.$startsWithEmojiAndSpace ? "-1.2em" : 0)}; - `}; - - ${AnimatedStar} { - opacity: ${(props) => (props.$isStarred ? "1 !important" : 0)}; - } - - &:hover { - ${AnimatedStar} { - opacity: 0.5; - - &:hover { - opacity: 1; - } - } - } - - @media print { - color: ${(props) => light.text}; - -webkit-text-fill-color: ${(props) => light.text}; - background: none; - } -`; - export default withTranslation()<DocumentEditor>( inject("policies")(DocumentEditor) ); diff --git a/app/scenes/Document/components/Header.js b/app/scenes/Document/components/Header.js index b5eb43e0..f5729f7e 100644 --- a/app/scenes/Document/components/Header.js +++ b/app/scenes/Document/components/Header.js @@ -230,7 +230,7 @@ function DocumentHeader({ </Action> </> )} - {canEdit && editAction} + {canEdit && !team.collaborativeEditing && editAction} {canEdit && can.createChildDocument && !isMobile && ( <Action> <NewChildDocumentMenu diff --git a/flow-typed/npm/react-autosize-textarea_vx.x.x.js b/flow-typed/npm/react-autosize-textarea_vx.x.x.js deleted file mode 100644 index 8625c21c..00000000 --- a/flow-typed/npm/react-autosize-textarea_vx.x.x.js +++ /dev/null @@ -1,42 +0,0 @@ -// flow-typed signature: 4739272fd9d8d2ec5c9881791bce7104 -// flow-typed version: <<STUB>>/react-autosize-textarea_v^6.0.0/flow_v0.104.0 - -/** - * This is an autogenerated libdef stub for: - * - * 'react-autosize-textarea' - * - * Fill this stub out by replacing all the `any` types. - * - * Once filled out, we encourage you to share your work with the - * community by sending a pull request to: - * https://github.com/flowtype/flow-typed - */ - -declare module 'react-autosize-textarea' { - declare module.exports: any; -} - -/** - * We include stubs for each file inside this npm package in case you need to - * require those files directly. Feel free to delete any files that aren't - * needed. - */ -declare module 'react-autosize-textarea/lib' { - declare module.exports: any; -} - -declare module 'react-autosize-textarea/lib/TextareaAutosize' { - declare module.exports: any; -} - -// Filename aliases -declare module 'react-autosize-textarea/lib/index' { - declare module.exports: $Exports<'react-autosize-textarea/lib'>; -} -declare module 'react-autosize-textarea/lib/index.js' { - declare module.exports: $Exports<'react-autosize-textarea/lib'>; -} -declare module 'react-autosize-textarea/lib/TextareaAutosize.js' { - declare module.exports: $Exports<'react-autosize-textarea/lib/TextareaAutosize'>; -} diff --git a/package.json b/package.json index f7cac5f9..2f473c32 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "imports-loader": "0.6.5", "invariant": "^2.2.2", "ioredis": "^4.24.3", + "is-printable-key-event": "^1.0.0", "joplin-turndown-plugin-gfm": "^1.0.12", "js-search": "^1.4.2", "json-loader": "0.5.4", @@ -131,7 +132,6 @@ "randomstring": "1.1.5", "raw-loader": "^0.5.1", "react": "^17.0.2", - "react-autosize-textarea": "^7.1.0", "react-avatar-editor": "^11.1.0", "react-color": "^2.17.3", "react-dnd": "^14.0.1", diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 06a14f0c..5994b7e5 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -449,6 +449,7 @@ "Send Invites": "Send Invites", "Edit current document": "Edit current document", "Move current document": "Move current document", + "Open document history": "Open document history", "Jump to search": "Jump to search", "Jump to home": "Jump to home", "Toggle navigation": "Toggle navigation", diff --git a/yarn.lock b/yarn.lock index 9d7b66a8..dc0c7222 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3530,11 +3530,6 @@ auto-bind@^1.1.0: resolved "https://registry.yarnpkg.com/auto-bind/-/auto-bind-1.2.1.tgz#807f7910b0210db9eefe133f3492c28e89698b96" integrity sha512-/W9yj1yKmBLwpexwAujeD9YHwYmRuWFGV8HWE7smQab797VeHa4/cnE2NFeDhA+E+5e/OGBI8763EhLjfZ/MXA== -autosize@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.2.tgz#073cfd07c8bf45da4b9fd153437f5bafbba1e4c9" - integrity sha512-jnSyH2d+qdfPGpWlcuhGiHmqBJ6g3X+8T+iRwFrHPLVcdoGJE/x6Qicm6aDHfTsbgZKxyV8UU/YB2p4cjKDRRA== - autotrack@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/autotrack/-/autotrack-2.4.1.tgz#ccbf010e3d95ef23c8dd6db4e8df025135c82ee6" @@ -4897,11 +4892,6 @@ compute-scroll-into-view@^1.0.16: resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.16.tgz#5b7bf4f7127ea2c19b750353d7ce6776a90ee088" integrity sha512-a85LHKY81oQnikatZYA90pufpZ6sQx++BoCxOEMsjpZx+ZnaKGQnCyCehTRr/1p9GBIAHTjcU9k71kSYWloLiQ== -computed-style@~0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/computed-style/-/computed-style-0.1.4.tgz#7f344fd8584b2e425bedca4a1afc0e300bb05d74" - integrity sha1-fzRP2FhLLkJb7cpKGvwOMAuwXXQ= - concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" @@ -8353,6 +8343,11 @@ is-potential-custom-element-name@^1.0.0: resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397" integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c= +is-printable-key-event@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-printable-key-event/-/is-printable-key-event-1.0.0.tgz#1ea47b8abe1a2e53a1f5ea6aecbd6d24da707c66" + integrity sha512-C/GJ8ApSdY6/RGQrSSkBzuWDtYI9/mOTRLCOu/5iYH46pI7Ki6y6B71kPL7OWRzqv9KkWSEmskKdq5IvgAGPHA== + is-promise@^2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" @@ -9568,13 +9563,6 @@ limiter@^1.1.4: resolved "https://registry.yarnpkg.com/limiter/-/limiter-1.1.5.tgz#8f92a25b3b16c6131293a0cc834b4a838a2aa7c2" integrity sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA== -line-height@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/line-height/-/line-height-0.3.1.tgz#4b1205edde182872a5efa3c8f620b3187a9c54c9" - integrity sha1-SxIF7d4YKHKl76PI9iCzGHqcVMk= - dependencies: - computed-style "~0.1.3" - lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" @@ -11688,7 +11676,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.6, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -12044,15 +12032,6 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-autosize-textarea@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/react-autosize-textarea/-/react-autosize-textarea-7.1.0.tgz#902c84fc395a689ca3a484dfb6bc2be9ba3694d1" - integrity sha512-BHpjCDkuOlllZn3nLazY2F8oYO1tS2jHnWhcjTWQdcKiiMU6gHLNt/fzmqMSyerR0eTdKtfSIqtSeTtghNwS+g== - dependencies: - autosize "^4.0.2" - line-height "^0.3.1" - prop-types "^15.5.6" - react-avatar-editor@^11.1.0: version "11.1.0" resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-11.1.0.tgz#0eaec7970b1fbbd90d42a1955be440ea27f598ea"