Various commenting improvements (#4938)
* fix: New threads attached to previous as replies * fix: Cannot use floating toolbar properly in comments * perf: Avoid re-writing history on click in editor * fix: Comment on text selection * fix: 'Copy link' on comments uses wrong hostname * Show comment buttons on input focus rather than non-empty input Increase maximum sidebar size * Allow opening comments from document menu * fix: Clicking comment menu should not focus thread
This commit is contained in:
@@ -22,9 +22,10 @@ import {
|
|||||||
LightBulbIcon,
|
LightBulbIcon,
|
||||||
UnpublishIcon,
|
UnpublishIcon,
|
||||||
PublishIcon,
|
PublishIcon,
|
||||||
|
CommentIcon,
|
||||||
} from "outline-icons";
|
} from "outline-icons";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ExportContentType } from "@shared/types";
|
import { ExportContentType, TeamPreference } from "@shared/types";
|
||||||
import { getEventFiles } from "@shared/utils/files";
|
import { getEventFiles } from "@shared/utils/files";
|
||||||
import DocumentDelete from "~/scenes/DocumentDelete";
|
import DocumentDelete from "~/scenes/DocumentDelete";
|
||||||
import DocumentMove from "~/scenes/DocumentMove";
|
import DocumentMove from "~/scenes/DocumentMove";
|
||||||
@@ -466,7 +467,7 @@ export const printDocument = createAction({
|
|||||||
icon: <PrintIcon />,
|
icon: <PrintIcon />,
|
||||||
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
visible: ({ activeDocumentId }) => !!(activeDocumentId && window.print),
|
||||||
perform: async () => {
|
perform: async () => {
|
||||||
window.print();
|
queueMicrotask(window.print);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -708,6 +709,29 @@ export const permanentlyDeleteDocument = createAction({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const openDocumentComments = createAction({
|
||||||
|
name: ({ t }) => t("Comments"),
|
||||||
|
analyticsName: "Open comments",
|
||||||
|
section: DocumentSection,
|
||||||
|
icon: <CommentIcon />,
|
||||||
|
visible: ({ activeDocumentId, stores }) => {
|
||||||
|
const can = stores.policies.abilities(activeDocumentId ?? "");
|
||||||
|
return (
|
||||||
|
!!activeDocumentId &&
|
||||||
|
can.read &&
|
||||||
|
!can.restore &&
|
||||||
|
!!stores.auth.team?.getPreference(TeamPreference.Commenting)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
perform: ({ activeDocumentId, stores }) => {
|
||||||
|
if (!activeDocumentId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stores.ui.toggleComments();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const openDocumentHistory = createAction({
|
export const openDocumentHistory = createAction({
|
||||||
name: ({ t }) => t("History"),
|
name: ({ t }) => t("History"),
|
||||||
analyticsName: "Open document history",
|
analyticsName: "Open document history",
|
||||||
@@ -771,6 +795,7 @@ export const rootDocumentActions = [
|
|||||||
printDocument,
|
printDocument,
|
||||||
pinDocumentToCollection,
|
pinDocumentToCollection,
|
||||||
pinDocumentToHome,
|
pinDocumentToHome,
|
||||||
|
openDocumentComments,
|
||||||
openDocumentHistory,
|
openDocumentHistory,
|
||||||
openDocumentInsights,
|
openDocumentInsights,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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";
|
||||||
@@ -61,7 +60,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
onDeleteCommentMark,
|
onDeleteCommentMark,
|
||||||
} = props;
|
} = props;
|
||||||
const { auth, comments, documents } = useStores();
|
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);
|
||||||
@@ -343,7 +341,6 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
|
|||||||
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
|
||||||
|
|||||||
18
app/components/Portal.tsx
Normal file
18
app/components/Portal.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Portal as ReactPortal } from "react-portal";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React context that provides a dom node for portals to be rendered into.
|
||||||
|
*/
|
||||||
|
export const PortalContext = React.createContext<
|
||||||
|
HTMLElement | null | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A portal component that uses context to render into a different dom node
|
||||||
|
* or the root of body if no context is available.
|
||||||
|
*/
|
||||||
|
export function Portal(props: { children: React.ReactNode }) {
|
||||||
|
const node = React.useContext(PortalContext);
|
||||||
|
return <ReactPortal node={node}>{props.children}</ReactPortal>;
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { findDomRefAtPos, findParentNode } from "prosemirror-utils";
|
|||||||
import { EditorView } from "prosemirror-view";
|
import { EditorView } from "prosemirror-view";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Trans } from "react-i18next";
|
import { Trans } from "react-i18next";
|
||||||
import { Portal } from "react-portal";
|
|
||||||
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
import { VisuallyHidden } from "reakit/VisuallyHidden";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import insertFiles from "@shared/editor/commands/insertFiles";
|
import insertFiles from "@shared/editor/commands/insertFiles";
|
||||||
@@ -14,6 +13,7 @@ import { MenuItem } from "@shared/editor/types";
|
|||||||
import { depths } from "@shared/styles";
|
import { depths } from "@shared/styles";
|
||||||
import { getEventFiles } from "@shared/utils/files";
|
import { getEventFiles } from "@shared/utils/files";
|
||||||
import { AttachmentValidation } from "@shared/validations";
|
import { AttachmentValidation } from "@shared/validations";
|
||||||
|
import { Portal } from "~/components/Portal";
|
||||||
import Scrollable from "~/components/Scrollable";
|
import Scrollable from "~/components/Scrollable";
|
||||||
import { Dictionary } from "~/hooks/useDictionary";
|
import { Dictionary } from "~/hooks/useDictionary";
|
||||||
import Input from "./Input";
|
import Input from "./Input";
|
||||||
@@ -406,7 +406,16 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
|||||||
const { top, bottom, right } = paragraph.node.getBoundingClientRect();
|
const { top, bottom, right } = paragraph.node.getBoundingClientRect();
|
||||||
const margin = 24;
|
const margin = 24;
|
||||||
|
|
||||||
let leftPos = left + window.scrollX;
|
const offsetParent = ref?.offsetParent
|
||||||
|
? ref.offsetParent.getBoundingClientRect()
|
||||||
|
: ({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
} as DOMRect);
|
||||||
|
|
||||||
|
let leftPos = left - offsetParent.left;
|
||||||
if (props.rtl && ref) {
|
if (props.rtl && ref) {
|
||||||
leftPos = right - ref.scrollWidth;
|
leftPos = right - ref.scrollWidth;
|
||||||
}
|
}
|
||||||
@@ -414,14 +423,14 @@ class CommandMenu<T extends MenuItem> extends React.Component<Props<T>, State> {
|
|||||||
if (startPos.top - offsetHeight > margin) {
|
if (startPos.top - offsetHeight > margin) {
|
||||||
return {
|
return {
|
||||||
left: leftPos,
|
left: leftPos,
|
||||||
top: undefined,
|
top: top - offsetParent.top - offsetHeight,
|
||||||
bottom: window.innerHeight - top - window.scrollY,
|
bottom: undefined,
|
||||||
isAbove: false,
|
isAbove: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
left: leftPos,
|
left: leftPos,
|
||||||
top: bottom + window.scrollY,
|
top: bottom - offsetParent.top,
|
||||||
bottom: undefined,
|
bottom: undefined,
|
||||||
isAbove: true,
|
isAbove: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { NodeSelection } from "prosemirror-state";
|
import { NodeSelection } from "prosemirror-state";
|
||||||
import { CellSelection } from "prosemirror-tables";
|
import { CellSelection } from "prosemirror-tables";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Portal } from "react-portal";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { depths } from "@shared/styles";
|
import { depths } from "@shared/styles";
|
||||||
|
import { Portal } from "~/components/Portal";
|
||||||
import useComponentSize from "~/hooks/useComponentSize";
|
import useComponentSize from "~/hooks/useComponentSize";
|
||||||
import useEventListener from "~/hooks/useEventListener";
|
import useEventListener from "~/hooks/useEventListener";
|
||||||
import useMediaQuery from "~/hooks/useMediaQuery";
|
import useMediaQuery from "~/hooks/useMediaQuery";
|
||||||
@@ -80,6 +80,15 @@ function usePosition({
|
|||||||
right: Math.max(fromPos.right, toPos.right),
|
right: Math.max(fromPos.right, toPos.right),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const offsetParent = menuRef.current.offsetParent
|
||||||
|
? menuRef.current.offsetParent.getBoundingClientRect()
|
||||||
|
: ({
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
} as DOMRect);
|
||||||
|
|
||||||
// tables are an oddity, and need their own positioning logic
|
// tables are an oddity, and need their own positioning logic
|
||||||
const isColSelection =
|
const isColSelection =
|
||||||
selection instanceof CellSelection &&
|
selection instanceof CellSelection &&
|
||||||
@@ -116,8 +125,8 @@ function usePosition({
|
|||||||
const { left, top, width } = imageElement.getBoundingClientRect();
|
const { left, top, width } = imageElement.getBoundingClientRect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
left: Math.round(left + width / 2 + window.scrollX - menuWidth / 2),
|
left: Math.round(left + width / 2 - menuWidth / 2 - offsetParent.left),
|
||||||
top: Math.round(top + window.scrollY - menuHeight),
|
top: Math.round(top - menuHeight - offsetParent.top),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
visible: true,
|
visible: true,
|
||||||
};
|
};
|
||||||
@@ -145,8 +154,8 @@ function usePosition({
|
|||||||
// of the selection still
|
// of the selection still
|
||||||
const offset = left - (centerOfSelection - menuWidth / 2);
|
const offset = left - (centerOfSelection - menuWidth / 2);
|
||||||
return {
|
return {
|
||||||
left: Math.round(left + window.scrollX),
|
left: Math.round(left - offsetParent.left),
|
||||||
top: Math.round(top + window.scrollY),
|
top: Math.round(top - offsetParent.top),
|
||||||
offset: Math.round(offset),
|
offset: Math.round(offset),
|
||||||
visible: true,
|
visible: true,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import styled from "styled-components";
|
|||||||
|
|
||||||
type Props = { active?: boolean; disabled?: boolean };
|
type Props = { active?: boolean; disabled?: boolean };
|
||||||
|
|
||||||
export default styled.button<Props>`
|
export default styled.button.attrs((props) => ({
|
||||||
|
type: props.type || "button",
|
||||||
|
}))<Props>`
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
flex: 0;
|
flex: 0;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { UserPreferences } from "@shared/types";
|
|||||||
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
|
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 { PortalContext } from "~/components/Portal";
|
||||||
import { Dictionary } from "~/hooks/useDictionary";
|
import { Dictionary } from "~/hooks/useDictionary";
|
||||||
import Logger from "~/utils/Logger";
|
import Logger from "~/utils/Logger";
|
||||||
import BlockMenu from "./components/BlockMenu";
|
import BlockMenu from "./components/BlockMenu";
|
||||||
@@ -178,7 +179,8 @@ export class Editor extends React.PureComponent<
|
|||||||
|
|
||||||
isBlurred: boolean;
|
isBlurred: boolean;
|
||||||
extensions: ExtensionManager;
|
extensions: ExtensionManager;
|
||||||
element = React.createRef<HTMLDivElement>();
|
elementRef = React.createRef<HTMLDivElement>();
|
||||||
|
wrapperRef = React.createRef<HTMLDivElement>();
|
||||||
view: EditorView;
|
view: EditorView;
|
||||||
schema: Schema;
|
schema: Schema;
|
||||||
serializer: MarkdownSerializer;
|
serializer: MarkdownSerializer;
|
||||||
@@ -435,7 +437,7 @@ export class Editor extends React.PureComponent<
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createView() {
|
private createView() {
|
||||||
if (!this.element.current) {
|
if (!this.elementRef.current) {
|
||||||
throw new Error("createView called before ref available");
|
throw new Error("createView called before ref available");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +450,7 @@ export class Editor extends React.PureComponent<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const self = this; // eslint-disable-line
|
const self = this; // eslint-disable-line
|
||||||
const view = new EditorView(this.element.current, {
|
const view = new EditorView(this.elementRef.current, {
|
||||||
handleDOMEvents: {
|
handleDOMEvents: {
|
||||||
blur: this.handleEditorBlur,
|
blur: this.handleEditorBlur,
|
||||||
focus: this.handleEditorFocus,
|
focus: this.handleEditorFocus,
|
||||||
@@ -521,13 +523,13 @@ export class Editor extends React.PureComponent<
|
|||||||
};
|
};
|
||||||
|
|
||||||
private calculateDir = () => {
|
private calculateDir = () => {
|
||||||
if (!this.element.current) {
|
if (!this.elementRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isRTL =
|
const isRTL =
|
||||||
this.props.dir === "rtl" ||
|
this.props.dir === "rtl" ||
|
||||||
getComputedStyle(this.element.current).direction === "rtl";
|
getComputedStyle(this.elementRef.current).direction === "rtl";
|
||||||
|
|
||||||
if (this.state.isRTL !== isRTL) {
|
if (this.state.isRTL !== isRTL) {
|
||||||
this.setState({ isRTL });
|
this.setState({ isRTL });
|
||||||
@@ -718,75 +720,78 @@ export class Editor extends React.PureComponent<
|
|||||||
const { isRTL } = this.state;
|
const { isRTL } = this.state;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContext.Provider value={this}>
|
<PortalContext.Provider value={this.wrapperRef.current}>
|
||||||
<Flex
|
<EditorContext.Provider value={this}>
|
||||||
onKeyDown={onKeyDown}
|
<Flex
|
||||||
style={style}
|
ref={this.wrapperRef}
|
||||||
className={className}
|
onKeyDown={onKeyDown}
|
||||||
align="flex-start"
|
style={style}
|
||||||
justify="center"
|
className={className}
|
||||||
column
|
align="flex-start"
|
||||||
>
|
justify="center"
|
||||||
<EditorContainer
|
column
|
||||||
dir={dir}
|
>
|
||||||
rtl={isRTL}
|
<EditorContainer
|
||||||
grow={grow}
|
dir={dir}
|
||||||
readOnly={readOnly}
|
rtl={isRTL}
|
||||||
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
|
grow={grow}
|
||||||
focusedCommentId={this.props.focusedCommentId}
|
readOnly={readOnly}
|
||||||
ref={this.element}
|
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
|
||||||
/>
|
focusedCommentId={this.props.focusedCommentId}
|
||||||
{!readOnly && this.view && (
|
ref={this.elementRef}
|
||||||
<>
|
/>
|
||||||
<SelectionToolbar
|
{!readOnly && this.view && (
|
||||||
view={this.view}
|
<>
|
||||||
dictionary={dictionary}
|
<SelectionToolbar
|
||||||
commands={this.commands}
|
view={this.view}
|
||||||
rtl={isRTL}
|
dictionary={dictionary}
|
||||||
isTemplate={this.props.template === true}
|
commands={this.commands}
|
||||||
onOpen={this.handleOpenSelectionMenu}
|
rtl={isRTL}
|
||||||
onClose={this.handleCloseSelectionMenu}
|
isTemplate={this.props.template === true}
|
||||||
onSearchLink={this.props.onSearchLink}
|
onOpen={this.handleOpenSelectionMenu}
|
||||||
onClickLink={this.props.onClickLink}
|
onClose={this.handleCloseSelectionMenu}
|
||||||
onCreateLink={this.props.onCreateLink}
|
onSearchLink={this.props.onSearchLink}
|
||||||
onShowToast={this.props.onShowToast}
|
onClickLink={this.props.onClickLink}
|
||||||
/>
|
onCreateLink={this.props.onCreateLink}
|
||||||
<LinkToolbar
|
onShowToast={this.props.onShowToast}
|
||||||
isActive={this.state.linkMenuOpen}
|
/>
|
||||||
onCreateLink={this.props.onCreateLink}
|
<LinkToolbar
|
||||||
onSearchLink={this.props.onSearchLink}
|
isActive={this.state.linkMenuOpen}
|
||||||
onClickLink={this.props.onClickLink}
|
onCreateLink={this.props.onCreateLink}
|
||||||
onClose={this.handleCloseLinkMenu}
|
onSearchLink={this.props.onSearchLink}
|
||||||
/>
|
onClickLink={this.props.onClickLink}
|
||||||
<EmojiMenu
|
onClose={this.handleCloseLinkMenu}
|
||||||
view={this.view}
|
/>
|
||||||
commands={this.commands}
|
<EmojiMenu
|
||||||
dictionary={dictionary}
|
view={this.view}
|
||||||
rtl={isRTL}
|
commands={this.commands}
|
||||||
onShowToast={this.props.onShowToast}
|
dictionary={dictionary}
|
||||||
isActive={this.state.emojiMenuOpen}
|
rtl={isRTL}
|
||||||
search={this.state.blockMenuSearch}
|
onShowToast={this.props.onShowToast}
|
||||||
onClose={this.handleCloseEmojiMenu}
|
isActive={this.state.emojiMenuOpen}
|
||||||
/>
|
search={this.state.blockMenuSearch}
|
||||||
<BlockMenu
|
onClose={this.handleCloseEmojiMenu}
|
||||||
view={this.view}
|
/>
|
||||||
commands={this.commands}
|
<BlockMenu
|
||||||
dictionary={dictionary}
|
view={this.view}
|
||||||
rtl={isRTL}
|
commands={this.commands}
|
||||||
isActive={this.state.blockMenuOpen}
|
dictionary={dictionary}
|
||||||
search={this.state.blockMenuSearch}
|
rtl={isRTL}
|
||||||
onClose={this.handleCloseBlockMenu}
|
isActive={this.state.blockMenuOpen}
|
||||||
uploadFile={this.props.uploadFile}
|
search={this.state.blockMenuSearch}
|
||||||
onLinkToolbarOpen={this.handleOpenLinkMenu}
|
onClose={this.handleCloseBlockMenu}
|
||||||
onFileUploadStart={this.props.onFileUploadStart}
|
uploadFile={this.props.uploadFile}
|
||||||
onFileUploadStop={this.props.onFileUploadStop}
|
onLinkToolbarOpen={this.handleOpenLinkMenu}
|
||||||
onShowToast={this.props.onShowToast}
|
onFileUploadStart={this.props.onFileUploadStart}
|
||||||
embeds={this.props.embeds}
|
onFileUploadStop={this.props.onFileUploadStop}
|
||||||
/>
|
onShowToast={this.props.onShowToast}
|
||||||
</>
|
embeds={this.props.embeds}
|
||||||
)}
|
/>
|
||||||
</Flex>
|
</>
|
||||||
</EditorContext.Provider>
|
)}
|
||||||
|
</Flex>
|
||||||
|
</EditorContext.Provider>
|
||||||
|
</PortalContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ContextMenu from "~/components/ContextMenu";
|
|||||||
import MenuItem from "~/components/ContextMenu/MenuItem";
|
import MenuItem from "~/components/ContextMenu/MenuItem";
|
||||||
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
|
||||||
import Separator from "~/components/ContextMenu/Separator";
|
import Separator from "~/components/ContextMenu/Separator";
|
||||||
|
import EventBoundary from "~/components/EventBoundary";
|
||||||
import usePolicy from "~/hooks/usePolicy";
|
import usePolicy from "~/hooks/usePolicy";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import useToasts from "~/hooks/useToasts";
|
import useToasts from "~/hooks/useToasts";
|
||||||
@@ -52,11 +53,14 @@ function CommentMenu({ comment, onEdit, onDelete, className }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<OverflowMenuButton
|
<EventBoundary>
|
||||||
aria-label={t("Show menu")}
|
<OverflowMenuButton
|
||||||
className={className}
|
aria-label={t("Show menu")}
|
||||||
{...menu}
|
className={className}
|
||||||
/>
|
{...menu}
|
||||||
|
/>
|
||||||
|
</EventBoundary>
|
||||||
|
|
||||||
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
<ContextMenu {...menu} aria-label={t("Comment options")}>
|
||||||
{can.update && (
|
{can.update && (
|
||||||
<MenuItem {...menu} onClick={onEdit}>
|
<MenuItem {...menu} onClick={onEdit}>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import {
|
import { EditIcon, NewDocumentIcon, RestoreIcon } from "outline-icons";
|
||||||
EditIcon,
|
|
||||||
PrintIcon,
|
|
||||||
NewDocumentIcon,
|
|
||||||
RestoreIcon,
|
|
||||||
} from "outline-icons";
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
@@ -40,6 +35,8 @@ import {
|
|||||||
openDocumentInsights,
|
openDocumentInsights,
|
||||||
publishDocument,
|
publishDocument,
|
||||||
unpublishDocument,
|
unpublishDocument,
|
||||||
|
printDocument,
|
||||||
|
openDocumentComments,
|
||||||
} from "~/actions/definitions/documents";
|
} from "~/actions/definitions/documents";
|
||||||
import useActionContext from "~/hooks/useActionContext";
|
import useActionContext from "~/hooks/useActionContext";
|
||||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||||
@@ -125,11 +122,6 @@ function DocumentMenu({
|
|||||||
[showToast, t, document]
|
[showToast, t, document]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePrint = React.useCallback(() => {
|
|
||||||
menu.hide();
|
|
||||||
window.print();
|
|
||||||
}, [menu]);
|
|
||||||
|
|
||||||
const collection = collections.get(document.collectionId);
|
const collection = collections.get(document.collectionId);
|
||||||
const can = usePolicy(document);
|
const can = usePolicy(document);
|
||||||
const restoreItems = React.useMemo(
|
const restoreItems = React.useMemo(
|
||||||
@@ -291,16 +283,11 @@ function DocumentMenu({
|
|||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
},
|
},
|
||||||
actionToMenuItem(downloadDocument, context),
|
actionToMenuItem(openDocumentComments, context),
|
||||||
actionToMenuItem(openDocumentHistory, context),
|
actionToMenuItem(openDocumentHistory, context),
|
||||||
actionToMenuItem(openDocumentInsights, context),
|
actionToMenuItem(openDocumentInsights, context),
|
||||||
{
|
actionToMenuItem(downloadDocument, context),
|
||||||
type: "button",
|
actionToMenuItem(printDocument, context),
|
||||||
title: t("Print"),
|
|
||||||
onClick: handlePrint,
|
|
||||||
visible: !!showDisplayOptions,
|
|
||||||
icon: <PrintIcon />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "separator",
|
type: "separator",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ type Props = {
|
|||||||
/** The document that the comment will be associated with */
|
/** The document that the comment will be associated with */
|
||||||
documentId: string;
|
documentId: string;
|
||||||
/** The comment thread that the comment will be associated with */
|
/** The comment thread that the comment will be associated with */
|
||||||
thread: Comment;
|
thread?: Comment;
|
||||||
/** Placeholder text to display in the editor */
|
/** Placeholder text to display in the editor */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
/** Whether to focus the editor on mount */
|
/** Whether to focus the editor on mount */
|
||||||
@@ -59,20 +59,22 @@ function CommentForm({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const { editor } = useDocumentContext();
|
const { editor } = useDocumentContext();
|
||||||
const [data, setData] = usePersistedState<Record<string, any> | undefined>(
|
const [data, setData] = usePersistedState<Record<string, any> | undefined>(
|
||||||
`draft-${documentId}-${thread.id}`,
|
`draft-${documentId}-${thread?.id ?? "new"}`,
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
const formRef = React.useRef<HTMLFormElement>(null);
|
const formRef = React.useRef<HTMLFormElement>(null);
|
||||||
const editorRef = React.useRef<SharedEditor>(null);
|
const editorRef = React.useRef<SharedEditor>(null);
|
||||||
const [forceRender, setForceRender] = React.useState(0);
|
const [forceRender, setForceRender] = React.useState(0);
|
||||||
|
const [inputFocused, setInputFocused] = React.useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { showToast } = useToasts();
|
const { showToast } = useToasts();
|
||||||
const { comments } = useStores();
|
const { comments } = useStores();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const isEmpty = editorRef.current?.isEmpty() ?? true;
|
|
||||||
|
|
||||||
useOnClickOutside(formRef, () => {
|
useOnClickOutside(formRef, () => {
|
||||||
if (isEmpty && thread.isNew) {
|
const isEmpty = editorRef.current?.isEmpty() ?? true;
|
||||||
|
|
||||||
|
if (isEmpty && thread?.isNew) {
|
||||||
if (thread.id) {
|
if (thread.id) {
|
||||||
editor?.removeComment(thread.id);
|
editor?.removeComment(thread.id);
|
||||||
}
|
}
|
||||||
@@ -86,19 +88,29 @@ function CommentForm({
|
|||||||
setData(undefined);
|
setData(undefined);
|
||||||
setForceRender((s) => ++s);
|
setForceRender((s) => ++s);
|
||||||
|
|
||||||
thread
|
const comment =
|
||||||
|
thread ??
|
||||||
|
new Comment(
|
||||||
|
{
|
||||||
|
documentId,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
comments
|
||||||
|
);
|
||||||
|
|
||||||
|
comment
|
||||||
.save({
|
.save({
|
||||||
documentId,
|
documentId,
|
||||||
data,
|
data,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
thread.isNew = true;
|
comment.isNew = true;
|
||||||
showToast(t("Error creating comment"), { type: "error" });
|
showToast(t("Error creating comment"), { type: "error" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// optimistically update the comment model
|
// optimistically update the comment model
|
||||||
thread.isNew = false;
|
comment.isNew = false;
|
||||||
thread.createdBy = user;
|
comment.createdBy = user;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleCreateReply = async (event: React.FormEvent) => {
|
const handleCreateReply = async (event: React.FormEvent) => {
|
||||||
@@ -145,6 +157,16 @@ function CommentForm({
|
|||||||
setForceRender((s) => ++s);
|
setForceRender((s) => ++s);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
onFocus?.();
|
||||||
|
setInputFocused(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
onBlur?.();
|
||||||
|
setInputFocused(false);
|
||||||
|
};
|
||||||
|
|
||||||
// Focus the editor when it's a new comment just mounted, after a delay as the
|
// Focus the editor when it's a new comment just mounted, after a delay as the
|
||||||
// editor is mounted within a fade transition.
|
// editor is mounted within a fade transition.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -199,21 +221,23 @@ function CommentForm({
|
|||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onFocus={onFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={onBlur}
|
onBlur={handleBlur}
|
||||||
maxLength={CommentValidation.maxLength}
|
maxLength={CommentValidation.maxLength}
|
||||||
placeholder={
|
placeholder={
|
||||||
placeholder ||
|
placeholder ||
|
||||||
// isNew is only the case for comments that exist in draft state,
|
// 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.
|
// they are marks in the document, but not yet saved to the db.
|
||||||
(thread.isNew ? `${t("Add a comment")}…` : `${t("Add a reply")}…`)
|
(thread?.isNew
|
||||||
|
? `${t("Add a comment")}…`
|
||||||
|
: `${t("Add a reply")}…`)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isEmpty && (
|
{inputFocused && (
|
||||||
<Flex justify={dir === "rtl" ? "flex-end" : "flex-start"} gap={8}>
|
<Flex justify={dir === "rtl" ? "flex-end" : "flex-start"} gap={8}>
|
||||||
<ButtonSmall type="submit" borderOnHover>
|
<ButtonSmall type="submit" borderOnHover>
|
||||||
{thread.isNew ? t("Post") : t("Reply")}
|
{thread && !thread.isNew ? t("Reply") : t("Post")}
|
||||||
</ButtonSmall>
|
</ButtonSmall>
|
||||||
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
|
<ButtonSmall onClick={handleCancel} neutral borderOnHover>
|
||||||
{t("Cancel")}
|
{t("Cancel")}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import * as React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRouteMatch } from "react-router-dom";
|
import { useRouteMatch } from "react-router-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import Comment from "~/models/Comment";
|
|
||||||
import Empty from "~/components/Empty";
|
import Empty from "~/components/Empty";
|
||||||
import Flex from "~/components/Flex";
|
import Flex from "~/components/Flex";
|
||||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||||
@@ -16,7 +15,6 @@ import Sidebar from "./SidebarLayout";
|
|||||||
|
|
||||||
function Comments() {
|
function Comments() {
|
||||||
const { ui, comments, documents } = useStores();
|
const { ui, comments, documents } = useStores();
|
||||||
const [newComment] = React.useState(new Comment({}, comments));
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useCurrentUser();
|
const user = useCurrentUser();
|
||||||
const match = useRouteMatch<{ documentSlug: string }>();
|
const match = useRouteMatch<{ documentSlug: string }>();
|
||||||
@@ -54,9 +52,7 @@ function Comments() {
|
|||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false}>
|
||||||
{!focusedComment && (
|
{!focusedComment && (
|
||||||
<NewCommentForm
|
<NewCommentForm
|
||||||
key="new-comment-form"
|
|
||||||
documentId={document.id}
|
documentId={document.id}
|
||||||
thread={newComment}
|
|
||||||
placeholder={`${t("Add a comment")}…`}
|
placeholder={`${t("Add a comment")}…`}
|
||||||
autoFocus={false}
|
autoFocus={false}
|
||||||
dir={document.dir}
|
dir={document.dir}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Document from "~/models/Document";
|
|||||||
import { RefHandle } from "~/components/ContentEditable";
|
import { RefHandle } from "~/components/ContentEditable";
|
||||||
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 useFocusedComment from "~/hooks/useFocusedComment";
|
||||||
import useStores from "~/hooks/useStores";
|
import useStores from "~/hooks/useStores";
|
||||||
import {
|
import {
|
||||||
documentHistoryUrl,
|
documentHistoryUrl,
|
||||||
@@ -43,6 +44,7 @@ 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 focusedComment = useFocusedComment();
|
||||||
const { ui, comments, auth } = useStores();
|
const { ui, comments, auth } = useStores();
|
||||||
const { user, team } = auth;
|
const { user, team } = auth;
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
@@ -91,13 +93,13 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
pathname: window.location.pathname.replace(/\/history$/, ""),
|
pathname: window.location.pathname.replace(/\/history$/, ""),
|
||||||
state: { commentId },
|
state: { commentId },
|
||||||
});
|
});
|
||||||
} else {
|
} else if (focusedComment) {
|
||||||
history.replace({
|
history.replace({
|
||||||
pathname: window.location.pathname,
|
pathname: window.location.pathname,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[ui, history]
|
[ui, focusedComment, history]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create a Comment model in local store when a comment mark is created, this
|
// Create a Comment model in local store when a comment mark is created, this
|
||||||
@@ -177,6 +179,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
|
|||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
shareId={shareId}
|
shareId={shareId}
|
||||||
userId={user?.id}
|
userId={user?.id}
|
||||||
|
focusedCommentId={focusedComment?.id}
|
||||||
onClickCommentMark={handleClickComment}
|
onClickCommentMark={handleClickComment}
|
||||||
onCreateCommentMark={
|
onCreateCommentMark={
|
||||||
team?.getPreference(TeamPreference.Commenting)
|
team?.getPreference(TeamPreference.Commenting)
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ function Insights() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Content>
|
</Content>
|
||||||
<Content column>
|
<Content column>
|
||||||
<Heading>{t("Collaborators")}</Heading>
|
<Heading>{t("Contributors")}</Heading>
|
||||||
<Text type="secondary" size="small">
|
<Text type="secondary" size="small">
|
||||||
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
|
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
|
||||||
<br />
|
<br />
|
||||||
@@ -92,7 +92,7 @@ function Insights() {
|
|||||||
</Text>
|
</Text>
|
||||||
<ListSpacing>
|
<ListSpacing>
|
||||||
<PaginatedList
|
<PaginatedList
|
||||||
aria-label={t("Collaborators")}
|
aria-label={t("Contributors")}
|
||||||
items={document.collaborators}
|
items={document.collaborators}
|
||||||
renderItem={(model: User) => (
|
renderItem={(model: User) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import queryString from "query-string";
|
|||||||
import Collection from "~/models/Collection";
|
import Collection from "~/models/Collection";
|
||||||
import Comment from "~/models/Comment";
|
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";
|
||||||
@@ -138,7 +137,7 @@ export function notFoundUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function urlify(path: string): string {
|
export function urlify(path: string): string {
|
||||||
return `${env.URL}${path}`;
|
return `${window.location.host}${path}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const matchDocumentSlug =
|
export const matchDocumentSlug =
|
||||||
|
|||||||
@@ -52,6 +52,7 @@
|
|||||||
"Delete {{ documentName }}": "Delete {{ documentName }}",
|
"Delete {{ documentName }}": "Delete {{ documentName }}",
|
||||||
"Permanently delete": "Permanently delete",
|
"Permanently delete": "Permanently delete",
|
||||||
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
"Permanently delete {{ documentName }}": "Permanently delete {{ documentName }}",
|
||||||
|
"Comments": "Comments",
|
||||||
"History": "History",
|
"History": "History",
|
||||||
"Insights": "Insights",
|
"Insights": "Insights",
|
||||||
"Home": "Home",
|
"Home": "Home",
|
||||||
@@ -436,10 +437,9 @@
|
|||||||
"Error creating comment": "Error creating comment",
|
"Error creating comment": "Error creating comment",
|
||||||
"Add a comment": "Add a comment",
|
"Add a comment": "Add a comment",
|
||||||
"Add a reply": "Add a reply",
|
"Add a reply": "Add a reply",
|
||||||
"Post": "Post",
|
|
||||||
"Reply": "Reply",
|
"Reply": "Reply",
|
||||||
|
"Post": "Post",
|
||||||
"Cancel": "Cancel",
|
"Cancel": "Cancel",
|
||||||
"Comments": "Comments",
|
|
||||||
"No comments yet": "No comments yet",
|
"No comments yet": "No comments yet",
|
||||||
"Error updating comment": "Error updating comment",
|
"Error updating comment": "Error updating comment",
|
||||||
"Document updated by {{userName}}": "Document updated by {{userName}}",
|
"Document updated by {{userName}}": "Document updated by {{userName}}",
|
||||||
@@ -476,7 +476,7 @@
|
|||||||
"{{ count }} words selected_plural": "{{ count }} words selected",
|
"{{ count }} words selected_plural": "{{ count }} words selected",
|
||||||
"{{ count }} characters selected": "{{ count }} character selected",
|
"{{ count }} characters selected": "{{ count }} character selected",
|
||||||
"{{ count }} characters selected_plural": "{{ count }} characters selected",
|
"{{ count }} characters selected_plural": "{{ count }} characters selected",
|
||||||
"Collaborators": "Collaborators",
|
"Contributors": "Contributors",
|
||||||
"Created": "Created",
|
"Created": "Created",
|
||||||
"Last updated": "Last updated",
|
"Last updated": "Last updated",
|
||||||
"Creator": "Creator",
|
"Creator": "Creator",
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const spacing = {
|
|||||||
sidebarWidth: 260,
|
sidebarWidth: 260,
|
||||||
sidebarCollapsedWidth: 16,
|
sidebarCollapsedWidth: 16,
|
||||||
sidebarMinWidth: 200,
|
sidebarMinWidth: 200,
|
||||||
sidebarMaxWidth: 400,
|
sidebarMaxWidth: 600,
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildBaseTheme = (input: Partial<Colors>) => {
|
const buildBaseTheme = (input: Partial<Colors>) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user