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;
|
||||
@@ -98,7 +98,7 @@
|
||||
"Viewers": "Viewers",
|
||||
"I’m sure – Delete": "I’m 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 you’re online": "Edits you make will sync once you’re 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 you’d like the option of referencing or restoring the {{noun}} in the future, consider archiving it instead.": "If you’d 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.",
|
||||
"You’ve not got any drafts at the moment.": "You’ve not got any drafts at the moment.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
155
shared/utils/ProsemirrorHelper.ts
Normal file
155
shared/utils/ProsemirrorHelper.ts
Normal 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
11
shared/utils/time.ts
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user