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

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

View File

@@ -567,6 +567,17 @@ h6 {
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 {
display: flex;
align-items: center;
@@ -1456,6 +1467,11 @@ del[data-operation-index] {
display: none;
}
.comment {
border: 0;
background: none;
}
.page-break {
opacity: 0;
}

View File

@@ -64,7 +64,7 @@ export default class Extension {
commands(_options: {
type?: NodeType | MarkType;
schema: Schema;
}): Record<string, CommandFactory> | CommandFactory {
}): Record<string, CommandFactory> | CommandFactory | undefined {
return {};
}
}

View File

@@ -213,7 +213,7 @@ export default class ExtensionManager {
Object.entries(value).forEach(([commandName, commandValue]) => {
handle(commandName, commandValue);
});
} else {
} else if (value) {
handle(name, value);
}

View File

@@ -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;
}

View File

@@ -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 {};
}

View File

@@ -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;
}

View 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;
},
},
},
}),
];
}
}

View File

@@ -113,10 +113,6 @@ export default class Link extends Mark {
];
}
commands({ type }: { type: MarkType }) {
return ({ href } = { href: "" }) => toggleMark(type, { href });
}
keys({ type }: { type: MarkType }) {
return {
"Mod-k": (state: EditorState, dispatch: Dispatch) => {

View File

@@ -39,7 +39,12 @@ export default abstract class Mark extends Extension {
return undefined;
}
commands({ type }: { type: MarkType; schema: Schema }): CommandFactory {
return () => toggleMark(type);
commands({
type,
}: {
type: MarkType;
schema: Schema;
}): Record<string, CommandFactory> | CommandFactory | undefined {
return (attrs) => toggleMark(type, attrs);
}
}

View File

@@ -8,7 +8,6 @@ import Strikethrough from "../marks/Strikethrough";
import Underline from "../marks/Underline";
import Doc from "../nodes/Doc";
import Emoji from "../nodes/Emoji";
import HardBreak from "../nodes/HardBreak";
import Image from "../nodes/Image";
import Node from "../nodes/Node";
import Paragraph from "../nodes/Paragraph";
@@ -16,6 +15,7 @@ import Text from "../nodes/Text";
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
import DateTime from "../plugins/DateTime";
import History from "../plugins/History";
import Keys from "../plugins/Keys";
import MaxLength from "../plugins/MaxLength";
import PasteHandler from "../plugins/PasteHandler";
import Placeholder from "../plugins/Placeholder";
@@ -24,7 +24,6 @@ import TrailingNode from "../plugins/TrailingNode";
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Doc,
HardBreak,
Paragraph,
Emoji,
Text,
@@ -42,6 +41,7 @@ const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Placeholder,
MaxLength,
DateTime,
Keys,
ClipboardTextSerializer,
];

View File

@@ -10,6 +10,7 @@ import CheckboxList from "../nodes/CheckboxList";
import CodeBlock from "../nodes/CodeBlock";
import CodeFence from "../nodes/CodeFence";
import Embed from "../nodes/Embed";
import HardBreak from "../nodes/HardBreak";
import Heading from "../nodes/Heading";
import HorizontalRule from "../nodes/HorizontalRule";
import ListItem from "../nodes/ListItem";
@@ -24,11 +25,11 @@ import TableHeadCell from "../nodes/TableHeadCell";
import TableRow from "../nodes/TableRow";
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
import Folding from "../plugins/Folding";
import Keys from "../plugins/Keys";
import basicPackage from "./basic";
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
...basicPackage,
HardBreak,
CodeBlock,
CodeFence,
CheckboxList,
@@ -49,7 +50,6 @@ const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Highlight,
TemplatePlaceholder,
Folding,
Keys,
BlockMenuTrigger,
Math,
MathBlock,

View 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;