Skip to content

Commit

Permalink
feat: allow sort by position for comments (outline#7770)
Browse files Browse the repository at this point in the history
* feat: allow sort by position for comments

* wait for prosemirror nodes to load

* Move to menu

* remove sort; rename enum

* asc sort for in-thread display

* revert sort

---------

Co-authored-by: Tom Moor <[email protected]>
  • Loading branch information
hmacr and tommoor authored Oct 23, 2024
1 parent 0d7ce76 commit 57e9abd
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 82 deletions.
8 changes: 8 additions & 0 deletions app/components/DocumentContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class DocumentContext {
/** The editor instance for this document */
editor?: Editor;

@observable
isEditorInitialized: boolean = false;

@observable
headings: Heading[] = [];

Expand All @@ -31,6 +34,11 @@ class DocumentContext {
this.updateState();
};

@action
setEditorInitialized = (initialized: boolean) => {
this.isEditorInitialized = initialized;
};

@action
updateState = () => {
this.updateHeadings();
Expand Down
6 changes: 3 additions & 3 deletions app/components/InputSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import * as React from "react";
import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Button, { Inner } from "~/components/Button";
import Button, { Props as ButtonProps, Inner } from "~/components/Button";
import Text from "~/components/Text";
import useMenuHeight from "~/hooks/useMenuHeight";
import useMobile from "~/hooks/useMobile";
Expand All @@ -33,7 +33,7 @@ export type Option = {
divider?: boolean;
};

export type Props = {
export type Props = Omit<ButtonProps<any>, "onChange"> & {
id?: string;
name?: string;
value?: string | null;
Expand Down Expand Up @@ -313,7 +313,7 @@ const StyledButton = styled(Button)<{ $nude?: boolean }>`
margin-bottom: 16px;
display: block;
width: 100%;
cursor: default;
cursor: var(--pointer);
&:hover:not(:disabled) {
background: ${s("buttonNeutralBackground")};
Expand Down
25 changes: 25 additions & 0 deletions app/editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ export type Props = {
scrollTo?: string;
/** Callback for handling uploaded images, should return the url of uploaded file */
uploadFile?: (file: File) => Promise<string>;
/** Callback when prosemirror nodes are initialized on document mount. */
onInit?: () => void;
/** Callback when prosemirror nodes are destroyed on document unmount. */
onDestroy?: () => void;
/** Callback when editor is blurred, as native input */
onBlur?: () => void;
/** Callback when editor is focused, as native input */
Expand Down Expand Up @@ -176,6 +180,7 @@ export class Editor extends React.PureComponent<
linkToolbarOpen: false,
};

isInitialized = false;
isBlurred = true;
extensions: ExtensionManager;
elementRef = React.createRef<HTMLDivElement>();
Expand Down Expand Up @@ -283,6 +288,7 @@ export class Editor extends React.PureComponent<
window.removeEventListener("theme-changed", this.dispatchThemeChanged);
this.view?.destroy();
this.mutationObserver?.disconnect();
this.handleEditorDestroy();
}

private init() {
Expand Down Expand Up @@ -480,6 +486,9 @@ export class Editor extends React.PureComponent<
(self.props.canComment && transactions.some(isEditingComment)))
) {
self.handleChange();
// Wait for the first transaction to initialize the nodes.
// This is bound to happen always - New / empty document has a "paragraph" node in it.
self.handleEditorInit();
}

self.calculateDir();
Expand Down Expand Up @@ -740,6 +749,22 @@ export class Editor extends React.PureComponent<
);
};

private handleEditorInit = () => {
if (!this.props.onInit || this.isInitialized) {
return;
}

this.props.onInit();
this.isInitialized = true;
};

private handleEditorDestroy = () => {
if (!this.props.onDestroy) {
return;
}
this.props.onDestroy();
};

private handleEditorBlur = () => {
this.setState({ isEditorFocused: false });
return false;
Expand Down
86 changes: 86 additions & 0 deletions app/scenes/Document/components/CommentSortMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import queryString from "query-string";
import React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { UserPreference } from "@shared/types";
import InputSelect from "~/components/InputSelect";
import useCurrentUser from "~/hooks/useCurrentUser";
import useQuery from "~/hooks/useQuery";
import { CommentSortType } from "~/types";

const CommentSortMenu = () => {
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const user = useCurrentUser();
const params = useQuery();

const viewingResolved = params.get("resolved") === "";
const value = viewingResolved
? "resolved"
: user.getPreference(UserPreference.SortCommentsByOrderInDocument)
? CommentSortType.OrderInDocument
: CommentSortType.MostRecent;

const handleSortTypeChange = (type: CommentSortType) => {
user.setPreference(
UserPreference.SortCommentsByOrderInDocument,
type === CommentSortType.OrderInDocument
);
void user.save();
};

const showResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: "",
}),
pathname: location.pathname,
});
};

const showUnresolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: undefined,
}),
pathname: location.pathname,
});
};

return (
<Select
style={{ margin: 0 }}
ariaLabel={t("Sort comments")}
value={value}
onChange={(ev) => {
if (ev === "resolved") {
showResolved();
} else {
handleSortTypeChange(ev as CommentSortType);
showUnresolved();
}
}}
borderOnHover
options={[
{ value: CommentSortType.MostRecent, label: t("Most recent") },
{ value: CommentSortType.OrderInDocument, label: t("Order in doc") },
{
divider: true,
value: "resolved",
label: t("Resolved"),
},
]}
/>
);
};

const Select = styled(InputSelect)`
color: ${s("textSecondary")};
`;

export default CommentSortMenu;
92 changes: 23 additions & 69 deletions app/scenes/Document/components/Comments.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,34 @@
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import queryString from "query-string";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation, useRouteMatch } from "react-router-dom";
import styled, { css } from "styled-components";
import { ProsemirrorData } from "@shared/types";
import Button from "~/components/Button";
import { useRouteMatch } from "react-router-dom";
import styled from "styled-components";
import { ProsemirrorData, UserPreference } from "@shared/types";
import { useDocumentContext } from "~/components/DocumentContext";
import Empty from "~/components/Empty";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import Tooltip from "~/components/Tooltip";
import useCurrentUser from "~/hooks/useCurrentUser";
import useFocusedComment from "~/hooks/useFocusedComment";
import useKeyDown from "~/hooks/useKeyDown";
import usePersistedState from "~/hooks/usePersistedState";
import usePolicy from "~/hooks/usePolicy";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { bigPulse } from "~/styles/animations";
import { CommentSortOption, CommentSortType } from "~/types";
import CommentForm from "./CommentForm";
import CommentSortMenu from "./CommentSortMenu";
import CommentThread from "./CommentThread";
import Sidebar from "./SidebarLayout";

function Comments() {
const { ui, comments, documents } = useStores();
const user = useCurrentUser();
const { editor, isEditorInitialized } = useDocumentContext();
const { t } = useTranslation();
const location = useLocation();
const history = useHistory();
const match = useRouteMatch<{ documentSlug: string }>();
const params = useQuery();
const [pulse, setPulse] = React.useState(false);
const document = documents.getByUrl(match.params.documentSlug);
const focusedComment = useFocusedComment();
const can = usePolicy(document);
Expand All @@ -42,71 +40,35 @@ function Comments() {
undefined
);

const sortOption: CommentSortOption = user.getPreference(
UserPreference.SortCommentsByOrderInDocument
)
? {
type: CommentSortType.OrderInDocument,
referencedCommentIds: editor?.getComments().map((c) => c.id) ?? [],
}
: { type: CommentSortType.MostRecent };

const viewingResolved = params.get("resolved") === "";
const resolvedThreads = document
? comments.resolvedThreadsInDocument(document.id)
? comments.resolvedThreadsInDocument(document.id, sortOption)
: [];
const resolvedThreadsCount = resolvedThreads.length;

React.useEffect(() => {
setPulse(true);
const timeout = setTimeout(() => setPulse(false), 250);

return () => {
clearTimeout(timeout);
setPulse(false);
};
}, [resolvedThreadsCount]);

if (!document) {
if (!document || !isEditorInitialized) {
return null;
}

const threads = viewingResolved
? resolvedThreads
: comments.unresolvedThreadsInDocument(document.id);
: comments.unresolvedThreadsInDocument(document.id, sortOption);
const hasComments = threads.length > 0;

const toggleViewingResolved = () => {
history.push({
search: queryString.stringify({
...queryString.parse(location.search),
resolved: viewingResolved ? undefined : "",
}),
pathname: location.pathname,
});
};

return (
<Sidebar
title={
<Flex align="center" justify="space-between" auto>
{viewingResolved ? (
<React.Fragment key="resolved">
<span>{t("Resolved comments")}</span>
<Tooltip delay={500} content={t("View comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon />}
onClick={toggleViewingResolved}
/>
</Tooltip>
</React.Fragment>
) : (
<React.Fragment>
<span>{t("Comments")}</span>
<Tooltip delay={250} content={t("View resolved comments")}>
<ResolvedButton
neutral
borderOnHover
icon={<DoneIcon outline />}
onClick={toggleViewingResolved}
$pulse={pulse}
/>
</Tooltip>
</React.Fragment>
)}
<span>{t("Comments")}</span>
<CommentSortMenu />
</Flex>
}
onClose={() => ui.collapseComments(document?.id)}
Expand Down Expand Up @@ -158,14 +120,6 @@ function Comments() {
);
}

const ResolvedButton = styled(Button)<{ $pulse: boolean }>`
${(props) =>
props.$pulse &&
css`
animation: ${bigPulse} 250ms 1;
`}
`;

const PositionedEmpty = styled(Empty)`
position: absolute;
top: calc(50vh - 30px);
Expand Down
8 changes: 7 additions & 1 deletion app/scenes/Document/components/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,11 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
[comments]
);

const { setEditor, updateState: updateDocState } = useDocumentContext();
const {
setEditor,
setEditorInitialized,
updateState: updateDocState,
} = useDocumentContext();
const handleRefChanged = React.useCallback(setEditor, [setEditor]);
const EditorComponent = multiplayer ? MultiplayerEditor : Editor;

Expand Down Expand Up @@ -241,6 +245,8 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? handleRemoveComment
: undefined
}
onInit={() => setEditorInitialized(true)}
onDestroy={() => setEditorInitialized(false)}
onChange={updateDocState}
extensions={extensions}
editorStyle={editorStyle}
Expand Down
Loading

0 comments on commit 57e9abd

Please sign in to comment.