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})";
|
||||
|
||||
|
||||
@@ -153,6 +153,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
case "collections.remove_group":
|
||||
await this.handleCollectionGroupEvent(subscription, event);
|
||||
return;
|
||||
case "comments.create":
|
||||
case "comments.update":
|
||||
case "comments.delete":
|
||||
// TODO
|
||||
return;
|
||||
case "groups.create":
|
||||
case "groups.update":
|
||||
case "groups.delete":
|
||||
|
||||
31
server/commands/commentCreator.test.ts
Normal file
31
server/commands/commentCreator.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Event } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { setupTestDatabase } from "@server/test/support";
|
||||
import commentCreator from "./commentCreator";
|
||||
|
||||
setupTestDatabase();
|
||||
|
||||
describe("commentCreator", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should create comment", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const comment = await commentCreator({
|
||||
documentId: document.id,
|
||||
data: { text: "test" },
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const event = await Event.findOne();
|
||||
expect(comment.documentId).toEqual(document.id);
|
||||
expect(comment.createdById).toEqual(user.id);
|
||||
expect(event!.name).toEqual("comments.create");
|
||||
expect(event!.modelId).toEqual(comment.id);
|
||||
});
|
||||
});
|
||||
62
server/commands/commentCreator.ts
Normal file
62
server/commands/commentCreator.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Comment, User, Event } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
id?: string;
|
||||
/** The user creating the comment */
|
||||
user: User;
|
||||
/** The comment as data in Prosemirror schema format */
|
||||
data: Record<string, any>;
|
||||
/** The document to comment within */
|
||||
documentId: string;
|
||||
/** The parent comment we're replying to, if any */
|
||||
parentCommentId?: string;
|
||||
/** The IP address of the user creating the comment */
|
||||
ip: string;
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command creates a comment inside a document.
|
||||
*
|
||||
* @param Props The properties of the comment to create
|
||||
* @returns Comment The comment that was created
|
||||
*/
|
||||
export default async function commentCreator({
|
||||
id,
|
||||
user,
|
||||
data,
|
||||
documentId,
|
||||
parentCommentId,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Comment> {
|
||||
// TODO: Parse data to validate
|
||||
|
||||
const comment = await Comment.create(
|
||||
{
|
||||
id,
|
||||
createdById: user.id,
|
||||
documentId,
|
||||
parentCommentId,
|
||||
data,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
comment.createdBy = user;
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "comments.create",
|
||||
modelId: comment.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
38
server/commands/commentDestroyer.test.ts
Normal file
38
server/commands/commentDestroyer.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Comment, Event } from "@server/models";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import { setupTestDatabase } from "@server/test/support";
|
||||
import commentDestroyer from "./commentDestroyer";
|
||||
|
||||
setupTestDatabase();
|
||||
|
||||
describe("commentDestroyer", () => {
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
it("should destroy existing comment", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
});
|
||||
|
||||
const comment = await Comment.create({
|
||||
teamId: document.teamId,
|
||||
documentId: document.id,
|
||||
data: { text: "test" },
|
||||
createdById: user.id,
|
||||
});
|
||||
|
||||
await commentDestroyer({
|
||||
comment,
|
||||
user,
|
||||
ip,
|
||||
});
|
||||
|
||||
const count = await Comment.count();
|
||||
expect(count).toEqual(0);
|
||||
|
||||
const event = await Event.findOne();
|
||||
expect(event!.name).toEqual("comments.delete");
|
||||
expect(event!.modelId).toEqual(comment.id);
|
||||
});
|
||||
});
|
||||
50
server/commands/commentDestroyer.ts
Normal file
50
server/commands/commentDestroyer.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event, Comment, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user destroying the comment */
|
||||
user: User;
|
||||
/** The comment to destroy */
|
||||
comment: Comment;
|
||||
/** The IP address of the user */
|
||||
ip: string;
|
||||
transaction?: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command destroys a document comment. This just removes the comment itself and
|
||||
* does not touch the document
|
||||
*
|
||||
* @param Props The properties of the comment to destroy
|
||||
* @returns void
|
||||
*/
|
||||
export default async function commentDestroyer({
|
||||
user,
|
||||
comment,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Comment> {
|
||||
await comment.destroy({ transaction });
|
||||
|
||||
// Also destroy any child comments
|
||||
const childComments = await Comment.findAll({
|
||||
where: { parentCommentId: comment.id },
|
||||
transaction,
|
||||
});
|
||||
await Promise.all(
|
||||
childComments.map((childComment) => childComment.destroy({ transaction }))
|
||||
);
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "comments.delete",
|
||||
modelId: comment.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: comment.documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
return comment;
|
||||
}
|
||||
54
server/commands/commentUpdater.ts
Normal file
54
server/commands/commentUpdater.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Transaction } from "sequelize";
|
||||
import { Event, Comment, User } from "@server/models";
|
||||
|
||||
type Props = {
|
||||
/** The user updating the comment */
|
||||
user: User;
|
||||
/** The user resolving the comment */
|
||||
resolvedBy?: User;
|
||||
/** The existing comment */
|
||||
comment: Comment;
|
||||
/** The index to comment the document at */
|
||||
data: Record<string, any>;
|
||||
/** The IP address of the user creating the comment */
|
||||
ip: string;
|
||||
transaction: Transaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* This command updates a comment.
|
||||
*
|
||||
* @param Props The properties of the comment to update
|
||||
* @returns Comment The updated comment
|
||||
*/
|
||||
export default async function commentUpdater({
|
||||
user,
|
||||
comment,
|
||||
data,
|
||||
resolvedBy,
|
||||
ip,
|
||||
transaction,
|
||||
}: Props): Promise<Comment> {
|
||||
if (resolvedBy !== undefined) {
|
||||
comment.resolvedBy = resolvedBy;
|
||||
}
|
||||
if (data !== undefined) {
|
||||
comment.data = data;
|
||||
}
|
||||
|
||||
await comment.save({ transaction });
|
||||
|
||||
await Event.create(
|
||||
{
|
||||
name: "comments.update",
|
||||
modelId: comment.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
documentId: comment.documentId,
|
||||
ip,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
|
||||
return comment;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Schema } from "prosemirror-model";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import fullPackage from "@shared/editor/packages/full";
|
||||
import extensionsPackage from "@shared/editor/packages/fullWithComments";
|
||||
|
||||
const extensions = new ExtensionManager(fullPackage);
|
||||
const extensions = new ExtensionManager(extensionsPackage);
|
||||
|
||||
export const schema = new Schema({
|
||||
nodes: extensions.nodes,
|
||||
|
||||
70
server/migrations/20220305195830-create-comments.js
Normal file
70
server/migrations/20220305195830-create-comments.js
Normal file
@@ -0,0 +1,70 @@
|
||||
"use strict";
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable("comments", {
|
||||
id: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
primaryKey: true
|
||||
},
|
||||
data: {
|
||||
type: Sequelize.JSONB,
|
||||
allowNull: false
|
||||
},
|
||||
documentId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "documents"
|
||||
}
|
||||
},
|
||||
parentCommentId: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
onDelete: "cascade",
|
||||
references: {
|
||||
model: "comments"
|
||||
}
|
||||
},
|
||||
createdById: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: "users"
|
||||
}
|
||||
},
|
||||
resolvedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
resolvedById: {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: "users"
|
||||
}
|
||||
},
|
||||
createdAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
updatedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
deletedAt: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
});
|
||||
|
||||
await queryInterface.addIndex("comments", ["documentId"]);
|
||||
await queryInterface.addIndex("comments", ["createdAt"]);
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
queryInterface.dropTable("comments");
|
||||
}
|
||||
};
|
||||
72
server/models/Comment.ts
Normal file
72
server/models/Comment.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
DataType,
|
||||
BelongsTo,
|
||||
ForeignKey,
|
||||
Column,
|
||||
Table,
|
||||
Scopes,
|
||||
DefaultScope,
|
||||
} from "sequelize-typescript";
|
||||
import Document from "./Document";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
import Fix from "./decorators/Fix";
|
||||
|
||||
@DefaultScope(() => ({
|
||||
include: [
|
||||
{
|
||||
model: User,
|
||||
as: "createdBy",
|
||||
paranoid: false,
|
||||
},
|
||||
],
|
||||
}))
|
||||
@Scopes(() => ({
|
||||
withDocument: {
|
||||
include: [
|
||||
{
|
||||
model: Document,
|
||||
as: "document",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
@Table({ tableName: "comments", modelName: "comment" })
|
||||
@Fix
|
||||
class Comment extends ParanoidModel {
|
||||
@Column(DataType.JSONB)
|
||||
data: Record<string, any>;
|
||||
|
||||
// associations
|
||||
|
||||
@BelongsTo(() => User, "createdById")
|
||||
createdBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
createdById: string;
|
||||
|
||||
@BelongsTo(() => User, "resolvedById")
|
||||
resolvedBy: User;
|
||||
|
||||
@ForeignKey(() => User)
|
||||
@Column(DataType.UUID)
|
||||
resolvedById: string;
|
||||
|
||||
@BelongsTo(() => Document, "documentId")
|
||||
document: Document;
|
||||
|
||||
@ForeignKey(() => Document)
|
||||
@Column(DataType.UUID)
|
||||
documentId: string;
|
||||
|
||||
@BelongsTo(() => Comment, "parentCommentId")
|
||||
parentComment: Comment;
|
||||
|
||||
@ForeignKey(() => Comment)
|
||||
@Column(DataType.UUID)
|
||||
parentCommentId: string;
|
||||
}
|
||||
|
||||
export default Comment;
|
||||
@@ -12,6 +12,8 @@ export { default as CollectionGroup } from "./CollectionGroup";
|
||||
|
||||
export { default as CollectionUser } from "./CollectionUser";
|
||||
|
||||
export { default as Comment } from "./Comment";
|
||||
|
||||
export { default as Document } from "./Document";
|
||||
|
||||
export { default as Event } from "./Event";
|
||||
|
||||
19
server/policies/comment.ts
Normal file
19
server/policies/comment.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Comment, User, Team } from "@server/models";
|
||||
import { allow } from "./cancan";
|
||||
|
||||
allow(User, "createComment", Team, (user, team) => {
|
||||
if (!team || user.isViewer || user.teamId !== team.id) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
allow(User, ["read", "update", "delete"], Comment, (user, comment) => {
|
||||
if (!comment) {
|
||||
return false;
|
||||
}
|
||||
if (user.isViewer) {
|
||||
return false;
|
||||
}
|
||||
return user?.id === comment.createdById;
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Team,
|
||||
User,
|
||||
Collection,
|
||||
Comment,
|
||||
Document,
|
||||
Group,
|
||||
} from "@server/models";
|
||||
@@ -12,6 +13,7 @@ import "./apiKey";
|
||||
import "./attachment";
|
||||
import "./authenticationProvider";
|
||||
import "./collection";
|
||||
import "./comment";
|
||||
import "./document";
|
||||
import "./fileOperation";
|
||||
import "./integration";
|
||||
@@ -47,9 +49,10 @@ export function serialize(
|
||||
model: User,
|
||||
target:
|
||||
| Attachment
|
||||
| Collection
|
||||
| Comment
|
||||
| FileOperation
|
||||
| Team
|
||||
| Collection
|
||||
| Document
|
||||
| User
|
||||
| Group
|
||||
|
||||
15
server/presenters/comment.ts
Normal file
15
server/presenters/comment.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Comment } from "@server/models";
|
||||
import presentUser from "./user";
|
||||
|
||||
export default function present(comment: Comment) {
|
||||
return {
|
||||
id: comment.id,
|
||||
data: comment.data,
|
||||
documentId: comment.documentId,
|
||||
parentCommentId: comment.parentCommentId,
|
||||
createdBy: presentUser(comment.createdBy),
|
||||
createdById: comment.createdById,
|
||||
createdAt: comment.createdAt,
|
||||
updatedAt: comment.updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import presentAuthenticationProvider from "./authenticationProvider";
|
||||
import presentAvailableTeam from "./availableTeam";
|
||||
import presentCollection from "./collection";
|
||||
import presentCollectionGroupMembership from "./collectionGroupMembership";
|
||||
import presentComment from "./comment";
|
||||
import presentDocument from "./document";
|
||||
import presentEvent from "./event";
|
||||
import presentFileOperation from "./fileOperation";
|
||||
@@ -32,6 +33,7 @@ export {
|
||||
presentAvailableTeam,
|
||||
presentCollection,
|
||||
presentCollectionGroupMembership,
|
||||
presentComment,
|
||||
presentDocument,
|
||||
presentEvent,
|
||||
presentFileOperation,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { subHours } from "date-fns";
|
||||
import { Op } from "sequelize";
|
||||
import { Server } from "socket.io";
|
||||
import {
|
||||
Comment,
|
||||
Document,
|
||||
Collection,
|
||||
FileOperation,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
Subscription,
|
||||
} from "@server/models";
|
||||
import {
|
||||
presentComment,
|
||||
presentCollection,
|
||||
presentDocument,
|
||||
presentFileOperation,
|
||||
@@ -355,6 +357,35 @@ export default class WebsocketsProcessor {
|
||||
});
|
||||
}
|
||||
|
||||
case "comments.create":
|
||||
case "comments.update": {
|
||||
const comment = await Comment.scope([
|
||||
"defaultScope",
|
||||
"withDocument",
|
||||
]).findByPk(event.modelId);
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
return socketio
|
||||
.to(`collection-${comment.document.collectionId}`)
|
||||
.emit(event.name, presentComment(comment));
|
||||
}
|
||||
|
||||
case "comments.delete": {
|
||||
const comment = await Comment.scope([
|
||||
"defaultScope",
|
||||
"withDocument",
|
||||
]).findByPk(event.modelId);
|
||||
if (!comment) {
|
||||
return;
|
||||
}
|
||||
return socketio
|
||||
.to(`collection-${comment.document.collectionId}`)
|
||||
.emit(event.name, {
|
||||
modelId: event.modelId,
|
||||
});
|
||||
}
|
||||
|
||||
case "stars.create":
|
||||
case "stars.update": {
|
||||
const star = await Star.findByPk(event.modelId);
|
||||
|
||||
144
server/routes/api/comments/comments.ts
Normal file
144
server/routes/api/comments/comments.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import Router from "koa-router";
|
||||
import { Transaction } from "sequelize";
|
||||
import commentCreator from "@server/commands/commentCreator";
|
||||
import commentDestroyer from "@server/commands/commentDestroyer";
|
||||
import commentUpdater from "@server/commands/commentUpdater";
|
||||
import auth from "@server/middlewares/authentication";
|
||||
import { transaction } from "@server/middlewares/transaction";
|
||||
import validate from "@server/middlewares/validate";
|
||||
import { Document, Comment } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import { presentComment, presentPolicies } from "@server/presenters";
|
||||
import { APIContext } from "@server/types";
|
||||
import pagination from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
|
||||
const router = new Router();
|
||||
|
||||
router.post(
|
||||
"comments.create",
|
||||
auth(),
|
||||
validate(T.CommentsCreateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsCreateReq>) => {
|
||||
const { id, documentId, parentCommentId, data } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const document = await Document.findByPk(documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
});
|
||||
authorize(user, "read", document);
|
||||
|
||||
const comment = await commentCreator({
|
||||
id,
|
||||
data,
|
||||
parentCommentId,
|
||||
documentId,
|
||||
user,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentComment(comment),
|
||||
policies: presentPolicies(user, [comment]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"comments.list",
|
||||
auth(),
|
||||
pagination(),
|
||||
validate(T.CollectionsListSchema),
|
||||
async (ctx: APIContext<T.CollectionsListReq>) => {
|
||||
const { sort, direction, documentId } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
|
||||
const document = await Document.findByPk(documentId, { userId: user.id });
|
||||
authorize(user, "read", document);
|
||||
|
||||
const comments = await Comment.findAll({
|
||||
where: { documentId },
|
||||
order: [[sort, direction]],
|
||||
offset: ctx.state.pagination.offset,
|
||||
limit: ctx.state.pagination.limit,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
pagination: ctx.state.pagination,
|
||||
data: comments.map(presentComment),
|
||||
policies: presentPolicies(user, comments),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"comments.update",
|
||||
auth(),
|
||||
validate(T.CommentsUpdateSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsUpdateReq>) => {
|
||||
const { id, data } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const comment = await Comment.findByPk(id, {
|
||||
transaction,
|
||||
lock: {
|
||||
level: transaction.LOCK.UPDATE,
|
||||
of: Comment,
|
||||
},
|
||||
});
|
||||
authorize(user, "update", comment);
|
||||
|
||||
await commentUpdater({
|
||||
user,
|
||||
comment,
|
||||
data,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
data: presentComment(comment),
|
||||
policies: presentPolicies(user, [comment]),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"comments.delete",
|
||||
auth(),
|
||||
validate(T.CommentsDeleteSchema),
|
||||
transaction(),
|
||||
async (ctx: APIContext<T.CommentsDeleteReq>) => {
|
||||
const { id } = ctx.input.body;
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const comment = await Comment.unscoped().findByPk(id, {
|
||||
transaction,
|
||||
lock: Transaction.LOCK.UPDATE,
|
||||
});
|
||||
authorize(user, "delete", comment);
|
||||
|
||||
await commentDestroyer({
|
||||
user,
|
||||
comment,
|
||||
ip: ctx.request.ip,
|
||||
transaction,
|
||||
});
|
||||
|
||||
ctx.body = {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// router.post("comments.resolve", auth(), async (ctx) => {
|
||||
// router.post("comments.unresolve", auth(), async (ctx) => {
|
||||
|
||||
export default router;
|
||||
1
server/routes/api/comments/index.ts
Normal file
1
server/routes/api/comments/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "./comments";
|
||||
64
server/routes/api/comments/schema.ts
Normal file
64
server/routes/api/comments/schema.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod";
|
||||
import BaseSchema from "@server/routes/api/BaseSchema";
|
||||
|
||||
const CollectionsSortParamsSchema = z.object({
|
||||
/** Specifies the attributes by which documents will be sorted in the list */
|
||||
sort: z
|
||||
.string()
|
||||
.refine((val) => ["createdAt", "updatedAt"].includes(val))
|
||||
.default("createdAt"),
|
||||
|
||||
/** Specifies the sort order with respect to sort field */
|
||||
direction: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) => (val !== "ASC" ? "DESC" : val)),
|
||||
});
|
||||
|
||||
export const CommentsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Allow creation with a specific ID */
|
||||
id: z.string().uuid().optional(),
|
||||
|
||||
/** Create comment for this document */
|
||||
documentId: z.string(),
|
||||
|
||||
/** Create comment under this parent */
|
||||
parentCommentId: z.string().uuid().optional(),
|
||||
|
||||
/** Create comment with this data */
|
||||
data: z.any(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CommentsCreateReq = z.infer<typeof CommentsCreateSchema>;
|
||||
|
||||
export const CommentsUpdateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Which comment to update */
|
||||
id: z.string().uuid(),
|
||||
|
||||
/** Update comment with this data */
|
||||
data: z.any(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CommentsUpdateReq = z.infer<typeof CommentsUpdateSchema>;
|
||||
|
||||
export const CommentsDeleteSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
/** Which comment to delete */
|
||||
id: z.string().uuid(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CommentsDeleteReq = z.infer<typeof CommentsDeleteSchema>;
|
||||
|
||||
export const CollectionsListSchema = BaseSchema.extend({
|
||||
body: CollectionsSortParamsSchema.extend({
|
||||
/** Id of a document to list comments for */
|
||||
documentId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type CollectionsListReq = z.infer<typeof CollectionsListSchema>;
|
||||
@@ -14,7 +14,8 @@ import attachments from "./attachments";
|
||||
import auth from "./auth";
|
||||
import authenticationProviders from "./authenticationProviders";
|
||||
import collections from "./collections";
|
||||
import utils from "./cron";
|
||||
import comments from "./comments/comments";
|
||||
import cron from "./cron";
|
||||
import developer from "./developer";
|
||||
import documents from "./documents";
|
||||
import events from "./events";
|
||||
@@ -67,6 +68,7 @@ router.use("/", authenticationProviders.routes());
|
||||
router.use("/", events.routes());
|
||||
router.use("/", users.routes());
|
||||
router.use("/", collections.routes());
|
||||
router.use("/", comments.routes());
|
||||
router.use("/", documents.routes());
|
||||
router.use("/", pins.routes());
|
||||
router.use("/", revisions.routes());
|
||||
@@ -80,7 +82,7 @@ router.use("/", teams.routes());
|
||||
router.use("/", integrations.routes());
|
||||
router.use("/", notificationSettings.routes());
|
||||
router.use("/", attachments.routes());
|
||||
router.use("/", utils.routes());
|
||||
router.use("/", cron.routes());
|
||||
router.use("/", groups.routes());
|
||||
router.use("/", fileOperationsRoute.routes());
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ export const TeamsUpdateSchema = BaseSchema.extend({
|
||||
publicBranding: z.boolean().optional(),
|
||||
/** Whether viewers should see download options. */
|
||||
viewersCanExport: z.boolean().optional(),
|
||||
/** Whether commenting is enabled */
|
||||
commenting: z.boolean().optional(),
|
||||
/** The custom theme for the team. */
|
||||
customTheme: z
|
||||
.object({
|
||||
|
||||
@@ -284,6 +284,18 @@ async function authenticated(io: IO.Server, socket: SocketWithAuth) {
|
||||
documentId: event.documentId,
|
||||
isEditing: event.isEditing,
|
||||
});
|
||||
|
||||
socket.on("typing", async (event) => {
|
||||
const room = `document-${event.documentId}`;
|
||||
|
||||
if (event.documentId && socket.rooms[room]) {
|
||||
io.to(room).emit("user.typing", {
|
||||
userId: user.id,
|
||||
documentId: event.documentId,
|
||||
commentId: event.commentId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,6 +279,13 @@ export type PinEvent = BaseEvent & {
|
||||
collectionId?: string;
|
||||
};
|
||||
|
||||
export type CommentEvent = BaseEvent & {
|
||||
name: "comments.create" | "comments.update" | "comments.delete";
|
||||
modelId: string;
|
||||
documentId: string;
|
||||
actorId: string;
|
||||
};
|
||||
|
||||
export type StarEvent = BaseEvent & {
|
||||
name: "stars.create" | "stars.update" | "stars.delete";
|
||||
modelId: string;
|
||||
@@ -332,6 +339,7 @@ export type Event =
|
||||
| AuthenticationProviderEvent
|
||||
| DocumentEvent
|
||||
| PinEvent
|
||||
| CommentEvent
|
||||
| StarEvent
|
||||
| CollectionEvent
|
||||
| FileOperationEvent
|
||||
|
||||
13
shared/editor/commands/collapseSelection.ts
Normal file
13
shared/editor/commands/collapseSelection.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { EditorState, TextSelection } from "prosemirror-state";
|
||||
import { Dispatch } from "../types";
|
||||
|
||||
const collapseSelection = () => (state: EditorState, dispatch?: Dispatch) => {
|
||||
dispatch?.(
|
||||
state.tr.setSelection(
|
||||
TextSelection.create(state.doc, state.tr.selection.from)
|
||||
)
|
||||
);
|
||||
return true;
|
||||
};
|
||||
|
||||
export default collapseSelection;
|
||||
@@ -567,6 +567,17 @@ h6 {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.comment {
|
||||
border-bottom: 2px solid ${transparentize(0.5, props.theme.brand.marine)};
|
||||
transition: background 100ms ease-in-out;
|
||||
cursor: pointer;
|
||||
border-radius: 2px;
|
||||
|
||||
&:hover {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
}
|
||||
}
|
||||
|
||||
.notice-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1456,6 +1467,11 @@ del[data-operation-index] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.comment {
|
||||
border: 0;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.page-break {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export default class Extension {
|
||||
commands(_options: {
|
||||
type?: NodeType | MarkType;
|
||||
schema: Schema;
|
||||
}): Record<string, CommandFactory> | CommandFactory {
|
||||
}): Record<string, CommandFactory> | CommandFactory | undefined {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export default class ExtensionManager {
|
||||
Object.entries(value).forEach(([commandName, commandValue]) => {
|
||||
handle(commandName, commandValue);
|
||||
});
|
||||
} else {
|
||||
} else if (value) {
|
||||
handle(name, value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import headingToSlug from "./headingToSlug";
|
||||
|
||||
export type Heading = {
|
||||
title: string;
|
||||
level: number;
|
||||
id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the headings and their level.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @returns Array<Heading>
|
||||
*/
|
||||
export default function getHeadings(doc: Node) {
|
||||
const headings: Heading[] = [];
|
||||
const previouslySeen = {};
|
||||
|
||||
doc.forEach((node) => {
|
||||
if (node.type.name === "heading") {
|
||||
// calculate the optimal id
|
||||
const id = headingToSlug(node);
|
||||
let name = id;
|
||||
|
||||
// check if we've already used it, and if so how many times?
|
||||
// Make the new id based on that number ensuring that we have
|
||||
// unique ID's even when headings are identical
|
||||
if (previouslySeen[id] > 0) {
|
||||
name = headingToSlug(node, previouslySeen[id]);
|
||||
}
|
||||
|
||||
// record that we've seen this id for the next loop
|
||||
previouslySeen[id] =
|
||||
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
|
||||
|
||||
headings.push({
|
||||
title: node.textContent,
|
||||
level: node.attrs.level,
|
||||
id: name,
|
||||
});
|
||||
}
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Node as ProsemirrorNode, Mark } from "prosemirror-model";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import Node from "../nodes/Node";
|
||||
|
||||
export default function getMarkAttrs(state: EditorState, type: Node) {
|
||||
const { from, to } = state.selection;
|
||||
let marks: Mark[] = [];
|
||||
|
||||
state.doc.nodesBetween(from, to, (node: ProsemirrorNode) => {
|
||||
marks = [...marks, ...node.marks];
|
||||
|
||||
if (node.content) {
|
||||
node.content.forEach((content) => {
|
||||
marks = [...marks, ...content.marks];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const mark = marks.find((markItem) => markItem.type.name === type.name);
|
||||
|
||||
if (mark) {
|
||||
return mark.attrs;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
|
||||
export type Task = {
|
||||
text: string;
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the tasks and their completion
|
||||
* state.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @returns Array<Task>
|
||||
*/
|
||||
export default function getTasks(doc: Node): Task[] {
|
||||
const tasks: Task[] = [];
|
||||
|
||||
doc.descendants((node) => {
|
||||
if (!node.isBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.type.name === "checkbox_list") {
|
||||
node.content.forEach((listItem) => {
|
||||
let text = "";
|
||||
|
||||
listItem.forEach((contentNode) => {
|
||||
if (contentNode.type.name === "paragraph") {
|
||||
text += contentNode.textContent;
|
||||
}
|
||||
});
|
||||
|
||||
tasks.push({
|
||||
text,
|
||||
completed: listItem.attrs.checked,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
107
shared/editor/marks/Comment.ts
Normal file
107
shared/editor/marks/Comment.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { toggleMark } from "prosemirror-commands";
|
||||
import { MarkSpec, MarkType, Schema } from "prosemirror-model";
|
||||
import { EditorState, Plugin } from "prosemirror-state";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import collapseSelection from "../commands/collapseSelection";
|
||||
import { Command } from "../lib/Extension";
|
||||
import chainTransactions from "../lib/chainTransactions";
|
||||
import isMarkActive from "../queries/isMarkActive";
|
||||
import { Dispatch } from "../types";
|
||||
import Mark from "./Mark";
|
||||
|
||||
export default class Comment extends Mark {
|
||||
get name() {
|
||||
return "comment";
|
||||
}
|
||||
|
||||
get schema(): MarkSpec {
|
||||
return {
|
||||
attrs: {
|
||||
id: {},
|
||||
userId: {},
|
||||
},
|
||||
inclusive: false,
|
||||
parseDOM: [{ tag: "span.comment" }],
|
||||
toDOM: (node) => [
|
||||
"span",
|
||||
{ class: "comment", id: `comment-${node.attrs.id}` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
keys({ type }: { type: MarkType }): Record<string, Command> {
|
||||
return this.options.onCreateCommentMark
|
||||
? {
|
||||
"Mod-Alt-m": (state: EditorState, dispatch: Dispatch) => {
|
||||
if (isMarkActive(state.schema.marks.comment)(state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chainTransactions(
|
||||
toggleMark(type, {
|
||||
id: uuidv4(),
|
||||
userId: this.options.userId,
|
||||
}),
|
||||
collapseSelection()
|
||||
)(state, dispatch);
|
||||
|
||||
return true;
|
||||
},
|
||||
}
|
||||
: {};
|
||||
}
|
||||
|
||||
commands({ type }: { type: MarkType; schema: Schema }) {
|
||||
return this.options.onCreateCommentMark
|
||||
? () => (state: EditorState, dispatch: Dispatch) => {
|
||||
if (isMarkActive(state.schema.marks.comment)(state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
chainTransactions(
|
||||
toggleMark(type, {
|
||||
id: uuidv4(),
|
||||
userId: this.options.userId,
|
||||
}),
|
||||
collapseSelection()
|
||||
)(state, dispatch);
|
||||
|
||||
return true;
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
toMarkdown() {
|
||||
return {
|
||||
open: "",
|
||||
close: "",
|
||||
mixable: true,
|
||||
expelEnclosingWhitespace: true,
|
||||
};
|
||||
}
|
||||
|
||||
get plugins(): Plugin[] {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousedown: (view, event: MouseEvent) => {
|
||||
if (
|
||||
!(event.target instanceof HTMLSpanElement) ||
|
||||
!event.target.classList.contains("comment")
|
||||
) {
|
||||
this.options?.onClickCommentMark?.();
|
||||
return false;
|
||||
}
|
||||
|
||||
const commentId = event.target.id.replace("comment-", "");
|
||||
this.options?.onClickCommentMark?.(commentId);
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -113,10 +113,6 @@ export default class Link extends Mark {
|
||||
];
|
||||
}
|
||||
|
||||
commands({ type }: { type: MarkType }) {
|
||||
return ({ href } = { href: "" }) => toggleMark(type, { href });
|
||||
}
|
||||
|
||||
keys({ type }: { type: MarkType }) {
|
||||
return {
|
||||
"Mod-k": (state: EditorState, dispatch: Dispatch) => {
|
||||
|
||||
@@ -39,7 +39,12 @@ export default abstract class Mark extends Extension {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
commands({ type }: { type: MarkType; schema: Schema }): CommandFactory {
|
||||
return () => toggleMark(type);
|
||||
commands({
|
||||
type,
|
||||
}: {
|
||||
type: MarkType;
|
||||
schema: Schema;
|
||||
}): Record<string, CommandFactory> | CommandFactory | undefined {
|
||||
return (attrs) => toggleMark(type, attrs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import Strikethrough from "../marks/Strikethrough";
|
||||
import Underline from "../marks/Underline";
|
||||
import Doc from "../nodes/Doc";
|
||||
import Emoji from "../nodes/Emoji";
|
||||
import HardBreak from "../nodes/HardBreak";
|
||||
import Image from "../nodes/Image";
|
||||
import Node from "../nodes/Node";
|
||||
import Paragraph from "../nodes/Paragraph";
|
||||
@@ -16,6 +15,7 @@ import Text from "../nodes/Text";
|
||||
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
|
||||
import DateTime from "../plugins/DateTime";
|
||||
import History from "../plugins/History";
|
||||
import Keys from "../plugins/Keys";
|
||||
import MaxLength from "../plugins/MaxLength";
|
||||
import PasteHandler from "../plugins/PasteHandler";
|
||||
import Placeholder from "../plugins/Placeholder";
|
||||
@@ -24,7 +24,6 @@ import TrailingNode from "../plugins/TrailingNode";
|
||||
|
||||
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
||||
Doc,
|
||||
HardBreak,
|
||||
Paragraph,
|
||||
Emoji,
|
||||
Text,
|
||||
@@ -42,6 +41,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
||||
Placeholder,
|
||||
MaxLength,
|
||||
DateTime,
|
||||
Keys,
|
||||
ClipboardTextSerializer,
|
||||
];
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import CheckboxList from "../nodes/CheckboxList";
|
||||
import CodeBlock from "../nodes/CodeBlock";
|
||||
import CodeFence from "../nodes/CodeFence";
|
||||
import Embed from "../nodes/Embed";
|
||||
import HardBreak from "../nodes/HardBreak";
|
||||
import Heading from "../nodes/Heading";
|
||||
import HorizontalRule from "../nodes/HorizontalRule";
|
||||
import ListItem from "../nodes/ListItem";
|
||||
@@ -24,11 +25,11 @@ import TableHeadCell from "../nodes/TableHeadCell";
|
||||
import TableRow from "../nodes/TableRow";
|
||||
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
|
||||
import Folding from "../plugins/Folding";
|
||||
import Keys from "../plugins/Keys";
|
||||
import basicPackage from "./basic";
|
||||
|
||||
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
||||
...basicPackage,
|
||||
HardBreak,
|
||||
CodeBlock,
|
||||
CodeFence,
|
||||
CheckboxList,
|
||||
@@ -49,7 +50,6 @@ const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
||||
Highlight,
|
||||
TemplatePlaceholder,
|
||||
Folding,
|
||||
Keys,
|
||||
BlockMenuTrigger,
|
||||
Math,
|
||||
MathBlock,
|
||||
|
||||
13
shared/editor/packages/fullWithComments.ts
Normal file
13
shared/editor/packages/fullWithComments.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Extension from "../lib/Extension";
|
||||
import Comment from "../marks/Comment";
|
||||
import Mark from "../marks/Mark";
|
||||
import Node from "../nodes/Node";
|
||||
import fullPackage from "./full";
|
||||
|
||||
const fullWithCommentsPackage: (
|
||||
| typeof Node
|
||||
| typeof Mark
|
||||
| typeof Extension
|
||||
)[] = [...fullPackage, Comment];
|
||||
|
||||
export default fullWithCommentsPackage;
|
||||
@@ -98,7 +98,7 @@
|
||||
"Viewers": "Viewers",
|
||||
"I’m sure – Delete": "I’m sure – Delete",
|
||||
"Deleting": "Deleting",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
|
||||
"Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view – deleting it will reset the start view to the Home page.",
|
||||
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||
"Add a description": "Add a description",
|
||||
@@ -106,6 +106,8 @@
|
||||
"Expand": "Expand",
|
||||
"Type a command or search": "Type a command or search",
|
||||
"Open search from anywhere with the {{ shortcut }} shortcut": "Open search from anywhere with the {{ shortcut }} shortcut",
|
||||
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
|
||||
"Are you sure you want to permanently delete this comment?": "Are you sure you want to permanently delete this comment?",
|
||||
"Server connection lost": "Server connection lost",
|
||||
"Edits you make will sync once you’re online": "Edits you make will sync once you’re online",
|
||||
"Submenu": "Submenu",
|
||||
@@ -138,10 +140,6 @@
|
||||
"in": "in",
|
||||
"nested document": "nested document",
|
||||
"nested document_plural": "nested documents",
|
||||
"Viewed by": "Viewed by",
|
||||
"only you": "only you",
|
||||
"person": "person",
|
||||
"people": "people",
|
||||
"{{ total }} task": "{{ total }} task",
|
||||
"{{ total }} task_plural": "{{ total }} tasks",
|
||||
"{{ completed }} task done": "{{ completed }} task done",
|
||||
@@ -246,6 +244,7 @@
|
||||
"Code block": "Code block",
|
||||
"Copied to clipboard": "Copied to clipboard",
|
||||
"Code": "Code",
|
||||
"Comment": "Comment",
|
||||
"Copy": "Copy",
|
||||
"Create link": "Create link",
|
||||
"Sorry, an error occurred creating the link": "Sorry, an error occurred creating the link",
|
||||
@@ -320,10 +319,12 @@
|
||||
"Group member options": "Group member options",
|
||||
"Remove": "Remove",
|
||||
"Export collection": "Export collection",
|
||||
"Delete collection": "Are you sure you want to delete this collection?",
|
||||
"Delete collection": "Delete collection",
|
||||
"Sort in sidebar": "Sort in sidebar",
|
||||
"Alphabetical sort": "Alphabetical sort",
|
||||
"Manual sort": "Manual sort",
|
||||
"Delete comment": "Delete comment",
|
||||
"Comment options": "Comment options",
|
||||
"Document options": "Document options",
|
||||
"Restore": "Restore",
|
||||
"Choose a collection": "Choose a collection",
|
||||
@@ -432,9 +433,24 @@
|
||||
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
|
||||
"Signing in": "Signing in",
|
||||
"You can safely close this window once the Outline desktop app has opened": "You can safely close this window once the Outline desktop app has opened",
|
||||
"Error creating comment": "Error creating comment",
|
||||
"Add a comment": "Add a comment",
|
||||
"Add a reply": "Add a reply",
|
||||
"Post": "Post",
|
||||
"Reply": "Reply",
|
||||
"Cancel": "Cancel",
|
||||
"Comments": "Comments",
|
||||
"No comments yet": "No comments yet",
|
||||
"Error updating comment": "Error updating comment",
|
||||
"Document updated by {{userName}}": "Document updated by {{userName}}",
|
||||
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
|
||||
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
|
||||
"Viewed by": "Viewed by",
|
||||
"only you": "only you",
|
||||
"person": "person",
|
||||
"people": "people",
|
||||
"{{ count }} comment": "{{ count }} comment",
|
||||
"{{ count }} comment_plural": "{{ count }} comments",
|
||||
"Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…",
|
||||
"Hide contents": "Hide contents",
|
||||
"Show contents": "Show contents",
|
||||
@@ -503,7 +519,7 @@
|
||||
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
|
||||
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>one nested document</em>.",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
|
||||
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested documents</em>.",
|
||||
"If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
|
||||
"Archiving": "Archiving",
|
||||
@@ -522,7 +538,6 @@
|
||||
"no access": "no access",
|
||||
"Heads up – moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}.": "Heads up – moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}.",
|
||||
"Moving": "Moving",
|
||||
"Cancel": "Cancel",
|
||||
"Search documents": "Search documents",
|
||||
"No documents found for your filters.": "No documents found for your filters.",
|
||||
"You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",
|
||||
|
||||
@@ -124,6 +124,9 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
|
||||
backdrop: "rgba(0, 0, 0, 0.2)",
|
||||
shadow: "rgba(0, 0, 0, 0.2)",
|
||||
|
||||
commentBackground: colors.warmGrey,
|
||||
commentActiveBackground: "#d7e0ea",
|
||||
|
||||
modalBackdrop: colors.black10,
|
||||
modalBackground: colors.white,
|
||||
modalShadow:
|
||||
@@ -189,6 +192,9 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
|
||||
backdrop: "rgba(0, 0, 0, 0.5)",
|
||||
shadow: "rgba(0, 0, 0, 0.6)",
|
||||
|
||||
commentBackground: colors.veryDarkBlue,
|
||||
commentActiveBackground: colors.black,
|
||||
|
||||
modalBackdrop: colors.black50,
|
||||
modalBackground: "#1f2128",
|
||||
modalShadow:
|
||||
|
||||
@@ -120,6 +120,8 @@ export enum TeamPreference {
|
||||
PublicBranding = "publicBranding",
|
||||
/** Whether viewers should see download options. */
|
||||
ViewersCanExport = "viewersCanExport",
|
||||
/** Whether users can comment on documents. */
|
||||
Commenting = "commenting",
|
||||
/** The custom theme for the team. */
|
||||
CustomTheme = "customTheme",
|
||||
}
|
||||
@@ -128,6 +130,7 @@ export type TeamPreferences = {
|
||||
[TeamPreference.SeamlessEdit]?: boolean;
|
||||
[TeamPreference.PublicBranding]?: boolean;
|
||||
[TeamPreference.ViewersCanExport]?: boolean;
|
||||
[TeamPreference.Commenting]?: boolean;
|
||||
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
|
||||
};
|
||||
|
||||
|
||||
155
shared/utils/ProsemirrorHelper.ts
Normal file
155
shared/utils/ProsemirrorHelper.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import headingToSlug from "../editor/lib/headingToSlug";
|
||||
|
||||
export type Heading = {
|
||||
/* The heading in plain text */
|
||||
title: string;
|
||||
/* The level of the heading */
|
||||
level: number;
|
||||
/* The unique id of the heading */
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type CommentMark = {
|
||||
/* The unique id of the comment */
|
||||
id: string;
|
||||
/* The id of the user who created the comment */
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type Task = {
|
||||
/* The text of the task */
|
||||
text: string;
|
||||
/* Whether the task is completed or not */
|
||||
completed: boolean;
|
||||
};
|
||||
|
||||
export default class ProsemirrorHelper {
|
||||
/**
|
||||
* Removes any empty paragraphs from the beginning and end of the document.
|
||||
*
|
||||
* @returns True if the editor is empty
|
||||
*/
|
||||
static trim(doc: Node) {
|
||||
const first = doc.firstChild;
|
||||
const last = doc.lastChild;
|
||||
const firstIsEmpty =
|
||||
first?.type.name === "paragraph" && !first.textContent.trim();
|
||||
const lastIsEmpty =
|
||||
last?.type.name === "paragraph" && !last.textContent.trim();
|
||||
const firstIsLast = first === last;
|
||||
|
||||
return doc.cut(
|
||||
firstIsEmpty ? first.nodeSize : 0,
|
||||
lastIsEmpty && !firstIsLast ? doc.nodeSize - last.nodeSize : undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the trimmed content of the passed document is an empty
|
||||
* string.
|
||||
*
|
||||
* @returns True if the editor is empty
|
||||
*/
|
||||
static isEmpty(doc: Node) {
|
||||
return !doc || doc.textContent.trim() === "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the comments that exist as
|
||||
* marks.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @returns Array<CommentMark>
|
||||
*/
|
||||
static getComments(doc: Node): CommentMark[] {
|
||||
const comments: CommentMark[] = [];
|
||||
|
||||
doc.descendants((node) => {
|
||||
node.marks.forEach((mark) => {
|
||||
if (mark.type.name === "comment") {
|
||||
comments.push(mark.attrs as CommentMark);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return comments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the tasks and their completion
|
||||
* state.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @returns Array<Task>
|
||||
*/
|
||||
static getTasks(doc: Node): Task[] {
|
||||
const tasks: Task[] = [];
|
||||
|
||||
doc.descendants((node) => {
|
||||
if (!node.isBlock) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (node.type.name === "checkbox_list") {
|
||||
node.content.forEach((listItem) => {
|
||||
let text = "";
|
||||
|
||||
listItem.forEach((contentNode) => {
|
||||
if (contentNode.type.name === "paragraph") {
|
||||
text += contentNode.textContent;
|
||||
}
|
||||
});
|
||||
|
||||
tasks.push({
|
||||
text,
|
||||
completed: listItem.attrs.checked,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates through the document to find all of the headings and their level.
|
||||
*
|
||||
* @param doc Prosemirror document node
|
||||
* @returns Array<Heading>
|
||||
*/
|
||||
static getHeadings(doc: Node) {
|
||||
const headings: Heading[] = [];
|
||||
const previouslySeen = {};
|
||||
|
||||
doc.forEach((node) => {
|
||||
if (node.type.name === "heading") {
|
||||
// calculate the optimal id
|
||||
const id = headingToSlug(node);
|
||||
let name = id;
|
||||
|
||||
// check if we've already used it, and if so how many times?
|
||||
// Make the new id based on that number ensuring that we have
|
||||
// unique ID's even when headings are identical
|
||||
if (previouslySeen[id] > 0) {
|
||||
name = headingToSlug(node, previouslySeen[id]);
|
||||
}
|
||||
|
||||
// record that we've seen this id for the next loop
|
||||
previouslySeen[id] =
|
||||
previouslySeen[id] !== undefined ? previouslySeen[id] + 1 : 1;
|
||||
|
||||
headings.push({
|
||||
title: node.textContent,
|
||||
level: node.attrs.level,
|
||||
id: name,
|
||||
});
|
||||
}
|
||||
});
|
||||
return headings;
|
||||
}
|
||||
}
|
||||
11
shared/utils/time.ts
Normal file
11
shared/utils/time.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/** A second in ms */
|
||||
export const Second = 1000;
|
||||
|
||||
/** A minute in ms */
|
||||
export const Minute = 60 * Second;
|
||||
|
||||
/** An hour in ms */
|
||||
export const Hour = 60 * Minute;
|
||||
|
||||
/** A day in ms */
|
||||
export const Day = 24 * Hour;
|
||||
@@ -27,6 +27,11 @@ export const CollectionValidation = {
|
||||
maxNameLength: 100,
|
||||
};
|
||||
|
||||
export const CommentValidation = {
|
||||
/** The maximum length of a comment */
|
||||
maxLength: 1000,
|
||||
};
|
||||
|
||||
export const DocumentValidation = {
|
||||
/** The maximum length of the document title */
|
||||
maxTitleLength: 100,
|
||||
|
||||
Reference in New Issue
Block a user