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 { observer, useLocalStore } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
import { Switch, Route, useLocation, matchPath } from "react-router-dom";
|
||||||
|
import { TeamPreference } from "@shared/types";
|
||||||
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
import ErrorSuspended from "~/scenes/ErrorSuspended";
|
||||||
import DocumentContext from "~/components/DocumentContext";
|
import DocumentContext from "~/components/DocumentContext";
|
||||||
import type { DocumentContextValue } from "~/components/DocumentContext";
|
import type { DocumentContextValue } from "~/components/DocumentContext";
|
||||||
@@ -16,14 +17,17 @@ import useStores from "~/hooks/useStores";
|
|||||||
import history from "~/utils/history";
|
import history from "~/utils/history";
|
||||||
import {
|
import {
|
||||||
searchPath,
|
searchPath,
|
||||||
matchDocumentSlug as slug,
|
|
||||||
newDocumentPath,
|
newDocumentPath,
|
||||||
settingsPath,
|
settingsPath,
|
||||||
matchDocumentHistory,
|
matchDocumentHistory,
|
||||||
|
matchDocumentSlug as slug,
|
||||||
matchDocumentInsights,
|
matchDocumentInsights,
|
||||||
} from "~/utils/routeHelpers";
|
} from "~/utils/routeHelpers";
|
||||||
import Fade from "./Fade";
|
import Fade from "./Fade";
|
||||||
|
|
||||||
|
const DocumentComments = React.lazy(
|
||||||
|
() => import("~/scenes/Document/components/Comments")
|
||||||
|
);
|
||||||
const DocumentHistory = React.lazy(
|
const DocumentHistory = React.lazy(
|
||||||
() => import("~/scenes/Document/components/History")
|
() => import("~/scenes/Document/components/History")
|
||||||
);
|
);
|
||||||
@@ -84,15 +88,21 @@ const AuthenticatedLayout: React.FC = ({ children }) => {
|
|||||||
const showInsights = !!matchPath(location.pathname, {
|
const showInsights = !!matchPath(location.pathname, {
|
||||||
path: matchDocumentInsights,
|
path: matchDocumentInsights,
|
||||||
});
|
});
|
||||||
|
const showComments =
|
||||||
|
!showInsights &&
|
||||||
|
!showHistory &&
|
||||||
|
!ui.commentsCollapsed &&
|
||||||
|
team?.getPreference(TeamPreference.Commenting);
|
||||||
|
|
||||||
const sidebarRight = (
|
const sidebarRight = (
|
||||||
<AnimatePresence key={ui.activeDocumentId}>
|
<AnimatePresence>
|
||||||
{(showHistory || showInsights) && (
|
{(showHistory || showInsights || showComments) && (
|
||||||
<Route path={`/doc/${slug}`}>
|
<Route path={`/doc/${slug}`}>
|
||||||
<SidebarRight>
|
<SidebarRight>
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
{showHistory && <DocumentHistory />}
|
{showHistory && <DocumentHistory />}
|
||||||
{showInsights && <DocumentInsights />}
|
{showInsights && <DocumentInsights />}
|
||||||
|
{showComments && <DocumentComments />}
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</SidebarRight>
|
</SidebarRight>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -19,15 +19,16 @@ type Props = {
|
|||||||
showBorder?: boolean;
|
showBorder?: boolean;
|
||||||
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
onClick?: React.MouseEventHandler<HTMLImageElement>;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Avatar(props: Props) {
|
function Avatar(props: Props) {
|
||||||
const { icon, showBorder, model, ...rest } = props;
|
const { icon, showBorder, model, style, ...rest } = props;
|
||||||
const src = props.src || model?.avatarUrl;
|
const src = props.src || model?.avatarUrl;
|
||||||
const [error, handleError] = useBoolean(false);
|
const [error, handleError] = useBoolean(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Relative>
|
<Relative style={style}>
|
||||||
{src && !error ? (
|
{src && !error ? (
|
||||||
<CircleImg
|
<CircleImg
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
@@ -53,6 +54,7 @@ Avatar.defaultProps = {
|
|||||||
|
|
||||||
const Relative = styled.div`
|
const Relative = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
flex-shrink: 0;
|
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;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 32px;
|
|
||||||
margin: 24px;
|
margin: 24px;
|
||||||
|
transform: translateX(-32px);
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from "date-fns";
|
||||||
import { deburr, sortBy } from "lodash";
|
import { deburr, difference, sortBy } from "lodash";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
|
||||||
import { TextSelection } from "prosemirror-state";
|
import { TextSelection } from "prosemirror-state";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { mergeRefs } from "react-merge-refs";
|
import { mergeRefs } from "react-merge-refs";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
import { Optional } from "utility-types";
|
import { Optional } from "utility-types";
|
||||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
|
||||||
import { AttachmentPreset } from "@shared/types";
|
import { AttachmentPreset } from "@shared/types";
|
||||||
|
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||||
import { getDataTransferFiles } from "@shared/utils/files";
|
import { getDataTransferFiles } from "@shared/utils/files";
|
||||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||||
import { isInternalUrl } from "@shared/utils/urls";
|
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 type { Props as EditorProps, Editor as SharedEditor } from "~/editor";
|
||||||
import useDictionary from "~/hooks/useDictionary";
|
import useDictionary from "~/hooks/useDictionary";
|
||||||
import useEmbeds from "~/hooks/useEmbeds";
|
import useEmbeds from "~/hooks/useEmbeds";
|
||||||
|
import useFocusedComment from "~/hooks/useFocusedComment";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
import { NotFoundError } from "~/utils/errors";
|
import { NotFoundError } from "~/utils/errors";
|
||||||
import { uploadFile } from "~/utils/files";
|
import { uploadFile } from "~/utils/files";
|
||||||
import history from "~/utils/history";
|
|
||||||
import { isModKey } from "~/utils/keyboard";
|
import { isModKey } from "~/utils/keyboard";
|
||||||
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
import { sharedDocumentPath } from "~/utils/routeHelpers";
|
||||||
import { isHash } from "~/utils/urls";
|
import { isHash } from "~/utils/urls";
|
||||||
@@ -51,11 +52,20 @@ export type Props = Optional<
|
|||||||
};
|
};
|
||||||
|
|
||||||
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
||||||
const { id, shareId, onChange, onHeadingsChange } = props;
|
const {
|
||||||
const { documents, auth } = useStores();
|
id,
|
||||||
|
shareId,
|
||||||
|
onChange,
|
||||||
|
onHeadingsChange,
|
||||||
|
onCreateCommentMark,
|
||||||
|
onDeleteCommentMark,
|
||||||
|
} = props;
|
||||||
|
const { auth, comments, documents } = useStores();
|
||||||
|
const focusedComment = useFocusedComment();
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const dictionary = useDictionary();
|
const dictionary = useDictionary();
|
||||||
const embeds = useEmbeds(!shareId);
|
const embeds = useEmbeds(!shareId);
|
||||||
|
const history = useHistory();
|
||||||
const localRef = React.useRef<SharedEditor>();
|
const localRef = React.useRef<SharedEditor>();
|
||||||
const preferences = auth.user?.preferences;
|
const preferences = auth.user?.preferences;
|
||||||
const previousHeadings = React.useRef<Heading[] | null>(null);
|
const previousHeadings = React.useRef<Heading[] | null>(null);
|
||||||
@@ -63,6 +73,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
activeLinkElement,
|
activeLinkElement,
|
||||||
setActiveLink,
|
setActiveLink,
|
||||||
] = React.useState<HTMLAnchorElement | null>(null);
|
] = React.useState<HTMLAnchorElement | null>(null);
|
||||||
|
const previousCommentIds = React.useRef<string[]>();
|
||||||
|
|
||||||
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
const handleLinkActive = React.useCallback((element: HTMLAnchorElement) => {
|
||||||
setActiveLink(element);
|
setActiveLink(element);
|
||||||
@@ -125,7 +136,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
[documents]
|
[documents]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onUploadFile = React.useCallback(
|
const handleUploadFile = React.useCallback(
|
||||||
async (file: File) => {
|
async (file: File) => {
|
||||||
const result = await uploadFile(file, {
|
const result = await uploadFile(file, {
|
||||||
documentId: id,
|
documentId: id,
|
||||||
@@ -136,7 +147,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const onClickLink = React.useCallback(
|
const handleClickLink = React.useCallback(
|
||||||
(href: string, event: MouseEvent) => {
|
(href: string, event: MouseEvent) => {
|
||||||
// on page hash
|
// on page hash
|
||||||
if (isHash(href)) {
|
if (isHash(href)) {
|
||||||
@@ -175,7 +186,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
window.open(href, "_blank");
|
window.open(href, "_blank");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[shareId]
|
[history, shareId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const focusAtEnd = React.useCallback(() => {
|
const focusAtEnd = React.useCallback(() => {
|
||||||
@@ -223,7 +234,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
insertFiles(view, event, pos, files, {
|
insertFiles(view, event, pos, files, {
|
||||||
uploadFile: onUploadFile,
|
uploadFile: handleUploadFile,
|
||||||
onFileUploadStart: props.onFileUploadStart,
|
onFileUploadStart: props.onFileUploadStart,
|
||||||
onFileUploadStop: props.onFileUploadStop,
|
onFileUploadStop: props.onFileUploadStop,
|
||||||
onShowToast: showToast,
|
onShowToast: showToast,
|
||||||
@@ -236,7 +247,7 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
props.onFileUploadStart,
|
props.onFileUploadStart,
|
||||||
props.onFileUploadStop,
|
props.onFileUploadStop,
|
||||||
dictionary,
|
dictionary,
|
||||||
onUploadFile,
|
handleUploadFile,
|
||||||
showToast,
|
showToast,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@@ -265,21 +276,54 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
}
|
}
|
||||||
}, [localRef, onHeadingsChange]);
|
}, [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(
|
const handleChange = React.useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
onChange?.(event);
|
onChange?.(event);
|
||||||
updateHeadings();
|
updateHeadings();
|
||||||
|
updateComments();
|
||||||
},
|
},
|
||||||
[onChange, updateHeadings]
|
[onChange, updateComments, updateHeadings]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRefChanged = React.useCallback(
|
const handleRefChanged = React.useCallback(
|
||||||
(node: SharedEditor | null) => {
|
(node: SharedEditor | null) => {
|
||||||
if (node) {
|
if (node) {
|
||||||
updateHeadings();
|
updateHeadings();
|
||||||
|
updateComments();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[updateHeadings]
|
[updateComments, updateHeadings]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -287,18 +331,19 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
<>
|
<>
|
||||||
<LazyLoadedEditor
|
<LazyLoadedEditor
|
||||||
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
ref={mergeRefs([ref, localRef, handleRefChanged])}
|
||||||
uploadFile={onUploadFile}
|
uploadFile={handleUploadFile}
|
||||||
onShowToast={showToast}
|
onShowToast={showToast}
|
||||||
embeds={embeds}
|
embeds={embeds}
|
||||||
userPreferences={preferences}
|
userPreferences={preferences}
|
||||||
dictionary={dictionary}
|
dictionary={dictionary}
|
||||||
{...props}
|
{...props}
|
||||||
onHoverLink={handleLinkActive}
|
onHoverLink={handleLinkActive}
|
||||||
onClickLink={onClickLink}
|
onClickLink={handleClickLink}
|
||||||
onSearchLink={handleSearchLink}
|
onSearchLink={handleSearchLink}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder={props.placeholder || ""}
|
placeholder={props.placeholder || ""}
|
||||||
defaultValue={props.defaultValue || ""}
|
defaultValue={props.defaultValue || ""}
|
||||||
|
focusedCommentId={focusedComment?.id}
|
||||||
/>
|
/>
|
||||||
{props.bottomPadding && !props.readOnly && (
|
{props.bottomPadding && !props.readOnly && (
|
||||||
<ClickablePadding
|
<ClickablePadding
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
import DocumentMeta from "~/components/DocumentMeta";
|
||||||
import Editor from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
|
|
||||||
@@ -30,10 +30,7 @@ function HoverPreviewDocument({ url, children }: Props) {
|
|||||||
{children(
|
{children(
|
||||||
<Content to={document.url}>
|
<Content to={document.url}>
|
||||||
<Heading>{document.titleWithDefault}</Heading>
|
<Heading>{document.titleWithDefault}</Heading>
|
||||||
<DocumentMetaWithViews
|
<DocumentMeta document={document} />
|
||||||
isDraft={document.isDraft}
|
|
||||||
document={document}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<React.Suspense fallback={<div />}>
|
<React.Suspense fallback={<div />}>
|
||||||
<Editor
|
<Editor
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export type Props = React.InputHTMLAttributes<
|
|||||||
margin?: string | number;
|
margin?: string | number;
|
||||||
error?: string;
|
error?: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
/* Callback is triggered with the CMD+Enter keyboard combo */
|
||||||
|
onRequestSubmit?: (ev: React.KeyboardEvent<HTMLInputElement>) => unknown;
|
||||||
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
onFocus?: (ev: React.SyntheticEvent) => unknown;
|
||||||
onBlur?: (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 {
|
const {
|
||||||
type = "text",
|
type = "text",
|
||||||
icon,
|
icon,
|
||||||
@@ -180,6 +196,7 @@ function Input(
|
|||||||
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
ref={ref as React.RefObject<HTMLTextAreaElement>}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
hasIcon={!!icon}
|
hasIcon={!!icon}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
@@ -188,6 +205,7 @@ function Input(
|
|||||||
ref={ref as React.RefObject<HTMLInputElement>}
|
ref={ref as React.RefObject<HTMLInputElement>}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
hasIcon={!!icon}
|
hasIcon={!!icon}
|
||||||
type={type}
|
type={type}
|
||||||
{...rest}
|
{...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 * as React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import useWindowSize from "~/hooks/useWindowSize";
|
import useWindowSize from "~/hooks/useWindowSize";
|
||||||
|
import { hideScrollbars } from "~/styles";
|
||||||
|
|
||||||
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
type Props = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
shadow?: boolean;
|
shadow?: boolean;
|
||||||
@@ -94,16 +95,7 @@ const Wrapper = styled.div<{
|
|||||||
}};
|
}};
|
||||||
transition: box-shadow 100ms ease-in-out;
|
transition: box-shadow 100ms ease-in-out;
|
||||||
|
|
||||||
${(props) =>
|
${(props) => props.$hiddenScrollbars && hideScrollbars()}
|
||||||
props.$hiddenScrollbars &&
|
|
||||||
`
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
overflow: -moz-scrollbars-none;
|
|
||||||
scrollbar-width: none;
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default observer(React.forwardRef(Scrollable));
|
export default observer(React.forwardRef(Scrollable));
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(
|
|||||||
const previousLocation = usePrevious(location);
|
const previousLocation = usePrevious(location);
|
||||||
const { isMenuOpen } = useMenuContext();
|
const { isMenuOpen } = useMenuContext();
|
||||||
const { user } = auth;
|
const { user } = auth;
|
||||||
|
|
||||||
const width = ui.sidebarWidth;
|
const width = ui.sidebarWidth;
|
||||||
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
|
||||||
const maxWidth = theme.sidebarMaxWidth;
|
const maxWidth = theme.sidebarMaxWidth;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import styled from "styled-components";
|
|||||||
type Props = {
|
type Props = {
|
||||||
type?: "secondary" | "tertiary" | "danger";
|
type?: "secondary" | "tertiary" | "danger";
|
||||||
size?: "large" | "small" | "xsmall";
|
size?: "large" | "small" | "xsmall";
|
||||||
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
selectable?: boolean;
|
||||||
weight?: "bold" | "normal";
|
weight?: "bold" | "normal";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -12,6 +14,7 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
const Text = styled.p<Props>`
|
const Text = styled.p<Props>`
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
|
text-align: ${(props) => (props.dir ? props.dir : "left")};
|
||||||
color: ${(props) =>
|
color: ${(props) =>
|
||||||
props.type === "secondary"
|
props.type === "secondary"
|
||||||
? props.theme.textSecondary
|
? props.theme.textSecondary
|
||||||
@@ -35,7 +38,7 @@ const Text = styled.p<Props>`
|
|||||||
? "normal"
|
? "normal"
|
||||||
: "inherit"};
|
: "inherit"};
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
user-select: none;
|
user-select: ${(props) => (props.selectable ? "text" : "none")};
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default Text;
|
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 { io, Socket } from "socket.io-client";
|
||||||
import RootStore from "~/stores/RootStore";
|
import RootStore from "~/stores/RootStore";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
|
import Comment from "~/models/Comment";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import FileOperation from "~/models/FileOperation";
|
import FileOperation from "~/models/FileOperation";
|
||||||
import Group from "~/models/Group";
|
import Group from "~/models/Group";
|
||||||
@@ -84,6 +85,7 @@ class WebsocketProvider extends React.Component<Props> {
|
|||||||
memberships,
|
memberships,
|
||||||
policies,
|
policies,
|
||||||
presence,
|
presence,
|
||||||
|
comments,
|
||||||
views,
|
views,
|
||||||
subscriptions,
|
subscriptions,
|
||||||
fileOperations,
|
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>) => {
|
this.socket.on("groups.create", (event: PartialWithId<Group>) => {
|
||||||
groups.add(event);
|
groups.add(event);
|
||||||
});
|
});
|
||||||
@@ -323,6 +339,13 @@ class WebsocketProvider extends React.Component<Props> {
|
|||||||
stars.remove(event.modelId);
|
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
|
// 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.
|
// if the user is us then we go ahead and load the collection from API.
|
||||||
this.socket.on(
|
this.socket.on(
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ function ToolbarMenu(props: Props) {
|
|||||||
const { items } = props;
|
const { items } = props;
|
||||||
const { state } = view;
|
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 (
|
return (
|
||||||
<FlexibleWrapper>
|
<FlexibleWrapper>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
@@ -34,10 +45,7 @@ function ToolbarMenu(props: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip tooltip={item.tooltip} key={index}>
|
<Tooltip tooltip={item.tooltip} key={index}>
|
||||||
<ToolbarButton
|
<ToolbarButton onClick={handleClick(item)} active={isActive}>
|
||||||
onClick={() => item.name && commands[item.name](item.attrs)}
|
|
||||||
active={isActive}
|
|
||||||
>
|
|
||||||
{React.cloneElement(item.icon, { color: "currentColor" })}
|
{React.cloneElement(item.icon, { color: "currentColor" })}
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* global File Promise */
|
/* global File Promise */
|
||||||
import { PluginSimple } from "markdown-it";
|
import { PluginSimple } from "markdown-it";
|
||||||
|
import { transparentize } from "polished";
|
||||||
import { baseKeymap } from "prosemirror-commands";
|
import { baseKeymap } from "prosemirror-commands";
|
||||||
import { dropCursor } from "prosemirror-dropcursor";
|
import { dropCursor } from "prosemirror-dropcursor";
|
||||||
import { gapCursor } from "prosemirror-gapcursor";
|
import { gapCursor } from "prosemirror-gapcursor";
|
||||||
@@ -15,13 +16,11 @@ import {
|
|||||||
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
||||||
import { Decoration, EditorView } from "prosemirror-view";
|
import { Decoration, EditorView } from "prosemirror-view";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
|
||||||
import EditorContainer from "@shared/editor/components/Styles";
|
import Styles from "@shared/editor/components/Styles";
|
||||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
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 { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||||
import textBetween from "@shared/editor/lib/textBetween";
|
import textBetween from "@shared/editor/lib/textBetween";
|
||||||
import Mark from "@shared/editor/marks/Mark";
|
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 fullExtensionsPackage from "@shared/editor/packages/full";
|
||||||
import { EventType } from "@shared/editor/types";
|
import { EventType } from "@shared/editor/types";
|
||||||
import { UserPreferences } from "@shared/types";
|
import { UserPreferences } from "@shared/types";
|
||||||
|
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
|
||||||
import EventEmitter from "@shared/utils/events";
|
import EventEmitter from "@shared/utils/events";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import { Dictionary } from "~/hooks/useDictionary";
|
import { Dictionary } from "~/hooks/useDictionary";
|
||||||
@@ -48,16 +48,20 @@ export { default as Extension } from "@shared/editor/lib/Extension";
|
|||||||
export type Props = {
|
export type Props = {
|
||||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||||
id?: string;
|
id?: string;
|
||||||
|
/** The current userId, if any */
|
||||||
|
userId?: string;
|
||||||
/** The editor content, should only be changed if you wish to reset the content */
|
/** The editor content, should only be changed if you wish to reset the content */
|
||||||
value?: string;
|
value?: string;
|
||||||
/** The initial editor content */
|
/** The initial editor content as a markdown string or JSON object */
|
||||||
defaultValue: string;
|
defaultValue: string | object;
|
||||||
/** Placeholder displayed when the editor is empty */
|
/** Placeholder displayed when the editor is empty */
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
/** Extensions to load into the editor */
|
/** Extensions to load into the editor */
|
||||||
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
|
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
|
||||||
/** If the editor should be focused on mount */
|
/** If the editor should be focused on mount */
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
/** The focused comment, if any */
|
||||||
|
focusedCommentId?: string;
|
||||||
/** If the editor should not allow editing */
|
/** If the editor should not allow editing */
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
/** If the editor should still allow editing checkboxes when it is readOnly */
|
/** 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 */
|
/** Callback when user uses cancel key combo */
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
/** Callback when user changes editor content */
|
/** 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 */
|
/** Callback when a file upload begins */
|
||||||
onFileUploadStart?: () => void;
|
onFileUploadStart?: () => void;
|
||||||
/** Callback when a file upload ends */
|
/** 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);
|
const doc = this.createDocument(value || this.props.defaultValue);
|
||||||
|
|
||||||
return EditorState.create({
|
return EditorState.create({
|
||||||
@@ -415,8 +425,13 @@ export class Editor extends React.PureComponent<
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private createDocument(content: string) {
|
private createDocument(content: string | object) {
|
||||||
return this.parser.parse(content);
|
// Looks like Markdown
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return this.parser.parse(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProsemirrorNode.fromJSON(this.schema, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
private createView() {
|
private createView() {
|
||||||
@@ -475,10 +490,6 @@ export class Editor extends React.PureComponent<
|
|||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatchThemeChanged = (event: CustomEvent) => {
|
|
||||||
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
|
|
||||||
};
|
|
||||||
|
|
||||||
public scrollToAnchor(hash: string) {
|
public scrollToAnchor(hash: string) {
|
||||||
if (!hash) {
|
if (!hash) {
|
||||||
return;
|
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 = () => {
|
private calculateDir = () => {
|
||||||
if (!this.element.current) {
|
if (!this.element.current) {
|
||||||
return;
|
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 = () => {
|
private handleChange = () => {
|
||||||
@@ -520,8 +641,8 @@ export class Editor extends React.PureComponent<
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.onChange(() => {
|
this.props.onChange((asString = true, trim = false) => {
|
||||||
return this.view ? this.value() : undefined;
|
return this.view ? this.value(asString, trim) : undefined;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -583,60 +704,6 @@ export class Editor extends React.PureComponent<
|
|||||||
this.setState({ blockMenuOpen: false });
|
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() {
|
public render() {
|
||||||
const {
|
const {
|
||||||
dir,
|
dir,
|
||||||
@@ -658,7 +725,6 @@ export class Editor extends React.PureComponent<
|
|||||||
className={className}
|
className={className}
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
justify="center"
|
justify="center"
|
||||||
dir={dir}
|
|
||||||
column
|
column
|
||||||
>
|
>
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
@@ -667,6 +733,7 @@ export class Editor extends React.PureComponent<
|
|||||||
grow={grow}
|
grow={grow}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
|
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
|
||||||
|
focusedCommentId={this.props.focusedCommentId}
|
||||||
ref={this.element}
|
ref={this.element}
|
||||||
/>
|
/>
|
||||||
{!readOnly && this.view && (
|
{!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>(
|
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
|
||||||
(props: Props, ref) => {
|
(props: Props, ref) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TodoListIcon,
|
TodoListIcon,
|
||||||
InputIcon,
|
InputIcon,
|
||||||
HighlightIcon,
|
HighlightIcon,
|
||||||
|
CommentIcon,
|
||||||
ItalicIcon,
|
ItalicIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import { EditorState } from "prosemirror-state";
|
import { EditorState } from "prosemirror-state";
|
||||||
@@ -146,5 +147,12 @@ export default function formattingMenuItems(
|
|||||||
attrs: { href: "" },
|
attrs: { href: "" },
|
||||||
visible: !isCode,
|
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"),
|
codeBlock: t("Code block"),
|
||||||
codeCopied: t("Copied to clipboard"),
|
codeCopied: t("Copied to clipboard"),
|
||||||
codeInline: t("Code"),
|
codeInline: t("Code"),
|
||||||
|
comment: t("Comment"),
|
||||||
copy: t("Copy"),
|
copy: t("Copy"),
|
||||||
createLink: t("Create link"),
|
createLink: t("Create link"),
|
||||||
createLinkError: t("Sorry, an error occurred creating the link"),
|
createLinkError: t("Sorry, an error occurred creating the link"),
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ type Options = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Measures the width of an emoji character
|
* 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(
|
export default function useEmojiWidth(
|
||||||
emoji: string | undefined,
|
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
|
* @param options Options for the hook
|
||||||
* @returns Tuple of the current value and a function to update it
|
* @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,
|
key: string,
|
||||||
defaultValue: T,
|
defaultValue: T,
|
||||||
options?: Options
|
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 { pick } from "lodash";
|
||||||
import { set, computed, observable } from "mobx";
|
import { set, observable } from "mobx";
|
||||||
import { getFieldsForModel } from "./decorators/Field";
|
import { getFieldsForModel } from "./decorators/Field";
|
||||||
|
|
||||||
export default abstract class BaseModel {
|
export default abstract class BaseModel {
|
||||||
@@ -9,6 +9,9 @@ export default abstract class BaseModel {
|
|||||||
@observable
|
@observable
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
|
|
||||||
|
@observable
|
||||||
|
isNew: boolean;
|
||||||
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
@@ -17,6 +20,7 @@ export default abstract class BaseModel {
|
|||||||
|
|
||||||
constructor(fields: Record<string, any>, store: any) {
|
constructor(fields: Record<string, any>, store: any) {
|
||||||
this.updateFromJson(fields);
|
this.updateFromJson(fields);
|
||||||
|
this.isNew = !this.id;
|
||||||
this.store = store;
|
this.store = store;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,10 +36,19 @@ export default abstract class BaseModel {
|
|||||||
params = this.toAPI();
|
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
|
// 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();
|
this.persistedAttributes = this.toAPI();
|
||||||
|
|
||||||
@@ -46,7 +59,8 @@ export default abstract class BaseModel {
|
|||||||
};
|
};
|
||||||
|
|
||||||
updateFromJson = (data: any) => {
|
updateFromJson = (data: any) => {
|
||||||
set(this, data);
|
//const isNew = !data.id && !this.id && this.isNew;
|
||||||
|
set(this, { ...data, isNew: false });
|
||||||
this.persistedAttributes = this.toAPI();
|
this.persistedAttributes = this.toAPI();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -94,7 +108,9 @@ export default abstract class BaseModel {
|
|||||||
if (
|
if (
|
||||||
// eslint-disable-next-line no-prototype-builtins
|
// eslint-disable-next-line no-prototype-builtins
|
||||||
this.hasOwnProperty(property) &&
|
this.hasOwnProperty(property) &&
|
||||||
!["persistedAttributes", "store", "isSaving"].includes(property)
|
!["persistedAttributes", "store", "isSaving", "isNew"].includes(
|
||||||
|
property
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
output[property] = this[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> = {};
|
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
|
@observable
|
||||||
collaborativeEditing: boolean;
|
collaborativeEditing: boolean;
|
||||||
|
|
||||||
|
@Field
|
||||||
|
@observable
|
||||||
|
commenting: boolean;
|
||||||
|
|
||||||
@Field
|
@Field
|
||||||
@observable
|
@observable
|
||||||
documentEmbeds: boolean;
|
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 { observer } from "mobx-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
import { useLocation, RouteComponentProps, StaticContext } from "react-router";
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode, TeamPreference } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import Error404 from "~/scenes/Error404";
|
import Error404 from "~/scenes/Error404";
|
||||||
@@ -45,6 +45,7 @@ function DataLoader({ match, children }: Props) {
|
|||||||
ui,
|
ui,
|
||||||
views,
|
views,
|
||||||
shares,
|
shares,
|
||||||
|
comments,
|
||||||
documents,
|
documents,
|
||||||
auth,
|
auth,
|
||||||
revisions,
|
revisions,
|
||||||
@@ -158,6 +159,12 @@ function DataLoader({ match, children }: Props) {
|
|||||||
// Prevents unauthorized request to load share information for the document
|
// Prevents unauthorized request to load share information for the document
|
||||||
// when viewing a public share link
|
// when viewing a public share link
|
||||||
if (can.read) {
|
if (can.read) {
|
||||||
|
if (team?.getPreference(TeamPreference.Commenting)) {
|
||||||
|
comments.fetchDocumentComments(document.id, {
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
shares.fetch(document.id).catch((err) => {
|
shares.fetch(document.id).catch((err) => {
|
||||||
if (!(err instanceof NotFoundError)) {
|
if (!(err instanceof NotFoundError)) {
|
||||||
throw err;
|
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) {
|
if (error) {
|
||||||
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
return error instanceof OfflineError ? <ErrorOffline /> : <Error404 />;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import {
|
|||||||
} from "react-router";
|
} from "react-router";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import breakpoint from "styled-components-breakpoint";
|
import breakpoint from "styled-components-breakpoint";
|
||||||
import { Heading } from "@shared/editor/lib/getHeadings";
|
|
||||||
import { NavigationNode } from "@shared/types";
|
import { NavigationNode } from "@shared/types";
|
||||||
|
import { Heading } from "@shared/utils/ProsemirrorHelper";
|
||||||
import { parseDomain } from "@shared/utils/domains";
|
import { parseDomain } from "@shared/utils/domains";
|
||||||
import getTasks from "@shared/utils/getTasks";
|
import getTasks from "@shared/utils/getTasks";
|
||||||
import RootStore from "~/stores/RootStore";
|
import RootStore from "~/stores/RootStore";
|
||||||
@@ -638,19 +638,28 @@ class DocumentScene extends React.Component<Props> {
|
|||||||
<Branding href="//www.getoutline.com?ref=sharelink" />
|
<Branding href="//www.getoutline.com?ref=sharelink" />
|
||||||
)}
|
)}
|
||||||
</Container>
|
</Container>
|
||||||
|
{!isShare && (
|
||||||
|
<Footer>
|
||||||
|
<KeyboardShortcutsButton />
|
||||||
|
<ConnectionStatus />
|
||||||
|
</Footer>
|
||||||
|
)}
|
||||||
</Background>
|
</Background>
|
||||||
{!isShare && (
|
|
||||||
<>
|
|
||||||
<KeyboardShortcutsButton />
|
|
||||||
<ConnectionStatus />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const Footer = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
text-align: right;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
`;
|
||||||
|
|
||||||
const Background = styled(Container)`
|
const Background = styled(Container)`
|
||||||
|
position: relative;
|
||||||
background: ${(props) => props.theme.background};
|
background: ${(props) => props.theme.background};
|
||||||
transition: ${(props) => props.theme.backgroundTransition};
|
transition: ${(props) => props.theme.backgroundTransition};
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
import { LocationDescriptor } from "history";
|
import { LocationDescriptor } from "history";
|
||||||
import { observer, useObserver } from "mobx-react";
|
import { observer, useObserver } from "mobx-react";
|
||||||
|
import { CommentIcon } from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link, useRouteMatch } from "react-router-dom";
|
import { Link, useRouteMatch } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
import { TeamPreference } from "@shared/types";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import DocumentMeta from "~/components/DocumentMeta";
|
import DocumentMeta from "~/components/DocumentMeta";
|
||||||
|
import Fade from "~/components/Fade";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
|
import { documentUrl, documentInsightsUrl } from "~/utils/routeHelpers";
|
||||||
import Fade from "./Fade";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
/* The document to display meta data for */
|
||||||
document: Document;
|
document: Document;
|
||||||
isDraft: boolean;
|
isDraft: boolean;
|
||||||
to?: LocationDescriptor;
|
to?: LocationDescriptor;
|
||||||
rtl?: boolean;
|
rtl?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
function TitleDocumentMeta({ to, isDraft, document, ...rest }: Props) {
|
||||||
const { views } = useStores();
|
const { auth, views, comments, ui } = useStores();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { team } = auth;
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
const documentViews = useObserver(() => views.inDocument(document.id));
|
const documentViews = useObserver(() => views.inDocument(document.id));
|
||||||
const totalViewers = documentViews.length;
|
const totalViewers = documentViews.length;
|
||||||
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
const onlyYou = totalViewers === 1 && documentViews[0].user.id;
|
||||||
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
|
const viewsLoadedOnMount = React.useRef(totalViewers > 0);
|
||||||
|
|
||||||
const insightsUrl = documentInsightsUrl(document);
|
|
||||||
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
const Wrapper = viewsLoadedOnMount.current ? React.Fragment : Fade;
|
||||||
|
|
||||||
|
const insightsUrl = documentInsightsUrl(document);
|
||||||
|
const commentsCount = comments.inDocument(document.id).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Meta document={document} to={to} replace {...rest}>
|
<Meta document={document} to={to} replace {...rest}>
|
||||||
{totalViewers && !isDraft ? (
|
{totalViewers && !isDraft ? (
|
||||||
@@ -46,15 +52,32 @@ function DocumentMetaWithViews({ to, isDraft, document, ...rest }: Props) {
|
|||||||
</Link>
|
</Link>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
) : null}
|
) : 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>
|
</Meta>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CommentLink = styled(Link)`
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
const Meta = styled(DocumentMeta)<{ rtl?: boolean }>`
|
||||||
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
justify-content: ${(props) => (props.rtl ? "flex-end" : "flex-start")};
|
||||||
margin: -12px 0 2em 0;
|
margin: -12px 0 2em 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
a {
|
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 * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { mergeRefs } from "react-merge-refs";
|
import { mergeRefs } from "react-merge-refs";
|
||||||
import { useRouteMatch } from "react-router-dom";
|
import { useHistory, useRouteMatch } from "react-router-dom";
|
||||||
import fullPackage from "@shared/editor/packages/full";
|
import fullWithCommentsPackage from "@shared/editor/packages/fullWithComments";
|
||||||
|
import { TeamPreference } from "@shared/types";
|
||||||
|
import Comment from "~/models/Comment";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import { RefHandle } from "~/components/ContentEditable";
|
import { RefHandle } from "~/components/ContentEditable";
|
||||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
|
||||||
import Editor, { Props as EditorProps } from "~/components/Editor";
|
import Editor, { Props as EditorProps } from "~/components/Editor";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
|
import useStores from "~/hooks/useStores";
|
||||||
import {
|
import {
|
||||||
documentHistoryUrl,
|
documentHistoryUrl,
|
||||||
documentUrl,
|
documentUrl,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
} from "~/utils/routeHelpers";
|
} from "~/utils/routeHelpers";
|
||||||
import { useDocumentContext } from "../../../components/DocumentContext";
|
import { useDocumentContext } from "../../../components/DocumentContext";
|
||||||
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
import MultiplayerEditor from "./AsyncMultiplayerEditor";
|
||||||
|
import DocumentMeta from "./DocumentMeta";
|
||||||
import EditableTitle from "./EditableTitle";
|
import EditableTitle from "./EditableTitle";
|
||||||
|
|
||||||
type Props = Omit<EditorProps, "extensions"> & {
|
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,
|
* 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>) {
|
function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
||||||
const titleRef = React.useRef<RefHandle>(null);
|
const titleRef = React.useRef<RefHandle>(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const match = useRouteMatch();
|
const match = useRouteMatch();
|
||||||
|
const { ui, comments, auth } = useStores();
|
||||||
|
const { user, team } = auth;
|
||||||
|
const history = useHistory();
|
||||||
const {
|
const {
|
||||||
document,
|
document,
|
||||||
onChangeTitle,
|
onChangeTitle,
|
||||||
@@ -77,9 +83,64 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
[focusAtStart, ref]
|
[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 { setEditor } = useDocumentContext();
|
||||||
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
|
||||||
|
|
||||||
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -95,7 +156,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
placeholder={t("Untitled")}
|
placeholder={t("Untitled")}
|
||||||
/>
|
/>
|
||||||
{!shareId && (
|
{!shareId && (
|
||||||
<DocumentMetaWithViews
|
<DocumentMeta
|
||||||
isDraft={isDraft}
|
isDraft={isDraft}
|
||||||
document={document}
|
document={document}
|
||||||
to={
|
to={
|
||||||
@@ -115,7 +176,19 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
scrollTo={decodeURIComponent(window.location.hash)}
|
scrollTo={decodeURIComponent(window.location.hash)}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
shareId={shareId}
|
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)`}
|
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ const Button = styled(NudeButton)`
|
|||||||
display: none;
|
display: none;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
right: 0;
|
|
||||||
margin: 24px;
|
margin: 24px;
|
||||||
|
|
||||||
${breakpoint("tablet")`
|
${breakpoint("tablet")`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
|||||||
import EditorContainer from "@shared/editor/components/Styles";
|
import EditorContainer from "@shared/editor/components/Styles";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
import Revision from "~/models/Revision";
|
import Revision from "~/models/Revision";
|
||||||
import DocumentMetaWithViews from "~/components/DocumentMetaWithViews";
|
import DocumentMeta from "~/components/DocumentMeta";
|
||||||
import { Props as EditorProps } from "~/components/Editor";
|
import { Props as EditorProps } from "~/components/Editor";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import { documentUrl } from "~/utils/routeHelpers";
|
import { documentUrl } from "~/utils/routeHelpers";
|
||||||
@@ -20,18 +20,13 @@ type Props = Omit<EditorProps, "extensions"> & {
|
|||||||
* Displays revision HTML pre-rendered on the server.
|
* Displays revision HTML pre-rendered on the server.
|
||||||
*/
|
*/
|
||||||
function RevisionViewer(props: Props) {
|
function RevisionViewer(props: Props) {
|
||||||
const { document, isDraft, shareId, children, revision } = props;
|
const { document, shareId, children, revision } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex auto column>
|
<Flex auto column>
|
||||||
<h1 dir={revision.dir}>{revision.title}</h1>
|
<h1 dir={revision.dir}>{revision.title}</h1>
|
||||||
{!shareId && (
|
{!shareId && (
|
||||||
<DocumentMetaWithViews
|
<DocumentMeta document={document} to={documentUrl(document)} />
|
||||||
isDraft={isDraft}
|
|
||||||
document={document}
|
|
||||||
to={documentUrl(document)}
|
|
||||||
rtl={revision.rtl}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<EditorContainer
|
<EditorContainer
|
||||||
dangerouslySetInnerHTML={{ __html: revision.html }}
|
dangerouslySetInnerHTML={{ __html: revision.html }}
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ function SidebarLayout({ title, onClose, children }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Header>
|
</Header>
|
||||||
<Scrollable topShadow>{children}</Scrollable>
|
<Scrollable hiddenScrollbars topShadow>
|
||||||
|
{children}
|
||||||
|
</Scrollable>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { WebsocketContext } from "~/components/WebsocketProvider";
|
|||||||
type Props = {
|
type Props = {
|
||||||
documentId: string;
|
documentId: string;
|
||||||
isEditing: boolean;
|
isEditing: boolean;
|
||||||
|
presence: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class SocketPresence extends React.Component<Props> {
|
export default class SocketPresence extends React.Component<Props> {
|
||||||
@@ -12,14 +13,16 @@ export default class SocketPresence extends React.Component<Props> {
|
|||||||
|
|
||||||
previousContext: typeof WebsocketContext;
|
previousContext: typeof WebsocketContext;
|
||||||
|
|
||||||
editingInterval: ReturnType<typeof setInterval>;
|
editingInterval: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.editingInterval = setInterval(() => {
|
this.editingInterval = this.props.presence
|
||||||
if (this.props.isEditing) {
|
? setInterval(() => {
|
||||||
this.emitPresence();
|
if (this.props.isEditing) {
|
||||||
}
|
this.emitPresence();
|
||||||
}, USER_PRESENCE_INTERVAL);
|
}
|
||||||
|
}, USER_PRESENCE_INTERVAL)
|
||||||
|
: undefined;
|
||||||
this.setupOnce();
|
this.setupOnce();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +42,9 @@ export default class SocketPresence extends React.Component<Props> {
|
|||||||
this.context.off("authenticated", this.emitJoin);
|
this.context.off("authenticated", this.emitJoin);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearInterval(this.editingInterval);
|
if (this.editingInterval) {
|
||||||
|
clearInterval(this.editingInterval);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupOnce = () => {
|
setupOnce = () => {
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ export default function DocumentScene(props: Props) {
|
|||||||
// no longer be required
|
// no longer be required
|
||||||
if (isActive && !team.collaborativeEditing) {
|
if (isActive && !team.collaborativeEditing) {
|
||||||
return (
|
return (
|
||||||
<SocketPresence documentId={document.id} isEditing={isEditing}>
|
<SocketPresence
|
||||||
|
documentId={document.id}
|
||||||
|
isEditing={isEditing}
|
||||||
|
presence={!team.collaborativeEditing}
|
||||||
|
>
|
||||||
<Document document={document} {...rest} />
|
<Document document={document} {...rest} />
|
||||||
</SocketPresence>
|
</SocketPresence>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,6 +57,25 @@ function Features() {
|
|||||||
/>
|
/>
|
||||||
</SettingRow>
|
</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 && (
|
{team.avatarUrl && (
|
||||||
<SettingRow
|
<SettingRow
|
||||||
name={TeamPreference.PublicBranding}
|
name={TeamPreference.PublicBranding}
|
||||||
|
|||||||
@@ -103,12 +103,13 @@ export default abstract class BaseStore<T extends BaseModel> {
|
|||||||
|
|
||||||
save(
|
save(
|
||||||
params: Partial<T>,
|
params: Partial<T>,
|
||||||
options?: Record<string, string | boolean | number | undefined>
|
options: Record<string, string | boolean | number | undefined> = {}
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (params.id) {
|
const { isNew, ...rest } = options;
|
||||||
return this.update(params, 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 {
|
get(id: string): T | undefined {
|
||||||
@@ -171,6 +172,10 @@ export default abstract class BaseStore<T extends BaseModel> {
|
|||||||
throw new Error(`Cannot delete ${this.modelName}`);
|
throw new Error(`Cannot delete ${this.modelName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.isNew) {
|
||||||
|
return this.remove(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
this.isSaving = true;
|
this.isSaving = true;
|
||||||
|
|
||||||
try {
|
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 AuthenticationProvidersStore from "./AuthenticationProvidersStore";
|
||||||
import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
|
import CollectionGroupMembershipsStore from "./CollectionGroupMembershipsStore";
|
||||||
import CollectionsStore from "./CollectionsStore";
|
import CollectionsStore from "./CollectionsStore";
|
||||||
|
import CommentsStore from "./CommentsStore";
|
||||||
import DialogsStore from "./DialogsStore";
|
import DialogsStore from "./DialogsStore";
|
||||||
import DocumentPresenceStore from "./DocumentPresenceStore";
|
import DocumentPresenceStore from "./DocumentPresenceStore";
|
||||||
import DocumentsStore from "./DocumentsStore";
|
import DocumentsStore from "./DocumentsStore";
|
||||||
@@ -32,6 +33,7 @@ export default class RootStore {
|
|||||||
authenticationProviders: AuthenticationProvidersStore;
|
authenticationProviders: AuthenticationProvidersStore;
|
||||||
collections: CollectionsStore;
|
collections: CollectionsStore;
|
||||||
collectionGroupMemberships: CollectionGroupMembershipsStore;
|
collectionGroupMemberships: CollectionGroupMembershipsStore;
|
||||||
|
comments: CommentsStore;
|
||||||
dialogs: DialogsStore;
|
dialogs: DialogsStore;
|
||||||
documents: DocumentsStore;
|
documents: DocumentsStore;
|
||||||
events: EventsStore;
|
events: EventsStore;
|
||||||
@@ -63,6 +65,7 @@ export default class RootStore {
|
|||||||
this.auth = new AuthStore(this);
|
this.auth = new AuthStore(this);
|
||||||
this.collections = new CollectionsStore(this);
|
this.collections = new CollectionsStore(this);
|
||||||
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
|
this.collectionGroupMemberships = new CollectionGroupMembershipsStore(this);
|
||||||
|
this.comments = new CommentsStore(this);
|
||||||
this.dialogs = new DialogsStore();
|
this.dialogs = new DialogsStore();
|
||||||
this.documents = new DocumentsStore(this);
|
this.documents = new DocumentsStore(this);
|
||||||
this.events = new EventsStore(this);
|
this.events = new EventsStore(this);
|
||||||
@@ -92,6 +95,7 @@ export default class RootStore {
|
|||||||
// this.auth omitted for reasons...
|
// this.auth omitted for reasons...
|
||||||
this.collections.clear();
|
this.collections.clear();
|
||||||
this.collectionGroupMemberships.clear();
|
this.collectionGroupMemberships.clear();
|
||||||
|
this.comments.clear();
|
||||||
this.documents.clear();
|
this.documents.clear();
|
||||||
this.events.clear();
|
this.events.clear();
|
||||||
this.groups.clear();
|
this.groups.clear();
|
||||||
|
|||||||
@@ -57,9 +57,15 @@ class UiStore {
|
|||||||
@observable
|
@observable
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
|
|
||||||
|
@observable
|
||||||
|
sidebarRightWidth: number;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
sidebarCollapsed = false;
|
sidebarCollapsed = false;
|
||||||
|
|
||||||
|
@observable
|
||||||
|
commentsCollapsed = false;
|
||||||
|
|
||||||
@observable
|
@observable
|
||||||
sidebarIsResizing = false;
|
sidebarIsResizing = false;
|
||||||
|
|
||||||
@@ -91,6 +97,8 @@ class UiStore {
|
|||||||
this.languagePromptDismissed = data.languagePromptDismissed;
|
this.languagePromptDismissed = data.languagePromptDismissed;
|
||||||
this.sidebarCollapsed = !!data.sidebarCollapsed;
|
this.sidebarCollapsed = !!data.sidebarCollapsed;
|
||||||
this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth;
|
this.sidebarWidth = data.sidebarWidth || defaultTheme.sidebarWidth;
|
||||||
|
this.sidebarRightWidth =
|
||||||
|
data.sidebarRightWidth || defaultTheme.sidebarWidth;
|
||||||
this.tocVisible = !!data.tocVisible;
|
this.tocVisible = !!data.tocVisible;
|
||||||
this.theme = data.theme || Theme.System;
|
this.theme = data.theme || Theme.System;
|
||||||
|
|
||||||
@@ -153,8 +161,8 @@ class UiStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
setSidebarWidth = (sidebarWidth: number): void => {
|
setSidebarWidth = (width: number): void => {
|
||||||
this.sidebarWidth = sidebarWidth;
|
this.sidebarWidth = width;
|
||||||
};
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -168,6 +176,21 @@ class UiStore {
|
|||||||
this.sidebarCollapsed = false;
|
this.sidebarCollapsed = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
collapseComments = () => {
|
||||||
|
this.commentsCollapsed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
expandComments = () => {
|
||||||
|
this.commentsCollapsed = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleComments = () => {
|
||||||
|
this.commentsCollapsed = !this.commentsCollapsed;
|
||||||
|
};
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleCollapsedSidebar = () => {
|
toggleCollapsedSidebar = () => {
|
||||||
sidebarHidden = false;
|
sidebarHidden = false;
|
||||||
@@ -239,6 +262,7 @@ class UiStore {
|
|||||||
tocVisible: this.tocVisible,
|
tocVisible: this.tocVisible,
|
||||||
sidebarCollapsed: this.sidebarCollapsed,
|
sidebarCollapsed: this.sidebarCollapsed,
|
||||||
sidebarWidth: this.sidebarWidth,
|
sidebarWidth: this.sidebarWidth,
|
||||||
|
sidebarRightWidth: this.sidebarRightWidth,
|
||||||
languagePromptDismissed: this.languagePromptDismissed,
|
languagePromptDismissed: this.languagePromptDismissed,
|
||||||
theme: this.theme,
|
theme: this.theme,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,3 +38,17 @@ export const fadeOnDesktopBackgrounded = () => {
|
|||||||
body.backgrounded & { opacity: 0.75; }
|
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;
|
textDiffDeleted: string;
|
||||||
textDiffDeletedBackground: string;
|
textDiffDeletedBackground: string;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
|
commentBackground: string;
|
||||||
|
commentActiveBackground: string;
|
||||||
sidebarBackground: string;
|
sidebarBackground: string;
|
||||||
sidebarActiveBackground: string;
|
sidebarActiveBackground: string;
|
||||||
sidebarControlHoverBackground: string;
|
sidebarControlHoverBackground: string;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import queryString from "query-string";
|
import queryString from "query-string";
|
||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
|
import Comment from "~/models/Comment";
|
||||||
import Document from "~/models/Document";
|
import Document from "~/models/Document";
|
||||||
|
import env from "~/env";
|
||||||
|
|
||||||
export function homePath(): string {
|
export function homePath(): string {
|
||||||
return "/home";
|
return "/home";
|
||||||
@@ -42,6 +44,10 @@ export function groupSettingsPath(): string {
|
|||||||
return "/settings/groups";
|
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 {
|
export function collectionUrl(url: string, section?: string): string {
|
||||||
if (section) {
|
if (section) {
|
||||||
return `${url}/${section}`;
|
return `${url}/${section}`;
|
||||||
@@ -131,6 +137,10 @@ export function notFoundUrl(): string {
|
|||||||
return "/404";
|
return "/404";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function urlify(path: string): string {
|
||||||
|
return `${env.URL}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const matchDocumentSlug =
|
export const matchDocumentSlug =
|
||||||
":documentSlug([0-9a-zA-Z-_~]*-[a-zA-z0-9]{10,15})";
|
":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":
|
case "collections.remove_group":
|
||||||
await this.handleCollectionGroupEvent(subscription, event);
|
await this.handleCollectionGroupEvent(subscription, event);
|
||||||
return;
|
return;
|
||||||
|
case "comments.create":
|
||||||
|
case "comments.update":
|
||||||
|
case "comments.delete":
|
||||||
|
// TODO
|
||||||
|
return;
|
||||||
case "groups.create":
|
case "groups.create":
|
||||||
case "groups.update":
|
case "groups.update":
|
||||||
case "groups.delete":
|
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 { Schema } from "prosemirror-model";
|
||||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
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({
|
export const schema = new Schema({
|
||||||
nodes: extensions.nodes,
|
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 CollectionUser } from "./CollectionUser";
|
||||||
|
|
||||||
|
export { default as Comment } from "./Comment";
|
||||||
|
|
||||||
export { default as Document } from "./Document";
|
export { default as Document } from "./Document";
|
||||||
|
|
||||||
export { default as Event } from "./Event";
|
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,
|
Team,
|
||||||
User,
|
User,
|
||||||
Collection,
|
Collection,
|
||||||
|
Comment,
|
||||||
Document,
|
Document,
|
||||||
Group,
|
Group,
|
||||||
} from "@server/models";
|
} from "@server/models";
|
||||||
@@ -12,6 +13,7 @@ import "./apiKey";
|
|||||||
import "./attachment";
|
import "./attachment";
|
||||||
import "./authenticationProvider";
|
import "./authenticationProvider";
|
||||||
import "./collection";
|
import "./collection";
|
||||||
|
import "./comment";
|
||||||
import "./document";
|
import "./document";
|
||||||
import "./fileOperation";
|
import "./fileOperation";
|
||||||
import "./integration";
|
import "./integration";
|
||||||
@@ -47,9 +49,10 @@ export function serialize(
|
|||||||
model: User,
|
model: User,
|
||||||
target:
|
target:
|
||||||
| Attachment
|
| Attachment
|
||||||
|
| Collection
|
||||||
|
| Comment
|
||||||
| FileOperation
|
| FileOperation
|
||||||
| Team
|
| Team
|
||||||
| Collection
|
|
||||||
| Document
|
| Document
|
||||||
| User
|
| User
|
||||||
| Group
|
| 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 presentAvailableTeam from "./availableTeam";
|
||||||
import presentCollection from "./collection";
|
import presentCollection from "./collection";
|
||||||
import presentCollectionGroupMembership from "./collectionGroupMembership";
|
import presentCollectionGroupMembership from "./collectionGroupMembership";
|
||||||
|
import presentComment from "./comment";
|
||||||
import presentDocument from "./document";
|
import presentDocument from "./document";
|
||||||
import presentEvent from "./event";
|
import presentEvent from "./event";
|
||||||
import presentFileOperation from "./fileOperation";
|
import presentFileOperation from "./fileOperation";
|
||||||
@@ -32,6 +33,7 @@ export {
|
|||||||
presentAvailableTeam,
|
presentAvailableTeam,
|
||||||
presentCollection,
|
presentCollection,
|
||||||
presentCollectionGroupMembership,
|
presentCollectionGroupMembership,
|
||||||
|
presentComment,
|
||||||
presentDocument,
|
presentDocument,
|
||||||
presentEvent,
|
presentEvent,
|
||||||
presentFileOperation,
|
presentFileOperation,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { subHours } from "date-fns";
|
|||||||
import { Op } from "sequelize";
|
import { Op } from "sequelize";
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import {
|
import {
|
||||||
|
Comment,
|
||||||
Document,
|
Document,
|
||||||
Collection,
|
Collection,
|
||||||
FileOperation,
|
FileOperation,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
Subscription,
|
Subscription,
|
||||||
} from "@server/models";
|
} from "@server/models";
|
||||||
import {
|
import {
|
||||||
|
presentComment,
|
||||||
presentCollection,
|
presentCollection,
|
||||||
presentDocument,
|
presentDocument,
|
||||||
presentFileOperation,
|
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.create":
|
||||||
case "stars.update": {
|
case "stars.update": {
|
||||||
const star = await Star.findByPk(event.modelId);
|
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 auth from "./auth";
|
||||||
import authenticationProviders from "./authenticationProviders";
|
import authenticationProviders from "./authenticationProviders";
|
||||||
import collections from "./collections";
|
import collections from "./collections";
|
||||||
import utils from "./cron";
|
import comments from "./comments/comments";
|
||||||
|
import cron from "./cron";
|
||||||
import developer from "./developer";
|
import developer from "./developer";
|
||||||
import documents from "./documents";
|
import documents from "./documents";
|
||||||
import events from "./events";
|
import events from "./events";
|
||||||
@@ -67,6 +68,7 @@ router.use("/", authenticationProviders.routes());
|
|||||||
router.use("/", events.routes());
|
router.use("/", events.routes());
|
||||||
router.use("/", users.routes());
|
router.use("/", users.routes());
|
||||||
router.use("/", collections.routes());
|
router.use("/", collections.routes());
|
||||||
|
router.use("/", comments.routes());
|
||||||
router.use("/", documents.routes());
|
router.use("/", documents.routes());
|
||||||
router.use("/", pins.routes());
|
router.use("/", pins.routes());
|
||||||
router.use("/", revisions.routes());
|
router.use("/", revisions.routes());
|
||||||
@@ -80,7 +82,7 @@ router.use("/", teams.routes());
|
|||||||
router.use("/", integrations.routes());
|
router.use("/", integrations.routes());
|
||||||
router.use("/", notificationSettings.routes());
|
router.use("/", notificationSettings.routes());
|
||||||
router.use("/", attachments.routes());
|
router.use("/", attachments.routes());
|
||||||
router.use("/", utils.routes());
|
router.use("/", cron.routes());
|
||||||
router.use("/", groups.routes());
|
router.use("/", groups.routes());
|
||||||
router.use("/", fileOperationsRoute.routes());
|
router.use("/", fileOperationsRoute.routes());
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export const TeamsUpdateSchema = BaseSchema.extend({
|
|||||||
publicBranding: z.boolean().optional(),
|
publicBranding: z.boolean().optional(),
|
||||||
/** Whether viewers should see download options. */
|
/** Whether viewers should see download options. */
|
||||||
viewersCanExport: z.boolean().optional(),
|
viewersCanExport: z.boolean().optional(),
|
||||||
|
/** Whether commenting is enabled */
|
||||||
|
commenting: z.boolean().optional(),
|
||||||
/** The custom theme for the team. */
|
/** The custom theme for the team. */
|
||||||
customTheme: z
|
customTheme: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -284,6 +284,18 @@ async function authenticated(io: IO.Server, socket: SocketWithAuth) {
|
|||||||
documentId: event.documentId,
|
documentId: event.documentId,
|
||||||
isEditing: event.isEditing,
|
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;
|
collectionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CommentEvent = BaseEvent & {
|
||||||
|
name: "comments.create" | "comments.update" | "comments.delete";
|
||||||
|
modelId: string;
|
||||||
|
documentId: string;
|
||||||
|
actorId: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type StarEvent = BaseEvent & {
|
export type StarEvent = BaseEvent & {
|
||||||
name: "stars.create" | "stars.update" | "stars.delete";
|
name: "stars.create" | "stars.update" | "stars.delete";
|
||||||
modelId: string;
|
modelId: string;
|
||||||
@@ -332,6 +339,7 @@ export type Event =
|
|||||||
| AuthenticationProviderEvent
|
| AuthenticationProviderEvent
|
||||||
| DocumentEvent
|
| DocumentEvent
|
||||||
| PinEvent
|
| PinEvent
|
||||||
|
| CommentEvent
|
||||||
| StarEvent
|
| StarEvent
|
||||||
| CollectionEvent
|
| CollectionEvent
|
||||||
| FileOperationEvent
|
| 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;
|
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 {
|
.notice-block {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1456,6 +1467,11 @@ del[data-operation-index] {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comment {
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
.page-break {
|
.page-break {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export default class Extension {
|
|||||||
commands(_options: {
|
commands(_options: {
|
||||||
type?: NodeType | MarkType;
|
type?: NodeType | MarkType;
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
}): Record<string, CommandFactory> | CommandFactory {
|
}): Record<string, CommandFactory> | CommandFactory | undefined {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export default class ExtensionManager {
|
|||||||
Object.entries(value).forEach(([commandName, commandValue]) => {
|
Object.entries(value).forEach(([commandName, commandValue]) => {
|
||||||
handle(commandName, commandValue);
|
handle(commandName, commandValue);
|
||||||
});
|
});
|
||||||
} else {
|
} else if (value) {
|
||||||
handle(name, 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 }) {
|
keys({ type }: { type: MarkType }) {
|
||||||
return {
|
return {
|
||||||
"Mod-k": (state: EditorState, dispatch: Dispatch) => {
|
"Mod-k": (state: EditorState, dispatch: Dispatch) => {
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ export default abstract class Mark extends Extension {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
commands({ type }: { type: MarkType; schema: Schema }): CommandFactory {
|
commands({
|
||||||
return () => toggleMark(type);
|
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 Underline from "../marks/Underline";
|
||||||
import Doc from "../nodes/Doc";
|
import Doc from "../nodes/Doc";
|
||||||
import Emoji from "../nodes/Emoji";
|
import Emoji from "../nodes/Emoji";
|
||||||
import HardBreak from "../nodes/HardBreak";
|
|
||||||
import Image from "../nodes/Image";
|
import Image from "../nodes/Image";
|
||||||
import Node from "../nodes/Node";
|
import Node from "../nodes/Node";
|
||||||
import Paragraph from "../nodes/Paragraph";
|
import Paragraph from "../nodes/Paragraph";
|
||||||
@@ -16,6 +15,7 @@ import Text from "../nodes/Text";
|
|||||||
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
|
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
|
||||||
import DateTime from "../plugins/DateTime";
|
import DateTime from "../plugins/DateTime";
|
||||||
import History from "../plugins/History";
|
import History from "../plugins/History";
|
||||||
|
import Keys from "../plugins/Keys";
|
||||||
import MaxLength from "../plugins/MaxLength";
|
import MaxLength from "../plugins/MaxLength";
|
||||||
import PasteHandler from "../plugins/PasteHandler";
|
import PasteHandler from "../plugins/PasteHandler";
|
||||||
import Placeholder from "../plugins/Placeholder";
|
import Placeholder from "../plugins/Placeholder";
|
||||||
@@ -24,7 +24,6 @@ import TrailingNode from "../plugins/TrailingNode";
|
|||||||
|
|
||||||
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
||||||
Doc,
|
Doc,
|
||||||
HardBreak,
|
|
||||||
Paragraph,
|
Paragraph,
|
||||||
Emoji,
|
Emoji,
|
||||||
Text,
|
Text,
|
||||||
@@ -42,6 +41,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
|||||||
Placeholder,
|
Placeholder,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
Keys,
|
||||||
ClipboardTextSerializer,
|
ClipboardTextSerializer,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import CheckboxList from "../nodes/CheckboxList";
|
|||||||
import CodeBlock from "../nodes/CodeBlock";
|
import CodeBlock from "../nodes/CodeBlock";
|
||||||
import CodeFence from "../nodes/CodeFence";
|
import CodeFence from "../nodes/CodeFence";
|
||||||
import Embed from "../nodes/Embed";
|
import Embed from "../nodes/Embed";
|
||||||
|
import HardBreak from "../nodes/HardBreak";
|
||||||
import Heading from "../nodes/Heading";
|
import Heading from "../nodes/Heading";
|
||||||
import HorizontalRule from "../nodes/HorizontalRule";
|
import HorizontalRule from "../nodes/HorizontalRule";
|
||||||
import ListItem from "../nodes/ListItem";
|
import ListItem from "../nodes/ListItem";
|
||||||
@@ -24,11 +25,11 @@ import TableHeadCell from "../nodes/TableHeadCell";
|
|||||||
import TableRow from "../nodes/TableRow";
|
import TableRow from "../nodes/TableRow";
|
||||||
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
|
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
|
||||||
import Folding from "../plugins/Folding";
|
import Folding from "../plugins/Folding";
|
||||||
import Keys from "../plugins/Keys";
|
|
||||||
import basicPackage from "./basic";
|
import basicPackage from "./basic";
|
||||||
|
|
||||||
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
||||||
...basicPackage,
|
...basicPackage,
|
||||||
|
HardBreak,
|
||||||
CodeBlock,
|
CodeBlock,
|
||||||
CodeFence,
|
CodeFence,
|
||||||
CheckboxList,
|
CheckboxList,
|
||||||
@@ -49,7 +50,6 @@ const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
|
|||||||
Highlight,
|
Highlight,
|
||||||
TemplatePlaceholder,
|
TemplatePlaceholder,
|
||||||
Folding,
|
Folding,
|
||||||
Keys,
|
|
||||||
BlockMenuTrigger,
|
BlockMenuTrigger,
|
||||||
Math,
|
Math,
|
||||||
MathBlock,
|
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",
|
"Viewers": "Viewers",
|
||||||
"I’m sure – Delete": "I’m sure – Delete",
|
"I’m sure – Delete": "I’m sure – Delete",
|
||||||
"Deleting": "Deleting",
|
"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.",
|
"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",
|
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
|
||||||
"Add a description": "Add a description",
|
"Add a description": "Add a description",
|
||||||
@@ -106,6 +106,8 @@
|
|||||||
"Expand": "Expand",
|
"Expand": "Expand",
|
||||||
"Type a command or search": "Type a command or search",
|
"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",
|
"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",
|
"Server connection lost": "Server connection lost",
|
||||||
"Edits you make will sync once you’re online": "Edits you make will sync once you’re online",
|
"Edits you make will sync once you’re online": "Edits you make will sync once you’re online",
|
||||||
"Submenu": "Submenu",
|
"Submenu": "Submenu",
|
||||||
@@ -138,10 +140,6 @@
|
|||||||
"in": "in",
|
"in": "in",
|
||||||
"nested document": "nested document",
|
"nested document": "nested document",
|
||||||
"nested document_plural": "nested documents",
|
"nested document_plural": "nested documents",
|
||||||
"Viewed by": "Viewed by",
|
|
||||||
"only you": "only you",
|
|
||||||
"person": "person",
|
|
||||||
"people": "people",
|
|
||||||
"{{ total }} task": "{{ total }} task",
|
"{{ total }} task": "{{ total }} task",
|
||||||
"{{ total }} task_plural": "{{ total }} tasks",
|
"{{ total }} task_plural": "{{ total }} tasks",
|
||||||
"{{ completed }} task done": "{{ completed }} task done",
|
"{{ completed }} task done": "{{ completed }} task done",
|
||||||
@@ -246,6 +244,7 @@
|
|||||||
"Code block": "Code block",
|
"Code block": "Code block",
|
||||||
"Copied to clipboard": "Copied to clipboard",
|
"Copied to clipboard": "Copied to clipboard",
|
||||||
"Code": "Code",
|
"Code": "Code",
|
||||||
|
"Comment": "Comment",
|
||||||
"Copy": "Copy",
|
"Copy": "Copy",
|
||||||
"Create link": "Create link",
|
"Create link": "Create link",
|
||||||
"Sorry, an error occurred creating the link": "Sorry, an error occurred creating the 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",
|
"Group member options": "Group member options",
|
||||||
"Remove": "Remove",
|
"Remove": "Remove",
|
||||||
"Export collection": "Export collection",
|
"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",
|
"Sort in sidebar": "Sort in sidebar",
|
||||||
"Alphabetical sort": "Alphabetical sort",
|
"Alphabetical sort": "Alphabetical sort",
|
||||||
"Manual sort": "Manual sort",
|
"Manual sort": "Manual sort",
|
||||||
|
"Delete comment": "Delete comment",
|
||||||
|
"Comment options": "Comment options",
|
||||||
"Document options": "Document options",
|
"Document options": "Document options",
|
||||||
"Restore": "Restore",
|
"Restore": "Restore",
|
||||||
"Choose a collection": "Choose a collection",
|
"Choose a collection": "Choose a collection",
|
||||||
@@ -432,9 +433,24 @@
|
|||||||
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
|
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
|
||||||
"Signing in": "Signing in",
|
"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",
|
"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}}",
|
"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?",
|
"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?",
|
"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…",
|
"Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…",
|
||||||
"Hide contents": "Hide contents",
|
"Hide contents": "Hide contents",
|
||||||
"Show contents": "Show 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.",
|
"{{ 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 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</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>.",
|
"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.",
|
"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",
|
"Archiving": "Archiving",
|
||||||
@@ -522,7 +538,6 @@
|
|||||||
"no access": "no access",
|
"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 }}.",
|
"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",
|
"Moving": "Moving",
|
||||||
"Cancel": "Cancel",
|
|
||||||
"Search documents": "Search documents",
|
"Search documents": "Search documents",
|
||||||
"No documents found for your filters.": "No documents found for your filters.",
|
"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.",
|
"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)",
|
backdrop: "rgba(0, 0, 0, 0.2)",
|
||||||
shadow: "rgba(0, 0, 0, 0.2)",
|
shadow: "rgba(0, 0, 0, 0.2)",
|
||||||
|
|
||||||
|
commentBackground: colors.warmGrey,
|
||||||
|
commentActiveBackground: "#d7e0ea",
|
||||||
|
|
||||||
modalBackdrop: colors.black10,
|
modalBackdrop: colors.black10,
|
||||||
modalBackground: colors.white,
|
modalBackground: colors.white,
|
||||||
modalShadow:
|
modalShadow:
|
||||||
@@ -189,6 +192,9 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
|
|||||||
backdrop: "rgba(0, 0, 0, 0.5)",
|
backdrop: "rgba(0, 0, 0, 0.5)",
|
||||||
shadow: "rgba(0, 0, 0, 0.6)",
|
shadow: "rgba(0, 0, 0, 0.6)",
|
||||||
|
|
||||||
|
commentBackground: colors.veryDarkBlue,
|
||||||
|
commentActiveBackground: colors.black,
|
||||||
|
|
||||||
modalBackdrop: colors.black50,
|
modalBackdrop: colors.black50,
|
||||||
modalBackground: "#1f2128",
|
modalBackground: "#1f2128",
|
||||||
modalShadow:
|
modalShadow:
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ export enum TeamPreference {
|
|||||||
PublicBranding = "publicBranding",
|
PublicBranding = "publicBranding",
|
||||||
/** Whether viewers should see download options. */
|
/** Whether viewers should see download options. */
|
||||||
ViewersCanExport = "viewersCanExport",
|
ViewersCanExport = "viewersCanExport",
|
||||||
|
/** Whether users can comment on documents. */
|
||||||
|
Commenting = "commenting",
|
||||||
/** The custom theme for the team. */
|
/** The custom theme for the team. */
|
||||||
CustomTheme = "customTheme",
|
CustomTheme = "customTheme",
|
||||||
}
|
}
|
||||||
@@ -128,6 +130,7 @@ export type TeamPreferences = {
|
|||||||
[TeamPreference.SeamlessEdit]?: boolean;
|
[TeamPreference.SeamlessEdit]?: boolean;
|
||||||
[TeamPreference.PublicBranding]?: boolean;
|
[TeamPreference.PublicBranding]?: boolean;
|
||||||
[TeamPreference.ViewersCanExport]?: boolean;
|
[TeamPreference.ViewersCanExport]?: boolean;
|
||||||
|
[TeamPreference.Commenting]?: boolean;
|
||||||
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
|
[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,
|
maxNameLength: 100,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CommentValidation = {
|
||||||
|
/** The maximum length of a comment */
|
||||||
|
maxLength: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
export const DocumentValidation = {
|
export const DocumentValidation = {
|
||||||
/** The maximum length of the document title */
|
/** The maximum length of the document title */
|
||||||
maxTitleLength: 100,
|
maxTitleLength: 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user