diff --git a/apiserver/plane/app/urls/search.py b/apiserver/plane/app/urls/search.py index bbea8093d..de4f1e7b2 100644 --- a/apiserver/plane/app/urls/search.py +++ b/apiserver/plane/app/urls/search.py @@ -1,7 +1,7 @@ from django.urls import path -from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint +from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint urlpatterns = [ @@ -15,4 +15,9 @@ urlpatterns = [ IssueSearchEndpoint.as_view(), name="project-issue-search", ), + path( + "workspaces//projects//entity-search/", + SearchEndpoint.as_view(), + name="entity-search", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 845cc8130..56ea78b41 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -158,7 +158,7 @@ from .page.base import ( ) from .page.version import PageVersionEndpoint -from .search.base import GlobalSearchEndpoint +from .search.base import GlobalSearchEndpoint, SearchEndpoint from .search.issue import IssueSearchEndpoint diff --git a/apiserver/plane/app/views/search/base.py b/apiserver/plane/app/views/search/base.py index 5161103f5..3af588b40 100644 --- a/apiserver/plane/app/views/search/base.py +++ b/apiserver/plane/app/views/search/base.py @@ -2,10 +2,21 @@ import re # Django imports -from django.db.models import Q, OuterRef, Subquery, Value, UUIDField, CharField +from django.db import models +from django.db.models import ( + Q, + OuterRef, + Subquery, + Value, + UUIDField, + CharField, + When, + Case, +) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce +from django.db.models.functions import Coalesce, Concat +from django.utils import timezone # Third party imports from rest_framework import status @@ -21,6 +32,7 @@ from plane.db.models import ( Module, Page, IssueView, + ProjectMember, ProjectPage, ) @@ -237,3 +249,221 @@ class GlobalSearchEndpoint(BaseAPIView): func = MODELS_MAPPER.get(model, None) results[model] = func(query, slug, project_id, workspace_search) return Response({"results": results}, status=status.HTTP_200_OK) + + +class SearchEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + query = request.query_params.get("query", False) + query_types = request.query_params.get("query_type", "user_mention").split(",") + query_types = [qt.strip() for qt in query_types] + count = int(request.query_params.get("count", 5)) + + response_data = {} + + for query_type in query_types: + if query_type == "user_mention": + fields = [ + "member__first_name", + "member__last_name", + "member__display_name", + ] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + users = ( + ProjectMember.objects.filter( + q, + member=self.request.user, + is_active=True, + project_id=project_id, + workspace__slug=slug, + ) + .annotate( + member__avatar_url=Case( + When( + member__avatar_asset__isnull=False, + then=Concat( + Value("/api/assets/v2/static/"), + "member__avatar_asset", + Value("/"), + ), + ), + When( + member__avatar_asset__isnull=True, then="member__avatar" + ), + default=Value(None), + output_field=models.CharField(), + ) + ) + .order_by("-created_at") + .values("member__avatar_url", "member__display_name", "member__id")[ + :count + ] + ) + response_data["user_mention"] = list(users) + + elif query_type == "project": + fields = ["name", "identifier"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + projects = ( + Project.objects.filter( + q, + Q(project_projectmember__member=self.request.user) + | Q(network=2), + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "identifier", "logo_props", "workspace__slug" + )[:count] + ) + response_data["project"] = list(projects) + + elif query_type == "issue": + fields = ["name", "sequence_id", "project__identifier"] + q = Q() + + if query: + for field in fields: + if field == "sequence_id": + sequences = re.findall(r"\b\d+\b", query) + for sequence_id in sequences: + q |= Q(**{"sequence_id": sequence_id}) + else: + q |= Q(**{f"{field}__icontains": query}) + + issues = ( + Issue.issue_objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + project_id=project_id, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "sequence_id", + "project__identifier", + "project_id", + "priority", + "state_id", + "type_id", + )[:count] + ) + response_data["issue"] = list(issues) + + elif query_type == "cycle": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + cycles = ( + Cycle.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .annotate( + status=Case( + When( + Q(start_date__lte=timezone.now()) + & Q(end_date__gte=timezone.now()), + then=Value("CURRENT"), + ), + When(start_date__gt=timezone.now(), then=Value("UPCOMING")), + When(end_date__lt=timezone.now(), then=Value("COMPLETED")), + When( + Q(start_date__isnull=True) & Q(end_date__isnull=True), + then=Value("DRAFT"), + ), + default=Value("DRAFT"), + output_field=CharField(), + ) + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["cycle"] = list(cycles) + + elif query_type == "module": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + modules = ( + Module.objects.filter( + q, + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + workspace__slug=slug, + ) + .order_by("-created_at") + .distinct() + .values( + "name", + "id", + "project_id", + "project__identifier", + "status", + "workspace__slug", + )[:count] + ) + response_data["module"] = list(modules) + + elif query_type == "page": + fields = ["name"] + q = Q() + + if query: + for field in fields: + q |= Q(**{f"{field}__icontains": query}) + + pages = ( + Page.objects.filter( + q, + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__id=project_id, + workspace__slug=slug, + access=0, + ) + .order_by("-created_at") + .distinct() + .values( + "name", "id", "logo_props", "projects__id", "workspace__slug" + )[:count] + ) + response_data["page"] = list(pages) + + else: + return Response( + {"error": f"Invalid query type: {query_type}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(response_data, status=status.HTTP_200_OK) diff --git a/packages/editor/package.json b/packages/editor/package.json index 9aa2f31d4..e9ef145a0 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -37,6 +37,7 @@ "dependencies": { "@floating-ui/react": "^0.26.4", "@hocuspocus/provider": "^2.13.5", + "@plane/types": "*", "@plane/ui": "*", "@plane/utils": "*", "@tiptap/core": "^2.1.13", diff --git a/packages/editor/src/core/components/editors/document/read-only-editor.tsx b/packages/editor/src/core/components/editors/document/read-only-editor.tsx index b36fb44a7..0e8ab63f8 100644 --- a/packages/editor/src/core/components/editors/document/read-only-editor.tsx +++ b/packages/editor/src/core/components/editors/document/read-only-editor.tsx @@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common"; // hooks import { useReadOnlyEditor } from "@/hooks/use-read-only-editor"; // types -import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types"; +import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types"; interface IDocumentReadOnlyEditor { disabledExtensions: TExtensions[]; @@ -23,9 +23,7 @@ interface IDocumentReadOnlyEditor { fileHandler: Pick; tabIndex?: number; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; forwardedRef?: React.MutableRefObject; } diff --git a/packages/editor/src/core/components/editors/editor-wrapper.tsx b/packages/editor/src/core/components/editors/editor-wrapper.tsx index 50898b097..9d1297e23 100644 --- a/packages/editor/src/core/components/editors/editor-wrapper.tsx +++ b/packages/editor/src/core/components/editors/editor-wrapper.tsx @@ -1,4 +1,4 @@ -import { Editor, Extension } from "@tiptap/core"; +import { Editor, Extensions } from "@tiptap/core"; // components import { EditorContainer } from "@/components/editors"; // constants @@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content"; type Props = IEditorProps & { children?: (editor: Editor) => React.ReactNode; - extensions: Extension[]; + extensions: Extensions; }; export const EditorWrapper: React.FC = (props) => { diff --git a/packages/editor/src/core/extensions/core-without-props.ts b/packages/editor/src/core/extensions/core-without-props.ts index 212e1c241..8864f49f7 100644 --- a/packages/editor/src/core/extensions/core-without-props.ts +++ b/packages/editor/src/core/extensions/core-without-props.ts @@ -13,7 +13,7 @@ import { CustomHorizontalRule } from "./horizontal-rule"; import { ImageExtensionWithoutProps } from "./image"; import { CustomImageComponentWithoutProps } from "./image/image-component-without-props"; import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props"; -import { CustomMentionWithoutProps } from "./mentions/mentions-without-props"; +import { CustomMentionExtensionConfig } from "./mentions/extension-config"; import { CustomQuoteExtension } from "./quote"; import { TableHeader, TableCell, TableRow, Table } from "./table"; import { CustomTextAlignExtension } from "./text-align"; @@ -97,7 +97,7 @@ export const CoreEditorExtensionsWithoutProps = [ TableHeader, TableCell, TableRow, - CustomMentionWithoutProps(), + CustomMentionExtensionConfig, CustomTextAlignExtension, CustomCalloutExtensionConfig, CustomColorExtension, diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 71126c576..748bd1040 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -18,7 +18,7 @@ import { CustomImageExtension, CustomKeymap, CustomLinkExtension, - CustomMention, + CustomMentionExtension, CustomQuoteExtension, CustomTextAlignExtension, CustomTypographyExtension, @@ -33,7 +33,7 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types"; +import { TExtensions, TFileHandler, TMentionHandler } from "@/types"; // plane editor extensions import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions"; @@ -41,17 +41,14 @@ type TArguments = { disabledExtensions: TExtensions[]; enableHistory: boolean; fileHandler: TFileHandler; - mentionConfig: { - mentionSuggestions?: () => Promise; - mentionHighlights?: () => Promise; - }; + mentionHandler: TMentionHandler; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; editable: boolean; }; export const CoreEditorExtensions = (args: TArguments): Extensions => { - const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args; + const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args; return [ StarterKit.configure({ @@ -146,11 +143,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => { TableHeader, TableCell, TableRow, - CustomMention({ - mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined, - mentionHighlights: mentionConfig.mentionHighlights, - readonly: !editable, - }), + CustomMentionExtension(mentionHandler), Placeholder.configure({ placeholder: ({ editor, node }) => { if (!editor.isEditable) return; diff --git a/packages/editor/src/core/extensions/mentions/extension-config.ts b/packages/editor/src/core/extensions/mentions/extension-config.ts new file mode 100644 index 000000000..827137a1d --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/extension-config.ts @@ -0,0 +1,48 @@ +import { mergeAttributes } from "@tiptap/core"; +import Mention, { MentionOptions } from "@tiptap/extension-mention"; +// types +import { TMentionHandler } from "@/types"; +// local types +import { EMentionComponentAttributeNames } from "./types"; + +export type TMentionExtensionOptions = MentionOptions & { + renderComponent: TMentionHandler["renderComponent"]; +}; + +export const CustomMentionExtensionConfig = Mention.extend({ + addAttributes() { + return { + [EMentionComponentAttributeNames.ID]: { + default: null, + }, + [EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: { + default: null, + }, + [EMentionComponentAttributeNames.ENTITY_NAME]: { + default: null, + }, + }; + }, + + parseHTML() { + return [ + { + tag: "mention-component", + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["mention-component", mergeAttributes(HTMLAttributes)]; + }, + + HTMLAttributes: { + class: "mention", + }, + + addStorage(this) { + return { + mentionsOpen: false, + }; + }, +}); diff --git a/packages/editor/src/core/extensions/mentions/extension.tsx b/packages/editor/src/core/extensions/mentions/extension.tsx index 32ad8a720..b701bdc76 100644 --- a/packages/editor/src/core/extensions/mentions/extension.tsx +++ b/packages/editor/src/core/extensions/mentions/extension.tsx @@ -1,154 +1,31 @@ -import { Editor, mergeAttributes } from "@tiptap/core"; -import Mention, { MentionOptions } from "@tiptap/extension-mention"; -import { MarkdownSerializerState } from "@tiptap/pm/markdown"; -import { Node } from "@tiptap/pm/model"; -import { ReactNodeViewRenderer, ReactRenderer } from "@tiptap/react"; -import tippy from "tippy.js"; -// extensions -import { MentionList, MentionNodeView } from "@/extensions"; +import { ReactNodeViewRenderer } from "@tiptap/react"; // types -import { IMentionHighlight, IMentionSuggestion } from "@/types"; +import { TMentionHandler } from "@/types"; +// extension config +import { CustomMentionExtensionConfig } from "./extension-config"; +// node view +import { MentionNodeView } from "./mention-node-view"; +// utils +import { renderMentionsDropdown } from "./utils"; -interface CustomMentionOptions extends MentionOptions { - mentionHighlights: () => Promise; - readonly?: boolean; -} - -export const CustomMention = ({ - mentionHighlights, - mentionSuggestions, - readonly, -}: { - mentionSuggestions?: () => Promise; - mentionHighlights?: () => Promise; - readonly: boolean; -}) => - Mention.extend({ - addStorage(this) { +export const CustomMentionExtension = (props: TMentionHandler) => { + const { searchCallback, renderComponent } = props; + return CustomMentionExtensionConfig.extend({ + addOptions(this) { return { - mentionsOpen: false, - markdown: { - serialize(state: MarkdownSerializerState, node: Node) { - const { attrs } = node; - const label = `@${state.esc(attrs.label)}`; - const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - const safeRedirectionPath = state.esc(attrs.redirect_uri); - const url = `${originUrl}${safeRedirectionPath}`; - state.write(`[${label}](${url})`); - }, - }, + ...this.parent?.(), + renderComponent, }; }, - addAttributes() { - return { - id: { - default: null, - }, - label: { - default: null, - }, - target: { - default: null, - }, - self: { - default: false, - }, - redirect_uri: { - default: "/", - }, - entity_identifier: { - default: null, - }, - entity_name: { - default: null, - }, - }; - }, addNodeView() { return ReactNodeViewRenderer(MentionNodeView); }, - parseHTML() { - return [ - { - tag: "mention-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["mention-component", mergeAttributes(HTMLAttributes)]; - }, }).configure({ - HTMLAttributes: { - class: "mention", - }, - readonly: readonly, - mentionHighlights, suggestion: { - // @ts-expect-error - Tiptap types are incorrect - render: () => { - if (!mentionSuggestions) return; - let component: ReactRenderer | null = null; - let popup: any | null = null; - - return { - onStart: (props: { editor: Editor; clientRect: DOMRect }) => { - if (!props.clientRect) { - return; - } - component = new ReactRenderer(MentionList, { - props: { ...props, mentionSuggestions }, - editor: props.editor, - }); - props.editor.storage.mentionsOpen = true; - // @ts-expect-error - Tippy types are incorrect - popup = tippy("body", { - getReferenceClientRect: props.clientRect, - appendTo: () => - document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'), - content: component.element, - showOnCreate: true, - interactive: true, - trigger: "manual", - placement: "bottom-start", - }); - }, - onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { - component?.updateProps(props); - - if (!props.clientRect) { - return; - } - - popup && - popup[0].setProps({ - getReferenceClientRect: props.clientRect, - }); - }, - - onKeyDown: (props: { event: KeyboardEvent }) => { - if (props.event.key === "Escape") { - popup?.[0].hide(); - - return true; - } - - const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; - - if (navigationKeys.includes(props.event.key)) { - // @ts-expect-error - Tippy types are incorrect - component?.ref?.onKeyDown(props); - event?.stopPropagation(); - return true; - } - return false; - }, - onExit: (props: { editor: Editor; event: KeyboardEvent }) => { - props.editor.storage.mentionsOpen = false; - popup?.[0].destroy(); - component?.destroy(); - }, - }; - }, + render: renderMentionsDropdown({ + searchCallback, + }), }, }); +}; diff --git a/packages/editor/src/core/extensions/mentions/index.ts b/packages/editor/src/core/extensions/mentions/index.ts index c7f2317a7..02b5a53d6 100644 --- a/packages/editor/src/core/extensions/mentions/index.ts +++ b/packages/editor/src/core/extensions/mentions/index.ts @@ -1,4 +1,2 @@ export * from "./extension"; -export * from "./mention-node-view"; -export * from "./mentions-list"; -export * from "./mentions-without-props"; +export * from "./extension-config"; diff --git a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx index 0bf088ca5..006336fbb 100644 --- a/packages/editor/src/core/extensions/mentions/mention-node-view.tsx +++ b/packages/editor/src/core/extensions/mentions/mention-node-view.tsx @@ -1,49 +1,26 @@ -// TODO: fix all warnings +import { NodeViewProps, NodeViewWrapper } from "@tiptap/react"; +// extension config +import { TMentionExtensionOptions } from "./extension-config"; +// extension types +import { EMentionComponentAttributeNames, TMentionComponentAttributes } from "./types"; -/* eslint-disable react/display-name */ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-nocheck -import { useEffect, useState } from "react"; -import { NodeViewWrapper } from "@tiptap/react"; -// plane utils -import { cn } from "@plane/utils"; -// types -import { IMentionHighlight } from "@/types"; - -// eslint-disable-next-line import/no-anonymous-default-export -export const MentionNodeView = (props) => { - // TODO: move it to web app - const [highlightsState, setHighlightsState] = useState(); - - useEffect(() => { - if (!props.extension.options.mentionHighlights) return; - const hightlights = async () => { - const userId = await props.extension.options.mentionHighlights?.(); - setHighlightsState(userId); - }; - hightlights(); - }, [props.extension.options]); - - const handleClick = (event: React.MouseEvent) => { - if (!props.node.attrs.redirect_uri) { - event.preventDefault(); - } +type Props = NodeViewProps & { + node: NodeViewProps["node"] & { + attrs: TMentionComponentAttributes; }; +}; +export const MentionNodeView = (props: Props) => { + const { + extension, + node: { attrs }, + } = props; return ( - - @{props.node.attrs.label} - + {(extension.options as TMentionExtensionOptions).renderComponent({ + entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER], + entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention", + })} ); }; diff --git a/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx new file mode 100644 index 000000000..81b5fc744 --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/mentions-list-dropdown.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +import { Editor } from "@tiptap/react"; +import { v4 as uuidv4 } from "uuid"; +// helpers +import { cn } from "@/helpers/common"; +import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; +// types +import { TMentionHandler, TMentionSection, TMentionSuggestion } from "@/types"; + +export type MentionsListDropdownProps = { + command: (item: TMentionSuggestion) => void; + query: string; + editor: Editor; +} & Pick; + +export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => { + const { command, query, searchCallback } = props; + // states + const [sections, setSections] = useState([]); + const [selectedIndex, setSelectedIndex] = useState({ + section: 0, + item: 0, + }); + const [isLoading, setIsLoading] = useState(false); + // refs + const commandListContainer = useRef(null); + + const selectItem = useCallback( + (sectionIndex: number, itemIndex: number) => { + try { + const item = sections?.[sectionIndex]?.items?.[itemIndex]; + const transactionId = uuidv4(); + if (item) { + command({ + ...item, + id: transactionId, + }); + } + } catch (error) { + console.error("Error selecting mention item:", error); + } + }, + [command, sections] + ); + + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return; + event.preventDefault(); + + if (event.key === "Enter") { + selectItem(selectedIndex.section, selectedIndex.item); + return; + } + + const newIndex = getNextValidIndex({ + event, + sections, + selectedIndex, + }); + setSelectedIndex(newIndex); + }, + })); + + // initialize the select index to 0 by default + useEffect(() => { + setSelectedIndex({ + section: 0, + item: 0, + }); + }, [sections]); + + // fetch mention sections based on query + useEffect(() => { + const fetchSuggestions = async () => { + setIsLoading(true); + try { + const sectionsResponse = await searchCallback?.(query); + setSections(sectionsResponse); + } catch (error) { + console.error("Failed to fetch suggestions:", error); + } finally { + setIsLoading(false); + } + }; + fetchSuggestions(); + }, [query, searchCallback]); + + // scroll to the dropdown item when navigating via keyboard + useLayoutEffect(() => { + const container = commandListContainer?.current; + if (!container) return; + + const item = container.querySelector(`#mention-item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement; + if (item) { + const containerRect = container.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + + const isItemInView = itemRect.top >= containerRect.top && itemRect.bottom <= containerRect.bottom; + + if (!isItemInView) { + item.scrollIntoView({ block: "nearest" }); + } + } + }, [selectedIndex]); + + return ( +
+ {isLoading ? ( +
Loading...
+ ) : sections.length ? ( + sections.map((section, sectionIndex) => ( +
+ {section.title &&
{section.title}
} + {section.items.map((item, itemIndex) => { + const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item; + + return ( + + ); + })} +
+ )) + ) : ( +
No results
+ )} +
+ ); +}); + +MentionsListDropdown.displayName = "MentionsListDropdown"; diff --git a/packages/editor/src/core/extensions/mentions/mentions-list.tsx b/packages/editor/src/core/extensions/mentions/mentions-list.tsx deleted file mode 100644 index ed8d0ba4a..000000000 --- a/packages/editor/src/core/extensions/mentions/mentions-list.tsx +++ /dev/null @@ -1,171 +0,0 @@ -"use client"; - -import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; -import { Editor } from "@tiptap/react"; -import { v4 as uuidv4 } from "uuid"; -// ui -import { Avatar } from "@plane/ui"; -// plane utils -import { cn } from "@plane/utils"; -// types -import { IMentionSuggestion } from "@/types"; - -interface MentionListProps { - command: (item: { - id: string; - label: string; - entity_name: string; - entity_identifier: string; - target: string; - redirect_uri: string; - }) => void; - query: string; - editor: Editor; - mentionSuggestions: () => Promise; -} - -export const MentionList = forwardRef((props: MentionListProps, ref) => { - const { query, mentionSuggestions } = props; - const [items, setItems] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); - const [isLoading, setIsLoading] = useState(false); - - useEffect(() => { - const fetchSuggestions = async () => { - setIsLoading(true); - try { - const suggestions = await mentionSuggestions(); - const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => { - const transactionId = uuidv4(); - return { - ...suggestion, - id: transactionId, - }; - }); - - const filteredSuggestions = mappedSuggestions.filter((suggestion) => - suggestion.title.toLowerCase().startsWith(query.toLowerCase()) - ); - - setItems(filteredSuggestions); - } catch (error) { - console.error("Failed to fetch suggestions:", error); - } finally { - setIsLoading(false); - } - }; - - fetchSuggestions(); - }, [query, mentionSuggestions]); - - const selectItem = (index: number) => { - try { - const item = items[index]; - - if (item) { - props.command({ - id: item.id, - label: item.title, - entity_identifier: item.entity_identifier, - entity_name: item.entity_name, - target: "users", - redirect_uri: item.redirect_uri, - }); - } - } catch (error) { - console.error("Error selecting item:", error); - } - }; - - const commandListContainer = useRef(null); - - useLayoutEffect(() => { - const container = commandListContainer?.current; - - const item = container?.children[selectedIndex] as HTMLElement; - - if (item && container) updateScrollView(container, item); - }, [selectedIndex]); - - const updateScrollView = (container: HTMLElement, item: HTMLElement) => { - const containerHeight = container.offsetHeight; - const itemHeight = item ? item.offsetHeight : 0; - - const top = item.offsetTop; - const bottom = top + itemHeight; - - if (top < container.scrollTop) { - container.scrollTop -= container.scrollTop - top + 5; - } else if (bottom > containerHeight + container.scrollTop) { - container.scrollTop += bottom - containerHeight - container.scrollTop + 5; - } - }; - const upHandler = () => { - setSelectedIndex((selectedIndex + items.length - 1) % items.length); - }; - - const downHandler = () => { - setSelectedIndex((selectedIndex + 1) % items.length); - }; - - const enterHandler = () => { - selectItem(selectedIndex); - }; - - useEffect(() => { - setSelectedIndex(0); - }, [items]); - - useImperativeHandle(ref, () => ({ - onKeyDown: ({ event }: { event: KeyboardEvent }) => { - if (event.key === "ArrowUp") { - upHandler(); - return true; - } - - if (event.key === "ArrowDown") { - downHandler(); - return true; - } - - if (event.key === "Enter") { - enterHandler(); - return true; - } - - return false; - }, - })); - - return ( -
- {isLoading ? ( -
Loading...
- ) : items.length ? ( - items.map((item, index) => ( - - )) - ) : ( -
No results
- )} -
- ); -}); - -MentionList.displayName = "MentionList"; diff --git a/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx b/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx deleted file mode 100644 index 8fa8ef695..000000000 --- a/packages/editor/src/core/extensions/mentions/mentions-without-props.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { mergeAttributes } from "@tiptap/core"; -import Mention, { MentionOptions } from "@tiptap/extension-mention"; -// types -import { IMentionHighlight } from "@/types"; - -interface CustomMentionOptions extends MentionOptions { - mentionHighlights: () => Promise; - readonly?: boolean; -} - -export const CustomMentionWithoutProps = () => - Mention.extend({ - addAttributes() { - return { - id: { - default: null, - }, - label: { - default: null, - }, - target: { - default: null, - }, - self: { - default: false, - }, - redirect_uri: { - default: "/", - }, - entity_identifier: { - default: null, - }, - entity_name: { - default: null, - }, - }; - }, - parseHTML() { - return [ - { - tag: "mention-component", - }, - ]; - }, - renderHTML({ HTMLAttributes }) { - return ["mention-component", mergeAttributes(HTMLAttributes)]; - }, - HTMLAttributes: { - class: "mention", - }, - }); diff --git a/packages/editor/src/core/extensions/mentions/types.ts b/packages/editor/src/core/extensions/mentions/types.ts new file mode 100644 index 000000000..5252aa8b8 --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/types.ts @@ -0,0 +1,14 @@ +// plane types +import { TSearchEntities } from "@plane/types"; + +export enum EMentionComponentAttributeNames { + ID = "id", + ENTITY_IDENTIFIER = "entity_identifier", + ENTITY_NAME = "entity_name", +} + +export type TMentionComponentAttributes = { + [EMentionComponentAttributeNames.ID]: string | null; + [EMentionComponentAttributeNames.ENTITY_IDENTIFIER]: string | null; + [EMentionComponentAttributeNames.ENTITY_NAME]: TSearchEntities | null; +}; diff --git a/packages/editor/src/core/extensions/mentions/utils.ts b/packages/editor/src/core/extensions/mentions/utils.ts new file mode 100644 index 000000000..e8e7ed4b7 --- /dev/null +++ b/packages/editor/src/core/extensions/mentions/utils.ts @@ -0,0 +1,72 @@ +import { Editor } from "@tiptap/core"; +import { SuggestionOptions } from "@tiptap/suggestion"; +import { ReactRenderer } from "@tiptap/react"; +import tippy from "tippy.js"; +// helpers +import { CommandListInstance } from "@/helpers/tippy"; +// types +import { TMentionHandler } from "@/types"; +// local components +import { MentionsListDropdown, MentionsListDropdownProps } from "./mentions-list-dropdown"; + +export const renderMentionsDropdown = + (props: Pick): SuggestionOptions["render"] => + // @ts-expect-error - Tiptap types are incorrect + () => { + const { searchCallback } = props; + let component: ReactRenderer | null = null; + let popup: any | null = null; + + return { + onStart: (props: { editor: Editor; clientRect: DOMRect }) => { + if (!searchCallback) return; + if (!props.clientRect) return; + component = new ReactRenderer(MentionsListDropdown, { + props: { + ...props, + searchCallback, + }, + editor: props.editor, + }); + props.editor.storage.mentionsOpen = true; + // @ts-expect-error - Tippy types are incorrect + popup = tippy("body", { + getReferenceClientRect: props.clientRect, + appendTo: () => + document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]'), + content: component.element, + showOnCreate: true, + interactive: true, + trigger: "manual", + placement: "bottom-start", + }); + }, + onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => { + component?.updateProps(props); + popup?.[0]?.setProps({ + getReferenceClientRect: props.clientRect, + }); + }, + onKeyDown: (props: { event: KeyboardEvent }) => { + if (props.event.key === "Escape") { + popup?.[0]?.hide(); + return true; + } + + const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"]; + + if (navigationKeys.includes(props.event.key)) { + props.event?.stopPropagation(); + if (component?.ref?.onKeyDown(props)) { + return true; + } + } + return false; + }, + onExit: (props: { editor: Editor; event: KeyboardEvent }) => { + props.editor.storage.mentionsOpen = false; + popup?.[0]?.destroy(); + component?.destroy(); + }, + }; + }; diff --git a/packages/editor/src/core/extensions/read-only-extensions.tsx b/packages/editor/src/core/extensions/read-only-extensions.tsx index 7e9b05521..38c7f9966 100644 --- a/packages/editor/src/core/extensions/read-only-extensions.tsx +++ b/packages/editor/src/core/extensions/read-only-extensions.tsx @@ -19,7 +19,7 @@ import { TableCell, TableRow, Table, - CustomMention, + CustomMentionExtension, CustomReadOnlyImageExtension, CustomTextAlignExtension, CustomCalloutReadOnlyExtension, @@ -28,20 +28,18 @@ import { // helpers import { isValidHttpUrl } from "@/helpers/common"; // types -import { IMentionHighlight, TExtensions, TFileHandler } from "@/types"; +import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types"; // plane editor extensions import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions"; type Props = { disabledExtensions: TExtensions[]; fileHandler: Pick; - mentionConfig: { - mentionHighlights?: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; }; export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { - const { disabledExtensions, fileHandler, mentionConfig } = props; + const { disabledExtensions, fileHandler, mentionHandler } = props; return [ StarterKit.configure({ @@ -132,10 +130,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => { TableHeader, TableCell, TableRow, - CustomMention({ - mentionHighlights: mentionConfig.mentionHighlights, - readonly: true, - }), + CustomMentionExtension(mentionHandler), CharacterCount, CustomColorExtension, CustomTextAlignExtension, diff --git a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx index 93b0ce2ea..4ecd3f8fa 100644 --- a/packages/editor/src/core/extensions/slash-commands/command-menu.tsx +++ b/packages/editor/src/core/extensions/slash-commands/command-menu.tsx @@ -1,14 +1,18 @@ -import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; +import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react"; +import { Editor } from "@tiptap/core"; +// helpers +import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy"; // components import { TSlashCommandSection } from "./command-items-list"; import { CommandMenuItem } from "./command-menu-item"; export type SlashCommandsMenuProps = { + editor: Editor; items: TSlashCommandSection[]; command: any; }; -export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { +export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => { const { items: sections, command } = props; // states const [selectedIndex, setSelectedIndex] = useState({ @@ -41,12 +45,12 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { if (nextItem < 0) { nextSection = currentSection - 1; if (nextSection < 0) nextSection = sections.length - 1; - nextItem = sections[nextSection]?.items.length - 1; + nextItem = sections[nextSection]?.items?.length - 1; } } if (e.key === "ArrowDown") { nextItem = currentItem + 1; - if (nextItem >= sections[currentSection].items.length) { + if (nextItem >= sections[currentSection]?.items?.length) { nextSection = currentSection + 1; if (nextSection >= sections.length) nextSection = 0; nextItem = 0; @@ -84,7 +88,26 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => { item?.scrollIntoView({ block: "nearest" }); }, [sections, selectedIndex]); - const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0; + useImperativeHandle(ref, () => ({ + onKeyDown: ({ event }: { event: KeyboardEvent }) => { + if (!DROPDOWN_NAVIGATION_KEYS.includes(event.key)) return; + event.preventDefault(); + + if (event.key === "Enter") { + selectItem(selectedIndex.section, selectedIndex.item); + return; + } + + const newIndex = getNextValidIndex({ + event, + sections, + selectedIndex, + }); + setSelectedIndex(newIndex); + }, + })); + + const areSearchResultsEmpty = sections.map((s) => s.items?.length).reduce((acc, curr) => acc + curr, 0) === 0; if (areSearchResultsEmpty) return null; @@ -98,7 +121,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
{section.title &&
{section.title}
}
- {section.items.map((item, itemIndex) => ( + {section.items?.map((item, itemIndex) => ( { ))}
); -}; +}); + +SlashCommandsMenu.displayName = "SlashCommandsMenu"; diff --git a/packages/editor/src/core/extensions/slash-commands/root.tsx b/packages/editor/src/core/extensions/slash-commands/root.tsx index 62c353f92..5e12c997f 100644 --- a/packages/editor/src/core/extensions/slash-commands/root.tsx +++ b/packages/editor/src/core/extensions/slash-commands/root.tsx @@ -2,6 +2,8 @@ import { Editor, Range, Extension } from "@tiptap/core"; import { ReactRenderer } from "@tiptap/react"; import Suggestion, { SuggestionOptions } from "@tiptap/suggestion"; import tippy from "tippy.js"; +// helpers +import { CommandListInstance } from "@/helpers/tippy"; // types import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types"; // components @@ -55,16 +57,12 @@ const Command = Extension.create({ }, }); -interface CommandListInstance { - onKeyDown: (props: { event: KeyboardEvent }) => boolean; -} - const renderItems = () => { let component: ReactRenderer | null = null; let popup: any | null = null; return { onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => { - component = new ReactRenderer(SlashCommandsMenu, { + component = new ReactRenderer(SlashCommandsMenu, { props, editor: props.editor, }); @@ -91,10 +89,8 @@ const renderItems = () => { onKeyDown: (props: { event: KeyboardEvent }) => { if (props.event.key === "Escape") { popup?.[0].hide(); - return true; } - if (component?.ref?.onKeyDown(props)) { return true; } diff --git a/packages/editor/src/core/helpers/tippy.ts b/packages/editor/src/core/helpers/tippy.ts new file mode 100644 index 000000000..c254bd450 --- /dev/null +++ b/packages/editor/src/core/helpers/tippy.ts @@ -0,0 +1,58 @@ +export type CommandListInstance = { + onKeyDown: (props: { event: KeyboardEvent }) => boolean; +}; + +type TArgs = { + event: KeyboardEvent; + sections: { + items: any[]; + }[]; + selectedIndex: { + section: number; + item: number; + }; +}; + +export const DROPDOWN_NAVIGATION_KEYS = ["ArrowUp", "ArrowDown", "Enter"]; + +export const getNextValidIndex = ( + args: TArgs +): + | { + section: number; + item: number; + } + | undefined => { + const { event, sections, selectedIndex } = args; + const direction = event.key === "ArrowUp" ? "up" : "down"; + if (!sections.length) return { section: 0, item: 0 }; + // next available selection + let nextSection = selectedIndex.section; + let nextItem = selectedIndex.item; + + if (direction === "up") { + nextItem--; + if (nextItem < 0) { + // Move to previous section + nextSection--; + if (nextSection < 0) { + // Wrap to last section + nextSection = sections?.length - 1; + } + nextItem = sections?.[nextSection]?.items?.length - 1; + } + } else { + nextItem++; + if (nextItem >= sections?.[nextSection]?.items?.length) { + // Move to next section + nextSection++; + if (nextSection >= sections?.length) { + // Wrap to first section + nextSection = 0; + } + nextItem = 0; + } + } + + return { section: nextSection, item: nextItem }; +}; diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts index dd17373f7..563d5902d 100644 --- a/packages/editor/src/core/hooks/use-editor.ts +++ b/packages/editor/src/core/hooks/use-editor.ts @@ -3,7 +3,7 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { DOMSerializer } from "@tiptap/pm/model"; import { Selection } from "@tiptap/pm/state"; import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useTiptapEditor, Editor } from "@tiptap/react"; +import { useEditor as useTiptapEditor, Editor, Extensions } from "@tiptap/react"; import * as Y from "yjs"; // components import { EditorMenuItem, getEditorMenuItems } from "@/components/menus"; @@ -19,11 +19,10 @@ import { CoreEditorProps } from "@/props"; import type { TDocumentEventsServer, EditorRefApi, - IMentionHighlight, - IMentionSuggestion, TEditorCommands, TFileHandler, TExtensions, + TMentionHandler, } from "@/types"; export interface CustomEditorProps { @@ -32,16 +31,13 @@ export interface CustomEditorProps { editorProps?: EditorProps; enableHistory: boolean; disabledExtensions: TExtensions[]; - extensions?: any; + extensions?: Extensions; fileHandler: TFileHandler; forwardedRef?: MutableRefObject; handleEditorReady?: (value: boolean) => void; id?: string; initialValue?: string; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; + mentionHandler: TMentionHandler; onChange?: (json: object, html: string) => void; onTransaction?: () => void; autofocus?: boolean; @@ -96,10 +92,7 @@ export const useEditor = (props: CustomEditorProps) => { disabledExtensions, enableHistory, fileHandler, - mentionConfig: { - mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])), - mentionHighlights: mentionHandler.highlights, - }, + mentionHandler, placeholder, tabIndex, }), diff --git a/packages/editor/src/core/hooks/use-read-only-editor.ts b/packages/editor/src/core/hooks/use-read-only-editor.ts index 5fb49be5f..c97cb6068 100644 --- a/packages/editor/src/core/hooks/use-read-only-editor.ts +++ b/packages/editor/src/core/hooks/use-read-only-editor.ts @@ -1,7 +1,7 @@ import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react"; import { HocuspocusProvider } from "@hocuspocus/provider"; import { EditorProps } from "@tiptap/pm/view"; -import { useEditor as useCustomEditor, Editor } from "@tiptap/react"; +import { useEditor as useCustomEditor, Editor, Extensions } from "@tiptap/react"; import * as Y from "yjs"; // extensions import { CoreReadOnlyEditorExtensions } from "@/extensions"; @@ -13,24 +13,22 @@ import { CoreReadOnlyEditorProps } from "@/props"; // types import type { EditorReadOnlyRefApi, - IMentionHighlight, TExtensions, TDocumentEventsServer, TFileHandler, + TReadOnlyMentionHandler, } from "@/types"; interface CustomReadOnlyEditorProps { disabledExtensions: TExtensions[]; editorClassName: string; editorProps?: EditorProps; - extensions?: any; + extensions?: Extensions; forwardedRef?: MutableRefObject; initialValue?: string; fileHandler: Pick; handleEditorReady?: (value: boolean) => void; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; provider?: HocuspocusProvider; } @@ -63,9 +61,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => { extensions: [ ...CoreReadOnlyEditorExtensions({ disabledExtensions, - mentionConfig: { - mentionHighlights: mentionHandler.highlights, - }, + mentionHandler, fileHandler, }), ...extensions, diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts index b95f7283e..c69a003fc 100644 --- a/packages/editor/src/core/types/collaboration.ts +++ b/packages/editor/src/core/types/collaboration.ts @@ -6,10 +6,10 @@ import { TEmbedConfig } from "@/plane-editor/types"; import { EditorReadOnlyRefApi, EditorRefApi, - IMentionHighlight, - IMentionSuggestion, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyMentionHandler, TRealtimeConfig, TUserDetails, } from "@/types"; @@ -27,10 +27,6 @@ type TCollaborativeEditorHookProps = { extensions?: Extensions; handleEditorReady?: (value: boolean) => void; id: string; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; realtimeConfig: TRealtimeConfig; serverHandler?: TServerHandler; user: TUserDetails; @@ -41,6 +37,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { embedHandler?: TEmbedConfig; fileHandler: TFileHandler; forwardedRef?: React.MutableRefObject; + mentionHandler: TMentionHandler; placeholder?: string | ((isFocused: boolean, value: string) => string); tabIndex?: number; }; @@ -48,4 +45,5 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & { export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & { fileHandler: Pick; forwardedRef?: React.MutableRefObject; + mentionHandler: TReadOnlyMentionHandler; }; diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts index 404a3021d..27a719f04 100644 --- a/packages/editor/src/core/types/editor.ts +++ b/packages/editor/src/core/types/editor.ts @@ -1,11 +1,9 @@ -import { JSONContent } from "@tiptap/core"; +import { Extensions, JSONContent } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; // helpers import { IMarking } from "@/helpers/scroll-to-node"; // types import { - IMentionHighlight, - IMentionSuggestion, TAIHandler, TDisplayConfig, TDocumentEventEmitter, @@ -13,6 +11,8 @@ import { TEmbedConfig, TExtensions, TFileHandler, + TMentionHandler, + TReadOnlyMentionHandler, TServerHandler, } from "@/types"; import { TTextAlign } from "@/extensions"; @@ -114,10 +114,7 @@ export interface IEditorProps { forwardedRef?: React.MutableRefObject; id: string; initialValue: string; - mentionHandler: { - highlights: () => Promise; - suggestions?: () => Promise; - }; + mentionHandler: TMentionHandler; onChange?: (json: object, html: string) => void; onTransaction?: () => void; handleEditorReady?: (value: boolean) => void; @@ -128,10 +125,10 @@ export interface IEditorProps { value?: string | null; } export interface ILiteTextEditor extends IEditorProps { - extensions?: any[]; + extensions?: Extensions; } export interface IRichTextEditor extends IEditorProps { - extensions?: any[]; + extensions?: Extensions; bubbleMenuEnabled?: boolean; dragDropEnabled?: boolean; } @@ -158,9 +155,7 @@ export interface IReadOnlyEditorProps { forwardedRef?: React.MutableRefObject; id: string; initialValue: string; - mentionHandler: { - highlights: () => Promise; - }; + mentionHandler: TReadOnlyMentionHandler; } export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps; diff --git a/packages/editor/src/core/types/index.ts b/packages/editor/src/core/types/index.ts index 527264d39..e99a74b28 100644 --- a/packages/editor/src/core/types/index.ts +++ b/packages/editor/src/core/types/index.ts @@ -5,7 +5,7 @@ export * from "./editor"; export * from "./embed"; export * from "./extensions"; export * from "./image"; -export * from "./mention-suggestion"; +export * from "./mention"; export * from "./slash-commands-suggestion"; export * from "@/plane-editor/types"; export * from "./document-collaborative-events"; diff --git a/packages/editor/src/core/types/mention-suggestion.ts b/packages/editor/src/core/types/mention-suggestion.ts deleted file mode 100644 index a51bed704..000000000 --- a/packages/editor/src/core/types/mention-suggestion.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type IMentionSuggestion = { - id: string; - type: string; - entity_name: string; - entity_identifier: string; - avatar: string; - title: string; - subtitle: string; - redirect_uri: string; -}; - -export type IMentionHighlight = string; diff --git a/packages/editor/src/core/types/mention.ts b/packages/editor/src/core/types/mention.ts new file mode 100644 index 000000000..20f1ec0dc --- /dev/null +++ b/packages/editor/src/core/types/mention.ts @@ -0,0 +1,27 @@ +// plane types +import { TSearchEntities } from "@plane/types"; + +export type TMentionSuggestion = { + entity_identifier: string; + entity_name: TSearchEntities; + icon: React.ReactNode; + id: string; + subTitle?: string; + title: string; +}; + +export type TMentionSection = { + key: string; + title?: string; + items: TMentionSuggestion[]; +}; + +export type TMentionComponentProps = Pick; + +export type TReadOnlyMentionHandler = { + renderComponent: (props: TMentionComponentProps) => React.ReactNode; +}; + +export type TMentionHandler = TReadOnlyMentionHandler & { + searchCallback?: (query: string) => Promise; +}; diff --git a/packages/types/.prettierrc b/packages/types/.prettierrc new file mode 100644 index 000000000..87d988f1b --- /dev/null +++ b/packages/types/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "es5" +} diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 9f2a6e066..fa4bf05a8 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -28,6 +28,7 @@ export * from "./workspace-views"; export * from "./common"; export * from "./pragmatic"; export * from "./publish"; +export * from "./search"; export * from "./workspace-notifications"; export * from "./favorite"; export * from "./file"; diff --git a/packages/types/src/search.d.ts b/packages/types/src/search.d.ts new file mode 100644 index 000000000..5ab01549c --- /dev/null +++ b/packages/types/src/search.d.ts @@ -0,0 +1,81 @@ +import { ICycle } from "./cycle"; +import { TIssue } from "./issues/issue"; +import { IModule } from "./module"; +import { TPage } from "./pages"; +import { IProject } from "./project"; +import { IUser } from "./users"; +import { IWorkspace } from "./workspace"; + +export type TSearchEntities = + | "user_mention" + | "issue_mention" + | "project_mention" + | "cycle_mention" + | "module_mention" + | "page_mention"; + +export type TUserSearchResponse = { + member__avatar_url: IUser["avatar_url"]; + member__display_name: IUser["display_name"]; + member__id: IUser["id"]; +}; + +export type TProjectSearchResponse = { + name: IProject["name"]; + id: IProject["id"]; + identifier: IProject["identifier"]; + logo_props: IProject["logo_props"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TIssueSearchResponse = { + name: TIssue["name"]; + id: TIssue["id"]; + sequence_id: TIssue["sequence_id"]; + project__identifier: IProject["identifier"]; + project_id: TIssue["project_id"]; + priority: TIssue["priority"]; + state_id: TIssue["state_id"]; + type_id: TIssue["type_id"]; +}; + +export type TCycleSearchResponse = { + name: ICycle["name"]; + id: ICycle["id"]; + project_id: ICycle["project_id"]; + project__identifier: IProject["identifier"]; + status: ICycle["status"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TModuleSearchResponse = { + name: IModule["name"]; + id: IModule["id"]; + project_id: IModule["project_id"]; + project__identifier: IProject["identifier"]; + status: IModule["status"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TPageSearchResponse = { + name: TPage["name"]; + id: TPage["id"]; + logo_props: TPage["logo_props"]; + projects__id: TPage["project_ids"]; + workspace__slug: IWorkspace["slug"]; +}; + +export type TSearchResponse = { + cycle_mention?: TCycleSearchResponse[]; + issue_mention?: TIssueSearchResponse[]; + module_mention?: TModuleSearchResponse[]; + page_mention?: TPageSearchResponse[]; + project_mention?: TProjectSearchResponse[]; + user_mention?: TUserSearchResponse[]; +}; + +export type TSearchEntityRequestPayload = { + count: number; + query_type: TSearchEntities[]; + query: string; +}; diff --git a/space/ce/components/editor/embeds/index.ts b/space/ce/components/editor/embeds/index.ts new file mode 100644 index 000000000..8146e94d9 --- /dev/null +++ b/space/ce/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/space/ce/components/editor/embeds/mentions/index.ts b/space/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/space/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/space/ce/components/editor/embeds/mentions/root.tsx b/space/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 000000000..16e21f848 --- /dev/null +++ b/space/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,4 @@ +// plane editor +import { TMentionComponentProps } from "@plane/editor"; + +export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/space/ce/components/editor/index.ts b/space/ce/components/editor/index.ts new file mode 100644 index 000000000..cf8352ae4 --- /dev/null +++ b/space/ce/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/space/core/components/editor/embeds/index.ts b/space/core/components/editor/embeds/index.ts new file mode 100644 index 000000000..8146e94d9 --- /dev/null +++ b/space/core/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/space/core/components/editor/embeds/mentions/index.ts b/space/core/components/editor/embeds/mentions/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/space/core/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/space/core/components/editor/embeds/mentions/root.tsx b/space/core/components/editor/embeds/mentions/root.tsx new file mode 100644 index 000000000..9ea5ef6fb --- /dev/null +++ b/space/core/components/editor/embeds/mentions/root.tsx @@ -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 = (props) => { + const { entity_identifier, entity_name } = props; + + switch (entity_name) { + case "user_mention": + return ; + default: + return ; + } +}; diff --git a/space/core/components/editor/embeds/mentions/user.tsx b/space/core/components/editor/embeds/mentions/user.tsx new file mode 100644 index 000000000..106f4b176 --- /dev/null +++ b/space/core/components/editor/embeds/mentions/user.tsx @@ -0,0 +1,41 @@ +import { observer } from "mobx-react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMember, useUser } from "@/hooks/store"; + +type Props = { + id: string; +}; + +export const EditorUserMention: React.FC = observer((props) => { + const { id } = props; + // params + const { workspaceSlug } = useParams(); + // store hooks + const { data: currentUser } = useUser(); + const { getMemberById } = useMember(); + // derived values + const userDetails = getMemberById(id); + const profileLink = `/${workspaceSlug}/profile/${id}`; + + if (!userDetails) { + return ( +
+ @deactivated user +
+ ); + } + + return ( +
+ @{userDetails?.member__display_name} +
+ ); +}); diff --git a/space/core/components/editor/index.ts b/space/core/components/editor/index.ts index 4ec0141e2..894daf224 100644 --- a/space/core/components/editor/index.ts +++ b/space/core/components/editor/index.ts @@ -1,3 +1,4 @@ +export * from "./embeds"; export * from "./lite-text-editor"; export * from "./lite-text-read-only-editor"; export * from "./rich-text-read-only-editor"; diff --git a/space/core/components/editor/lite-text-editor.tsx b/space/core/components/editor/lite-text-editor.tsx index 5d5027135..ac0a0633a 100644 --- a/space/core/components/editor/lite-text-editor.tsx +++ b/space/core/components/editor/lite-text-editor.tsx @@ -2,13 +2,11 @@ import React from "react"; // editor import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor"; // 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 { useMention } from "@/hooks/use-mention"; interface LiteTextEditorWrapperProps extends Omit { @@ -29,8 +27,6 @@ export const LiteTextEditor = React.forwardRef(ref: React.ForwardedRef): ref is React.MutableRefObject { return !!ref && typeof ref === "object" && "current" in ref; } @@ -49,8 +45,7 @@ export const LiteTextEditor = React.forwardRef , }} {...rest} // overriding the containerClassName to add relative class passed diff --git a/space/core/components/editor/lite-text-read-only-editor.tsx b/space/core/components/editor/lite-text-read-only-editor.tsx index 014f4010c..5f936baec 100644 --- a/space/core/components/editor/lite-text-read-only-editor.tsx +++ b/space/core/components/editor/lite-text-read-only-editor.tsx @@ -1,11 +1,11 @@ import React from "react"; // 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 } from "@/hooks/use-mention"; type LiteTextReadOnlyEditorWrapperProps = Omit< ILiteTextReadOnlyEditor, @@ -15,25 +15,21 @@ type LiteTextReadOnlyEditorWrapperProps = Omit< }; export const LiteTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => { - const { mentionHighlights } = useMention(); - - return ( - - ); - } + ({ anchor, ...props }, ref) => ( + , + }} + {...props} + // overriding the customClassName to add relative class passed + containerClassName={cn(props.containerClassName, "relative p-2")} + /> + ) ); LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor"; diff --git a/space/core/components/editor/rich-text-editor.tsx b/space/core/components/editor/rich-text-editor.tsx index cfe2e1b7f..96f490054 100644 --- a/space/core/components/editor/rich-text-editor.tsx +++ b/space/core/components/editor/rich-text-editor.tsx @@ -1,6 +1,8 @@ import React, { forwardRef } from "react"; // editor -import { EditorRefApi, IMentionHighlight, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; +import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor"; +// components +import { EditorMentionsRoot } from "@/components/editor"; // helpers import { getEditorFileHandlers } from "@/helpers/editor.helper"; @@ -11,19 +13,11 @@ interface RichTextEditorWrapperProps export const RichTextEditor = forwardRef((props, ref) => { const { containerClassName, uploadFile, ...rest } = props; - // store hooks - - // use-mention - - // file size return ( { - throw new Error("Function not implemented."); - }, - suggestions: undefined, + renderComponent: (props) => , }} ref={ref} disabledExtensions={[]} diff --git a/space/core/components/editor/rich-text-read-only-editor.tsx b/space/core/components/editor/rich-text-read-only-editor.tsx index fca1776e2..76075f296 100644 --- a/space/core/components/editor/rich-text-read-only-editor.tsx +++ b/space/core/components/editor/rich-text-read-only-editor.tsx @@ -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/use-mention"; type RichTextReadOnlyEditorWrapperProps = Omit< IRichTextReadOnlyEditor, @@ -15,23 +15,21 @@ type RichTextReadOnlyEditorWrapperProps = Omit< }; export const RichTextReadOnlyEditor = React.forwardRef( - ({ anchor, ...props }, ref) => { - const { mentionHighlights } = useMention(); - - return ( - - ); - } + ({ anchor, ...props }, ref) => ( + , + }} + {...props} + // overriding the customClassName to add relative class passed + containerClassName={cn("relative p-0 border-none", props.containerClassName)} + /> + ) ); RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor"; diff --git a/space/core/store/mentions.store.ts b/space/core/store/mentions.store.ts deleted file mode 100644 index 977df4221..000000000 --- a/space/core/store/mentions.store.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { computed, makeObservable } from "mobx"; -// editor -import { IMentionHighlight } from "@plane/editor"; -// store -import { CoreRootStore } from "@/store/root.store"; - -export interface IMentionsStore { - // mentionSuggestions: IMentionSuggestion[]; - mentionHighlights: IMentionHighlight[]; -} - -export class MentionsStore implements IMentionsStore { - // root store - rootStore; - - constructor(_rootStore: CoreRootStore) { - // rootStore - this.rootStore = _rootStore; - - makeObservable(this, { - mentionHighlights: computed, - // mentionSuggestions: computed - }); - } - - // get mentionSuggestions() { - // const projectMembers = this.rootStore.project.project. - - // const suggestions = projectMembers === null ? [] : projectMembers.map((member) => ({ - // id: member.member.id, - // type: "User", - // title: member.member.display_name, - // subtitle: member.member.email ?? "", - // avatar: member.member.avatar, - // redirect_uri: `/${member.workspace.slug}/profile/${member.member.id}`, - // })) - - // return suggestions - // } - - get mentionHighlights() { - const user = this.rootStore.user.data; - return user ? [user.id] : []; - } -} diff --git a/space/core/store/root.store.ts b/space/core/store/root.store.ts index de43001d2..db9e26566 100644 --- a/space/core/store/root.store.ts +++ b/space/core/store/root.store.ts @@ -8,7 +8,6 @@ import { CycleStore, ICycleStore } from "./cycle.store"; import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store"; import { IIssueLabelStore, LabelStore } from "./label.store"; import { IIssueMemberStore, MemberStore } from "./members.store"; -import { IMentionsStore, MentionsStore } from "./mentions.store"; import { IIssueModuleStore, ModuleStore } from "./module.store"; import { IPublishListStore, PublishListStore } from "./publish/publish_list.store"; import { IStateStore, StateStore } from "./state.store"; @@ -20,7 +19,6 @@ export class CoreRootStore { user: IUserStore; issue: IIssueStore; issueDetail: IIssueDetailStore; - mentionStore: IMentionsStore; state: IStateStore; label: IIssueLabelStore; module: IIssueModuleStore; @@ -34,7 +32,6 @@ export class CoreRootStore { this.user = new UserStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); - this.mentionStore = new MentionsStore(this); this.state = new StateStore(this); this.label = new LabelStore(this); this.module = new ModuleStore(this); @@ -57,7 +54,6 @@ export class CoreRootStore { this.user = new UserStore(this); this.issue = new IssueStore(this); this.issueDetail = new IssueDetailStore(this); - this.mentionStore = new MentionsStore(this); this.state = new StateStore(this); this.label = new LabelStore(this); this.module = new ModuleStore(this); diff --git a/space/ee/components/editor/index.ts b/space/ee/components/editor/index.ts new file mode 100644 index 000000000..f8506c1d6 --- /dev/null +++ b/space/ee/components/editor/index.ts @@ -0,0 +1 @@ +export * from "ce/components/editor"; diff --git a/web/ce/components/editor/embeds/index.ts b/web/ce/components/editor/embeds/index.ts new file mode 100644 index 000000000..8146e94d9 --- /dev/null +++ b/web/ce/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/web/ce/components/editor/embeds/mentions/index.ts b/web/ce/components/editor/embeds/mentions/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/ce/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/ce/components/editor/embeds/mentions/root.tsx b/web/ce/components/editor/embeds/mentions/root.tsx new file mode 100644 index 000000000..16e21f848 --- /dev/null +++ b/web/ce/components/editor/embeds/mentions/root.tsx @@ -0,0 +1,4 @@ +// plane editor +import { TMentionComponentProps } from "@plane/editor"; + +export const EditorAdditionalMentionsRoot: React.FC = () => null; diff --git a/web/ce/components/editor/index.ts b/web/ce/components/editor/index.ts new file mode 100644 index 000000000..cf8352ae4 --- /dev/null +++ b/web/ce/components/editor/index.ts @@ -0,0 +1 @@ +export * from "./embeds"; diff --git a/web/ce/constants/editor.ts b/web/ce/constants/editor.ts new file mode 100644 index 000000000..b9a6d5d38 --- /dev/null +++ b/web/ce/constants/editor.ts @@ -0,0 +1,4 @@ +// plane types +import { TSearchEntities } from "@plane/types"; + +export const EDITOR_MENTION_TYPES: TSearchEntities[] = ["user_mention"]; diff --git a/web/ce/hooks/use-additional-editor-mention.tsx b/web/ce/hooks/use-additional-editor-mention.tsx new file mode 100644 index 000000000..58416379f --- /dev/null +++ b/web/ce/hooks/use-additional-editor-mention.tsx @@ -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, + }; +}; diff --git a/web/core/components/editor/embeds/index.ts b/web/core/components/editor/embeds/index.ts new file mode 100644 index 000000000..8146e94d9 --- /dev/null +++ b/web/core/components/editor/embeds/index.ts @@ -0,0 +1 @@ +export * from "./mentions"; diff --git a/web/core/components/editor/embeds/mentions/index.ts b/web/core/components/editor/embeds/mentions/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/core/components/editor/embeds/mentions/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/core/components/editor/embeds/mentions/root.tsx b/web/core/components/editor/embeds/mentions/root.tsx new file mode 100644 index 000000000..9ea5ef6fb --- /dev/null +++ b/web/core/components/editor/embeds/mentions/root.tsx @@ -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 = (props) => { + const { entity_identifier, entity_name } = props; + + switch (entity_name) { + case "user_mention": + return ; + default: + return ; + } +}; diff --git a/web/core/components/editor/embeds/mentions/user.tsx b/web/core/components/editor/embeds/mentions/user.tsx new file mode 100644 index 000000000..8952e44d5 --- /dev/null +++ b/web/core/components/editor/embeds/mentions/user.tsx @@ -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 = observer((props) => { + const { id } = props; + // states + const [popperElement, setPopperElement] = useState(null); + const [referenceElement, setReferenceElement] = useState(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 ( +
+ @deactivated user +
+ ); + } + + return ( +
+ + @{userDetails?.display_name} + +
+
+
+ +
+
+ + {userDetails?.first_name} {userDetails?.last_name} + + {roleDetails &&

{ROLE[roleDetails]}

} +
+
+
+
+ ); +}); diff --git a/web/core/components/editor/index.ts b/web/core/components/editor/index.ts index 0b14bd135..e7651069e 100644 --- a/web/core/components/editor/index.ts +++ b/web/core/components/editor/index.ts @@ -1,3 +1,4 @@ +export * from "./embeds"; export * from "./lite-text-editor"; export * from "./pdf"; export * from "./rich-text-editor"; diff --git a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx index 9ba82f9de..d1a7b0640 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-editor.tsx @@ -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 { @@ -48,23 +50,12 @@ export const LiteTextEditor = React.forwardRef 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 { + const res = await fetchMentions(query); + if (!res) throw new Error("Failed in fetching mentions"); + return res; + }, + renderComponent: (props) => , }} placeholder={placeholder} containerClassName={cn(containerClassName, "relative")} diff --git a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx index 1f1edad7d..1272604e2 100644 --- a/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx +++ b/web/core/components/editor/lite-text-editor/lite-text-read-only-editor.tsx @@ -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( ({ 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 , }} {...props} // overriding the containerClassName to add relative class passed diff --git a/web/core/components/editor/rich-text-editor/rich-text-editor.tsx b/web/core/components/editor/rich-text-editor/rich-text-editor.tsx index 23750b397..41d67a1c6 100644 --- a/web/core/components/editor/rich-text-editor/rich-text-editor.tsx +++ b/web/core/components/editor/rich-text-editor/rich-text-editor.tsx @@ -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 { workspaceSlug: string; workspaceId: string; - memberIds: string[]; projectId?: string; uploadFile: (file: File) => Promise; } export const RichTextEditor = forwardRef((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 { + const res = await fetchMentions(query); + if (!res) throw new Error("Failed in fetching mentions"); + return res; + }, + renderComponent: (props) => , }} {...rest} containerClassName={cn("relative pl-3", containerClassName)} diff --git a/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx b/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx index a9067641f..a21e8b668 100644 --- a/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx +++ b/web/core/components/editor/rich-text-editor/rich-text-read-only-editor.tsx @@ -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( ({ workspaceSlug, projectId, ...props }, ref) => { - const { mentionHighlights } = useMention({}); // editor flaggings const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString()); @@ -32,7 +31,7 @@ export const RichTextReadOnlyEditor = React.forwardRef , }} {...props} // overriding the containerClassName to add relative class passed diff --git a/web/core/components/inbox/modals/create-modal/issue-description.tsx b/web/core/components/inbox/modals/create-modal/issue-description.tsx index 3835c006d..b9bad6c11 100644 --- a/web/core/components/inbox/modals/create-modal/issue-description.tsx +++ b/web/core/components/inbox/modals/create-modal/issue-description.tsx @@ -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 = 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 = 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)} diff --git a/web/core/components/issues/description-input.tsx b/web/core/components/issues/description-input.tsx index fd629f9ed..9b9abe038 100644 --- a/web/core/components/issues/description-input.tsx +++ b/web/core/components/issues/description-input.tsx @@ -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 = observer((p setIsSubmitting, placeholder, } = props; - // store hooks - const { - project: { getProjectMemberIds }, - } = useMember(); - // derived values - const memberIds = getProjectMemberIds(projectId) ?? []; const { handleSubmit, reset, control } = useForm({ defaultValues: { @@ -114,7 +108,6 @@ export const IssueDescriptionInput: FC = observer((p value={swrIssueDescription ?? null} workspaceSlug={workspaceSlug} workspaceId={workspaceId} - memberIds={memberIds} projectId={projectId} dragDropEnabled onChange={(_description: object, description_html: string) => { diff --git a/web/core/components/issues/issue-modal/components/description-editor.tsx b/web/core/components/issues/issue-modal/components/description-editor.tsx index bb7fa8d32..316668d01 100644 --- a/web/core/components/issues/issue-modal/components/description-editor.tsx +++ b/web/core/components/issues/issue-modal/components/description-editor.tsx @@ -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 = 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 = ob value={descriptionHtmlData} workspaceSlug={workspaceSlug?.toString() as string} workspaceId={workspaceId} - memberIds={memberIds} projectId={projectId} onChange={(_description: object, description_html: string) => { onChange(description_html); diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx index 4c65f3511..ddd45f622 100644 --- a/web/core/components/pages/editor/editor-body.tsx +++ b/web/core/components/pages/editor/editor-body.tsx @@ -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; @@ -53,23 +55,15 @@ export const PageEditorBody: React.FC = 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 = 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) => , }} embedHandler={{ issue: issueEmbedProps, diff --git a/web/core/components/pages/editor/header/options-dropdown.tsx b/web/core/components/pages/editor/header/options-dropdown.tsx index a1db6f972..928b84bf9 100644 --- a/web/core/components/pages/editor/header/options-dropdown.tsx +++ b/web/core/components/pages/editor/header/options-dropdown.tsx @@ -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 = 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 = 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!", diff --git a/web/core/components/pages/modals/export-page-modal.tsx b/web/core/components/pages/modals/export-page-modal.tsx index acf4ff083..67e2f629d 100644 --- a/web/core/components/pages/modals/export-page-modal.tsx +++ b/web/core/components/pages/modals/export-page-modal.tsx @@ -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) => { const { control, reset, watch } = useForm({ 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) => { }); handleClose(); } catch (error) { + console.error("Error in exporting page:", error); setToast({ type: TOAST_TYPE.ERROR, title: "Error!", diff --git a/web/core/components/pages/version/editor.tsx b/web/core/components/pages/version/editor.tsx index 637736e39..b64ee0962 100644 --- a/web/core/components/pages/version/editor.tsx +++ b/web/core/components/pages/version/editor.tsx @@ -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 = 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 = observer((props workspaceSlug: workspaceSlug?.toString() ?? "", })} mentionHandler={{ - highlights: mentionHighlights, + renderComponent: (props) => , }} embedHandler={{ issue: { diff --git a/web/core/hooks/store/index.ts b/web/core/hooks/store/index.ts index dc7861724..266efea32 100644 --- a/web/core/hooks/store/index.ts +++ b/web/core/hooks/store/index.ts @@ -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"; diff --git a/web/core/hooks/store/use-mention.ts b/web/core/hooks/store/use-mention.ts deleted file mode 100644 index bcdcc2f8c..000000000 --- a/web/core/hooks/store/use-mention.ts +++ /dev/null @@ -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(); - const userRef = useRef(); - - 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((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((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 => { - 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, - }; -}; diff --git a/web/core/hooks/use-editor-mention.tsx b/web/core/hooks/use-editor-mention.tsx new file mode 100644 index 000000000..183cc1ec9 --- /dev/null +++ b/web/core/hooks/use-editor-mention.tsx @@ -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; +}; + +export const useEditorMention = (args: TArgs) => { + const { searchEntity } = args; + // additional mentions + const { updateAdditionalSections } = useAdditionalEditorMention(); + // fetch mentions handler + const fetchMentions = useCallback( + async (query: string): Promise => { + 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: ( + + ), + 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, + }; +}; diff --git a/web/core/hooks/use-parse-editor-content.ts b/web/core/hooks/use-parse-editor-content.ts new file mode 100644 index 000000000..86e13a73f --- /dev/null +++ b/web/core/hooks/use-parse-editor-content.ts @@ -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} + */ + const replaceCustomComponentsFromHTMLContent = useCallback( + async (props: { htmlContent: string; noAssets?: boolean }): Promise => { + 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, "
") || ""; + // 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 = + /]*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 + const imageComponentRegex = /]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g; + const imgTagRegex = /]*src="([^"]+)"[^>]*\/?>/g; + if (noAssets) { + // remove all image components + parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, ""); + } else { + // replace the matched image components with + parsedMarkdownContent = parsedMarkdownContent.replace( + imageComponentRegex, + (_match, src) => `` + ); + } + // remove all issue-embed components + const issueEmbedRegex = /]*>[^]*<\/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, + }; +}; diff --git a/web/core/services/project/project.service.ts b/web/core/services/project/project.service.ts index f9c2af8b6..e3be9c5f2 100644 --- a/web/core/services/project/project.service.ts +++ b/web/core/services/project/project.service.ts @@ -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 { + 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; + }); + } } diff --git a/web/ee/components/editor/index.ts b/web/ee/components/editor/index.ts new file mode 100644 index 000000000..f8506c1d6 --- /dev/null +++ b/web/ee/components/editor/index.ts @@ -0,0 +1 @@ +export * from "ce/components/editor"; diff --git a/web/ee/constants/editor.ts b/web/ee/constants/editor.ts new file mode 100644 index 000000000..c59cd0b61 --- /dev/null +++ b/web/ee/constants/editor.ts @@ -0,0 +1 @@ +export * from "ce/constants/editor"; diff --git a/web/ee/hooks/use-additional-editor-mention.tsx b/web/ee/hooks/use-additional-editor-mention.tsx new file mode 100644 index 000000000..5e9372d08 --- /dev/null +++ b/web/ee/hooks/use-additional-editor-mention.tsx @@ -0,0 +1 @@ +export * from "@/plane-web/hooks/use-additional-editor-mention"; diff --git a/web/helpers/editor.helper.ts b/web/helpers/editor.helper.ts index e0cbef2e1..a3b05041d 100644 --- a/web/helpers/editor.helper.ts +++ b/web/helpers/editor.helper.ts @@ -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} - */ -export const replaceCustomComponentsFromHTMLContent = async (props: { - htmlContent: string; - noAssets?: boolean; -}): Promise => { - 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, "
") || ""; - // 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 = /]*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 - const imageComponentRegex = /]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g; - const imgTagRegex = /]*src="([^"]+)"[^>]*\/?>/g; - if (noAssets) { - // remove all image components - parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, ""); - } else { - // replace the matched image components with - parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, (_match, src) => ``); - } - // remove all issue-embed components - const issueEmbedRegex = /]*>[^]*<\/issue-embed-component>/g; - parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, ""); - return parsedMarkdownContent; -}; - export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => { if (!jsx) return "";