diff --git a/apps/api/plane/ee/permissions/page.py b/apps/api/plane/ee/permissions/page.py index 023cb6a3a0..48fbc89ee8 100644 --- a/apps/api/plane/ee/permissions/page.py +++ b/apps/api/plane/ee/permissions/page.py @@ -44,7 +44,7 @@ def has_shared_page_access(request, slug, page_id, project_id=None): user_id=user_id, workspace__slug=slug, project_id=project_id, - access=EDIT, + access__in=[EDIT, COMMENT], ).exists() # View, comment, or edit access is allowed for safe methods and updates @@ -54,7 +54,7 @@ def has_shared_page_access(request, slug, page_id, project_id=None): user_id=user_id, workspace__slug=slug, project_id=project_id, - access=EDIT, + access__in=[EDIT, COMMENT], ).exists() # Deny for any other unsupported method @@ -103,7 +103,6 @@ class WorkspacePagePermission(BasePermission): return True - def _has_public_page_access(self, request, slug): """ Check if the user has permission to access a public page @@ -200,9 +199,7 @@ class ProjectPagePermission(BasePermission): slug=slug, user_id=user_id, ): - return has_shared_page_access( - request, slug, page.id, project_id - ) + return has_shared_page_access(request, slug, page.id, project_id) # If shared pages feature is not enabled, only the owner can access return False @@ -217,7 +214,6 @@ class ProjectPagePermission(BasePermission): else: return True - def _has_public_page_access(self, request, slug, project_id): """ Check if the user has permission to access a public page diff --git a/apps/api/plane/ee/urls/app/page.py b/apps/api/plane/ee/urls/app/page.py index 231f5480a2..bf07062599 100644 --- a/apps/api/plane/ee/urls/app/page.py +++ b/apps/api/plane/ee/urls/app/page.py @@ -19,6 +19,7 @@ from plane.ee.views import ( ProjectPageCommentReactionViewSet, ProjectPageUserViewSet, ProjectPageRestoreEndpoint, + WorkspacePageLiveServerEndpoint, ) @@ -165,6 +166,11 @@ urlpatterns = [ WorkspacePageCommentReactionViewSet.as_view({"delete": "destroy"}), name="workspace-page-comment-reactions", ), + path( + "workspaces//pages//page-comments/", + WorkspacePageLiveServerEndpoint.as_view({"get": "list"}), + name="workspace-page-live-server", + ), ## End Comment Reactions ## EE project level path( diff --git a/apps/api/plane/ee/views/__init__.py b/apps/api/plane/ee/views/__init__.py index 7b5c56ad7a..17c572394a 100644 --- a/apps/api/plane/ee/views/__init__.py +++ b/apps/api/plane/ee/views/__init__.py @@ -46,6 +46,7 @@ from plane.ee.views.app.page import ( ProjectPageCommentViewSet, ProjectPageCommentReactionViewSet, ProjectPageRestoreEndpoint, + WorkspacePageLiveServerEndpoint, ) from plane.ee.views.app.views import ( IssueViewEEViewSet, diff --git a/apps/api/plane/ee/views/app/__init__.py b/apps/api/plane/ee/views/app/__init__.py index 1b1a540819..28db67f377 100644 --- a/apps/api/plane/ee/views/app/__init__.py +++ b/apps/api/plane/ee/views/app/__init__.py @@ -61,4 +61,5 @@ from plane.ee.views.app.page import ( WorkspacePageCommentReactionViewSet, ProjectPageCommentViewSet, ProjectPageCommentReactionViewSet, + WorkspacePageLiveServerEndpoint, ) diff --git a/apps/api/plane/ee/views/app/page/__init__.py b/apps/api/plane/ee/views/app/page/__init__.py index 183dea8877..2f72fee8c7 100644 --- a/apps/api/plane/ee/views/app/page/__init__.py +++ b/apps/api/plane/ee/views/app/page/__init__.py @@ -14,6 +14,7 @@ from .workspace.share import WorkspacePageUserViewSet from .workspace.comment import ( WorkspacePageCommentViewSet, WorkspacePageCommentReactionViewSet, + WorkspacePageLiveServerEndpoint, ) # project level diff --git a/apps/api/plane/ee/views/app/page/workspace/comment.py b/apps/api/plane/ee/views/app/page/workspace/comment.py index c54a7a92cb..497e1a2b38 100644 --- a/apps/api/plane/ee/views/app/page/workspace/comment.py +++ b/apps/api/plane/ee/views/app/page/workspace/comment.py @@ -6,9 +6,10 @@ from django.db.models import Prefetch, OuterRef, Func, F, Q # Third Party imports from rest_framework.response import Response from rest_framework import status +from rest_framework.permissions import AllowAny # Module imports -from plane.db.models import Workspace +from plane.db.models import Workspace, Page from plane.ee.models import PageComment, PageCommentReaction from plane.ee.permissions.page import WorkspacePagePermission from plane.ee.serializers.app.page import ( @@ -19,6 +20,7 @@ from plane.ee.views.base import BaseViewSet from plane.payment.flags.flag import FeatureFlag from plane.payment.flags.flag_decorator import check_feature_flag from plane.ee.bgtasks.page_update import nested_page_update, PageAction +from plane.authentication.secret import SecretKeyAuthentication class WorkspacePageCommentViewSet(BaseViewSet): @@ -229,3 +231,20 @@ class WorkspacePageCommentReactionViewSet(BaseViewSet): ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) + + +class WorkspacePageLiveServerEndpoint(BaseViewSet): + authentication_classes = [SecretKeyAuthentication] + permission_classes = [AllowAny] + + def list(self, request, slug, page_id): + page = Page.objects.filter(pk=page_id).first() + + if page is None: + return Response({"error": "Page not found"}, status=404) + + page_comments = PageComment.objects.filter( + workspace__slug=slug, page_id=page_id + ).filter(parent__isnull=True).values_list("id", flat=True) + + return Response(page_comments, status=status.HTTP_200_OK) diff --git a/apps/dev-wiki/core/components/editor/lite-text/editor.tsx b/apps/dev-wiki/core/components/editor/lite-text/editor.tsx index 099e096501..6dd6663c2b 100644 --- a/apps/dev-wiki/core/components/editor/lite-text/editor.tsx +++ b/apps/dev-wiki/core/components/editor/lite-text/editor.tsx @@ -12,11 +12,12 @@ import { IssueCommentToolbar } from "@/components/editor/lite-text/toolbar"; // hooks import { useEditorConfig, useEditorMention } from "@/hooks/editor"; import { useMember } from "@/hooks/store/use-member"; -// import { useUserProfile } from "@/hooks/store/use-user-profile"; +import { useUserProfile } from "@/hooks/store/use-user-profile"; // plane web hooks import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; // plane web services import { WorkspaceService } from "@/plane-web/services"; +import { LiteToolbar } from "./lite-toolbar"; const workspaceService = new WorkspaceService(); type LiteTextEditorWrapperProps = MakeOptional< @@ -32,7 +33,7 @@ type LiteTextEditorWrapperProps = MakeOptional< showSubmitButton?: boolean; isSubmitting?: boolean; showToolbarInitially?: boolean; - showToolbar?: boolean; + variant?: "full" | "lite" | "none"; issue_id?: string; parentClassName?: string; editorClassName?: string; @@ -61,7 +62,7 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { @@ -101,46 +104,71 @@ export const LiteTextEditor = React.forwardRef !showToolbarInitially && setIsFocused(true)} - onBlur={() => !showToolbarInitially && setIsFocused(false)} + onFocus={() => isFullVariant && !showToolbarInitially && setIsFocused(true)} + onBlur={() => isFullVariant && !showToolbarInitially && setIsFocused(false)} > - "", - workspaceId, - workspaceSlug, - })} - mentionHandler={{ - searchCallback: async (query) => { - const res = await fetchMentions(query); - if (!res) throw new Error("Failed in fetching mentions"); - return res; - }, - renderComponent: EditorMentionsRoot, - getMentionedEntityDetails: (id) => ({ - display_name: getUserDetails(id)?.display_name ?? "", - }), - }} - placeholder={placeholder} - containerClassName={cn(containerClassName, "relative", { - "p-2": !editable, - })} - extendedEditorProps={{ - isSmoothCursorEnabled: false, - }} - editorClassName={editorClassName} - {...rest} - /> - {showToolbar && editable && ( + {/* Wrapper for lite toolbar layout */} +
+ {/* Main Editor - always rendered once */} +
+ "", + workspaceId, + workspaceSlug, + })} + mentionHandler={{ + searchCallback: async (query) => { + const res = await fetchMentions(query); + if (!res) throw new Error("Failed in fetching mentions"); + return res; + }, + renderComponent: EditorMentionsRoot, + getMentionedEntityDetails: (id) => ({ + display_name: getUserDetails(id)?.display_name ?? "", + }), + }} + placeholder={placeholder} + containerClassName={cn(containerClassName, "relative", { + "p-2": !editable, + })} + extendedEditorProps={{ + isSmoothCursorEnabled: is_smooth_cursor_enabled, + }} + editorClassName={editorClassName} + {...rest} + /> +
+ + {/* Lite Toolbar - conditionally rendered */} + {isLiteVariant && editable && ( + { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }} + onSubmit={(e) => rest.onEnterKeyPress?.(e)} + isSubmitting={isSubmitting} + isEmpty={isEmpty} + /> + )} +
+ + {/* Full Toolbar - conditionally rendered */} + {isFullVariant && editable && (
| React.MouseEvent) => void; + isSubmitting: boolean; + isEmpty: boolean; + executeCommand: (item: ToolbarMenuItem) => void; +}; + +export const LiteToolbar = ({ onSubmit, isSubmitting, isEmpty, executeCommand }: LiteToolbarProps) => ( +
+ + +
+); + +export type { LiteToolbarProps }; diff --git a/apps/dev-wiki/core/components/pages/editor/page-root.tsx b/apps/dev-wiki/core/components/pages/editor/page-root.tsx index 5a52cc326d..012dd3fd4f 100644 --- a/apps/dev-wiki/core/components/pages/editor/page-root.tsx +++ b/apps/dev-wiki/core/components/pages/editor/page-root.tsx @@ -17,11 +17,7 @@ import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; // store import type { TPageInstance } from "@/store/pages/base-page"; // local imports -import { - PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, - PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, - PageNavigationPaneRoot, -} from "../navigation-pane"; +import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PageNavigationPaneRoot } from "../navigation-pane"; import { PageVersionsOverlay } from "../version"; import { PagesVersionEditor } from "../version/editor"; import { PageEditorBody, type TEditorBodyConfig, type TEditorBodyHandlers } from "./editor-body"; @@ -99,11 +95,16 @@ export const PageRoot = observer((props: TPageRootProps) => { }, [isContentEditable, setEditorRef]); // Get extensions and navigation logic from hook - const { editorExtensionHandlers, navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen } = - usePagesPaneExtensions({ - page, - editorRef, - }); + const { + editorExtensionHandlers, + navigationPaneExtensions, + handleOpenNavigationPane, + handleCloseNavigationPane, + isNavigationPaneOpen, + } = usePagesPaneExtensions({ + page, + editorRef, + }); // Get extended editor extensions configuration const extendedEditorProps = useExtendedEditorProps({ @@ -145,13 +146,6 @@ export const PageRoot = observer((props: TPageRootProps) => { [setEditorRef] ); - const handleCloseNavigationPane = useCallback(() => { - const updatedRoute = updateQueryParams({ - paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], - }); - router.push(updatedRoute); - }, [router, updateQueryParams]); - return (
diff --git a/apps/dev-wiki/core/constants/editor.ts b/apps/dev-wiki/core/constants/editor.ts index d617506643..c6bc2fef40 100644 --- a/apps/dev-wiki/core/constants/editor.ts +++ b/apps/dev-wiki/core/constants/editor.ts @@ -15,7 +15,6 @@ import { Image, Italic, List, - ListCollapse, ListOrdered, ListTodo, LucideIcon, @@ -24,12 +23,10 @@ import { TextQuote, Underline, } from "lucide-react"; -// editor -import { TCommandExtraProps, TEditorCommands, TEditorFontStyle } from "@plane/editor"; -// ui +// plane imports +import type { TCommandExtraProps, TEditorCommands, TEditorFontStyle } from "@plane/editor"; import { MonospaceIcon, SansSerifIcon, SerifIcon } from "@plane/propel/icons"; -// helpers -import { convertRemToPixel } from "@/helpers/common.helper"; +import { convertRemToPixel } from "@plane/utils"; type TEditorTypes = "lite" | "document" | "sticky"; @@ -161,9 +158,18 @@ const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [ { itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, ]; +export const IMAGE_ITEM = { + itemKey: "image", + renderKey: "image", + name: "Image", + icon: Image, + editors: ["lite", "document"], + extraProps: {}, +} as ToolbarMenuItem<"image">; + const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [ { itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] }, - { itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"], extraProps: {} }, + IMAGE_ITEM, ]; export const TOOLBAR_ITEMS: { diff --git a/apps/dev-wiki/core/services/file.service.ts b/apps/dev-wiki/core/services/file.service.ts index 39f83e1704..5cb5a57e99 100644 --- a/apps/dev-wiki/core/services/file.service.ts +++ b/apps/dev-wiki/core/services/file.service.ts @@ -1,9 +1,8 @@ import { AxiosRequestConfig } from "axios"; // plane types -import { EFileAssetType, TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; -// helpers -import { API_BASE_URL } from "@/helpers/common.helper"; -import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@/helpers/file.helper"; +import { API_BASE_URL } from "@plane/constants"; +import { TFileEntityInfo, TFileSignedURLResponse } from "@plane/types"; +import { generateFileUploadPayload, getAssetIdFromUrl, getFileMetaDataForUpload } from "@plane/utils"; // services import { APIService } from "@/services/api.service"; import { FileUploadService } from "@/services/file-upload.service"; @@ -299,22 +298,6 @@ export class FileService extends APIService { }); } - async duplicateAssets( - workspaceSlug: string, - data: { - entity_id: string; - entity_type: EFileAssetType; - project_id?: string; - asset_ids: string[]; - } - ): Promise> { - return this.post(`/api/assets/v2/workspaces/${workspaceSlug}/duplicate-assets/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - cancelUpload() { this.cancelSource.cancel("Upload canceled"); } diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-avatar.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-avatar.tsx new file mode 100644 index 0000000000..fd779d2f56 --- /dev/null +++ b/apps/dev-wiki/ee/components/pages/comments/comment-avatar.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { Avatar } from "@plane/ui"; +import { getFileURL, cn } from "@plane/utils"; +import { useMember } from "@/hooks/store/use-member"; + +type PageCommentAvatarProps = { + userId: string; + size?: "sm" | "md"; + className?: string; +}; + +export const PageCommentAvatar = observer(({ userId, size = "sm", className = "" }: PageCommentAvatarProps) => { + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + const memberDetails = getWorkspaceMemberDetails(userId); + + const sizeClasses = { + sm: "size-6", + md: "size-8", + }; + + return ( + + ); +}); diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-creation-handler.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-creation-handler.tsx index e3efd0b3ba..881f85f3f3 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comment-creation-handler.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comment-creation-handler.tsx @@ -96,7 +96,7 @@ export const PageCommentCreationHandler = observer( handleNewCommentCancel({ pendingComment, onPendingCommentCancel }); }; - const handleSubmit = (data: { + const handleSubmit = async (data: { description: { description_html: string; description_json: JSONContent }; uploadedAssetIds: string[]; }) => { @@ -104,9 +104,15 @@ export const PageCommentCreationHandler = observer( // Update bulk asset status if (data.uploadedAssetIds.length > 0 && page.id) { - fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, page.id, { - asset_ids: data.uploadedAssetIds, - }); + if (page.project_ids?.length && page.project_ids?.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, page.project_ids[0], page.id, { + asset_ids: data.uploadedAssetIds, + }); + } else { + await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, page.id, { + asset_ids: data.uploadedAssetIds, + }); + } } }; @@ -115,7 +121,7 @@ export const PageCommentCreationHandler = observer( } return ( -
+
{/* Reference Text Quote with Overlay Cancel Button */} {referenceText && (
diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-display.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-display.tsx index e0fc76d457..7107688a67 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comment-display.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comment-display.tsx @@ -6,138 +6,149 @@ import type { JSONContent } from "@plane/types"; import { AlertModalCore, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast, Tooltip } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks -import { useEditorAsset } from "@/hooks/store/use-editor-asset"; import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser } from "@/hooks/store/user"; // store types import { type TCommentInstance } from "@/plane-web/store/pages/comments/comment-instance"; import { type TPageInstance } from "@/store/pages/base-page"; // local imports +import { PageCommentAvatar } from "./comment-avatar"; import { PageCommentForm } from "./comment-form"; -import { PageCommentUserInfo } from "./comment-user-info"; +import { PageCommentUserDetails } from "./comment-user-details"; type CommentItemProps = { comment: TCommentInstance; page: TPageInstance; - isSelected?: boolean; isParent: boolean; className?: string; }; -export const PageCommentDisplay = observer( - ({ comment, page, isSelected: _isSelected = false, isParent, className = "" }: CommentItemProps) => { - // Local state for UI controls (optimized to only essential states) - const [isEditing, setIsEditing] = useState(false); - const [deleteCommentModal, setDeleteCommentModal] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); +export const PageCommentDisplay = observer(({ comment, page, isParent, className = "" }: CommentItemProps) => { + // Local state for UI controls (optimized to only essential states) + const [isEditing, setIsEditing] = useState(false); + const [deleteCommentModal, setDeleteCommentModal] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); - // Get workspace details for editor - const { currentWorkspace } = useWorkspace(); - const { uploadEditorAsset } = useEditorAsset(); - const workspaceSlug = currentWorkspace?.slug || ""; - const workspaceId = currentWorkspace?.id || ""; + // Get workspace details for editor + const { data: currentUser } = useUser(); + const { currentWorkspace } = useWorkspace(); + const workspaceSlug = currentWorkspace?.slug || ""; + const workspaceId = currentWorkspace?.id || ""; - const showResolveButton = isParent; + const commentAuthorId = comment.created_by || comment.actor; + const pageOwnerId = page.owned_by; + const canEditComment = !!commentAuthorId && commentAuthorId === currentUser?.id; + const canDeleteComment = canEditComment || (!!pageOwnerId && pageOwnerId === currentUser?.id); + const showResolveButton = isParent && page.canCurrentUserCommentOnPage; - const handleEdit = useCallback( - async (data: { description: { description_html: string; description_json: JSONContent } }) => { - if (!comment.id) return; - - await page.comments.updateComment(comment.id, { - description: { - description_html: data.description.description_html, - description_json: data.description.description_json, - }, - }); - - setIsEditing(false); - }, - [comment.id, page.comments] - ); - - const handleClose = () => { - setIsDeleting(false); - setDeleteCommentModal(false); - }; - - const handleDeleteConfirm = useCallback(async () => { + const handleEdit = useCallback( + async (data: { description: { description_html: string; description_json: JSONContent } }) => { if (!comment.id) return; - setIsDeleting(true); + page.comments.updateComment(comment.id, { + description: { + description_html: data.description.description_html, + description_json: data.description.description_json, + }, + }); + + setIsEditing(false); + }, + [comment.id, page.comments] + ); + + const handleClose = () => { + setIsDeleting(false); + setDeleteCommentModal(false); + }; + + const handleDeleteConfirm = useCallback(async () => { + if (!comment.id) return; + + setIsDeleting(true); + try { + await page.comments.deleteComment(comment.id); + // Also remove the corresponding comment mark from the editor + page.editor.editorRef?.removeComment(comment.id); + handleClose(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Comment deleted successfully.", + }); + } catch (error) { + console.error("Failed to delete comment:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Comment could not be deleted. Please try again.", + }); + } finally { + setIsDeleting(false); + } + }, [comment.id, page.comments, page.editor.editorRef]); + + const handleResolve = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!comment.id) return; try { - await page.comments.deleteComment(comment.id); - // Also remove the corresponding comment mark from the editor - page.editor.editorRef?.removeComment(comment.id); - handleClose(); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Comment deleted successfully.", - }); - } catch (error) { - console.error("Failed to delete comment:", error); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Comment could not be deleted. Please try again.", - }); - } finally { - setIsDeleting(false); - } - }, [comment.id, page.comments, page.editor.editorRef]); + if (comment.is_resolved) { + await page.comments.unresolveComment(comment.id); - const handleResolve = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation(); - if (!comment.id) return; - try { - if (comment.is_resolved) { - await page.comments.unresolveComment(comment.id); - - if (page.editor.editorRef) { - page.editor.editorRef.unresolveCommentMark(comment.id); - } - } else { - await page.comments.resolveComment(comment.id); - if (page.editor.editorRef) { - page.editor.editorRef.resolveCommentMark(comment.id); - } + if (page.editor.editorRef) { + page.editor.editorRef.unresolveCommentMark(comment.id); + } + } else { + await page.comments.resolveComment(comment.id); + if (page.editor.editorRef) { + page.editor.editorRef.resolveCommentMark(comment.id); } - } catch (error) { - console.error("Failed to resolve/unresolve comment:", error); } + } catch (error) { + console.error("Failed to resolve/unresolve comment:", error); + } + }, + [comment.id, comment.is_resolved, page.comments, page.editor.editorRef] + ); + + // Define menu items following the actions.tsx pattern + const menuItems: (TContextMenuItem & { key: string })[] = useMemo( + () => [ + { + key: "edit", + action: () => setIsEditing(true), + title: "Edit", + icon: Pencil, + shouldRender: canEditComment && !isEditing, }, - [comment.id, comment.is_resolved, page.comments, page.editor.editorRef] - ); + { + key: "delete", + action: () => setDeleteCommentModal(true), + title: "Delete", + icon: Trash2, + shouldRender: canDeleteComment, + }, + ], + [canDeleteComment, canEditComment, isEditing] + ); - // Define menu items following the actions.tsx pattern - const menuItems: (TContextMenuItem & { key: string })[] = useMemo( - () => [ - { - key: "edit", - action: () => setIsEditing(true), - title: "Edit", - icon: Pencil, - shouldRender: !isEditing, - }, - { - key: "delete", - action: () => setDeleteCommentModal(true), - title: "Delete", - icon: Trash2, - shouldRender: true, - }, - ], - [isEditing] - ); + const hasMenuItems = useMemo(() => menuItems.some((item) => item.shouldRender !== false), [menuItems]); - return ( -
- {/* Comment Header */} -
- + return ( +
+ {/* Left Column - Avatar */} + - {/* Action Buttons - Always Visible */} -
+ {/* Right Column - Details + Content */} +
+ {/* Header Row - Name/Timestamp + Actions */} +
+ + + {/* Action Buttons */} +
{showResolveButton && ( )} -
- - {menuItems.map((item) => { - if (item.shouldRender === false) return null; - return ( - { - e.preventDefault(); - e.stopPropagation(); - item.action?.(); - }} - className={cn(`flex items-center gap-2`, item.className)} - > - {item.icon && } - {item.title} - - ); - })} - -
+ {hasMenuItems && ( +
+ + {menuItems.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + item.action?.(); + }} + className={cn(`flex items-center gap-2`, item.className)} + > + {item.icon && } + {item.title} + + ); + })} + +
+ )}
@@ -197,23 +208,22 @@ export const PageCommentDisplay = observer( workspaceId={workspaceId} comment={comment} editable={isEditing} - placeholder="Edit comment..." + placeholder="Edit comment" autoFocus={isEditing} onSubmit={handleEdit} onCancel={() => setIsEditing(false)} - uploadEditorAsset={uploadEditorAsset} - /> - - {/* Delete Comment Modal */} - Are you sure you want to delete this comment? This action cannot be undone.} />
- ); - } -); + + {/* Delete Comment Modal */} + Are you sure you want to delete this comment? This action cannot be undone.} + /> +
+ ); +}); diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-filter-controls.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-filter-controls.tsx index 81d8a28027..d6bc59a01d 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comment-filter-controls.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comment-filter-controls.tsx @@ -12,40 +12,47 @@ export type CommentFiltersProps = { onFilterChange: (filterKey: "showAll" | "showActive" | "showResolved") => void; }; -export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => ( - -
- - Filters +export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => { + const isFiltersApplied = filters.showActive || filters.showResolved; + + return ( + +
+ + Filters +
+ {isFiltersApplied && ( + + )}
-
- } - placement="bottom-end" - closeOnSelect={false} - > - onFilterChange("showAll")} className="flex items-center gap-2"> - - Show all - - onFilterChange("showActive")} className="flex items-center gap-2"> - - Show active - - onFilterChange("showResolved")} className="flex items-center gap-2"> - - Show resolved - - -)); + } + placement="bottom-end" + closeOnSelect={false} + > + onFilterChange("showActive")} className="flex items-center gap-2"> + + Show active + + onFilterChange("showResolved")} className="flex items-center gap-2"> + + Show resolved + + onFilterChange("showAll")} className="flex items-center gap-2"> + + Show all + + + ); +}); diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-form.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-form.tsx index 5ca534cdef..b128c8536b 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comment-form.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comment-form.tsx @@ -2,15 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; -import { Check, X } from "lucide-react"; // editor import type { EditorRefApi } from "@plane/editor"; // types import { EFileAssetType, type JSONContent, type TPageComment } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; -import { cn, isCommentEmpty } from "@plane/utils"; +import { cn, isCommentEmpty, trimEmptyParagraphsFromJson, trimEmptyParagraphsFromHTML } from "@plane/utils"; // editor import { LiteTextEditor } from "@/components/editor/lite-text"; +import { useEditorAsset } from "@/hooks/store/use-editor-asset"; // types import { type TPageInstance } from "@/store/pages/base-page"; @@ -44,13 +44,6 @@ type CommentBoxProps = { uploadedAssetIds: string[]; }) => void; onCancel?: () => void; - uploadEditorAsset?: (args: { - blockId: string; - data: { entity_identifier: string; entity_type: EFileAssetType }; - projectId?: string; - file: File; - workspaceSlug: string; - }) => Promise<{ asset_id: string }>; }; export const EMPTY_COMMENT_JSON: JSONContent = { @@ -69,13 +62,11 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { workspaceId, comment, editable = false, - placeholder = "Add a comment...", + placeholder = "Add a comment", isSubmitting = false, pageId, isReply = false, onSubmit, - onCancel, - uploadEditorAsset, } = props; const editorRef = useRef(null); @@ -112,18 +103,17 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { const watchedDescription = watch("description"); const isEmpty = isCommentEmpty(watchedDescription?.description_html); - const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard(); - const isSubmitButtonDisabled = isSubmittingState || !isEditorReadyToDiscard; - const isDisabled = isSubmittingState || isEmpty || isSubmitButtonDisabled; + + const { uploadEditorAsset } = useEditorAsset(); const uploadCommentAsset = useCallback( - async (blockId: string, file: File, entityIdentifier: string) => { + async (blockId: string, file: File) => { if (!workspaceSlug || !uploadEditorAsset) throw new Error("Missing upload configuration"); let uploadConfig: Parameters[0] = { blockId, data: { - entity_identifier: entityIdentifier, + entity_identifier: comment?.id ?? "", entity_type: EFileAssetType.COMMENT_DESCRIPTION, }, file, @@ -141,7 +131,7 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { setUploadedAssetIds((prev) => [...prev, res.asset_id]); return res; }, - [uploadEditorAsset, page.project_ids, workspaceSlug] + [uploadEditorAsset, page.project_ids, workspaceSlug, comment?.id] ); const onFormSubmit = async (formData: Partial) => { @@ -156,10 +146,19 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { try { setInternalIsSubmitting(true); + // Trim empty paragraphs from both JSON and HTML content + const trimmedJson = formData.description.description_json + ? trimEmptyParagraphsFromJson(formData.description.description_json) + : EMPTY_COMMENT_JSON; + + const trimmedHtml = formData.description.description_html + ? trimEmptyParagraphsFromHTML(formData.description.description_html) + : "

