feat: init
This commit is contained in:
86
apps/web/core/components/editor/document/editor.tsx
Normal file
86
apps/web/core/components/editor/document/editor.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { forwardRef } from "react";
|
||||
// plane imports
|
||||
import { DocumentEditorWithRef } from "@plane/editor";
|
||||
import type { IEditorPropsExtended, EditorRefApi, IDocumentEditorProps, TFileHandler } from "@plane/editor";
|
||||
import type { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
// local imports
|
||||
import { EditorMentionsRoot } from "../embeds/mentions";
|
||||
|
||||
type DocumentEditorWrapperProps = MakeOptional<
|
||||
Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "user" | "extendedEditorProps">,
|
||||
"disabledExtensions" | "editable" | "flaggedExtensions"
|
||||
> & {
|
||||
extendedEditorProps?: Partial<IEditorPropsExtended>;
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
projectId?: string;
|
||||
} & (
|
||||
| {
|
||||
editable: false;
|
||||
}
|
||||
| {
|
||||
editable: true;
|
||||
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
uploadFile: TFileHandler["upload"];
|
||||
}
|
||||
);
|
||||
|
||||
export const DocumentEditor = forwardRef<EditorRefApi, DocumentEditorWrapperProps>((props, ref) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editable,
|
||||
extendedEditorProps,
|
||||
workspaceSlug,
|
||||
workspaceId,
|
||||
projectId,
|
||||
disabledExtensions: additionalDisabledExtensions = [],
|
||||
...rest
|
||||
} = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// editor flaggings
|
||||
const { document: documentEditorExtensions } = useEditorFlagging({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
});
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),
|
||||
});
|
||||
// editor config
|
||||
const { getEditorFileHandlers } = useEditorConfig();
|
||||
|
||||
return (
|
||||
<DocumentEditorWithRef
|
||||
ref={ref}
|
||||
disabledExtensions={[...documentEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}
|
||||
editable={editable}
|
||||
flaggedExtensions={documentEditorExtensions.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: string) => ({ display_name: getUserDetails(id)?.display_name ?? "" }),
|
||||
}}
|
||||
extendedEditorProps={extendedEditorProps}
|
||||
{...rest}
|
||||
containerClassName={cn("relative pl-3 pb-3", containerClassName)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DocumentEditor.displayName = "DocumentEditor";
|
||||
1
apps/web/core/components/editor/embeds/mentions/index.ts
Normal file
1
apps/web/core/components/editor/embeds/mentions/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
17
apps/web/core/components/editor/embeds/mentions/root.tsx
Normal file
17
apps/web/core/components/editor/embeds/mentions/root.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
// plane editor
|
||||
import type { TMentionComponentProps } from "@plane/editor";
|
||||
// plane web components
|
||||
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
|
||||
// local components
|
||||
import { EditorUserMention } from "./user";
|
||||
|
||||
export const EditorMentionsRoot: React.FC<TMentionComponentProps> = (props) => {
|
||||
const { entity_identifier, entity_name } = props;
|
||||
|
||||
switch (entity_name) {
|
||||
case "user_mention":
|
||||
return <EditorUserMention id={entity_identifier} />;
|
||||
default:
|
||||
return <EditorAdditionalMentionsRoot {...props} />;
|
||||
}
|
||||
};
|
||||
99
apps/web/core/components/editor/embeds/mentions/user.tsx
Normal file
99
apps/web/core/components/editor/embeds/mentions/user.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
// plane imports
|
||||
import { ROLE } from "@plane/constants";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { cn, getFileURL } from "@plane/utils";
|
||||
// constants
|
||||
// helpers
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const EditorUserMention: React.FC<Props> = observer((props) => {
|
||||
const { id } = props;
|
||||
// router
|
||||
const { projectId } = useParams();
|
||||
// states
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLAnchorElement | null>(null);
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberDetails },
|
||||
} = useMember();
|
||||
// popper-js refs
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// derived values
|
||||
const userDetails = getUserDetails(id);
|
||||
const roleDetails = projectId ? getProjectMemberDetails(id, projectId.toString())?.role : null;
|
||||
const profileLink = `/${workspaceSlug}/profile/${id}`;
|
||||
|
||||
if (!userDetails) {
|
||||
return (
|
||||
<div className="not-prose inline px-1 py-0.5 rounded bg-custom-background-80 text-custom-text-300 no-underline">
|
||||
@deactivated user
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose group/user-mention inline px-1 py-0.5 rounded bg-custom-primary-100/20 text-custom-primary-100 no-underline",
|
||||
{
|
||||
"bg-yellow-500/20 text-yellow-500": id === currentUser?.id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Link href={profileLink} ref={setReferenceElement}>
|
||||
@{userDetails?.display_name}
|
||||
</Link>
|
||||
<div
|
||||
className="top-full left-0 z-10 min-w-60 bg-custom-background-90 shadow-custom-shadow-rg rounded-lg p-4 opacity-0 pointer-events-none group-hover/user-mention:opacity-100 group-hover/user-mention:pointer-events-auto transition-opacity"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 size-10 grid place-items-center">
|
||||
<Avatar
|
||||
src={getFileURL(userDetails?.avatar_url ?? "")}
|
||||
name={userDetails?.display_name}
|
||||
size={40}
|
||||
className="text-xl"
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={profileLink} className="not-prose font-medium text-custom-text-100 text-sm hover:underline">
|
||||
{userDetails?.first_name} {userDetails?.last_name}
|
||||
</Link>
|
||||
{roleDetails && <p className="text-custom-text-200 text-xs">{ROLE[roleDetails]}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
197
apps/web/core/components/editor/lite-text/editor.tsx
Normal file
197
apps/web/core/components/editor/lite-text/editor.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useState } from "react";
|
||||
// plane constants
|
||||
import type { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane imports
|
||||
import { LiteTextEditorWithRef } from "@plane/editor";
|
||||
import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { MakeOptional } from "@plane/types";
|
||||
import { cn, isCommentEmpty } from "@plane/utils";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor/embeds/mentions";
|
||||
import { IssueCommentToolbar } from "@/components/editor/lite-text/toolbar";
|
||||
// hooks
|
||||
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
// plane web service
|
||||
import { WorkspaceService } from "@/plane-web/services";
|
||||
import { LiteToolbar } from "./lite-toolbar";
|
||||
const workspaceService = new WorkspaceService();
|
||||
|
||||
type LiteTextEditorWrapperProps = MakeOptional<
|
||||
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
> & {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
projectId?: string;
|
||||
accessSpecifier?: EIssueCommentAccessSpecifier;
|
||||
handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;
|
||||
showAccessSpecifier?: boolean;
|
||||
showSubmitButton?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
showToolbarInitially?: boolean;
|
||||
variant?: "full" | "lite" | "none";
|
||||
issue_id?: string;
|
||||
parentClassName?: string;
|
||||
editorClassName?: string;
|
||||
} & (
|
||||
| {
|
||||
editable: false;
|
||||
}
|
||||
| {
|
||||
editable: true;
|
||||
uploadFile: TFileHandler["upload"];
|
||||
}
|
||||
);
|
||||
|
||||
export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapperProps>((props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
containerClassName,
|
||||
editable,
|
||||
workspaceSlug,
|
||||
workspaceId,
|
||||
projectId,
|
||||
issue_id,
|
||||
accessSpecifier,
|
||||
handleAccessChange,
|
||||
showAccessSpecifier = false,
|
||||
showSubmitButton = true,
|
||||
isSubmitting = false,
|
||||
showToolbarInitially = true,
|
||||
variant = "full",
|
||||
parentClassName = "",
|
||||
placeholder = t("issue.comments.placeholder"),
|
||||
disabledExtensions: additionalDisabledExtensions = [],
|
||||
editorClassName = "",
|
||||
...rest
|
||||
} = props;
|
||||
// states
|
||||
const isLiteVariant = variant === "lite";
|
||||
const isFullVariant = variant === "full";
|
||||
const [isFocused, setIsFocused] = useState(isFullVariant ? showToolbarInitially : true);
|
||||
// editor flaggings
|
||||
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
});
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: async (payload) =>
|
||||
await workspaceService.searchEntity(workspaceSlug, {
|
||||
...payload,
|
||||
project_id: projectId,
|
||||
issue_id,
|
||||
}),
|
||||
});
|
||||
// editor config
|
||||
const { getEditorFileHandlers } = useEditorConfig();
|
||||
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
||||
return !!ref && typeof ref === "object" && "current" in ref;
|
||||
}
|
||||
// derived values
|
||||
const isEmpty = isCommentEmpty(props.initialValue);
|
||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative border border-custom-border-200 rounded",
|
||||
{
|
||||
"p-3": editable && !isLiteVariant,
|
||||
},
|
||||
parentClassName
|
||||
)}
|
||||
onFocus={() => isFullVariant && !showToolbarInitially && setIsFocused(true)}
|
||||
onBlur={() => isFullVariant && !showToolbarInitially && setIsFocused(false)}
|
||||
>
|
||||
{/* 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={{}}
|
||||
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",
|
||||
isFocused ? "max-h-[200px] opacity-100 scale-y-100 mt-3" : "max-h-0 opacity-0 scale-y-0 invisible"
|
||||
)}
|
||||
>
|
||||
<IssueCommentToolbar
|
||||
accessSpecifier={accessSpecifier}
|
||||
executeCommand={(item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
editorRef?.executeMenuItemCommand({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
}}
|
||||
handleAccessChange={handleAccessChange}
|
||||
handleSubmit={(e) => rest.onEnterKeyPress?.(e)}
|
||||
isCommentEmpty={isEmpty}
|
||||
isSubmitting={isSubmitting}
|
||||
showAccessSpecifier={showAccessSpecifier}
|
||||
editorRef={editorRef}
|
||||
showSubmitButton={showSubmitButton}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LiteTextEditor.displayName = "LiteTextEditor";
|
||||
2
apps/web/core/components/editor/lite-text/index.ts
Normal file
2
apps/web/core/components/editor/lite-text/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./editor";
|
||||
export * from "./toolbar";
|
||||
34
apps/web/core/components/editor/lite-text/lite-toolbar.tsx
Normal file
34
apps/web/core/components/editor/lite-text/lite-toolbar.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from "react";
|
||||
import { ArrowUp, Paperclip } from "lucide-react";
|
||||
// constants
|
||||
import type { ToolbarMenuItem } from "@/constants/editor";
|
||||
import { IMAGE_ITEM } 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 };
|
||||
187
apps/web/core/components/editor/lite-text/toolbar.tsx
Normal file
187
apps/web/core/components/editor/lite-text/toolbar.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Globe2, Lock } from "lucide-react";
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// editor
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// i18n
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
// constants
|
||||
import { cn } from "@plane/utils";
|
||||
import type { ToolbarMenuItem } from "@/constants/editor";
|
||||
import { TOOLBAR_ITEMS } from "@/constants/editor";
|
||||
// helpers
|
||||
|
||||
type Props = {
|
||||
accessSpecifier?: EIssueCommentAccessSpecifier;
|
||||
executeCommand: (item: ToolbarMenuItem) => void;
|
||||
handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;
|
||||
handleSubmit: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
isCommentEmpty: boolean;
|
||||
isSubmitting: boolean;
|
||||
showAccessSpecifier: boolean;
|
||||
showSubmitButton: boolean;
|
||||
editorRef: EditorRefApi | null;
|
||||
};
|
||||
|
||||
type TCommentAccessType = {
|
||||
icon: LucideIcon;
|
||||
key: EIssueCommentAccessSpecifier;
|
||||
label: "Private" | "Public";
|
||||
};
|
||||
|
||||
const COMMENT_ACCESS_SPECIFIERS: TCommentAccessType[] = [
|
||||
{
|
||||
icon: Lock,
|
||||
key: EIssueCommentAccessSpecifier.INTERNAL,
|
||||
label: "Private",
|
||||
},
|
||||
{
|
||||
icon: Globe2,
|
||||
key: EIssueCommentAccessSpecifier.EXTERNAL,
|
||||
label: "Public",
|
||||
},
|
||||
];
|
||||
|
||||
const toolbarItems = TOOLBAR_ITEMS.lite;
|
||||
|
||||
export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
accessSpecifier,
|
||||
executeCommand,
|
||||
handleAccessChange,
|
||||
handleSubmit,
|
||||
isCommentEmpty,
|
||||
isSubmitting,
|
||||
showAccessSpecifier,
|
||||
showSubmitButton,
|
||||
editorRef,
|
||||
} = props;
|
||||
// State to manage active states of toolbar items
|
||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Function to update active states
|
||||
const updateActiveStates = useCallback(() => {
|
||||
if (!editorRef) return;
|
||||
const newActiveStates: Record<string, boolean> = {};
|
||||
Object.values(toolbarItems)
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
newActiveStates[item.renderKey] = editorRef.isMenuItemActive({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
});
|
||||
setActiveStates(newActiveStates);
|
||||
}, [editorRef]);
|
||||
|
||||
// useEffect to call updateActiveStates when isActive prop changes
|
||||
useEffect(() => {
|
||||
if (!editorRef) return;
|
||||
const unsubscribe = editorRef.onStateChange(updateActiveStates);
|
||||
updateActiveStates();
|
||||
return () => unsubscribe();
|
||||
}, [editorRef, updateActiveStates]);
|
||||
|
||||
const isEditorReadyToDiscard = editorRef?.isEditorReadyToDiscard();
|
||||
const isSubmitButtonDisabled = isCommentEmpty || !isEditorReadyToDiscard;
|
||||
|
||||
return (
|
||||
<div className="flex h-9 w-full items-stretch gap-1.5 bg-custom-background-90 overflow-x-scroll">
|
||||
{showAccessSpecifier && (
|
||||
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1">
|
||||
{COMMENT_ACCESS_SPECIFIERS.map((access) => {
|
||||
const isAccessActive = accessSpecifier === access.key;
|
||||
|
||||
return (
|
||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAccessChange?.(access.key)}
|
||||
className={cn("grid place-items-center aspect-square rounded-sm p-1 hover:bg-custom-background-80", {
|
||||
"bg-custom-background-80": isAccessActive,
|
||||
})}
|
||||
>
|
||||
<access.icon
|
||||
className={cn("h-3.5 w-3.5 text-custom-text-400", {
|
||||
"text-custom-text-100": isAccessActive,
|
||||
})}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-stretch justify-between gap-2 rounded border-[0.5px] border-custom-border-200 p-1">
|
||||
<div className="flex items-stretch">
|
||||
{Object.keys(toolbarItems).map((key, index) => (
|
||||
<div
|
||||
key={key}
|
||||
className={cn("flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5", {
|
||||
"pl-0": index === 0,
|
||||
})}
|
||||
>
|
||||
{toolbarItems[key].map((item) => {
|
||||
const isItemActive = activeStates[item.renderKey];
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.renderKey}
|
||||
tooltipContent={
|
||||
<p className="flex flex-col gap-1 text-center text-xs">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
{item.shortcut && <kbd className="text-custom-text-400">{item.shortcut.join(" + ")}</kbd>}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => executeCommand(item)}
|
||||
className={cn(
|
||||
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-400 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": isItemActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"text-custom-text-100": isItemActive,
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSubmitButton && (
|
||||
<div className="sticky right-1">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="px-2.5 py-1.5 text-xs"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitButtonDisabled}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("common.comment")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
54
apps/web/core/components/editor/pdf/document.tsx
Normal file
54
apps/web/core/components/editor/pdf/document.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import type { PageProps } from "@react-pdf/renderer";
|
||||
import { Document, Font, Page } from "@react-pdf/renderer";
|
||||
import { Html } from "react-pdf-html";
|
||||
// constants
|
||||
import { EDITOR_PDF_DOCUMENT_STYLESHEET } from "@/constants/editor";
|
||||
|
||||
Font.register({
|
||||
family: "Inter",
|
||||
fonts: [
|
||||
{ src: "/fonts/inter/thin.ttf", fontWeight: "thin" },
|
||||
{ src: "/fonts/inter/thin.ttf", fontWeight: "thin", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight" },
|
||||
{ src: "/fonts/inter/ultralight.ttf", fontWeight: "ultralight", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/light.ttf", fontWeight: "light" },
|
||||
{ src: "/fonts/inter/light.ttf", fontWeight: "light", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/regular.ttf", fontWeight: "normal" },
|
||||
{ src: "/fonts/inter/regular.ttf", fontWeight: "normal", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/medium.ttf", fontWeight: "medium" },
|
||||
{ src: "/fonts/inter/medium.ttf", fontWeight: "medium", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/semibold.ttf", fontWeight: "semibold" },
|
||||
{ src: "/fonts/inter/semibold.ttf", fontWeight: "semibold", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/bold.ttf", fontWeight: "bold" },
|
||||
{ src: "/fonts/inter/bold.ttf", fontWeight: "bold", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold" },
|
||||
{ src: "/fonts/inter/extrabold.ttf", fontWeight: "ultrabold", fontStyle: "italic" },
|
||||
{ src: "/fonts/inter/heavy.ttf", fontWeight: "heavy" },
|
||||
{ src: "/fonts/inter/heavy.ttf", fontWeight: "heavy", fontStyle: "italic" },
|
||||
],
|
||||
});
|
||||
|
||||
type Props = {
|
||||
content: string;
|
||||
pageFormat: PageProps["size"];
|
||||
};
|
||||
|
||||
export const PDFDocument: React.FC<Props> = (props) => {
|
||||
const { content, pageFormat } = props;
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page
|
||||
size={pageFormat}
|
||||
style={{
|
||||
backgroundColor: "#ffffff",
|
||||
padding: 64,
|
||||
}}
|
||||
>
|
||||
<Html stylesheet={EDITOR_PDF_DOCUMENT_STYLESHEET}>{content}</Html>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
};
|
||||
1
apps/web/core/components/editor/pdf/index.ts
Normal file
1
apps/web/core/components/editor/pdf/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./document";
|
||||
86
apps/web/core/components/editor/rich-text/editor.tsx
Normal file
86
apps/web/core/components/editor/rich-text/editor.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { forwardRef } from "react";
|
||||
// plane imports
|
||||
import { RichTextEditorWithRef } from "@plane/editor";
|
||||
import type { EditorRefApi, IRichTextEditorProps, TFileHandler } from "@plane/editor";
|
||||
import type { MakeOptional, TSearchEntityRequestPayload, TSearchResponse } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor/embeds/mentions";
|
||||
// hooks
|
||||
import { useEditorConfig, useEditorMention } from "@/hooks/editor";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
|
||||
type RichTextEditorWrapperProps = MakeOptional<
|
||||
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
|
||||
"disabledExtensions" | "editable" | "flaggedExtensions"
|
||||
> & {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
projectId?: string;
|
||||
} & (
|
||||
| {
|
||||
editable: false;
|
||||
}
|
||||
| {
|
||||
editable: true;
|
||||
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
uploadFile: TFileHandler["upload"];
|
||||
}
|
||||
);
|
||||
|
||||
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editable,
|
||||
workspaceSlug,
|
||||
workspaceId,
|
||||
projectId,
|
||||
disabledExtensions: additionalDisabledExtensions = [],
|
||||
...rest
|
||||
} = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// editor flaggings
|
||||
const { richText: richTextEditorExtensions } = useEditorFlagging({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
});
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),
|
||||
});
|
||||
// editor config
|
||||
const { getEditorFileHandlers } = useEditorConfig();
|
||||
|
||||
return (
|
||||
<RichTextEditorWithRef
|
||||
ref={ref}
|
||||
disabledExtensions={[...richTextEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}
|
||||
editable={editable}
|
||||
flaggedExtensions={richTextEditorExtensions.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 ?? "",
|
||||
}),
|
||||
}}
|
||||
extendedEditorProps={{}}
|
||||
{...rest}
|
||||
containerClassName={cn("relative pl-3 pb-3", containerClassName)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
RichTextEditor.displayName = "RichTextEditor";
|
||||
1
apps/web/core/components/editor/rich-text/index.ts
Normal file
1
apps/web/core/components/editor/rich-text/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./editor";
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { TSticky } from "@plane/types";
|
||||
|
||||
export const STICKY_COLORS_LIST: {
|
||||
key: string;
|
||||
label: string;
|
||||
backgroundColor: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "gray",
|
||||
label: "Gray",
|
||||
backgroundColor: "rgba(var(--color-background-90))",
|
||||
},
|
||||
{
|
||||
key: "peach",
|
||||
label: "Peach",
|
||||
backgroundColor: "var(--editor-colors-peach-background)",
|
||||
},
|
||||
{
|
||||
key: "pink",
|
||||
label: "Pink",
|
||||
backgroundColor: "var(--editor-colors-pink-background)",
|
||||
},
|
||||
{
|
||||
key: "orange",
|
||||
label: "Orange",
|
||||
backgroundColor: "var(--editor-colors-orange-background)",
|
||||
},
|
||||
{
|
||||
key: "green",
|
||||
label: "Green",
|
||||
backgroundColor: "var(--editor-colors-green-background)",
|
||||
},
|
||||
{
|
||||
key: "light-blue",
|
||||
label: "Light blue",
|
||||
backgroundColor: "var(--editor-colors-light-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "dark-blue",
|
||||
label: "Dark blue",
|
||||
backgroundColor: "var(--editor-colors-dark-blue-background)",
|
||||
},
|
||||
{
|
||||
key: "purple",
|
||||
label: "Purple",
|
||||
backgroundColor: "var(--editor-colors-purple-background)",
|
||||
},
|
||||
];
|
||||
|
||||
type TProps = {
|
||||
handleUpdate: (data: Partial<TSticky>) => Promise<void>;
|
||||
};
|
||||
|
||||
export const ColorPalette = (props: TProps) => {
|
||||
const { handleUpdate } = props;
|
||||
return (
|
||||
<div className="absolute z-10 bottom-5 left-0 w-56 shadow p-2 rounded-md bg-custom-background-100 mb-2">
|
||||
<div className="text-sm font-semibold text-custom-text-400 mb-2">Background colors</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{STICKY_COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
handleUpdate({
|
||||
background_color: color.key,
|
||||
});
|
||||
}}
|
||||
className="h-6 w-6 rounded-md hover:ring-2 hover:ring-custom-primary focus:outline-none focus:ring-2 focus:ring-custom-primary transition-all"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
115
apps/web/core/components/editor/sticky-editor/editor.tsx
Normal file
115
apps/web/core/components/editor/sticky-editor/editor.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from "react";
|
||||
// plane constants
|
||||
import type { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane editor
|
||||
import { LiteTextEditorWithRef } from "@plane/editor";
|
||||
import type { EditorRefApi, ILiteTextEditorProps, TFileHandler } from "@plane/editor";
|
||||
// components
|
||||
import type { TSticky } from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
import { useEditorConfig } from "@/hooks/editor";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { StickyEditorToolbar } from "./toolbar";
|
||||
|
||||
interface StickyEditorWrapperProps
|
||||
extends Omit<
|
||||
Omit<ILiteTextEditorProps, "extendedEditorProps">,
|
||||
"disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler"
|
||||
> {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
projectId?: string;
|
||||
accessSpecifier?: EIssueCommentAccessSpecifier;
|
||||
handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void;
|
||||
showAccessSpecifier?: boolean;
|
||||
showSubmitButton?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
showToolbarInitially?: boolean;
|
||||
showToolbar?: boolean;
|
||||
uploadFile: TFileHandler["upload"];
|
||||
parentClassName?: string;
|
||||
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
|
||||
handleDelete: () => void;
|
||||
}
|
||||
|
||||
export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperProps>((props, ref) => {
|
||||
const {
|
||||
containerClassName,
|
||||
workspaceSlug,
|
||||
workspaceId,
|
||||
projectId,
|
||||
handleDelete,
|
||||
handleColorChange,
|
||||
showToolbarInitially = true,
|
||||
showToolbar = true,
|
||||
parentClassName = "",
|
||||
uploadFile,
|
||||
...rest
|
||||
} = props;
|
||||
// states
|
||||
const [isFocused, setIsFocused] = useState(showToolbarInitially);
|
||||
// editor flaggings
|
||||
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
});
|
||||
// editor config
|
||||
const { getEditorFileHandlers } = useEditorConfig();
|
||||
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
||||
return !!ref && typeof ref === "object" && "current" in ref;
|
||||
}
|
||||
// derived values
|
||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||
return (
|
||||
<div
|
||||
className={cn("relative border border-custom-border-200 rounded", parentClassName)}
|
||||
onFocus={() => !showToolbarInitially && setIsFocused(true)}
|
||||
onBlur={() => !showToolbarInitially && setIsFocused(false)}
|
||||
>
|
||||
<LiteTextEditorWithRef
|
||||
ref={ref}
|
||||
disabledExtensions={[...liteTextEditorExtensions.disabled, "enter-key"]}
|
||||
flaggedExtensions={liteTextEditorExtensions.flagged}
|
||||
editable
|
||||
fileHandler={getEditorFileHandlers({
|
||||
projectId,
|
||||
uploadFile,
|
||||
workspaceId,
|
||||
workspaceSlug,
|
||||
})}
|
||||
mentionHandler={{
|
||||
renderComponent: () => <></>,
|
||||
}}
|
||||
extendedEditorProps={{}}
|
||||
containerClassName={cn(containerClassName, "relative")}
|
||||
{...rest}
|
||||
/>
|
||||
{showToolbar && (
|
||||
<div
|
||||
className={cn("transition-all duration-300 ease-out origin-top px-4 h-[60px]", {
|
||||
"max-h-[60px] opacity-100 scale-y-100": isFocused,
|
||||
"max-h-0 opacity-0 scale-y-0 invisible": !isFocused,
|
||||
})}
|
||||
>
|
||||
<StickyEditorToolbar
|
||||
executeCommand={(item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
editorRef?.executeMenuItemCommand({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
}}
|
||||
handleDelete={handleDelete}
|
||||
handleColorChange={handleColorChange}
|
||||
editorRef={editorRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
StickyEditor.displayName = "StickyEditor";
|
||||
2
apps/web/core/components/editor/sticky-editor/index.ts
Normal file
2
apps/web/core/components/editor/sticky-editor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./editor";
|
||||
export * from "./toolbar";
|
||||
136
apps/web/core/components/editor/sticky-editor/toolbar.tsx
Normal file
136
apps/web/core/components/editor/sticky-editor/toolbar.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Palette, Trash2 } from "lucide-react";
|
||||
// editor
|
||||
import type { EditorRefApi } from "@plane/editor";
|
||||
// ui
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import type { TSticky } from "@plane/types";
|
||||
// constants
|
||||
import { cn } from "@plane/utils";
|
||||
import type { ToolbarMenuItem } from "@/constants/editor";
|
||||
import { TOOLBAR_ITEMS } from "@/constants/editor";
|
||||
// helpers
|
||||
import { ColorPalette } from "./color-palette";
|
||||
|
||||
type Props = {
|
||||
executeCommand: (item: ToolbarMenuItem) => void;
|
||||
editorRef: EditorRefApi | null;
|
||||
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
|
||||
handleDelete: () => void;
|
||||
};
|
||||
|
||||
const toolbarItems = TOOLBAR_ITEMS.sticky;
|
||||
|
||||
export const StickyEditorToolbar: React.FC<Props> = (props) => {
|
||||
const { executeCommand, editorRef, handleColorChange, handleDelete } = props;
|
||||
|
||||
// State to manage active states of toolbar items
|
||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||
const [showColorPalette, setShowColorPalette] = useState(false);
|
||||
const colorPaletteRef = React.useRef<HTMLDivElement>(null);
|
||||
// Function to update active states
|
||||
const updateActiveStates = useCallback(() => {
|
||||
if (!editorRef) return;
|
||||
const newActiveStates: Record<string, boolean> = {};
|
||||
Object.values(toolbarItems)
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
// TODO: update this while toolbar homogenization
|
||||
// @ts-expect-error type mismatch here
|
||||
newActiveStates[item.renderKey] = editorRef.isMenuItemActive({
|
||||
itemKey: item.itemKey,
|
||||
...item.extraProps,
|
||||
});
|
||||
});
|
||||
setActiveStates(newActiveStates);
|
||||
}, [editorRef]);
|
||||
|
||||
// useEffect to call updateActiveStates when isActive prop changes
|
||||
useEffect(() => {
|
||||
if (!editorRef) return;
|
||||
const unsubscribe = editorRef.onStateChange(updateActiveStates);
|
||||
updateActiveStates();
|
||||
return () => unsubscribe();
|
||||
}, [editorRef, updateActiveStates]);
|
||||
|
||||
useOutsideClickDetector(colorPaletteRef, () => setShowColorPalette(false));
|
||||
|
||||
return (
|
||||
<div className="flex w-full justify-between h-full">
|
||||
<div className="flex my-auto gap-4" ref={colorPaletteRef}>
|
||||
{/* color palette */}
|
||||
{showColorPalette && <ColorPalette handleUpdate={handleColorChange} />}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<p className="flex flex-col gap-1 text-center text-xs">
|
||||
<span className="font-medium">Background color</span>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowColorPalette(!showColorPalette)}
|
||||
className="flex text-custom-text-100/50"
|
||||
>
|
||||
<Palette className="size-4 my-auto" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className="flex w-fit items-stretch justify-between gap-4 rounded p-1 my-auto">
|
||||
<div className="flex items-stretch my-auto gap-4">
|
||||
{Object.keys(toolbarItems).map((key) => (
|
||||
<div key={key} className={cn("flex items-stretch gap-4", {})}>
|
||||
{toolbarItems[key].map((item) => {
|
||||
const isItemActive = activeStates[item.renderKey];
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={item.renderKey}
|
||||
tooltipContent={
|
||||
<p className="flex flex-col gap-1 text-center text-xs">
|
||||
<span className="font-medium">{item.name}</span>
|
||||
{item.shortcut && <kbd className="text-custom-text-400">{item.shortcut.join(" + ")}</kbd>}
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => executeCommand(item)}
|
||||
className={cn(
|
||||
"grid place-items-center aspect-square rounded-sm p-0.5 text-custom-text-100/50",
|
||||
{}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"font-extrabold": isItemActive,
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* delete action */}
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
<p className="flex flex-col gap-1 text-center text-xs">
|
||||
<span className="font-medium">Delete</span>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<button type="button" onClick={handleDelete} className="my-auto text-custom-text-100/50">
|
||||
<Trash2 className="size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user