[WIKI-650] fix: page comments bugs (#4189)

This commit is contained in:
M. Palanikannan
2025-09-18 20:43:51 +05:30
committed by GitHub
parent ecd3fb3ac7
commit 333937947e
79 changed files with 2363 additions and 1250 deletions

View File

@@ -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

View File

@@ -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/<str:slug>/pages/<uuid:page_id>/page-comments/",
WorkspacePageLiveServerEndpoint.as_view({"get": "list"}),
name="workspace-page-live-server",
),
## End Comment Reactions
## EE project level
path(

View File

@@ -46,6 +46,7 @@ from plane.ee.views.app.page import (
ProjectPageCommentViewSet,
ProjectPageCommentReactionViewSet,
ProjectPageRestoreEndpoint,
WorkspacePageLiveServerEndpoint,
)
from plane.ee.views.app.views import (
IssueViewEEViewSet,

View File

@@ -61,4 +61,5 @@ from plane.ee.views.app.page import (
WorkspacePageCommentReactionViewSet,
ProjectPageCommentViewSet,
ProjectPageCommentReactionViewSet,
WorkspacePageLiveServerEndpoint,
)

View File

@@ -14,6 +14,7 @@ from .workspace.share import WorkspacePageUserViewSet
from .workspace.comment import (
WorkspacePageCommentViewSet,
WorkspacePageCommentReactionViewSet,
WorkspacePageLiveServerEndpoint,
)
# project level

View File

@@ -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)

View File

@@ -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<EditorRefApi, LiteTextEditorWrapp
showSubmitButton = true,
isSubmitting = false,
showToolbarInitially = true,
showToolbar = true,
variant = "full",
parentClassName = "",
placeholder = t("issue.comments.placeholder"),
disabledExtensions: additionalDisabledExtensions = [],
@@ -69,7 +70,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
...rest
} = props;
// states
const [isFocused, setIsFocused] = useState(showToolbarInitially);
const isLiteVariant = variant === "lite";
const isFullVariant = variant === "full";
const [isFocused, setIsFocused] = useState(isFullVariant ? showToolbarInitially : true);
// editor flaggings
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
workspaceSlug: workspaceSlug?.toString() ?? "",
@@ -85,9 +88,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
issue_id,
}),
});
// const {
// data: { is_smooth_cursor_enabled },
// } = useUserProfile();
const {
data: { is_smooth_cursor_enabled },
} = useUserProfile();
// editor config
const { getEditorFileHandlers } = useEditorConfig();
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
@@ -101,46 +104,71 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
className={cn(
"relative border border-custom-border-200 rounded",
{
"p-3": editable,
"p-3": editable && !isLiteVariant,
},
parentClassName
)}
onFocus={() => !showToolbarInitially && setIsFocused(true)}
onBlur={() => !showToolbarInitially && setIsFocused(false)}
onFocus={() => isFullVariant && !showToolbarInitially && setIsFocused(true)}
onBlur={() => isFullVariant && !showToolbarInitially && setIsFocused(false)}
>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
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 */}
<div className={cn(isLiteVariant && editable ? "flex items-end gap-1" : "")}>
{/* Main Editor - always rendered once */}
<div className={cn(isLiteVariant && editable ? "flex-1 min-w-0" : "")}>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
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}
/>
</div>
{/* Lite Toolbar - conditionally rendered */}
{isLiteVariant && editable && (
<LiteToolbar
executeCommand={(item) => {
// 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}
/>
)}
</div>
{/* Full Toolbar - conditionally rendered */}
{isFullVariant && editable && (
<div
className={cn(
"transition-all duration-300 ease-out origin-top overflow-hidden",

View File

@@ -0,0 +1,33 @@
import React from "react";
import { ArrowUp, Paperclip } from "lucide-react";
// constants
import { IMAGE_ITEM, ToolbarMenuItem } from "@/constants/editor";
type LiteToolbarProps = {
onSubmit: (e: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>) => void;
isSubmitting: boolean;
isEmpty: boolean;
executeCommand: (item: ToolbarMenuItem) => void;
};
export const LiteToolbar = ({ onSubmit, isSubmitting, isEmpty, executeCommand }: LiteToolbarProps) => (
<div className="flex items-center gap-2 pb-1">
<button
onClick={() => executeCommand(IMAGE_ITEM)}
type="button"
className="p-1 text-custom-text-300 hover:text-custom-text-200 transition-colors"
>
<Paperclip className="size-3" />
</button>
<button
type="button"
onClick={(e) => onSubmit(e)}
disabled={isEmpty || isSubmitting}
className="p-1 bg-custom-primary-100 hover:bg-custom-primary-200 disabled:bg-custom-text-400 disabled:text-custom-text-200 text-custom-text-100 rounded transition-colors"
>
<ArrowUp className="size-3" />
</button>
</div>
);
export type { LiteToolbarProps };

View File

@@ -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 (
<div className="relative size-full overflow-hidden flex transition-all duration-300 ease-in-out">
<div className="size-full flex flex-col overflow-hidden">

View File

@@ -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: {

View File

@@ -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<Record<string, string>> {
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");
}

View File

@@ -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 (
<Avatar
className={cn("shrink-0 rounded-full relative", sizeClasses[size], className)}
size="base"
src={memberDetails?.member.avatar_url ? getFileURL(memberDetails?.member.avatar_url) : undefined}
name={memberDetails?.member.display_name}
/>
);
});

View File

@@ -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 (
<div ref={newCommentBoxRef} className="overflow-hidden my-4 animate-expand-down space-y-3 group px-[4px]">
<div ref={newCommentBoxRef} className="overflow-hidden my-4 animate-expand-down space-y-3 group px-3.5">
{/* Reference Text Quote with Overlay Cancel Button */}
{referenceText && (
<div className="relative flex gap-1 p-[4px] rounded bg-custom-background-90">

View File

@@ -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 (
<div className={cn(`group flex flex-col justify-center items-start gap-1 w-full`, className)}>
{/* Comment Header */}
<div className="flex items-center gap-1 pr-1 relative w-full">
<PageCommentUserInfo userId={comment.created_by} size="sm" timestamp={comment.created_at} />
return (
<div className={cn(`group flex gap-2 min-w-0`, className)}>
{/* Left Column - Avatar */}
<PageCommentAvatar userId={comment.created_by} size="sm" />
{/* Action Buttons - Always Visible */}
<div className="absolute right-0 top-0 flex items-center gap-1">
{/* Right Column - Details + Content */}
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
{/* Header Row - Name/Timestamp + Actions */}
<div className="flex items-baseline justify-between pr-1">
<PageCommentUserDetails userId={comment.created_by} timestamp={comment.created_at} />
{/* Action Buttons */}
<div className="flex items-center gap-1">
{showResolveButton && (
<Tooltip
tooltipContent={comment.is_resolved ? "Mark as unresolved" : "Mark as resolved"}
@@ -160,33 +171,33 @@ export const PageCommentDisplay = observer(
</Tooltip>
)}
<div className="size-5 flex items-center justify-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors">
<CustomMenu
placement="bottom-end"
closeOnSelect
ellipsis
portalElement={document.body}
optionsClassName="z-[60]"
>
{menuItems.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action?.();
}}
className={cn(`flex items-center gap-2`, item.className)}
>
{item.icon && <item.icon className="size-3" />}
{item.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
{hasMenuItems && (
<div className="size-5 flex items-center justify-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors">
<CustomMenu
placement="bottom-end"
closeOnSelect
ellipsis
portalElement={document.body}
optionsClassName="z-[60]"
>
{menuItems.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action?.();
}}
className={cn(`flex items-center gap-2`, item.className)}
>
{item.icon && <item.icon className="size-3" />}
{item.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
)}
</div>
</div>
@@ -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 */}
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeleteConfirm}
isSubmitting={isDeleting}
isOpen={deleteCommentModal}
title="Delete comment"
content={<>Are you sure you want to delete this comment? This action cannot be undone.</>}
/>
</div>
);
}
);
{/* Delete Comment Modal */}
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeleteConfirm}
isSubmitting={isDeleting}
isOpen={deleteCommentModal}
title="Delete comment"
content={<>Are you sure you want to delete this comment? This action cannot be undone.</>}
/>
</div>
);
});

View File

@@ -12,40 +12,47 @@ export type CommentFiltersProps = {
onFilterChange: (filterKey: "showAll" | "showActive" | "showResolved") => void;
};
export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => (
<CustomMenu
customButton={
<div className="flex h-6 items-center border border-custom-border-200 rounded hover:border-custom-border-300 transition-colors">
<div className="flex h-6 px-2 items-center gap-1">
<ListFilter className="size-3 text-custom-text-300" />
<span className="text-custom-text-300 text-[11px] font-medium leading-[14px]">Filters</span>
export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => {
const isFiltersApplied = filters.showActive || filters.showResolved;
return (
<CustomMenu
customButton={
<div className="relative flex h-6 items-center border border-custom-border-200 rounded hover:border-custom-border-300 transition-colors">
<div className="flex h-6 px-2 items-center gap-1">
<ListFilter className="size-3 text-custom-text-300" />
<span className="text-custom-text-300 text-[11px] font-medium leading-[14px]">Filters</span>
</div>
{isFiltersApplied && (
<span className="absolute h-1.5 w-1.5 right-0 top-0 translate-x-1/2 -translate-y-1/2 bg-custom-primary-100 rounded-full" />
)}
</div>
</div>
}
placement="bottom-end"
closeOnSelect={false}
>
<CustomMenu.MenuItem onClick={() => onFilterChange("showAll")} className="flex items-center gap-2">
<Checkbox id="show-all-main" checked={filters.showAll} className="size-3.5 border-custom-border-400" readOnly />
<span className="text-sm">Show all</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showActive")} className="flex items-center gap-2">
<Checkbox
id="show-active-main"
checked={filters.showActive}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show active</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showResolved")} className="flex items-center gap-2">
<Checkbox
id="show-resolved-main"
checked={filters.showResolved}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show resolved</span>
</CustomMenu.MenuItem>
</CustomMenu>
));
}
placement="bottom-end"
closeOnSelect={false}
>
<CustomMenu.MenuItem onClick={() => onFilterChange("showActive")} className="flex items-center gap-2">
<Checkbox
id="show-active-main"
checked={filters.showActive}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show active</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showResolved")} className="flex items-center gap-2">
<Checkbox
id="show-resolved-main"
checked={filters.showResolved}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show resolved</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showAll")} className="flex items-center gap-2">
<Checkbox id="show-all-main" checked={filters.showAll} className="size-3.5 border-custom-border-400" readOnly />
<span className="text-sm">Show all</span>
</CustomMenu.MenuItem>
</CustomMenu>
);
});

View File

@@ -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<EditorRefApi>(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<typeof uploadEditorAsset>[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<TPageComment>) => {
@@ -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)
: "<p></p>";
onSubmit({
description: {
description_html: formData.description.description_html || "<p></p>",
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 (
<div className="relative w-full">
<div
onKeyDown={(e) => {
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" : "")}
>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
editable={editable}
workspaceId={workspaceId}
autofocus
id={
comment
? `edit_comment_${comment.id}`
: (isReply ? "reply_comment_" : "add_comment_") + (pageId || "new")
}
workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => 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" }}
/>
)}
/>
</div>
{/* Custom submit buttons - only show when editing existing comments */}
{comment && editable && (
<div className="flex justify-end gap-1 mt-2 pb-1">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onFormSubmit)}
disabled={isDisabled}
className={cn(
"group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300",
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
)}
>
<Check
className={cn(
"size-2.5 text-green-500 duration-300",
isEmpty ? "text-black" : "group-hover:text-white"
)}
/>
</button>
)}
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={handleCancel}
>
<X className="size-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
<div
onKeyDown={(e) => {
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" : ""
)}
>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
showToolbarInitially={false}
editable={editable}
workspaceId={workspaceId}
autofocus
id={
comment ? `edit_comment_${comment.id}` : (isReply ? "reply_comment_" : "add_comment_") + (pageId || "new")
}
workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => 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" }}
/>
)}
/>
</div>
);
});

