snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,105 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { 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";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
import { useEditorFlagging } from "@/hooks/use-editor-flagging";
// local imports
import { EditorMentionsRoot } from "../embeds/mentions";
type DocumentEditorWrapperProps = MakeOptional<
Omit<IDocumentEditorProps, "fileHandler" | "mentionHandler" | "user" | "extendedEditorProps">,
"disabledExtensions" | "editable" | "flaggedExtensions" | "getEditorMetaData"
> & {
extendedEditorProps?: Partial<IEditorPropsExtended>;
workspaceSlug: string;
workspaceId: string;
projectId?: string;
} & (
| {
editable: false;
}
| {
editable: true;
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
}
);
export const DocumentEditor = forwardRef(function DocumentEditor(
props: DocumentEditorWrapperProps,
ref: React.ForwardedRef<EditorRefApi>
) {
const {
containerClassName,
editable,
extendedEditorProps,
workspaceSlug,
workspaceId,
projectId,
disabledExtensions: additionalDisabledExtensions = [],
...rest
} = props;
// store hooks
const { getUserDetails } = useMember();
// parse content
const { getEditorMetaData } = useParseEditorContent({
projectId,
workspaceSlug,
});
// editor flaggings
const { document: documentEditorExtensions } = useEditorFlagging({
workspaceSlug,
projectId,
});
// use editor mention
const { fetchMentions } = useEditorMention({
enableAdvancedMentions: true,
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 () => "",
duplicateFile: editable ? props.duplicateFile : async () => "",
workspaceId,
workspaceSlug,
})}
getEditorMetaData={getEditorMetaData}
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 pb-3 pl-3", containerClassName)}
/>
);
});
DocumentEditor.displayName = "DocumentEditor";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,20 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// local imports
import { EditorUserMention } from "./user";
import type { TCallbackMentionComponentProps } from "@plane/editor";
export function EditorMentionsRoot(props: TCallbackMentionComponentProps) {
const { entity_identifier, entity_name } = props;
switch (entity_name) {
case "user_mention":
return <EditorUserMention id={entity_identifier} />;
default:
return null;
}
}

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Link } from "react-router";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { ROLE } from "@plane/constants";
import { Popover } from "@plane/propel/popover";
import { cn, getFileURL } from "@plane/utils";
// hooks
import { useMember } from "@/hooks/store/use-member";
import { useUser } from "@/hooks/store/user";
type Props = {
id: string;
};
export const EditorUserMention = observer(function EditorUserMention(props: Props) {
const { id } = props;
// router
const { projectId } = useParams();
// params
const { workspaceSlug } = useParams();
// store hooks
const { data: currentUser } = useUser();
const {
getUserDetails,
project: { getProjectMemberDetails },
} = useMember();
// 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 rounded-sm bg-layer-1 px-1 py-0.5 text-tertiary no-underline">
@suspended user
</div>
);
}
return (
<div
className={cn(
"not-prose inline rounded-sm bg-accent-subtle-active px-1 py-0.5 text-accent-primary no-underline",
{
"bg-label-yellow-bg text-label-yellow-text": id === currentUser?.id,
}
)}
>
<Popover delay={100} openOnHover>
<Popover.Button>
<Link to={profileLink}>@{userDetails?.display_name}</Link>
</Popover.Button>
<Popover.Panel side="bottom" align="start">
<div className="w-60 rounded-lg border-[0.5px] border-strong bg-surface-1 p-3 shadow-raised-200">
<div className="flex items-center gap-3">
<div className="grid size-10 flex-shrink-0 place-items-center">
<Avatar
alt={userDetails?.display_name}
fallback={userDetails?.display_name?.[0]?.toUpperCase()}
src={getFileURL(userDetails?.avatar_url ?? "")}
size="xl"
/>
</div>
<div>
<Link to={profileLink} className="not-prose text-13 font-medium text-primary hover:underline">
{userDetails?.first_name} {userDetails?.last_name}
</Link>
{roleDetails && <p className="text-11 text-secondary">{ROLE[roleDetails]}</p>}
</div>
</div>
</div>
</Popover.Panel>
</Popover>
</div>
);
});

