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:
@@ -21,6 +21,17 @@ function ToolbarMenu(props: Props) {
|
||||
const { items } = props;
|
||||
const { state } = view;
|
||||
|
||||
const handleClick = (item: MenuItem) => () => {
|
||||
if (!item.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const attrs =
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs;
|
||||
|
||||
commands[item.name](attrs);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlexibleWrapper>
|
||||
{items.map((item, index) => {
|
||||
@@ -34,10 +45,7 @@ function ToolbarMenu(props: Props) {
|
||||
|
||||
return (
|
||||
<Tooltip tooltip={item.tooltip} key={index}>
|
||||
<ToolbarButton
|
||||
onClick={() => item.name && commands[item.name](item.attrs)}
|
||||
active={isActive}
|
||||
>
|
||||
<ToolbarButton onClick={handleClick(item)} active={isActive}>
|
||||
{React.cloneElement(item.icon, { color: "currentColor" })}
|
||||
</ToolbarButton>
|
||||
</Tooltip>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* global File Promise */
|
||||
import { PluginSimple } from "markdown-it";
|
||||
import { transparentize } from "polished";
|
||||
import { baseKeymap } from "prosemirror-commands";
|
||||
import { dropCursor } from "prosemirror-dropcursor";
|
||||
import { gapCursor } from "prosemirror-gapcursor";
|
||||
@@ -15,13 +16,11 @@ import {
|
||||
import { EditorState, Selection, Plugin, Transaction } from "prosemirror-state";
|
||||
import { Decoration, EditorView } from "prosemirror-view";
|
||||
import * as React from "react";
|
||||
import { DefaultTheme, ThemeProps } from "styled-components";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import styled, { css, DefaultTheme, ThemeProps } from "styled-components";
|
||||
import Styles from "@shared/editor/components/Styles";
|
||||
import { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import Extension, { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
|
||||
import getHeadings from "@shared/editor/lib/getHeadings";
|
||||
import getTasks from "@shared/editor/lib/getTasks";
|
||||
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import Mark from "@shared/editor/marks/Mark";
|
||||
@@ -30,6 +29,7 @@ import ReactNode from "@shared/editor/nodes/ReactNode";
|
||||
import fullExtensionsPackage from "@shared/editor/packages/full";
|
||||
import { EventType } from "@shared/editor/types";
|
||||
import { UserPreferences } from "@shared/types";
|
||||
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
|
||||
import EventEmitter from "@shared/utils/events";
|
||||
import Flex from "~/components/Flex";
|
||||
import { Dictionary } from "~/hooks/useDictionary";
|
||||
@@ -48,16 +48,20 @@ export { default as Extension } from "@shared/editor/lib/Extension";
|
||||
export type Props = {
|
||||
/** An optional identifier for the editor context. It is used to persist local settings */
|
||||
id?: string;
|
||||
/** The current userId, if any */
|
||||
userId?: string;
|
||||
/** The editor content, should only be changed if you wish to reset the content */
|
||||
value?: string;
|
||||
/** The initial editor content */
|
||||
defaultValue: string;
|
||||
/** The initial editor content as a markdown string or JSON object */
|
||||
defaultValue: string | object;
|
||||
/** Placeholder displayed when the editor is empty */
|
||||
placeholder: string;
|
||||
/** Extensions to load into the editor */
|
||||
extensions?: (typeof Node | typeof Mark | typeof Extension | Extension)[];
|
||||
/** If the editor should be focused on mount */
|
||||
autoFocus?: boolean;
|
||||
/** The focused comment, if any */
|
||||
focusedCommentId?: string;
|
||||
/** If the editor should not allow editing */
|
||||
readOnly?: boolean;
|
||||
/** If the editor should still allow editing checkboxes when it is readOnly */
|
||||
@@ -85,7 +89,13 @@ export type Props = {
|
||||
/** Callback when user uses cancel key combo */
|
||||
onCancel?: () => void;
|
||||
/** Callback when user changes editor content */
|
||||
onChange?: (value: () => string | undefined) => void;
|
||||
onChange?: (value: () => any) => void;
|
||||
/** Callback when a comment mark is clicked */
|
||||
onClickCommentMark?: (commentId: string) => void;
|
||||
/** Callback when a comment mark is created */
|
||||
onCreateCommentMark?: (commentId: string, userId: string) => void;
|
||||
/** Callback when a comment mark is removed */
|
||||
onDeleteCommentMark?: (commentId: string) => void;
|
||||
/** Callback when a file upload begins */
|
||||
onFileUploadStart?: () => void;
|
||||
/** Callback when a file upload ends */
|
||||
@@ -394,7 +404,7 @@ export class Editor extends React.PureComponent<
|
||||
});
|
||||
}
|
||||
|
||||
private createState(value?: string) {
|
||||
private createState(value?: string | object) {
|
||||
const doc = this.createDocument(value || this.props.defaultValue);
|
||||
|
||||
return EditorState.create({
|
||||
@@ -415,8 +425,13 @@ export class Editor extends React.PureComponent<
|
||||
});
|
||||
}
|
||||
|
||||
private createDocument(content: string) {
|
||||
return this.parser.parse(content);
|
||||
private createDocument(content: string | object) {
|
||||
// Looks like Markdown
|
||||
if (typeof content === "string") {
|
||||
return this.parser.parse(content);
|
||||
}
|
||||
|
||||
return ProsemirrorNode.fromJSON(this.schema, content);
|
||||
}
|
||||
|
||||
private createView() {
|
||||
@@ -475,10 +490,6 @@ export class Editor extends React.PureComponent<
|
||||
return view;
|
||||
}
|
||||
|
||||
private dispatchThemeChanged = (event: CustomEvent) => {
|
||||
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
|
||||
};
|
||||
|
||||
public scrollToAnchor(hash: string) {
|
||||
if (!hash) {
|
||||
return;
|
||||
@@ -497,6 +508,18 @@ export class Editor extends React.PureComponent<
|
||||
}
|
||||
}
|
||||
|
||||
public value = (asString = true, trim?: boolean) => {
|
||||
if (asString) {
|
||||
const content = this.serializer.serialize(this.view.state.doc);
|
||||
return trim ? content.trim() : content;
|
||||
}
|
||||
|
||||
return (trim
|
||||
? ProsemirrorHelper.trim(this.view.state.doc)
|
||||
: this.view.state.doc
|
||||
).toJSON();
|
||||
};
|
||||
|
||||
private calculateDir = () => {
|
||||
if (!this.element.current) {
|
||||
return;
|
||||
@@ -511,8 +534,106 @@ export class Editor extends React.PureComponent<
|
||||
}
|
||||
};
|
||||
|
||||
public value = (): string => {
|
||||
return this.serializer.serialize(this.view.state.doc);
|
||||
/**
|
||||
* Focus the editor at the start of the content.
|
||||
*/
|
||||
public focusAtStart = () => {
|
||||
const selection = Selection.atStart(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the end of the content.
|
||||
*/
|
||||
public focusAtEnd = () => {
|
||||
const selection = Selection.atEnd(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if the trimmed content of the editor is an empty string.
|
||||
*
|
||||
* @returns True if the editor is empty
|
||||
*/
|
||||
public isEmpty = () => {
|
||||
return ProsemirrorHelper.isEmpty(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the headings in the current editor.
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () => {
|
||||
return ProsemirrorHelper.getHeadings(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the tasks/checkmarks in the current editor.
|
||||
*
|
||||
* @returns A list of tasks in the document
|
||||
*/
|
||||
public getTasks = () => {
|
||||
return ProsemirrorHelper.getTasks(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the comments in the current editor.
|
||||
*
|
||||
* @returns A list of comments in the document
|
||||
*/
|
||||
public getComments = () => {
|
||||
return ProsemirrorHelper.getComments(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a specific comment mark from the document.
|
||||
*
|
||||
* @param commentId The id of the comment to remove
|
||||
*/
|
||||
public removeComment = (commentId: string) => {
|
||||
const { state, dispatch } = this.view;
|
||||
let found = false;
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (!node.isInline || found) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mark = node.marks.find(
|
||||
(mark) =>
|
||||
mark.type === state.schema.marks.comment &&
|
||||
mark.attrs.id === commentId
|
||||
);
|
||||
|
||||
if (mark) {
|
||||
dispatch(state.tr.removeMark(pos, pos + node.nodeSize, mark));
|
||||
found = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the plain text content of the current editor.
|
||||
*
|
||||
* @returns A string of text
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
};
|
||||
|
||||
private dispatchThemeChanged = (event: CustomEvent) => {
|
||||
this.view.dispatch(this.view.state.tr.setMeta("theme", event.detail));
|
||||
};
|
||||
|
||||
private handleChange = () => {
|
||||
@@ -520,8 +641,8 @@ export class Editor extends React.PureComponent<
|
||||
return;
|
||||
}
|
||||
|
||||
this.props.onChange(() => {
|
||||
return this.view ? this.value() : undefined;
|
||||
this.props.onChange((asString = true, trim = false) => {
|
||||
return this.view ? this.value(asString, trim) : undefined;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -583,60 +704,6 @@ export class Editor extends React.PureComponent<
|
||||
this.setState({ blockMenuOpen: false });
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the start of the content.
|
||||
*/
|
||||
public focusAtStart = () => {
|
||||
const selection = Selection.atStart(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the editor at the end of the content.
|
||||
*/
|
||||
public focusAtEnd = () => {
|
||||
const selection = Selection.atEnd(this.view.state.doc);
|
||||
const transaction = this.view.state.tr.setSelection(selection);
|
||||
this.view.dispatch(transaction);
|
||||
this.view.focus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the headings in the current editor.
|
||||
*
|
||||
* @returns A list of headings in the document
|
||||
*/
|
||||
public getHeadings = () => {
|
||||
return getHeadings(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the tasks/checkmarks in the current editor.
|
||||
*
|
||||
* @returns A list of tasks in the document
|
||||
*/
|
||||
public getTasks = () => {
|
||||
return getTasks(this.view.state.doc);
|
||||
};
|
||||
|
||||
/**
|
||||
* Return the plain text content of the current editor.
|
||||
*
|
||||
* @returns A string of text
|
||||
*/
|
||||
public getPlainText = () => {
|
||||
const { doc } = this.view.state;
|
||||
const textSerializers = Object.fromEntries(
|
||||
Object.entries(this.schema.nodes)
|
||||
.filter(([, node]) => node.spec.toPlainText)
|
||||
.map(([name, node]) => [name, node.spec.toPlainText])
|
||||
);
|
||||
|
||||
return textBetween(doc, 0, doc.content.size, textSerializers);
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
dir,
|
||||
@@ -658,7 +725,6 @@ export class Editor extends React.PureComponent<
|
||||
className={className}
|
||||
align="flex-start"
|
||||
justify="center"
|
||||
dir={dir}
|
||||
column
|
||||
>
|
||||
<EditorContainer
|
||||
@@ -667,6 +733,7 @@ export class Editor extends React.PureComponent<
|
||||
grow={grow}
|
||||
readOnly={readOnly}
|
||||
readOnlyWriteCheckboxes={readOnlyWriteCheckboxes}
|
||||
focusedCommentId={this.props.focusedCommentId}
|
||||
ref={this.element}
|
||||
/>
|
||||
{!readOnly && this.view && (
|
||||
@@ -724,6 +791,16 @@ export class Editor extends React.PureComponent<
|
||||
}
|
||||
}
|
||||
|
||||
const EditorContainer = styled(Styles)<{ focusedCommentId?: string }>`
|
||||
${(props) =>
|
||||
props.focusedCommentId &&
|
||||
css`
|
||||
#comment-${props.focusedCommentId} {
|
||||
background: ${transparentize(0.5, props.theme.brand.marine)};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
|
||||
(props: Props, ref) => {
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TodoListIcon,
|
||||
InputIcon,
|
||||
HighlightIcon,
|
||||
CommentIcon,
|
||||
ItalicIcon,
|
||||
} from "outline-icons";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
@@ -146,5 +147,12 @@ export default function formattingMenuItems(
|
||||
attrs: { href: "" },
|
||||
visible: !isCode,
|
||||
},
|
||||
{
|
||||
name: "comment",
|
||||
tooltip: dictionary.comment,
|
||||
icon: <CommentIcon />,
|
||||
active: isMarkActive(schema.marks.comment),
|
||||
visible: !isCode,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user