import { observer } from "mobx-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { mergeRefs } from "react-merge-refs"; import { useRouteMatch } from "react-router-dom"; import styled from "styled-components"; import Text from "@shared/components/Text"; import { richExtensions, withComments } from "@shared/editor/nodes"; import { TeamPreference } from "@shared/types"; import { colorPalette } from "@shared/utils/collections"; import Comment from "~/models/Comment"; import Document from "~/models/Document"; import { RefHandle } from "~/components/ContentEditable"; import { useDocumentContext } from "~/components/DocumentContext"; import Editor, { Props as EditorProps } from "~/components/Editor"; import Flex from "~/components/Flex"; import Time from "~/components/Time"; import { withUIExtensions } from "~/editor/extensions"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useCurrentUser from "~/hooks/useCurrentUser"; import { useFocusedComment } from "~/hooks/useFocusedComment"; import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext"; import usePolicy from "~/hooks/usePolicy"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; import { documentHistoryPath, documentPath, matchDocumentHistory, } from "~/utils/routeHelpers"; import { decodeURIComponentSafe } from "~/utils/urls"; import MultiplayerEditor from "./AsyncMultiplayerEditor"; import DocumentMeta from "./DocumentMeta"; import DocumentTitle from "./DocumentTitle"; import first from "lodash/first"; const extensions = withUIExtensions(withComments(richExtensions)); type Props = Omit & { onChangeTitle: (title: string) => void; onChangeIcon: (icon: string | null, color: string | null) => void; id: string; document: Document; isDraft: boolean; multiplayer?: boolean; onSave: (options: { done?: boolean; autosave?: boolean; publish?: boolean; }) => void; children: React.ReactNode; }; /** * The main document editor includes an editable title with metadata below it, * and support for commenting. */ function DocumentEditor(props: Props, ref: React.RefObject) { const titleRef = React.useRef(null); const { t } = useTranslation(); const match = useRouteMatch(); const { setFocusedCommentId } = useDocumentContext(); const focusedComment = useFocusedComment(); const { ui, comments } = useStores(); const user = useCurrentUser({ rejectOnEmpty: false }); const team = useCurrentTeam({ rejectOnEmpty: false }); const sidebarContext = useLocationSidebarContext(); const params = useQuery(); const { document, onChangeTitle, onChangeIcon, isDraft, shareId, readOnly, children, multiplayer, ...rest } = props; const can = usePolicy(document); const commentingEnabled = !!team?.getPreference(TeamPreference.Commenting); const iconColor = document.color ?? (first(colorPalette) as string); const childRef = React.useRef(null); const focusAtStart = React.useCallback(() => { if (ref.current) { ref.current.focusAtStart(); } }, [ref]); React.useEffect(() => { if (focusedComment) { const viewingResolved = params.get("resolved") === ""; if ( (focusedComment.isResolved && !viewingResolved) || (!focusedComment.isResolved && viewingResolved) ) { setFocusedCommentId(focusedComment.id); } ui.set({ commentsExpanded: true }); } }, [focusedComment, ui, document.id, params]); // Save document when blurring title, but delay so that if clicking on a // button this is allowed to execute first. const handleBlur = React.useCallback(() => { setTimeout(() => props.onSave({ autosave: true }), 250); }, [props]); const handleGoToNextInput = React.useCallback( (insertParagraph: boolean) => { if (insertParagraph && ref.current) { const { view } = ref.current; const { dispatch, state } = view; dispatch(state.tr.insert(0, state.schema.nodes.paragraph.create())); } focusAtStart(); }, [focusAtStart, ref] ); // Create a Comment model in local store when a comment mark is created, this // acts as a local draft before submission. const handleDraftComment = React.useCallback( (commentId: string, createdById: string) => { if (comments.get(commentId) || createdById !== user?.id) { return; } const comment = new Comment( { documentId: props.id, createdAt: new Date(), createdById, reactions: [], }, comments ); comment.id = commentId; comments.add(comment); setFocusedCommentId(commentId); }, [comments, user?.id, props.id] ); // Soft delete the Comment model when associated mark is totally removed. const handleRemoveComment = React.useCallback( async (commentId: string) => { const comment = comments.get(commentId); if (comment?.isNew) { await comment?.delete(); } }, [comments] ); const { setEditor, setEditorInitialized, updateState: updateDocState, } = useDocumentContext(); const handleRefChanged = React.useCallback(setEditor, [setEditor]); const EditorComponent = multiplayer ? MultiplayerEditor : Editor; const childOffsetHeight = childRef.current?.offsetHeight || 0; const editorStyle = React.useMemo( () => ({ padding: "0 32px", margin: "0 -32px", paddingBottom: `calc(50vh - ${childOffsetHeight}px)`, }), [childOffsetHeight] ); const handleInit = React.useCallback( () => setEditorInitialized(true), [setEditorInitialized] ); const handleDestroy = React.useCallback( () => setEditorInitialized(false), [setEditorInitialized] ); const direction = titleRef.current?.getComputedDirection(); return ( {shareId ? ( document.updatedAt ? ( {t("Last updated")} ) : null ) : ( )}
{children}
); } const SharedMeta = styled(Text)` margin: -12px 0 2em 0; font-size: 14px; `; export default observer(React.forwardRef(DocumentEditor));