From 0bd2c7b1229fadc81649be57678f860887b61ca2 Mon Sep 17 00:00:00 2001 From: Arek Nawo Date: Tue, 11 Jun 2024 00:15:43 +0200 Subject: [PATCH] feat: Comments panel --- .../fragments/collapsible-section.tsx | 4 +- .../src/components/fragments/mini-editor.tsx | 8 +- apps/web/src/context/comments.tsx | 206 ++++++++++- apps/web/src/layout/secured-layout.tsx | 121 +++--- apps/web/src/layout/side-panel-right.tsx | 69 ++-- apps/web/src/lib/editor/editing.ts | 4 +- .../extensions/comment-menu/comment-card.tsx | 5 +- .../extensions/comment-menu/comment-data.tsx | 124 ------- .../extensions/comment-menu/comment-input.tsx | 7 +- .../comment-menu/comment-thread.tsx | 58 +-- .../extensions/comment-menu/component.tsx | 61 ++-- .../editor/extensions/comment-menu/index.ts | 1 + .../editor/extensions/comment-menu/plugin.tsx | 50 ++- .../lib/editor/extensions/element/node.tsx | 23 +- .../src/lib/editor/extensions/image/view.tsx | 4 +- .../src/lib/editor/extensions/placeholder.ts | 12 +- apps/web/src/lib/utils/general.ts | 27 +- apps/web/src/views/comments/index.tsx | 345 +++++++++++++++--- apps/web/src/views/editor/editor.tsx | 7 +- .../src/views/editor/menus/bubble/format.tsx | 9 +- .../src/views/explorer/content-piece-row.tsx | 9 +- apps/web/src/views/explorer/tree-level.tsx | 30 +- .../src/collections/comment-threads.ts | 1 + packages/backend/src/events/comment.ts | 4 +- .../routes/comments/handlers/create-thread.ts | 7 +- .../comments/handlers/delete-comment.ts | 3 +- .../routes/comments/handlers/list-threads.ts | 37 +- .../comments/handlers/update-comment.ts | 3 +- .../routes/content-pieces/handlers/delete.ts | 16 + 29 files changed, 789 insertions(+), 466 deletions(-) delete mode 100644 apps/web/src/lib/editor/extensions/comment-menu/comment-data.tsx diff --git a/apps/web/src/components/fragments/collapsible-section.tsx b/apps/web/src/components/fragments/collapsible-section.tsx index 42c71316..bb27533e 100644 --- a/apps/web/src/components/fragments/collapsible-section.tsx +++ b/apps/web/src/components/fragments/collapsible-section.tsx @@ -9,8 +9,8 @@ interface CollapsibleSectionProps { children?: JSX.Element; action?: JSX.Element; gradient?: boolean; - collapsible?: boolean; defaultOpened?: boolean; + mode?: "hide" | "remove"; } const CollapsibleSection: Component = (props) => { @@ -68,7 +68,7 @@ const CollapsibleSection: Component = (props) => { opened() ? "py-1 my-1" : "max-h-0 overflow-hidden" )} > - {props.children} + {props.children} ); diff --git a/apps/web/src/components/fragments/mini-editor.tsx b/apps/web/src/components/fragments/mini-editor.tsx index 7ebe8624..493aeb0e 100644 --- a/apps/web/src/components/fragments/mini-editor.tsx +++ b/apps/web/src/components/fragments/mini-editor.tsx @@ -51,13 +51,7 @@ const getExtensions = (options: ExtensionOptions): Extensions => { const extensions = [ options.content ? Document.extend({ content: options.content }) : Document, Placeholder.configure({ - placeholder: ({ node, editor }) => { - if (node.type.name === "paragraph" && editor.state.doc.firstChild === node) { - return options.placeholder || ""; - } - - return ""; - } + placeholder: options.placeholder }), Paragraph, Text, diff --git a/apps/web/src/context/comments.tsx b/apps/web/src/context/comments.tsx index dbf0ff50..ca721b30 100644 --- a/apps/web/src/context/comments.tsx +++ b/apps/web/src/context/comments.tsx @@ -1,13 +1,211 @@ -import { createContext, ParentComponent, useContext } from "solid-js"; +import { + Accessor, + InitializedResource, + ParentComponent, + Setter, + createContext, + createEffect, + createMemo, + createResource, + createSignal, + on, + onCleanup, + useContext +} from "solid-js"; +import { SetStoreFunction, createStore, reconcile } from "solid-js/store"; +import { App, useClient, useContentData } from "#context"; -interface CommentDataContextData {} +type ThreadWithFirstComment = Omit & { + firstComment: (Omit & { member: App.CommentMember | null }) | null; +}; +type UseCommentsOutput = { + comments: Accessor & { member: App.CommentMember | null }>>; + loading: Accessor; +}; +interface CommentFragmentData { + id: string; + top: Accessor; + computedTop: Accessor; + overlap: number; + pos: number; + size: number; +} +interface CommentDataProviderProps {} +interface CommentDataContextData { + fragments: Record; + orderedFragmentIds: Accessor; + threads: InitializedResource; + setOrderedFragmentIds: Setter; + setFragments: SetStoreFunction>; + activeFragmentId: Accessor; + setActiveFragmentId: Setter; + getThreadByFragment(fragmentId: string): ThreadWithFirstComment | null; + getCommentNumbers(fragmentId: string): number; + useCommentsInFragment(fragmentId: Accessor): UseCommentsOutput; + useCommentsInThread(threadId: Accessor): UseCommentsOutput; +} const CommentDataContext = createContext(); -const CommentDataProvider: ParentComponent = (props) => { - return {props.children}; +const CommentDataProvider: ParentComponent = (props) => { + const { activeContentPieceId } = useContentData(); + const client = useClient(); + const [orderedFragmentIds, setOrderedFragmentIds] = createSignal([]); + const [activeFragmentId, setActiveFragmentId] = createSignal(null); + const [fragments, setFragments] = createStore>( + {} + ); + const [loadingComments, setLoadingComments] = createStore>( + {} + ); + const [threads, { mutate: setThreads }] = createResource( + () => { + if (!activeContentPieceId()) return []; + + return client.comments.listThreads.query({ + contentPieceId: activeContentPieceId()! + }); + }, + { initialValue: [] } + ); + const [commentsByFragmentId, setCommentsByFragmentId] = createStore< + Record< + string, + Array & { member: App.CommentMember | null }> | undefined + > + >({}); + const useCommentsInFragment = (fragmentId: Accessor): UseCommentsOutput => { + createEffect( + on(fragmentId, (fragmentId, previousFragmentId) => { + if (fragmentId && fragmentId !== previousFragmentId && !commentsByFragmentId[fragmentId]) { + setLoadingComments(fragmentId, true); + client.comments.listComments + .query({ + fragment: fragmentId + }) + .then((comments) => { + setCommentsByFragmentId(fragmentId, comments); + setLoadingComments(fragmentId, undefined); + }) + .finally(() => { + setLoadingComments(fragmentId, undefined); + }); + } + }) + ); + + return { + comments: () => commentsByFragmentId[fragmentId()] || [], + loading: () => loadingComments[fragmentId()] || false + }; + }; + const useCommentsInThread = (threadId: Accessor): UseCommentsOutput => { + const fragmentId = createMemo(() => { + return threads().find((thread) => thread.id === threadId())?.fragment || ""; + }); + + return useCommentsInFragment(fragmentId); + }; + const getThreadByFragment = (fragmentId: string): ThreadWithFirstComment | null => { + return threads().find((thread) => thread.fragment === fragmentId) || null; + }; + const getCommentNumbers = (fragmentId: string): number => { + return commentsByFragmentId[fragmentId]?.length || 0; + }; + + createEffect( + on(activeContentPieceId, () => { + const commentChangesSubscription = client.comments.changes.subscribe( + { contentPieceId: activeContentPieceId()! }, + { + onData({ action, data }) { + if (action.includes("Comment")) { + const thread = threads().find((thread) => { + return "threadId" in data && thread.id === data.threadId; + }); + + if (action === "createComment" && thread && thread.fragment in commentsByFragmentId) { + return setCommentsByFragmentId(thread.fragment, (comments) => [ + ...(comments || []), + data + ]); + } + + if (action === "updateComment" && thread && thread.fragment in commentsByFragmentId) { + return setCommentsByFragmentId(thread.fragment, (comments) => { + return (comments || []).map((comment) => { + if (comment.id === data.id) { + return { ...comment, content: data.content }; + } + + return comment; + }); + }); + } + + if (action === "deleteComment" && thread && thread.fragment in commentsByFragmentId) { + return setCommentsByFragmentId(thread.fragment, (comments) => { + return (comments || []).filter((comment) => comment.id !== data.id); + }); + } + } + + if (action === "createThread") { + return setThreads((threads) => [...threads, { ...data, firstComment: null }]); + } + + if (action === "resolveThread") { + return setThreads((threads) => { + return threads.map((thread) => { + if (thread.id === data.id) { + return { ...thread, resolved: data.resolved }; + } + + return thread; + }); + }); + } + + if (action === "deleteThread") { + setCommentsByFragmentId(data.fragment, undefined); + + return setThreads((threads) => { + return threads.filter((thread) => thread.id !== data.id); + }); + } + } + } + ); + + onCleanup(() => { + commentChangesSubscription.unsubscribe(); + setCommentsByFragmentId(reconcile({})); + }); + }) + ); + + return ( + + {props.children} + + ); }; const useCommentData = (): CommentDataContextData => { return useContext(CommentDataContext)!; }; export { CommentDataProvider, useCommentData }; +export type { ThreadWithFirstComment, CommentFragmentData, CommentDataContextData }; diff --git a/apps/web/src/layout/secured-layout.tsx b/apps/web/src/layout/secured-layout.tsx index 3609ef65..60cacc5d 100644 --- a/apps/web/src/layout/secured-layout.tsx +++ b/apps/web/src/layout/secured-layout.tsx @@ -22,6 +22,7 @@ import { import { IconButton, Tooltip } from "#components/primitives"; import { SubscriptionBanner } from "#ee"; import { SnippetsDataProvider } from "#context/snippets"; +import { CommentDataProvider } from "#context/comments"; const Analytics: Component = () => { const { profile, workspace } = useAuthenticatedUserData(); @@ -60,75 +61,77 @@ const SecuredLayout: ParentComponent = (props) => { - - - -
- - - -
- - { - setStorage((storage) => ({ ...storage, zenMode: false })); - }} - /> - - } - > - + + + + +
+ +
-
- - - -
+ + { + setStorage((storage) => ({ ...storage, zenMode: false })); + }} + /> + + } + > + + +
+
- + -
- {props.children} +
+ + + +
+ {props.children} +
+ + +
- - -
+ + +
- - - -
- - - + + + + diff --git a/apps/web/src/layout/side-panel-right.tsx b/apps/web/src/layout/side-panel-right.tsx index 48095c76..b03307fb 100644 --- a/apps/web/src/layout/side-panel-right.tsx +++ b/apps/web/src/layout/side-panel-right.tsx @@ -8,29 +8,29 @@ import { createRef } from "#lib/utils"; import { ExplorerView } from "#views/explorer"; import { IconButton, Tooltip } from "#components/primitives"; import { SnippetsView } from "#views/snippets"; -import { CommentThreadsMenu } from "#views/comments"; +import { CommentsView } from "#views/comments"; const sidePanelRightViews: Record< string, - { view: Component>; icon: string; label: string; id: string } + { + view: Component>; + icon: string; + label: string; + id: string; + show?(): boolean; + } > = { explorer: { view: ExplorerView, icon: mdiFileMultipleOutline, label: "Explorer", id: "explorer" }, snippets: { view: SnippetsView, icon: mdiShapeOutline, label: "Snippets", id: "snippets" }, comments: { - view: () => { - const { activeContentPieceId, contentPieces } = useContentData(); + show: () => { + const { activeContentPieceId } = useContentData(); const { useSharedSignal } = useSharedState(); const [sharedEditor] = useSharedSignal("editor"); - return ( - - - - ); + return Boolean(activeContentPieceId()); }, + view: CommentsView, icon: mdiCommentMultipleOutline, label: "Comments", id: "comments" @@ -44,7 +44,14 @@ const SidePanelRight: Component = () => { const [minWidth] = createSignal(375); const [maxWidth] = createSignal(640); const [handleHover, setHandleHover] = createSignal(false); - const viewId = createMemo(() => storage().sidePanelRightView || "explorer"); + const viewId = createMemo(() => { + const viewId = storage().sidePanelRightView || "explorer"; + const { show } = sidePanelRightViews[viewId]; + + if (show && !show()) return "explorer"; + + return viewId; + }); const view = (): Component => sidePanelRightViews[viewId() || "explorer"].view; const collapsed = createMemo(() => { return (storage().rightPanelWidth || 0) < minWidth(); @@ -114,27 +121,29 @@ const SidePanelRight: Component = () => { {(view) => { return ( - - { - setStorage((storage) => ({ - ...storage, - sidePanelRightView: view.id - })); - }} - /> - + + + { + setStorage((storage) => ({ + ...storage, + sidePanelRightView: view.id + })); + }} + /> + + ); }}
-
+
diff --git a/apps/web/src/lib/editor/editing.ts b/apps/web/src/lib/editor/editing.ts index 3d37d2ea..13dfc17c 100644 --- a/apps/web/src/lib/editor/editing.ts +++ b/apps/web/src/lib/editor/editing.ts @@ -61,7 +61,9 @@ const createClipboardSerializer = ( paragraph: base.nodes.paragraph, text: base.nodes.text }; - const marks: Record DOMOutputSpec> = {}; + const marks: Record DOMOutputSpec> = { + comment: base.marks.comment + }; if (settings.embeds.length > 0) { nodes.embed = base.nodes.embed; diff --git a/apps/web/src/lib/editor/extensions/comment-menu/comment-card.tsx b/apps/web/src/lib/editor/extensions/comment-menu/comment-card.tsx index f4d5310a..2eb87c50 100644 --- a/apps/web/src/lib/editor/extensions/comment-menu/comment-card.tsx +++ b/apps/web/src/lib/editor/extensions/comment-menu/comment-card.tsx @@ -52,7 +52,10 @@ const CommentCard: Component = (props) => {
- + {dayjs(props.comment.date).fromNow()} } - | { action: "deleteThread"; data: Pick } - | { - action: "createComment"; - data: Omit & { member: App.CommentMember | null }; - } - | { - action: "updateComment"; - data: Pick; - } - | { - action: "deleteComment"; - data: Pick; - }; -type ThreadWithFirstComment = Omit & { - firstComment: (Omit & { member: App.CommentMember | null }) | null; -}; -type CommentUpdateHandler = (data: CommentUpdateData) => void; -interface CommentUpdatesProviderProps { - contentPieceId: string; -} -interface CommentUpdatesContextData { - getThreadByFragment(fragmentId: string): ThreadWithFirstComment | null; - subscribeToUpdates(handler: CommentUpdateHandler): () => void; -} - -const CommentUpdatesContext = createContext(); -const CommentDataProvider: ParentComponent = (props) => { - const client = useClient(); - const handlers: CommentUpdateHandler[] = []; - const [threads, { mutate: setThreads }] = createResource( - () => { - if (!props.contentPieceId) return []; - - return client.comments.listThreads.query({ - contentPieceId: props.contentPieceId - }); - }, - { initialValue: [] } - ); - const getThreadByFragment = (fragmentId: string): ThreadWithFirstComment | null => { - return threads().find((thread) => thread.fragment === fragmentId) || null; - }; - const subscribeToUpdates: CommentUpdatesContextData["subscribeToUpdates"] = (handler) => { - const unsubscribe = (): void => { - const index = handlers.indexOf(handler); - - if (index > -1) { - handlers.splice(index, 1); - } - }; - - handlers.push(handler); - onCleanup(() => { - unsubscribe(); - }); - - return unsubscribe; - }; - - subscribeToUpdates(({ action, data }) => { - if (action === "createThread") { - setThreads((threads) => [...threads, { ...data, firstComment: null }]); - } else if (action === "resolveThread") { - setThreads((threads) => { - return threads.map((thread) => { - if (thread.id === data.id) { - return { ...thread, resolved: data.resolved }; - } - - return thread; - }); - }); - } else if (action === "deleteThread") { - setThreads((threads) => { - return threads.filter((thread) => thread.id !== data.id); - }); - } - }); - createEffect( - on( - () => props.contentPieceId, - () => { - const commentChangesSubscription = client.comments.changes.subscribe( - { contentPieceId: props.contentPieceId }, - { - onData(data) { - handlers.forEach((handler) => handler(data)); - } - } - ); - - onCleanup(() => { - commentChangesSubscription.unsubscribe(); - }); - } - ) - ); - - return ( - - {props.children} - - ); -}; -const useCommentData = (): CommentUpdatesContextData => { - return useContext(CommentUpdatesContext)!; -}; - -export { CommentDataProvider, useCommentData }; -export type { ThreadWithFirstComment }; diff --git a/apps/web/src/lib/editor/extensions/comment-menu/comment-input.tsx b/apps/web/src/lib/editor/extensions/comment-menu/comment-input.tsx index cf7b920c..0c388b42 100644 --- a/apps/web/src/lib/editor/extensions/comment-menu/comment-input.tsx +++ b/apps/web/src/lib/editor/extensions/comment-menu/comment-input.tsx @@ -81,7 +81,12 @@ const CommentInput: Component<{ />
-
+
{isAppleDevice() ? : "Ctrl "}{" "} Enter diff --git a/apps/web/src/lib/editor/extensions/comment-menu/comment-thread.tsx b/apps/web/src/lib/editor/extensions/comment-menu/comment-thread.tsx index e23642ae..dfe56054 100644 --- a/apps/web/src/lib/editor/extensions/comment-menu/comment-thread.tsx +++ b/apps/web/src/lib/editor/extensions/comment-menu/comment-thread.tsx @@ -1,6 +1,5 @@ import { CommentCard, CommentWithMember } from "./comment-card"; import { CommentInput } from "./comment-input"; -import { ThreadWithFirstComment, useCommentData } from "./comment-data"; import { SolidEditor } from "@vrite/tiptap-solid"; import clsx from "clsx"; import { Component, createSignal, createEffect, on, Show, For, createMemo } from "solid-js"; @@ -9,14 +8,7 @@ import { createRef } from "@vrite/components/src/ref"; import { Button, Card, Heading, IconButton, Loader } from "#components/primitives"; import { App, useClient } from "#context"; import { ScrollShadow } from "#components/fragments"; - -interface CommentFragmentData { - id: string; - overlap: number; - pos: number; - top: number; - computedTop: number; -} +import { CommentFragmentData, ThreadWithFirstComment, useCommentData } from "#context/comments"; const CommentThread: Component<{ fragment: CommentFragmentData; @@ -27,63 +19,29 @@ const CommentThread: Component<{ setFragment(fragment: string): void; }> = (props) => { const client = useClient(); - const { subscribeToUpdates, getThreadByFragment } = useCommentData(); + const { getThreadByFragment, useCommentsInThread } = useCommentData(); const [resolving, setResolving] = createSignal(false); const [scrollableContainerRef, setScrollableContainerRef] = createRef( null ); - const [loading, setLoading] = createSignal(true); - const [comments, setComments] = createSignal< - Array & { member: App.CommentMember | null }> - >([]); const selected = (): boolean => props.selectedFragmentId === props.fragment.id; const top = createMemo(() => { - return props.contentOverlap || selected() ? props.fragment.top : props.fragment.computedTop; + return props.contentOverlap || selected() ? props.fragment.top() : props.fragment.computedTop(); }); - const loadComments = async (): Promise => { - setLoading(true); - - try { - const comments = await client.comments.listComments.query({ - fragment: props.fragment.id - }); - - setComments(comments); - setLoading(false); - } catch (error) { - setLoading(false); - } - }; const thread = (): ThreadWithFirstComment | null => getThreadByFragment(props.fragment.id); + const { comments, loading } = useCommentsInThread(() => thread()?.id || ""); const firstComment = (): CommentWithMember => comments()[0]; const latterComments = (): CommentWithMember[] => comments().slice(1); - createEffect( - on(thread, (thread, previousThread) => { - if (thread?.id !== previousThread?.id) { - loadComments(); - } - }) - ); - subscribeToUpdates(({ action, data }) => { - if (action === "createComment" && data.threadId === thread()?.id) { - setComments((comments) => [...comments, data]); - } else if (action === "deleteComment") { - setComments((comments) => { - return comments.filter((comment) => comment.id !== data.id); - }); - } - }); - return (