View File

@@ -0,0 +1,227 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web hooks
import { useEditorFlagging } from "@/hooks/use-editor-flagging";
// plane web service
import { WorkspaceService } from "@/services/workspace.service";
import { LiteToolbar } from "./lite-toolbar";
const workspaceService = new WorkspaceService();
type LiteTextEditorWrapperProps = MakeOptional<
Omit<ILiteTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "flaggedExtensions" | "getEditorMetaData"
> & {
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;
submitButtonText?: string;
} & (
| {
editable: false;
}
| {
editable: true;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
}
);
export const LiteTextEditor = React.forwardRef(function LiteTextEditor(
props: LiteTextEditorWrapperProps,
ref: React.ForwardedRef<EditorRefApi>
) {
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 = "",
showPlaceholderOnEmpty = true,
submitButtonText = "common.comment",
...rest
} = props;
// states
const isLiteVariant = variant === "lite";
const isFullVariant = variant === "full";
const [isFocused, setIsFocused] = useState(isFullVariant ? showToolbarInitially : true);
const [editorRef, setEditorRef] = useState<EditorRefApi | null>(null);
// editor flaggings
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
workspaceSlug,
projectId,
});
// store hooks
const { getUserDetails } = useMember();
// parse content
const { getEditorMetaData } = useParseEditorContent({
projectId,
workspaceSlug,
});
// 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);
return (
<div
className={cn(
"relative rounded-sm border border-subtle",
{
"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 ? "min-w-0 flex-1" : "")}>
<LiteTextEditorWithRef
ref={ref}
disabledExtensions={[...liteTextEditorExtensions.disabled, ...additionalDisabledExtensions]}
editable={editable}
flaggedExtensions={liteTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
duplicateFile: editable ? props.duplicateFile : async () => "",
workspaceId,
workspaceSlug,
})}
getEditorMetaData={getEditorMetaData}
handleEditorReady={(ready) => {
if (ready) {
setEditorRef(isMutableRefObject<EditorRefApi>(ref) ? ref.current : null);
}
}}
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}
showPlaceholderOnEmpty={showPlaceholderOnEmpty}
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(
"origin-top overflow-hidden transition-all duration-300 ease-out",
isFocused ? "mt-3 max-h-[200px] scale-y-100 opacity-100" : "invisible max-h-0 scale-y-0 opacity-0"
)}
>
<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}
submitButtonText={submitButtonText}
/>
</div>
)}
</div>
);
});
LiteTextEditor.displayName = "LiteTextEditor";

View File

@@ -0,0 +1,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./editor";
export * from "./toolbar";

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { AttachOutline, TopArrowOutline } from "@makeplane/propel/icons";
// constants
import type { ToolbarMenuItem } from "@plane/editor";
import { IMAGE_ITEM } from "@plane/editor";
type LiteToolbarProps = {
onSubmit: (e: React.KeyboardEvent<HTMLDivElement> | React.MouseEvent<HTMLButtonElement>) => void;
isSubmitting: boolean;
isEmpty: boolean;
executeCommand: (item: ToolbarMenuItem) => void;
};
export function LiteToolbar({ onSubmit, isSubmitting, isEmpty, executeCommand }: LiteToolbarProps) {
return (
<div className="flex items-center gap-2 pb-1">
<button
onClick={() => executeCommand(IMAGE_ITEM)}
type="button"
className="p-1 text-tertiary transition-colors hover:text-secondary"
>
<AttachOutline className="size-3" />
</button>
<button
type="button"
onClick={(e) => onSubmit(e)}
disabled={isEmpty || isSubmitting}
className="rounded-sm bg-accent-primary p-1 text-primary transition-colors hover:bg-accent-primary/80 disabled:bg-layer-1 disabled:text-secondary"
>
<TopArrowOutline className="size-3" />
</button>
</div>
);
}
export type { LiteToolbarProps };

View File

