[PE-93] refactor: editor mentions extension (#6178)
* refactor: editor mentions * fix: build errors * fix: build errors * chore: add cycle status to search endpoint response * fix: build errors * fix: dynamic mention content in markdown * chore: update entity search endpoint * style: user mention popover * chore: edition specific mention content handler * chore: show deactivated user for old mentions * chore: update search entity keys * refactor: use editor mention hook
This commit is contained in:
parent
c10b875e2a
commit
119d343d5f
78 changed files with 1491 additions and 992 deletions
1
web/ce/components/editor/embeds/index.ts
Normal file
1
web/ce/components/editor/embeds/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./mentions";
|
||||
1
web/ce/components/editor/embeds/mentions/index.ts
Normal file
1
web/ce/components/editor/embeds/mentions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
4
web/ce/components/editor/embeds/mentions/root.tsx
Normal file
4
web/ce/components/editor/embeds/mentions/root.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// plane editor
|
||||
import { TMentionComponentProps } from "@plane/editor";
|
||||
|
||||
export const EditorAdditionalMentionsRoot: React.FC<TMentionComponentProps> = () => null;
|
||||
1
web/ce/components/editor/index.ts
Normal file
1
web/ce/components/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./embeds";
|
||||
4
web/ce/constants/editor.ts
Normal file
4
web/ce/constants/editor.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// plane types
|
||||
import { TSearchEntities } from "@plane/types";
|
||||
|
||||
export const EDITOR_MENTION_TYPES: TSearchEntities[] = ["user_mention"];
|
||||
41
web/ce/hooks/use-additional-editor-mention.tsx
Normal file
41
web/ce/hooks/use-additional-editor-mention.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useCallback } from "react";
|
||||
// plane editor
|
||||
import { TMentionSection } from "@plane/editor";
|
||||
// plane types
|
||||
import { TSearchEntities, TSearchResponse } from "@plane/types";
|
||||
|
||||
export type TAdditionalEditorMentionHandlerArgs = {
|
||||
response: TSearchResponse;
|
||||
sections: TMentionSection[];
|
||||
};
|
||||
|
||||
export type TAdditionalParseEditorContentArgs = {
|
||||
id: string;
|
||||
entityType: TSearchEntities;
|
||||
};
|
||||
|
||||
export type TAdditionalParseEditorContentReturnType =
|
||||
| {
|
||||
redirectionPath: string;
|
||||
textContent: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
export const useAdditionalEditorMention = () => {
|
||||
const updateAdditionalSections = useCallback((args: TAdditionalEditorMentionHandlerArgs) => {
|
||||
const {} = args;
|
||||
}, []);
|
||||
|
||||
const parseAdditionalEditorContent = useCallback(
|
||||
(args: TAdditionalParseEditorContentArgs): TAdditionalParseEditorContentReturnType => {
|
||||
const {} = args;
|
||||
return undefined;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
updateAdditionalSections,
|
||||
parseAdditionalEditorContent,
|
||||
};
|
||||
};
|
||||
1
web/core/components/editor/embeds/index.ts
Normal file
1
web/core/components/editor/embeds/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./mentions";
|
||||
1
web/core/components/editor/embeds/mentions/index.ts
Normal file
1
web/core/components/editor/embeds/mentions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "./root";
|
||||
17
web/core/components/editor/embeds/mentions/root.tsx
Normal file
17
web/core/components/editor/embeds/mentions/root.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// plane editor
|
||||
import { TMentionComponentProps } from "@plane/editor";
|
||||
// plane web components
|
||||
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
|
||||
// local components
|
||||
import { EditorUserMention } from "./user";
|
||||
|
||||
export const EditorMentionsRoot: React.FC<TMentionComponentProps> = (props) => {
|
||||
const { entity_identifier, entity_name } = props;
|
||||
|
||||
switch (entity_name) {
|
||||
case "user_mention":
|
||||
return <EditorUserMention id={entity_identifier} />;
|
||||
default:
|
||||
return <EditorAdditionalMentionsRoot {...props} />;
|
||||
}
|
||||
};
|
||||
96
web/core/components/editor/embeds/mentions/user.tsx
Normal file
96
web/core/components/editor/embeds/mentions/user.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { usePopper } from "react-popper";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// constants
|
||||
import { ROLE } from "@/constants/workspace";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember, useUser } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export const EditorUserMention: React.FC<Props> = observer((props) => {
|
||||
const { id } = props;
|
||||
// states
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLAnchorElement | null>(null);
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberDetails },
|
||||
} = useMember();
|
||||
// popper-js refs
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// derived values
|
||||
const userDetails = getUserDetails(id);
|
||||
const roleDetails = getProjectMemberDetails(id)?.role;
|
||||
const profileLink = `/${workspaceSlug}/profile/${id}`;
|
||||
|
||||
if (!userDetails) {
|
||||
return (
|
||||
<div className="not-prose inline px-1 py-0.5 rounded bg-custom-background-80 text-custom-text-300 no-underline">
|
||||
@deactivated user
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose group/user-mention inline px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-500 no-underline",
|
||||
{
|
||||
"bg-custom-primary-100/20 text-custom-primary-100": id === currentUser?.id,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Link href={profileLink} ref={setReferenceElement}>
|
||||
@{userDetails?.display_name}
|
||||
</Link>
|
||||
<div
|
||||
className="top-full left-0 z-10 min-w-60 bg-custom-background-90 shadow-custom-shadow-rg rounded-lg p-4 opacity-0 pointer-events-none group-hover/user-mention:opacity-100 group-hover/user-mention:pointer-events-auto transition-opacity"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0 size-10 grid place-items-center">
|
||||
<Avatar
|
||||
src={getFileURL(userDetails?.avatar_url ?? "")}
|
||||
name={userDetails?.display_name}
|
||||
size={40}
|
||||
className="text-xl"
|
||||
showTooltip={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Link href={profileLink} className="not-prose font-medium text-custom-text-100 text-sm hover:underline">
|
||||
{userDetails?.first_name} {userDetails?.last_name}
|
||||
</Link>
|
||||
{roleDetails && <p className="text-custom-text-200 text-xs">{ROLE[roleDetails]}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./embeds";
|
||||
export * from "./lite-text-editor";
|
||||
export * from "./pdf";
|
||||
export * from "./rich-text-editor";
|
||||
|
|
|
|||
|
|
@ -1,20 +1,22 @@
|
|||
import React, { useState } from "react";
|
||||
// editor
|
||||
// plane constants
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane editor
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import { IssueCommentToolbar } from "@/components/editor";
|
||||
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
||||
import { useEditorMention } from "@/hooks/use-editor-mention";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
const projectService = new ProjectService();
|
||||
|
||||
interface LiteTextEditorWrapperProps
|
||||
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||
|
|
@ -48,23 +50,12 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
|||
} = props;
|
||||
// states
|
||||
const [isFocused, setIsFocused] = useState(showToolbarInitially);
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// editor flaggings
|
||||
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||
// derived values
|
||||
const projectMemberIds = getProjectMemberIds(projectId);
|
||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
||||
// use-mention
|
||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
members: projectMemberDetails,
|
||||
user: currentUser ?? undefined,
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: async (payload) =>
|
||||
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||
});
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
|
|
@ -92,8 +83,12 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
|||
workspaceSlug,
|
||||
})}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
searchCallback: async (query) => {
|
||||
const res = await fetchMentions(query);
|
||||
if (!res) throw new Error("Failed in fetching mentions");
|
||||
return res;
|
||||
},
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
containerClassName={cn(containerClassName, "relative")}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
// editor
|
||||
// plane editor
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useMention, useUser } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
|
||||
|
|
@ -19,11 +19,6 @@ type LiteTextReadOnlyEditorWrapperProps = Omit<
|
|||
|
||||
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
|
||||
({ workspaceSlug, projectId, ...props }, ref) => {
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { mentionHighlights } = useMention({
|
||||
user: currentUser,
|
||||
});
|
||||
// editor flaggings
|
||||
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||
|
||||
|
|
@ -36,7 +31,7 @@ export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Lit
|
|||
workspaceSlug,
|
||||
})}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
}}
|
||||
{...props}
|
||||
// overriding the containerClassName to add relative class passed
|
||||
|
|
|
|||
|
|
@ -1,41 +1,36 @@
|
|||
import React, { forwardRef } from "react";
|
||||
// editor
|
||||
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
||||
import { useEditorMention } from "@/hooks/use-editor-mention";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||
// services
|
||||
import { ProjectService } from "@/services/project";
|
||||
const projectService = new ProjectService();
|
||||
|
||||
interface RichTextEditorWrapperProps
|
||||
extends Omit<IRichTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||
workspaceSlug: string;
|
||||
workspaceId: string;
|
||||
memberIds: string[];
|
||||
projectId?: string;
|
||||
uploadFile: (file: File) => Promise<string>;
|
||||
}
|
||||
|
||||
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
|
||||
const { containerClassName, workspaceSlug, workspaceId, projectId, memberIds, uploadFile, ...rest } = props;
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getUserDetails } = useMember();
|
||||
const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props;
|
||||
// editor flaggings
|
||||
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||
// derived values
|
||||
const memberDetails = memberIds?.map((id) => getUserDetails(id) as IUserLite);
|
||||
// use-mention
|
||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
members: memberDetails,
|
||||
user: currentUser ?? undefined,
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: async (payload) =>
|
||||
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||
});
|
||||
// file size
|
||||
const { maxFileSize } = useFileSize();
|
||||
|
|
@ -52,8 +47,12 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
|||
workspaceSlug,
|
||||
})}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
searchCallback: async (query) => {
|
||||
const res = await fetchMentions(query);
|
||||
if (!res) throw new Error("Failed in fetching mentions");
|
||||
return res;
|
||||
},
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
}}
|
||||
{...rest}
|
||||
containerClassName={cn("relative pl-3", containerClassName)}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
// editor
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useMention } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ type RichTextReadOnlyEditorWrapperProps = Omit<
|
|||
|
||||
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
|
||||
({ workspaceSlug, projectId, ...props }, ref) => {
|
||||
const { mentionHighlights } = useMention({});
|
||||
// editor flaggings
|
||||
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||
|
||||
|
|
@ -32,7 +31,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
|
|||
workspaceSlug,
|
||||
})}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
}}
|
||||
{...props}
|
||||
// overriding the containerClassName to add relative class passed
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { ETabIndices } from "@/constants/tab-indices";
|
|||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
// hooks
|
||||
import { useMember, useProjectInbox } from "@/hooks/store";
|
||||
import { useProjectInbox } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
|
@ -51,11 +51,6 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
|||
// hooks
|
||||
const { loader } = useProjectInbox();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const memberIds = getProjectMemberIds(projectId) ?? [];
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile);
|
||||
|
||||
|
|
@ -73,7 +68,6 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
|||
ref={editorRef}
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceId={workspaceId}
|
||||
memberIds={memberIds}
|
||||
projectId={projectId}
|
||||
dragDropEnabled={false}
|
||||
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { TIssueOperations } from "@/components/issues/issue-detail";
|
|||
// helpers
|
||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useMember, useWorkspace } from "@/hooks/store";
|
||||
import { useWorkspace } from "@/hooks/store";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
const fileService = new FileService();
|
||||
|
|
@ -46,12 +46,6 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
|
|||
setIsSubmitting,
|
||||
placeholder,
|
||||
} = props;
|
||||
// store hooks
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const memberIds = getProjectMemberIds(projectId) ?? [];
|
||||
|
||||
const { handleSubmit, reset, control } = useForm<TIssue>({
|
||||
defaultValues: {
|
||||
|
|
@ -114,7 +108,6 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
|
|||
value={swrIssueDescription ?? null}
|
||||
workspaceSlug={workspaceSlug}
|
||||
workspaceId={workspaceId}
|
||||
memberIds={memberIds}
|
||||
projectId={projectId}
|
||||
dragDropEnabled
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { ETabIndices } from "@/constants/tab-indices";
|
|||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||
// hooks
|
||||
import { useInstance, useMember, useWorkspace } from "@/hooks/store";
|
||||
import { useInstance, useWorkspace } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// services
|
||||
|
|
@ -76,11 +76,6 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
|||
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
|
||||
const { config } = useInstance();
|
||||
const { isMobile } = usePlatformOS();
|
||||
const {
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const memberIds = projectId ? (getProjectMemberIds(projectId) ?? []) : [];
|
||||
|
||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||
|
||||
|
|
@ -184,7 +179,6 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
|||
value={descriptionHtmlData}
|
||||
workspaceSlug={workspaceSlug?.toString() as string}
|
||||
workspaceId={workspaceId}
|
||||
memberIds={memberIds}
|
||||
projectId={projectId}
|
||||
onChange={(_description: object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
|
|
|
|||
|
|
@ -11,17 +11,18 @@ import {
|
|||
TServerHandler,
|
||||
} from "@plane/editor";
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
import { EFileAssetType } from "@plane/types/src/enums";
|
||||
// components
|
||||
import { Row } from "@plane/ui";
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
|
||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
import { generateRandomColor } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { useUser, useWorkspace } from "@/hooks/store";
|
||||
import { useEditorMention } from "@/hooks/use-editor-mention";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// plane web components
|
||||
import { EditorAIMenu } from "@/plane-web/components/pages";
|
||||
|
|
@ -31,11 +32,12 @@ import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
|||
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
import { ProjectService } from "@/services/project";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
||||
// services init
|
||||
const fileService = new FileService();
|
||||
const projectService = new ProjectService();
|
||||
|
||||
type Props = {
|
||||
editorRef: React.RefObject<EditorRefApi>;
|
||||
|
|
@ -53,23 +55,15 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const { getWorkspaceBySlug } = useWorkspace();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// derived values
|
||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
||||
const pageId = page?.id;
|
||||
const pageTitle = page?.name ?? "";
|
||||
const { isContentEditable, updateTitle } = page;
|
||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
|
||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
||||
// use-mention
|
||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
projectId: projectId?.toString() ?? "",
|
||||
members: projectMemberDetails,
|
||||
user: currentUser ?? undefined,
|
||||
// use editor mention
|
||||
const { fetchMentions } = useEditorMention({
|
||||
searchEntity: async (payload) =>
|
||||
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||
});
|
||||
// editor flaggings
|
||||
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||
|
|
@ -199,8 +193,12 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
|||
displayConfig={displayConfig}
|
||||
editorClassName="pl-10"
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
suggestions: mentionSuggestions,
|
||||
searchCallback: async (query) => {
|
||||
const res = await fetchMentions(query);
|
||||
if (!res) throw new Error("Failed in fetching mentions");
|
||||
return res;
|
||||
},
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
}}
|
||||
embedHandler={{
|
||||
issue: issueEmbedProps,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper
|
|||
// hooks
|
||||
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
import { useQueryParams } from "@/hooks/use-query-params";
|
||||
// store
|
||||
import { IPage } from "@/store/pages/page";
|
||||
|
|
@ -59,6 +60,8 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
|||
const { updateQueryParams } = useQueryParams();
|
||||
// collaborative actions
|
||||
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
|
||||
// parse editor content
|
||||
const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent();
|
||||
|
||||
// menu items list
|
||||
const MENU_ITEMS: {
|
||||
|
|
@ -72,7 +75,11 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
|||
key: "copy-markdown",
|
||||
action: () => {
|
||||
if (!editorRef) return;
|
||||
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
||||
const markdownContent = editorRef.getMarkDown();
|
||||
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
|
||||
markdownContent,
|
||||
});
|
||||
copyTextToClipboard(parsedMarkdownContent).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
|
|
|
|||
|
|
@ -9,11 +9,8 @@ import { EditorRefApi } from "@plane/editor";
|
|||
import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { PDFDocument } from "@/components/editor";
|
||||
// helpers
|
||||
import {
|
||||
replaceCustomComponentsFromHTMLContent,
|
||||
replaceCustomComponentsFromMarkdownContent,
|
||||
} from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||
|
||||
type Props = {
|
||||
editorRef: EditorRefApi | null;
|
||||
|
|
@ -104,6 +101,9 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
|||
const { control, reset, watch } = useForm<TFormValues>({
|
||||
defaultValues,
|
||||
});
|
||||
// parse editor content
|
||||
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } =
|
||||
useParseEditorContent();
|
||||
// derived values
|
||||
const selectedExportFormat = watch("export_format");
|
||||
const selectedPageFormat = watch("page_format");
|
||||
|
|
@ -179,6 +179,7 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
|||
});
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Error in exporting page:", error);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
|
|
|
|||
|
|
@ -3,13 +3,14 @@ import { useParams } from "next/navigation";
|
|||
// plane editor
|
||||
import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
|
||||
// plane types
|
||||
import { IUserLite, TPageVersion } from "@plane/types";
|
||||
import { TPageVersion } from "@plane/types";
|
||||
// plane ui
|
||||
import { Loader } from "@plane/ui";
|
||||
// components
|
||||
import { EditorMentionsRoot } from "@/components/editor";
|
||||
// helpers
|
||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
|
|
@ -26,26 +27,10 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
|
|||
const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails } = props;
|
||||
// params
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
const { data: currentUser } = useUser();
|
||||
const {
|
||||
getUserDetails,
|
||||
project: { getProjectMemberIds },
|
||||
} = useMember();
|
||||
// editor flaggings
|
||||
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? "");
|
||||
// derived values
|
||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
|
||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
||||
// issue-embed
|
||||
const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
|
||||
// use-mention
|
||||
const { mentionHighlights } = useMention({
|
||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
projectId: projectId?.toString() ?? "",
|
||||
members: projectMemberDetails,
|
||||
user: currentUser ?? undefined,
|
||||
});
|
||||
// page filters
|
||||
const { fontSize, fontStyle } = usePageFilters();
|
||||
|
||||
|
|
@ -112,7 +97,7 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
|
|||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||
})}
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||
}}
|
||||
embedHandler={{
|
||||
issue: {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ export * from "./use-issues";
|
|||
export * from "./use-kanban-view";
|
||||
export * from "./use-label";
|
||||
export * from "./use-member";
|
||||
export * from "./use-mention";
|
||||
export * from "./use-module";
|
||||
export * from "./use-module-filter";
|
||||
export * from "./use-multiple-select-store";
|
||||
|
|
|
|||
|
|
@ -1,104 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
// plane editor
|
||||
import { IMentionSuggestion } from "@plane/editor";
|
||||
// plane types
|
||||
import { IUser, IUserLite } from "@plane/types";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug?: string;
|
||||
projectId?: string;
|
||||
members?: IUserLite[] | undefined;
|
||||
user?: IUser | undefined;
|
||||
};
|
||||
|
||||
export const useMention = ({ workspaceSlug, projectId, members, user }: Props) => {
|
||||
const projectMembersRef = useRef<IUserLite[] | undefined>();
|
||||
const userRef = useRef<IUser | undefined>();
|
||||
|
||||
const {
|
||||
project: { fetchProjectMembers },
|
||||
} = useMember();
|
||||
|
||||
useEffect(() => {
|
||||
if (members) projectMembersRef.current = members;
|
||||
else {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
fetchProjectMembers(workspaceSlug.toString(), projectId.toString());
|
||||
}
|
||||
}, [fetchProjectMembers, members, projectId, workspaceSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userRef) userRef.current = user;
|
||||
}, [user]);
|
||||
|
||||
const waitForUserData = async () =>
|
||||
new Promise<IUser>((resolve) => {
|
||||
const checkData = () => {
|
||||
if (userRef.current) resolve(userRef.current);
|
||||
else setTimeout(checkData, 100);
|
||||
};
|
||||
checkData();
|
||||
});
|
||||
|
||||
const mentionHighlights = async () => {
|
||||
if (user && userRef.current) {
|
||||
return [userRef.current.id];
|
||||
} else {
|
||||
const userData = await waitForUserData();
|
||||
return [userData.id];
|
||||
}
|
||||
};
|
||||
|
||||
// Polling function to wait for projectMembersRef.current to be populated
|
||||
const waitForData = async () =>
|
||||
new Promise<IUserLite[]>((resolve) => {
|
||||
const checkData = () => {
|
||||
if (projectMembersRef.current && projectMembersRef.current.length > 0) {
|
||||
resolve(projectMembersRef.current);
|
||||
} else {
|
||||
setTimeout(checkData, 100); // Check every 100ms
|
||||
}
|
||||
};
|
||||
checkData();
|
||||
});
|
||||
|
||||
const mentionSuggestions = async (): Promise<IMentionSuggestion[]> => {
|
||||
if (members && projectMembersRef.current && projectMembersRef.current.length > 0) {
|
||||
// If data is already available, return it immediately
|
||||
return projectMembersRef.current.map((memberDetails) => ({
|
||||
entity_name: "user_mention",
|
||||
entity_identifier: `${memberDetails?.id}`,
|
||||
id: `${memberDetails?.id}`,
|
||||
type: "User",
|
||||
title: `${memberDetails?.display_name}`,
|
||||
subtitle: memberDetails?.email ?? "",
|
||||
avatar: getFileURL(memberDetails?.avatar_url) ?? "",
|
||||
redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.id}`,
|
||||
}));
|
||||
} else {
|
||||
// Wait for data to be available
|
||||
const membersList = await waitForData();
|
||||
return membersList.map((memberDetails) => ({
|
||||
entity_name: "user_mention",
|
||||
entity_identifier: `${memberDetails?.id}`,
|
||||
id: `${memberDetails?.id}`,
|
||||
type: "User",
|
||||
title: `${memberDetails?.display_name}`,
|
||||
subtitle: memberDetails?.email ?? "",
|
||||
avatar: getFileURL(memberDetails?.avatar_url) ?? "",
|
||||
redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.id}`,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
mentionSuggestions,
|
||||
mentionHighlights,
|
||||
};
|
||||
};
|
||||
76
web/core/hooks/use-editor-mention.tsx
Normal file
76
web/core/hooks/use-editor-mention.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useCallback } from "react";
|
||||
// plane editor
|
||||
import { TMentionSection, TMentionSuggestion } from "@plane/editor";
|
||||
// plane types
|
||||
import { TSearchEntities, TSearchEntityRequestPayload, TSearchResponse, TUserSearchResponse } from "@plane/types";
|
||||
// plane ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
// helpers
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// plane web constants
|
||||
import { EDITOR_MENTION_TYPES } from "@/plane-web/constants/editor";
|
||||
// plane web hooks
|
||||
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
|
||||
|
||||
type TArgs = {
|
||||
searchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||
};
|
||||
|
||||
export const useEditorMention = (args: TArgs) => {
|
||||
const { searchEntity } = args;
|
||||
// additional mentions
|
||||
const { updateAdditionalSections } = useAdditionalEditorMention();
|
||||
// fetch mentions handler
|
||||
const fetchMentions = useCallback(
|
||||
async (query: string): Promise<TMentionSection[]> => {
|
||||
try {
|
||||
const res = await searchEntity({
|
||||
count: 5,
|
||||
query_type: EDITOR_MENTION_TYPES,
|
||||
query,
|
||||
});
|
||||
const suggestionSections: TMentionSection[] = [];
|
||||
if (!res) {
|
||||
throw new Error("No response found");
|
||||
}
|
||||
Object.keys(res).map((key) => {
|
||||
const responseKey = key as TSearchEntities;
|
||||
const response = res[responseKey];
|
||||
if (responseKey === "user_mention" && response && response.length > 0) {
|
||||
const items: TMentionSuggestion[] = (response as TUserSearchResponse[]).map((user) => ({
|
||||
icon: (
|
||||
<Avatar
|
||||
className="flex-shrink-0"
|
||||
src={getFileURL(user.member__avatar_url)}
|
||||
name={user.member__display_name}
|
||||
/>
|
||||
),
|
||||
id: user.member__id,
|
||||
entity_identifier: user.member__id,
|
||||
entity_name: "user_mention",
|
||||
title: user.member__display_name,
|
||||
}));
|
||||
suggestionSections.push({
|
||||
key: "users",
|
||||
title: "Users",
|
||||
items,
|
||||
});
|
||||
}
|
||||
});
|
||||
updateAdditionalSections({
|
||||
response: res,
|
||||
sections: suggestionSections,
|
||||
});
|
||||
return suggestionSections;
|
||||
} catch (error) {
|
||||
console.error("Error in fetching mentions for project pages:", error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[searchEntity, updateAdditionalSections]
|
||||
);
|
||||
|
||||
return {
|
||||
fetchMentions,
|
||||
};
|
||||
};
|
||||
215
web/core/hooks/use-parse-editor-content.ts
Normal file
215
web/core/hooks/use-parse-editor-content.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { useCallback } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// plane types
|
||||
import { TSearchEntities } from "@plane/types";
|
||||
// helpers
|
||||
import { getBase64Image } from "@/helpers/file.helper";
|
||||
// hooks
|
||||
import { useMember } from "@/hooks/store";
|
||||
// plane web hooks
|
||||
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
|
||||
|
||||
export const useParseEditorContent = () => {
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
// parse additional content
|
||||
const { parseAdditionalEditorContent } = useAdditionalEditorMention();
|
||||
|
||||
/**
|
||||
* @description function to replace all the custom components from the html component to make it pdf compatible
|
||||
* @param props
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const replaceCustomComponentsFromHTMLContent = useCallback(
|
||||
async (props: { htmlContent: string; noAssets?: boolean }): Promise<string> => {
|
||||
const { htmlContent, noAssets = false } = props;
|
||||
// create a DOM parser
|
||||
const parser = new DOMParser();
|
||||
// parse the HTML string into a DOM document
|
||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||
// replace all mention-component elements
|
||||
const mentionComponents = doc.querySelectorAll("mention-component");
|
||||
mentionComponents.forEach((component) => {
|
||||
// create a span element to replace the mention-component
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("data-node-type", "mention-block");
|
||||
// get the user id from the component
|
||||
const id = component.getAttribute("entity_identifier") || "";
|
||||
const entityType = (component.getAttribute("entity_name") || "user_mention") as TSearchEntities;
|
||||
let textContent = "user";
|
||||
if (entityType === "user_mention") {
|
||||
const userDetails = getUserDetails(id);
|
||||
textContent = userDetails?.display_name ?? "";
|
||||
} else {
|
||||
const mentionDetails = parseAdditionalEditorContent({
|
||||
id,
|
||||
entityType,
|
||||
});
|
||||
if (mentionDetails) {
|
||||
textContent = mentionDetails.textContent;
|
||||
}
|
||||
}
|
||||
span.textContent = `@${textContent}`;
|
||||
// replace the mention-component with the span element
|
||||
component.replaceWith(span);
|
||||
});
|
||||
// handle code inside pre elements
|
||||
const preElements = doc.querySelectorAll("pre");
|
||||
preElements.forEach((preElement) => {
|
||||
const codeElement = preElement.querySelector("code");
|
||||
if (codeElement) {
|
||||
// create a div element with the required attributes for code blocks
|
||||
const div = doc.createElement("div");
|
||||
div.setAttribute("data-node-type", "code-block");
|
||||
div.setAttribute("class", "courier");
|
||||
// transfer the content from the code block
|
||||
div.innerHTML = codeElement.innerHTML.replace(/\n/g, "<br>") || "";
|
||||
// replace the pre element with the new div
|
||||
preElement.replaceWith(div);
|
||||
}
|
||||
});
|
||||
// handle inline code elements (not inside pre tags)
|
||||
const inlineCodeElements = doc.querySelectorAll("code");
|
||||
inlineCodeElements.forEach((codeElement) => {
|
||||
// check if the code element is inside a pre element
|
||||
if (!codeElement.closest("pre")) {
|
||||
// create a span element with the required attributes for inline code blocks
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("data-node-type", "inline-code-block");
|
||||
span.setAttribute("class", "courier-bold");
|
||||
// transfer the code content
|
||||
span.textContent = codeElement.textContent || "";
|
||||
// replace the standalone code element with the new span
|
||||
codeElement.replaceWith(span);
|
||||
}
|
||||
});
|
||||
// handle image-component elements
|
||||
const imageComponents = doc.querySelectorAll("image-component");
|
||||
if (noAssets) {
|
||||
// if no assets is enabled, remove the image component elements
|
||||
imageComponents.forEach((component) => component.remove());
|
||||
// remove default img elements
|
||||
const imageElements = doc.querySelectorAll("img");
|
||||
imageElements.forEach((img) => img.remove());
|
||||
} else {
|
||||
// if no assets is not enabled, replace the image component elements with img elements
|
||||
imageComponents.forEach((component) => {
|
||||
// get the image src from the component
|
||||
const src = component.getAttribute("src") ?? "";
|
||||
const height = component.getAttribute("height") ?? "";
|
||||
const width = component.getAttribute("width") ?? "";
|
||||
// create an img element to replace the image-component
|
||||
const img = doc.createElement("img");
|
||||
img.src = src;
|
||||
img.style.height = height;
|
||||
img.style.width = width;
|
||||
// replace the image-component with the img element
|
||||
component.replaceWith(img);
|
||||
});
|
||||
}
|
||||
// convert all images to base64
|
||||
const imgElements = doc.querySelectorAll("img");
|
||||
await Promise.all(
|
||||
Array.from(imgElements).map(async (img) => {
|
||||
// get the image src from the img element
|
||||
const src = img.getAttribute("src");
|
||||
if (src) {
|
||||
try {
|
||||
const base64Image = await getBase64Image(src);
|
||||
img.src = base64Image;
|
||||
} catch (error) {
|
||||
// log the error if the image conversion fails
|
||||
console.error("Failed to convert image to base64:", error);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
// replace all checkbox elements
|
||||
const checkboxComponents = doc.querySelectorAll("input[type='checkbox']");
|
||||
checkboxComponents.forEach((component) => {
|
||||
// get the checked status from the element
|
||||
const checked = component.getAttribute("checked");
|
||||
// create a div element to replace the input element
|
||||
const div = doc.createElement("div");
|
||||
div.classList.value = "input-checkbox";
|
||||
// add the checked class if the checkbox is checked
|
||||
if (checked === "checked" || checked === "true") div.classList.add("checked");
|
||||
// replace the input element with the div element
|
||||
component.replaceWith(div);
|
||||
});
|
||||
// remove all issue-embed-component elements
|
||||
const issueEmbedComponents = doc.querySelectorAll("issue-embed-component");
|
||||
issueEmbedComponents.forEach((component) => component.remove());
|
||||
// serialize the document back into a string
|
||||
let serializedDoc = doc.body.innerHTML;
|
||||
// remove null colors from table elements
|
||||
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
||||
return serializedDoc;
|
||||
},
|
||||
[getUserDetails]
|
||||
);
|
||||
|
||||
/**
|
||||
* @description function to replace all the custom components from the markdown content
|
||||
* @param props
|
||||
* @returns {string}
|
||||
*/
|
||||
const replaceCustomComponentsFromMarkdownContent = useCallback(
|
||||
(props: { markdownContent: string; noAssets?: boolean }): string => {
|
||||
const start = performance.now();
|
||||
const { markdownContent, noAssets = false } = props;
|
||||
let parsedMarkdownContent = markdownContent;
|
||||
// replace the matched mention components with [display_name](redirect_url)
|
||||
const mentionRegex =
|
||||
/<mention-component[^>]*entity_identifier="([^"]+)"[^>]*entity_name="([^"]+)"[^>]*><\/mention-component>/g;
|
||||
const originUrl = typeof window !== "undefined" && (window.location.origin ?? "");
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(mentionRegex, (_match, id, entity_type) => {
|
||||
const entityType = entity_type as TSearchEntities;
|
||||
if (!id || !entityType) return "";
|
||||
if (entityType === "user_mention") {
|
||||
const userDetails = getUserDetails(id);
|
||||
if (!userDetails) return "";
|
||||
return `[${userDetails.display_name}](${originUrl}/${workspaceSlug}/profile/${id})`;
|
||||
} else {
|
||||
const mentionDetails = parseAdditionalEditorContent({
|
||||
id,
|
||||
entityType,
|
||||
});
|
||||
if (!mentionDetails) {
|
||||
return "";
|
||||
} else {
|
||||
const { redirectionPath, textContent } = mentionDetails;
|
||||
return `[${textContent}](${originUrl}/${redirectionPath})`;
|
||||
}
|
||||
}
|
||||
});
|
||||
// replace the matched image components with <img src={src} >
|
||||
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
|
||||
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
|
||||
if (noAssets) {
|
||||
// remove all image components
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
|
||||
} else {
|
||||
// replace the matched image components with <img src={src} >
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(
|
||||
imageComponentRegex,
|
||||
(_match, src) => `<img src="${src}" >`
|
||||
);
|
||||
}
|
||||
// remove all issue-embed components
|
||||
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
||||
const end = performance.now();
|
||||
console.log("Exec time:", end - start);
|
||||
return parsedMarkdownContent;
|
||||
},
|
||||
[getUserDetails, workspaceSlug]
|
||||
);
|
||||
|
||||
return {
|
||||
replaceCustomComponentsFromHTMLContent,
|
||||
replaceCustomComponentsFromMarkdownContent,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,4 +1,10 @@
|
|||
import type { GithubRepositoriesResponse, ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
|
||||
import type {
|
||||
GithubRepositoriesResponse,
|
||||
ISearchIssueResponse,
|
||||
TProjectIssuesSearchParams,
|
||||
TSearchEntityRequestPayload,
|
||||
TSearchResponse,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// plane web types
|
||||
|
|
@ -164,4 +170,21 @@ export class ProjectService extends APIService {
|
|||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async searchEntity(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
params: TSearchEntityRequestPayload
|
||||
): Promise<TSearchResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/entity-search/`, {
|
||||
params: {
|
||||
...params,
|
||||
query_type: params.query_type.join(","),
|
||||
},
|
||||
})
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
web/ee/components/editor/index.ts
Normal file
1
web/ee/components/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/components/editor";
|
||||
1
web/ee/constants/editor.ts
Normal file
1
web/ee/constants/editor.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "ce/constants/editor";
|
||||
1
web/ee/hooks/use-additional-editor-mention.tsx
Normal file
1
web/ee/hooks/use-additional-editor-mention.tsx
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "@/plane-web/hooks/use-additional-editor-mention";
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
// plane editor
|
||||
import { TFileHandler } from "@plane/editor";
|
||||
// helpers
|
||||
import { getBase64Image, getFileURL } from "@/helpers/file.helper";
|
||||
import { getFileURL } from "@/helpers/file.helper";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
const fileService = new FileService();
|
||||
|
|
@ -111,161 +111,6 @@ export const getReadOnlyEditorFileHandlers = (
|
|||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* @description function to replace all the custom components from the html component to make it pdf compatible
|
||||
* @param props
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
export const replaceCustomComponentsFromHTMLContent = async (props: {
|
||||
htmlContent: string;
|
||||
noAssets?: boolean;
|
||||
}): Promise<string> => {
|
||||
const { htmlContent, noAssets = false } = props;
|
||||
// create a DOM parser
|
||||
const parser = new DOMParser();
|
||||
// parse the HTML string into a DOM document
|
||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||
// replace all mention-component elements
|
||||
const mentionComponents = doc.querySelectorAll("mention-component");
|
||||
mentionComponents.forEach((component) => {
|
||||
// get the user label from the component (or use any other attribute)
|
||||
const label = component.getAttribute("label") || "user";
|
||||
// create a span element to replace the mention-component
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("data-node-type", "mention-block");
|
||||
span.textContent = `@${label}`;
|
||||
// replace the mention-component with the anchor element
|
||||
component.replaceWith(span);
|
||||
});
|
||||
// handle code inside pre elements
|
||||
const preElements = doc.querySelectorAll("pre");
|
||||
preElements.forEach((preElement) => {
|
||||
const codeElement = preElement.querySelector("code");
|
||||
if (codeElement) {
|
||||
// create a div element with the required attributes for code blocks
|
||||
const div = doc.createElement("div");
|
||||
div.setAttribute("data-node-type", "code-block");
|
||||
div.setAttribute("class", "courier");
|
||||
// transfer the content from the code block
|
||||
div.innerHTML = codeElement.innerHTML.replace(/\n/g, "<br>") || "";
|
||||
// replace the pre element with the new div
|
||||
preElement.replaceWith(div);
|
||||
}
|
||||
});
|
||||
// handle inline code elements (not inside pre tags)
|
||||
const inlineCodeElements = doc.querySelectorAll("code");
|
||||
inlineCodeElements.forEach((codeElement) => {
|
||||
// check if the code element is inside a pre element
|
||||
if (!codeElement.closest("pre")) {
|
||||
// create a span element with the required attributes for inline code blocks
|
||||
const span = doc.createElement("span");
|
||||
span.setAttribute("data-node-type", "inline-code-block");
|
||||
span.setAttribute("class", "courier-bold");
|
||||
// transfer the code content
|
||||
span.textContent = codeElement.textContent || "";
|
||||
// replace the standalone code element with the new span
|
||||
codeElement.replaceWith(span);
|
||||
}
|
||||
});
|
||||
// handle image-component elements
|
||||
const imageComponents = doc.querySelectorAll("image-component");
|
||||
if (noAssets) {
|
||||
// if no assets is enabled, remove the image component elements
|
||||
imageComponents.forEach((component) => component.remove());
|
||||
// remove default img elements
|
||||
const imageElements = doc.querySelectorAll("img");
|
||||
imageElements.forEach((img) => img.remove());
|
||||
} else {
|
||||
// if no assets is not enabled, replace the image component elements with img elements
|
||||
imageComponents.forEach((component) => {
|
||||
// get the image src from the component
|
||||
const src = component.getAttribute("src") ?? "";
|
||||
const height = component.getAttribute("height") ?? "";
|
||||
const width = component.getAttribute("width") ?? "";
|
||||
// create an img element to replace the image-component
|
||||
const img = doc.createElement("img");
|
||||
img.src = src;
|
||||
img.style.height = height;
|
||||
img.style.width = width;
|
||||
// replace the image-component with the img element
|
||||
component.replaceWith(img);
|
||||
});
|
||||
}
|
||||
// convert all images to base64
|
||||
const imgElements = doc.querySelectorAll("img");
|
||||
await Promise.all(
|
||||
Array.from(imgElements).map(async (img) => {
|
||||
// get the image src from the img element
|
||||
const src = img.getAttribute("src");
|
||||
if (src) {
|
||||
try {
|
||||
const base64Image = await getBase64Image(src);
|
||||
img.src = base64Image;
|
||||
} catch (error) {
|
||||
// log the error if the image conversion fails
|
||||
console.error("Failed to convert image to base64:", error);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
// replace all checkbox elements
|
||||
const checkboxComponents = doc.querySelectorAll("input[type='checkbox']");
|
||||
checkboxComponents.forEach((component) => {
|
||||
// get the checked status from the element
|
||||
const checked = component.getAttribute("checked");
|
||||
// create a div element to replace the input element
|
||||
const div = doc.createElement("div");
|
||||
div.classList.value = "input-checkbox";
|
||||
// add the checked class if the checkbox is checked
|
||||
if (checked === "checked" || checked === "true") div.classList.add("checked");
|
||||
// replace the input element with the div element
|
||||
component.replaceWith(div);
|
||||
});
|
||||
// remove all issue-embed-component elements
|
||||
const issueEmbedComponents = doc.querySelectorAll("issue-embed-component");
|
||||
issueEmbedComponents.forEach((component) => component.remove());
|
||||
// serialize the document back into a string
|
||||
let serializedDoc = doc.body.innerHTML;
|
||||
// remove null colors from table elements
|
||||
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
||||
return serializedDoc;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description function to replace all the custom components from the markdown content
|
||||
* @param props
|
||||
* @returns {string}
|
||||
*/
|
||||
export const replaceCustomComponentsFromMarkdownContent = (props: {
|
||||
markdownContent: string;
|
||||
noAssets?: boolean;
|
||||
}): string => {
|
||||
const { markdownContent, noAssets = false } = props;
|
||||
let parsedMarkdownContent = markdownContent;
|
||||
// replace the matched mention components with [label](redirect_uri)
|
||||
const mentionRegex = /<mention-component[^>]*label="([^"]+)"[^>]*redirect_uri="([^"]+)"[^>]*><\/mention-component>/g;
|
||||
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(
|
||||
mentionRegex,
|
||||
(_match, label, redirectUri) => `[${label}](${originUrl}/${redirectUri})`
|
||||
);
|
||||
// replace the matched image components with <img src={src} >
|
||||
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
|
||||
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
|
||||
if (noAssets) {
|
||||
// remove all image components
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
|
||||
} else {
|
||||
// replace the matched image components with <img src={src} >
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, (_match, src) => `<img src="${src}" >`);
|
||||
}
|
||||
// remove all issue-embed components
|
||||
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
||||
return parsedMarkdownContent;
|
||||
};
|
||||
|
||||
export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => {
|
||||
if (!jsx) return "";
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue