diff --git a/app/actions/definitions/documents.tsx b/app/actions/definitions/documents.tsx index 78dd82bbf..7cfe17ce8 100644 --- a/app/actions/definitions/documents.tsx +++ b/app/actions/definitions/documents.tsx @@ -22,9 +22,10 @@ import { LightBulbIcon, UnpublishIcon, PublishIcon, + CommentIcon, } from "outline-icons"; import * as React from "react"; -import { ExportContentType } from "@shared/types"; +import { ExportContentType, TeamPreference } from "@shared/types"; import { getEventFiles } from "@shared/utils/files"; import DocumentDelete from "~/scenes/DocumentDelete"; import DocumentMove from "~/scenes/DocumentMove"; @@ -466,7 +467,7 @@ export const printDocument = createAction({ icon: , visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print), perform: async () => { - window.print(); + queueMicrotask(window.print); }, }); @@ -708,6 +709,29 @@ export const permanentlyDeleteDocument = createAction({ }, }); +export const openDocumentComments = createAction({ + name: ({ t }) => t("Comments"), + analyticsName: "Open comments", + section: DocumentSection, + icon: , + visible: ({ activeDocumentId, stores }) => { + const can = stores.policies.abilities(activeDocumentId ?? ""); + return ( + !!activeDocumentId && + can.read && + !can.restore && + !!stores.auth.team?.getPreference(TeamPreference.Commenting) + ); + }, + perform: ({ activeDocumentId, stores }) => { + if (!activeDocumentId) { + return; + } + + stores.ui.toggleComments(); + }, +}); + export const openDocumentHistory = createAction({ name: ({ t }) => t("History"), analyticsName: "Open document history", @@ -771,6 +795,7 @@ export const rootDocumentActions = [ printDocument, pinDocumentToCollection, pinDocumentToHome, + openDocumentComments, openDocumentHistory, openDocumentInsights, ]; diff --git a/app/components/Editor.tsx b/app/components/Editor.tsx index 0459e7133..94ba811d4 100644 --- a/app/components/Editor.tsx +++ b/app/components/Editor.tsx @@ -21,7 +21,6 @@ import HoverPreview from "~/components/HoverPreview"; import type { Props as EditorProps, Editor as SharedEditor } from "~/editor"; import useDictionary from "~/hooks/useDictionary"; import useEmbeds from "~/hooks/useEmbeds"; -import useFocusedComment from "~/hooks/useFocusedComment"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; import { NotFoundError } from "~/utils/errors"; @@ -61,7 +60,6 @@ function Editor(props: Props, ref: React.RefObject | null) { onDeleteCommentMark, } = props; const { auth, comments, documents } = useStores(); - const focusedComment = useFocusedComment(); const { showToast } = useToasts(); const dictionary = useDictionary(); const embeds = useEmbeds(!shareId); @@ -343,7 +341,6 @@ function Editor(props: Props, ref: React.RefObject | null) { onChange={handleChange} placeholder={props.placeholder || ""} defaultValue={props.defaultValue || ""} - focusedCommentId={focusedComment?.id} /> {props.bottomPadding && !props.readOnly && ( (undefined); + +/** + * A portal component that uses context to render into a different dom node + * or the root of body if no context is available. + */ +export function Portal(props: { children: React.ReactNode }) { + const node = React.useContext(PortalContext); + return {props.children}; +} diff --git a/app/editor/components/CommandMenu.tsx b/app/editor/components/CommandMenu.tsx index 5abe98851..57fed095f 100644 --- a/app/editor/components/CommandMenu.tsx +++ b/app/editor/components/CommandMenu.tsx @@ -3,7 +3,6 @@ import { findDomRefAtPos, findParentNode } from "prosemirror-utils"; import { EditorView } from "prosemirror-view"; import * as React from "react"; import { Trans } from "react-i18next"; -import { Portal } from "react-portal"; import { VisuallyHidden } from "reakit/VisuallyHidden"; import styled from "styled-components"; import insertFiles from "@shared/editor/commands/insertFiles"; @@ -14,6 +13,7 @@ import { MenuItem } from "@shared/editor/types"; import { depths } from "@shared/styles"; import { getEventFiles } from "@shared/utils/files"; import { AttachmentValidation } from "@shared/validations"; +import { Portal } from "~/components/Portal"; import Scrollable from "~/components/Scrollable"; import { Dictionary } from "~/hooks/useDictionary"; import Input from "./Input"; @@ -406,7 +406,16 @@ class CommandMenu extends React.Component, State> { const { top, bottom, right } = paragraph.node.getBoundingClientRect(); const margin = 24; - let leftPos = left + window.scrollX; + const offsetParent = ref?.offsetParent + ? ref.offsetParent.getBoundingClientRect() + : ({ + width: 0, + height: 0, + top: 0, + left: 0, + } as DOMRect); + + let leftPos = left - offsetParent.left; if (props.rtl && ref) { leftPos = right - ref.scrollWidth; } @@ -414,14 +423,14 @@ class CommandMenu extends React.Component, State> { if (startPos.top - offsetHeight > margin) { return { left: leftPos, - top: undefined, - bottom: window.innerHeight - top - window.scrollY, + top: top - offsetParent.top - offsetHeight, + bottom: undefined, isAbove: false, }; } else { return { left: leftPos, - top: bottom + window.scrollY, + top: bottom - offsetParent.top, bottom: undefined, isAbove: true, }; diff --git a/app/editor/components/FloatingToolbar.tsx b/app/editor/components/FloatingToolbar.tsx index c288d5b9a..b903f3f96 100644 --- a/app/editor/components/FloatingToolbar.tsx +++ b/app/editor/components/FloatingToolbar.tsx @@ -1,9 +1,9 @@ import { NodeSelection } from "prosemirror-state"; import { CellSelection } from "prosemirror-tables"; import * as React from "react"; -import { Portal } from "react-portal"; import styled from "styled-components"; import { depths } from "@shared/styles"; +import { Portal } from "~/components/Portal"; import useComponentSize from "~/hooks/useComponentSize"; import useEventListener from "~/hooks/useEventListener"; import useMediaQuery from "~/hooks/useMediaQuery"; @@ -80,6 +80,15 @@ function usePosition({ right: Math.max(fromPos.right, toPos.right), }; + const offsetParent = menuRef.current.offsetParent + ? menuRef.current.offsetParent.getBoundingClientRect() + : ({ + width: 0, + height: 0, + top: 0, + left: 0, + } as DOMRect); + // tables are an oddity, and need their own positioning logic const isColSelection = selection instanceof CellSelection && @@ -116,8 +125,8 @@ function usePosition({ const { left, top, width } = imageElement.getBoundingClientRect(); return { - left: Math.round(left + width / 2 + window.scrollX - menuWidth / 2), - top: Math.round(top + window.scrollY - menuHeight), + left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left), + top: Math.round(top - menuHeight - offsetParent.top), offset: 0, visible: true, }; @@ -145,8 +154,8 @@ function usePosition({ // of the selection still const offset = left - (centerOfSelection - menuWidth / 2); return { - left: Math.round(left + window.scrollX), - top: Math.round(top + window.scrollY), + left: Math.round(left - offsetParent.left), + top: Math.round(top - offsetParent.top), offset: Math.round(offset), visible: true, }; diff --git a/app/editor/components/ToolbarButton.tsx b/app/editor/components/ToolbarButton.tsx index 7c059bbe8..049bbeabc 100644 --- a/app/editor/components/ToolbarButton.tsx +++ b/app/editor/components/ToolbarButton.tsx @@ -2,7 +2,9 @@ import styled from "styled-components"; type Props = { active?: boolean; disabled?: boolean }; -export default styled.button` +export default styled.button.attrs((props) => ({ + type: props.type || "button", +}))` display: inline-block; flex: 0; width: 24px; diff --git a/app/editor/index.tsx b/app/editor/index.tsx index be796cc48..e17e34ee6 100644 --- a/app/editor/index.tsx +++ b/app/editor/index.tsx @@ -32,6 +32,7 @@ import { UserPreferences } from "@shared/types"; import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper"; import EventEmitter from "@shared/utils/events"; import Flex from "~/components/Flex"; +import { PortalContext } from "~/components/Portal"; import { Dictionary } from "~/hooks/useDictionary"; import Logger from "~/utils/Logger"; import BlockMenu from "./components/BlockMenu"; @@ -178,7 +179,8 @@ export class Editor extends React.PureComponent< isBlurred: boolean; extensions: ExtensionManager; - element = React.createRef(); + elementRef = React.createRef(); + wrapperRef = React.createRef(); view: EditorView; schema: Schema; serializer: MarkdownSerializer; @@ -435,7 +437,7 @@ export class Editor extends React.PureComponent< } private createView() { - if (!this.element.current) { + if (!this.elementRef.current) { throw new Error("createView called before ref available"); } @@ -448,7 +450,7 @@ export class Editor extends React.PureComponent< }; const self = this; // eslint-disable-line - const view = new EditorView(this.element.current, { + const view = new EditorView(this.elementRef.current, { handleDOMEvents: { blur: this.handleEditorBlur, focus: this.handleEditorFocus, @@ -521,13 +523,13 @@ export class Editor extends React.PureComponent< }; private calculateDir = () => { - if (!this.element.current) { + if (!this.elementRef.current) { return; } const isRTL = this.props.dir === "rtl" || - getComputedStyle(this.element.current).direction === "rtl"; + getComputedStyle(this.elementRef.current).direction === "rtl"; if (this.state.isRTL !== isRTL) { this.setState({ isRTL }); @@ -718,75 +720,78 @@ export class Editor extends React.PureComponent< const { isRTL } = this.state; return ( - - - - {!readOnly && this.view && ( - <> - - - - - - )} - - + + + + + {!readOnly && this.view && ( + <> + + + + + + )} + + + ); } } diff --git a/app/menus/CommentMenu.tsx b/app/menus/CommentMenu.tsx index c3a18a841..a561a80dc 100644 --- a/app/menus/CommentMenu.tsx +++ b/app/menus/CommentMenu.tsx @@ -9,6 +9,7 @@ import ContextMenu from "~/components/ContextMenu"; import MenuItem from "~/components/ContextMenu/MenuItem"; import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton"; import Separator from "~/components/ContextMenu/Separator"; +import EventBoundary from "~/components/EventBoundary"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; @@ -52,11 +53,14 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) { return ( <> - + + + + {can.update && ( diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 15e0583a9..8d7d562ba 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -1,10 +1,5 @@ import { observer } from "mobx-react"; -import { - EditIcon, - PrintIcon, - NewDocumentIcon, - RestoreIcon, -} from "outline-icons"; +import { EditIcon, NewDocumentIcon, RestoreIcon } from "outline-icons"; import * as React from "react"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; @@ -40,6 +35,8 @@ import { openDocumentInsights, publishDocument, unpublishDocument, + printDocument, + openDocumentComments, } from "~/actions/definitions/documents"; import useActionContext from "~/hooks/useActionContext"; import useCurrentTeam from "~/hooks/useCurrentTeam"; @@ -125,11 +122,6 @@ function DocumentMenu({ [showToast, t, document] ); - const handlePrint = React.useCallback(() => { - menu.hide(); - window.print(); - }, [menu]); - const collection = collections.get(document.collectionId); const can = usePolicy(document); const restoreItems = React.useMemo( @@ -291,16 +283,11 @@ function DocumentMenu({ { type: "separator", }, - actionToMenuItem(downloadDocument, context), + actionToMenuItem(openDocumentComments, context), actionToMenuItem(openDocumentHistory, context), actionToMenuItem(openDocumentInsights, context), - { - type: "button", - title: t("Print"), - onClick: handlePrint, - visible: !!showDisplayOptions, - icon: , - }, + actionToMenuItem(downloadDocument, context), + actionToMenuItem(printDocument, context), { type: "separator", }, diff --git a/app/scenes/Document/components/CommentForm.tsx b/app/scenes/Document/components/CommentForm.tsx index 55f7e5fed..5e83a81b8 100644 --- a/app/scenes/Document/components/CommentForm.tsx +++ b/app/scenes/Document/components/CommentForm.tsx @@ -22,7 +22,7 @@ type Props = { /** The document that the comment will be associated with */ documentId: string; /** The comment thread that the comment will be associated with */ - thread: Comment; + thread?: Comment; /** Placeholder text to display in the editor */ placeholder?: string; /** Whether to focus the editor on mount */ @@ -59,20 +59,22 @@ function CommentForm({ }: Props) { const { editor } = useDocumentContext(); const [data, setData] = usePersistedState | undefined>( - `draft-${documentId}-${thread.id}`, + `draft-${documentId}-${thread?.id ?? "new"}`, undefined ); const formRef = React.useRef(null); const editorRef = React.useRef(null); const [forceRender, setForceRender] = React.useState(0); + const [inputFocused, setInputFocused] = React.useState(false); const { t } = useTranslation(); const { showToast } = useToasts(); const { comments } = useStores(); const user = useCurrentUser(); - const isEmpty = editorRef.current?.isEmpty() ?? true; useOnClickOutside(formRef, () => { - if (isEmpty && thread.isNew) { + const isEmpty = editorRef.current?.isEmpty() ?? true; + + if (isEmpty && thread?.isNew) { if (thread.id) { editor?.removeComment(thread.id); } @@ -86,19 +88,29 @@ function CommentForm({ setData(undefined); setForceRender((s) => ++s); - thread + const comment = + thread ?? + new Comment( + { + documentId, + data, + }, + comments + ); + + comment .save({ documentId, data, }) .catch(() => { - thread.isNew = true; + comment.isNew = true; showToast(t("Error creating comment"), { type: "error" }); }); // optimistically update the comment model - thread.isNew = false; - thread.createdBy = user; + comment.isNew = false; + comment.createdBy = user; }); const handleCreateReply = async (event: React.FormEvent) => { @@ -145,6 +157,16 @@ function CommentForm({ setForceRender((s) => ++s); }; + const handleFocus = () => { + onFocus?.(); + setInputFocused(true); + }; + + const handleBlur = () => { + onBlur?.(); + setInputFocused(false); + }; + // Focus the editor when it's a new comment just mounted, after a delay as the // editor is mounted within a fade transition. React.useEffect(() => { @@ -199,21 +221,23 @@ function CommentForm({ ref={editorRef} onChange={handleChange} onSave={handleSave} - onFocus={onFocus} - onBlur={onBlur} + onFocus={handleFocus} + onBlur={handleBlur} maxLength={CommentValidation.maxLength} placeholder={ placeholder || // isNew is only the case for comments that exist in draft state, // they are marks in the document, but not yet saved to the db. - (thread.isNew ? `${t("Add a comment")}…` : `${t("Add a reply")}…`) + (thread?.isNew + ? `${t("Add a comment")}…` + : `${t("Add a reply")}…`) } /> - {!isEmpty && ( + {inputFocused && ( - {thread.isNew ? t("Post") : t("Reply")} + {thread && !thread.isNew ? t("Reply") : t("Post")} {t("Cancel")} diff --git a/app/scenes/Document/components/Comments.tsx b/app/scenes/Document/components/Comments.tsx index 6b87db204..76e3ab39d 100644 --- a/app/scenes/Document/components/Comments.tsx +++ b/app/scenes/Document/components/Comments.tsx @@ -4,7 +4,6 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; import { useRouteMatch } from "react-router-dom"; import styled from "styled-components"; -import Comment from "~/models/Comment"; import Empty from "~/components/Empty"; import Flex from "~/components/Flex"; import useCurrentUser from "~/hooks/useCurrentUser"; @@ -16,7 +15,6 @@ import Sidebar from "./SidebarLayout"; function Comments() { const { ui, comments, documents } = useStores(); - const [newComment] = React.useState(new Comment({}, comments)); const { t } = useTranslation(); const user = useCurrentUser(); const match = useRouteMatch<{ documentSlug: string }>(); @@ -54,9 +52,7 @@ function Comments() { {!focusedComment && ( ) { const titleRef = React.useRef(null); const { t } = useTranslation(); const match = useRouteMatch(); + const focusedComment = useFocusedComment(); const { ui, comments, auth } = useStores(); const { user, team } = auth; const history = useHistory(); @@ -91,13 +93,13 @@ function DocumentEditor(props: Props, ref: React.RefObject) { pathname: window.location.pathname.replace(/\/history$/, ""), state: { commentId }, }); - } else { + } else if (focusedComment) { history.replace({ pathname: window.location.pathname, }); } }, - [ui, history] + [ui, focusedComment, history] ); // Create a Comment model in local store when a comment mark is created, this @@ -177,6 +179,7 @@ function DocumentEditor(props: Props, ref: React.RefObject) { readOnly={readOnly} shareId={shareId} userId={user?.id} + focusedCommentId={focusedComment?.id} onClickCommentMark={handleClickComment} onCreateCommentMark={ team?.getPreference(TeamPreference.Commenting) diff --git a/app/scenes/Document/components/Insights.tsx b/app/scenes/Document/components/Insights.tsx index bd4a40b21..6cd57aa70 100644 --- a/app/scenes/Document/components/Insights.tsx +++ b/app/scenes/Document/components/Insights.tsx @@ -83,7 +83,7 @@ function Insights() { - {t("Collaborators")} + {t("Contributors")} {t(`Created`)} ( ) => {