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;

View File

@@ -98,7 +98,7 @@
"Viewers": "Viewers",
"Im sure Delete": "Im sure Delete",
"Deleting": "Deleting",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.": "Are you sure about that? Deleting the <em>{{collectionName}}</em> collection is permanent and cannot be restored, however documents within will be moved to the trash.",
"Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.": "Also, <em>{{collectionName}}</em> is being used as the start view deleting it will reset the start view to the Home page.",
"Sorry, an error occurred saving the collection": "Sorry, an error occurred saving the collection",
"Add a description": "Add a description",
@@ -106,6 +106,8 @@
"Expand": "Expand",
"Type a command or search": "Type a command or search",
"Open search from anywhere with the {{ shortcut }} shortcut": "Open search from anywhere with the {{ shortcut }} shortcut",
"Are you sure you want to permanently delete this entire comment thread?": "Are you sure you want to permanently delete this entire comment thread?",
"Are you sure you want to permanently delete this comment?": "Are you sure you want to permanently delete this comment?",
"Server connection lost": "Server connection lost",
"Edits you make will sync once youre online": "Edits you make will sync once youre online",
"Submenu": "Submenu",
@@ -138,10 +140,6 @@
"in": "in",
"nested document": "nested document",
"nested document_plural": "nested documents",
"Viewed by": "Viewed by",
"only you": "only you",
"person": "person",
"people": "people",
"{{ total }} task": "{{ total }} task",
"{{ total }} task_plural": "{{ total }} tasks",
"{{ completed }} task done": "{{ completed }} task done",
@@ -246,6 +244,7 @@
"Code block": "Code block",
"Copied to clipboard": "Copied to clipboard",
"Code": "Code",
"Comment": "Comment",
"Copy": "Copy",
"Create link": "Create link",
"Sorry, an error occurred creating the link": "Sorry, an error occurred creating the link",
@@ -320,10 +319,12 @@
"Group member options": "Group member options",
"Remove": "Remove",
"Export collection": "Export collection",
"Delete collection": "Are you sure you want to delete this collection?",
"Delete collection": "Delete collection",
"Sort in sidebar": "Sort in sidebar",
"Alphabetical sort": "Alphabetical sort",
"Manual sort": "Manual sort",
"Delete comment": "Delete comment",
"Comment options": "Comment options",
"Document options": "Document options",
"Restore": "Restore",
"Choose a collection": "Choose a collection",
@@ -432,9 +433,24 @@
"Add people to {{ collectionName }}": "Add people to {{ collectionName }}",
"Signing in": "Signing in",
"You can safely close this window once the Outline desktop app has opened": "You can safely close this window once the Outline desktop app has opened",
"Error creating comment": "Error creating comment",
"Add a comment": "Add a comment",
"Add a reply": "Add a reply",
"Post": "Post",
"Reply": "Reply",
"Cancel": "Cancel",
"Comments": "Comments",
"No comments yet": "No comments yet",
"Error updating comment": "Error updating comment",
"Document updated by {{userName}}": "Document updated by {{userName}}",
"You have unsaved changes.\nAre you sure you want to discard them?": "You have unsaved changes.\nAre you sure you want to discard them?",
"Images are still uploading.\nAre you sure you want to discard them?": "Images are still uploading.\nAre you sure you want to discard them?",
"Viewed by": "Viewed by",
"only you": "only you",
"person": "person",
"people": "people",
"{{ count }} comment": "{{ count }} comment",
"{{ count }} comment_plural": "{{ count }} comments",
"Type '/' to insert, or start writing…": "Type '/' to insert, or start writing…",
"Hide contents": "Hide contents",
"Show contents": "Show contents",
@@ -503,7 +519,7 @@
"{{ teamName }} is using {{ appName }} to share documents, please login to continue.": "{{ teamName }} is using {{ appName }} to share documents, please login to continue.",
"Are you sure you want to delete the <em>{{ documentTitle }}</em> template?": "Are you sure you want to delete the <em>{{ documentTitle }}</em> template?",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>one nested document</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>.",
"Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested document</em>._plural": "Are you sure about that? Deleting the <em>{{ documentTitle }}</em> document will delete all of its history and <em>{{ any }} nested documents</em>.",
"If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If youd like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.",
"Archiving": "Archiving",
@@ -522,7 +538,6 @@
"no access": "no access",
"Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}.": "Heads up moving the document <em>{{ title }}</em> to the <em>{{ newCollectionName }}</em> collection will grant all members of the workspace <em>{{ newPermission }}</em>, they currently have {{ prevPermission }}.",
"Moving": "Moving",
"Cancel": "Cancel",
"Search documents": "Search documents",
"No documents found for your filters.": "No documents found for your filters.",
"Youve not got any drafts at the moment.": "Youve not got any drafts at the moment.",