View File

@@ -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 && (
<div className="w-full animate-expand-action">
<div className="w-full relative">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-custom-border-300 animate-scale-line" />
<div className="relative flex justify-center">
<button
onClick={handleShowRepliesToggle}
className="bg-custom-background-100 group-hover:bg-custom-background-90 px-3 py-1 text-custom-text-300 hover:text-custom-text-200 transition-colors animate-button-fade-up rounded text-xs font-medium"
>
{showReplies ? "Hide replies" : `Show ${replyCount} ${replyCount === 1 ? "reply" : "replies"}`}
</button>
</div>
</div>
<div className="w-full animate-expand-action mb-4">
<div className="w-full relative">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-custom-border-300 animate-scale-line" />
<div className="relative flex justify-center">
<button
onClick={handleShowRepliesToggle}
className="bg-custom-background-100 group-hover:bg-custom-background-90 px-3 py-1 text-custom-text-300 hover:text-custom-text-200 transition-colors animate-button-fade-up rounded text-xs font-medium"
>
{showReplies
? "Hide replies"
: `Show ${threadState.hiddenRepliesCount} ${threadState.hiddenRepliesCount === 1 ? "reply" : "replies"}`}
</button>
</div>
)}
</>
</div>
</div>
);
}
);

View File

@@ -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 (
<div className={cn("flex items-baseline gap-2 flex-1", className)}>
<div className="text-custom-text-100 text-sm font-medium truncate">{memberDetails?.member.display_name}</div>
{timestamp && <PageCommentTimestampDisplay timestamp={timestamp} />}
</div>
);
});

View File

