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:
13
shared/editor/commands/collapseSelection.ts
Normal file
13
shared/editor/commands/collapseSelection.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ export default class ExtensionManager {
|
||||
Object.entries(value).forEach(([commandName, commandValue]) => {
|
||||
handle(commandName, commandValue);
|
||||
});
|
||||
} else {
|
||||
} else if (value) {
|
||||
handle(name, value);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
107
shared/editor/marks/Comment.ts
Normal file
107
shared/editor/marks/Comment.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
13
shared/editor/packages/fullWithComments.ts
Normal file
13
shared/editor/packages/fullWithComments.ts
Normal 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;
|
||||
Reference in New Issue
Block a user