View File

@@ -124,6 +124,9 @@ export const buildLightTheme = (input: Partial<Colors>): DefaultTheme => {
backdrop: "rgba(0, 0, 0, 0.2)",
shadow: "rgba(0, 0, 0, 0.2)",
commentBackground: colors.warmGrey,
commentActiveBackground: "#d7e0ea",
modalBackdrop: colors.black10,
modalBackground: colors.white,
modalShadow:
@@ -189,6 +192,9 @@ export const buildDarkTheme = (input: Partial<Colors>): DefaultTheme => {
backdrop: "rgba(0, 0, 0, 0.5)",
shadow: "rgba(0, 0, 0, 0.6)",
commentBackground: colors.veryDarkBlue,
commentActiveBackground: colors.black,
modalBackdrop: colors.black50,
modalBackground: "#1f2128",
modalShadow:

View File

@@ -120,6 +120,8 @@ export enum TeamPreference {
PublicBranding = "publicBranding",
/** Whether viewers should see download options. */
ViewersCanExport = "viewersCanExport",
/** Whether users can comment on documents. */
Commenting = "commenting",
/** The custom theme for the team. */
CustomTheme = "customTheme",
}
@@ -128,6 +130,7 @@ export type TeamPreferences = {
[TeamPreference.SeamlessEdit]?: boolean;
[TeamPreference.PublicBranding]?: boolean;
[TeamPreference.ViewersCanExport]?: boolean;
[TeamPreference.Commenting]?: boolean;
[TeamPreference.CustomTheme]?: Partial<CustomTheme>;
};

View File

@@ -0,0 +1,155 @@
import { Node } from "prosemirror-model";
import headingToSlug from "../editor/lib/headingToSlug";
export type Heading = {
/* The heading in plain text */
title: string;
/* The level of the heading */
level: number;
/* The unique id of the heading */
id: string;
};
export type CommentMark = {
/* The unique id of the comment */
id: string;
/* The id of the user who created the comment */
userId: string;
};
export type Task = {
/* The text of the task */
text: string;
/* Whether the task is completed or not */
completed: boolean;
};
export default class ProsemirrorHelper {
/**
* Removes any empty paragraphs from the beginning and end of the document.
*
* @returns True if the editor is empty
*/
static trim(doc: Node) {
const first = doc.firstChild;
const last = doc.lastChild;
const firstIsEmpty =
first?.type.name === "paragraph" && !first.textContent.trim();
const lastIsEmpty =
last?.type.name === "paragraph" && !last.textContent.trim();
const firstIsLast = first === last;
return doc.cut(
firstIsEmpty ? first.nodeSize : 0,
lastIsEmpty && !firstIsLast ? doc.nodeSize - last.nodeSize : undefined
);
}
/**
* Returns true if the trimmed content of the passed document is an empty
* string.
*
* @returns True if the editor is empty
*/
static isEmpty(doc: Node) {
return !doc || doc.textContent.trim() === "";
}
/**
* Iterates through the document to find all of the comments that exist as
* marks.
*
* @param doc Prosemirror document node
* @returns Array<CommentMark>
*/
static getComments(doc: Node): CommentMark[] {
const comments: CommentMark[] = [];
doc.descendants((node) => {
node.marks.forEach((mark) => {
if (mark.type.name === "comment") {
comments.push(mark.attrs as CommentMark);
}
});
return true;
});
return comments;
}
/**
* Iterates through the document to find all of the tasks and their completion
* state.
*
* @param doc Prosemirror document node
* @returns Array<Task>
*/
static 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;
}
/**
* Iterates through the document to find all of the headings and their level.
*
* @param doc Prosemirror document node
* @returns Array<Heading>
*/
static 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;
}
}

11
shared/utils/time.ts Normal file
View File

@@ -0,0 +1,11 @@
/** A second in ms */
export const Second = 1000;
/** A minute in ms */
export const Minute = 60 * Second;
/** An hour in ms */
export const Hour = 60 * Minute;
/** A day in ms */
export const Day = 24 * Hour;

View File

@@ -27,6 +27,11 @@ export const CollectionValidation = {
maxNameLength: 100,
};
export const CommentValidation = {
/** The maximum length of a comment */
maxLength: 1000,
};
export const DocumentValidation = {
/** The maximum length of the document title */
maxTitleLength: 100,