Collaborative editing (#1660)

This commit is contained in:
Tom Moor
2021-09-10 22:46:57 -07:00
committed by GitHub
parent 0a998789a3
commit 801f6681ba
144 changed files with 3552 additions and 310 deletions

View File

@@ -18,16 +18,15 @@ import Document from "models/Document";
import Revision from "models/Revision";
import Error404 from "scenes/Error404";
import ErrorOffline from "scenes/ErrorOffline";
import DocumentComponent from "./Document";
import HideSidebar from "./HideSidebar";
import Loading from "./Loading";
import SocketPresence from "./SocketPresence";
import { type LocationWithState, type NavigationNode } from "types";
import { NotFoundError, OfflineError } from "utils/errors";
import { matchDocumentEdit, updateDocumentUrl } from "utils/routeHelpers";
import { isInternalUrl } from "utils/urls";
type Props = {|
match: Match,
auth: AuthStore,
location: LocationWithState,
shares: SharesStore,
documents: DocumentsStore,
@@ -36,6 +35,7 @@ type Props = {|
auth: AuthStore,
ui: UiStore,
history: RouterHistory,
children: (any) => React.Node,
|};
const sharedTreeCache = {};
@@ -223,7 +223,7 @@ class DataLoader extends React.Component<Props> {
};
render() {
const { location, policies, ui } = this.props;
const { location, policies, auth, ui } = this.props;
if (this.error) {
return this.error instanceof OfflineError ? (
@@ -233,10 +233,11 @@ class DataLoader extends React.Component<Props> {
);
}
const team = auth.team;
const document = this.document;
const revision = this.revision;
if (!document) {
if (!document || !team) {
return (
<>
<Loading location={location} />
@@ -247,20 +248,28 @@ class DataLoader extends React.Component<Props> {
const abilities = policies.abilities(document.id);
// We do not want to remount the document when changing from view->edit
// on the multiplayer flag as the doc is guaranteed to be upto date.
const key = team.collaborativeEditing
? ""
: this.isEditing
? "editing"
: "read-only";
return (
<SocketPresence documentId={document.id} isEditing={this.isEditing}>
<React.Fragment key={key}>
{this.isEditing && <HideSidebar ui={ui} />}
<DocumentComponent
document={document}
revision={revision}
abilities={abilities}
location={location}
readOnly={!this.isEditing || !abilities.update || document.isArchived}
onSearchLink={this.onSearchLink}
onCreateLink={this.onCreateLink}
sharedTree={this.sharedTree}
/>
</SocketPresence>
{this.props.children({
document,
revision,
abilities,
isEditing: this.isEditing,
readOnly: !this.isEditing || !abilities.update || document.isArchived,
onSearchLink: this.onSearchLink,
onCreateLink: this.onCreateLink,
sharedTree: this.sharedTree,
})}
</React.Fragment>
);
}
}

View File

@@ -1,8 +1,9 @@
// @flow
import { debounce } from "lodash";
import { observable } from "mobx";
import { action, observable } from "mobx";
import { observer, inject } from "mobx-react";
import { InputIcon } from "outline-icons";
import { AllSelection } from "prosemirror-state";
import * as React from "react";
import { type TFunction, Trans, withTranslation } from "react-i18next";
import keydown from "react-keydown";
@@ -18,6 +19,7 @@ import Document from "models/Document";
import Revision from "models/Revision";
import DocumentMove from "scenes/DocumentMove";
import Branding from "components/Branding";
import ConnectionStatus from "components/ConnectionStatus";
import ErrorBoundary from "components/ErrorBoundary";
import Flex from "components/Flex";
import LoadingIndicator from "components/LoadingIndicator";
@@ -113,15 +115,31 @@ class DocumentScene extends React.Component<Props> {
);
}
}
if (document.injectTemplate) {
document.injectTemplate = false;
this.title = document.title;
this.isDirty = true;
this.updateIsDirty();
}
}
onSelectTemplate = (template: Document) => {
this.title = template.title;
this.isDirty = true;
const editorRef = this.editor.current;
if (!editorRef) {
return;
}
const { view, parser } = editorRef;
view.dispatch(
view.state.tr
.setSelection(new AllSelection(view.state.doc))
.replaceSelectionWith(parser.parse(template.text))
);
this.props.document.templateId = template.id;
this.props.document.title = template.title;
this.props.document.text = template.text;
this.updateIsDirty();
};
@keydown("m")
goToMove(ev) {
if (!this.props.readOnly) return;
@@ -197,7 +215,7 @@ class DocumentScene extends React.Component<Props> {
autosave?: boolean,
} = {}
) => {
const { document } = this.props;
const { document, auth } = this.props;
// prevent saves when we are already saving
if (document.isSaving) return;
@@ -219,18 +237,29 @@ class DocumentScene extends React.Component<Props> {
document.title = title;
document.text = text;
document.tasks = getTasks(document.text);
let isNew = !document.id;
this.isSaving = true;
this.isPublishing = !!options.publish;
document.tasks = getTasks(document.text);
try {
const savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
let savedDocument = document;
if (auth.team?.collaborativeEditing) {
// update does not send "text" field to the API, this is a workaround
// while the multiplayer editor is toggleable. Once it's finalized
// this can be cleaned up to single code path
savedDocument = await document.update({
...options,
lastRevision: this.lastRevision,
});
} else {
savedDocument = await document.save({
...options,
lastRevision: this.lastRevision,
});
}
this.isDirty = false;
this.lastRevision = savedDocument.revision;
@@ -275,8 +304,21 @@ class DocumentScene extends React.Component<Props> {
};
onChange = (getEditorText) => {
const { document, auth } = this.props;
this.getEditorText = getEditorText;
// If the multiplayer editor is enabled then we still want to keep the local
// text value in sync as it is used as a cache.
if (auth.team?.collaborativeEditing) {
action(() => {
document.text = this.getEditorText();
document.tasks = getTasks(document.text);
})();
return;
}
// document change while read only is presumed to be a checkbox edit,
// in that case we don't delay in saving for a better user experience.
if (this.props.readOnly) {
@@ -314,7 +356,6 @@ class DocumentScene extends React.Component<Props> {
const isShare = !!shareId;
const value = revision ? revision.text : document.text;
const injectTemplate = document.injectTemplate;
const disableEmbeds =
(team && team.documentEmbeds === false) || document.embedsDisabled;
@@ -323,6 +364,12 @@ class DocumentScene extends React.Component<Props> {
: [];
const showContents = ui.tocVisible && readOnly;
const collaborativeEditing =
team?.collaborativeEditing &&
!document.isArchived &&
!document.isDeleted &&
!revision;
return (
<ErrorBoundary>
<Background
@@ -332,7 +379,7 @@ class DocumentScene extends React.Component<Props> {
auto
>
<Route
path={`${match.url}/move`}
path={`${document.url}/move`}
component={() => (
<Modal
title={`Move ${document.noun}`}
@@ -356,7 +403,11 @@ class DocumentScene extends React.Component<Props> {
{!readOnly && (
<>
<Prompt
when={this.isDirty && !this.isUploading}
when={
this.isDirty &&
!this.isUploading &&
!team?.collaborativeEditing
}
message={t(
`You have unsaved changes.\nAre you sure you want to discard them?`
)}
@@ -383,6 +434,7 @@ class DocumentScene extends React.Component<Props> {
savingIsDisabled={document.isSaving || this.isEmpty}
sharedTree={this.props.sharedTree}
goBack={this.goBack}
onSelectTemplate={this.onSelectTemplate}
onSave={this.onSave}
headings={headings}
/>
@@ -443,11 +495,12 @@ class DocumentScene extends React.Component<Props> {
{showContents && <Contents headings={headings} />}
<Editor
id={document.id}
key={disableEmbeds ? "disabled" : "enabled"}
innerRef={this.editor}
multiplayer={collaborativeEditing}
shareId={shareId}
isDraft={document.isDraft}
template={document.isTemplate}
key={[injectTemplate, disableEmbeds].join("-")}
title={revision ? revision.title : this.title}
document={document}
value={readOnly ? value : undefined}
@@ -492,7 +545,12 @@ class DocumentScene extends React.Component<Props> {
{isShare && !isCustomDomain() && (
<Branding href="//www.getoutline.com?ref=sharelink" />
)}
{!isShare && <KeyboardShortcutsButton />}
{!isShare && (
<>
<KeyboardShortcutsButton />
<ConnectionStatus />
</>
)}
</ErrorBoundary>
);
}

View File

@@ -17,6 +17,7 @@ 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 MultiplayerEditor from "./MultiplayerEditor";
import { isModKey } from "utils/keyboard";
import { documentHistoryUrl } from "utils/routeHelpers";
@@ -27,6 +28,7 @@ type Props = {|
document: Document,
isDraft: boolean,
shareId: ?string,
multiplayer?: boolean,
onSave: ({ done?: boolean, autosave?: boolean, publish?: boolean }) => any,
innerRef: { current: any },
children: React.Node,
@@ -107,10 +109,12 @@ class DocumentEditor extends React.Component<Props> {
innerRef,
children,
policies,
multiplayer,
t,
...rest
} = this.props;
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
const can = policies.abilities(document.id);
const { emoji } = parseTitle(title);
const startsWithEmojiAndSpace = !!(emoji && title.startsWith(`${emoji} `));
@@ -162,7 +166,7 @@ class DocumentEditor extends React.Component<Props> {
}
/>
)}
<Editor
<EditorComponent
ref={innerRef}
autoFocus={!!title && !this.props.defaultValue}
placeholder={t("…the rest is up to you")}

View File

@@ -41,6 +41,7 @@ type Props = {|
isPublishing: boolean,
publishingIsDisabled: boolean,
savingIsDisabled: boolean,
onSelectTemplate: (template: Document) => void,
onDiscard: () => void,
onSave: ({
done?: boolean,
@@ -61,6 +62,7 @@ function DocumentHeader({
savingIsDisabled,
publishingIsDisabled,
sharedTree,
onSelectTemplate,
onSave,
headings,
}: Props) {
@@ -167,7 +169,10 @@ function DocumentHeader({
/>
{isEditing && !isTemplate && isNew && (
<Action>
<TemplatesMenu document={document} />
<TemplatesMenu
document={document}
onSelectTemplate={onSelectTemplate}
/>
</Action>
)}
{!isEditing && (!isMobile || !isTemplate) && (

View File

@@ -27,12 +27,7 @@ function KeyboardShortcutsButton() {
>
<KeyboardShortcuts />
</Guide>
<Tooltip
tooltip={t("Keyboard shortcuts")}
shortcut="?"
placement="left"
delay={500}
>
<Tooltip tooltip={t("Keyboard shortcuts")} shortcut="?" delay={500}>
<Button onClick={handleOpenKeyboardShortcuts}>
<KeyboardIcon />
</Button>

View File

@@ -0,0 +1,144 @@
// @flow
import { HocuspocusProvider } from "@hocuspocus/provider";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
import Editor, { type Props as EditorProps } from "components/Editor";
import PlaceholderDocument from "components/PlaceholderDocument";
import env from "env";
import useCurrentToken from "hooks/useCurrentToken";
import useCurrentUser from "hooks/useCurrentUser";
import useStores from "hooks/useStores";
import useToasts from "hooks/useToasts";
import useUnmount from "hooks/useUnmount";
import MultiplayerExtension from "multiplayer/MultiplayerExtension";
import { homeUrl } from "utils/routeHelpers";
type Props = {|
...EditorProps,
id: string,
|};
function MultiplayerEditor(props: Props, ref: any) {
const documentId = props.id;
const history = useHistory();
const { t } = useTranslation();
const currentUser = useCurrentUser();
const { presence, ui } = useStores();
const token = useCurrentToken();
const [localProvider, setLocalProvider] = React.useState();
const [remoteProvider, setRemoteProvider] = React.useState();
const [isLocalSynced, setLocalSynced] = React.useState(false);
const [isRemoteSynced, setRemoteSynced] = React.useState(false);
const [ydoc] = React.useState(() => new Y.Doc());
const { showToast } = useToasts();
// Provider initialization must be within useLayoutEffect rather than useState
// or useMemo as both of these are ran twice in React StrictMode resulting in
// an orphaned websocket connection.
// see: https://github.com/facebook/react/issues/20090#issuecomment-715926549
React.useLayoutEffect(() => {
const debug = env.ENVIRONMENT === "development";
const name = `document.${documentId}`;
const localProvider = new IndexeddbPersistence(name, ydoc);
const provider = new HocuspocusProvider({
url: `${env.COLLABORATION_URL}/collaboration`,
debug,
name,
document: ydoc,
token,
maxReconnectTimeout: 10000,
});
provider.on("authenticationFailed", () => {
showToast(
t(
"Sorry, it looks like you dont have permission to access the document"
)
);
history.replace(homeUrl());
});
provider.on("awarenessChange", ({ states }) => {
states.forEach(({ user }) => {
if (user) {
// could know if the user is editing here using `state.cursor` but it
// feels distracting in the UI, once multiplayer is on for everyone we
// can stop diffentiating
presence.touch(documentId, user.id, false);
}
});
});
localProvider.on("synced", () => setLocalSynced(true));
provider.on("synced", () => setRemoteSynced(true));
if (debug) {
provider.on("status", (ev) => console.log("status", ev.status));
provider.on("message", (ev) => console.log("incoming", ev.message));
provider.on("outgoingMessage", (ev) =>
console.log("outgoing", ev.message)
);
localProvider.on("synced", (ev) => console.log("local synced"));
}
provider.on("status", (ev) => ui.setMultiplayerStatus(ev.status));
setRemoteProvider(provider);
setLocalProvider(localProvider);
}, [history, showToast, t, documentId, ui, presence, token, ydoc]);
const user = React.useMemo(() => {
return {
id: currentUser.id,
name: currentUser.name,
color: currentUser.color,
};
}, [currentUser.id, currentUser.color, currentUser.name]);
const extensions = React.useMemo(() => {
if (!remoteProvider) {
return [];
}
return [
new MultiplayerExtension({
user,
provider: remoteProvider,
document: ydoc,
}),
];
}, [remoteProvider, user, ydoc]);
useUnmount(() => {
remoteProvider?.destroy();
localProvider?.destroy();
ui.setMultiplayerStatus(undefined);
});
if (!extensions.length) {
return null;
}
if (isLocalSynced && !isRemoteSynced && !ydoc.get("default")._start) {
return <PlaceholderDocument includeTitle={false} delay={500} />;
}
return (
<Editor
{...props}
value={undefined}
defaultValue={undefined}
extensions={extensions}
ref={ref}
/>
);
}
export default React.forwardRef<any, typeof MultiplayerEditor>(
MultiplayerEditor
);