feat: Seamless Edit (#2701)

* feat: Remove explicit edit

* Restore revision remains disabled for now

* Bump RME, better differentiation of focused state

* fix: Star not visible in edit mode

* remove stray log

* fix: Occassional user context not available in collaborative persistence
This commit is contained in:
Tom Moor 2021-11-08 20:52:17 -08:00 committed by GitHub
parent 37be7f99c4
commit c597f2d9a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 302 additions and 207 deletions

View File

@ -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<?HTMLSpanElement>();
const [innerHTML, setInnerHTML] = React.useState<string>(value);
const lastValue = React.useRef("");
const wrappedEvent = (callback) => (
event: SyntheticInputEvent<HTMLInputElement>
) => {
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 (
<div className={className}>
<Content
contentEditable={!disabled}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
ref={ref}
data-placeholder={placeholder}
role="textbox"
dangerouslySetInnerHTML={{ __html: innerHTML }}
{...rest}
/>
{children}
</div>
);
}
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<Props>(ContentEditable);

View File

@ -325,7 +325,7 @@ function DocumentMenu({
{
title: t("Edit"),
to: editDocumentUrl(document),
visible: !!can.update,
visible: !!can.update && !team.collaborativeEditing,
icon: <EditIcon />,
},
{

View File

@ -88,7 +88,10 @@ class DataLoader extends React.Component<Props> {
}
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<Props> {
return (
<>
<Loading location={location} />
{this.isEditing && <HideSidebar ui={ui} />}
{this.isEditing && !team?.collaborativeEditing && (
<HideSidebar ui={ui} />
)}
</>
);
}
@ -261,7 +266,9 @@ class DataLoader extends React.Component<Props> {
return (
<React.Fragment key={key}>
{this.isEditing && <HideSidebar ui={ui} />}
{this.isEditing && !team.collaborativeEditing && (
<HideSidebar ui={ui} />
)}
{this.props.children({
document,
revision,

View File

@ -357,8 +357,8 @@ class DocumentScene extends React.Component<Props> {
}
};
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<Props> {
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<Props> {
shareId={shareId}
isRevision={!!revision}
isDraft={document.isDraft}
isEditing={!readOnly}
isEditing={!readOnly && !team?.collaborativeEditing}
isSaving={this.isSaving}
isPublishing={this.isPublishing}
publishingIsDisabled={

View File

@ -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 (
<Title
ref={ref}
onChange={onChange}
onKeyDown={handleKeyDown}
placeholder={
document.isTemplate
? t("Start your template…")
: t("Start with a title…")
}
value={normalizedTitle}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
$isStarred={document.isStarred}
autoFocus={!value}
maxLength={MAX_TITLE_LENGTH}
readOnly={readOnly}
dir="auto"
>
{(can.star || can.unstar) && <StarButton document={document} size={32} />}
</Title>
);
}
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);

View File

@ -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<Props> {
}
};
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<Props> {
this.activeLinkEvent = null;
};
handleGoToNextInput = (insertParagraph: boolean) => {
if (insertParagraph) {
this.insertParagraph();
}
this.focusAtStart();
};
render() {
const {
document,
@ -115,45 +86,16 @@ class DocumentEditor extends React.Component<Props> {
} = 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 (
<Flex auto column>
{readOnly ? (
<Title
as="div"
ref={this.ref}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
$isStarred={document.isStarred}
dir="auto"
>
<span>{normalizedTitle}</span>{" "}
{(can.star || can.unstar) && (
<StarButton document={document} size={32} />
)}
</Title>
) : (
<Title
type="text"
ref={this.ref}
onChange={onChangeTitle}
onKeyDown={this.handleTitleKeyDown}
placeholder={
document.isTemplate
? t("Start your template…")
: t("Start with a title…")
}
value={normalizedTitle}
$startsWithEmojiAndSpace={startsWithEmojiAndSpace}
autoFocus={!title}
maxLength={MAX_TITLE_LENGTH}
dir="auto"
/>
)}
<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)
);

View File

@ -230,7 +230,7 @@ function DocumentHeader({
</Action>
</>
)}
{canEdit && editAction}
{canEdit && !team.collaborativeEditing && editAction}
{canEdit && can.createChildDocument && !isMobile && (
<Action>
<NewChildDocumentMenu

View File

@ -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'>;
}

View File

@ -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",

View File

@ -52,7 +52,7 @@ export default class Persistence {
documentName,
}: {
document: Y.Doc,
context: { user: User },
context: { user: ?User },
documentName: string,
}) => {
const [, documentId] = documentName.split(".");
@ -63,7 +63,7 @@ export default class Persistence {
await documentUpdater({
documentId,
ydoc: document,
userId: context.user.id,
userId: context.user?.id,
});
} catch (err) {
Logger.error("Unable to persist document", err, {

View File

@ -13,7 +13,7 @@ export default async function documentUpdater({
}: {
documentId: string,
ydoc: Y.Doc,
userId: string,
userId?: string,
}) {
const document = await Document.findByPk(documentId);
const state = Y.encodeStateAsUpdate(ydoc);
@ -38,7 +38,8 @@ export default async function documentUpdater({
text,
state: Buffer.from(state),
updatedAt: isUnchanged ? document.updatedAt : new Date(),
lastModifiedById: isUnchanged ? document.lastModifiedById : userId,
lastModifiedById:
isUnchanged || !userId ? document.lastModifiedById : userId,
collaboratorIds,
},
{

View File

@ -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",

View File

@ -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"