@@ -0,0 +1,187 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useEffect, useState, useCallback } from "react";
import type { LucideIcon } 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 { GlobeOutline, LockOutline } from "@makeplane/propel/icons";
import type { ISvgIcons } from "@plane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
// constants
import { cn } from "@plane/utils";
import type { ToolbarMenuItem } from "@plane/editor";
import { TOOLBAR_ITEMS } from "@plane/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;
submitButtonText?: string;
};
type TCommentAccessType = {
icon: LucideIcon | React.FC<ISvgIcons>;
key: EIssueCommentAccessSpecifier;
label: "Private" | "Public";
};
const COMMENT_ACCESS_SPECIFIERS: TCommentAccessType[] = [
{
icon: LockOutline,
key: EIssueCommentAccessSpecifier.INTERNAL,
label: "Private",
},
{
icon: GlobeOutline,
key: EIssueCommentAccessSpecifier.EXTERNAL,
label: "Public",
},
];
const toolbarItems = TOOLBAR_ITEMS.lite;
export function IssueCommentToolbar(props: Props) {
const { t } = useTranslation();
const {
accessSpecifier,
executeCommand,
handleAccessChange,
handleSubmit,
isCommentEmpty,
isSubmitting,
showAccessSpecifier,
showSubmitButton,
editorRef,
submitButtonText = "common.comment",
} = 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 overflow-x-scroll bg-surface-2">
{showAccessSpecifier && (
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded-sm border-[0.5px] border-subtle p-1">
{COMMENT_ACCESS_SPECIFIERS.map((access) => {
const isAccessActive = accessSpecifier === access.key;
return (
<Tooltip key={access.key} label={access.label} layout="stacked">
<button
type="button"
onClick={() => handleAccessChange?.(access.key)}
className={cn("grid aspect-square place-items-center rounded-xs p-1 hover:bg-layer-1", {
"bg-layer-1": isAccessActive,
})}
>
<access.icon
className={cn("h-3.5 w-3.5 text-placeholder", {
"text-primary": isAccessActive,
})}
strokeWidth={2}
/>
</button>
</Tooltip>
);
})}
</div>
)}
<div className="flex w-full items-stretch justify-between gap-2 rounded-sm border-[0.5px] border-subtle 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-subtle px-2.5", {
"pl-0": index === 0,
})}
>
{toolbarItems[key].map((item) => {
const isItemActive = activeStates[item.renderKey];
return (
<Tooltip key={item.renderKey} label={item.name} shortcut={item.shortcut?.join(" + ")}>
<button
type="button"
onClick={() => executeCommand(item)}
className={cn(
"grid aspect-square place-items-center rounded-xs p-0.5 text-placeholder hover:bg-layer-1",
{
"bg-layer-1 text-primary": isItemActive,
}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-primary": 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-11"
onClick={handleSubmit}
disabled={isSubmitButtonDisabled}
loading={isSubmitting}
>
{t(submitButtonText)}
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,237 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { PageProps, Styles } from "@react-pdf/renderer";
import { Document, Font, Page, StyleSheet } from "@react-pdf/renderer";
import { Html } from "react-pdf-html";
// assets
import interBold from "@/app/assets/fonts/inter/bold.ttf?url";
import interHeavy from "@/app/assets/fonts/inter/heavy.ttf?url";
import interLight from "@/app/assets/fonts/inter/light.ttf?url";
import interMedium from "@/app/assets/fonts/inter/medium.ttf?url";
import interRegular from "@/app/assets/fonts/inter/regular.ttf?url";
import interSemibold from "@/app/assets/fonts/inter/semibold.ttf?url";
import interThin from "@/app/assets/fonts/inter/thin.ttf?url";
import interUltraBold from "@/app/assets/fonts/inter/ultrabold.ttf?url";
import interUltraLight from "@/app/assets/fonts/inter/ultralight.ttf?url";
// plane imports
import { convertRemToPixel } from "@plane/utils";
const EDITOR_PDF_FONT_FAMILY_STYLES: Styles = {
"*:not(.courier, .courier-bold)": { fontFamily: "Inter" },
".courier": { fontFamily: "Courier" },
".courier-bold": { fontFamily: "Courier-Bold" },
};
const EDITOR_PDF_TYPOGRAPHY_STYLES: Styles = {
// page title
"h1.page-title": {
fontSize: convertRemToPixel(1.6),
fontWeight: "bold",
marginTop: 0,
marginBottom: convertRemToPixel(2),
},
// headings
"h1:not(.page-title)": {
fontSize: convertRemToPixel(1.4),
fontWeight: "semibold",
marginTop: convertRemToPixel(2),
marginBottom: convertRemToPixel(0.25),
},
h2: {
fontSize: convertRemToPixel(1.2),
fontWeight: "semibold",
marginTop: convertRemToPixel(1.4),
marginBottom: convertRemToPixel(0.0625),
},
h3: {
fontSize: convertRemToPixel(1.1),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h4: {
fontSize: convertRemToPixel(1),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h5: {
fontSize: convertRemToPixel(0.9),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
h6: {
fontSize: convertRemToPixel(0.8),
fontWeight: "semibold",
marginTop: convertRemToPixel(1),
marginBottom: convertRemToPixel(0.0625),
},
// paragraph
"p:not(table p)": {
fontSize: convertRemToPixel(0.8),
},
"p:not(ol p, ul p)": {
marginTop: convertRemToPixel(0.25),
marginBottom: convertRemToPixel(0.0625),
},
};
const EDITOR_PDF_LIST_STYLES: Styles = {
"ul, ol": {
fontSize: convertRemToPixel(0.8),
marginHorizontal: -20,
},
"ol p, ul p": {
marginVertical: 0,
},
"ol li, ul li": {
marginTop: convertRemToPixel(0.45),
},
"ul ul, ul ol, ol ol, ol ul": {
marginVertical: 0,
},
"ul[data-type='taskList']": {
position: "relative",
},
"div.input-checkbox": {
position: "absolute",
top: convertRemToPixel(0.15),
left: -convertRemToPixel(1.2),
height: convertRemToPixel(0.75),
width: convertRemToPixel(0.75),
borderWidth: "1.5px",
borderStyle: "solid",
borderRadius: convertRemToPixel(0.125),
},
"div.input-checkbox:not(.checked)": {
backgroundColor: "#ffffff",
borderColor: "#171717",
},
"div.input-checkbox.checked": {
backgroundColor: "#3f76ff",
borderColor: "#3f76ff",
},
"ul li[data-checked='true'] p": {
color: "#a3a3a3",
},
};
const EDITOR_PDF_CODE_STYLES: Styles = {
// code block
"[data-node-type='code-block']": {
marginVertical: convertRemToPixel(0.5),
padding: convertRemToPixel(1),
borderRadius: convertRemToPixel(0.5),
backgroundColor: "#f7f7f7",
fontSize: convertRemToPixel(0.7),
},
// inline code block
"[data-node-type='inline-code-block']": {
margin: 0,
paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8),
paddingHorizontal: convertRemToPixel(0.375),
border: "0.5px solid #e5e5e5",
borderRadius: convertRemToPixel(0.25),
backgroundColor: "#e8e8e8",
color: "#f97316",
fontSize: convertRemToPixel(0.7),
},
};
const EDITOR_PDF_DOCUMENT_STYLESHEET = StyleSheet.create({
...EDITOR_PDF_FONT_FAMILY_STYLES,
...EDITOR_PDF_TYPOGRAPHY_STYLES,
...EDITOR_PDF_LIST_STYLES,
...EDITOR_PDF_CODE_STYLES,
// quote block
blockquote: {
borderLeft: "3px solid gray",
paddingLeft: convertRemToPixel(1),
marginTop: convertRemToPixel(0.625),
marginBottom: 0,
marginHorizontal: 0,
},
img: {
marginVertical: 0,
borderRadius: convertRemToPixel(0.375),
},
// divider
"div[data-type='horizontalRule']": {
marginVertical: convertRemToPixel(1),
height: 1,
width: "100%",
backgroundColor: "gray",
},
// mention block
"[data-node-type='mention-block']": {
margin: 0,
color: "#3f76ff",
backgroundColor: "#3f76ff33",
paddingHorizontal: convertRemToPixel(0.375),
},
// table
table: {
marginTop: convertRemToPixel(0.5),
marginBottom: convertRemToPixel(1),
marginHorizontal: 0,
},
"table td": {
padding: convertRemToPixel(0.625),
border: "1px solid #e5e5e5",
},
"table p": {
fontSize: convertRemToPixel(0.7),
},
});
Font.register({
family: "Inter",
fonts: [
{ src: interThin, fontWeight: "thin" },
{ src: interThin, fontWeight: "thin", fontStyle: "italic" },
{ src: interUltraLight, fontWeight: "ultralight" },
{ src: interUltraLight, fontWeight: "ultralight", fontStyle: "italic" },
{ src: interLight, fontWeight: "light" },
{ src: interLight, fontWeight: "light", fontStyle: "italic" },
{ src: interRegular, fontWeight: "normal" },
{ src: interRegular, fontWeight: "normal", fontStyle: "italic" },
{ src: interMedium, fontWeight: "medium" },
{ src: interMedium, fontWeight: "medium", fontStyle: "italic" },
{ src: interSemibold, fontWeight: "semibold" },
{ src: interSemibold, fontWeight: "semibold", fontStyle: "italic" },
{ src: interBold, fontWeight: "bold" },
{ src: interBold, fontWeight: "bold", fontStyle: "italic" },
{ src: interUltraBold, fontWeight: "ultrabold" },
{ src: interUltraBold, fontWeight: "ultrabold", fontStyle: "italic" },
{ src: interHeavy, fontWeight: "heavy" },
{ src: interHeavy, fontWeight: "heavy", fontStyle: "italic" },
],
});
type Props = {
content: string;
pageFormat: PageProps["size"];
};
export function PDFDocument(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,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./document";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// plane imports
import { Loader } from "@plane/ui";
import { cn } from "@plane/utils";
type Props = {
className?: string;
};
export function DescriptionInputLoader(props: Props) {
const { className } = props;
return (
<Loader className={cn("space-y-2", className)}>
<Loader.Item width="100%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<div className="flex items-center gap-2">
<Loader.Item width="26px" height="26px" />
<Loader.Item width="400px" height="26px" />
</div>
<Loader.Item width="80%" height="26px" />
<div className="flex items-center gap-2">
<Loader.Item width="50%" height="26px" />
</div>
<div className="border-0.5 absolute right-3.5 bottom-2 z-10 flex items-center gap-2">
<Loader.Item width="100px" height="26px" />
<Loader.Item width="50px" height="26px" />
</div>
</Loader>
);
}

View File

@@ -0,0 +1,297 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useState, useRef } from "react";
import { debounce } from "lodash-es";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import type { EditorRefApi, TExtensions } from "@plane/editor";
import { useTranslation } from "@plane/i18n";
import type { EFileAssetType, TNameDescriptionLoader } from "@plane/types";
import { getDescriptionPlaceholderI18n } from "@plane/utils";
// components
import { RichTextEditor } from "@/components/editor/rich-text";
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
// plane web services
import { WorkspaceService } from "@/services/workspace.service";
// local imports
import { DescriptionInputLoader } from "./loader";
// services init
const workspaceService = new WorkspaceService();
type TFormData = {
id: string;
description_html: string;
description_json?: object;
isMigrationUpdate: boolean;
};
type Props = {
/**
* @description Container class name, this will be used to add custom styles to the editor container
*/
containerClassName?: string;
/**
* @description Disabled, this will be used to disable the editor
*/
disabled?: boolean;
/**
* @description Disabled extensions, this will be used to disable the extensions in the editor
*/
disabledExtensions?: TExtensions[];
/**
* @description Editor ref, this will be used to imperatively attach editor related helper functions
*/
editorRef?: React.RefObject<EditorRefApi | null>;
/**
* @description Entity ID, this will be used for file uploads and as the unique identifier for the entity
*/
entityId: string;
/**
* @description File asset type, this will be used to upload the file to the editor
*/
fileAssetType: EFileAssetType;
/**
* @description Initial value, pass the actual description to initialize the editor
*/
initialValue: string | undefined;
/**
* @description Key, to ensure the editor is re-rendered when the key changes
*/
key: string;
/**
* @description Submit handler, the actual function which will be called when the form is submitted
*/
onSubmit: (
value: {
description_html: string;
description_json: object | undefined;
},
isMigrationUpdate?: boolean
) => Promise<void>;
/**
* @description Placeholder, if not provided, the placeholder will be the default placeholder
*/
placeholder?: string | ((isFocused: boolean, value: string) => string);
/**
* @description projectId, if not provided, the entity will be considered as a workspace entity
*/
projectId?: string;
/**
* @description Set is submitting, use it to set the loading state of the form
*/
setIsSubmitting: (initialValue: TNameDescriptionLoader) => void;
/**
* @description SWR description, use it only if you want to sync changes in realtime(pseudo realtime)
*/
swrDescription?: string | null | undefined;
/**
* @description Workspace slug, this will be used to get the workspace details
*/
workspaceSlug: string;
/**
* @description Issue sequence id, this will be used to get the issue sequence id
*/
issueSequenceId?: number;
};
/**
* @description DescriptionInput component for rich text editor with autosave functionality using debounce
* The component also makes an API call to save the description on unmount
*/
export const DescriptionInput = observer(function DescriptionInput(props: Props) {
const {
containerClassName,
disabled,
disabledExtensions,
editorRef,
entityId,
fileAssetType,
initialValue,
issueSequenceId,
onSubmit,
placeholder,
projectId,
setIsSubmitting,
swrDescription,
workspaceSlug,
} = props;
// states
const [localDescription, setLocalDescription] = useState<TFormData>({
id: entityId,
description_html: initialValue?.trim() ?? "",
isMigrationUpdate: false,
});
// ref to track if there are unsaved changes
const hasUnsavedChanges = useRef(false);
// ref to track last saved content (to skip onChange when content hasn't actually changed)
const lastSavedContent = useRef(initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>"));
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
// derived values
const workspaceDetails = getWorkspaceBySlug(workspaceSlug);
// translation
const { t } = useTranslation();
// form info
const { handleSubmit, reset, control, setValue } = useForm<TFormData>({
defaultValues: {
id: entityId,
description_html: initialValue?.trim() ?? "",
isMigrationUpdate: false,
},
});
// submit handler
const handleDescriptionFormSubmit = useCallback(
async (formData: TFormData) => {
await onSubmit(
{
description_html: formData.description_html,
description_json: formData.description_json,
},
formData.isMigrationUpdate
);
// Update lastSavedContent after successful save
lastSavedContent.current = formData.description_html;
},
[onSubmit]
);
// reset form values
useEffect(() => {
if (!entityId) return;
const normalizedValue = initialValue?.trim() === "" ? "<p></p>" : (initialValue ?? "<p></p>");
// Update last saved content when entity/initialValue changes
lastSavedContent.current = normalizedValue;
reset({
id: entityId,
description_html: normalizedValue,
isMigrationUpdate: false,
});
setLocalDescription({
id: entityId,
description_html: normalizedValue,
isMigrationUpdate: false,
});
// Reset unsaved changes flag when form is reset
hasUnsavedChanges.current = false;
}, [entityId, initialValue, reset]);
// ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS
// TODO: Verify the exhaustive-deps warning
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedFormSave = useCallback(
debounce(async () => {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => console.error(`Failed to save description for ${entityId}:`, error))
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}, 1500),
[entityId, handleSubmit]
);
// Save on unmount if there are unsaved changes
useEffect(
() => () => {
debouncedFormSave.cancel();
if (hasUnsavedChanges.current) {
handleSubmit(handleDescriptionFormSubmit)()
.catch((error) => {
console.error("Failed to save description on unmount:", error);
})
.finally(() => {
setIsSubmitting("submitted");
hasUnsavedChanges.current = false;
});
}
},
// since we don't want to save on unmount if there are no unsaved changes, no deps are needed
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
if (!workspaceDetails) return null;
if (!localDescription.description_html) return <DescriptionInputLoader />;
return (
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<RichTextEditor
key={entityId}
editable={!disabled}
ref={editorRef}
id={entityId}
issueSequenceId={issueSequenceId}
disabledExtensions={disabledExtensions}
initialValue={localDescription.description_html ?? "<p></p>"}
value={swrDescription ?? null}
workspaceSlug={workspaceSlug}
workspaceId={workspaceDetails.id}
projectId={projectId}
dragDropEnabled
onChange={(description_json, description_html, options) => {
if (description_html === lastSavedContent.current) return;
setIsSubmitting("submitting");
onChange(description_html);
setValue("isMigrationUpdate", !!options?.isMigrationUpdate);
setValue("description_json", description_json);
hasUnsavedChanges.current = true;
debouncedFormSave();
}}
placeholder={placeholder ?? ((isFocused, value) => t(getDescriptionPlaceholderI18n(isFocused, value)))}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug?.toString() ?? "", {
...payload,
project_id: projectId,
})
}
containerClassName={containerClassName}
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: entityId,
entity_type: fileAssetType,
},
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.log("Error in uploading asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
duplicateFile={async (assetId: string) => {
try {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityType: fileAssetType,
projectId,
workspaceSlug,
});
return asset_id;
} catch {
throw new Error("Asset duplication failed. Please try again later.");
}
}}
/>
)}
/>
);
});

View File

@@ -0,0 +1,106 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { 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";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web hooks
import { useEditorFlagging } from "@/hooks/use-editor-flagging";
type RichTextEditorWrapperProps = MakeOptional<
Omit<IRichTextEditorProps, "fileHandler" | "mentionHandler" | "extendedEditorProps">,
"disabledExtensions" | "editable" | "flaggedExtensions" | "getEditorMetaData"
> & {
workspaceSlug: string;
workspaceId: string;
projectId?: string;
issueSequenceId?: number;
} & (
| {
editable: false;
}
| {
editable: true;
searchMentionCallback: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
uploadFile: TFileHandler["upload"];
duplicateFile: TFileHandler["duplicate"];
}
);
export const RichTextEditor = forwardRef(function RichTextEditor(
props: RichTextEditorWrapperProps,
ref: React.ForwardedRef<EditorRefApi>
) {
const {
containerClassName,
editable,
workspaceSlug,
workspaceId,
projectId,
disabledExtensions: additionalDisabledExtensions = [],
...rest
} = props;
// store hooks
const { getUserDetails } = useMember();
// editor flaggings
const { richText: richTextEditorExtensions } = useEditorFlagging({
workspaceSlug,
projectId,
});
// use editor mention
const { fetchMentions } = useEditorMention({
searchEntity: editable ? async (payload) => await props.searchMentionCallback(payload) : async () => ({}),
});
// editor config
const { getEditorFileHandlers } = useEditorConfig();
// parse content
const { getEditorMetaData } = useParseEditorContent({
projectId,
workspaceSlug,
});
return (
<RichTextEditorWithRef
ref={ref}
disabledExtensions={[...richTextEditorExtensions.disabled, ...(additionalDisabledExtensions ?? [])]}
editable={editable}
flaggedExtensions={richTextEditorExtensions.flagged}
fileHandler={getEditorFileHandlers({
projectId,
uploadFile: editable ? props.uploadFile : async () => "",
duplicateFile: editable ? props.duplicateFile : async () => "",
workspaceId,
workspaceSlug,
})}
getEditorMetaData={getEditorMetaData}
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 pb-3 pl-3", containerClassName)}
/>
);
});
RichTextEditor.displayName = "RichTextEditor";

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./editor";

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { TSticky } from "@plane/types";
export const STICKY_COLORS_LIST: {
key: string;
label: string;
backgroundColor: string;
}[] = [
{
key: "gray",
label: "Gray",
backgroundColor: "var(--editor-colors-gray-background)",
},
{
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 function ColorPalette(props: TProps) {
const { handleUpdate } = props;
return (
<div className="shadow absolute bottom-5 left-0 z-10 mb-2 w-56 rounded-md bg-surface-1 p-2">
<div className="mb-2 text-13 font-semibold text-placeholder">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 transition-all hover:ring-2 hover:ring-accent-strong focus:ring-2 focus:ring-accent-strong focus:outline-none"
style={{
backgroundColor: color.backgroundColor,
}}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
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";
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
// plane web hooks
import { useEditorFlagging } from "@/hooks/use-editor-flagging";
import { StickyEditorToolbar } from "./toolbar";
interface StickyEditorWrapperProps extends Omit<
Omit<ILiteTextEditorProps, "extendedEditorProps">,
"disabledExtensions" | "editable" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "getEditorMetaData"
> {
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"];
duplicateFile: TFileHandler["duplicate"];
parentClassName?: string;
handleColorChange: (data: Partial<TSticky>) => Promise<void>;
handleDelete: () => void;
}
export const StickyEditor = React.forwardRef(function StickyEditor(
props: StickyEditorWrapperProps,
ref: React.ForwardedRef<EditorRefApi>
) {
const {
containerClassName,
workspaceSlug,
workspaceId,
projectId,
handleDelete,
handleColorChange,
showToolbarInitially = true,
showToolbar = true,
parentClassName = "",
uploadFile,
duplicateFile,
...rest
} = props;
// states
const [isFocused, setIsFocused] = useState(showToolbarInitially);
// editor flaggings
const { liteText: liteTextEditorExtensions } = useEditorFlagging({
workspaceSlug,
projectId,
});
// parse content
const { getEditorMetaData } = useParseEditorContent({
projectId,
workspaceSlug,
});
// 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 rounded-sm border border-subtle", 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,
duplicateFile,
workspaceId,
workspaceSlug,
})}
getEditorMetaData={getEditorMetaData}
mentionHandler={{
renderComponent: () => <></>,
}}
extendedEditorProps={{}}
containerClassName={cn(containerClassName, "relative")}
{...rest}
/>
{showToolbar && (
<div
className={cn("h-[60px] origin-top px-4 transition-all duration-300 ease-out", {
"max-h-[60px] scale-y-100 opacity-100": isFocused,
"invisible max-h-0 scale-y-0 opacity-0": !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,8 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./editor";
export * from "./toolbar";

View File

@@ -0,0 +1,113 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useEffect, useState, useCallback } from "react";
import { DeleteOutline, PaletteOutline } from "@makeplane/propel/icons";
// editor
import type { EditorRefApi } from "@plane/editor";
// ui
import { useOutsideClickDetector } from "@plane/hooks";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TSticky } from "@plane/types";
// constants
import { cn } from "@plane/utils";
import type { ToolbarMenuItem } from "@plane/editor";
import { TOOLBAR_ITEMS } from "@plane/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 function StickyEditorToolbar(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 h-full w-full justify-between">
<div className="my-auto flex gap-4" ref={colorPaletteRef}>
{/* color palette */}
{showColorPalette && <ColorPalette handleUpdate={handleColorChange} />}
<Tooltip label="Background color">
<button type="button" onClick={() => setShowColorPalette(!showColorPalette)} className="flex text-primary/50">
<PaletteOutline className="my-auto size-4" />
</button>
</Tooltip>
<div className="my-auto flex w-fit items-stretch justify-between gap-4 rounded-sm p-1">
<div className="my-auto flex items-stretch 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} label={item.name} shortcut={item.shortcut?.join(" + ")}>
<button
type="button"
onClick={() => executeCommand(item)}
className={cn("grid aspect-square place-items-center rounded-xs p-0.5 text-primary/50", {})}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"font-heavy": isItemActive,
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
);
})}
</div>
))}
</div>
</div>
</div>
{/* delete action */}
<Tooltip label="Delete">
<button type="button" onClick={handleDelete} className="my-auto text-primary/50">
<DeleteOutline className="size-4" />
</button>
</Tooltip>
</div>
);
}