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:
Tom Moor
2023-02-25 15:03:05 -05:00
committed by GitHub
parent 59e25a0ef0
commit fc8c20149f
89 changed files with 2909 additions and 315 deletions

View File

@@ -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 (