mirror of
https://github.com/makeplane/plane.git
synced 2026-02-24 20:20:49 +01:00
[WIKI-650] fix: page comments bugs (#4189)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -46,6 +46,7 @@ from plane.ee.views.app.page import (
|
||||
ProjectPageCommentViewSet,
|
||||
ProjectPageCommentReactionViewSet,
|
||||
ProjectPageRestoreEndpoint,
|
||||
WorkspacePageLiveServerEndpoint,
|
||||
)
|
||||
from plane.ee.views.app.views import (
|
||||
IssueViewEEViewSet,
|
||||
|
||||
@@ -61,4 +61,5 @@ from plane.ee.views.app.page import (
|
||||
WorkspacePageCommentReactionViewSet,
|
||||
ProjectPageCommentViewSet,
|
||||
ProjectPageCommentReactionViewSet,
|
||||
WorkspacePageLiveServerEndpoint,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from .workspace.share import WorkspacePageUserViewSet
|
||||
from .workspace.comment import (
|
||||
WorkspacePageCommentViewSet,
|
||||
WorkspacePageCommentReactionViewSet,
|
||||
WorkspacePageLiveServerEndpoint,
|
||||
)
|
||||
|
||||
# project level
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type TCommentsNavigationExtensionData = TCommentConfig & {
|
||||
referenceText?: string;
|
||||
};
|
||||
onPendingCommentCancel?: () => void;
|
||||
onSelectedThreadConsumed?: () => void;
|
||||
};
|
||||
|
||||
// EE Union of all possible navigation pane extension data types
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
33
apps/web/core/components/editor/lite-text/lite-toolbar.tsx
Normal file
33
apps/web/core/components/editor/lite-text/lite-toolbar.tsx
Normal 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 };
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
33
apps/web/ee/components/pages/comments/comment-avatar.tsx
Normal file
33
apps/web/ee/components/pages/comments/comment-avatar.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export type TCommentsNavigationExtensionData = TCommentConfig & {
|
||||
referenceText?: string;
|
||||
};
|
||||
onPendingCommentCancel?: () => void;
|
||||
onSelectedThreadConsumed?: () => void;
|
||||
};
|
||||
|
||||
// EE Union of all possible navigation pane extension data types
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }),
|
||||
];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
3
packages/editor/src/ee/extensions/comments/utils.ts
Normal file
3
packages/editor/src/ee/extensions/comments/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ECommentAttributeNames } from "./types";
|
||||
|
||||
export const getCommentSelector = (commentId: string) => `[${ECommentAttributeNames.COMMENT_ID}=${commentId}]`;
|
||||
@@ -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";
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
outDir: "dist",
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
clean: false,
|
||||
sourcemap: true,
|
||||
copy: ["src/styles"],
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user