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
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:
105
apps/web/core/components/editor/document/editor.tsx
Normal file
105
apps/web/core/components/editor/document/editor.tsx
Normal 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";
|
||||
7
apps/web/core/components/editor/embeds/mentions/index.ts
Normal file
7
apps/web/core/components/editor/embeds/mentions/index.ts
Normal 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";
|
||||
20
apps/web/core/components/editor/embeds/mentions/root.tsx
Normal file
20
apps/web/core/components/editor/embeds/mentions/root.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
84
apps/web/core/components/editor/embeds/mentions/user.tsx
Normal file
84
apps/web/core/components/editor/embeds/mentions/user.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
227
apps/web/core/components/editor/lite-text/editor.tsx
Normal file
227
apps/web/core/components/editor/lite-text/editor.tsx
Normal 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";
|
||||
8
apps/web/core/components/editor/lite-text/index.ts
Normal file
8
apps/web/core/components/editor/lite-text/index.ts
Normal 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";
|
||||
42
apps/web/core/components/editor/lite-text/lite-toolbar.tsx
Normal file
42
apps/web/core/components/editor/lite-text/lite-toolbar.tsx
Normal 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 };
|
||||
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 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
237
apps/web/core/components/editor/pdf/document.tsx
Normal file
237
apps/web/core/components/editor/pdf/document.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/web/core/components/editor/pdf/index.ts
Normal file
7
apps/web/core/components/editor/pdf/index.ts
Normal 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";
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
106
apps/web/core/components/editor/rich-text/editor.tsx
Normal file
106
apps/web/core/components/editor/rich-text/editor.tsx
Normal 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";
|
||||
7
apps/web/core/components/editor/rich-text/index.ts
Normal file
7
apps/web/core/components/editor/rich-text/index.ts
Normal 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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
135
apps/web/core/components/editor/sticky-editor/editor.tsx
Normal file
135
apps/web/core/components/editor/sticky-editor/editor.tsx
Normal 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";
|
||||
8
apps/web/core/components/editor/sticky-editor/index.ts
Normal file
8
apps/web/core/components/editor/sticky-editor/index.ts
Normal 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";
|
||||
113
apps/web/core/components/editor/sticky-editor/toolbar.tsx
Normal file
113
apps/web/core/components/editor/sticky-editor/toolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user