"; + onSubmit({ description: { - description_html: formData.description.description_html || "

", - description_json: formData.description.description_json || EMPTY_COMMENT_JSON, + description_html: trimmedHtml, + description_json: trimmedJson, }, uploadedAssetIds, }); @@ -191,123 +190,59 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { setInternalIsSubmitting(false); } }; - - const handleCancel = () => { - try { - // Reset form to original values - if (comment?.description) { - const resetContent = originalContent || { - description_html: comment.description.description_html, - description_json: comment.description.description_json, - }; - - // Reset editor content - editorRef.current?.setEditorValue(resetContent.description_html); - - // Reset form state - reset({ - description: resetContent, - }); - } - - // Clear uploaded assets - setUploadedAssetIds([]); - - // Call parent cancel handler - if (onCancel) { - onCancel(); - } - } catch (error) { - console.error("Failed to cancel comment editing:", error); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to cancel editing. Please refresh the page.", - }); - } - }; + // states + const [isFocused, setIsFocused] = useState(false); // For editable mode (both new comments and editing existing) return ( -
-
{ - if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onFormSubmit)(e); - }} - className={cn(isReply || !comment ? "border border-custom-border-200 rounded p-2" : "")} - > - ( - handleSubmit(onFormSubmit)(e)} - value={null} - uploadFile={ - uploadEditorAsset - ? async (blockId, file) => { - const { asset_id } = await uploadCommentAsset(blockId, file, comment?.id || pageId || "new"); - return asset_id; - } - : async () => "" - } - ref={editorRef} - initialValue={value?.description_json ?? EMPTY_COMMENT_JSON} - containerClassName="min-h-min !p-0" - onChange={(description_json, description_html) => { - onChange({ description_json, description_html }); - }} - isSubmitting={isSubmittingState} - showSubmitButton={!comment} - showToolbarInitially - placeholder={placeholder} - parentClassName="!border-none !p-0" - // editorClassName="!text-base" - displayConfig={{ fontSize: "small-font" }} - /> - )} - /> -
- - {/* Custom submit buttons - only show when editing existing comments */} - {comment && editable && ( -
- {!isEmpty && ( - - )} - -
+
{ + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onFormSubmit)(e); + }} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className={cn( + "relative w-full ", + comment && "px-2 -mx-2", + isReply || !comment ? "border border-custom-border-200 rounded p-2" : "", + isFocused && editable ? "border-2 border-custom-primary-100 rounded py-2" : "" )} + > + ( + handleSubmit(onFormSubmit)(e)} + value={null} + uploadFile={async (blockId, file) => { + const { asset_id } = await uploadCommentAsset(blockId, file); + setUploadedAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} + ref={editorRef} + initialValue={value?.description_json ?? EMPTY_COMMENT_JSON} + containerClassName="min-h-min !p-0" + onChange={(description_json, description_html) => { + onChange({ description_json, description_html }); + }} + isSubmitting={isSubmittingState} + showSubmitButton={!comment} + variant="lite" + placeholder={placeholder} + parentClassName="!border-none !p-0" + displayConfig={{ fontSize: "small-font" }} + /> + )} + />
); }); diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-reply-controller.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-reply-controller.tsx index 8af6141621..dab64554e6 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comment-reply-controller.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comment-reply-controller.tsx @@ -1,35 +1,37 @@ import { observer } from "mobx-react"; import { TCommentInstance } from "@/plane-web/store/pages/comments/comment-instance"; +import { TPageInstance } from "@/store/pages/base-page"; type TCommentReplyController = { comment: TCommentInstance; handleShowRepliesToggle: (e: React.MouseEvent) => void; showReplies: boolean; + page: TPageInstance; }; export const PageCommentReplyController = observer( - ({ comment, handleShowRepliesToggle, showReplies }: TCommentReplyController) => { - if (comment.total_replies == null) return null; - if (comment.total_replies <= 1) return null; - const replyCount = comment.total_replies - 1; + ({ comment, handleShowRepliesToggle, showReplies, page }: TCommentReplyController) => { + // Use centralized thread display state for consistency + const threadState = page.comments.getThreadDisplayState(comment.id, showReplies); + + if (!threadState || !threadState.shouldShowReplyController) return null; + return ( - <> - {comment.hasReplies && replyCount && ( -
-
-
-
- -
-
+
+
+
+
+
- )} - +
+
); } ); diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-user-details.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-user-details.tsx new file mode 100644 index 0000000000..db63995ab2 --- /dev/null +++ b/apps/dev-wiki/ee/components/pages/comments/comment-user-details.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { cn } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +// local components +import { PageCommentTimestampDisplay } from "./comment-timestamp-display"; + +type PageCommentUserDetailsProps = { + userId: string; + timestamp?: string; + className?: string; +}; + +export const PageCommentUserDetails = observer(({ userId, timestamp, className = "" }: PageCommentUserDetailsProps) => { + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + const memberDetails = getWorkspaceMemberDetails(userId); + + return ( +
+
{memberDetails?.member.display_name}
+ {timestamp && } +
+ ); +}); diff --git a/apps/dev-wiki/ee/components/pages/comments/comment-user-info.tsx b/apps/dev-wiki/ee/components/pages/comments/comment-user-info.tsx deleted file mode 100644 index 2d755e4ac3..0000000000 --- a/apps/dev-wiki/ee/components/pages/comments/comment-user-info.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react"; -// plane imports -import { Avatar } from "@plane/ui"; -import { getFileURL, cn } from "@plane/utils"; -// hooks -import { useMember } from "@/hooks/store/use-member"; -// local components -import { PageCommentTimestampDisplay } from "./comment-timestamp-display"; - -type UserAvatarProps = { - size?: "sm" | "md"; - className?: string; - userId: string; - timestamp?: string; -}; - -export const PageCommentUserInfo = observer(({ userId, size = "sm", className = "", timestamp }: UserAvatarProps) => { - const { - workspace: { getWorkspaceMemberDetails }, - } = useMember(); - - const memberDetails = getWorkspaceMemberDetails(userId); - - const sizeClasses = { - sm: "size-6", - md: "size-8", - }; - - return ( -
-
-
- -
-
-
-
{memberDetails?.member.display_name}
- {timestamp && } -
-
- ); -}); diff --git a/apps/dev-wiki/ee/components/pages/comments/comments-empty-placeholder.tsx b/apps/dev-wiki/ee/components/pages/comments/comments-empty-placeholder.tsx index baf5c0bf56..3924f68486 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comments-empty-placeholder.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comments-empty-placeholder.tsx @@ -1,15 +1,21 @@ import React from "react"; import { MessageCircle } from "lucide-react"; +import { type TCommentFilters } from "@/plane-web/store/pages/comments/comment.store"; export type CommentsEmptyStateProps = { hasComments: boolean; + commentFilter: TCommentFilters; }; -export function PageCommentsEmptyState({ hasComments }: CommentsEmptyStateProps) { - const title = hasComments ? "No comments match current filters" : "No comments yet"; - const message = hasComments - ? "Try adjusting your filters to see more comments." - : "Select text in the editor and add a comment to get started."; +export function PageCommentsEmptyState({ hasComments, commentFilter }: CommentsEmptyStateProps) { + const title = hasComments + ? commentFilter.showActive + ? "No active comments" + : commentFilter.showResolved + ? "No resolved comments match current filters" + : "No comments match current filters" + : "No comments yet"; + const message = "Select text in the editor and add a comment to get started."; return (
diff --git a/apps/dev-wiki/ee/components/pages/comments/comments-navigation-extension.tsx b/apps/dev-wiki/ee/components/pages/comments/comments-navigation-extension.tsx index 67e91f5d59..9541baae42 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comments-navigation-extension.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comments-navigation-extension.tsx @@ -13,8 +13,14 @@ export const PageCommentsNavigationExtension: INavigationPaneExtensionComponent const { workspaceSlug } = useParams(); // Extract comments-specific data from extensionData - const { selectedCommentId, pendingComment, onPendingCommentCancel, onStartNewComment, onCreateCommentMark } = - extensionData || {}; + const { + selectedCommentId, + pendingComment, + onPendingCommentCancel, + onStartNewComment, + onCreateCommentMark, + onSelectedThreadConsumed, + } = extensionData || {}; // Store the ThreadsSidebar's registered handler const [registeredHandler, setRegisteredHandler] = useState< @@ -48,6 +54,7 @@ export const PageCommentsNavigationExtension: INavigationPaneExtensionComponent onPendingCommentCancel: onPendingCommentCancel, onRegisterStartNewComment: handleRegisterStartNewComment, onCreateCommentMark: onCreateCommentMark, + onSelectedThreadConsumed, }} /> ); diff --git a/apps/dev-wiki/ee/components/pages/comments/comments-sidebar-panel.tsx b/apps/dev-wiki/ee/components/pages/comments/comments-sidebar-panel.tsx index a5a2ba7d24..13c882c633 100644 --- a/apps/dev-wiki/ee/components/pages/comments/comments-sidebar-panel.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/comments-sidebar-panel.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useMemo, useRef } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // plane imports // hooks -import { useRouterParams } from "@/hooks/store/use-router-params"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useScrollManager } from "@/plane-web/hooks/pages/use-scroll-manager"; import { TPageInstance } from "@/store/pages/base-page"; @@ -20,6 +20,7 @@ type CommentHandlers = { handler: (selection?: { from: number; to: number; referenceText?: string }) => void ) => void; onCreateCommentMark?: (selection: { from: number; to: number }, commentId: string) => void; + onSelectedThreadConsumed?: () => void; }; export type ThreadsSidebarProps = { @@ -38,7 +39,7 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ pendingComment, handlers = {}, }: ThreadsSidebarProps) { - const { workspaceSlug } = useRouterParams(); + const { workspaceSlug } = useParams(); // Refs const scrollContainerRef = useRef(null); @@ -74,20 +75,27 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ useEffect(() => { page.comments.onScrollToPendingComment = (commentId: string) => { scrollToItem(commentId, { highlight: true }); - }; - - return () => { page.comments.onScrollToPendingComment = null; }; }, [page.comments, scrollToItem]); + const { onPendingCommentCancel, onRegisterStartNewComment, onCreateCommentMark, onSelectedThreadConsumed } = handlers; + // Auto-scroll to selected thread - wait for data to load first useEffect(() => { if (selectedThreadId && !isLoading && !isEmpty) { // Data is loaded, scroll to the selected thread scrollToItem(selectedThreadId, { highlight: true }); + onSelectedThreadConsumed?.(); } - }, [selectedThreadId, scrollToItem, isLoading, isEmpty]); + }, [selectedThreadId, scrollToItem, isLoading, isEmpty, onSelectedThreadConsumed]); + + const commentCreationHandlers = { + onPendingCommentCancel, + onRegisterStartNewComment, + onCreateCommentMark, + onScrollToElement: scrollToElement, + }; if (isLoading && isEmpty && !page.comments.pendingScrollToComment) { return ; @@ -96,10 +104,10 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ return (
{/* Header */} -
+

Comments

@@ -116,15 +124,12 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ workspaceId, }} pendingComment={pendingComment} - handlers={{ - ...handlers, - onScrollToElement: scrollToElement, - }} + handlers={commentCreationHandlers} /> {/* Comments List or Empty State */} {filteredBaseComments.length === 0 ? ( - 0} /> + 0} commentFilter={commentsFilters} /> ) : ( ( - + {Array.from({ length: commentReplyCount }, (_, index) => ( -
- {index > 0 && ( -
-
-
- )} +
{/* User avatar and timestamp */}
- +
+ +
{/* Reply content */} - - - {index % 3 === 1 && } +
+ + + {index % 3 === 1 && } +
))} -
-
-
); diff --git a/apps/dev-wiki/ee/components/pages/comments/thread-comment-item.tsx b/apps/dev-wiki/ee/components/pages/comments/thread-comment-item.tsx index a3052fe454..53af9b0ad1 100644 --- a/apps/dev-wiki/ee/components/pages/comments/thread-comment-item.tsx +++ b/apps/dev-wiki/ee/components/pages/comments/thread-comment-item.tsx @@ -8,6 +8,7 @@ import { useWorkspace } from "@/hooks/store/use-workspace"; // store types import { useCommentMarkInteraction } from "@/plane-web/hooks/pages/use-comment-mark-interaction"; import { TCommentInstance } from "@/plane-web/store/pages/comments/comment-instance"; +import { FileService } from "@/services/file.service"; import { TPageInstance } from "@/store/pages/base-page"; // local components import { PageCommentDisplay } from "./comment-display"; @@ -22,11 +23,9 @@ export type ThreadItemProps = { referenceText?: string; }; +const fileService = new FileService(); export const PageThreadCommentItem = observer( - React.forwardRef(function ThreadItem( - { comment, page, isSelected, referenceText }, - ref - ) { + React.forwardRef(function ThreadItem({ comment, page, referenceText }, ref) { const { currentWorkspace } = useWorkspace(); const { workspaceSlug } = useParams(); const workspaceId = currentWorkspace?.id || ""; @@ -58,7 +57,10 @@ export const PageThreadCommentItem = observer( ); const handleReply = useCallback( - async (data: { description: { description_html: string; description_json: JSONContent } }) => { + async (data: { + description: { description_html: string; description_json: JSONContent }; + uploadedAssetIds: string[]; + }) => { if (!page.canCurrentUserCommentOnPage) { console.warn("User does not have permission to comment"); return; @@ -73,6 +75,24 @@ export const PageThreadCommentItem = observer( parent_id: comment.id, }); + // Update bulk asset status + if (data.uploadedAssetIds.length > 0 && page.id) { + if (page.project_ids?.length && page.project_ids?.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus( + workspaceSlug.toString(), + page.project_ids[0], + page.id, + { + asset_ids: data.uploadedAssetIds, + } + ); + } else { + await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug.toString(), page.id, { + asset_ids: data.uploadedAssetIds, + }); + } + } + // Close reply box and show replies setShowReplyBox(false); setShowReplies(true); @@ -82,11 +102,15 @@ export const PageThreadCommentItem = observer( setIsSubmittingReply(false); } }, - [comment.id, page.comments, page.canCurrentUserCommentOnPage] + [comment.id, page, workspaceSlug] ); + const threadState = page.comments.getThreadDisplayState(comment.id, showReplies); // Use custom hook for comment mark interactions - const { handleMouseEnter, handleMouseLeave, handleThreadClick } = useCommentMarkInteraction(comment.id); + const { handleMouseEnter, handleMouseLeave, handleThreadClick } = useCommentMarkInteraction({ + commentId: comment.id, + editorRef: page.editor.editorRef, + }); return (
)} - {/* Main Thread Comment */} -
- +
+ {/* We only show the connector if there are only 2 comments or if there's a single comment but replybox is open */} + {((!threadState?.shouldShowReplyController && comment.total_replies) || + (comment.total_replies === 0 && showReplyBox)) && ( +
+ )} + {/* Main Thread Comment */} +
@@ -121,14 +147,20 @@ export const PageThreadCommentItem = observer( comment={comment} handleShowRepliesToggle={handleShowRepliesToggle} showReplies={showReplies} + page={page} /> {/* Replies List */} - + {/* Action Bar */} {page.canCurrentUserCommentOnPage && !showReplyBox && ( -
+
); diff --git a/apps/dev-wiki/ee/components/pages/share/access-menu.tsx b/apps/dev-wiki/ee/components/pages/share/access-menu.tsx index 0ee6df8694..5ce9ad5536 100644 --- a/apps/dev-wiki/ee/components/pages/share/access-menu.tsx +++ b/apps/dev-wiki/ee/components/pages/share/access-menu.tsx @@ -1,11 +1,12 @@ "use client"; import { memo } from "react"; -import { ChevronDown, Trash2, Eye, Pencil, Check } from "lucide-react"; +import { ChevronDown, Trash2, Eye, Pencil, Check, MessageSquareText } from "lucide-react"; import { CustomMenu } from "@plane/ui"; const ACCESS_OPTIONS = [ { value: "0", label: "View", icon: Eye }, + { value: "1", label: "Comment", icon: MessageSquareText }, { value: "2", label: "Edit", icon: Pencil }, ]; diff --git a/apps/dev-wiki/ee/hooks/pages/use-comment-mark-interaction.ts b/apps/dev-wiki/ee/hooks/pages/use-comment-mark-interaction.ts index 96052421f2..448e702b4c 100644 --- a/apps/dev-wiki/ee/hooks/pages/use-comment-mark-interaction.ts +++ b/apps/dev-wiki/ee/hooks/pages/use-comment-mark-interaction.ts @@ -1,4 +1,5 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import type { EditorRefApi } from "@plane/editor"; type CommentMarkInteractionHook = { handleMouseEnter: () => void; @@ -6,26 +7,35 @@ type CommentMarkInteractionHook = { handleThreadClick: (e: React.MouseEvent) => void; }; -export function useCommentMarkInteraction(commentId: string): CommentMarkInteractionHook { - const getCommentMark = useCallback(() => document.querySelector(`[data-comment-id="${commentId}"]`), [commentId]); +type UseCommentMarkInteractionParams = { + commentId: string; + editorRef?: EditorRefApi | null; +}; + +export function useCommentMarkInteraction({ + commentId, + editorRef, +}: UseCommentMarkInteractionParams): CommentMarkInteractionHook { + const deselectTimeoutRef = useRef(null); + + const clearHover = useCallback(() => { + editorRef?.hoverCommentMarks([]); + }, [editorRef]); + + const clearSelection = useCallback(() => { + editorRef?.selectCommentMark(null); + }, [editorRef]); const handleMouseEnter = useCallback(() => { - const commentMark = getCommentMark(); - if (commentMark) { - commentMark.classList.add("bg-[#FFBF66]/40", "transition-all", "duration-200"); - } - }, [getCommentMark]); + editorRef?.hoverCommentMarks([commentId]); + }, [editorRef, commentId]); const handleMouseLeave = useCallback(() => { - const commentMark = getCommentMark(); - if (commentMark) { - commentMark.classList.remove("bg-[#FFBF66]/40", "transition-all", "duration-200"); - } - }, [getCommentMark]); + clearHover(); + }, [clearHover]); const handleThreadClick = useCallback( (e: React.MouseEvent) => { - // Don't trigger selection if clicking on interactive elements const target = e.target as HTMLElement; if ( target.tagName === "BUTTON" || @@ -38,26 +48,29 @@ export function useCommentMarkInteraction(commentId: string): CommentMarkInterac return; } - const commentMark = getCommentMark(); - if (commentMark) { - // Add temporary highlight effect - commentMark.classList.add("scale-[1.02]", "transition-all", "duration-300"); + editorRef?.selectCommentMark(commentId); + editorRef?.scrollToCommentMark(commentId); - // Scroll the comment mark into view in the editor - commentMark.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - - // Remove highlight effect after animation - setTimeout(() => { - commentMark.classList.remove("shadow-lg", "scale-[1.02]", "transition-all", "duration-300"); - }, 2000); + if (deselectTimeoutRef.current) { + window.clearTimeout(deselectTimeoutRef.current); } + + deselectTimeoutRef.current = window.setTimeout(() => { + editorRef?.selectCommentMark(null); + deselectTimeoutRef.current = null; + }, 2000); }, - [getCommentMark] + [editorRef, commentId] ); + useEffect(() => () => { + if (deselectTimeoutRef.current) { + window.clearTimeout(deselectTimeoutRef.current); + } + clearHover(); + clearSelection(); + }, [clearHover, clearSelection]); + return { handleMouseEnter, handleMouseLeave, diff --git a/apps/dev-wiki/ee/hooks/pages/use-pages-pane-extensions.ts b/apps/dev-wiki/ee/hooks/pages/use-pages-pane-extensions.ts index 79d43ee4ec..1735255b71 100644 --- a/apps/dev-wiki/ee/hooks/pages/use-pages-pane-extensions.ts +++ b/apps/dev-wiki/ee/hooks/pages/use-pages-pane-extensions.ts @@ -1,9 +1,10 @@ import { useCallback, useMemo, useState, type RefObject } from "react"; import { useSearchParams } from "next/navigation"; -import type { EditorRefApi } from "@plane/editor"; +import type { EditorRefApi, TCommentClickPayload } from "@plane/editor"; import { PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_TAB_KEYS, + PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, } from "@/components/pages/navigation-pane"; import { useAppRouter } from "@/hooks/use-app-router"; import { useQueryParams } from "@/hooks/use-query-params"; @@ -40,8 +41,8 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { // Comment-specific callbacks - all contained within hook const onCommentClick = useCallback( - (commentId: string) => { - setSelectedCommentId(commentId); + (payload: TCommentClickPayload, _referenceTextParagraph?: string) => { + setSelectedCommentId(payload.primaryCommentId); const updatedRoute = updateQueryParams({ paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "comments" }, @@ -55,6 +56,10 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { setPendingComment(undefined); }, []); + const onSelectedThreadConsumed = useCallback(() => { + setSelectedCommentId(undefined); + }, []); + const onCreateCommentMark = useCallback( (selection: { from: number; to: number }, commentId: string) => { if (editorRef.current) { @@ -101,6 +106,15 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { router.push(updatedRoute); }, [router, updateQueryParams]); + const handleCloseNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + router.push(updatedRoute); + setSelectedCommentId(undefined); + setPendingComment(undefined); + }, [router, updateQueryParams]); + // Editor extension handlers map - directly consumable by PageEditorBody const editorExtensionHandlers: Map = useMemo(() => { const map: Map = new Map(); @@ -138,6 +152,7 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { selectedCommentId, pendingComment, onPendingCommentCancel, + onSelectedThreadConsumed, onClick: onCommentClick, onDelete: page.comments.deleteComment, onRestore: page.comments.restoreComment, @@ -156,5 +171,6 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen, + handleCloseNavigationPane, }; }; diff --git a/apps/dev-wiki/ee/store/pages/comments/comment-instance.ts b/apps/dev-wiki/ee/store/pages/comments/comment-instance.ts index 1399f6e15f..32de0459c3 100644 --- a/apps/dev-wiki/ee/store/pages/comments/comment-instance.ts +++ b/apps/dev-wiki/ee/store/pages/comments/comment-instance.ts @@ -14,6 +14,7 @@ export type TCommentInstance = TPageComment & { // computed properties isRootComment: boolean; hasReplies: boolean; + asJSON: TPageComment; childComments: TCommentInstance[]; threadComments: TCommentInstance[]; // all comments in this thread (parent + children) @@ -118,6 +119,7 @@ export class CommentInstance implements TCommentInstance { reference_stripped: observable.ref, // computed + asJSON: computed, isRootComment: computed, hasReplies: computed, childComments: computed, @@ -128,6 +130,37 @@ export class CommentInstance implements TCommentInstance { }); } + get asJSON() { + return { + id: this.id, + workspace: this.workspace, + workspace_detail: this.workspace_detail, + page: this.page, + project: this.project, + actor: this.actor, + actor_detail: this.actor_detail, + comment_stripped: this.comment_stripped, + description: this.description, + created_at: this.created_at, + updated_at: this.updated_at, + created_by: this.created_by, + updated_by: this.updated_by, + parent: this.parent, + parent_id: this.parent_id, + page_comment_reactions: this.page_comment_reactions, + is_resolved: this.is_resolved, + resolved_at: this.resolved_at, + resolved_by: this.resolved_by, + node_id: this.node_id, + external_id: this.external_id, + external_source: this.external_source, + replies: this.replies, + reactions: this.reactions, + total_replies: this.total_replies, + reference_stripped: this.reference_stripped, + }; + } + // Computed properties get isRootComment(): boolean { return this.parent_id === null; diff --git a/apps/dev-wiki/ee/store/pages/comments/comment.store.ts b/apps/dev-wiki/ee/store/pages/comments/comment.store.ts index 524e3762cc..34bbb2cba3 100644 --- a/apps/dev-wiki/ee/store/pages/comments/comment.store.ts +++ b/apps/dev-wiki/ee/store/pages/comments/comment.store.ts @@ -31,6 +31,16 @@ export interface ICommentStore { getCommentById: (commentId: string) => TCommentInstance | undefined; getCommentsByParentId: (parentId: string) => TCommentInstance[]; getLatestReplyByParentId: (parentId: string) => TCommentInstance | undefined; + getThreadDisplayState: ( + threadId: string, + showReplies: boolean + ) => { + shouldShowReplyController: boolean; + hiddenRepliesCount: number; + displayItems: Array<{ comment: TCommentInstance }>; + totalReplies: number; + loadedRepliesCount: number; + } | null; // computed properties baseComments: TCommentInstance[]; filteredBaseComments: TCommentInstance[]; @@ -44,7 +54,11 @@ export interface ICommentStore { // API actions - now context-aware (no need to pass pageId/config) fetchPageComments: () => Promise; - fetchThreadComments: (threadId: string) => Promise; + fetchThreadComments: (threadId: string) => Promise; + getOrFetchInstance: ( + commentId: string, + options?: { restoreOn404?: boolean } + ) => Promise; createComment: (data: Partial) => Promise; deleteComment: (commentId: string) => Promise; restoreComment: (commentId: string) => Promise; @@ -52,15 +66,15 @@ export interface ICommentStore { unresolveComment: (commentId: string) => Promise; addReaction: (commentId: string, reaction: string) => Promise; removeReaction: (commentId: string, reaction: string) => Promise; - updateComment: (commentId: string, data: Partial) => Promise; + updateComment: (commentId: string, data: Partial) => Promise; } export class CommentStore implements ICommentStore { // observables comments: Map = new Map(); commentsFilters: TCommentFilters = { - showAll: true, - showActive: false, + showAll: false, + showActive: true, showResolved: false, }; commentsOrder: string[] = []; @@ -108,6 +122,7 @@ export class CommentStore implements ICommentStore { setPendingScrollToComment: action, fetchPageComments: action, fetchThreadComments: action, + getOrFetchInstance: action, createComment: action, deleteComment: action, restoreComment: action, @@ -131,6 +146,36 @@ export class CommentStore implements ICommentStore { return { pageId, config }; } + private isNotFoundError(error: unknown): boolean { + if (!error) return false; + + if (Array.isArray(error)) { + return error.some((entry) => typeof entry === "string" && entry.toLowerCase().includes("not found")); + } + + if (typeof error !== "object") return false; + + const errorObject = error as Record; + + const statusCandidates = [errorObject.status, errorObject.status_code, errorObject.statusCode, errorObject.code]; + if (statusCandidates.some((value) => value === 404 || value === "404")) { + return true; + } + + const detailCandidates = [errorObject.detail, errorObject.message, errorObject.error]; + + return detailCandidates.some((candidate) => { + if (typeof candidate === "string") { + const normalized = candidate.toLowerCase(); + return normalized.includes("not found") || normalized.includes("deleted"); + } + if (Array.isArray(candidate)) { + return candidate.some((entry) => typeof entry === "string" && entry.toLowerCase().includes("not found")); + } + return false; + }); + } + // Computed methods using computedFn for better performance getCommentById = computedFn((commentId: string): TCommentInstance | undefined => this.comments.get(commentId)); @@ -151,6 +196,42 @@ export class CommentStore implements ICommentStore { return replies[replies.length - 1]; }); + getThreadDisplayState = computedFn((threadId: string, showReplies: boolean) => { + const parentComment = this.getCommentById(threadId); + if (!parentComment) return null; + + const replies = this.getCommentsByParentId(threadId); + const totalReplies = parentComment.total_replies || 0; + + // Calculate how many replies are hidden (not loaded yet) + const hiddenRepliesCount = totalReplies - 1; + + const shouldShowReplyController = hiddenRepliesCount > 0; + + // Always show the latest reply if there are any replies + // showReplies controls whether to show the rest (older replies) + let displayItems: Array<{ comment: TCommentInstance }> = []; + + if (replies.length > 0) { + if (showReplies) { + // Show all loaded replies when expanded + displayItems = replies.map((comment) => ({ comment })); + } else { + // Show only the latest reply when collapsed + const latestReply = replies[replies.length - 1]; + displayItems = [{ comment: latestReply }]; + } + } + + return { + shouldShowReplyController, + hiddenRepliesCount, + displayItems, + totalReplies, + loadedRepliesCount: replies.length, + }; + }); + get baseComments(): TCommentInstance[] { const allComments = Array.from(this.comments.values()); const comments = allComments.filter((comment) => !comment.parent_id); @@ -233,6 +314,18 @@ export class CommentStore implements ICommentStore { const previousOrder = [...this.commentsOrder]; this.commentsOrder = commentsOrder; + // Detect new comment IDs that were added to the order + const newCommentIds = commentsOrder.filter((id) => !previousOrder.includes(id)); + + // Fetch any missing comments for new IDs + if (newCommentIds.length > 0) { + Promise.all(newCommentIds.map((commentId) => this.getOrFetchInstance(commentId, { restoreOn404: true }))).catch( + (error) => { + console.error("Failed to fetch some comments from order update:", error); + } + ); + } + // If we have a pending scroll comment and the order actually changed, // and the pending comment is now in the new order, trigger scroll if ( @@ -249,6 +342,44 @@ export class CommentStore implements ICommentStore { }); }; + getOrFetchInstance = async ( + commentId: string, + options?: { restoreOn404?: boolean } + ): Promise => { + // Return existing comment if found + if (this.comments.has(commentId)) { + return this.comments.get(commentId); + } + + try { + // Fetch missing comment from API + const { pageId, config } = this.getPageContext(); + const comment = await this.commentService.retrieve({ pageId, config, commentId }); + + runInAction(() => { + this.comments.set(commentId, new CommentInstance(this, comment)); + }); + + return this.comments.get(commentId); + } catch (error) { + const shouldAttemptRestore = options?.restoreOn404 && this.isNotFoundError(error); + + if (shouldAttemptRestore) { + try { + console.warn(`Comment ${commentId} not found during order sync. Attempting restore.`); + await this.restoreComment(commentId); + return this.comments.get(commentId); + } catch (restoreError) { + console.error(`Failed to restore comment ${commentId} after not-found response:`, restoreError); + } + } else { + console.error(`Failed to fetch comment ${commentId}:`, error); + } + + return undefined; + } + }; + // API actions fetchPageComments = async (): Promise => { const { pageId, config } = this.getPageContext(); @@ -276,8 +407,8 @@ export class CommentStore implements ICommentStore { } }; - fetchThreadComments = async (threadId: string): Promise => { - if (!threadId) return; + fetchThreadComments = async (threadId: string): Promise => { + if (!threadId) return []; const { pageId, config } = this.getPageContext(); @@ -298,6 +429,7 @@ export class CommentStore implements ICommentStore { } }); }); + return threadComments; } catch (error) { console.error("Failed to fetch thread comments:", error); throw error; @@ -312,7 +444,7 @@ export class CommentStore implements ICommentStore { if (data.parent_id) { const parentCommentInstance = this.getCommentById(data.parent_id); - if (parentCommentInstance && parentCommentInstance.total_replies) { + if (parentCommentInstance && parentCommentInstance.total_replies != null) { parentCommentInstance.total_replies++; } } @@ -334,6 +466,17 @@ export class CommentStore implements ICommentStore { const { pageId, config } = this.getPageContext(); await this.commentService.destroy({ pageId, config, commentId }); + const commentInstance = this.getCommentById(commentId); + if (!commentInstance) { + throw new Error("Comment instance not found while deleting"); + } + + if (commentInstance.parent_id) { + const parentCommentInstance = this.getCommentById(commentInstance.parent_id); + if (parentCommentInstance && parentCommentInstance.total_replies != null) { + parentCommentInstance.total_replies--; + } + } runInAction(() => { this.comments.delete(commentId); @@ -441,30 +584,42 @@ export class CommentStore implements ICommentStore { }); runInAction(() => { - const comment = this.comments.get(commentId); + const comment = this.getCommentInstance(commentId); if (comment) { comment.page_comment_reactions = comment.page_comment_reactions.filter((r) => r.reaction !== reaction); } }); }; - updateComment = async (commentId: string, data: Partial): Promise => { - const { pageId, config } = this.getPageContext(); + getCommentInstance = (commentId: string): TCommentInstance | undefined => this.comments.get(commentId); - const updatedComment = await this.commentService.update({ - pageId, - commentId, - data, - config, - }); + updateComment = async (commentId: string, data: Partial): Promise => { + const { pageId, config } = this.getPageContext(); + const commentInstance = this.getCommentInstance(commentId); + const oldValues = commentInstance?.asJSON; + + if (!commentInstance) { + throw new Error(`Comment with ID ${commentId} not found`); + } runInAction(() => { - const comment = this.comments.get(commentId); - if (comment) { - comment.updateProperties(updatedComment); - } + commentInstance.updateProperties(data); }); - return updatedComment; + await this.commentService + .update({ + pageId, + commentId, + data, + config, + }) + .catch((error) => { + runInAction(() => { + if (oldValues) { + commentInstance.updateProperties(oldValues); + } + }); + throw error; + }); }; } diff --git a/apps/dev-wiki/ee/types/pages/pane-extensions.ts b/apps/dev-wiki/ee/types/pages/pane-extensions.ts index 9995594a7e..9cc8fe3963 100644 --- a/apps/dev-wiki/ee/types/pages/pane-extensions.ts +++ b/apps/dev-wiki/ee/types/pages/pane-extensions.ts @@ -12,6 +12,7 @@ export type TCommentsNavigationExtensionData = TCommentConfig & { referenceText?: string; }; onPendingCommentCancel?: () => void; + onSelectedThreadConsumed?: () => void; }; // EE Union of all possible navigation pane extension data types diff --git a/apps/live/src/core/agents/server-agent.ts b/apps/live/src/core/agents/server-agent.ts index 16e8a477bc..16d2da3ea9 100644 --- a/apps/live/src/core/agents/server-agent.ts +++ b/apps/live/src/core/agents/server-agent.ts @@ -213,7 +213,7 @@ export class ServerAgentManager { }); } - // // Process the document using our extensible system + // Process the document using our extensible system DocumentProcessor.process(xmlFragment, subPagesFromBackend || [], options); }, { diff --git a/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts index cd405c1ca4..b73ffc58b0 100644 --- a/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts +++ b/apps/web/ce/hooks/pages/use-pages-pane-extensions.ts @@ -4,6 +4,7 @@ import type { EditorRefApi } from "@plane/editor"; import { PAGE_NAVIGATION_PANE_TAB_KEYS, PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, + PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, } from "@/components/pages/navigation-pane"; import { useAppRouter } from "@/hooks/use-app-router"; import { useQueryParams } from "@/hooks/use-query-params"; @@ -43,10 +44,18 @@ export const usePagesPaneExtensions = (_params: TPageExtensionHookParams) => { const navigationPaneExtensions: INavigationPaneExtension[] = []; + const handleCloseNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + router.push(updatedRoute); + }, [router, updateQueryParams]); + return { editorExtensionHandlers, navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen, + handleCloseNavigationPane, }; }; diff --git a/apps/web/core/components/editor/lite-text/editor.tsx b/apps/web/core/components/editor/lite-text/editor.tsx index 797f3f3b11..6dd6663c2b 100644 --- a/apps/web/core/components/editor/lite-text/editor.tsx +++ b/apps/web/core/components/editor/lite-text/editor.tsx @@ -17,6 +17,7 @@ import { useUserProfile } from "@/hooks/store/use-user-profile"; import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging"; // plane web services import { WorkspaceService } from "@/plane-web/services"; +import { LiteToolbar } from "./lite-toolbar"; const workspaceService = new WorkspaceService(); type LiteTextEditorWrapperProps = MakeOptional< @@ -32,7 +33,7 @@ type LiteTextEditorWrapperProps = MakeOptional< showSubmitButton?: boolean; isSubmitting?: boolean; showToolbarInitially?: boolean; - showToolbar?: boolean; + variant?: "full" | "lite" | "none"; issue_id?: string; parentClassName?: string; editorClassName?: string; @@ -61,7 +62,7 @@ export const LiteTextEditor = React.forwardRef !showToolbarInitially && setIsFocused(true)} - onBlur={() => !showToolbarInitially && setIsFocused(false)} + onFocus={() => isFullVariant && !showToolbarInitially && setIsFocused(true)} + onBlur={() => isFullVariant && !showToolbarInitially && setIsFocused(false)} > - "", - workspaceId, - workspaceSlug, - })} - mentionHandler={{ - searchCallback: async (query) => { - const res = await fetchMentions(query); - if (!res) throw new Error("Failed in fetching mentions"); - return res; - }, - renderComponent: EditorMentionsRoot, - getMentionedEntityDetails: (id) => ({ - display_name: getUserDetails(id)?.display_name ?? "", - }), - }} - placeholder={placeholder} - containerClassName={cn(containerClassName, "relative", { - "p-2": !editable, - })} - extendedEditorProps={{ - isSmoothCursorEnabled: is_smooth_cursor_enabled, - }} - editorClassName={editorClassName} - {...rest} - /> - {showToolbar && editable && ( + {/* Wrapper for lite toolbar layout */} +
+ {/* Main Editor - always rendered once */} +
+ "", + workspaceId, + workspaceSlug, + })} + mentionHandler={{ + searchCallback: async (query) => { + const res = await fetchMentions(query); + if (!res) throw new Error("Failed in fetching mentions"); + return res; + }, + renderComponent: EditorMentionsRoot, + getMentionedEntityDetails: (id) => ({ + display_name: getUserDetails(id)?.display_name ?? "", + }), + }} + placeholder={placeholder} + containerClassName={cn(containerClassName, "relative", { + "p-2": !editable, + })} + extendedEditorProps={{ + isSmoothCursorEnabled: is_smooth_cursor_enabled, + }} + editorClassName={editorClassName} + {...rest} + /> +
+ + {/* Lite Toolbar - conditionally rendered */} + {isLiteVariant && editable && ( + { + // TODO: update this while toolbar homogenization + // @ts-expect-error type mismatch here + editorRef?.executeMenuItemCommand({ + itemKey: item.itemKey, + ...item.extraProps, + }); + }} + onSubmit={(e) => rest.onEnterKeyPress?.(e)} + isSubmitting={isSubmitting} + isEmpty={isEmpty} + /> + )} +
+ + {/* Full Toolbar - conditionally rendered */} + {isFullVariant && editable && (
| React.MouseEvent) => void; + isSubmitting: boolean; + isEmpty: boolean; + executeCommand: (item: ToolbarMenuItem) => void; +}; + +export const LiteToolbar = ({ onSubmit, isSubmitting, isEmpty, executeCommand }: LiteToolbarProps) => ( +
+ + +
+); + +export type { LiteToolbarProps }; diff --git a/apps/web/core/components/pages/editor/page-root.tsx b/apps/web/core/components/pages/editor/page-root.tsx index a0235deded..012dd3fd4f 100644 --- a/apps/web/core/components/pages/editor/page-root.tsx +++ b/apps/web/core/components/pages/editor/page-root.tsx @@ -12,16 +12,12 @@ import { useQueryParams } from "@/hooks/use-query-params"; import { type TCustomEventHandlers } from "@/hooks/use-realtime-page-events"; // plane web import import { PageModals } from "@/plane-web/components/pages"; -import { usePagesPaneExtensions, useExtendedEditorProps } from "@/plane-web/hooks"; +import { useExtendedEditorProps, usePagesPaneExtensions } from "@/plane-web/hooks/pages"; import { EPageStoreType, usePageStore } from "@/plane-web/hooks/store"; // store import type { TPageInstance } from "@/store/pages/base-page"; // local imports -import { - PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, - PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, - PageNavigationPaneRoot, -} from "../navigation-pane"; +import { PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, PageNavigationPaneRoot } from "../navigation-pane"; import { PageVersionsOverlay } from "../version"; import { PagesVersionEditor } from "../version/editor"; import { PageEditorBody, type TEditorBodyConfig, type TEditorBodyHandlers } from "./editor-body"; @@ -99,11 +95,16 @@ export const PageRoot = observer((props: TPageRootProps) => { }, [isContentEditable, setEditorRef]); // Get extensions and navigation logic from hook - const { editorExtensionHandlers, navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen } = - usePagesPaneExtensions({ - page, - editorRef, - }); + const { + editorExtensionHandlers, + navigationPaneExtensions, + handleOpenNavigationPane, + handleCloseNavigationPane, + isNavigationPaneOpen, + } = usePagesPaneExtensions({ + page, + editorRef, + }); // Get extended editor extensions configuration const extendedEditorProps = useExtendedEditorProps({ @@ -145,13 +146,6 @@ export const PageRoot = observer((props: TPageRootProps) => { [setEditorRef] ); - const handleCloseNavigationPane = useCallback(() => { - const updatedRoute = updateQueryParams({ - paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], - }); - router.push(updatedRoute); - }, [router, updateQueryParams]); - return (
diff --git a/apps/web/core/constants/editor.ts b/apps/web/core/constants/editor.ts index 5ca2abb76e..c6bc2fef40 100644 --- a/apps/web/core/constants/editor.ts +++ b/apps/web/core/constants/editor.ts @@ -15,7 +15,6 @@ import { Image, Italic, List, - ListCollapse, ListOrdered, ListTodo, LucideIcon, @@ -159,9 +158,18 @@ const USER_ACTION_ITEMS: ToolbarMenuItem<"quote" | "code">[] = [ { itemKey: "code", renderKey: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, ]; +export const IMAGE_ITEM = { + itemKey: "image", + renderKey: "image", + name: "Image", + icon: Image, + editors: ["lite", "document"], + extraProps: {}, +} as ToolbarMenuItem<"image">; + const COMPLEX_ITEMS: ToolbarMenuItem<"table" | "image">[] = [ { itemKey: "table", renderKey: "table", name: "Table", icon: Table, editors: ["document"] }, - { itemKey: "image", renderKey: "image", name: "Image", icon: Image, editors: ["lite", "document"], extraProps: {} }, + IMAGE_ITEM, ]; export const TOOLBAR_ITEMS: { diff --git a/apps/web/ee/components/automations/details/main-content/actions/comment-block.tsx b/apps/web/ee/components/automations/details/main-content/actions/comment-block.tsx index 761b43b095..80b778b655 100644 --- a/apps/web/ee/components/automations/details/main-content/actions/comment-block.tsx +++ b/apps/web/ee/components/automations/details/main-content/actions/comment-block.tsx @@ -35,7 +35,7 @@ export const AutomationDetailsMainContentAddCommentBlock: React.FC = obs initialValue={config.comment_text ?? "

"} parentClassName="p-0" showSubmitButton={false} - showToolbar={false} + variant="none" workspaceId={workspaceId} workspaceSlug={workspaceSlug} /> diff --git a/apps/web/ee/components/automations/details/sidebar/actions/form/configuration/add_comment/root.tsx b/apps/web/ee/components/automations/details/sidebar/actions/form/configuration/add_comment/root.tsx index 9bc2dc77dd..8d0f57a6f7 100644 --- a/apps/web/ee/components/automations/details/sidebar/actions/form/configuration/add_comment/root.tsx +++ b/apps/web/ee/components/automations/details/sidebar/actions/form/configuration/add_comment/root.tsx @@ -40,7 +40,7 @@ export const AutomationActionAddCommentConfiguration: React.FC = observe onChange={(_json, html) => onChange(html)} parentClassName="p-2" // TODO: add background if disabled editable={!isDisabled} - showToolbar={!isDisabled} + variant={isDisabled ? "none" : "full"} /> )} /> diff --git a/apps/web/ee/components/pages/comments/comment-avatar.tsx b/apps/web/ee/components/pages/comments/comment-avatar.tsx new file mode 100644 index 0000000000..fd779d2f56 --- /dev/null +++ b/apps/web/ee/components/pages/comments/comment-avatar.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { Avatar } from "@plane/ui"; +import { getFileURL, cn } from "@plane/utils"; +import { useMember } from "@/hooks/store/use-member"; + +type PageCommentAvatarProps = { + userId: string; + size?: "sm" | "md"; + className?: string; +}; + +export const PageCommentAvatar = observer(({ userId, size = "sm", className = "" }: PageCommentAvatarProps) => { + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + const memberDetails = getWorkspaceMemberDetails(userId); + + const sizeClasses = { + sm: "size-6", + md: "size-8", + }; + + return ( + + ); +}); diff --git a/apps/web/ee/components/pages/comments/comment-creation-handler.tsx b/apps/web/ee/components/pages/comments/comment-creation-handler.tsx index e3efd0b3ba..881f85f3f3 100644 --- a/apps/web/ee/components/pages/comments/comment-creation-handler.tsx +++ b/apps/web/ee/components/pages/comments/comment-creation-handler.tsx @@ -96,7 +96,7 @@ export const PageCommentCreationHandler = observer( handleNewCommentCancel({ pendingComment, onPendingCommentCancel }); }; - const handleSubmit = (data: { + const handleSubmit = async (data: { description: { description_html: string; description_json: JSONContent }; uploadedAssetIds: string[]; }) => { @@ -104,9 +104,15 @@ export const PageCommentCreationHandler = observer( // Update bulk asset status if (data.uploadedAssetIds.length > 0 && page.id) { - fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, page.id, { - asset_ids: data.uploadedAssetIds, - }); + if (page.project_ids?.length && page.project_ids?.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus(workspaceSlug, page.project_ids[0], page.id, { + asset_ids: data.uploadedAssetIds, + }); + } else { + await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug, page.id, { + asset_ids: data.uploadedAssetIds, + }); + } } }; @@ -115,7 +121,7 @@ export const PageCommentCreationHandler = observer( } return ( -
+
{/* Reference Text Quote with Overlay Cancel Button */} {referenceText && (
diff --git a/apps/web/ee/components/pages/comments/comment-display.tsx b/apps/web/ee/components/pages/comments/comment-display.tsx index e0fc76d457..7107688a67 100644 --- a/apps/web/ee/components/pages/comments/comment-display.tsx +++ b/apps/web/ee/components/pages/comments/comment-display.tsx @@ -6,138 +6,149 @@ import type { JSONContent } from "@plane/types"; import { AlertModalCore, CustomMenu, TContextMenuItem, TOAST_TYPE, setToast, Tooltip } from "@plane/ui"; import { cn } from "@plane/utils"; // hooks -import { useEditorAsset } from "@/hooks/store/use-editor-asset"; import { useWorkspace } from "@/hooks/store/use-workspace"; +import { useUser } from "@/hooks/store/user"; // store types import { type TCommentInstance } from "@/plane-web/store/pages/comments/comment-instance"; import { type TPageInstance } from "@/store/pages/base-page"; // local imports +import { PageCommentAvatar } from "./comment-avatar"; import { PageCommentForm } from "./comment-form"; -import { PageCommentUserInfo } from "./comment-user-info"; +import { PageCommentUserDetails } from "./comment-user-details"; type CommentItemProps = { comment: TCommentInstance; page: TPageInstance; - isSelected?: boolean; isParent: boolean; className?: string; }; -export const PageCommentDisplay = observer( - ({ comment, page, isSelected: _isSelected = false, isParent, className = "" }: CommentItemProps) => { - // Local state for UI controls (optimized to only essential states) - const [isEditing, setIsEditing] = useState(false); - const [deleteCommentModal, setDeleteCommentModal] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); +export const PageCommentDisplay = observer(({ comment, page, isParent, className = "" }: CommentItemProps) => { + // Local state for UI controls (optimized to only essential states) + const [isEditing, setIsEditing] = useState(false); + const [deleteCommentModal, setDeleteCommentModal] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); - // Get workspace details for editor - const { currentWorkspace } = useWorkspace(); - const { uploadEditorAsset } = useEditorAsset(); - const workspaceSlug = currentWorkspace?.slug || ""; - const workspaceId = currentWorkspace?.id || ""; + // Get workspace details for editor + const { data: currentUser } = useUser(); + const { currentWorkspace } = useWorkspace(); + const workspaceSlug = currentWorkspace?.slug || ""; + const workspaceId = currentWorkspace?.id || ""; - const showResolveButton = isParent; + const commentAuthorId = comment.created_by || comment.actor; + const pageOwnerId = page.owned_by; + const canEditComment = !!commentAuthorId && commentAuthorId === currentUser?.id; + const canDeleteComment = canEditComment || (!!pageOwnerId && pageOwnerId === currentUser?.id); + const showResolveButton = isParent && page.canCurrentUserCommentOnPage; - const handleEdit = useCallback( - async (data: { description: { description_html: string; description_json: JSONContent } }) => { - if (!comment.id) return; - - await page.comments.updateComment(comment.id, { - description: { - description_html: data.description.description_html, - description_json: data.description.description_json, - }, - }); - - setIsEditing(false); - }, - [comment.id, page.comments] - ); - - const handleClose = () => { - setIsDeleting(false); - setDeleteCommentModal(false); - }; - - const handleDeleteConfirm = useCallback(async () => { + const handleEdit = useCallback( + async (data: { description: { description_html: string; description_json: JSONContent } }) => { if (!comment.id) return; - setIsDeleting(true); + page.comments.updateComment(comment.id, { + description: { + description_html: data.description.description_html, + description_json: data.description.description_json, + }, + }); + + setIsEditing(false); + }, + [comment.id, page.comments] + ); + + const handleClose = () => { + setIsDeleting(false); + setDeleteCommentModal(false); + }; + + const handleDeleteConfirm = useCallback(async () => { + if (!comment.id) return; + + setIsDeleting(true); + try { + await page.comments.deleteComment(comment.id); + // Also remove the corresponding comment mark from the editor + page.editor.editorRef?.removeComment(comment.id); + handleClose(); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Comment deleted successfully.", + }); + } catch (error) { + console.error("Failed to delete comment:", error); + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Comment could not be deleted. Please try again.", + }); + } finally { + setIsDeleting(false); + } + }, [comment.id, page.comments, page.editor.editorRef]); + + const handleResolve = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!comment.id) return; try { - await page.comments.deleteComment(comment.id); - // Also remove the corresponding comment mark from the editor - page.editor.editorRef?.removeComment(comment.id); - handleClose(); - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Success!", - message: "Comment deleted successfully.", - }); - } catch (error) { - console.error("Failed to delete comment:", error); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Comment could not be deleted. Please try again.", - }); - } finally { - setIsDeleting(false); - } - }, [comment.id, page.comments, page.editor.editorRef]); + if (comment.is_resolved) { + await page.comments.unresolveComment(comment.id); - const handleResolve = useCallback( - async (e: React.MouseEvent) => { - e.stopPropagation(); - if (!comment.id) return; - try { - if (comment.is_resolved) { - await page.comments.unresolveComment(comment.id); - - if (page.editor.editorRef) { - page.editor.editorRef.unresolveCommentMark(comment.id); - } - } else { - await page.comments.resolveComment(comment.id); - if (page.editor.editorRef) { - page.editor.editorRef.resolveCommentMark(comment.id); - } + if (page.editor.editorRef) { + page.editor.editorRef.unresolveCommentMark(comment.id); + } + } else { + await page.comments.resolveComment(comment.id); + if (page.editor.editorRef) { + page.editor.editorRef.resolveCommentMark(comment.id); } - } catch (error) { - console.error("Failed to resolve/unresolve comment:", error); } + } catch (error) { + console.error("Failed to resolve/unresolve comment:", error); + } + }, + [comment.id, comment.is_resolved, page.comments, page.editor.editorRef] + ); + + // Define menu items following the actions.tsx pattern + const menuItems: (TContextMenuItem & { key: string })[] = useMemo( + () => [ + { + key: "edit", + action: () => setIsEditing(true), + title: "Edit", + icon: Pencil, + shouldRender: canEditComment && !isEditing, }, - [comment.id, comment.is_resolved, page.comments, page.editor.editorRef] - ); + { + key: "delete", + action: () => setDeleteCommentModal(true), + title: "Delete", + icon: Trash2, + shouldRender: canDeleteComment, + }, + ], + [canDeleteComment, canEditComment, isEditing] + ); - // Define menu items following the actions.tsx pattern - const menuItems: (TContextMenuItem & { key: string })[] = useMemo( - () => [ - { - key: "edit", - action: () => setIsEditing(true), - title: "Edit", - icon: Pencil, - shouldRender: !isEditing, - }, - { - key: "delete", - action: () => setDeleteCommentModal(true), - title: "Delete", - icon: Trash2, - shouldRender: true, - }, - ], - [isEditing] - ); + const hasMenuItems = useMemo(() => menuItems.some((item) => item.shouldRender !== false), [menuItems]); - return ( -
- {/* Comment Header */} -
- + return ( +
+ {/* Left Column - Avatar */} + - {/* Action Buttons - Always Visible */} -
+ {/* Right Column - Details + Content */} +
+ {/* Header Row - Name/Timestamp + Actions */} +
+ + + {/* Action Buttons */} +
{showResolveButton && ( )} -
- - {menuItems.map((item) => { - if (item.shouldRender === false) return null; - return ( - { - e.preventDefault(); - e.stopPropagation(); - item.action?.(); - }} - className={cn(`flex items-center gap-2`, item.className)} - > - {item.icon && } - {item.title} - - ); - })} - -
+ {hasMenuItems && ( +
+ + {menuItems.map((item) => { + if (item.shouldRender === false) return null; + return ( + { + item.action?.(); + }} + className={cn(`flex items-center gap-2`, item.className)} + > + {item.icon && } + {item.title} + + ); + })} + +
+ )}
@@ -197,23 +208,22 @@ export const PageCommentDisplay = observer( workspaceId={workspaceId} comment={comment} editable={isEditing} - placeholder="Edit comment..." + placeholder="Edit comment" autoFocus={isEditing} onSubmit={handleEdit} onCancel={() => setIsEditing(false)} - uploadEditorAsset={uploadEditorAsset} - /> - - {/* Delete Comment Modal */} - Are you sure you want to delete this comment? This action cannot be undone.} />
- ); - } -); + + {/* Delete Comment Modal */} + Are you sure you want to delete this comment? This action cannot be undone.} + /> +
+ ); +}); diff --git a/apps/web/ee/components/pages/comments/comment-filter-controls.tsx b/apps/web/ee/components/pages/comments/comment-filter-controls.tsx index 81d8a28027..d6bc59a01d 100644 --- a/apps/web/ee/components/pages/comments/comment-filter-controls.tsx +++ b/apps/web/ee/components/pages/comments/comment-filter-controls.tsx @@ -12,40 +12,47 @@ export type CommentFiltersProps = { onFilterChange: (filterKey: "showAll" | "showActive" | "showResolved") => void; }; -export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => ( - -
- - Filters +export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => { + const isFiltersApplied = filters.showActive || filters.showResolved; + + return ( + +
+ + Filters +
+ {isFiltersApplied && ( + + )}
-
- } - placement="bottom-end" - closeOnSelect={false} - > - onFilterChange("showAll")} className="flex items-center gap-2"> - - Show all - - onFilterChange("showActive")} className="flex items-center gap-2"> - - Show active - - onFilterChange("showResolved")} className="flex items-center gap-2"> - - Show resolved - - -)); + } + placement="bottom-end" + closeOnSelect={false} + > + onFilterChange("showActive")} className="flex items-center gap-2"> + + Show active + + onFilterChange("showResolved")} className="flex items-center gap-2"> + + Show resolved + + onFilterChange("showAll")} className="flex items-center gap-2"> + + Show all + + + ); +}); diff --git a/apps/web/ee/components/pages/comments/comment-form.tsx b/apps/web/ee/components/pages/comments/comment-form.tsx index 5ca534cdef..b128c8536b 100644 --- a/apps/web/ee/components/pages/comments/comment-form.tsx +++ b/apps/web/ee/components/pages/comments/comment-form.tsx @@ -2,15 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; -import { Check, X } from "lucide-react"; // editor import type { EditorRefApi } from "@plane/editor"; // types import { EFileAssetType, type JSONContent, type TPageComment } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; -import { cn, isCommentEmpty } from "@plane/utils"; +import { cn, isCommentEmpty, trimEmptyParagraphsFromJson, trimEmptyParagraphsFromHTML } from "@plane/utils"; // editor import { LiteTextEditor } from "@/components/editor/lite-text"; +import { useEditorAsset } from "@/hooks/store/use-editor-asset"; // types import { type TPageInstance } from "@/store/pages/base-page"; @@ -44,13 +44,6 @@ type CommentBoxProps = { uploadedAssetIds: string[]; }) => void; onCancel?: () => void; - uploadEditorAsset?: (args: { - blockId: string; - data: { entity_identifier: string; entity_type: EFileAssetType }; - projectId?: string; - file: File; - workspaceSlug: string; - }) => Promise<{ asset_id: string }>; }; export const EMPTY_COMMENT_JSON: JSONContent = { @@ -69,13 +62,11 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { workspaceId, comment, editable = false, - placeholder = "Add a comment...", + placeholder = "Add a comment", isSubmitting = false, pageId, isReply = false, onSubmit, - onCancel, - uploadEditorAsset, } = props; const editorRef = useRef(null); @@ -112,18 +103,17 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { const watchedDescription = watch("description"); const isEmpty = isCommentEmpty(watchedDescription?.description_html); - const isEditorReadyToDiscard = editorRef.current?.isEditorReadyToDiscard(); - const isSubmitButtonDisabled = isSubmittingState || !isEditorReadyToDiscard; - const isDisabled = isSubmittingState || isEmpty || isSubmitButtonDisabled; + + const { uploadEditorAsset } = useEditorAsset(); const uploadCommentAsset = useCallback( - async (blockId: string, file: File, entityIdentifier: string) => { + async (blockId: string, file: File) => { if (!workspaceSlug || !uploadEditorAsset) throw new Error("Missing upload configuration"); let uploadConfig: Parameters[0] = { blockId, data: { - entity_identifier: entityIdentifier, + entity_identifier: comment?.id ?? "", entity_type: EFileAssetType.COMMENT_DESCRIPTION, }, file, @@ -141,7 +131,7 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { setUploadedAssetIds((prev) => [...prev, res.asset_id]); return res; }, - [uploadEditorAsset, page.project_ids, workspaceSlug] + [uploadEditorAsset, page.project_ids, workspaceSlug, comment?.id] ); const onFormSubmit = async (formData: Partial) => { @@ -156,10 +146,19 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { try { setInternalIsSubmitting(true); + // Trim empty paragraphs from both JSON and HTML content + const trimmedJson = formData.description.description_json + ? trimEmptyParagraphsFromJson(formData.description.description_json) + : EMPTY_COMMENT_JSON; + + const trimmedHtml = formData.description.description_html + ? trimEmptyParagraphsFromHTML(formData.description.description_html) + : "

