feat: Comments (#4911)
* Comment model * Framework, model, policy, presenter, api endpoint etc * Iteration, first pass of UI * fixes, refactors * Comment commands * comment socket support * typing indicators * comment component, styling * wip * right sidebar resize * fix: CMD+Enter submit * Add usePersistedState fix: Main page scrolling on comment highlight * drafts * Typing indicator * refactor * policies * Click thread to highlight Improve comment timestamps * padding * Comment menu v1 * Change comments to use editor * Basic comment editing * fix: Hide commenting button when disabled at team level * Enable opening sidebar without mark * Move selected comment to location state * Add comment delete confirmation * Add comment count to document meta * fix: Comment sidebar togglable Add copy link to comment * stash * Restore History changes * Refactor right sidebar to allow for comment animation * Update to new router best practices * stash * Various improvements * stash * Handle click outside * Fix incorrect placeholder in input fix: Input box appearing on other sessions erroneously * stash * fix: Don't leave orphaned child comments * styling * stash * Enable comment toggling again * Edit styling, merge conflicts * fix: Cannot navigate from insights to comments * Remove draft comment mark on click outside * Fix: Empty comment sidebar, tsc * Remove public toggle * fix: All comments are recessed fix: Comments should not be printed * fix: Associated mark should be removed on comment delete * Revert unused changes * Empty state, basic RTL support * Create dont toggle comment mark * Make it feel more snappy * Highlight active comment in text * fix animation * RTL support * Add reply CTA * Translations
This commit is contained in:
@@ -2,6 +2,7 @@ import { AnimatePresence } from "framer-motion";
|
||||
import { observer, useLocalStore } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||
import DocumentContext from "~/components/DocumentContext";
|
||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||
@@ -16,14 +17,17 @@ import useStores from "~/hooks/useStores";
|
||||
import history from "~/utils/history";
|
||||
import {
|
||||
searchPath,
|
||||
matchDocumentSlug as slug,
|
||||
newDocumentPath,
|
||||
settingsPath,
|
||||
matchDocumentHistory,
|
||||
matchDocumentSlug as slug,
|
||||
matchDocumentInsights,
|
||||
} from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
const DocumentComments = React.lazy(
|
||||
() => import("~/scenes/Document/components/Comments")
|
||||
);
|
||||
const DocumentHistory = React.lazy(
|
||||
() => import("~/scenes/Document/components/History")
|
||||
);
|
||||
@@ -84,15 +88,21 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
||||
const showInsights = !!matchPath(location.pathname, {
|
||||
path: matchDocumentInsights,
|
||||
});
|
||||
const showComments =
|
||||
!showInsights &&
|
||||
!showHistory &&
|
||||
!ui.commentsCollapsed &&
|
||||
team?.getPreference(TeamPreference.Commenting);
|
||||
|
||||
const sidebarRight = (
|
||||
<AnimatePresence key={ui.activeDocumentId}>
|
||||
{(showHistory || showInsights) && (
|
||||
<AnimatePresence>
|
||||
{(showHistory || showInsights || showComments) && (
|
||||
<Route path={`/doc/${slug}`}>
|
||||
<SidebarRight>
|
||||
<React.Suspense fallback={null}>
|
||||
{showHistory && <DocumentHistory />}
|
||||
{showInsights && <DocumentInsights />}
|
||||
{showComments && <DocumentComments />}
|
||||
</React.Suspense>
|
||||
</SidebarRight>
|
||||
</Route>
|
||||
|
||||
@@ -19,15 +19,16 @@ type Props = {
|
||||
showBorder?: boolean;
|
||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
function Avatar(props: Props) {
|
||||
const { icon, showBorder, model, ...rest } = props;
|
||||
const { icon, showBorder, model, style, ...rest } = props;
|
||||
const src = props.src || model?.avatarUrl;
|
||||
const [error, handleError] = useBoolean(false);
|
||||
|
||||
return (
|
||||
<Relative>
|
||||
<Relative style={style}>
|
||||
{src && !error ? (
|
||||
<CircleImg
|
||||
onError={handleError}
|
||||
@@ -53,6 +54,7 @@ Avatar.defaultProps = {
|
||||
|
||||
const Relative = styled.div`
|
||||
position: relative;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
|
||||
15
app/components/ButtonSmall.ts
Normal file
15
app/components/ButtonSmall.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import styled from "styled-components";
|
||||
import Button, { Inner } from "./Button";
|
||||
|
||||
const ButtonSmall = styled(Button)`
|
||||
font-size: 13px;
|
||||
height: 26px;
|
||||
|
||||
${Inner} {
|
||||
padding: 0 6px;
|
||||
line-height: 26px;
|
||||
min-height: 26px;
|
||||
}
|
||||
`;
|
||||
|
||||
export default ButtonSmall;
|
||||
53
app/components/CommentDeleteDialog.tsx
Normal file
53
app/components/CommentDeleteDialog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import Comment from "~/models/Comment";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
|
||||
type Props = {
|
||||
comment: Comment;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
function CommentDeleteDialog({ comment, onSubmit }: Props) {
|
||||
const { comments } = useStores();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const hasChildComments = comments.inThread(comment.id).length > 1;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await comment.delete();
|
||||
onSubmit?.();
|
||||
} catch (err) {
|
||||
showToast(err.message, { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmationDialog
|
||||
onSubmit={handleSubmit}
|
||||
submitText={t("I’m sure – Delete")}
|
||||
savingText={`${t("Deleting")}…`}
|
||||
danger
|
||||
>
|
||||
<Text type="secondary">
|
||||
{hasChildComments ? (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this entire comment
|
||||
thread?
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans>
|
||||
Are you sure you want to permanently delete this comment?
|
||||
</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</ConfirmationDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CommentDeleteDialog);
|
||||
@@ -39,8 +39,8 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
margin: 24px;
|
||||
transform: translateX(-32px);
|
||||
|
||||
${breakpoint("tablet")`
|
||||
display: block;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { deburr, sortBy } from "lodash";
|
||||
import { deburr, difference, sortBy } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||
import { TextSelection } from "prosemirror-state";
|
||||
import * as React from "react";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { Optional } from "utility-types";
|
||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { AttachmentPreset } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { getDataTransferFiles } from "@shared/utils/files";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { isInternalUrl } from "@shared/utils/urls";
|
||||
@@ -20,11 +21,11 @@ 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";
|
||||
import { uploadFile } from "~/utils/files";
|
||||
import history from "~/utils/history";
|
||||
import { isModKey } from "~/utils/keyboard";
|
||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||
import { isHash } from "~/utils/urls";
|
||||
@@ -51,11 +52,20 @@ export type Props = Optional<
|
||||
};
|
||||
|
||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
||||
const { documents, auth } = useStores();
|
||||
const {
|
||||
id,
|
||||
shareId,
|
||||
onChange,
|
||||
onHeadingsChange,
|
||||
onCreateCommentMark,
|
||||
onDeleteCommentMark,
|
||||
} = props;
|
||||
const { auth, comments, documents } = useStores();
|
||||
const focusedComment = useFocusedComment();
|
||||
const { showToast } = useToasts();
|
||||
const dictionary = useDictionary();
|
||||
const embeds = useEmbeds(!shareId);
|
||||
const history = useHistory();
|
||||
const localRef = React.useRef<SharedEditor>();
|
||||
const preferences = auth.user?.preferences;
|
||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||
@@ -63,6 +73,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
activeLinkElement,
|
||||
setActiveLink,
|
||||
] = React.useState<HTMLAnchorElement | null>(null);
|
||||
const previousCommentIds = React.useRef<string[]>();
|
||||
|
||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||
setActiveLink(element);
|
||||
@@ -125,7 +136,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[documents]
|
||||
);
|
||||
|
||||
const onUploadFile = React.useCallback(
|
||||
const handleUploadFile = React.useCallback(
|
||||
async (file: File) => {
|
||||
const result = await uploadFile(file, {
|
||||
documentId: id,
|
||||
@@ -136,7 +147,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
[id]
|
||||
);
|
||||
|
||||
const onClickLink = React.useCallback(
|
||||
const handleClickLink = React.useCallback(
|
||||
(href: string, event: MouseEvent) => {
|
||||
// on page hash
|
||||
if (isHash(href)) {
|
||||
@@ -175,7 +186,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
window.open(href, "_blank");
|
||||
}
|
||||
},
|
||||
[shareId]
|
||||
[history, shareId]
|
||||
);
|
||||
|
||||
const focusAtEnd = React.useCallback(() => {
|
||||
@@ -223,7 +234,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
);
|
||||
|
||||
insertFiles(view, event, pos, files, {
|
||||
uploadFile: onUploadFile,
|
||||
uploadFile: handleUploadFile,
|
||||
onFileUploadStart: props.onFileUploadStart,
|
||||
onFileUploadStop: props.onFileUploadStop,
|
||||
onShowToast: showToast,
|
||||
@@ -236,7 +247,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
props.onFileUploadStart,
|
||||
props.onFileUploadStop,
|
||||
dictionary,
|
||||
onUploadFile,
|
||||
handleUploadFile,
|
||||
showToast,
|
||||
]
|
||||
);
|
||||
@@ -265,21 +276,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
}
|
||||
}, [localRef, onHeadingsChange]);
|
||||
|
||||
const updateComments = React.useCallback(() => {
|
||||
if (onCreateCommentMark && onDeleteCommentMark) {
|
||||
const commentMarks = localRef.current?.getComments();
|
||||
const commentIds = comments.orderedData.map((c) => c.id);
|
||||
const commentMarkIds = commentMarks?.map((c) => c.id);
|
||||
const newCommentIds = difference(
|
||||
commentMarkIds,
|
||||
previousCommentIds.current ?? [],
|
||||
commentIds
|
||||
);
|
||||
|
||||
newCommentIds.forEach((commentId) => {
|
||||
const mark = commentMarks?.find((c) => c.id === commentId);
|
||||
if (mark) {
|
||||
onCreateCommentMark(mark.id, mark.userId);
|
||||
}
|
||||
});
|
||||
|
||||
const removedCommentIds = difference(
|
||||
previousCommentIds.current ?? [],
|
||||
commentMarkIds ?? []
|
||||
);
|
||||
|
||||
removedCommentIds.forEach((commentId) => {
|
||||
onDeleteCommentMark(commentId);
|
||||
});
|
||||
|
||||
previousCommentIds.current = commentMarkIds;
|
||||
}
|
||||
}, [onCreateCommentMark, onDeleteCommentMark, comments.orderedData]);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(event) => {
|
||||
onChange?.(event);
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
},
|
||||
[onChange, updateHeadings]
|
||||
[onChange, updateComments, updateHeadings]
|
||||
);
|
||||
|
||||
const handleRefChanged = React.useCallback(
|
||||
(node: SharedEditor | null) => {
|
||||
if (node) {
|
||||
updateHeadings();
|
||||
updateComments();
|
||||
}
|
||||
},
|
||||
[updateHeadings]
|
||||
[updateComments, updateHeadings]
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -287,18 +331,19 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||
<>
|
||||
<LazyLoadedEditor
|
||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||
uploadFile={onUploadFile}
|
||||
uploadFile={handleUploadFile}
|
||||
onShowToast={showToast}
|
||||
embeds={embeds}
|
||||
userPreferences={preferences}
|
||||
dictionary={dictionary}
|
||||
{...props}
|
||||
onHoverLink={handleLinkActive}
|
||||
onClickLink={onClickLink}
|
||||
onClickLink={handleClickLink}
|
||||
onSearchLink={handleSearchLink}
|
||||
onChange={handleChange}
|
||||
placeholder={props.placeholder || ""}
|
||||
defaultValue={props.defaultValue || ""}
|
||||
focusedCommentId={focusedComment?.id}
|
||||
/>
|
||||
{props.bottomPadding && !props.readOnly && (
|
||||
<ClickablePadding
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Editor from "~/components/Editor";
|
||||
import useStores from "~/hooks/useStores";
|
||||
|
||||
@@ -30,10 +30,7 @@ function HoverPreviewDocument({ url, children }: Props) {
|
||||
{children(
|
||||
<Content to={document.url}>
|
||||
<Heading>{document.titleWithDefault}</Heading>
|
||||
<DocumentMetaWithViews
|
||||
isDraft={document.isDraft}
|
||||
document={document}
|
||||
/>
|
||||
<DocumentMeta document={document} />
|
||||
|
||||
<React.Suspense fallback={<div />}>
|
||||
<Editor
|
||||
|
||||
@@ -121,6 +121,8 @@ export type Props = React.InputHTMLAttributes<
|
||||
margin?: string | number;
|
||||
error?: string;
|
||||
icon?: React.ReactNode;
|
||||
/* Callback is triggered with the CMD+Enter keyboard combo */
|
||||
onRequestSubmit?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||
onBlur?: (ev: React.SyntheticEvent) => unknown;
|
||||
};
|
||||
@@ -147,6 +149,20 @@ function Input(
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (
|
||||
ev: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
if (ev.key === "Enter" && ev.metaKey) {
|
||||
if (this.props.onRequestSubmit) {
|
||||
this.props.onRequestSubmit(ev);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.props.onKeyDown) {
|
||||
this.props.onKeyDown(ev);
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
type = "text",
|
||||
icon,
|
||||
@@ -180,6 +196,7 @@ function Input(
|
||||
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
{...rest}
|
||||
/>
|
||||
@@ -188,6 +205,7 @@ function Input(
|
||||
ref={ref as React.RefObject<HTMLInputElement>}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
hasIcon={!!icon}
|
||||
type={type}
|
||||
{...rest}
|
||||
|
||||
42
app/components/ResizingHeightContainer.tsx
Normal file
42
app/components/ResizingHeightContainer.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { m, TargetAndTransition } from "framer-motion";
|
||||
import * as React from "react";
|
||||
import useComponentSize from "~/hooks/useComponentSize";
|
||||
|
||||
type Props = {
|
||||
/** The children to render */
|
||||
children: React.ReactNode;
|
||||
/** Whether to hide overflow. */
|
||||
hideOverflow?: boolean;
|
||||
/** A way to calculate height */
|
||||
componentSizeCalculation?: "clientRectHeight" | "scrollHeight";
|
||||
/** Optional animation config. */
|
||||
config?: TargetAndTransition;
|
||||
/** Optional styles. */
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically animates the height of a container based on it's contents.
|
||||
*/
|
||||
export function ResizingHeightContainer(props: Props) {
|
||||
const { hideOverflow, children, config, style } = props;
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const { height } = useComponentSize(ref);
|
||||
|
||||
return (
|
||||
<m.div
|
||||
animate={{
|
||||
...config,
|
||||
height: Math.round(height),
|
||||
}}
|
||||
style={{
|
||||
...style,
|
||||
overflow: hideOverflow ? "hidden" : "inherit",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import { hideScrollbars } from "~/styles";
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||
shadow?: boolean;
|
||||
@@ -94,16 +95,7 @@ const Wrapper = styled.div<{
|
||||
}};
|
||||
transition: box-shadow 100ms ease-in-out;
|
||||
|
||||
${(props) =>
|
||||
props.$hiddenScrollbars &&
|
||||
`
|
||||
-ms-overflow-style: none;
|
||||
overflow: -moz-scrollbars-none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
${(props) => props.$hiddenScrollbars && hideScrollbars()}
|
||||
`;
|
||||
|
||||
export default observer(React.forwardRef(Scrollable));
|
||||
|
||||
@@ -35,7 +35,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
||||
const previousLocation = usePrevious(location);
|
||||
const { isMenuOpen } = useMenuContext();
|
||||
const { user } = auth;
|
||||
|
||||
const width = ui.sidebarWidth;
|
||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
|
||||
@@ -3,6 +3,8 @@ import styled from "styled-components";
|
||||
type Props = {
|
||||
type?: "secondary" | "tertiary" | "danger";
|
||||
size?: "large" | "small" | "xsmall";
|
||||
dir?: "ltr" | "rtl" | "auto";
|
||||
selectable?: boolean;
|
||||
weight?: "bold" | "normal";
|
||||
};
|
||||
|
||||
@@ -12,6 +14,7 @@ type Props = {
|
||||
*/
|
||||
const Text = styled.p<Props>`
|
||||
margin-top: 0;
|
||||
text-align: ${(props) => (props.dir ? props.dir : "left")};
|
||||
color: ${(props) =>
|
||||
props.type === "secondary"
|
||||
? props.theme.textSecondary
|
||||
@@ -35,7 +38,7 @@ const Text = styled.p<Props>`
|
||||
? "normal"
|
||||
: "inherit"};
|
||||
white-space: normal;
|
||||
user-select: none;
|
||||
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||
`;
|
||||
|
||||
export default Text;
|
||||
|
||||
41
app/components/Typing.tsx
Normal file
41
app/components/Typing.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
type Props = {
|
||||
/** The size to render the indicator, defaults to 24px */
|
||||
size?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component to show an animated typing indicator.
|
||||
*/
|
||||
export default function Typing({ size = 24 }: Props) {
|
||||
return (
|
||||
<Wrapper height={size} width={size}>
|
||||
<Circle cx={size / 4} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 2} cy={size / 2} r="2" />
|
||||
<Circle cx={size / 1.33333} cy={size / 2} r="2" />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const Wrapper = styled.svg`
|
||||
fill: ${(props) => props.theme.textTertiary};
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
fill: transparent;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const Circle = styled.circle`
|
||||
animation: 1s blink infinite;
|
||||
|
||||
&:nth-child(2) {
|
||||
animation-delay: 250ms;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 500ms;
|
||||
}
|
||||
`;
|
||||
@@ -6,6 +6,7 @@ import * as React from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import FileOperation from "~/models/FileOperation";
|
||||
import Group from "~/models/Group";
|
||||
@@ -84,6 +85,7 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
memberships,
|
||||
policies,
|
||||
presence,
|
||||
comments,
|
||||
views,
|
||||
subscriptions,
|
||||
fileOperations,
|
||||
@@ -261,6 +263,20 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
}
|
||||
);
|
||||
|
||||
this.socket.on("comments.create", (event: PartialWithId<Comment>) => {
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("comments.update", (event: PartialWithId<Comment>) => {
|
||||
comments.add(event);
|
||||
});
|
||||
|
||||
this.socket.on("comments.delete", (event: WebsocketEntityDeletedEvent) => {
|
||||
comments.inThread(event.modelId).forEach((comment) => {
|
||||
comments.remove(comment.id);
|
||||
});
|
||||
});
|
||||
|
||||
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||
groups.add(event);
|
||||
});
|
||||
@@ -323,6 +339,13 @@ class WebsocketProvider extends React.Component<Props> {
|
||||
stars.remove(event.modelId);
|
||||
});
|
||||
|
||||
this.socket.on(
|
||||
"user.typing",
|
||||
(event: { userId: string; documentId: string; commentId: string }) => {
|
||||
comments.setTyping(event);
|
||||
}
|
||||
);
|
||||
|
||||
// received when a user is given access to a collection
|
||||
// if the user is us then we go ahead and load the collection from API.
|
||||
this.socket.on(
|
||||
|
||||
@@ -21,6 +21,17 @@ function ToolbarMenu(props: Props) {
|
||||
const { items } = props;
|
||||
const { state } = view;
|
||||
|
||||
const handleClick = (item: MenuItem) => () => {
|
||||
if (!item.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs;
|
||||
|
||||
commands[item.name](attrs);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
@@ -34,10 +45,7 @@ function ToolbarMenu(props: Props) {
|
||||
|
||||
return (
|
||||
<Tooltip tooltip={item.tooltip} key={index}>
|
||||
<ToolbarButton
|
||||
onClick={() => item.name && commands[item.name](item.attrs)}
|
||||
active={isActive}
|
||||
>
|
||||
<ToolbarButton onClick={handleClick(item)} active={isActive}>
|
||||
{React.cloneElement(item.icon, { color: "currentColor" })}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* global File Promise */
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
import { gapCursor } from "prosemirror-gapcursor";
|
||||
@@ -15,13 +16,11 @@ import {
|
||||
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
||||
import { Decoration, EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
|
||||
import Styles from "@shared/editor/components/Styles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
import getTasks from "@shared/editor/lib/getTasks";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
@@ -30,6 +29,7 @@ import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import fullExtensionsPackage from "@shared/editor/packages/full";
|
||||
import { EventType } from "@shared/editor/types";
|
||||
import { UserPreferences } from "@shared/types";
|
||||
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -48,16 +48,20 @@ export { default as Extension } from "@shared/editor/lib/Extension";
|
||||
export type Props = {
|
||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||
id?: string;
|
||||
/** The current userId, if any */
|
||||
userId?: string;
|
||||
/** The editor content, should only be changed if you wish to reset the content */
|
||||
value?: string;
|
||||
/** The initial editor content */
|
||||
defaultValue: string;
|
||||
/** The initial editor content as a markdown string or JSON object */
|
||||
defaultValue: string | object;
|
||||
/** Placeholder displayed when the editor is empty */
|
||||
placeholder: string;
|
||||
/** Extensions to load into the editor */
|
||||
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
|
||||
/** If the editor should be focused on mount */
|
||||
autoFocus?: boolean;
|
||||
/** The focused comment, if any */
|
||||
focusedCommentId?: string;
|
||||
/** If the editor should not allow editing */
|
||||
readOnly?: boolean;
|
||||
/** If the editor should still allow editing checkboxes when it is readOnly */
|
||||
@@ -85,7 +89,13 @@ export type Props = {
|
||||
/** Callback when user uses cancel key combo */
|
||||
onCancel?: () => void;
|
||||
/** Callback when user changes editor content */
|
||||
onChange?: (value: () => string | undefined) => void;
|
||||
onChange?: (value: () => any) => void;
|
||||
/** Callback when a comment mark is clicked */
|
||||
onClickCommentMark?: (commentId: string) => void;
|
||||
/** Callback when a comment mark is created */
|
||||
onCreateCommentMark?: (commentId: string, userId: string) => void;
|
||||
/** Callback when a comment mark is removed */
|
||||
onDeleteCommentMark?: (commentId: string) => void;
|
||||
/** Callback when a file upload begins */
|
||||
onFileUploadStart?: () => void;
|
||||
/** Callback when a file upload ends */
|
||||
@@ -394,7 +404,7 @@ export class Editor extends React.PureComponent<
|
||||
});
|
||||
}
|
||||
|
||||
private createState(value?: string) {
|
||||
private createState(value?: string | object) {
|
||||
const doc = this.createDocument(value || this.props.defaultValue);
|
||||
|
||||
return EditorState.create({
|
||||
@@ -415,8 +425,13 @@ export class Editor extends React.PureComponent<
|
||||
});
|
||||
}
|
||||
|
||||
private createDocument(content: string) {
|
||||
return this.parser.parse(content);
|
||||
private createDocument(content: string | object) {
|
||||
// Looks like Markdown
|
||||
if (typeof content === "string") {
|
||||
return this.parser.parse(content);
|
||||
}
|
||||
|
||||
return ProsemirrorNode.fromJSON(this.schema, content);
|
||||
}
|
||||
|
||||
private createView() {
|
||||
@@ -475,10 +490,6 @@ export class Editor extends React.PureComponent<
|
||||
return view;
|
||||
}
|
||||
|
||||
private dispatchThemeChanged = (event: CustomEvent) => {
|
||||
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
|
||||
};
|
||||
|
||||
public scrollToAnchor(hash: string) {
|
||||
if (!hash) {
|
||||
return;
|
||||
@@ -497,6 +508,18 @@ export class Editor extends React.PureComponent<
|
||||
}
|
||||
}
|
||||
|
||||
public value = (asString = true, trim?: boolean) => {
|
||||
if (asString) {
|
||||
const content = this.serializer.serialize(this.view.state.doc);
|
||||
return trim ? content.trim() : content;
|
||||
}
|
||||
|
||||
return (trim
|
||||
? ProsemirrorHelper.trim(this.view.state.doc)
|
||||
: this.view.state.doc
|
||||
).toJSON();
|
||||
};
|
||||
|
||||
private calculateDir = () => {
|
||||
if (!this.element.current) {
|
||||
return;
|
||||
@@ -511,8 +534,106 @@ export class Editor extends React.PureComponent<
|
||||
}
|
||||
};
|
||||
|
||||
public value = (): string => {
|
||||
return this.serializer.serialize(this.view.state.doc);
|
||||
/**
|
||||
* Focus the editor at the start of the content.
|
||||
*/
|
||||
public focusAtStart = () => {
|
||||
const selection = Selection.atStart(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the end of the content.
|
||||
*/
|
||||
public focusAtEnd = () => {
|
||||
const selection = Selection.atEnd(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the trimmed content of the editor is an empty string.
|
||||
*
|
||||
* @returns True if the editor is empty
|
||||
*/
|
||||
public isEmpty = () => {
|
||||
return ProsemirrorHelper.isEmpty(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the headings in the current editor.
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () => {
|
||||
return ProsemirrorHelper.getHeadings(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the tasks/checkmarks in the current editor.
|
||||
*
|
||||
* @returns A list of tasks in the document
|
||||
*/
|
||||
public getTasks = () => {
|
||||
return ProsemirrorHelper.getTasks(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the comments in the current editor.
|
||||
*
|
||||
* @returns A list of comments in the document
|
||||
*/
|
||||
public getComments = () => {
|
||||
return ProsemirrorHelper.getComments(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a specific comment mark from the document.
|
||||
*
|
||||
* @param commentId The id of the comment to remove
|
||||
*/
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
let found = false;
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline || found) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type === state.schema.marks.comment &&
|
||||
mark.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the plain text content of the current editor.
|
||||
*
|
||||
* @returns A string of text
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
};
|
||||
|
||||
private dispatchThemeChanged = (event: CustomEvent) => {
|
||||
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
|
||||
};
|
||||
|
||||
private handleChange = () => {
|
||||
@@ -520,8 +641,8 @@ export class Editor extends React.PureComponent<
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onChange(() => {
|
||||
return this.view ? this.value() : undefined;
|
||||
this.props.onChange((asString = true, trim = false) => {
|
||||
return this.view ? this.value(asString, trim) : undefined;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -583,60 +704,6 @@ export class Editor extends React.PureComponent<
|
||||
this.setState({ blockMenuOpen: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the start of the content.
|
||||
*/
|
||||
public focusAtStart = () => {
|
||||
const selection = Selection.atStart(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the end of the content.
|
||||
*/
|
||||
public focusAtEnd = () => {
|
||||
const selection = Selection.atEnd(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the headings in the current editor.
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () => {
|
||||
return getHeadings(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the tasks/checkmarks in the current editor.
|
||||
*
|
||||
* @returns A list of tasks in the document
|
||||
*/
|
||||
public getTasks = () => {
|
||||
return getTasks(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the plain text content of the current editor.
|
||||
*
|
||||
* @returns A string of text
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
dir,
|
||||
@@ -658,7 +725,6 @@ export class Editor extends React.PureComponent<
|
||||
className={className}
|
||||
align="flex-start"
|
||||
justify="center"
|
||||
dir={dir}
|
||||
column
|
||||
>
|
||||
<EditorContainer
|
||||
@@ -667,6 +733,7 @@ export class Editor extends React.PureComponent<
|
||||
grow={grow}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
|
||||
focusedCommentId={this.props.focusedCommentId}
|
||||
ref={this.element}
|
||||
/>
|
||||
{!readOnly && this.view && (
|
||||
@@ -724,6 +791,16 @@ export class Editor extends React.PureComponent<
|
||||
}
|
||||
}
|
||||
|
||||
const EditorContainer = styled(Styles)<{ focusedCommentId?: string }>`
|
||||
${(props) =>
|
||||
props.focusedCommentId &&
|
||||
css`
|
||||
#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
|
||||
(props: Props, ref) => {
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TodoListIcon,
|
||||
InputIcon,
|
||||
HighlightIcon,
|
||||
CommentIcon,
|
||||
ItalicIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
@@ -146,5 +147,12 @@ export default function formattingMenuItems(
|
||||
attrs: { href: "" },
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "comment",
|
||||
tooltip: dictionary.comment,
|
||||
icon: <CommentIcon />,
|
||||
active: isMarkActive(schema.marks.comment),
|
||||
visible: !isCode,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function useDictionary() {
|
||||
codeBlock: t("Code block"),
|
||||
codeCopied: t("Copied to clipboard"),
|
||||
codeInline: t("Code"),
|
||||
comment: t("Comment"),
|
||||
copy: t("Copy"),
|
||||
createLink: t("Create link"),
|
||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||
|
||||
@@ -7,6 +7,10 @@ type Options = {
|
||||
|
||||
/**
|
||||
* Measures the width of an emoji character
|
||||
*
|
||||
* @param emoji The emoji to measure
|
||||
* @param options Options to pass to the measurement element
|
||||
* @returns The width of the emoji in pixels
|
||||
*/
|
||||
export default function useEmojiWidth(
|
||||
emoji: string | undefined,
|
||||
|
||||
11
app/hooks/useFocusedComment.ts
Normal file
11
app/hooks/useFocusedComment.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useLocation } from "react-router-dom";
|
||||
import useQuery from "~/hooks/useQuery";
|
||||
import useStores from "./useStores";
|
||||
|
||||
export default function useFocusedComment() {
|
||||
const { comments } = useStores();
|
||||
const location = useLocation<{ commentId?: string }>();
|
||||
const query = useQuery();
|
||||
const focusedCommentId = location.state?.commentId || query.get("commentId");
|
||||
return focusedCommentId ? comments.get(focusedCommentId) : undefined;
|
||||
}
|
||||
27
app/hooks/useOnClickOutside.ts
Normal file
27
app/hooks/useOnClickOutside.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import useEventListener from "./useEventListener";
|
||||
|
||||
/**
|
||||
* Hook to detect clicks outside of a specified element.
|
||||
*
|
||||
* @param ref The React ref to the element.
|
||||
* @param callback The handler to call when a click outside the element is detected.
|
||||
*/
|
||||
export default function useOnClickOutside(
|
||||
ref: React.RefObject<HTMLElement>,
|
||||
callback?: (event: MouseEvent | TouchEvent) => void
|
||||
) {
|
||||
const listener = React.useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
// Do nothing if clicking ref's element or descendent elements
|
||||
if (!ref.current || ref.current.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
callback?.(event);
|
||||
},
|
||||
[ref, callback]
|
||||
);
|
||||
|
||||
useEventListener("mousedown", listener);
|
||||
useEventListener("touchstart", listener);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ type Options = {
|
||||
* @param options Options for the hook
|
||||
* @returns Tuple of the current value and a function to update it
|
||||
*/
|
||||
export default function usePersistedState<T extends Primitive>(
|
||||
export default function usePersistedState<T extends Primitive | object>(
|
||||
key: string,
|
||||
defaultValue: T,
|
||||
options?: Options
|
||||
|
||||
82
app/menus/CommentMenu.tsx
Normal file
82
app/menus/CommentMenu.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMenuState } from "reakit/Menu";
|
||||
import Comment from "~/models/Comment";
|
||||
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
|
||||
import ContextMenu from "~/components/ContextMenu";
|
||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||
import Separator from "~/components/ContextMenu/Separator";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import { commentPath, urlify } from "~/utils/routeHelpers";
|
||||
|
||||
type Props = {
|
||||
/** The comment to associate with the menu */
|
||||
comment: Comment;
|
||||
/** CSS class name */
|
||||
className?: string;
|
||||
/** Callback when the "Edit" is selected in the menu */
|
||||
onEdit: () => void;
|
||||
/** Callback when the comment has been deleted */
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
|
||||
const menu = useMenuState({
|
||||
modal: true,
|
||||
});
|
||||
const { showToast } = useToasts();
|
||||
const { documents, dialogs } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(comment.id);
|
||||
const document = documents.get(comment.documentId);
|
||||
|
||||
const handleDelete = React.useCallback(() => {
|
||||
dialogs.openModal({
|
||||
title: t("Delete comment"),
|
||||
isCentered: true,
|
||||
content: <CommentDeleteDialog comment={comment} onSubmit={onDelete} />,
|
||||
});
|
||||
}, [dialogs, comment, onDelete, t]);
|
||||
|
||||
const handleCopyLink = React.useCallback(() => {
|
||||
if (document) {
|
||||
copy(urlify(commentPath(document, comment)));
|
||||
showToast(t("Link copied"));
|
||||
}
|
||||
}, [t, document, comment, showToast]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OverflowMenuButton
|
||||
aria-label={t("Show menu")}
|
||||
className={className}
|
||||
{...menu}
|
||||
/>
|
||||
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
||||
{can.update && (
|
||||
<MenuItem {...menu} onClick={onEdit}>
|
||||
{t("Edit")}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem {...menu} onClick={handleCopyLink}>
|
||||
{t("Copy link")}
|
||||
</MenuItem>
|
||||
{can.delete && (
|
||||
<>
|
||||
<Separator />
|
||||
<MenuItem {...menu} onClick={handleDelete} dangerous>
|
||||
{t("Delete")}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CommentMenu);
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pick } from "lodash";
|
||||
import { set, computed, observable } from "mobx";
|
||||
import { set, observable } from "mobx";
|
||||
import { getFieldsForModel } from "./decorators/Field";
|
||||
|
||||
export default abstract class BaseModel {
|
||||
@@ -9,6 +9,9 @@ export default abstract class BaseModel {
|
||||
@observable
|
||||
isSaving: boolean;
|
||||
|
||||
@observable
|
||||
isNew: boolean;
|
||||
|
||||
createdAt: string;
|
||||
|
||||
updatedAt: string;
|
||||
@@ -17,6 +20,7 @@ export default abstract class BaseModel {
|
||||
|
||||
constructor(fields: Record<string, any>, store: any) {
|
||||
this.updateFromJson(fields);
|
||||
this.isNew = !this.id;
|
||||
this.store = store;
|
||||
}
|
||||
|
||||
@@ -32,10 +36,19 @@ export default abstract class BaseModel {
|
||||
params = this.toAPI();
|
||||
}
|
||||
|
||||
const model = await this.store.save({ ...params, id: this.id }, options);
|
||||
const model = await this.store.save(
|
||||
{
|
||||
...params,
|
||||
id: this.id,
|
||||
},
|
||||
{
|
||||
...options,
|
||||
isNew: this.isNew,
|
||||
}
|
||||
);
|
||||
|
||||
// if saving is successful set the new values on the model itself
|
||||
set(this, { ...params, ...model });
|
||||
set(this, { ...params, ...model, isNew: false });
|
||||
|
||||
this.persistedAttributes = this.toAPI();
|
||||
|
||||
@@ -46,7 +59,8 @@ export default abstract class BaseModel {
|
||||
};
|
||||
|
||||
updateFromJson = (data: any) => {
|
||||
set(this, data);
|
||||
//const isNew = !data.id && !this.id && this.isNew;
|
||||
set(this, { ...data, isNew: false });
|
||||
this.persistedAttributes = this.toAPI();
|
||||
};
|
||||
|
||||
@@ -94,7 +108,9 @@ export default abstract class BaseModel {
|
||||
if (
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
this.hasOwnProperty(property) &&
|
||||
!["persistedAttributes", "store", "isSaving"].includes(property)
|
||||
!["persistedAttributes", "store", "isSaving", "isNew"].includes(
|
||||
property
|
||||
)
|
||||
) {
|
||||
output[property] = this[property];
|
||||
}
|
||||
@@ -121,15 +137,5 @@ export default abstract class BaseModel {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a boolean indicating whether the model has been persisted to db
|
||||
*
|
||||
* @returns boolean true if the model has never been persisted
|
||||
*/
|
||||
@computed
|
||||
get isNew(): boolean {
|
||||
return !this.id;
|
||||
}
|
||||
|
||||
protected persistedAttributes: Partial<BaseModel> = {};
|
||||
}
|
||||
|
||||
66
app/models/Comment.ts
Normal file
66
app/models/Comment.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { subSeconds } from "date-fns";
|
||||
import { computed, observable } from "mobx";
|
||||
import { now } from "mobx-utils";
|
||||
import User from "~/models/User";
|
||||
import BaseModel from "./BaseModel";
|
||||
import Field from "./decorators/Field";
|
||||
|
||||
class Comment extends BaseModel {
|
||||
/**
|
||||
* Map to keep track of which users are currently typing a reply in this
|
||||
* comments thread.
|
||||
*/
|
||||
@observable
|
||||
typingUsers: Map<string, Date> = new Map();
|
||||
|
||||
@Field
|
||||
@observable
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* The Prosemirror data representing the comment content
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
data: Record<string, any>;
|
||||
|
||||
/**
|
||||
* If this comment is a reply then the parent comment will be set, otherwise
|
||||
* it is a top thread.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
parentCommentId: string;
|
||||
|
||||
/**
|
||||
* The document to which this comment belongs.
|
||||
*/
|
||||
@Field
|
||||
@observable
|
||||
documentId: string;
|
||||
|
||||
createdAt: string;
|
||||
|
||||
createdBy: User;
|
||||
|
||||
createdById: string;
|
||||
|
||||
resolvedAt: string;
|
||||
|
||||
resolvedBy: User;
|
||||
|
||||
updatedAt: string;
|
||||
|
||||
/**
|
||||
* An array of users that are currently typing a reply in this comments thread.
|
||||
*/
|
||||
@computed
|
||||
get currentlyTypingUsers(): User[] {
|
||||
return Array.from(this.typingUsers.entries())
|
||||
.filter(([, lastReceivedDate]) => lastReceivedDate > subSeconds(now(), 3))
|
||||
.map(([userId]) => this.store.rootStore.users.get(userId))
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
@@ -29,6 +29,10 @@ class Team extends BaseModel {
|
||||
@observable
|
||||
collaborativeEditing: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
commenting: boolean;
|
||||
|
||||
@Field
|
||||
@observable
|
||||
documentEmbeds: boolean;
|
||||
|
||||
13
app/scenes/Document/components/CommentEditor.tsx
Normal file
13
app/scenes/Document/components/CommentEditor.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
import extensions from "@shared/editor/packages/basic";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import type { Editor as SharedEditor } from "~/editor";
|
||||
|
||||
const CommentEditor = (
|
||||
props: EditorProps,
|
||||
ref: React.RefObject<SharedEditor>
|
||||
) => {
|
||||
return <Editor extensions={extensions} {...props} ref={ref} />;
|
||||
};
|
||||
|
||||
export default React.forwardRef(CommentEditor);
|
||||
229
app/scenes/Document/components/CommentForm.tsx
Normal file
229
app/scenes/Document/components/CommentForm.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { m } from "framer-motion";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CommentValidation } from "@shared/validations";
|
||||
import Comment from "~/models/Comment";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import type { Editor as SharedEditor } from "~/editor";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import usePersistedState from "~/hooks/usePersistedState";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
import { Bubble } from "./CommentThreadItem";
|
||||
|
||||
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;
|
||||
/** Placeholder text to display in the editor */
|
||||
placeholder?: string;
|
||||
/** Whether to focus the editor on mount */
|
||||
autoFocus?: boolean;
|
||||
/** Whether to render the comment form as standalone, rather than as a reply */
|
||||
standalone?: boolean;
|
||||
/** Whether to animate the comment form in and out */
|
||||
animatePresence?: boolean;
|
||||
/** The text direction of the editor */
|
||||
dir?: "rtl" | "ltr";
|
||||
/** Callback when the user is typing in the editor */
|
||||
onTyping?: () => void;
|
||||
/** Callback when the editor is focused */
|
||||
onFocus?: () => void;
|
||||
/** Callback when the editor is blurred */
|
||||
onBlur?: () => void;
|
||||
/** Callback when the editor is clicked outside of */
|
||||
onClickOutside?: (event: MouseEvent | TouchEvent) => void;
|
||||
};
|
||||
|
||||
function CommentForm({
|
||||
documentId,
|
||||
thread,
|
||||
onTyping,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onClickOutside,
|
||||
autoFocus,
|
||||
standalone,
|
||||
placeholder,
|
||||
animatePresence,
|
||||
dir,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { editor } = useDocumentContext();
|
||||
const [data, setData] = usePersistedState<Record<string, any> | undefined>(
|
||||
`draft-${documentId}-${thread.id}`,
|
||||
undefined
|
||||
);
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
const editorRef = React.useRef<SharedEditor>(null);
|
||||
const [forceRender, setForceRender] = React.useState(0);
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToasts();
|
||||
const { comments } = useStores();
|
||||
const user = useCurrentUser();
|
||||
const isEmpty = editorRef.current?.isEmpty() ?? true;
|
||||
|
||||
useOnClickOutside(formRef, () => {
|
||||
if (isEmpty && thread.isNew) {
|
||||
if (thread.id) {
|
||||
editor?.removeComment(thread.id);
|
||||
}
|
||||
thread.delete();
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateComment = action(async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
setData(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
thread
|
||||
.save({
|
||||
documentId,
|
||||
data,
|
||||
})
|
||||
.catch(() => {
|
||||
thread.isNew = true;
|
||||
showToast(t("Error creating comment"), { type: "error" });
|
||||
});
|
||||
|
||||
// optimistically update the comment model
|
||||
thread.isNew = false;
|
||||
thread.createdBy = user;
|
||||
});
|
||||
|
||||
const handleCreateReply = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
setData(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
try {
|
||||
await comments.save({
|
||||
parentCommentId: thread?.id,
|
||||
documentId,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
showToast(t("Error creating comment"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
value: (asString: boolean, trim: boolean) => Record<string, any>
|
||||
) => {
|
||||
setData(value(false, true));
|
||||
onTyping?.();
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
formRef.current?.dispatchEvent(
|
||||
new Event("submit", { cancelable: true, bubbles: true })
|
||||
);
|
||||
};
|
||||
|
||||
const handleClickPadding = () => {
|
||||
if (editorRef.current?.isBlurred) {
|
||||
editorRef.current?.focusAtStart();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setData(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
};
|
||||
|
||||
// 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(() => {
|
||||
setTimeout(() => {
|
||||
if (autoFocus) {
|
||||
editorRef.current?.focusAtStart();
|
||||
}
|
||||
}, 0);
|
||||
}, [autoFocus]);
|
||||
|
||||
const presence = animatePresence
|
||||
? {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
translateY: 100,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
translateY: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
bounce: 0.1,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
translateY: 100,
|
||||
scale: 0.98,
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<m.form
|
||||
ref={formRef}
|
||||
onSubmit={thread?.isNew ? handleCreateComment : handleCreateReply}
|
||||
{...presence}
|
||||
{...rest}
|
||||
>
|
||||
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
|
||||
<Avatar model={user} size={24} style={{ marginTop: 8 }} />
|
||||
<Bubble
|
||||
gap={10}
|
||||
onClick={handleClickPadding}
|
||||
$lastOfThread
|
||||
$firstOfAuthor
|
||||
$firstOfThread={standalone}
|
||||
column
|
||||
>
|
||||
<CommentEditor
|
||||
key={`${forceRender}`}
|
||||
ref={editorRef}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
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")}…`)
|
||||
}
|
||||
/>
|
||||
|
||||
{!isEmpty && (
|
||||
<Flex justify={dir === "rtl" ? "flex-end" : "flex-start"} gap={8}>
|
||||
<ButtonSmall type="submit" borderOnHover>
|
||||
{thread.isNew ? t("Post") : t("Reply")}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
|
||||
{t("Cancel")}
|
||||
</ButtonSmall>
|
||||
</Flex>
|
||||
)}
|
||||
</Bubble>
|
||||
</Flex>
|
||||
</m.form>
|
||||
);
|
||||
}
|
||||
|
||||
export default observer(CommentForm);
|
||||
232
app/scenes/Document/components/CommentThread.tsx
Normal file
232
app/scenes/Document/components/CommentThread.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { throttle } from "lodash";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import scrollIntoView from "smooth-scroll-into-view-if-needed";
|
||||
import styled, { css } from "styled-components";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Fade from "~/components/Fade";
|
||||
import Flex from "~/components/Flex";
|
||||
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
|
||||
import Typing from "~/components/Typing";
|
||||
import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentThreadItem from "./CommentThreadItem";
|
||||
|
||||
type Props = {
|
||||
/** The document that this comment thread belongs to */
|
||||
document: Document;
|
||||
/** The root comment to render */
|
||||
comment: Comment;
|
||||
/** Whether the thread is focused */
|
||||
focused: boolean;
|
||||
/** Whether the thread is displayed in a recessed/backgrounded state */
|
||||
recessed: boolean;
|
||||
};
|
||||
|
||||
function useTypingIndicator({
|
||||
document,
|
||||
comment,
|
||||
}: Omit<Props, "focused" | "recessed">): [undefined, () => void] {
|
||||
const socket = React.useContext(WebsocketContext);
|
||||
|
||||
const setIsTyping = React.useMemo(
|
||||
() =>
|
||||
throttle(() => {
|
||||
socket?.emit("typing", {
|
||||
documentId: document.id,
|
||||
commentId: comment.id,
|
||||
});
|
||||
}, 500),
|
||||
[socket, document.id, comment.id]
|
||||
);
|
||||
|
||||
return [undefined, setIsTyping];
|
||||
}
|
||||
|
||||
function CommentThread({
|
||||
comment: thread,
|
||||
document,
|
||||
recessed,
|
||||
focused,
|
||||
}: Props) {
|
||||
const { comments } = useStores();
|
||||
const topRef = React.useRef<HTMLDivElement>(null);
|
||||
const user = useCurrentUser();
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const [autoFocus, setAutoFocus] = React.useState(thread.isNew);
|
||||
const [, setIsTyping] = useTypingIndicator({
|
||||
document,
|
||||
comment: thread,
|
||||
});
|
||||
|
||||
const commentsInThread = comments.inThread(thread.id);
|
||||
|
||||
useOnClickOutside(topRef, (event) => {
|
||||
if (
|
||||
focused &&
|
||||
!(event.target as HTMLElement).classList.contains("comment")
|
||||
) {
|
||||
history.replace({
|
||||
pathname: window.location.pathname,
|
||||
state: { commentId: undefined },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleClickThread = () => {
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId: thread.id },
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!focused && autoFocus) {
|
||||
setAutoFocus(false);
|
||||
}
|
||||
}, [focused, autoFocus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focused && topRef.current) {
|
||||
scrollIntoView(topRef.current, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
block: "start",
|
||||
boundary: (parent) => {
|
||||
// Prevents body and other parent elements from being scrolled
|
||||
return parent.id !== "comments";
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const commentMarkElement = window.document?.getElementById(
|
||||
`comment-${thread.id}`
|
||||
);
|
||||
commentMarkElement?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
}, [focused, thread.id]);
|
||||
|
||||
return (
|
||||
<Thread
|
||||
ref={topRef}
|
||||
$focused={focused}
|
||||
$recessed={recessed}
|
||||
$dir={document.dir}
|
||||
onClick={handleClickThread}
|
||||
>
|
||||
{commentsInThread.map((comment, index) => {
|
||||
const firstOfAuthor =
|
||||
index === 0 ||
|
||||
comment.createdById !== commentsInThread[index - 1].createdById;
|
||||
const lastOfAuthor =
|
||||
index === commentsInThread.length - 1 ||
|
||||
comment.createdById !== commentsInThread[index + 1].createdById;
|
||||
|
||||
return (
|
||||
<CommentThreadItem
|
||||
comment={comment}
|
||||
key={comment.id}
|
||||
firstOfThread={index === 0}
|
||||
lastOfThread={index === commentsInThread.length - 1 && !focused}
|
||||
firstOfAuthor={firstOfAuthor}
|
||||
lastOfAuthor={lastOfAuthor}
|
||||
previousCommentCreatedAt={commentsInThread[index - 1]?.createdAt}
|
||||
dir={document.dir}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{thread.currentlyTypingUsers
|
||||
.filter((typing) => typing.id !== user.id)
|
||||
.map((typing) => (
|
||||
<Flex gap={8} key={typing.id}>
|
||||
<Avatar model={typing} size={24} />
|
||||
<Typing />
|
||||
</Flex>
|
||||
))}
|
||||
|
||||
<ResizingHeightContainer
|
||||
hideOverflow={false}
|
||||
config={{
|
||||
transition: {
|
||||
duration: 0.1,
|
||||
ease: "easeInOut",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{focused && (
|
||||
<Fade timing={100}>
|
||||
<CommentForm
|
||||
documentId={document.id}
|
||||
thread={thread}
|
||||
onTyping={setIsTyping}
|
||||
standalone={commentsInThread.length === 0}
|
||||
dir={document.dir}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
</Fade>
|
||||
)}
|
||||
</ResizingHeightContainer>
|
||||
{!focused && !recessed && (
|
||||
<Reply onClick={() => setAutoFocus(true)}>{t("Reply")}…</Reply>
|
||||
)}
|
||||
</Thread>
|
||||
);
|
||||
}
|
||||
|
||||
const Reply = styled.button`
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
background: none;
|
||||
color: ${(props) => props.theme.textTertiary};
|
||||
font-size: 14px;
|
||||
-webkit-appearance: none;
|
||||
cursor: var(--pointer);
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-out;
|
||||
position: absolute;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
bottom: -30px;
|
||||
left: 32px;
|
||||
`;
|
||||
|
||||
const Thread = styled.div<{
|
||||
$focused: boolean;
|
||||
$recessed: boolean;
|
||||
$dir?: "rtl" | "ltr";
|
||||
}>`
|
||||
margin: 12px 12px 32px;
|
||||
margin-right: ${(props) => (props.$dir !== "rtl" ? "18px" : "12px")};
|
||||
margin-left: ${(props) => (props.$dir === "rtl" ? "18px" : "12px")};
|
||||
position: relative;
|
||||
transition: opacity 100ms ease-out;
|
||||
|
||||
&:hover {
|
||||
${Reply} {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.$recessed &&
|
||||
css`
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(CommentThread);
|
||||
279
app/scenes/Document/components/CommentThreadItem.tsx
Normal file
279
app/scenes/Document/components/CommentThreadItem.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { differenceInMilliseconds, formatDistanceToNow } from "date-fns";
|
||||
import { toJS } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { css } from "styled-components";
|
||||
import { Minute } from "@shared/utils/time";
|
||||
import Comment from "~/models/Comment";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import { useDocumentContext } from "~/components/DocumentContext";
|
||||
import Flex from "~/components/Flex";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useToasts from "~/hooks/useToasts";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
import CommentEditor from "./CommentEditor";
|
||||
|
||||
/**
|
||||
* Hook to calculate if we should display a timestamp on a comment
|
||||
*
|
||||
* @param createdAt The date the comment was created
|
||||
* @param previousCreatedAt The date of the previous comment, if any
|
||||
* @returns boolean if to show timestamp
|
||||
*/
|
||||
function useShowTime(
|
||||
createdAt: string | undefined,
|
||||
previousCreatedAt: string | undefined
|
||||
): boolean {
|
||||
if (!createdAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousTimeStamp = previousCreatedAt
|
||||
? formatDistanceToNow(Date.parse(previousCreatedAt))
|
||||
: undefined;
|
||||
const currentTimeStamp = formatDistanceToNow(Date.parse(createdAt));
|
||||
|
||||
const msSincePreviousComment = previousCreatedAt
|
||||
? differenceInMilliseconds(
|
||||
Date.parse(createdAt),
|
||||
Date.parse(previousCreatedAt)
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
!msSincePreviousComment ||
|
||||
(msSincePreviousComment > 15 * Minute &&
|
||||
previousTimeStamp !== currentTimeStamp)
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
/** The comment to render */
|
||||
comment: Comment;
|
||||
/** The text direction of the editor */
|
||||
dir?: "rtl" | "ltr";
|
||||
/** Whether this is the first comment in the thread */
|
||||
firstOfThread?: boolean;
|
||||
/** Whether this is the last comment in the thread */
|
||||
lastOfThread?: boolean;
|
||||
/** Whether this is the first consecutive comment by this author */
|
||||
firstOfAuthor?: boolean;
|
||||
/** Whether this is the last consecutive comment by this author */
|
||||
lastOfAuthor?: boolean;
|
||||
/** The date of the previous comment in the thread */
|
||||
previousCommentCreatedAt?: string;
|
||||
};
|
||||
|
||||
function CommentThreadItem({
|
||||
comment,
|
||||
firstOfAuthor,
|
||||
firstOfThread,
|
||||
lastOfThread,
|
||||
dir,
|
||||
previousCommentCreatedAt,
|
||||
}: Props) {
|
||||
const { editor } = useDocumentContext();
|
||||
const { showToast } = useToasts();
|
||||
const { t } = useTranslation();
|
||||
const [forceRender, setForceRender] = React.useState(0);
|
||||
const [data, setData] = React.useState(toJS(comment.data));
|
||||
const showAuthor = firstOfAuthor;
|
||||
const showTime = useShowTime(comment.createdAt, previousCommentCreatedAt);
|
||||
const [isEditing, setEditing, setReadOnly] = useBoolean();
|
||||
const formRef = React.useRef<HTMLFormElement>(null);
|
||||
|
||||
const handleChange = (value: (asString: boolean) => object) => {
|
||||
setData(value(false));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
formRef.current?.dispatchEvent(
|
||||
new Event("submit", { cancelable: true, bubbles: true })
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setReadOnly();
|
||||
await comment.save({
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
setEditing();
|
||||
showToast(t("Error updating comment"), { type: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
editor?.removeComment(comment.id);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setData(toJS(comment.data));
|
||||
setReadOnly();
|
||||
setForceRender((s) => ++s);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
setData(toJS(comment.data));
|
||||
setForceRender((s) => ++s);
|
||||
}, [comment.data]);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="flex-start" reverse={dir === "rtl"}>
|
||||
{firstOfAuthor && (
|
||||
<AvatarSpacer>
|
||||
<Avatar model={comment.createdBy} size={24} />
|
||||
</AvatarSpacer>
|
||||
)}
|
||||
<Bubble
|
||||
$firstOfThread={firstOfThread}
|
||||
$firstOfAuthor={firstOfAuthor}
|
||||
$lastOfThread={lastOfThread}
|
||||
$dir={dir}
|
||||
column
|
||||
>
|
||||
{(showAuthor || showTime) && (
|
||||
<Meta size="xsmall" type="secondary" dir={dir}>
|
||||
{showAuthor && <em>{comment.createdBy.name}</em>}
|
||||
{showAuthor && showTime && <> · </>}
|
||||
{showTime && (
|
||||
<Time
|
||||
dateTime={comment.createdAt}
|
||||
tooltipDelay={500}
|
||||
addSuffix
|
||||
shorten
|
||||
/>
|
||||
)}
|
||||
</Meta>
|
||||
)}
|
||||
<Body ref={formRef} onSubmit={handleSubmit}>
|
||||
<StyledCommentEditor
|
||||
key={`${forceRender}`}
|
||||
readOnly={!isEditing}
|
||||
defaultValue={data}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
autoFocus
|
||||
/>
|
||||
{isEditing && (
|
||||
<Flex align="flex-end" gap={8}>
|
||||
<ButtonSmall type="submit" borderOnHover>
|
||||
{t("Save")}
|
||||
</ButtonSmall>
|
||||
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
|
||||
{t("Cancel")}
|
||||
</ButtonSmall>
|
||||
</Flex>
|
||||
)}
|
||||
</Body>
|
||||
{!isEditing && (
|
||||
<Menu
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
dir={dir}
|
||||
/>
|
||||
)}
|
||||
</Bubble>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
${(props) =>
|
||||
!props.readOnly &&
|
||||
css`
|
||||
box-shadow: 0 0 0 2px ${props.theme.accent};
|
||||
border-radius: 2px;
|
||||
padding: 2px;
|
||||
margin: 2px;
|
||||
margin-bottom: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const AvatarSpacer = styled(Flex)`
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 4px;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const Menu = styled(CommentMenu)<{ dir?: "rtl" | "ltr" }>`
|
||||
position: absolute;
|
||||
left: ${(props) => (props.dir !== "rtl" ? "auto" : "4px")};
|
||||
right: ${(props) => (props.dir === "rtl" ? "auto" : "4px")};
|
||||
top: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 100ms ease-in-out;
|
||||
|
||||
&:hover,
|
||||
&[aria-expanded="true"] {
|
||||
opacity: 1;
|
||||
background: ${(props) => props.theme.sidebarActiveBackground};
|
||||
}
|
||||
`;
|
||||
|
||||
const Meta = styled(Text)`
|
||||
margin-bottom: 2px;
|
||||
|
||||
em {
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Bubble = styled(Flex)<{
|
||||
$firstOfThread?: boolean;
|
||||
$firstOfAuthor?: boolean;
|
||||
$lastOfThread?: boolean;
|
||||
$focused?: boolean;
|
||||
$dir?: "rtl" | "ltr";
|
||||
}>`
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
font-size: 15px;
|
||||
color: ${(props) => props.theme.text};
|
||||
background: ${(props) => props.theme.commentBackground};
|
||||
min-width: 2em;
|
||||
margin-bottom: 1px;
|
||||
padding: 8px 12px;
|
||||
transition: color 100ms ease-out,
|
||||
${(props) => props.theme.backgroundTransition};
|
||||
|
||||
${({ $lastOfThread }) =>
|
||||
$lastOfThread &&
|
||||
"border-bottom-left-radius: 8px; border-bottom-right-radius: 8px"};
|
||||
|
||||
${({ $firstOfThread }) =>
|
||||
$firstOfThread &&
|
||||
"border-top-left-radius: 8px; border-top-right-radius: 8px"};
|
||||
|
||||
margin-left: ${(props) =>
|
||||
props.$firstOfAuthor || props.$dir === "rtl" ? 0 : 32}px;
|
||||
margin-right: ${(props) =>
|
||||
props.$firstOfAuthor || props.$dir !== "rtl" ? 0 : 32}px;
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&:hover ${Menu} {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(CommentThreadItem);
|
||||
94
app/scenes/Document/components/Comments.tsx
Normal file
94
app/scenes/Document/components/Comments.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { observer } from "mobx-react";
|
||||
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";
|
||||
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import CommentForm from "./CommentForm";
|
||||
import CommentThread from "./CommentThread";
|
||||
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 }>();
|
||||
const document = documents.getByUrl(match.params.documentSlug);
|
||||
const focusedComment = useFocusedComment();
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const threads = comments
|
||||
.threadsInDocument(document.id)
|
||||
.filter((thread) => !thread.isNew || thread.createdById === user.id);
|
||||
const hasComments = threads.length > 0;
|
||||
|
||||
return (
|
||||
<Sidebar title={t("Comments")} onClose={ui.collapseComments}>
|
||||
<Wrapper $hasComments={hasComments}>
|
||||
{hasComments ? (
|
||||
threads.map((thread) => (
|
||||
<CommentThread
|
||||
key={thread.id}
|
||||
comment={thread}
|
||||
document={document}
|
||||
recessed={!!focusedComment && focusedComment.id !== thread.id}
|
||||
focused={focusedComment?.id === thread.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<NoComments align="center" justify="center" auto>
|
||||
<Empty>{t("No comments yet")}</Empty>
|
||||
</NoComments>
|
||||
)}
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{!focusedComment && (
|
||||
<NewCommentForm
|
||||
key="new-comment-form"
|
||||
documentId={document.id}
|
||||
thread={newComment}
|
||||
placeholder={`${t("Add a comment")}…`}
|
||||
autoFocus={false}
|
||||
dir={document.dir}
|
||||
animatePresence
|
||||
standalone
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Wrapper>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
const NoComments = styled(Flex)`
|
||||
padding-bottom: 65px;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const Wrapper = styled.div<{ $hasComments: boolean }>`
|
||||
padding-bottom: ${(props) => (props.$hasComments ? "50vh" : "0")};
|
||||
height: ${(props) => (props.$hasComments ? "auto" : "100%")};
|
||||
`;
|
||||
|
||||
const NewCommentForm = styled(CommentForm)<{ dir?: "ltr" | "rtl" }>`
|
||||
background: ${(props) => props.theme.background};
|
||||
position: absolute;
|
||||
padding: 12px;
|
||||
padding-right: ${(props) => (props.dir !== "rtl" ? "18px" : "12px")};
|
||||
padding-left: ${(props) => (props.dir === "rtl" ? "18px" : "12px")};
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
`;
|
||||
|
||||
export default observer(Comments);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { NavigationNode, TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import Error404 from "~/scenes/Error404";
|
||||
@@ -45,6 +45,7 @@ function DataLoader({ match, children }: Props) {
|
||||
ui,
|
||||
views,
|
||||
shares,
|
||||
comments,
|
||||
documents,
|
||||
auth,
|
||||
revisions,
|
||||
@@ -158,6 +159,12 @@ function DataLoader({ match, children }: Props) {
|
||||
// Prevents unauthorized request to load share information for the document
|
||||
// when viewing a public share link
|
||||
if (can.read) {
|
||||
if (team?.getPreference(TeamPreference.Commenting)) {
|
||||
comments.fetchDocumentComments(document.id, {
|
||||
limit: 100,
|
||||
});
|
||||
}
|
||||
|
||||
shares.fetch(document.id).catch((err) => {
|
||||
if (!(err instanceof NotFoundError)) {
|
||||
throw err;
|
||||
@@ -165,7 +172,7 @@ function DataLoader({ match, children }: Props) {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [can.read, can.update, document, isEditRoute, shares, ui]);
|
||||
}, [can.read, can.update, document, isEditRoute, comments, team, shares, ui]);
|
||||
|
||||
if (error) {
|
||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from "react-router";
|
||||
import styled from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
||||
import { NavigationNode } from "@shared/types";
|
||||
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||
import { parseDomain } from "@shared/utils/domains";
|
||||
import getTasks from "@shared/utils/getTasks";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
@@ -638,19 +638,28 @@ class DocumentScene extends React.Component<Props> {
|
||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||
)}
|
||||
</Container>
|
||||
{!isShare && (
|
||||
<Footer>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
</Footer>
|
||||
)}
|
||||
</Background>
|
||||
{!isShare && (
|
||||
<>
|
||||
<KeyboardShortcutsButton />
|
||||
<ConnectionStatus />
|
||||
</>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Footer = styled.div`
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const Background = styled(Container)`
|
||||
position: relative;
|
||||
background: ${(props) => props.theme.background};
|
||||
transition: ${(props) => props.theme.backgroundTransition};
|
||||
`;
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
import { LocationDescriptor } from "history";
|
||||
import { observer, useObserver } from "mobx-react";
|
||||
import { CommentIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useRouteMatch } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Document from "~/models/Document";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import Fade from "~/components/Fade";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
|
||||
import Fade from "./Fade";
|
||||
|
||||
type Props = {
|
||||
/* The document to display meta data for */
|
||||
document: Document;
|
||||
isDraft: boolean;
|
||||
to?: LocationDescriptor;
|
||||
rtl?: boolean;
|
||||
};
|
||||
|
||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
const { views } = useStores();
|
||||
function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
|
||||
const { auth, views, comments, ui } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const { team } = auth;
|
||||
const match = useRouteMatch();
|
||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||
const totalViewers = documentViews.length;
|
||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
|
||||
|
||||
const insightsUrl = documentInsightsUrl(document);
|
||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||
|
||||
const insightsUrl = documentInsightsUrl(document);
|
||||
const commentsCount = comments.inDocument(document.id).length;
|
||||
|
||||
return (
|
||||
<Meta document={document} to={to} replace {...rest}>
|
||||
{totalViewers && !isDraft ? (
|
||||
@@ -46,15 +52,32 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
||||
</Link>
|
||||
</Wrapper>
|
||||
) : null}
|
||||
{team?.getPreference(TeamPreference.Commenting) && (
|
||||
<>
|
||||
•
|
||||
<CommentLink to={documentUrl(document)} onClick={ui.toggleComments}>
|
||||
<CommentIcon color="currentColor" size={18} />
|
||||
{commentsCount
|
||||
? t("{{ count }} comment", { count: commentsCount })
|
||||
: t("Comment")}
|
||||
</CommentLink>
|
||||
</>
|
||||
)}
|
||||
</Meta>
|
||||
);
|
||||
}
|
||||
|
||||
const CommentLink = styled(Link)`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||
margin: -12px 0 2em 0;
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
|
||||
a {
|
||||
@@ -70,4 +93,4 @@ const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||
}
|
||||
`;
|
||||
|
||||
export default observer(DocumentMetaWithViews);
|
||||
export default observer(TitleDocumentMeta);
|
||||
@@ -2,13 +2,15 @@ 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 fullPackage from "@shared/editor/packages/full";
|
||||
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||
import fullWithCommentsPackage from "@shared/editor/packages/fullWithComments";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { RefHandle } from "~/components/ContentEditable";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import {
|
||||
documentHistoryUrl,
|
||||
documentUrl,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
} from "~/utils/routeHelpers";
|
||||
import { useDocumentContext } from "../../../components/DocumentContext";
|
||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||
import DocumentMeta from "./DocumentMeta";
|
||||
import EditableTitle from "./EditableTitle";
|
||||
|
||||
type Props = Omit<EditorProps, "extensions"> & {
|
||||
@@ -34,12 +37,15 @@ type Props = Omit<EditorProps, "extensions"> & {
|
||||
|
||||
/**
|
||||
* The main document editor includes an editable title with metadata below it,
|
||||
* and support for hover previews of internal links.
|
||||
* and support for commenting.
|
||||
*/
|
||||
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
const titleRef = React.useRef<RefHandle>(null);
|
||||
const { t } = useTranslation();
|
||||
const match = useRouteMatch();
|
||||
const { ui, comments, auth } = useStores();
|
||||
const { user, team } = auth;
|
||||
const history = useHistory();
|
||||
const {
|
||||
document,
|
||||
onChangeTitle,
|
||||
@@ -77,9 +83,64 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
[focusAtStart, ref]
|
||||
);
|
||||
|
||||
const handleClickComment = React.useCallback(
|
||||
(commentId?: string) => {
|
||||
if (commentId) {
|
||||
ui.expandComments();
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
});
|
||||
} else {
|
||||
history.replace({
|
||||
pathname: window.location.pathname,
|
||||
});
|
||||
}
|
||||
},
|
||||
[ui, history]
|
||||
);
|
||||
|
||||
// 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,
|
||||
},
|
||||
comments
|
||||
);
|
||||
comment.id = commentId;
|
||||
comments.add(comment);
|
||||
|
||||
ui.expandComments();
|
||||
history.replace({
|
||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||
state: { commentId },
|
||||
});
|
||||
},
|
||||
[comments, user?.id, props.id, ui, history]
|
||||
);
|
||||
|
||||
// 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 } = useDocumentContext();
|
||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||
|
||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||
|
||||
return (
|
||||
@@ -95,7 +156,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
placeholder={t("Untitled")}
|
||||
/>
|
||||
{!shareId && (
|
||||
<DocumentMetaWithViews
|
||||
<DocumentMeta
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={
|
||||
@@ -115,7 +176,19 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||
scrollTo={decodeURIComponent(window.location.hash)}
|
||||
readOnly={readOnly}
|
||||
shareId={shareId}
|
||||
extensions={fullPackage}
|
||||
userId={user?.id}
|
||||
onClickCommentMark={handleClickComment}
|
||||
onCreateCommentMark={
|
||||
team?.getPreference(TeamPreference.Commenting)
|
||||
? handleDraftComment
|
||||
: undefined
|
||||
}
|
||||
onDeleteCommentMark={
|
||||
team?.getPreference(TeamPreference.Commenting)
|
||||
? handleRemoveComment
|
||||
: undefined
|
||||
}
|
||||
extensions={fullWithCommentsPackage}
|
||||
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,6 @@ const Button = styled(NudeButton)`
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
margin: 24px;
|
||||
|
||||
${breakpoint("tablet")`
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import Document from "~/models/Document";
|
||||
import Revision from "~/models/Revision";
|
||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
||||
import DocumentMeta from "~/components/DocumentMeta";
|
||||
import { Props as EditorProps } from "~/components/Editor";
|
||||
import Flex from "~/components/Flex";
|
||||
import { documentUrl } from "~/utils/routeHelpers";
|
||||
@@ -20,18 +20,13 @@ type Props = Omit<EditorProps, "extensions"> & {
|
||||
* Displays revision HTML pre-rendered on the server.
|
||||
*/
|
||||
function RevisionViewer(props: Props) {
|
||||
const { document, isDraft, shareId, children, revision } = props;
|
||||
const { document, shareId, children, revision } = props;
|
||||
|
||||
return (
|
||||
<Flex auto column>
|
||||
<h1 dir={revision.dir}>{revision.title}</h1>
|
||||
{!shareId && (
|
||||
<DocumentMetaWithViews
|
||||
isDraft={isDraft}
|
||||
document={document}
|
||||
to={documentUrl(document)}
|
||||
rtl={revision.rtl}
|
||||
/>
|
||||
<DocumentMeta document={document} to={documentUrl(document)} />
|
||||
)}
|
||||
<EditorContainer
|
||||
dangerouslySetInnerHTML={{ __html: revision.html }}
|
||||
|
||||
@@ -31,7 +31,9 @@ function SidebarLayout({ title, onClose, children }: Props) {
|
||||
/>
|
||||
</Tooltip>
|
||||
</Header>
|
||||
<Scrollable topShadow>{children}</Scrollable>
|
||||
<Scrollable hiddenScrollbars topShadow>
|
||||
{children}
|
||||
</Scrollable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { WebsocketContext } from "~/components/WebsocketProvider";
|
||||
type Props = {
|
||||
documentId: string;
|
||||
isEditing: boolean;
|
||||
presence: boolean;
|
||||
};
|
||||
|
||||
export default class SocketPresence extends React.Component<Props> {
|
||||
@@ -12,14 +13,16 @@ export default class SocketPresence extends React.Component<Props> {
|
||||
|
||||
previousContext: typeof WebsocketContext;
|
||||
|
||||
editingInterval: ReturnType<typeof setInterval>;
|
||||
editingInterval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
componentDidMount() {
|
||||
this.editingInterval = setInterval(() => {
|
||||
if (this.props.isEditing) {
|
||||
this.emitPresence();
|
||||
}
|
||||
}, USER_PRESENCE_INTERVAL);
|
||||
this.editingInterval = this.props.presence
|
||||
? setInterval(() => {
|
||||
if (this.props.isEditing) {
|
||||
this.emitPresence();
|
||||
}
|
||||
}, USER_PRESENCE_INTERVAL)
|
||||
: undefined;
|
||||
this.setupOnce();
|
||||
}
|
||||
|
||||
@@ -39,7 +42,9 @@ export default class SocketPresence extends React.Component<Props> {
|
||||
this.context.off("authenticated", this.emitJoin);
|
||||
}
|
||||
|
||||
clearInterval(this.editingInterval);
|
||||
if (this.editingInterval) {
|
||||
clearInterval(this.editingInterval);
|
||||
}
|
||||
}
|
||||
|
||||
setupOnce = () => {
|
||||
|
||||
@@ -60,7 +60,11 @@ export default function DocumentScene(props: Props) {
|
||||
// no longer be required
|
||||
if (isActive && !team.collaborativeEditing) {
|
||||
return (
|
||||
<SocketPresence documentId={document.id} isEditing={isEditing}>
|
||||
<SocketPresence
|
||||
documentId={document.id}
|
||||
isEditing={isEditing}
|
||||
presence={!team.collaborativeEditing}
|
||||
>
|
||||
<Document document={document} {...rest} />
|
||||
</SocketPresence>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,25 @@ function Features() {
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
{/* <SettingRow
|
||||
name={TeamPreference.Commenting}
|
||||
label={
|
||||
<Flex align="center">
|
||||
{t("Commenting")} <Badge>Beta</Badge>
|
||||
</Flex>
|
||||
}
|
||||
description={t(
|
||||
"When enabled team members can add comments to documents."
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
id={TeamPreference.Commenting}
|
||||
name={TeamPreference.Commenting}
|
||||
checked={team.getPreference(TeamPreference.Commenting, false)}
|
||||
disabled={!team.collaborativeEditing}
|
||||
onChange={handlePreferenceChange}
|
||||
/>
|
||||
</SettingRow> */}
|
||||
{team.avatarUrl && (
|
||||
<SettingRow
|
||||
name={TeamPreference.PublicBranding}
|
||||
|
||||
@@ -103,12 +103,13 @@ export default abstract class BaseStore<T extends BaseModel> {
|
||||
|
||||
save(
|
||||
params: Partial<T>,
|
||||
options?: Record<string, string | boolean | number | undefined>
|
||||
options: Record<string, string | boolean | number | undefined> = {}
|
||||
): Promise<T> {
|
||||
if (params.id) {
|
||||
return this.update(params, options);
|
||||
const { isNew, ...rest } = options;
|
||||
if (isNew || !params.id) {
|
||||
return this.create(params, rest);
|
||||
}
|
||||
return this.create(params, options);
|
||||
return this.update(params, rest);
|
||||
}
|
||||
|
||||
get(id: string): T | undefined {
|
||||
@@ -171,6 +172,10 @@ export default abstract class BaseStore<T extends BaseModel> {
|
||||
throw new Error(`Cannot delete ${this.modelName}`);
|
||||
}
|
||||
|
||||
if (item.isNew) {
|
||||
return this.remove(item.id);
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
try {
|
||||
|
||||
101
app/stores/CommentsStore.ts
Normal file
101
app/stores/CommentsStore.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import invariant from "invariant";
|
||||
import { filter, orderBy } from "lodash";
|
||||
import { action, runInAction, computed } from "mobx";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import { PaginationParams } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import BaseStore from "./BaseStore";
|
||||
import RootStore from "./RootStore";
|
||||
|
||||
export default class CommentsStore extends BaseStore<Comment> {
|
||||
apiEndpoint = "comments";
|
||||
|
||||
constructor(rootStore: RootStore) {
|
||||
super(rootStore, Comment);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments in a document that are not replies to other
|
||||
* comments.
|
||||
*
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
threadsInDocument(documentId: string): Comment[] {
|
||||
return this.inDocument(documentId).filter(
|
||||
(comment) => !comment.parentCommentId
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments that are replies to the given comment.
|
||||
*
|
||||
* @param commentId ID of the comment to get replies for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
inThread(threadId: string): Comment[] {
|
||||
return filter(
|
||||
this.orderedData,
|
||||
(comment) =>
|
||||
comment.parentCommentId === threadId ||
|
||||
(comment.id === threadId && !comment.isNew)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of comments in a document.
|
||||
*
|
||||
* @param documentId ID of the document to get comments for
|
||||
* @returns Array of comments
|
||||
*/
|
||||
inDocument(documentId: string): Comment[] {
|
||||
return filter(
|
||||
this.orderedData,
|
||||
(comment) => comment.documentId === documentId
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
setTyping({
|
||||
commentId,
|
||||
userId,
|
||||
}: {
|
||||
commentId: string;
|
||||
userId: string;
|
||||
}): void {
|
||||
const comment = this.get(commentId);
|
||||
if (comment) {
|
||||
comment.typingUsers.set(userId, new Date());
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
fetchDocumentComments = async (
|
||||
documentId: string,
|
||||
options?: PaginationParams | undefined
|
||||
): Promise<Document[]> => {
|
||||
this.isFetching = true;
|
||||
|
||||
try {
|
||||
const res = await client.post(`/comments.list`, {
|
||||
documentId,
|
||||
...options,
|
||||
});
|
||||
invariant(res && res.data, "Comment list not available");
|
||||
|
||||
runInAction("CommentsStore#fetchDocumentComments", () => {
|
||||
res.data.forEach(this.add);
|
||||
this.addPolicies(res.policies);
|
||||
});
|
||||
return res.data;
|
||||
} finally {
|
||||
this.isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
@computed
|
||||
get orderedData(): Comment[] {
|
||||
return orderBy(Array.from(this.data.values()), "createdAt", "asc");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import AuthStore from "./AuthStore";
|
||||
import AuthenticationProvidersStore from "./AuthenticationProvidersStore";
|
||||
import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
|
||||
import CollectionsStore from "./CollectionsStore";
|
||||
import CommentsStore from "./CommentsStore";
|
||||
import DialogsStore from "./DialogsStore";
|
||||
import DocumentPresenceStore from "./DocumentPresenceStore";
|
||||
import DocumentsStore from "./DocumentsStore";
|
||||
@@ -32,6 +33,7 @@ export default class RootStore {
|
||||
authenticationProviders: AuthenticationProvidersStore;
|
||||
collections: CollectionsStore;
|
||||
collectionGroupMemberships: CollectionGroupMembershipsStore;
|
||||
comments: CommentsStore;
|
||||
dialogs: DialogsStore;
|
||||
documents: DocumentsStore;
|
||||
events: EventsStore;
|
||||
@@ -63,6 +65,7 @@ export default class RootStore {
|
||||
this.auth = new AuthStore(this);
|
||||
this.collections = new CollectionsStore(this);
|
||||
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
|
||||
this.comments = new CommentsStore(this);
|
||||
this.dialogs = new DialogsStore();
|
||||
this.documents = new DocumentsStore(this);
|
||||
this.events = new EventsStore(this);
|
||||
@@ -92,6 +95,7 @@ export default class RootStore {
|
||||
// this.auth omitted for reasons...
|
||||
this.collections.clear();
|
||||
this.collectionGroupMemberships.clear();
|
||||
this.comments.clear();
|
||||
this.documents.clear();
|
||||
this.events.clear();
|
||||
this.groups.clear();
|
||||
|
||||
@@ -57,9 +57,15 @@ class UiStore {
|
||||
@observable
|
||||
sidebarWidth: number;
|
||||
|
||||
@observable
|
||||
sidebarRightWidth: number;
|
||||
|
||||
@observable
|
||||
sidebarCollapsed = false;
|
||||
|
||||
@observable
|
||||
commentsCollapsed = false;
|
||||
|
||||
@observable
|
||||
sidebarIsResizing = false;
|
||||
|
||||
@@ -91,6 +97,8 @@ class UiStore {
|
||||
this.languagePromptDismissed = data.languagePromptDismissed;
|
||||
this.sidebarCollapsed = !!data.sidebarCollapsed;
|
||||
this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth;
|
||||
this.sidebarRightWidth =
|
||||
data.sidebarRightWidth || defaultTheme.sidebarWidth;
|
||||
this.tocVisible = !!data.tocVisible;
|
||||
this.theme = data.theme || Theme.System;
|
||||
|
||||
@@ -153,8 +161,8 @@ class UiStore {
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarWidth = (sidebarWidth: number): void => {
|
||||
this.sidebarWidth = sidebarWidth;
|
||||
setSidebarWidth = (width: number): void => {
|
||||
this.sidebarWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -168,6 +176,21 @@ class UiStore {
|
||||
this.sidebarCollapsed = false;
|
||||
};
|
||||
|
||||
@action
|
||||
collapseComments = () => {
|
||||
this.commentsCollapsed = true;
|
||||
};
|
||||
|
||||
@action
|
||||
expandComments = () => {
|
||||
this.commentsCollapsed = false;
|
||||
};
|
||||
|
||||
@action
|
||||
toggleComments = () => {
|
||||
this.commentsCollapsed = !this.commentsCollapsed;
|
||||
};
|
||||
|
||||
@action
|
||||
toggleCollapsedSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
@@ -239,6 +262,7 @@ class UiStore {
|
||||
tocVisible: this.tocVisible,
|
||||
sidebarCollapsed: this.sidebarCollapsed,
|
||||
sidebarWidth: this.sidebarWidth,
|
||||
sidebarRightWidth: this.sidebarRightWidth,
|
||||
languagePromptDismissed: this.languagePromptDismissed,
|
||||
theme: this.theme,
|
||||
};
|
||||
|
||||
@@ -38,3 +38,17 @@ export const fadeOnDesktopBackgrounded = () => {
|
||||
body.backgrounded & { opacity: 0.75; }
|
||||
`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mixin to hide scrollbars.
|
||||
*
|
||||
* @returns string of CSS
|
||||
*/
|
||||
export const hideScrollbars = () => `
|
||||
-ms-overflow-style: none;
|
||||
overflow: -moz-scrollbars-none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
2
app/typings/styled-components.d.ts
vendored
2
app/typings/styled-components.d.ts
vendored
@@ -136,6 +136,8 @@ declare module "styled-components" {
|
||||
textDiffDeleted: string;
|
||||
textDiffDeletedBackground: string;
|
||||
placeholder: string;
|
||||
commentBackground: string;
|
||||
commentActiveBackground: string;
|
||||
sidebarBackground: string;
|
||||
sidebarActiveBackground: string;
|
||||
sidebarControlHoverBackground: string;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import queryString from "query-string";
|
||||
import Collection from "~/models/Collection";
|
||||
import Comment from "~/models/Comment";
|
||||
import Document from "~/models/Document";
|
||||
import env from "~/env";
|
||||
|
||||
export function homePath(): string {
|
||||
return "/home";
|
||||
@@ -42,6 +44,10 @@ export function groupSettingsPath(): string {
|
||||
return "/settings/groups";
|
||||
}
|
||||
|
||||
export function commentPath(document: Document, comment: Comment): string {
|
||||
return `${documentUrl(document)}?commentId=${comment.id}`;
|
||||
}
|
||||
|
||||
export function collectionUrl(url: string, section?: string): string {
|
||||
if (section) {
|
||||
return `${url}/${section}`;
|
||||
@@ -131,6 +137,10 @@ export function notFoundUrl(): string {
|
||||
return "/404";
|
||||
}
|
||||
|
||||
export function urlify(path: string): string {
|
||||
return `${env.URL}${path}`;
|
||||
}
|
||||
|
||||
export const matchDocumentSlug =
|
||||
":documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user