@@ -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 (
<div className="flex items-center gap-1">
<div className="flex flex-col items-center relative">
<div
className={cn(
"flex items-center gap-2.5 rounded-full relative overflow-hidden",
sizeClasses[size],
className
)}
>
<Avatar
className="flex-1 self-stretch rounded-full object-cover"
size="base"
src={memberDetails?.member.avatar_url ? getFileURL(memberDetails?.member.avatar_url) : undefined}
name={memberDetails?.member.display_name}
/>
</div>
</div>
<div className="flex flex-col justify-center items-start gap-px flex-1">
<div className="text-custom-text-100 text-xs font-medium truncate">{memberDetails?.member.display_name}</div>
{timestamp && <PageCommentTimestampDisplay timestamp={timestamp} />}
</div>
</div>
);
});

View File

@@ -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 (
<div className="h-full flex flex-col items-center justify-center space-y-3 animate-fade-in-up">

View File

@@ -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,
}}
/>
);

View File

@@ -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<HTMLDivElement>(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 <PageCommentThreadLoader />;
@@ -96,10 +104,10 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({
return (
<div
ref={scrollContainerRef}
className="size-full p-3.5 pt-0 overflow-y-auto vertical-scrollbar scrollbar-sm outline-none flex flex-col"
className="size-full pt-0 overflow-y-auto vertical-scrollbar scrollbar-sm outline-none flex flex-col"
>
{/* Header */}
<div className="flex-shrink-0 pb-3">
<div className="flex-shrink-0 py-1 px-3.5">
<div className="flex justify-between items-start w-full">
<h2 className="text-custom-text-100 text-base font-medium leading-6">Comments</h2>
<PageCommentFilterControls filters={commentsFilters} onFilterChange={updateCommentFilters} />
@@ -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 ? (
<PageCommentsEmptyState hasComments={baseComments.length > 0} />
<PageCommentsEmptyState hasComments={baseComments.length > 0} commentFilter={commentsFilters} />
) : (
<PageCommentsThreadList
comments={filteredBaseComments}

View File

@@ -3,30 +3,27 @@ import { Loader } from "@plane/ui";
type PageCommentReplyLoadingSkeletonProps = {
commentReplyCount: number;
};
export const PageCommentReplyLoadingSkeleton = ({ commentReplyCount }: PageCommentReplyLoadingSkeletonProps) => (
<Loader className="space-y-3">
<Loader>
{Array.from({ length: commentReplyCount }, (_, index) => (
<div key={index} className="relative w-full">
{index > 0 && (
<div className="size-6 relative flex items-center justify-center">
<div aria-hidden className="pointer-events-none h-5 w-0.5 bg-custom-border-300" />
</div>
)}
<div key={index} className="relative w-full mb-4">
<div className="space-y-2">
{/* User avatar and timestamp */}
<div className="flex items-center gap-2">
<Loader.Item width="20px" height="20px" />
<div className="rounded-full overflow-hidden">
<Loader.Item width="24px" height="24px" />
</div>
<Loader.Item width={index % 2 === 0 ? "25%" : "30%"} height="12px" />
</div>
{/* Reply content */}
<Loader.Item width={index % 3 === 0 ? "75%" : index % 3 === 1 ? "90%" : "60%"} height="14px" />
<Loader.Item width={index % 2 === 0 ? "45%" : "60%"} height="14px" />
{index % 3 === 1 && <Loader.Item width="35%" height="14px" />}
<div className="pl-8 space-y-1">
<Loader.Item width={index % 3 === 0 ? "75%" : index % 3 === 1 ? "90%" : "60%"} height="14px" />
<Loader.Item width={index % 2 === 0 ? "45%" : "60%"} height="14px" />
{index % 3 === 1 && <Loader.Item width="35%" height="14px" />}
</div>
</div>
</div>
))}
<div className="size-6 relative flex items-center justify-center pb-3">
<div aria-hidden className="pointer-events-none h-5 w-0.5 bg-custom-border-300" />
</div>
</Loader>
);

View File

@@ -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<HTMLDivElement, ThreadItemProps>(function ThreadItem(
{ comment, page, isSelected, referenceText },
ref
) {
React.forwardRef<HTMLDivElement, ThreadItemProps>(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 (
<div
@@ -94,10 +118,7 @@ export const PageThreadCommentItem = observer(
data-thread-id={comment.id}
key={comment.id}
className={cn(
`relative w-full p-3 px-[4px] flex-col flex gap-1 cursor-pointer transition-all duration-200 bg-custom-background-100 hover:bg-custom-background-90 group animate-comment-item`,
{
"bg-custom-background-90": isSelected,
}
`relative w-full py-3 px-3.5 flex-col flex gap-3 cursor-pointer transition-all duration-200 bg-custom-background-100 hover:bg-custom-background-90 group animate-comment-item`
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -111,9 +132,14 @@ export const PageThreadCommentItem = observer(
</div>
)}
{/* Main Thread Comment */}
<div className="overflow-hidden space-y-3">
<PageCommentDisplay comment={comment} page={page} isSelected={isSelected} isParent />
<div className="relative">
{/* 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)) && (
<div className="absolute left-3 top-0 -bottom-4 w-0.5 bg-custom-border-300" aria-hidden />
)}
{/* Main Thread Comment */}
<PageCommentDisplay comment={comment} page={page} isParent />
</div>
<div className="flex flex-col gap-0">
@@ -121,14 +147,20 @@ export const PageThreadCommentItem = observer(
comment={comment}
handleShowRepliesToggle={handleShowRepliesToggle}
showReplies={showReplies}
page={page}
/>
{/* Replies List */}
<PageCommentThreadReplyList page={page} threadId={comment.id} showReplies={showReplies} />
<PageCommentThreadReplyList
page={page}
threadId={comment.id}
showReplies={showReplies}
showReplyBox={showReplyBox}
/>
{/* Action Bar */}
{page.canCurrentUserCommentOnPage && !showReplyBox && (
<div className="flex items-center h-8">
<div className="flex items-center justify-end h-8">
<button
type="button"
onClick={handleReplyToggle}

View File

@@ -2,6 +2,7 @@ import React from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { cn } from "@plane/utils";
import { TPageInstance } from "@/store/pages/base-page";
import { PageCommentDisplay } from "./comment-display";
import { PageCommentReplyLoadingSkeleton } from "./reply-loading-skeleton";
@@ -9,45 +10,51 @@ import { PageCommentReplyLoadingSkeleton } from "./reply-loading-skeleton";
type ThreadRepliesProps = {
threadId: string;
showReplies: boolean;
showReplyBox: boolean;
page: TPageInstance;
};
export const PageCommentThreadReplyList: React.FC<ThreadRepliesProps> = observer(({ threadId, showReplies, page }) => {
const { fetchThreadComments, getCommentsByParentId, getLatestReplyByParentId } = page.comments;
export const PageCommentThreadReplyList: React.FC<ThreadRepliesProps> = observer(
({ threadId, showReplies, showReplyBox, page }) => {
const { fetchThreadComments } = page.comments;
// Only fetch thread comments when showReplies is true (user clicked to expand)
const { isLoading } = useSWR(
showReplies && threadId ? `THREAD-COMMENTS-${threadId}` : null,
async () => {
if (!threadId) return [];
await fetchThreadComments(threadId);
},
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 5000,
}
);
// Get thread display state - single source of truth
const threadState = page.comments.getThreadDisplayState(threadId, showReplies);
const replies = getCommentsByParentId(threadId);
const latestReply = getLatestReplyByParentId(threadId);
const parentComment = page.comments.getCommentById(threadId);
if (!threadState) return null;
const repliesToShow = showReplies ? replies : latestReply ? [latestReply] : [];
// Only fetch thread comments when showReplies is true (user clicked to expand)
const { isLoading, data: dataFromServer } = useSWR(
showReplies && threadId ? `THREAD-COMMENTS-${threadId}` : null,
async () => {
if (!threadId) return [];
return await fetchThreadComments(threadId);
},
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 5000,
}
);
return (
<div className="overflow-hidden animate-expand">
{isLoading && <PageCommentReplyLoadingSkeleton commentReplyCount={(parentComment?.total_replies || 1) - 1} />}
{repliesToShow.map((reply, index) => (
<div key={reply.id} className="relative w-full">
{(index > 0 || parentComment?.total_replies === 1) && (
<div className="size-6 relative flex items-center justify-center">
<div aria-hidden className="pointer-events-none h-5 w-0.5 bg-custom-border-300" />
return (
<div className="overflow-hidden animate-expand relative">
{isLoading && !dataFromServer && (
<PageCommentReplyLoadingSkeleton commentReplyCount={threadState.hiddenRepliesCount} />
)}
{threadState.displayItems.map((item, index, array) => {
const isLastItem = index === array.length - 1;
return (
<div key={item.comment.id} className={cn("relative w-full", !isLastItem && "mb-4")}>
{(!isLastItem || showReplyBox) && (
<div className="absolute left-3 top-0 -bottom-4 w-0.5 bg-custom-border-300" aria-hidden />
)}
<PageCommentDisplay page={page} comment={item.comment} isParent={false} />
</div>
)}
<PageCommentDisplay page={page} comment={reply} isParent={false} />
</div>
))}
</div>
);
});
);
})}
</div>
);
}
);

View File

@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { MessageCircle } from "lucide-react";
import { MessageSquareText } from "lucide-react";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/ui";
// plane web hooks
@@ -36,7 +36,7 @@ export const PageCommentControl: React.FC<TPageCommentControlProps> = observer((
)}
aria-label={isActive ? "Close comments" : "Open comments"}
>
<MessageCircle className="h-3.5 w-3.5" />
<MessageSquareText className="h-3.5 w-3.5" />
</button>
</Tooltip>
);

View File

@@ -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 },
];

View File

@@ -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<number | null>(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,

View File

@@ -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<string, unknown> = useMemo(() => {
const map: Map<string, unknown> = 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,
};
};

View File

@@ -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;

View File

@@ -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<void>;
fetchThreadComments: (threadId: string) => Promise<void>;
fetchThreadComments: (threadId: string) => Promise<TPageComment[]>;
getOrFetchInstance: (
commentId: string,
options?: { restoreOn404?: boolean }
) => Promise<TCommentInstance | undefined>;
createComment: (data: Partial<TPageComment>) => Promise<TCommentInstance>;
deleteComment: (commentId: string) => Promise<void>;
restoreComment: (commentId: string) => Promise<void>;
@@ -52,15 +66,15 @@ export interface ICommentStore {
unresolveComment: (commentId: string) => Promise<void>;
addReaction: (commentId: string, reaction: string) => Promise<TPageCommentReaction>;
removeReaction: (commentId: string, reaction: string) => Promise<void>;
updateComment: (commentId: string, data: Partial<TPageComment>) => Promise<TPageComment>;
updateComment: (commentId: string, data: Partial<TPageComment>) => Promise<void>;
}
export class CommentStore implements ICommentStore {
// observables
comments: Map<string, TCommentInstance> = 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<string, unknown>;
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<TCommentInstance | undefined> => {
// 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<void> => {
const { pageId, config } = this.getPageContext();
@@ -276,8 +407,8 @@ export class CommentStore implements ICommentStore {
}
};
fetchThreadComments = async (threadId: string): Promise<void> => {
if (!threadId) return;
fetchThreadComments = async (threadId: string): Promise<TPageComment[]> => {
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<TPageComment>): Promise<TPageComment> => {
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<TPageComment>): Promise<void> => {
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;
});
};
}

View File

@@ -12,6 +12,7 @@ export type TCommentsNavigationExtensionData = TCommentConfig & {
referenceText?: string;
};
onPendingCommentCancel?: () => void;
onSelectedThreadConsumed?: () => void;
};
// EE Union of all possible navigation pane extension data types

View File

@@ -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);
},
{

View File

@@ -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,
};
};

View File

@@ -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<EditorRefApi, LiteTextEditorWrapp
showSubmitButton = true,
isSubmitting = false,
showToolbarInitially = true,
showToolbar = true,
variant = "full",
parentClassName = "",
placeholder = t("issue.comments.placeholder"),
disabledExtensions: additionalDisabledExtensions = [],
@@ -69,7 +70,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
...rest
} = props;
// states
const [isFocused, setIsFocused] = useState(showToolbarInitially);
const isLiteVariant = variant === "lite";
const isFullVariant = variant === "full";
const [isFocused, setIsFocused] = useState(isFullVariant ? showToolbarInitially : true);
// editor flaggings
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
workspaceSlug: workspaceSlug?.toString() ?? "",
@@ -101,46 +104,71 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
className={cn(
"relative border border-custom-border-200 rounded",
{
"p-3": editable,
"p-3": editable && !isLiteVariant,
},
parentClassName
)}
onFocus={() => !showToolbarInitially && setIsFocused(true)}
onBlur={() => !showToolbarInitially && setIsFocused(false)}
onFocus={() => isFullVariant && !showToolbarInitially && setIsFocused(true)}
onBlur={() => isFullVariant && !showToolbarInitially && setIsFocused(false)}
>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
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 */}
<div className={cn(isLiteVariant && editable ? "flex items-end gap-1" : "")}>
{/* Main Editor - always rendered once */}
<div className={cn(isLiteVariant && editable ? "flex-1 min-w-0" : "")}>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
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}
/>
</div>
{/* Lite Toolbar - conditionally rendered */}
{isLiteVariant && editable && (
<LiteToolbar
executeCommand={(item) => {
// 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}
/>
)}
</div>
{/* Full Toolbar - conditionally rendered */}
{isFullVariant && editable && (
<div
className={cn(
"transition-all duration-300 ease-out origin-top overflow-hidden",

View File

@@ -0,0 +1,33 @@
import React from "react";
import { ArrowUp, Paperclip } from "lucide-react";
// constants
import { IMAGE_ITEM, ToolbarMenuItem } from "@/constants/editor";
type LiteToolbarProps = {
onSubmit: (e: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>) => void;
isSubmitting: boolean;
isEmpty: boolean;
executeCommand: (item: ToolbarMenuItem) => void;
};
export const LiteToolbar = ({ onSubmit, isSubmitting, isEmpty, executeCommand }: LiteToolbarProps) => (
<div className="flex items-center gap-2 pb-1">
<button
onClick={() => executeCommand(IMAGE_ITEM)}
type="button"
className="p-1 text-custom-text-300 hover:text-custom-text-200 transition-colors"
>
<Paperclip className="size-3" />
</button>
<button
type="button"
onClick={(e) => onSubmit(e)}
disabled={isEmpty || isSubmitting}
className="p-1 bg-custom-primary-100 hover:bg-custom-primary-200 disabled:bg-custom-text-400 disabled:text-custom-text-200 text-custom-text-100 rounded transition-colors"
>
<ArrowUp className="size-3" />
</button>
</div>
);
export type { LiteToolbarProps };

View File

@@ -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 (
<div className="relative size-full overflow-hidden flex transition-all duration-300 ease-in-out">
<div className="size-full flex flex-col overflow-hidden">

View File

@@ -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: {

View File

@@ -35,7 +35,7 @@ export const AutomationDetailsMainContentAddCommentBlock: React.FC<TProps> = obs
initialValue={config.comment_text ?? "<p></p>"}
parentClassName="p-0"
showSubmitButton={false}
showToolbar={false}
variant="none"
workspaceId={workspaceId}
workspaceSlug={workspaceSlug}
/>

View File

@@ -40,7 +40,7 @@ export const AutomationActionAddCommentConfiguration: React.FC<TProps> = observe
onChange={(_json, html) => onChange(html)}
parentClassName="p-2" // TODO: add background if disabled
editable={!isDisabled}
showToolbar={!isDisabled}
variant={isDisabled ? "none" : "full"}
/>
)}
/>

View File

@@ -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 (
<Avatar
className={cn("shrink-0 rounded-full relative", sizeClasses[size], className)}
size="base"
src={memberDetails?.member.avatar_url ? getFileURL(memberDetails?.member.avatar_url) : undefined}
name={memberDetails?.member.display_name}
/>
);
});

View File

@@ -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 (
<div ref={newCommentBoxRef} className="overflow-hidden my-4 animate-expand-down space-y-3 group px-[4px]">
<div ref={newCommentBoxRef} className="overflow-hidden my-4 animate-expand-down space-y-3 group px-3.5">
{/* Reference Text Quote with Overlay Cancel Button */}
{referenceText && (
<div className="relative flex gap-1 p-[4px] rounded bg-custom-background-90">

View File

@@ -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 (
<div className={cn(`group flex flex-col justify-center items-start gap-1 w-full`, className)}>
{/* Comment Header */}
<div className="flex items-center gap-1 pr-1 relative w-full">
<PageCommentUserInfo userId={comment.created_by} size="sm" timestamp={comment.created_at} />
return (
<div className={cn(`group flex gap-2 min-w-0`, className)}>
{/* Left Column - Avatar */}
<PageCommentAvatar userId={comment.created_by} size="sm" />
{/* Action Buttons - Always Visible */}
<div className="absolute right-0 top-0 flex items-center gap-1">
{/* Right Column - Details + Content */}
<div className="flex-1 min-w-0 flex flex-col gap-1.5">
{/* Header Row - Name/Timestamp + Actions */}
<div className="flex items-baseline justify-between pr-1">
<PageCommentUserDetails userId={comment.created_by} timestamp={comment.created_at} />
{/* Action Buttons */}
<div className="flex items-center gap-1">
{showResolveButton && (
<Tooltip
tooltipContent={comment.is_resolved ? "Mark as unresolved" : "Mark as resolved"}
@@ -160,33 +171,33 @@ export const PageCommentDisplay = observer(
</Tooltip>
)}
<div className="size-5 flex items-center justify-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors">
<CustomMenu
placement="bottom-end"
closeOnSelect
ellipsis
portalElement={document.body}
optionsClassName="z-[60]"
>
{menuItems.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
item.action?.();
}}
className={cn(`flex items-center gap-2`, item.className)}
>
{item.icon && <item.icon className="size-3" />}
{item.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
{hasMenuItems && (
<div className="size-5 flex items-center justify-center rounded text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-80 transition-colors">
<CustomMenu
placement="bottom-end"
closeOnSelect
ellipsis
portalElement={document.body}
optionsClassName="z-[60]"
>
{menuItems.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action?.();
}}
className={cn(`flex items-center gap-2`, item.className)}
>
{item.icon && <item.icon className="size-3" />}
{item.title}
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</div>
)}
</div>
</div>
@@ -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 */}
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeleteConfirm}
isSubmitting={isDeleting}
isOpen={deleteCommentModal}
title="Delete comment"
content={<>Are you sure you want to delete this comment? This action cannot be undone.</>}
/>
</div>
);
}
);
{/* Delete Comment Modal */}
<AlertModalCore
handleClose={handleClose}
handleSubmit={handleDeleteConfirm}
isSubmitting={isDeleting}
isOpen={deleteCommentModal}
title="Delete comment"
content={<>Are you sure you want to delete this comment? This action cannot be undone.</>}
/>
</div>
);
});

View File

@@ -12,40 +12,47 @@ export type CommentFiltersProps = {
onFilterChange: (filterKey: "showAll" | "showActive" | "showResolved") => void;
};
export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => (
<CustomMenu
customButton={
<div className="flex h-6 items-center border border-custom-border-200 rounded hover:border-custom-border-300 transition-colors">
<div className="flex h-6 px-2 items-center gap-1">
<ListFilter className="size-3 text-custom-text-300" />
<span className="text-custom-text-300 text-[11px] font-medium leading-[14px]">Filters</span>
export const PageCommentFilterControls = observer(({ filters, onFilterChange }: CommentFiltersProps) => {
const isFiltersApplied = filters.showActive || filters.showResolved;
return (
<CustomMenu
customButton={
<div className="relative flex h-6 items-center border border-custom-border-200 rounded hover:border-custom-border-300 transition-colors">
<div className="flex h-6 px-2 items-center gap-1">
<ListFilter className="size-3 text-custom-text-300" />
<span className="text-custom-text-300 text-[11px] font-medium leading-[14px]">Filters</span>
</div>
{isFiltersApplied && (
<span className="absolute h-1.5 w-1.5 right-0 top-0 translate-x-1/2 -translate-y-1/2 bg-custom-primary-100 rounded-full" />
)}
</div>
</div>
}
placement="bottom-end"
closeOnSelect={false}
>
<CustomMenu.MenuItem onClick={() => onFilterChange("showAll")} className="flex items-center gap-2">
<Checkbox id="show-all-main" checked={filters.showAll} className="size-3.5 border-custom-border-400" readOnly />
<span className="text-sm">Show all</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showActive")} className="flex items-center gap-2">
<Checkbox
id="show-active-main"
checked={filters.showActive}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show active</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showResolved")} className="flex items-center gap-2">
<Checkbox
id="show-resolved-main"
checked={filters.showResolved}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show resolved</span>
</CustomMenu.MenuItem>
</CustomMenu>
));
}
placement="bottom-end"
closeOnSelect={false}
>
<CustomMenu.MenuItem onClick={() => onFilterChange("showActive")} className="flex items-center gap-2">
<Checkbox
id="show-active-main"
checked={filters.showActive}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show active</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showResolved")} className="flex items-center gap-2">
<Checkbox
id="show-resolved-main"
checked={filters.showResolved}
className="size-3.5 border-custom-border-400"
readOnly
/>
<span className="text-sm">Show resolved</span>
</CustomMenu.MenuItem>
<CustomMenu.MenuItem onClick={() => onFilterChange("showAll")} className="flex items-center gap-2">
<Checkbox id="show-all-main" checked={filters.showAll} className="size-3.5 border-custom-border-400" readOnly />
<span className="text-sm">Show all</span>
</CustomMenu.MenuItem>
</CustomMenu>
);
});

View File

@@ -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<EditorRefApi>(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<typeof uploadEditorAsset>[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<TPageComment>) => {
@@ -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)
: "<p></p>";
onSubmit({
description: {
description_html: formData.description.description_html || "<p></p>",
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 (
<div className="relative w-full">
<div
onKeyDown={(e) => {
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" : "")}
>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
editable={editable}
workspaceId={workspaceId}
autofocus
id={
comment
? `edit_comment_${comment.id}`
: (isReply ? "reply_comment_" : "add_comment_") + (pageId || "new")
}
workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => 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" }}
/>
)}
/>
</div>
{/* Custom submit buttons - only show when editing existing comments */}
{comment && editable && (
<div className="flex justify-end gap-1 mt-2 pb-1">
{!isEmpty && (
<button
type="button"
onClick={handleSubmit(onFormSubmit)}
disabled={isDisabled}
className={cn(
"group rounded border border-green-500 bg-green-500/20 p-2 shadow-md duration-300",
isEmpty ? "cursor-not-allowed bg-gray-200" : "hover:bg-green-500"
)}
>
<Check
className={cn(
"size-2.5 text-green-500 duration-300",
isEmpty ? "text-black" : "group-hover:text-white"
)}
/>
</button>
)}
<button
type="button"
className="group rounded border border-red-500 bg-red-500/20 p-2 shadow-md duration-300 hover:bg-red-500"
onClick={handleCancel}
>
<X className="size-3 text-red-500 duration-300 group-hover:text-white" />
</button>
</div>
<div
onKeyDown={(e) => {
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" : ""
)}
>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<LiteTextEditor
showToolbarInitially={false}
editable={editable}
workspaceId={workspaceId}
autofocus
id={
comment ? `edit_comment_${comment.id}` : (isReply ? "reply_comment_" : "add_comment_") + (pageId || "new")
}
workspaceSlug={workspaceSlug}
onEnterKeyPress={(e) => 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" }}
/>
)}
/>
</div>
);
});

View File

@@ -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 && (
<div className="w-full animate-expand-action">
<div className="w-full relative">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-custom-border-300 animate-scale-line" />
<div className="relative flex justify-center">
<button
onClick={handleShowRepliesToggle}
className="bg-custom-background-100 group-hover:bg-custom-background-90 px-3 py-1 text-custom-text-300 hover:text-custom-text-200 transition-colors animate-button-fade-up rounded text-xs font-medium"
>
{showReplies ? "Hide replies" : `Show ${replyCount} ${replyCount === 1 ? "reply" : "replies"}`}
</button>
</div>
</div>
<div className="w-full animate-expand-action mb-4">
<div className="w-full relative">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-custom-border-300 animate-scale-line" />
<div className="relative flex justify-center">
<button
onClick={handleShowRepliesToggle}
className="bg-custom-background-100 group-hover:bg-custom-background-90 px-3 py-1 text-custom-text-300 hover:text-custom-text-200 transition-colors animate-button-fade-up rounded text-xs font-medium"
>
{showReplies
? "Hide replies"
: `Show ${threadState.hiddenRepliesCount} ${threadState.hiddenRepliesCount === 1 ? "reply" : "replies"}`}
</button>
</div>
)}
</>
</div>
</div>
);
}
);

View File

@@ -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 (
<div className={cn("flex items-baseline gap-2 flex-1", className)}>
<div className="text-custom-text-100 text-sm font-medium truncate">{memberDetails?.member.display_name}</div>
{timestamp && <PageCommentTimestampDisplay timestamp={timestamp} />}
</div>
);
});