"; + onSubmit({ description: { - description_html: formData.description.description_html || "

", - description_json: formData.description.description_json || EMPTY_COMMENT_JSON, + description_html: trimmedHtml, + description_json: trimmedJson, }, uploadedAssetIds, }); @@ -191,123 +190,59 @@ export const PageCommentForm = observer((props: CommentBoxProps) => { setInternalIsSubmitting(false); } }; - - const handleCancel = () => { - try { - // Reset form to original values - if (comment?.description) { - const resetContent = originalContent || { - description_html: comment.description.description_html, - description_json: comment.description.description_json, - }; - - // Reset editor content - editorRef.current?.setEditorValue(resetContent.description_html); - - // Reset form state - reset({ - description: resetContent, - }); - } - - // Clear uploaded assets - setUploadedAssetIds([]); - - // Call parent cancel handler - if (onCancel) { - onCancel(); - } - } catch (error) { - console.error("Failed to cancel comment editing:", error); - setToast({ - type: TOAST_TYPE.ERROR, - title: "Error!", - message: "Failed to cancel editing. Please refresh the page.", - }); - } - }; + // states + const [isFocused, setIsFocused] = useState(false); // For editable mode (both new comments and editing existing) return ( -
-
{ - if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onFormSubmit)(e); - }} - className={cn(isReply || !comment ? "border border-custom-border-200 rounded p-2" : "")} - > - ( - handleSubmit(onFormSubmit)(e)} - value={null} - uploadFile={ - uploadEditorAsset - ? async (blockId, file) => { - const { asset_id } = await uploadCommentAsset(blockId, file, comment?.id || pageId || "new"); - return asset_id; - } - : async () => "" - } - ref={editorRef} - initialValue={value?.description_json ?? EMPTY_COMMENT_JSON} - containerClassName="min-h-min !p-0" - onChange={(description_json, description_html) => { - onChange({ description_json, description_html }); - }} - isSubmitting={isSubmittingState} - showSubmitButton={!comment} - showToolbarInitially - placeholder={placeholder} - parentClassName="!border-none !p-0" - // editorClassName="!text-base" - displayConfig={{ fontSize: "small-font" }} - /> - )} - /> -
- - {/* Custom submit buttons - only show when editing existing comments */} - {comment && editable && ( -
- {!isEmpty && ( - - )} - -
+
{ + if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && !isEmpty) handleSubmit(onFormSubmit)(e); + }} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + className={cn( + "relative w-full ", + comment && "px-2 -mx-2", + isReply || !comment ? "border border-custom-border-200 rounded p-2" : "", + isFocused && editable ? "border-2 border-custom-primary-100 rounded py-2" : "" )} + > + ( + handleSubmit(onFormSubmit)(e)} + value={null} + uploadFile={async (blockId, file) => { + const { asset_id } = await uploadCommentAsset(blockId, file); + setUploadedAssetIds((prev) => [...prev, asset_id]); + return asset_id; + }} + ref={editorRef} + initialValue={value?.description_json ?? EMPTY_COMMENT_JSON} + containerClassName="min-h-min !p-0" + onChange={(description_json, description_html) => { + onChange({ description_json, description_html }); + }} + isSubmitting={isSubmittingState} + showSubmitButton={!comment} + variant="lite" + placeholder={placeholder} + parentClassName="!border-none !p-0" + displayConfig={{ fontSize: "small-font" }} + /> + )} + />
); }); diff --git a/apps/web/ee/components/pages/comments/comment-reply-controller.tsx b/apps/web/ee/components/pages/comments/comment-reply-controller.tsx index 8af6141621..dab64554e6 100644 --- a/apps/web/ee/components/pages/comments/comment-reply-controller.tsx +++ b/apps/web/ee/components/pages/comments/comment-reply-controller.tsx @@ -1,35 +1,37 @@ import { observer } from "mobx-react"; import { TCommentInstance } from "@/plane-web/store/pages/comments/comment-instance"; +import { TPageInstance } from "@/store/pages/base-page"; type TCommentReplyController = { comment: TCommentInstance; handleShowRepliesToggle: (e: React.MouseEvent) => void; showReplies: boolean; + page: TPageInstance; }; export const PageCommentReplyController = observer( - ({ comment, handleShowRepliesToggle, showReplies }: TCommentReplyController) => { - if (comment.total_replies == null) return null; - if (comment.total_replies <= 1) return null; - const replyCount = comment.total_replies - 1; + ({ comment, handleShowRepliesToggle, showReplies, page }: TCommentReplyController) => { + // Use centralized thread display state for consistency + const threadState = page.comments.getThreadDisplayState(comment.id, showReplies); + + if (!threadState || !threadState.shouldShowReplyController) return null; + return ( - <> - {comment.hasReplies && replyCount && ( -
-
-
-
- -
-
+
+
+
+
+
- )} - +
+
); } ); diff --git a/apps/web/ee/components/pages/comments/comment-user-details.tsx b/apps/web/ee/components/pages/comments/comment-user-details.tsx new file mode 100644 index 0000000000..db63995ab2 --- /dev/null +++ b/apps/web/ee/components/pages/comments/comment-user-details.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { observer } from "mobx-react"; +import { cn } from "@plane/utils"; +// hooks +import { useMember } from "@/hooks/store/use-member"; +// local components +import { PageCommentTimestampDisplay } from "./comment-timestamp-display"; + +type PageCommentUserDetailsProps = { + userId: string; + timestamp?: string; + className?: string; +}; + +export const PageCommentUserDetails = observer(({ userId, timestamp, className = "" }: PageCommentUserDetailsProps) => { + const { + workspace: { getWorkspaceMemberDetails }, + } = useMember(); + + const memberDetails = getWorkspaceMemberDetails(userId); + + return ( +
+
{memberDetails?.member.display_name}
+ {timestamp && } +
+ ); +}); diff --git a/apps/web/ee/components/pages/comments/comment-user-info.tsx b/apps/web/ee/components/pages/comments/comment-user-info.tsx deleted file mode 100644 index 2d755e4ac3..0000000000 --- a/apps/web/ee/components/pages/comments/comment-user-info.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react"; -// plane imports -import { Avatar } from "@plane/ui"; -import { getFileURL, cn } from "@plane/utils"; -// hooks -import { useMember } from "@/hooks/store/use-member"; -// local components -import { PageCommentTimestampDisplay } from "./comment-timestamp-display"; - -type UserAvatarProps = { - size?: "sm" | "md"; - className?: string; - userId: string; - timestamp?: string; -}; - -export const PageCommentUserInfo = observer(({ userId, size = "sm", className = "", timestamp }: UserAvatarProps) => { - const { - workspace: { getWorkspaceMemberDetails }, - } = useMember(); - - const memberDetails = getWorkspaceMemberDetails(userId); - - const sizeClasses = { - sm: "size-6", - md: "size-8", - }; - - return ( -
-
-
- -
-
-
-
{memberDetails?.member.display_name}
- {timestamp && } -
-
- ); -}); diff --git a/apps/web/ee/components/pages/comments/comments-empty-placeholder.tsx b/apps/web/ee/components/pages/comments/comments-empty-placeholder.tsx index baf5c0bf56..3924f68486 100644 --- a/apps/web/ee/components/pages/comments/comments-empty-placeholder.tsx +++ b/apps/web/ee/components/pages/comments/comments-empty-placeholder.tsx @@ -1,15 +1,21 @@ import React from "react"; import { MessageCircle } from "lucide-react"; +import { type TCommentFilters } from "@/plane-web/store/pages/comments/comment.store"; export type CommentsEmptyStateProps = { hasComments: boolean; + commentFilter: TCommentFilters; }; -export function PageCommentsEmptyState({ hasComments }: CommentsEmptyStateProps) { - const title = hasComments ? "No comments match current filters" : "No comments yet"; - const message = hasComments - ? "Try adjusting your filters to see more comments." - : "Select text in the editor and add a comment to get started."; +export function PageCommentsEmptyState({ hasComments, commentFilter }: CommentsEmptyStateProps) { + const title = hasComments + ? commentFilter.showActive + ? "No active comments" + : commentFilter.showResolved + ? "No resolved comments match current filters" + : "No comments match current filters" + : "No comments yet"; + const message = "Select text in the editor and add a comment to get started."; return (
diff --git a/apps/web/ee/components/pages/comments/comments-navigation-extension.tsx b/apps/web/ee/components/pages/comments/comments-navigation-extension.tsx index 67e91f5d59..9541baae42 100644 --- a/apps/web/ee/components/pages/comments/comments-navigation-extension.tsx +++ b/apps/web/ee/components/pages/comments/comments-navigation-extension.tsx @@ -13,8 +13,14 @@ export const PageCommentsNavigationExtension: INavigationPaneExtensionComponent const { workspaceSlug } = useParams(); // Extract comments-specific data from extensionData - const { selectedCommentId, pendingComment, onPendingCommentCancel, onStartNewComment, onCreateCommentMark } = - extensionData || {}; + const { + selectedCommentId, + pendingComment, + onPendingCommentCancel, + onStartNewComment, + onCreateCommentMark, + onSelectedThreadConsumed, + } = extensionData || {}; // Store the ThreadsSidebar's registered handler const [registeredHandler, setRegisteredHandler] = useState< @@ -48,6 +54,7 @@ export const PageCommentsNavigationExtension: INavigationPaneExtensionComponent onPendingCommentCancel: onPendingCommentCancel, onRegisterStartNewComment: handleRegisterStartNewComment, onCreateCommentMark: onCreateCommentMark, + onSelectedThreadConsumed, }} /> ); diff --git a/apps/web/ee/components/pages/comments/comments-sidebar-panel.tsx b/apps/web/ee/components/pages/comments/comments-sidebar-panel.tsx index a5a2ba7d24..13c882c633 100644 --- a/apps/web/ee/components/pages/comments/comments-sidebar-panel.tsx +++ b/apps/web/ee/components/pages/comments/comments-sidebar-panel.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useMemo, useRef } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import useSWR from "swr"; // plane imports // hooks -import { useRouterParams } from "@/hooks/store/use-router-params"; import { useWorkspace } from "@/hooks/store/use-workspace"; import { useScrollManager } from "@/plane-web/hooks/pages/use-scroll-manager"; import { TPageInstance } from "@/store/pages/base-page"; @@ -20,6 +20,7 @@ type CommentHandlers = { handler: (selection?: { from: number; to: number; referenceText?: string }) => void ) => void; onCreateCommentMark?: (selection: { from: number; to: number }, commentId: string) => void; + onSelectedThreadConsumed?: () => void; }; export type ThreadsSidebarProps = { @@ -38,7 +39,7 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ pendingComment, handlers = {}, }: ThreadsSidebarProps) { - const { workspaceSlug } = useRouterParams(); + const { workspaceSlug } = useParams(); // Refs const scrollContainerRef = useRef(null); @@ -74,20 +75,27 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ useEffect(() => { page.comments.onScrollToPendingComment = (commentId: string) => { scrollToItem(commentId, { highlight: true }); - }; - - return () => { page.comments.onScrollToPendingComment = null; }; }, [page.comments, scrollToItem]); + const { onPendingCommentCancel, onRegisterStartNewComment, onCreateCommentMark, onSelectedThreadConsumed } = handlers; + // Auto-scroll to selected thread - wait for data to load first useEffect(() => { if (selectedThreadId && !isLoading && !isEmpty) { // Data is loaded, scroll to the selected thread scrollToItem(selectedThreadId, { highlight: true }); + onSelectedThreadConsumed?.(); } - }, [selectedThreadId, scrollToItem, isLoading, isEmpty]); + }, [selectedThreadId, scrollToItem, isLoading, isEmpty, onSelectedThreadConsumed]); + + const commentCreationHandlers = { + onPendingCommentCancel, + onRegisterStartNewComment, + onCreateCommentMark, + onScrollToElement: scrollToElement, + }; if (isLoading && isEmpty && !page.comments.pendingScrollToComment) { return ; @@ -96,10 +104,10 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ return (
{/* Header */} -
+

Comments

@@ -116,15 +124,12 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({ workspaceId, }} pendingComment={pendingComment} - handlers={{ - ...handlers, - onScrollToElement: scrollToElement, - }} + handlers={commentCreationHandlers} /> {/* Comments List or Empty State */} {filteredBaseComments.length === 0 ? ( - 0} /> + 0} commentFilter={commentsFilters} /> ) : ( ( - + {Array.from({ length: commentReplyCount }, (_, index) => ( -
- {index > 0 && ( -
-
-
- )} +
{/* User avatar and timestamp */}
- +
+ +
{/* Reply content */} - - - {index % 3 === 1 && } +
+ + + {index % 3 === 1 && } +
))} -
-
-
); diff --git a/apps/web/ee/components/pages/comments/thread-comment-item.tsx b/apps/web/ee/components/pages/comments/thread-comment-item.tsx index a3052fe454..53af9b0ad1 100644 --- a/apps/web/ee/components/pages/comments/thread-comment-item.tsx +++ b/apps/web/ee/components/pages/comments/thread-comment-item.tsx @@ -8,6 +8,7 @@ import { useWorkspace } from "@/hooks/store/use-workspace"; // store types import { useCommentMarkInteraction } from "@/plane-web/hooks/pages/use-comment-mark-interaction"; import { TCommentInstance } from "@/plane-web/store/pages/comments/comment-instance"; +import { FileService } from "@/services/file.service"; import { TPageInstance } from "@/store/pages/base-page"; // local components import { PageCommentDisplay } from "./comment-display"; @@ -22,11 +23,9 @@ export type ThreadItemProps = { referenceText?: string; }; +const fileService = new FileService(); export const PageThreadCommentItem = observer( - React.forwardRef(function ThreadItem( - { comment, page, isSelected, referenceText }, - ref - ) { + React.forwardRef(function ThreadItem({ comment, page, referenceText }, ref) { const { currentWorkspace } = useWorkspace(); const { workspaceSlug } = useParams(); const workspaceId = currentWorkspace?.id || ""; @@ -58,7 +57,10 @@ export const PageThreadCommentItem = observer( ); const handleReply = useCallback( - async (data: { description: { description_html: string; description_json: JSONContent } }) => { + async (data: { + description: { description_html: string; description_json: JSONContent }; + uploadedAssetIds: string[]; + }) => { if (!page.canCurrentUserCommentOnPage) { console.warn("User does not have permission to comment"); return; @@ -73,6 +75,24 @@ export const PageThreadCommentItem = observer( parent_id: comment.id, }); + // Update bulk asset status + if (data.uploadedAssetIds.length > 0 && page.id) { + if (page.project_ids?.length && page.project_ids?.length > 0) { + await fileService.updateBulkProjectAssetsUploadStatus( + workspaceSlug.toString(), + page.project_ids[0], + page.id, + { + asset_ids: data.uploadedAssetIds, + } + ); + } else { + await fileService.updateBulkWorkspaceAssetsUploadStatus(workspaceSlug.toString(), page.id, { + asset_ids: data.uploadedAssetIds, + }); + } + } + // Close reply box and show replies setShowReplyBox(false); setShowReplies(true); @@ -82,11 +102,15 @@ export const PageThreadCommentItem = observer( setIsSubmittingReply(false); } }, - [comment.id, page.comments, page.canCurrentUserCommentOnPage] + [comment.id, page, workspaceSlug] ); + const threadState = page.comments.getThreadDisplayState(comment.id, showReplies); // Use custom hook for comment mark interactions - const { handleMouseEnter, handleMouseLeave, handleThreadClick } = useCommentMarkInteraction(comment.id); + const { handleMouseEnter, handleMouseLeave, handleThreadClick } = useCommentMarkInteraction({ + commentId: comment.id, + editorRef: page.editor.editorRef, + }); return (
)} - {/* Main Thread Comment */} -
- +
+ {/* We only show the connector if there are only 2 comments or if there's a single comment but replybox is open */} + {((!threadState?.shouldShowReplyController && comment.total_replies) || + (comment.total_replies === 0 && showReplyBox)) && ( +
+ )} + {/* Main Thread Comment */} +
@@ -121,14 +147,20 @@ export const PageThreadCommentItem = observer( comment={comment} handleShowRepliesToggle={handleShowRepliesToggle} showReplies={showReplies} + page={page} /> {/* Replies List */} - + {/* Action Bar */} {page.canCurrentUserCommentOnPage && !showReplyBox && ( -
+
); diff --git a/apps/web/ee/components/pages/share/access-menu.tsx b/apps/web/ee/components/pages/share/access-menu.tsx index 0ee6df8694..5ce9ad5536 100644 --- a/apps/web/ee/components/pages/share/access-menu.tsx +++ b/apps/web/ee/components/pages/share/access-menu.tsx @@ -1,11 +1,12 @@ "use client"; import { memo } from "react"; -import { ChevronDown, Trash2, Eye, Pencil, Check } from "lucide-react"; +import { ChevronDown, Trash2, Eye, Pencil, Check, MessageSquareText } from "lucide-react"; import { CustomMenu } from "@plane/ui"; const ACCESS_OPTIONS = [ { value: "0", label: "View", icon: Eye }, + { value: "1", label: "Comment", icon: MessageSquareText }, { value: "2", label: "Edit", icon: Pencil }, ]; diff --git a/apps/web/ee/hooks/pages/use-comment-mark-interaction.ts b/apps/web/ee/hooks/pages/use-comment-mark-interaction.ts index 96052421f2..6c91494361 100644 --- a/apps/web/ee/hooks/pages/use-comment-mark-interaction.ts +++ b/apps/web/ee/hooks/pages/use-comment-mark-interaction.ts @@ -1,4 +1,5 @@ -import { useCallback } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import type { EditorRefApi } from "@plane/editor"; type CommentMarkInteractionHook = { handleMouseEnter: () => void; @@ -6,26 +7,35 @@ type CommentMarkInteractionHook = { handleThreadClick: (e: React.MouseEvent) => void; }; -export function useCommentMarkInteraction(commentId: string): CommentMarkInteractionHook { - const getCommentMark = useCallback(() => document.querySelector(`[data-comment-id="${commentId}"]`), [commentId]); +type UseCommentMarkInteractionParams = { + commentId: string; + editorRef?: EditorRefApi | null; +}; + +export function useCommentMarkInteraction({ + commentId, + editorRef, +}: UseCommentMarkInteractionParams): CommentMarkInteractionHook { + const deselectTimeoutRef = useRef(null); + + const clearHover = useCallback(() => { + editorRef?.hoverCommentMarks([]); + }, [editorRef]); + + const clearSelection = useCallback(() => { + editorRef?.selectCommentMark(null); + }, [editorRef]); const handleMouseEnter = useCallback(() => { - const commentMark = getCommentMark(); - if (commentMark) { - commentMark.classList.add("bg-[#FFBF66]/40", "transition-all", "duration-200"); - } - }, [getCommentMark]); + editorRef?.hoverCommentMarks([commentId]); + }, [editorRef, commentId]); const handleMouseLeave = useCallback(() => { - const commentMark = getCommentMark(); - if (commentMark) { - commentMark.classList.remove("bg-[#FFBF66]/40", "transition-all", "duration-200"); - } - }, [getCommentMark]); + clearHover(); + }, [clearHover]); const handleThreadClick = useCallback( (e: React.MouseEvent) => { - // Don't trigger selection if clicking on interactive elements const target = e.target as HTMLElement; if ( target.tagName === "BUTTON" || @@ -38,24 +48,30 @@ export function useCommentMarkInteraction(commentId: string): CommentMarkInterac return; } - const commentMark = getCommentMark(); - if (commentMark) { - // Add temporary highlight effect - commentMark.classList.add("scale-[1.02]", "transition-all", "duration-300"); + editorRef?.selectCommentMark(commentId); + editorRef?.scrollToCommentMark(commentId); - // Scroll the comment mark into view in the editor - commentMark.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - - // Remove highlight effect after animation - setTimeout(() => { - commentMark.classList.remove("shadow-lg", "scale-[1.02]", "transition-all", "duration-300"); - }, 2000); + if (deselectTimeoutRef.current) { + window.clearTimeout(deselectTimeoutRef.current); } + + deselectTimeoutRef.current = window.setTimeout(() => { + editorRef?.selectCommentMark(null); + deselectTimeoutRef.current = null; + }, 2000); }, - [getCommentMark] + [editorRef, commentId] + ); + + useEffect( + () => () => { + if (deselectTimeoutRef.current) { + window.clearTimeout(deselectTimeoutRef.current); + } + clearHover(); + clearSelection(); + }, + [clearHover, clearSelection] ); return { diff --git a/apps/web/ee/hooks/pages/use-pages-pane-extensions.ts b/apps/web/ee/hooks/pages/use-pages-pane-extensions.ts index 79d43ee4ec..1735255b71 100644 --- a/apps/web/ee/hooks/pages/use-pages-pane-extensions.ts +++ b/apps/web/ee/hooks/pages/use-pages-pane-extensions.ts @@ -1,9 +1,10 @@ import { useCallback, useMemo, useState, type RefObject } from "react"; import { useSearchParams } from "next/navigation"; -import type { EditorRefApi } from "@plane/editor"; +import type { EditorRefApi, TCommentClickPayload } from "@plane/editor"; import { PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_TAB_KEYS, + PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM, } from "@/components/pages/navigation-pane"; import { useAppRouter } from "@/hooks/use-app-router"; import { useQueryParams } from "@/hooks/use-query-params"; @@ -40,8 +41,8 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { // Comment-specific callbacks - all contained within hook const onCommentClick = useCallback( - (commentId: string) => { - setSelectedCommentId(commentId); + (payload: TCommentClickPayload, _referenceTextParagraph?: string) => { + setSelectedCommentId(payload.primaryCommentId); const updatedRoute = updateQueryParams({ paramsToAdd: { [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM]: "comments" }, @@ -55,6 +56,10 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { setPendingComment(undefined); }, []); + const onSelectedThreadConsumed = useCallback(() => { + setSelectedCommentId(undefined); + }, []); + const onCreateCommentMark = useCallback( (selection: { from: number; to: number }, commentId: string) => { if (editorRef.current) { @@ -101,6 +106,15 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { router.push(updatedRoute); }, [router, updateQueryParams]); + const handleCloseNavigationPane = useCallback(() => { + const updatedRoute = updateQueryParams({ + paramsToRemove: [PAGE_NAVIGATION_PANE_TABS_QUERY_PARAM, PAGE_NAVIGATION_PANE_VERSION_QUERY_PARAM], + }); + router.push(updatedRoute); + setSelectedCommentId(undefined); + setPendingComment(undefined); + }, [router, updateQueryParams]); + // Editor extension handlers map - directly consumable by PageEditorBody const editorExtensionHandlers: Map = useMemo(() => { const map: Map = new Map(); @@ -138,6 +152,7 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { selectedCommentId, pendingComment, onPendingCommentCancel, + onSelectedThreadConsumed, onClick: onCommentClick, onDelete: page.comments.deleteComment, onRestore: page.comments.restoreComment, @@ -156,5 +171,6 @@ export const usePagesPaneExtensions = (params: TPageExtensionHookParams) => { navigationPaneExtensions, handleOpenNavigationPane, isNavigationPaneOpen, + handleCloseNavigationPane, }; }; diff --git a/apps/web/ee/store/pages/comments/comment-instance.ts b/apps/web/ee/store/pages/comments/comment-instance.ts index 1399f6e15f..32de0459c3 100644 --- a/apps/web/ee/store/pages/comments/comment-instance.ts +++ b/apps/web/ee/store/pages/comments/comment-instance.ts @@ -14,6 +14,7 @@ export type TCommentInstance = TPageComment & { // computed properties isRootComment: boolean; hasReplies: boolean; + asJSON: TPageComment; childComments: TCommentInstance[]; threadComments: TCommentInstance[]; // all comments in this thread (parent + children) @@ -118,6 +119,7 @@ export class CommentInstance implements TCommentInstance { reference_stripped: observable.ref, // computed + asJSON: computed, isRootComment: computed, hasReplies: computed, childComments: computed, @@ -128,6 +130,37 @@ export class CommentInstance implements TCommentInstance { }); } + get asJSON() { + return { + id: this.id, + workspace: this.workspace, + workspace_detail: this.workspace_detail, + page: this.page, + project: this.project, + actor: this.actor, + actor_detail: this.actor_detail, + comment_stripped: this.comment_stripped, + description: this.description, + created_at: this.created_at, + updated_at: this.updated_at, + created_by: this.created_by, + updated_by: this.updated_by, + parent: this.parent, + parent_id: this.parent_id, + page_comment_reactions: this.page_comment_reactions, + is_resolved: this.is_resolved, + resolved_at: this.resolved_at, + resolved_by: this.resolved_by, + node_id: this.node_id, + external_id: this.external_id, + external_source: this.external_source, + replies: this.replies, + reactions: this.reactions, + total_replies: this.total_replies, + reference_stripped: this.reference_stripped, + }; + } + // Computed properties get isRootComment(): boolean { return this.parent_id === null; diff --git a/apps/web/ee/store/pages/comments/comment.store.ts b/apps/web/ee/store/pages/comments/comment.store.ts index 524e3762cc..34bbb2cba3 100644 --- a/apps/web/ee/store/pages/comments/comment.store.ts +++ b/apps/web/ee/store/pages/comments/comment.store.ts @@ -31,6 +31,16 @@ export interface ICommentStore { getCommentById: (commentId: string) => TCommentInstance | undefined; getCommentsByParentId: (parentId: string) => TCommentInstance[]; getLatestReplyByParentId: (parentId: string) => TCommentInstance | undefined; + getThreadDisplayState: ( + threadId: string, + showReplies: boolean + ) => { + shouldShowReplyController: boolean; + hiddenRepliesCount: number; + displayItems: Array<{ comment: TCommentInstance }>; + totalReplies: number; + loadedRepliesCount: number; + } | null; // computed properties baseComments: TCommentInstance[]; filteredBaseComments: TCommentInstance[]; @@ -44,7 +54,11 @@ export interface ICommentStore { // API actions - now context-aware (no need to pass pageId/config) fetchPageComments: () => Promise; - fetchThreadComments: (threadId: string) => Promise; + fetchThreadComments: (threadId: string) => Promise; + getOrFetchInstance: ( + commentId: string, + options?: { restoreOn404?: boolean } + ) => Promise; createComment: (data: Partial) => Promise; deleteComment: (commentId: string) => Promise; restoreComment: (commentId: string) => Promise; @@ -52,15 +66,15 @@ export interface ICommentStore { unresolveComment: (commentId: string) => Promise; addReaction: (commentId: string, reaction: string) => Promise; removeReaction: (commentId: string, reaction: string) => Promise; - updateComment: (commentId: string, data: Partial) => Promise; + updateComment: (commentId: string, data: Partial) => Promise; } export class CommentStore implements ICommentStore { // observables comments: Map = new Map(); commentsFilters: TCommentFilters = { - showAll: true, - showActive: false, + showAll: false, + showActive: true, showResolved: false, }; commentsOrder: string[] = []; @@ -108,6 +122,7 @@ export class CommentStore implements ICommentStore { setPendingScrollToComment: action, fetchPageComments: action, fetchThreadComments: action, + getOrFetchInstance: action, createComment: action, deleteComment: action, restoreComment: action, @@ -131,6 +146,36 @@ export class CommentStore implements ICommentStore { return { pageId, config }; } + private isNotFoundError(error: unknown): boolean { + if (!error) return false; + + if (Array.isArray(error)) { + return error.some((entry) => typeof entry === "string" && entry.toLowerCase().includes("not found")); + } + + if (typeof error !== "object") return false; + + const errorObject = error as Record; + + const statusCandidates = [errorObject.status, errorObject.status_code, errorObject.statusCode, errorObject.code]; + if (statusCandidates.some((value) => value === 404 || value === "404")) { + return true; + } + + const detailCandidates = [errorObject.detail, errorObject.message, errorObject.error]; + + return detailCandidates.some((candidate) => { + if (typeof candidate === "string") { + const normalized = candidate.toLowerCase(); + return normalized.includes("not found") || normalized.includes("deleted"); + } + if (Array.isArray(candidate)) { + return candidate.some((entry) => typeof entry === "string" && entry.toLowerCase().includes("not found")); + } + return false; + }); + } + // Computed methods using computedFn for better performance getCommentById = computedFn((commentId: string): TCommentInstance | undefined => this.comments.get(commentId)); @@ -151,6 +196,42 @@ export class CommentStore implements ICommentStore { return replies[replies.length - 1]; }); + getThreadDisplayState = computedFn((threadId: string, showReplies: boolean) => { + const parentComment = this.getCommentById(threadId); + if (!parentComment) return null; + + const replies = this.getCommentsByParentId(threadId); + const totalReplies = parentComment.total_replies || 0; + + // Calculate how many replies are hidden (not loaded yet) + const hiddenRepliesCount = totalReplies - 1; + + const shouldShowReplyController = hiddenRepliesCount > 0; + + // Always show the latest reply if there are any replies + // showReplies controls whether to show the rest (older replies) + let displayItems: Array<{ comment: TCommentInstance }> = []; + + if (replies.length > 0) { + if (showReplies) { + // Show all loaded replies when expanded + displayItems = replies.map((comment) => ({ comment })); + } else { + // Show only the latest reply when collapsed + const latestReply = replies[replies.length - 1]; + displayItems = [{ comment: latestReply }]; + } + } + + return { + shouldShowReplyController, + hiddenRepliesCount, + displayItems, + totalReplies, + loadedRepliesCount: replies.length, + }; + }); + get baseComments(): TCommentInstance[] { const allComments = Array.from(this.comments.values()); const comments = allComments.filter((comment) => !comment.parent_id); @@ -233,6 +314,18 @@ export class CommentStore implements ICommentStore { const previousOrder = [...this.commentsOrder]; this.commentsOrder = commentsOrder; + // Detect new comment IDs that were added to the order + const newCommentIds = commentsOrder.filter((id) => !previousOrder.includes(id)); + + // Fetch any missing comments for new IDs + if (newCommentIds.length > 0) { + Promise.all(newCommentIds.map((commentId) => this.getOrFetchInstance(commentId, { restoreOn404: true }))).catch( + (error) => { + console.error("Failed to fetch some comments from order update:", error); + } + ); + } + // If we have a pending scroll comment and the order actually changed, // and the pending comment is now in the new order, trigger scroll if ( @@ -249,6 +342,44 @@ export class CommentStore implements ICommentStore { }); }; + getOrFetchInstance = async ( + commentId: string, + options?: { restoreOn404?: boolean } + ): Promise => { + // Return existing comment if found + if (this.comments.has(commentId)) { + return this.comments.get(commentId); + } + + try { + // Fetch missing comment from API + const { pageId, config } = this.getPageContext(); + const comment = await this.commentService.retrieve({ pageId, config, commentId }); + + runInAction(() => { + this.comments.set(commentId, new CommentInstance(this, comment)); + }); + + return this.comments.get(commentId); + } catch (error) { + const shouldAttemptRestore = options?.restoreOn404 && this.isNotFoundError(error); + + if (shouldAttemptRestore) { + try { + console.warn(`Comment ${commentId} not found during order sync. Attempting restore.`); + await this.restoreComment(commentId); + return this.comments.get(commentId); + } catch (restoreError) { + console.error(`Failed to restore comment ${commentId} after not-found response:`, restoreError); + } + } else { + console.error(`Failed to fetch comment ${commentId}:`, error); + } + + return undefined; + } + }; + // API actions fetchPageComments = async (): Promise => { const { pageId, config } = this.getPageContext(); @@ -276,8 +407,8 @@ export class CommentStore implements ICommentStore { } }; - fetchThreadComments = async (threadId: string): Promise => { - if (!threadId) return; + fetchThreadComments = async (threadId: string): Promise => { + if (!threadId) return []; const { pageId, config } = this.getPageContext(); @@ -298,6 +429,7 @@ export class CommentStore implements ICommentStore { } }); }); + return threadComments; } catch (error) { console.error("Failed to fetch thread comments:", error); throw error; @@ -312,7 +444,7 @@ export class CommentStore implements ICommentStore { if (data.parent_id) { const parentCommentInstance = this.getCommentById(data.parent_id); - if (parentCommentInstance && parentCommentInstance.total_replies) { + if (parentCommentInstance && parentCommentInstance.total_replies != null) { parentCommentInstance.total_replies++; } } @@ -334,6 +466,17 @@ export class CommentStore implements ICommentStore { const { pageId, config } = this.getPageContext(); await this.commentService.destroy({ pageId, config, commentId }); + const commentInstance = this.getCommentById(commentId); + if (!commentInstance) { + throw new Error("Comment instance not found while deleting"); + } + + if (commentInstance.parent_id) { + const parentCommentInstance = this.getCommentById(commentInstance.parent_id); + if (parentCommentInstance && parentCommentInstance.total_replies != null) { + parentCommentInstance.total_replies--; + } + } runInAction(() => { this.comments.delete(commentId); @@ -441,30 +584,42 @@ export class CommentStore implements ICommentStore { }); runInAction(() => { - const comment = this.comments.get(commentId); + const comment = this.getCommentInstance(commentId); if (comment) { comment.page_comment_reactions = comment.page_comment_reactions.filter((r) => r.reaction !== reaction); } }); }; - updateComment = async (commentId: string, data: Partial): Promise => { - const { pageId, config } = this.getPageContext(); + getCommentInstance = (commentId: string): TCommentInstance | undefined => this.comments.get(commentId); - const updatedComment = await this.commentService.update({ - pageId, - commentId, - data, - config, - }); + updateComment = async (commentId: string, data: Partial): Promise => { + const { pageId, config } = this.getPageContext(); + const commentInstance = this.getCommentInstance(commentId); + const oldValues = commentInstance?.asJSON; + + if (!commentInstance) { + throw new Error(`Comment with ID ${commentId} not found`); + } runInAction(() => { - const comment = this.comments.get(commentId); - if (comment) { - comment.updateProperties(updatedComment); - } + commentInstance.updateProperties(data); }); - return updatedComment; + await this.commentService + .update({ + pageId, + commentId, + data, + config, + }) + .catch((error) => { + runInAction(() => { + if (oldValues) { + commentInstance.updateProperties(oldValues); + } + }); + throw error; + }); }; } diff --git a/apps/web/ee/types/pages/pane-extensions.ts b/apps/web/ee/types/pages/pane-extensions.ts index 9995594a7e..9cc8fe3963 100644 --- a/apps/web/ee/types/pages/pane-extensions.ts +++ b/apps/web/ee/types/pages/pane-extensions.ts @@ -12,6 +12,7 @@ export type TCommentsNavigationExtensionData = TCommentConfig & { referenceText?: string; }; onPendingCommentCancel?: () => void; + onSelectedThreadConsumed?: () => void; }; // EE Union of all possible navigation pane extension data types diff --git a/packages/editor/src/core/components/menus/block-menu.tsx b/packages/editor/src/core/components/menus/block-menu.tsx index bfb61237bc..fcded03d9e 100644 --- a/packages/editor/src/core/components/menus/block-menu.tsx +++ b/packages/editor/src/core/components/menus/block-menu.tsx @@ -8,6 +8,7 @@ import { useInteractions, FloatingPortal, } from "@floating-ui/react"; +import type { JSONContent } from "@tiptap/core"; import { type Editor, useEditorState } from "@tiptap/react"; import { Copy, LucideIcon, Trash2, Link, Code, Bookmark } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -26,6 +27,29 @@ type Props = { disabledExtensions?: IEditorProps["disabledExtensions"]; }; +const stripCommentMarksFromJSON = (node: JSONContent | null | undefined): JSONContent | null | undefined => { + if (!node) return node; + + const sanitizedNode: JSONContent = { ...node }; + + if (sanitizedNode.marks) { + const filteredMarks = sanitizedNode.marks.filter((mark) => mark.type !== ADDITIONAL_EXTENSIONS.COMMENTS); + if (filteredMarks.length > 0) { + sanitizedNode.marks = filteredMarks.map((mark) => ({ ...mark })); + } else { + delete sanitizedNode.marks; + } + } + + if (sanitizedNode.content) { + sanitizedNode.content = sanitizedNode.content + .map((child) => stripCommentMarksFromJSON(child)) + .filter((child): child is JSONContent => Boolean(child)); + } + + return sanitizedNode; +}; + export const BlockMenu = (props: Props) => { const { editor } = props; const [isOpen, setIsOpen] = useState(false); @@ -297,14 +321,14 @@ export const BlockMenu = (props: Props) => { if (insertPos < 0 || insertPos > docSize) { throw new Error("The insertion position is invalid or outside the document."); } - const contentToInsert = firstChild.toJSON(); + const contentToInsert = stripCommentMarksFromJSON(firstChild.toJSON() as JSONContent) as JSONContent; if (contentToInsert.type === ADDITIONAL_EXTENSIONS.EXTERNAL_EMBED) { return editor .chain() .insertExternalEmbed({ [EExternalEmbedAttributeNames.IS_RICH_CARD]: - contentToInsert.attrs[EExternalEmbedAttributeNames.IS_RICH_CARD], - [EExternalEmbedAttributeNames.SOURCE]: contentToInsert.attrs.src, + contentToInsert.attrs?.[EExternalEmbedAttributeNames.IS_RICH_CARD], + [EExternalEmbedAttributeNames.SOURCE]: contentToInsert.attrs?.src, pos: insertPos, }) .focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false }) @@ -313,7 +337,7 @@ export const BlockMenu = (props: Props) => { return editor .chain() .setBlockMath({ - latex: contentToInsert.attrs.latex, + latex: contentToInsert.attrs?.latex, pos: insertPos, }) .focus(Math.min(insertPos + 1, docSize), { scrollIntoView: false }) diff --git a/packages/editor/src/core/props.ts b/packages/editor/src/core/props.ts index a2ef109301..28970faa7c 100644 --- a/packages/editor/src/core/props.ts +++ b/packages/editor/src/core/props.ts @@ -6,6 +6,27 @@ type TArgs = { editorClassName: string; }; +const stripCommentMarksFromHTML = (html: string): string => { + const sanitizedHtml = html.replace(//g, ""); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = sanitizedHtml; + + const commentNodes = Array.from(wrapper.querySelectorAll("span[data-comment-id]")); + commentNodes.forEach((node) => { + const parentNode = node.parentNode; + if (!parentNode) return; + + while (node.firstChild) { + parentNode.insertBefore(node.firstChild, node); + } + + parentNode.removeChild(node); + }); + + return wrapper.innerHTML; +}; + export const CoreEditorProps = (props: TArgs): EditorProps => { const { editorClassName } = props; @@ -26,7 +47,7 @@ export const CoreEditorProps = (props: TArgs): EditorProps => { }, }, transformPastedHTML(html) { - return html.replace(//g, ""); + return stripCommentMarksFromHTML(html); }, }; }; diff --git a/packages/editor/src/ee/extensions/comments/commands.ts b/packages/editor/src/ee/extensions/comments/commands.ts index 0a0c8bc384..27602843f4 100644 --- a/packages/editor/src/ee/extensions/comments/commands.ts +++ b/packages/editor/src/ee/extensions/comments/commands.ts @@ -4,6 +4,7 @@ import { v4 as uuidv4 } from "uuid"; // plane editor imports import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; // local imports +import { commentInteractionPluginKey } from "./plugins"; import { ECommentAttributeNames, TCommentMarkAttributes } from "./types"; declare module "@tiptap/core" { @@ -13,6 +14,8 @@ declare module "@tiptap/core" { removeComment: (commentId: string) => ReturnType; resolveComment: (commentId: string) => ReturnType; unresolveComment: (commentId: string) => ReturnType; + hoverComments: (commentIds: string[]) => ReturnType; + selectComment: (commentId: string | null) => ReturnType; }; } } @@ -117,4 +120,28 @@ export const commentMarkCommands = (markType: MarkType): Partial => return true; }, + + hoverComments: + (commentIds: string[]) => + ({ tr, dispatch }) => { + if (!dispatch) { + return true; + } + + const sanitizedIds = Array.from(new Set(commentIds.filter((id) => typeof id === "string" && id.length > 0))); + + dispatch(tr.setMeta(commentInteractionPluginKey, { hovered: sanitizedIds })); + return true; + }, + + selectComment: + (commentId: string | null) => + ({ tr, dispatch }) => { + if (!dispatch) { + return true; + } + + dispatch(tr.setMeta(commentInteractionPluginKey, { selected: commentId })); + return true; + }, }); diff --git a/packages/editor/src/ee/extensions/comments/extension-config.ts b/packages/editor/src/ee/extensions/comments/extension-config.ts index 38438bbb6d..3e62609fd1 100644 --- a/packages/editor/src/ee/extensions/comments/extension-config.ts +++ b/packages/editor/src/ee/extensions/comments/extension-config.ts @@ -1,4 +1,6 @@ import { Mark, mergeAttributes } from "@tiptap/core"; +import { CORE_EXTENSIONS } from "@/constants/extension"; +import { getExtensionStorage } from "@/helpers/get-extension-storage"; // plane editor imports import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; // local imports @@ -7,6 +9,7 @@ import { createClickHandlerPlugin, createHoverHandlerPlugin, createCommentsOrderPlugin, + createCommentHighlightPlugin, TrackCommentDeletionPlugin, TrackCommentRestorationPlugin, } from "./plugins"; @@ -81,9 +84,14 @@ export const CommentsExtensionConfig = Mark.create void; + onCommentClick?: (payload: TCommentClickPayload) => void; + isTouchDevice?: boolean; }; export const createClickHandlerPlugin = (options: TClickHandlerPluginOptions) => { - const { onCommentClick } = options; + const { onCommentClick, isTouchDevice } = options; return new Plugin({ key: new PluginKey("commentClickHandler"), props: { handleDOMEvents: { - click: (view, event) => { + mousedown: (view, event) => { const target = event.target as HTMLElement; + if (!(target instanceof Element)) { + return false; + } const commentMark = target.closest(COMMENT_MARK_SELECTORS.WITH_ID); const commentId = commentMark?.getAttribute(ECommentAttributeNames.COMMENT_ID); const isCommentResolved = commentMark?.getAttribute(ECommentAttributeNames.RESOLVED) === "true"; if (commentMark && commentId && !isCommentResolved) { - // Do nothing for direct mark clicks; let default editor behavior proceed - event.preventDefault(); - event.stopPropagation(); + if (isTouchDevice) { + event.preventDefault(); + event.stopPropagation(); + } - onCommentClick?.(commentId); + const commentIds = new Set([commentId]); + + const domRange = getDomRangePositions(view, commentMark); + const coords = view.posAtCoords({ left: event.clientX, top: event.clientY }); + + const markRange = findCommentBounds(view, commentId); + + if (markRange) { + addCommentIdsFromRange(commentIds, view, markRange.from, markRange.to); + } + + if (domRange) { + addCommentIdsFromRange(commentIds, view, domRange.from, domRange.to); + addCommentIdsAtPosition(commentIds, view, domRange.from); + addCommentIdsAtPosition(commentIds, view, domRange.to); + } + + addCommentIdsAtPosition(commentIds, view, coords?.pos); + + const referenceParagraph = commentMark.closest("p")?.outerHTML ?? "

"; + const payload: TCommentClickPayload = { + referenceParagraph, + primaryCommentId: commentId, + commentIds: Array.from(commentIds), + }; + + onCommentClick?.(payload); return false; } @@ -34,3 +68,138 @@ export const createClickHandlerPlugin = (options: TClickHandlerPluginOptions) => }, }); }; + +function getCommentMarkType(view: EditorView): MarkType | undefined { + return view.state.schema.marks[ADDITIONAL_EXTENSIONS.COMMENTS] as MarkType | undefined; +} + +function isResolvedAttr(value: unknown): boolean { + return typeof value === "string" ? value === "true" : Boolean(value); +} + +function isCommentMark(mark: Mark, commentMarkType: MarkType): boolean { + return mark.type === commentMarkType; +} + +function collectCommentIdsInRange(view: EditorView, from: number, to: number): string[] { + const commentMarkType = getCommentMarkType(view); + + if (!commentMarkType) { + return [] as string[]; + } + + const { doc } = view.state; + const docSize = doc.content.size; + + let start = Math.max(0, Math.min(from, docSize)); + let end = Math.max(0, Math.min(to, docSize)); + + if (start > end) { + [start, end] = [end, start]; + } + + if (start === end) { + if (end < docSize) { + end = Math.min(docSize, end + 1); + } else if (start > 0) { + start = Math.max(0, start - 1); + } + } + + if (start === end) { + return [] as string[]; + } + + const ids = new Set(); + + const addMarks = (marks?: readonly Mark[]) => { + marks?.forEach((mark) => { + if (!isCommentMark(mark, commentMarkType)) { + return; + } + + const commentId = mark.attrs[ECommentAttributeNames.COMMENT_ID]; + const resolvedValue = mark.attrs[ECommentAttributeNames.RESOLVED]; + + if (typeof commentId !== "string" || commentId.length === 0 || isResolvedAttr(resolvedValue)) { + return; + } + + ids.add(commentId); + }); + }; + + doc.nodesBetween(start, end, (node) => { + addMarks(node.marks); + }); + + return Array.from(ids); +} + +function findCommentBounds(view: EditorView, commentId: string): { from: number; to: number } | null { + const commentMarkType = getCommentMarkType(view); + + if (!commentMarkType) { + return null; + } + + let from: number | null = null; + let to: number | null = null; + + view.state.doc.descendants((node, pos) => { + if (!node.isText) { + return; + } + + node.marks.forEach((mark) => { + if (!isCommentMark(mark, commentMarkType)) { + return; + } + + const markCommentId = mark.attrs[ECommentAttributeNames.COMMENT_ID]; + const resolvedValue = mark.attrs[ECommentAttributeNames.RESOLVED]; + + if (markCommentId !== commentId || isResolvedAttr(resolvedValue)) { + return; + } + + const nodeEnd = pos + node.nodeSize; + + from = from === null ? pos : Math.min(from, pos); + to = to === null ? nodeEnd : Math.max(to, nodeEnd); + }); + }); + + if (from === null || to === null || from === to) { + return null; + } + + return { from, to }; +} + +function getDomRangePositions(view: EditorView, element: Element): { from: number; to: number } | null { + try { + const from = view.posAtDOM(element, 0); + const to = view.posAtDOM(element, element.childNodes.length); + + return { from, to }; + } catch (_error) { + return null; + } +} + +function addCommentIdsFromRange(commentIds: Set, view: EditorView, from?: number | null, to?: number | null) { + if (typeof from !== "number" || typeof to !== "number") { + return; + } + + collectCommentIdsInRange(view, from, to).forEach((id) => commentIds.add(id)); +} + +function addCommentIdsAtPosition(commentIds: Set, view: EditorView, position?: number | null) { + if (typeof position !== "number") { + return; + } + + addCommentIdsFromRange(commentIds, view, position, position); +} diff --git a/packages/editor/src/ee/extensions/comments/plugins/delete.ts b/packages/editor/src/ee/extensions/comments/plugins/delete.ts index 68354a7458..fae32160ac 100644 --- a/packages/editor/src/ee/extensions/comments/plugins/delete.ts +++ b/packages/editor/src/ee/extensions/comments/plugins/delete.ts @@ -1,4 +1,5 @@ import { Editor } from "@tiptap/core"; +import { isChangeOrigin } from "@tiptap/extension-collaboration"; import { EditorState, Plugin, PluginKey, Transaction } from "@tiptap/pm/state"; // constants import { getExtensionStorage } from "@/helpers/get-extension-storage"; @@ -13,7 +14,10 @@ export const TrackCommentDeletionPlugin = (editor: Editor, deleteHandler: TComme new Plugin({ key: COMMENT_DELETE_PLUGIN_KEY, appendTransaction: (transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) => { - if (!transactions.some((tr) => tr.docChanged)) return null; + const hasChanges = transactions.some((tr) => tr.docChanged); + const areTransactionsFromOtherClient = transactions.some((tr) => isChangeOrigin(tr)); + + if (!hasChanges || areTransactionsFromOtherClient) return null; const oldCommentIds = new Set(); const newCommentIds = new Set(); diff --git a/packages/editor/src/ee/extensions/comments/plugins/highlight-handler-plugin.ts b/packages/editor/src/ee/extensions/comments/plugins/highlight-handler-plugin.ts new file mode 100644 index 0000000000..aa0bf690a9 --- /dev/null +++ b/packages/editor/src/ee/extensions/comments/plugins/highlight-handler-plugin.ts @@ -0,0 +1,137 @@ +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Decoration, DecorationSet } from "@tiptap/pm/view"; +// constants +import { ADDITIONAL_EXTENSIONS } from "@/plane-editor/constants/extensions"; +// local imports +import { ECommentAttributeNames } from "../types"; + +type CommentInteractionState = { + hovered: Set; + selected: string | null; + decorations: DecorationSet; +}; + +type CommentInteractionMeta = { + hovered?: string[]; + selected?: string | null; +}; + +export const commentInteractionPluginKey = new PluginKey("commentInteraction"); + +const buildDecorations = ( + doc: Parameters[0], + hovered: Set, + selected: string | null +) => { + if (hovered.size === 0 && !selected) { + return DecorationSet.empty; + } + + const decorations: Decoration[] = []; + const hoverClassNames = ["bg-[#FFBF66]/40", "transition-all", "duration-200"]; + const selectedClassNames = ["scale-[1.02]", "transition-all", "duration-300"]; + + doc.descendants((node, pos) => { + if (!node.isText) { + return true; + } + + node.marks.forEach((mark) => { + if (mark.type.name !== ADDITIONAL_EXTENSIONS.COMMENTS) { + return; + } + + const commentId = mark.attrs[ECommentAttributeNames.COMMENT_ID]; + if (typeof commentId !== "string" || commentId.length === 0) { + return; + } + + const isHovered = hovered.has(commentId); + const isSelected = selected === commentId; + + if (!isHovered && !isSelected) { + return; + } + + const classNames: string[] = []; + if (isHovered) { + classNames.push(...hoverClassNames); + } + if (isSelected) { + classNames.push(...selectedClassNames); + } + + const decorationAttrs: Record = { + "data-comment-highlighted": "true", + }; + if (isHovered) { + decorationAttrs["data-comment-highlight-state"] = isSelected ? "hovered-selected" : "hovered"; + } + if (isSelected && !isHovered) { + decorationAttrs["data-comment-highlight-state"] = "selected"; + } + if (classNames.length > 0) { + decorationAttrs.class = classNames.join(" "); + } + + decorations.push( + Decoration.inline(pos, pos + node.nodeSize, decorationAttrs, { + inclusiveStart: true, + inclusiveEnd: true, + }) + ); + }); + + return true; + }); + + return DecorationSet.create(doc, decorations); +}; + +export const createCommentHighlightPlugin = () => + new Plugin({ + key: commentInteractionPluginKey, + state: { + init: () => ({ + hovered: new Set(), + selected: null, + decorations: DecorationSet.empty, + }), + apply: (tr, value, _oldState, newState) => { + let hovered = value.hovered; + let selected = value.selected; + let decorations = value.decorations; + + const meta = tr.getMeta(commentInteractionPluginKey) as CommentInteractionMeta | undefined; + let shouldRecalculate = tr.docChanged; + + if (meta) { + if (meta.hovered) { + hovered = new Set(meta.hovered.filter((id) => typeof id === "string" && id.length > 0)); + shouldRecalculate = true; + } + if (meta.selected !== undefined) { + selected = typeof meta.selected === "string" && meta.selected.length > 0 ? meta.selected : null; + shouldRecalculate = true; + } + } + + if (shouldRecalculate) { + decorations = buildDecorations(newState.doc, hovered, selected); + } else if (tr.docChanged) { + decorations = decorations.map(tr.mapping, newState.doc); + } + + return { + hovered, + selected, + decorations, + }; + }, + }, + props: { + decorations(state) { + return commentInteractionPluginKey.getState(state)?.decorations ?? DecorationSet.empty; + }, + }, + }); diff --git a/packages/editor/src/ee/extensions/comments/plugins/index.ts b/packages/editor/src/ee/extensions/comments/plugins/index.ts index e0c06c3541..cab1c29747 100644 --- a/packages/editor/src/ee/extensions/comments/plugins/index.ts +++ b/packages/editor/src/ee/extensions/comments/plugins/index.ts @@ -1,5 +1,6 @@ export { createClickHandlerPlugin } from "./click-handler-plugin"; export { createHoverHandlerPlugin } from "./hover-handler-plugin"; +export { createCommentHighlightPlugin, commentInteractionPluginKey } from "./highlight-handler-plugin"; export { createCommentsOrderPlugin } from "./comments-order-plugin"; export { TrackCommentDeletionPlugin } from "./delete"; export { TrackCommentRestorationPlugin } from "./restore"; diff --git a/packages/editor/src/ee/extensions/comments/types.ts b/packages/editor/src/ee/extensions/comments/types.ts index bad5cfb274..604c604782 100644 --- a/packages/editor/src/ee/extensions/comments/types.ts +++ b/packages/editor/src/ee/extensions/comments/types.ts @@ -17,10 +17,16 @@ export type TCommentMarkAttributes = { [ECommentAttributeNames.RESOLVED]?: boolean; }; +export type TCommentClickPayload = { + referenceParagraph: string; + primaryCommentId: string; + commentIds: string[]; +}; + // COMMENT MARK OPTIONS export type TCommentMarkOptions = { isFlagged: boolean; - onCommentClick?: (commentId: string) => void; + onCommentClick?: (payload: TCommentClickPayload) => void; onCommentDelete?: (commentId: string) => void; onCommentRestore?: (commentId: string) => void; onCommentResolve?: (commentId: string) => void; diff --git a/packages/editor/src/ee/extensions/comments/utils.ts b/packages/editor/src/ee/extensions/comments/utils.ts new file mode 100644 index 0000000000..b18bbd8d59 --- /dev/null +++ b/packages/editor/src/ee/extensions/comments/utils.ts @@ -0,0 +1,3 @@ +import { ECommentAttributeNames } from "./types"; + +export const getCommentSelector = (commentId: string) => `[${ECommentAttributeNames.COMMENT_ID}=${commentId}]`; diff --git a/packages/editor/src/ee/extensions/document-extensions.tsx b/packages/editor/src/ee/extensions/document-extensions.tsx index 63fb93b8ac..c284aeb155 100644 --- a/packages/editor/src/ee/extensions/document-extensions.tsx +++ b/packages/editor/src/ee/extensions/document-extensions.tsx @@ -3,6 +3,8 @@ import { FileText, Paperclip } from "lucide-react"; // ce imports // plane imports import { LayersIcon } from "@plane/propel/icons"; +// ce imports +import { TDocumentEditorAdditionalExtensionsProps, TDocumentEditorAdditionalExtensionsRegistry } from "@/ce/extensions"; // extensions import { SlashCommands, TSlashCommandAdditionalOption, WorkItemEmbedExtension } from "@/extensions"; // helpers @@ -11,10 +13,6 @@ import { insertPageEmbed } from "@/helpers/editor-commands"; import { IssueEmbedSuggestions, IssueListRenderer, PageEmbedExtension } from "@/plane-editor/extensions"; // types import { TExtensions } from "@/types"; -import { - TDocumentEditorAdditionalExtensionsProps, - TDocumentEditorAdditionalExtensionsRegistry, -} from "src/ce/extensions"; // local imports import { insertAttachment } from "../helpers/editor-commands"; import { CustomAttachmentExtension } from "./attachments/extension"; diff --git a/packages/editor/src/ee/helpers/extended-editor-ref.ts b/packages/editor/src/ee/helpers/extended-editor-ref.ts index 3685aca8bd..28480d44d3 100644 --- a/packages/editor/src/ee/helpers/extended-editor-ref.ts +++ b/packages/editor/src/ee/helpers/extended-editor-ref.ts @@ -1,4 +1,5 @@ import type { Editor } from "@tiptap/core"; +import { getCommentSelector } from "../extensions/comments"; import { TExtendedEditorRefApi } from "../types"; type TArgs = { @@ -25,5 +26,22 @@ export const getExtenedEditorRefHelpers = (args: TArgs): TExtendedEditorRefApi = if (!editor) return; editor.chain().focus().unresolveComment(commentId).run(); }, + hoverCommentMarks: (commentIds) => { + if (!editor) return; + editor.commands.hoverComments(commentIds); + }, + selectCommentMark: (commentId) => { + if (!editor) return; + editor.commands.selectComment(commentId); + }, + scrollToCommentMark: (commentId) => { + if (!editor || !commentId) return; + const selector = getCommentSelector(commentId); + const element = editor.view.dom.querySelector(selector) as HTMLElement | null; + + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, }; }; diff --git a/packages/editor/src/ee/types/comments.ts b/packages/editor/src/ee/types/comments.ts index 4e5bfc6bbb..c152bc7969 100644 --- a/packages/editor/src/ee/types/comments.ts +++ b/packages/editor/src/ee/types/comments.ts @@ -1,5 +1,9 @@ +import type { TCommentClickPayload } from "../extensions/comments/types"; + +export type { TCommentClickPayload }; + export type TCommentConfig = { - onClick?: (commentId: string) => void; + onClick?: (payload: TCommentClickPayload) => void; onDelete?: (commentId: string) => void; onRestore?: (commentId: string) => void; onResolve?: (commentId: string) => void; diff --git a/packages/editor/src/ee/types/editor-extended.ts b/packages/editor/src/ee/types/editor-extended.ts index 5b7f1209c5..378aab2d29 100644 --- a/packages/editor/src/ee/types/editor-extended.ts +++ b/packages/editor/src/ee/types/editor-extended.ts @@ -45,4 +45,7 @@ export type TExtendedEditorRefApi = { setCommentMark: (params: { commentId: string; from: number; to: number }) => void; resolveCommentMark: (commentId: string) => void; unresolveCommentMark: (commentId: string) => void; + hoverCommentMarks: (commentIds: string[]) => void; + selectCommentMark: (commentId: string | null) => void; + scrollToCommentMark: (commentId: string) => void; }; diff --git a/packages/editor/tsdown.config.ts b/packages/editor/tsdown.config.ts index 348f1fd7a7..5e9a7b960a 100644 --- a/packages/editor/tsdown.config.ts +++ b/packages/editor/tsdown.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ outDir: "dist", format: ["esm", "cjs"], dts: true, - clean: true, + clean: false, sourcemap: true, copy: ["src/styles"], }); diff --git a/packages/utils/src/string.ts b/packages/utils/src/string.ts index 7dfd28daa8..72e48459d1 100644 --- a/packages/utils/src/string.ts +++ b/packages/utils/src/string.ts @@ -425,3 +425,62 @@ export const joinUrlPath = (...segments: string[]): string => { return pathParts.length > 0 ? `/${pathParts.join("/")}` : ""; } }; + +// Utility function to trim empty paragraphs from start and end of JSONContent +export const trimEmptyParagraphsFromJson = (content: JSONContent): JSONContent => { + if (!content?.content) return content; + + const trimmed = [...content.content]; + + // Remove empty paragraphs from the beginning + while (trimmed.length > 0 && isEmptyParagraph(trimmed[0])) { + trimmed.shift(); + } + + // Remove empty paragraphs from the end + while (trimmed.length > 0 && isEmptyParagraph(trimmed[trimmed.length - 1])) { + trimmed.pop(); + } + + // If all content was removed, keep one empty paragraph + if (trimmed.length === 0) { + trimmed.push({ type: "paragraph" }); + } + + return { + ...content, + content: trimmed, + }; +}; + +const isEmptyParagraph = (node: JSONContent): boolean => { + if (node.type !== "paragraph") return false; + if (!node.content || node.content.length === 0) return true; + + // Check if paragraph only contains empty text nodes or whitespace + return node.content.every((child) => { + if (child.type === "text") { + return !child.text || child.text.trim() === ""; + } + return false; + }); +}; + +// Utility function to trim empty paragraphs from HTML content +export const trimEmptyParagraphsFromHTML = (html: string): string => { + if (!html) return "

"; + + // Remove leading and trailing empty paragraphs + const trimmed = html + // Remove empty paragraphs at the start + .replace(/^(\s*]*>\s*<\/p>\s*)*/g, "") + // Remove empty paragraphs at the end + .replace(/(\s*]*>\s*<\/p>\s*)*$/g, ""); + + // If all content was removed, return a single empty paragraph + if (!trimmed.trim()) { + return "

"; + } + + return trimmed; +};