feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

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

View File

@@ -0,0 +1 @@
export * from "./root";

View 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} />;
}
};

View 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>
);
});

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

View File

@@ -0,0 +1,2 @@
export * from "./editor";
export * from "./toolbar";

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

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1 @@
export * from "./document";

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

View File

@@ -0,0 +1 @@
export * from "./editor";

View File

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

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

View File

@@ -0,0 +1,2 @@
export * from "./editor";
export * from "./toolbar";

View 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>
);
};