View File

@@ -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 (
<div className="flex items-center gap-1">
<div className="flex flex-col items-center relative">
<div
className={cn(
"flex items-center gap-2.5 rounded-full relative overflow-hidden",
sizeClasses[size],
className
)}
>
<Avatar
className="flex-1 self-stretch rounded-full object-cover"
size="base"
src={memberDetails?.member.avatar_url ? getFileURL(memberDetails?.member.avatar_url) : undefined}
name={memberDetails?.member.display_name}
/>
</div>
</div>
<div className="flex flex-col justify-center items-start gap-px flex-1">
<div className="text-custom-text-100 text-xs font-medium truncate">{memberDetails?.member.display_name}</div>
{timestamp && <PageCommentTimestampDisplay timestamp={timestamp} />}
</div>
</div>
);
});

View File

@@ -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 (
<div className="h-full flex flex-col items-center justify-center space-y-3 animate-fade-in-up">

View File

@@ -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,
}}
/>
);

View File

@@ -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<HTMLDivElement>(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 <PageCommentThreadLoader />;
@@ -96,10 +104,10 @@ export const PageCommentsSidebarPanel = observer(function ThreadsSidebar({
return (
<div
ref={scrollContainerRef}
className="size-full p-3.5 pt-0 overflow-y-auto vertical-scrollbar scrollbar-sm outline-none flex flex-col"
className="size-full pt-0 overflow-y-auto vertical-scrollbar scrollbar-sm outline-none flex flex-col"
>
{/* Header */}
<div className="flex-shrink-0 pb-3">
<div className="flex-shrink-0 py-1 px-3.5">
<div className="flex justify-between items-start w-full">
<h2 className="text-custom-text-100 text-base font-medium leading-6">Comments</h2>
<PageCommentFilterControls filters={commentsFilters} onFilterChange={updateCommentFilters} />
@@ -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 ? (
<PageCommentsEmptyState hasComments={baseComments.length > 0} />
<PageCommentsEmptyState hasComments={baseComments.length > 0} commentFilter={commentsFilters} />
) : (
<PageCommentsThreadList
comments={filteredBaseComments}

View File

@@ -3,30 +3,27 @@ import { Loader } from "@plane/ui";
type PageCommentReplyLoadingSkeletonProps = {
commentReplyCount: number;
};
export const PageCommentReplyLoadingSkeleton = ({ commentReplyCount }: PageCommentReplyLoadingSkeletonProps) => (
<Loader className="space-y-3">
<Loader>
{Array.from({ length: commentReplyCount }, (_, index) => (
<div key={index} className="relative w-full">
{index > 0 && (
<div className="size-6 relative flex items-center justify-center">
<div aria-hidden className="pointer-events-none h-5 w-0.5 bg-custom-border-300" />
</div>
)}
<div key={index} className="relative w-full mb-4">
<div className="space-y-2">
{/* User avatar and timestamp */}
<div className="flex items-center gap-2">
<Loader.Item width="20px" height="20px" />
<div className="rounded-full overflow-hidden">
<Loader.Item width="24px" height="24px" />
</div>
<Loader.Item width={index % 2 === 0 ? "25%" : "30%"} height="12px" />
</div>
{/* Reply content */}
<Loader.Item width={index % 3 === 0 ? "75%" : index % 3 === 1 ? "90%" : "60%"} height="14px" />
<Loader.Item width={index % 2 === 0 ? "45%" : "60%"} height="14px" />
{index % 3 === 1 && <Loader.Item width="35%" height="14px" />}
<div className="pl-8 space-y-1">
<Loader.Item width={index % 3 === 0 ? "75%" : index % 3 === 1 ? "90%" : "60%"} height="14px" />
<Loader.Item width={index % 2 === 0 ? "45%" : "60%"} height="14px" />
{index % 3 === 1 && <Loader.Item width="35%" height="14px" />}
</div>
</div>
</div>
))}
<div className="size-6 relative flex items-center justify-center pb-3">
<div aria-hidden className="pointer-events-none h-5 w-0.5 bg-custom-border-300" />
</div>
</Loader>
);

View File

@@ -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<HTMLDivElement, ThreadItemProps>(function ThreadItem(
{ comment, page, isSelected, referenceText },
ref
) {
React.forwardRef<HTMLDivElement, ThreadItemProps>(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 (
<div
@@ -94,10 +118,7 @@ export const PageThreadCommentItem = observer(
data-thread-id={comment.id}
key={comment.id}
className={cn(
`relative w-full p-3 px-[4px] flex-col flex gap-1 cursor-pointer transition-all duration-200 bg-custom-background-100 hover:bg-custom-background-90 group animate-comment-item`,
{
"bg-custom-background-90": isSelected,
}
`relative w-full py-3 px-3.5 flex-col flex gap-3 cursor-pointer transition-all duration-200 bg-custom-background-100 hover:bg-custom-background-90 group animate-comment-item`
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
@@ -111,9 +132,14 @@ export const PageThreadCommentItem = observer(
</div>
)}
{/* Main Thread Comment */}
<div className="overflow-hidden space-y-3">
<PageCommentDisplay comment={comment} page={page} isSelected={isSelected} isParent />
<div className="relative">
{/* 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)) && (
<div className="absolute left-3 top-0 -bottom-4 w-0.5 bg-custom-border-300" aria-hidden />
)}
{/* Main Thread Comment */}
<PageCommentDisplay comment={comment} page={page} isParent />
</div>
<div className="flex flex-col gap-0">
@@ -121,14 +147,20 @@ export const PageThreadCommentItem = observer(
comment={comment}
handleShowRepliesToggle={handleShowRepliesToggle}
showReplies={showReplies}
page={page}
/>
{/* Replies List */}
<PageCommentThreadReplyList page={page} threadId={comment.id} showReplies={showReplies} />
<PageCommentThreadReplyList
page={page}
threadId={comment.id}
showReplies={showReplies}
showReplyBox={showReplyBox}
/>
{/* Action Bar */}
{page.canCurrentUserCommentOnPage && !showReplyBox && (
<div className="flex items-center h-8">
<div className="flex items-center justify-end h-8">
<button
type="button"
onClick={handleReplyToggle}

View File

@@ -20,6 +20,7 @@ export const PageCommentThreadLoader = () => (
<Loader className="space-y-4">
{/* Comment Thread 1 */}
<div className="space-y-3 p-1.5 border-b border-custom-border-200">
{/* Reference text quote skeleton */}
<div className="flex gap-1 p-[4px] rounded bg-custom-background-90">
<Loader.Item width="2px" height="16px" />
<Loader.Item width="85%" height="12px" />

View File

@@ -2,6 +2,7 @@ import React from "react";
import { observer } from "mobx-react";
import useSWR from "swr";
import { cn } from "@plane/utils";
import { TPageInstance } from "@/store/pages/base-page";
import { PageCommentDisplay } from "./comment-display";
import { PageCommentReplyLoadingSkeleton } from "./reply-loading-skeleton";
@@ -9,45 +10,51 @@ import { PageCommentReplyLoadingSkeleton } from "./reply-loading-skeleton";
type ThreadRepliesProps = {
threadId: string;
showReplies: boolean;
showReplyBox: boolean;
page: TPageInstance;
};
export const PageCommentThreadReplyList: React.FC<ThreadRepliesProps> = observer(({ threadId, showReplies, page }) => {
const { fetchThreadComments, getCommentsByParentId, getLatestReplyByParentId } = page.comments;
export const PageCommentThreadReplyList: React.FC<ThreadRepliesProps> = observer(
({ threadId, showReplies, showReplyBox, page }) => {
const { fetchThreadComments } = page.comments;
// Only fetch thread comments when showReplies is true (user clicked to expand)
const { isLoading } = useSWR(
showReplies && threadId ? `THREAD-COMMENTS-${threadId}` : null,
async () => {
if (!threadId) return [];
await fetchThreadComments(threadId);
},
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 5000,
}
);
// Get thread display state - single source of truth
const threadState = page.comments.getThreadDisplayState(threadId, showReplies);
const replies = getCommentsByParentId(threadId);
const latestReply = getLatestReplyByParentId(threadId);
const parentComment = page.comments.getCommentById(threadId);
if (!threadState) return null;
const repliesToShow = showReplies ? replies : latestReply ? [latestReply] : [];
// Only fetch thread comments when showReplies is true (user clicked to expand)
const { isLoading, data: dataFromServer } = useSWR(
showReplies && threadId ? `THREAD-COMMENTS-${threadId}` : null,
async () => {
if (!threadId) return [];
return await fetchThreadComments(threadId);
},
{
revalidateOnFocus: true,
revalidateOnReconnect: true,
dedupingInterval: 5000,
}
);
return (
<div className="overflow-hidden animate-expand">
{isLoading && <PageCommentReplyLoadingSkeleton commentReplyCount={(parentComment?.total_replies || 1) - 1} />}
{repliesToShow.map((reply, index) => (
<div key={reply.id} className="relative w-full">
{(index > 0 || parentComment?.total_replies === 1) && (
<div className="size-6 relative flex items-center justify-center">
<div aria-hidden className="pointer-events-none h-5 w-0.5 bg-custom-border-300" />
return (
<div className="overflow-hidden animate-expand relative">
{isLoading && !dataFromServer && (
<PageCommentReplyLoadingSkeleton commentReplyCount={threadState.hiddenRepliesCount} />
)}
{threadState.displayItems.map((item, index, array) => {
const isLastItem = index === array.length - 1;
return (
<div key={item.comment.id} className={cn("relative w-full", !isLastItem && "mb-4")}>
{(!isLastItem || showReplyBox) && (
<div className="absolute left-3 top-0 -bottom-4 w-0.5 bg-custom-border-300" aria-hidden />
)}
<PageCommentDisplay page={page} comment={item.comment} isParent={false} />
</div>
)}
<PageCommentDisplay page={page} comment={reply} isParent={false} />
</div>
))}
</div>
);
});
);
})}
</div>
);
}
);

View File

@@ -2,7 +2,7 @@
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { MessageCircle } from "lucide-react";
import { MessageSquareText } from "lucide-react";
import { Tooltip } from "@plane/propel/tooltip";
import { cn } from "@plane/ui";
// plane web hooks
@@ -36,7 +36,7 @@ export const PageCommentControl: React.FC<TPageCommentControlProps> = observer((
)}
aria-label={isActive ? "Close comments" : "Open comments"}
>
<MessageCircle className="h-3.5 w-3.5" />
<MessageSquareText className="h-3.5 w-3.5" />
</button>
</Tooltip>
);

View File

@@ -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 },
];

View File

@@ -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<number | null>(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 {

View File

@@ -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<string, unknown> = useMemo(() => {
const map: Map<string, unknown> = 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,
};
};

View File

@@ -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;

View File

@@ -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<void>;
fetchThreadComments: (threadId: string) => Promise<void>;
fetchThreadComments: (threadId: string) => Promise<TPageComment[]>;
getOrFetchInstance: (
commentId: string,
options?: { restoreOn404?: boolean }
) => Promise<TCommentInstance | undefined>;
createComment: (data: Partial<TPageComment>) => Promise<TCommentInstance>;
deleteComment: (commentId: string) => Promise<void>;
restoreComment: (commentId: string) => Promise<void>;
@@ -52,15 +66,15 @@ export interface ICommentStore {
unresolveComment: (commentId: string) => Promise<void>;
addReaction: (commentId: string, reaction: string) => Promise<TPageCommentReaction>;
removeReaction: (commentId: string, reaction: string) => Promise<void>;
updateComment: (commentId: string, data: Partial<TPageComment>) => Promise<TPageComment>;
updateComment: (commentId: string, data: Partial<TPageComment>) => Promise<void>;
}
export class CommentStore implements ICommentStore {
// observables
comments: Map<string, TCommentInstance> = 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<string, unknown>;
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<TCommentInstance | undefined> => {
// 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<void> => {
const { pageId, config } = this.getPageContext();
@@ -276,8 +407,8 @@ export class CommentStore implements ICommentStore {
}
};
fetchThreadComments = async (threadId: string): Promise<void> => {
if (!threadId) return;
fetchThreadComments = async (threadId: string): Promise<TPageComment[]> => {
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<TPageComment>): Promise<TPageComment> => {
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<TPageComment>): Promise<void> => {
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;
});
};
}

View File

@@ -12,6 +12,7 @@ export type TCommentsNavigationExtensionData = TCommentConfig & {
referenceText?: string;
};
onPendingCommentCancel?: () => void;
onSelectedThreadConsumed?: () => void;
};
// EE Union of all possible navigation pane extension data types

View File

@@ -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 })

View File

@@ -6,6 +6,27 @@ type TArgs = {
editorClassName: string;
};
const stripCommentMarksFromHTML = (html: string): string => {
const sanitizedHtml = html.replace(/<img.*?>/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(/<img.*?>/g, "");
return stripCommentMarksFromHTML(html);
},
};
};

View File

@@ -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<RawCommands> =>
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;
},
});

View File

@@ -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<TCommentMarkOptions, TComment
const plugins = [
// Click handler plugin
createClickHandlerPlugin({ onCommentClick }),
createClickHandlerPlugin({
onCommentClick,
isTouchDevice: getExtensionStorage(this.editor, CORE_EXTENSIONS.UTILITY).isTouchDevice,
}),
// Hover handler plugin
createHoverHandlerPlugin(),
// Highlight handler plugin for comment mark decorations
createCommentHighlightPlugin(),
// Comments order tracking plugin
createCommentsOrderPlugin({ storage: this.storage }),
];

View File

@@ -1,2 +1,9 @@
export { CommentsExtension } from "./extension";
export type { TCommentMarkAttributes, TCommentMarkOptions, TCommentMarkStorage, ECommentAttributeNames } from "./types";
export type {
TCommentMarkAttributes,
TCommentMarkOptions,
TCommentMarkStorage,
ECommentAttributeNames,
TCommentClickPayload,
} from "./types";
export * from "./utils";

View File

@@ -1,30 +1,64 @@
import type { Mark, MarkType } from "@tiptap/pm/model";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
// local imports
import { COMMENT_MARK_SELECTORS, ECommentAttributeNames } from "../types";
import { ADDITIONAL_EXTENSIONS } from "../../../constants/extensions";
import { COMMENT_MARK_SELECTORS, ECommentAttributeNames, type TCommentClickPayload } from "../types";
export type TClickHandlerPluginOptions = {
onCommentClick?: (commentId: string) => 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<string>([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 ?? "<p></p>";
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<string>();
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<string>, 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<string>, view: EditorView, position?: number | null) {
if (typeof position !== "number") {
return;
}
addCommentIdsFromRange(commentIds, view, position, position);
}

View File

@@ -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<string>();
const newCommentIds = new Set<string>();

View File

@@ -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<string>;
selected: string | null;
decorations: DecorationSet;
};
type CommentInteractionMeta = {
hovered?: string[];
selected?: string | null;
};
export const commentInteractionPluginKey = new PluginKey<CommentInteractionState>("commentInteraction");
const buildDecorations = (
doc: Parameters<typeof DecorationSet.create>[0],
hovered: Set<string>,
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<string, string> = {
"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<CommentInteractionState>({
key: commentInteractionPluginKey,
state: {
init: () => ({
hovered: new Set<string>(),
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;
},
},
});

View File

@@ -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";

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import { ECommentAttributeNames } from "./types";
export const getCommentSelector = (commentId: string) => `[${ECommentAttributeNames.COMMENT_ID}=${commentId}]`;

View File

@@ -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";

View File

@@ -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" });
}
},
};
};

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -5,7 +5,7 @@ export default defineConfig({
outDir: "dist",
format: ["esm", "cjs"],
dts: true,
clean: true,
clean: false,
sourcemap: true,
copy: ["src/styles"],
});

View File

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