[PE-93] refactor: editor mentions extension (#6178)
* refactor: editor mentions * fix: build errors * fix: build errors * chore: add cycle status to search endpoint response * fix: build errors * fix: dynamic mention content in markdown * chore: update entity search endpoint * style: user mention popover * chore: edition specific mention content handler * chore: show deactivated user for old mentions * chore: update search entity keys * refactor: use editor mention hook
This commit is contained in:
parent
c10b875e2a
commit
119d343d5f
78 changed files with 1491 additions and 992 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
|
|
||||||
from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint
|
from plane.app.views import GlobalSearchEndpoint, IssueSearchEndpoint, SearchEndpoint
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
@ -15,4 +15,9 @@ urlpatterns = [
|
||||||
IssueSearchEndpoint.as_view(),
|
IssueSearchEndpoint.as_view(),
|
||||||
name="project-issue-search",
|
name="project-issue-search",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/projects/<uuid:project_id>/entity-search/",
|
||||||
|
SearchEndpoint.as_view(),
|
||||||
|
name="entity-search",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ from .page.base import (
|
||||||
)
|
)
|
||||||
from .page.version import PageVersionEndpoint
|
from .page.version import PageVersionEndpoint
|
||||||
|
|
||||||
from .search.base import GlobalSearchEndpoint
|
from .search.base import GlobalSearchEndpoint, SearchEndpoint
|
||||||
from .search.issue import IssueSearchEndpoint
|
from .search.issue import IssueSearchEndpoint
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,21 @@
|
||||||
import re
|
import re
|
||||||
|
|
||||||
# Django imports
|
# 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.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
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
|
# Third party imports
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|
@ -21,6 +32,7 @@ from plane.db.models import (
|
||||||
Module,
|
Module,
|
||||||
Page,
|
Page,
|
||||||
IssueView,
|
IssueView,
|
||||||
|
ProjectMember,
|
||||||
ProjectPage,
|
ProjectPage,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -237,3 +249,221 @@ class GlobalSearchEndpoint(BaseAPIView):
|
||||||
func = MODELS_MAPPER.get(model, None)
|
func = MODELS_MAPPER.get(model, None)
|
||||||
results[model] = func(query, slug, project_id, workspace_search)
|
results[model] = func(query, slug, project_id, workspace_search)
|
||||||
return Response({"results": results}, status=status.HTTP_200_OK)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/react": "^0.26.4",
|
"@floating-ui/react": "^0.26.4",
|
||||||
"@hocuspocus/provider": "^2.13.5",
|
"@hocuspocus/provider": "^2.13.5",
|
||||||
|
"@plane/types": "*",
|
||||||
"@plane/ui": "*",
|
"@plane/ui": "*",
|
||||||
"@plane/utils": "*",
|
"@plane/utils": "*",
|
||||||
"@tiptap/core": "^2.1.13",
|
"@tiptap/core": "^2.1.13",
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||||
// hooks
|
// hooks
|
||||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||||
// types
|
// types
|
||||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
|
import { EditorReadOnlyRefApi, TDisplayConfig, TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||||
|
|
||||||
interface IDocumentReadOnlyEditor {
|
interface IDocumentReadOnlyEditor {
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
|
|
@ -23,9 +23,7 @@ interface IDocumentReadOnlyEditor {
|
||||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
mentionHandler: {
|
mentionHandler: TReadOnlyMentionHandler;
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
|
||||||
};
|
|
||||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Editor, Extension } from "@tiptap/core";
|
import { Editor, Extensions } from "@tiptap/core";
|
||||||
// components
|
// components
|
||||||
import { EditorContainer } from "@/components/editors";
|
import { EditorContainer } from "@/components/editors";
|
||||||
// constants
|
// constants
|
||||||
|
|
@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content";
|
||||||
|
|
||||||
type Props = IEditorProps & {
|
type Props = IEditorProps & {
|
||||||
children?: (editor: Editor) => React.ReactNode;
|
children?: (editor: Editor) => React.ReactNode;
|
||||||
extensions: Extension<any, any>[];
|
extensions: Extensions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EditorWrapper: React.FC<Props> = (props) => {
|
export const EditorWrapper: React.FC<Props> = (props) => {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { CustomHorizontalRule } from "./horizontal-rule";
|
||||||
import { ImageExtensionWithoutProps } from "./image";
|
import { ImageExtensionWithoutProps } from "./image";
|
||||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-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 { CustomQuoteExtension } from "./quote";
|
||||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||||
import { CustomTextAlignExtension } from "./text-align";
|
import { CustomTextAlignExtension } from "./text-align";
|
||||||
|
|
@ -97,7 +97,7 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
CustomMentionWithoutProps(),
|
CustomMentionExtensionConfig,
|
||||||
CustomTextAlignExtension,
|
CustomTextAlignExtension,
|
||||||
CustomCalloutExtensionConfig,
|
CustomCalloutExtensionConfig,
|
||||||
CustomColorExtension,
|
CustomColorExtension,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
CustomImageExtension,
|
CustomImageExtension,
|
||||||
CustomKeymap,
|
CustomKeymap,
|
||||||
CustomLinkExtension,
|
CustomLinkExtension,
|
||||||
CustomMention,
|
CustomMentionExtension,
|
||||||
CustomQuoteExtension,
|
CustomQuoteExtension,
|
||||||
CustomTextAlignExtension,
|
CustomTextAlignExtension,
|
||||||
CustomTypographyExtension,
|
CustomTypographyExtension,
|
||||||
|
|
@ -33,7 +33,7 @@ import {
|
||||||
// helpers
|
// helpers
|
||||||
import { isValidHttpUrl } from "@/helpers/common";
|
import { isValidHttpUrl } from "@/helpers/common";
|
||||||
// types
|
// types
|
||||||
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
|
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||||
// plane editor extensions
|
// plane editor extensions
|
||||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||||
|
|
||||||
|
|
@ -41,17 +41,14 @@ type TArguments = {
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
enableHistory: boolean;
|
enableHistory: boolean;
|
||||||
fileHandler: TFileHandler;
|
fileHandler: TFileHandler;
|
||||||
mentionConfig: {
|
mentionHandler: TMentionHandler;
|
||||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
|
||||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
|
||||||
};
|
|
||||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
|
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
|
|
@ -146,11 +143,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
CustomMention({
|
CustomMentionExtension(mentionHandler),
|
||||||
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
|
|
||||||
mentionHighlights: mentionConfig.mentionHighlights,
|
|
||||||
readonly: !editable,
|
|
||||||
}),
|
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ editor, node }) => {
|
placeholder: ({ editor, node }) => {
|
||||||
if (!editor.isEditable) return;
|
if (!editor.isEditable) return;
|
||||||
|
|
|
||||||
|
|
@ -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<TMentionExtensionOptions>({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,154 +1,31 @@
|
||||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
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";
|
|
||||||
// types
|
// 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 {
|
export const CustomMentionExtension = (props: TMentionHandler) => {
|
||||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
const { searchCallback, renderComponent } = props;
|
||||||
readonly?: boolean;
|
return CustomMentionExtensionConfig.extend({
|
||||||
}
|
addOptions(this) {
|
||||||
|
|
||||||
export const CustomMention = ({
|
|
||||||
mentionHighlights,
|
|
||||||
mentionSuggestions,
|
|
||||||
readonly,
|
|
||||||
}: {
|
|
||||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
|
||||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
|
||||||
readonly: boolean;
|
|
||||||
}) =>
|
|
||||||
Mention.extend<CustomMentionOptions>({
|
|
||||||
addStorage(this) {
|
|
||||||
return {
|
return {
|
||||||
mentionsOpen: false,
|
...this.parent?.(),
|
||||||
markdown: {
|
renderComponent,
|
||||||
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})`);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
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() {
|
addNodeView() {
|
||||||
return ReactNodeViewRenderer(MentionNodeView);
|
return ReactNodeViewRenderer(MentionNodeView);
|
||||||
},
|
},
|
||||||
parseHTML() {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
tag: "mention-component",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
|
||||||
return ["mention-component", mergeAttributes(HTMLAttributes)];
|
|
||||||
},
|
|
||||||
}).configure({
|
}).configure({
|
||||||
HTMLAttributes: {
|
|
||||||
class: "mention",
|
|
||||||
},
|
|
||||||
readonly: readonly,
|
|
||||||
mentionHighlights,
|
|
||||||
suggestion: {
|
suggestion: {
|
||||||
// @ts-expect-error - Tiptap types are incorrect
|
render: renderMentionsDropdown({
|
||||||
render: () => {
|
searchCallback,
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,2 @@
|
||||||
export * from "./extension";
|
export * from "./extension";
|
||||||
export * from "./mention-node-view";
|
export * from "./extension-config";
|
||||||
export * from "./mentions-list";
|
|
||||||
export * from "./mentions-without-props";
|
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
type Props = NodeViewProps & {
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
node: NodeViewProps["node"] & {
|
||||||
// @ts-nocheck
|
attrs: TMentionComponentAttributes;
|
||||||
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<IMentionHighlight[]>();
|
|
||||||
|
|
||||||
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<HTMLAnchorElement>) => {
|
|
||||||
if (!props.node.attrs.redirect_uri) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MentionNodeView = (props: Props) => {
|
||||||
|
const {
|
||||||
|
extension,
|
||||||
|
node: { attrs },
|
||||||
|
} = props;
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper className="mention-component inline w-fit">
|
<NodeViewWrapper className="mention-component inline w-fit">
|
||||||
<a
|
{(extension.options as TMentionExtensionOptions).renderComponent({
|
||||||
href={props.node.attrs.redirect_uri || "#"}
|
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER],
|
||||||
target="_blank"
|
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
|
||||||
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
|
|
||||||
"bg-yellow-500/20 text-yellow-500": highlightsState
|
|
||||||
? highlightsState.includes(props.node.attrs.entity_identifier)
|
|
||||||
: false,
|
|
||||||
"cursor-pointer": !props.extension.options.readonly,
|
|
||||||
})}
|
})}
|
||||||
>
|
|
||||||
@{props.node.attrs.label}
|
|
||||||
</a>
|
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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<TMentionHandler, "searchCallback">;
|
||||||
|
|
||||||
|
export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps, ref) => {
|
||||||
|
const { command, query, searchCallback } = props;
|
||||||
|
// states
|
||||||
|
const [sections, setSections] = useState<TMentionSection[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState({
|
||||||
|
section: 0,
|
||||||
|
item: 0,
|
||||||
|
});
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
// refs
|
||||||
|
const commandListContainer = useRef<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={commandListContainer}
|
||||||
|
className="z-10 max-h-[90vh] w-[14rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg space-y-2"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center text-sm text-custom-text-400">Loading...</div>
|
||||||
|
) : sections.length ? (
|
||||||
|
sections.map((section, sectionIndex) => (
|
||||||
|
<div key={section.key} className="space-y-2">
|
||||||
|
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||||
|
{section.items.map((item, itemIndex) => {
|
||||||
|
const isSelected = sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
id={`mention-item-${sectionIndex}-${itemIndex}`}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200",
|
||||||
|
{
|
||||||
|
"bg-custom-background-80": isSelected,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
selectItem(sectionIndex, itemIndex);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
setSelectedIndex({
|
||||||
|
section: sectionIndex,
|
||||||
|
item: itemIndex,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="size-5 grid place-items-center flex-shrink-0">{item.icon}</span>
|
||||||
|
{item.subTitle && (
|
||||||
|
<h5 className="whitespace-nowrap text-xs text-custom-text-300 flex-shrink-0">{item.subTitle}</h5>
|
||||||
|
)}
|
||||||
|
<p className="flex-grow truncate">{item.title}</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-sm text-custom-text-400">No results</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
MentionsListDropdown.displayName = "MentionsListDropdown";
|
||||||
|
|
@ -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<IMentionSuggestion[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
|
||||||
const { query, mentionSuggestions } = props;
|
|
||||||
const [items, setItems] = useState<IMentionSuggestion[]>([]);
|
|
||||||
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<HTMLDivElement>(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 (
|
|
||||||
<div
|
|
||||||
ref={commandListContainer}
|
|
||||||
className="mentions max-h-48 min-w-[12rem] rounded-md bg-custom-background-100 border-[0.5px] border-custom-border-300 px-2 py-2.5 text-xs shadow-custom-shadow-rg overflow-y-scroll"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center text-custom-text-400">Loading...</div>
|
|
||||||
) : items.length ? (
|
|
||||||
items.map((item, index) => (
|
|
||||||
<button
|
|
||||||
key={item.id}
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
"w-full text-left flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
|
|
||||||
{
|
|
||||||
"bg-custom-background-80": index === selectedIndex,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
onClick={() => selectItem(index)}
|
|
||||||
>
|
|
||||||
<Avatar name={item?.title} src={item?.avatar} />
|
|
||||||
<span className="flex-grow truncate">{item.title}</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-custom-text-400">No results</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
MentionList.displayName = "MentionList";
|
|
||||||
|
|
@ -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<IMentionHighlight[]>;
|
|
||||||
readonly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CustomMentionWithoutProps = () =>
|
|
||||||
Mention.extend<CustomMentionOptions>({
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
14
packages/editor/src/core/extensions/mentions/types.ts
Normal file
14
packages/editor/src/core/extensions/mentions/types.ts
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
72
packages/editor/src/core/extensions/mentions/utils.ts
Normal file
72
packages/editor/src/core/extensions/mentions/utils.ts
Normal file
|
|
@ -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<TMentionHandler, "searchCallback">): SuggestionOptions["render"] =>
|
||||||
|
// @ts-expect-error - Tiptap types are incorrect
|
||||||
|
() => {
|
||||||
|
const { searchCallback } = props;
|
||||||
|
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
|
||||||
|
let popup: any | null = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||||
|
if (!searchCallback) return;
|
||||||
|
if (!props.clientRect) return;
|
||||||
|
component = new ReactRenderer<CommandListInstance, MentionsListDropdownProps>(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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
Table,
|
Table,
|
||||||
CustomMention,
|
CustomMentionExtension,
|
||||||
CustomReadOnlyImageExtension,
|
CustomReadOnlyImageExtension,
|
||||||
CustomTextAlignExtension,
|
CustomTextAlignExtension,
|
||||||
CustomCalloutReadOnlyExtension,
|
CustomCalloutReadOnlyExtension,
|
||||||
|
|
@ -28,20 +28,18 @@ import {
|
||||||
// helpers
|
// helpers
|
||||||
import { isValidHttpUrl } from "@/helpers/common";
|
import { isValidHttpUrl } from "@/helpers/common";
|
||||||
// types
|
// types
|
||||||
import { IMentionHighlight, TExtensions, TFileHandler } from "@/types";
|
import { TExtensions, TFileHandler, TReadOnlyMentionHandler } from "@/types";
|
||||||
// plane editor extensions
|
// plane editor extensions
|
||||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||||
mentionConfig: {
|
mentionHandler: TReadOnlyMentionHandler;
|
||||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||||
const { disabledExtensions, fileHandler, mentionConfig } = props;
|
const { disabledExtensions, fileHandler, mentionHandler } = props;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
|
|
@ -132,10 +130,7 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
CustomMention({
|
CustomMentionExtension(mentionHandler),
|
||||||
mentionHighlights: mentionConfig.mentionHighlights,
|
|
||||||
readonly: true,
|
|
||||||
}),
|
|
||||||
CharacterCount,
|
CharacterCount,
|
||||||
CustomColorExtension,
|
CustomColorExtension,
|
||||||
CustomTextAlignExtension,
|
CustomTextAlignExtension,
|
||||||
|
|
|
||||||
|
|
@ -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
|
// components
|
||||||
import { TSlashCommandSection } from "./command-items-list";
|
import { TSlashCommandSection } from "./command-items-list";
|
||||||
import { CommandMenuItem } from "./command-menu-item";
|
import { CommandMenuItem } from "./command-menu-item";
|
||||||
|
|
||||||
export type SlashCommandsMenuProps = {
|
export type SlashCommandsMenuProps = {
|
||||||
|
editor: Editor;
|
||||||
items: TSlashCommandSection[];
|
items: TSlashCommandSection[];
|
||||||
command: any;
|
command: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
|
||||||
const { items: sections, command } = props;
|
const { items: sections, command } = props;
|
||||||
// states
|
// states
|
||||||
const [selectedIndex, setSelectedIndex] = useState({
|
const [selectedIndex, setSelectedIndex] = useState({
|
||||||
|
|
@ -41,12 +45,12 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
||||||
if (nextItem < 0) {
|
if (nextItem < 0) {
|
||||||
nextSection = currentSection - 1;
|
nextSection = currentSection - 1;
|
||||||
if (nextSection < 0) nextSection = sections.length - 1;
|
if (nextSection < 0) nextSection = sections.length - 1;
|
||||||
nextItem = sections[nextSection]?.items.length - 1;
|
nextItem = sections[nextSection]?.items?.length - 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
nextItem = currentItem + 1;
|
nextItem = currentItem + 1;
|
||||||
if (nextItem >= sections[currentSection].items.length) {
|
if (nextItem >= sections[currentSection]?.items?.length) {
|
||||||
nextSection = currentSection + 1;
|
nextSection = currentSection + 1;
|
||||||
if (nextSection >= sections.length) nextSection = 0;
|
if (nextSection >= sections.length) nextSection = 0;
|
||||||
nextItem = 0;
|
nextItem = 0;
|
||||||
|
|
@ -84,7 +88,26 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
||||||
item?.scrollIntoView({ block: "nearest" });
|
item?.scrollIntoView({ block: "nearest" });
|
||||||
}, [sections, selectedIndex]);
|
}, [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;
|
if (areSearchResultsEmpty) return null;
|
||||||
|
|
||||||
|
|
@ -98,7 +121,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
||||||
<div key={section.key} className="space-y-2">
|
<div key={section.key} className="space-y-2">
|
||||||
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
{section.title && <h6 className="text-xs font-semibold text-custom-text-300">{section.title}</h6>}
|
||||||
<div>
|
<div>
|
||||||
{section.items.map((item, itemIndex) => (
|
{section.items?.map((item, itemIndex) => (
|
||||||
<CommandMenuItem
|
<CommandMenuItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||||
|
|
@ -122,4 +145,6 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
SlashCommandsMenu.displayName = "SlashCommandsMenu";
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import { Editor, Range, Extension } from "@tiptap/core";
|
||||||
import { ReactRenderer } from "@tiptap/react";
|
import { ReactRenderer } from "@tiptap/react";
|
||||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
|
// helpers
|
||||||
|
import { CommandListInstance } from "@/helpers/tippy";
|
||||||
// types
|
// types
|
||||||
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||||
// components
|
// components
|
||||||
|
|
@ -55,16 +57,12 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface CommandListInstance {
|
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderItems = () => {
|
const renderItems = () => {
|
||||||
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
|
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
|
||||||
let popup: any | null = null;
|
let popup: any | null = null;
|
||||||
return {
|
return {
|
||||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||||
component = new ReactRenderer(SlashCommandsMenu, {
|
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
|
||||||
props,
|
props,
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
});
|
});
|
||||||
|
|
@ -91,10 +89,8 @@ const renderItems = () => {
|
||||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||||
if (props.event.key === "Escape") {
|
if (props.event.key === "Escape") {
|
||||||
popup?.[0].hide();
|
popup?.[0].hide();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (component?.ref?.onKeyDown(props)) {
|
if (component?.ref?.onKeyDown(props)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
packages/editor/src/core/helpers/tippy.ts
Normal file
58
packages/editor/src/core/helpers/tippy.ts
Normal file
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -3,7 +3,7 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { DOMSerializer } from "@tiptap/pm/model";
|
import { DOMSerializer } from "@tiptap/pm/model";
|
||||||
import { Selection } from "@tiptap/pm/state";
|
import { Selection } from "@tiptap/pm/state";
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
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";
|
import * as Y from "yjs";
|
||||||
// components
|
// components
|
||||||
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
import { EditorMenuItem, getEditorMenuItems } from "@/components/menus";
|
||||||
|
|
@ -19,11 +19,10 @@ import { CoreEditorProps } from "@/props";
|
||||||
import type {
|
import type {
|
||||||
TDocumentEventsServer,
|
TDocumentEventsServer,
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
IMentionHighlight,
|
|
||||||
IMentionSuggestion,
|
|
||||||
TEditorCommands,
|
TEditorCommands,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
TExtensions,
|
TExtensions,
|
||||||
|
TMentionHandler,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
export interface CustomEditorProps {
|
export interface CustomEditorProps {
|
||||||
|
|
@ -32,16 +31,13 @@ export interface CustomEditorProps {
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
enableHistory: boolean;
|
enableHistory: boolean;
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
extensions?: any;
|
extensions?: Extensions;
|
||||||
fileHandler: TFileHandler;
|
fileHandler: TFileHandler;
|
||||||
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
id?: string;
|
id?: string;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
mentionHandler: {
|
mentionHandler: TMentionHandler;
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
|
||||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
|
||||||
};
|
|
||||||
onChange?: (json: object, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
onTransaction?: () => void;
|
onTransaction?: () => void;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
|
|
@ -96,10 +92,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
enableHistory,
|
enableHistory,
|
||||||
fileHandler,
|
fileHandler,
|
||||||
mentionConfig: {
|
mentionHandler,
|
||||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
|
||||||
mentionHighlights: mentionHandler.highlights,
|
|
||||||
},
|
|
||||||
placeholder,
|
placeholder,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
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";
|
import * as Y from "yjs";
|
||||||
// extensions
|
// extensions
|
||||||
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
import { CoreReadOnlyEditorExtensions } from "@/extensions";
|
||||||
|
|
@ -13,24 +13,22 @@ import { CoreReadOnlyEditorProps } from "@/props";
|
||||||
// types
|
// types
|
||||||
import type {
|
import type {
|
||||||
EditorReadOnlyRefApi,
|
EditorReadOnlyRefApi,
|
||||||
IMentionHighlight,
|
|
||||||
TExtensions,
|
TExtensions,
|
||||||
TDocumentEventsServer,
|
TDocumentEventsServer,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
|
TReadOnlyMentionHandler,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
||||||
interface CustomReadOnlyEditorProps {
|
interface CustomReadOnlyEditorProps {
|
||||||
disabledExtensions: TExtensions[];
|
disabledExtensions: TExtensions[];
|
||||||
editorClassName: string;
|
editorClassName: string;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
extensions?: any;
|
extensions?: Extensions;
|
||||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
mentionHandler: {
|
mentionHandler: TReadOnlyMentionHandler;
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
|
||||||
};
|
|
||||||
provider?: HocuspocusProvider;
|
provider?: HocuspocusProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,9 +61,7 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||||
extensions: [
|
extensions: [
|
||||||
...CoreReadOnlyEditorExtensions({
|
...CoreReadOnlyEditorExtensions({
|
||||||
disabledExtensions,
|
disabledExtensions,
|
||||||
mentionConfig: {
|
mentionHandler,
|
||||||
mentionHighlights: mentionHandler.highlights,
|
|
||||||
},
|
|
||||||
fileHandler,
|
fileHandler,
|
||||||
}),
|
}),
|
||||||
...extensions,
|
...extensions,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,10 @@ import { TEmbedConfig } from "@/plane-editor/types";
|
||||||
import {
|
import {
|
||||||
EditorReadOnlyRefApi,
|
EditorReadOnlyRefApi,
|
||||||
EditorRefApi,
|
EditorRefApi,
|
||||||
IMentionHighlight,
|
|
||||||
IMentionSuggestion,
|
|
||||||
TExtensions,
|
TExtensions,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
|
TMentionHandler,
|
||||||
|
TReadOnlyMentionHandler,
|
||||||
TRealtimeConfig,
|
TRealtimeConfig,
|
||||||
TUserDetails,
|
TUserDetails,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
|
@ -27,10 +27,6 @@ type TCollaborativeEditorHookProps = {
|
||||||
extensions?: Extensions;
|
extensions?: Extensions;
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
id: string;
|
id: string;
|
||||||
mentionHandler: {
|
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
|
||||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
|
||||||
};
|
|
||||||
realtimeConfig: TRealtimeConfig;
|
realtimeConfig: TRealtimeConfig;
|
||||||
serverHandler?: TServerHandler;
|
serverHandler?: TServerHandler;
|
||||||
user: TUserDetails;
|
user: TUserDetails;
|
||||||
|
|
@ -41,6 +37,7 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||||
embedHandler?: TEmbedConfig;
|
embedHandler?: TEmbedConfig;
|
||||||
fileHandler: TFileHandler;
|
fileHandler: TFileHandler;
|
||||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
|
mentionHandler: TMentionHandler;
|
||||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
@ -48,4 +45,5 @@ export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||||
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
export type TReadOnlyCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
|
mentionHandler: TReadOnlyMentionHandler;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { JSONContent } from "@tiptap/core";
|
import { Extensions, JSONContent } from "@tiptap/core";
|
||||||
import { Selection } from "@tiptap/pm/state";
|
import { Selection } from "@tiptap/pm/state";
|
||||||
// helpers
|
// helpers
|
||||||
import { IMarking } from "@/helpers/scroll-to-node";
|
import { IMarking } from "@/helpers/scroll-to-node";
|
||||||
// types
|
// types
|
||||||
import {
|
import {
|
||||||
IMentionHighlight,
|
|
||||||
IMentionSuggestion,
|
|
||||||
TAIHandler,
|
TAIHandler,
|
||||||
TDisplayConfig,
|
TDisplayConfig,
|
||||||
TDocumentEventEmitter,
|
TDocumentEventEmitter,
|
||||||
|
|
@ -13,6 +11,8 @@ import {
|
||||||
TEmbedConfig,
|
TEmbedConfig,
|
||||||
TExtensions,
|
TExtensions,
|
||||||
TFileHandler,
|
TFileHandler,
|
||||||
|
TMentionHandler,
|
||||||
|
TReadOnlyMentionHandler,
|
||||||
TServerHandler,
|
TServerHandler,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { TTextAlign } from "@/extensions";
|
import { TTextAlign } from "@/extensions";
|
||||||
|
|
@ -114,10 +114,7 @@ export interface IEditorProps {
|
||||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
id: string;
|
id: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
mentionHandler: {
|
mentionHandler: TMentionHandler;
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
|
||||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
|
||||||
};
|
|
||||||
onChange?: (json: object, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
onTransaction?: () => void;
|
onTransaction?: () => void;
|
||||||
handleEditorReady?: (value: boolean) => void;
|
handleEditorReady?: (value: boolean) => void;
|
||||||
|
|
@ -128,10 +125,10 @@ export interface IEditorProps {
|
||||||
value?: string | null;
|
value?: string | null;
|
||||||
}
|
}
|
||||||
export interface ILiteTextEditor extends IEditorProps {
|
export interface ILiteTextEditor extends IEditorProps {
|
||||||
extensions?: any[];
|
extensions?: Extensions;
|
||||||
}
|
}
|
||||||
export interface IRichTextEditor extends IEditorProps {
|
export interface IRichTextEditor extends IEditorProps {
|
||||||
extensions?: any[];
|
extensions?: Extensions;
|
||||||
bubbleMenuEnabled?: boolean;
|
bubbleMenuEnabled?: boolean;
|
||||||
dragDropEnabled?: boolean;
|
dragDropEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -158,9 +155,7 @@ export interface IReadOnlyEditorProps {
|
||||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
id: string;
|
id: string;
|
||||||
initialValue: string;
|
initialValue: string;
|
||||||
mentionHandler: {
|
mentionHandler: TReadOnlyMentionHandler;
|
||||||
highlights: () => Promise<IMentionHighlight[]>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
|
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export * from "./editor";
|
||||||
export * from "./embed";
|
export * from "./embed";
|
||||||
export * from "./extensions";
|
export * from "./extensions";
|
||||||
export * from "./image";
|
export * from "./image";
|
||||||
export * from "./mention-suggestion";
|
export * from "./mention";
|
||||||
export * from "./slash-commands-suggestion";
|
export * from "./slash-commands-suggestion";
|
||||||
export * from "@/plane-editor/types";
|
export * from "@/plane-editor/types";
|
||||||
export * from "./document-collaborative-events";
|
export * from "./document-collaborative-events";
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
27
packages/editor/src/core/types/mention.ts
Normal file
27
packages/editor/src/core/types/mention.ts
Normal file
|
|
@ -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<TMentionSuggestion, "entity_identifier" | "entity_name">;
|
||||||
|
|
||||||
|
export type TReadOnlyMentionHandler = {
|
||||||
|
renderComponent: (props: TMentionComponentProps) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TMentionHandler = TReadOnlyMentionHandler & {
|
||||||
|
searchCallback?: (query: string) => Promise<TMentionSection[]>;
|
||||||
|
};
|
||||||
5
packages/types/.prettierrc
Normal file
5
packages/types/.prettierrc
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5"
|
||||||
|
}
|
||||||
1
packages/types/src/index.d.ts
vendored
1
packages/types/src/index.d.ts
vendored
|
|
@ -28,6 +28,7 @@ export * from "./workspace-views";
|
||||||
export * from "./common";
|
export * from "./common";
|
||||||
export * from "./pragmatic";
|
export * from "./pragmatic";
|
||||||
export * from "./publish";
|
export * from "./publish";
|
||||||
|
export * from "./search";
|
||||||
export * from "./workspace-notifications";
|
export * from "./workspace-notifications";
|
||||||
export * from "./favorite";
|
export * from "./favorite";
|
||||||
export * from "./file";
|
export * from "./file";
|
||||||
|
|
|
||||||
81
packages/types/src/search.d.ts
vendored
Normal file
81
packages/types/src/search.d.ts
vendored
Normal file
|
|
@ -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;
|
||||||
|
};
|
||||||
1
space/ce/components/editor/embeds/index.ts
Normal file
1
space/ce/components/editor/embeds/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./mentions";
|
||||||
1
space/ce/components/editor/embeds/mentions/index.ts
Normal file
1
space/ce/components/editor/embeds/mentions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
4
space/ce/components/editor/embeds/mentions/root.tsx
Normal file
4
space/ce/components/editor/embeds/mentions/root.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// plane editor
|
||||||
|
import { TMentionComponentProps } from "@plane/editor";
|
||||||
|
|
||||||
|
export const EditorAdditionalMentionsRoot: React.FC<TMentionComponentProps> = () => null;
|
||||||
1
space/ce/components/editor/index.ts
Normal file
1
space/ce/components/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./embeds";
|
||||||
1
space/core/components/editor/embeds/index.ts
Normal file
1
space/core/components/editor/embeds/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./mentions";
|
||||||
1
space/core/components/editor/embeds/mentions/index.ts
Normal file
1
space/core/components/editor/embeds/mentions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
17
space/core/components/editor/embeds/mentions/root.tsx
Normal file
17
space/core/components/editor/embeds/mentions/root.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// plane editor
|
||||||
|
import { TMentionComponentProps } from "@plane/editor";
|
||||||
|
// plane web components
|
||||||
|
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
|
||||||
|
// local components
|
||||||
|
import { EditorUserMention } from "./user";
|
||||||
|
|
||||||
|
export const EditorMentionsRoot: React.FC<TMentionComponentProps> = (props) => {
|
||||||
|
const { entity_identifier, entity_name } = props;
|
||||||
|
|
||||||
|
switch (entity_name) {
|
||||||
|
case "user_mention":
|
||||||
|
return <EditorUserMention id={entity_identifier} />;
|
||||||
|
default:
|
||||||
|
return <EditorAdditionalMentionsRoot {...props} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
41
space/core/components/editor/embeds/mentions/user.tsx
Normal file
41
space/core/components/editor/embeds/mentions/user.tsx
Normal file
|
|
@ -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<Props> = 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 (
|
||||||
|
<div className="not-prose inline px-1 py-0.5 rounded bg-custom-background-80 text-custom-text-300 no-underline">
|
||||||
|
@deactivated user
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("not-prose inline px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-500 no-underline", {
|
||||||
|
"bg-custom-primary-100/20 text-custom-primary-100": id === currentUser?.id,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Link href={profileLink}>@{userDetails?.member__display_name}</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./embeds";
|
||||||
export * from "./lite-text-editor";
|
export * from "./lite-text-editor";
|
||||||
export * from "./lite-text-read-only-editor";
|
export * from "./lite-text-read-only-editor";
|
||||||
export * from "./rich-text-read-only-editor";
|
export * from "./rich-text-read-only-editor";
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,11 @@ import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
||||||
// components
|
// components
|
||||||
import { IssueCommentToolbar } from "@/components/editor";
|
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||||
// hooks
|
|
||||||
import { useMention } from "@/hooks/use-mention";
|
|
||||||
|
|
||||||
interface LiteTextEditorWrapperProps
|
interface LiteTextEditorWrapperProps
|
||||||
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||||
|
|
@ -29,8 +27,6 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
uploadFile,
|
uploadFile,
|
||||||
...rest
|
...rest
|
||||||
} = props;
|
} = props;
|
||||||
// use-mention
|
|
||||||
const { mentionHighlights } = useMention();
|
|
||||||
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
function isMutableRefObject<T>(ref: React.ForwardedRef<T>): ref is React.MutableRefObject<T | null> {
|
||||||
return !!ref && typeof ref === "object" && "current" in ref;
|
return !!ref && typeof ref === "object" && "current" in ref;
|
||||||
}
|
}
|
||||||
|
|
@ -49,8 +45,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
workspaceId,
|
workspaceId,
|
||||||
})}
|
})}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
// suggestions disabled for now
|
|
||||||
}}
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
// overriding the containerClassName to add relative class passed
|
// overriding the containerClassName to add relative class passed
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
|
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||||
|
// components
|
||||||
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
// hooks
|
|
||||||
import { useMention } from "@/hooks/use-mention";
|
|
||||||
|
|
||||||
type LiteTextReadOnlyEditorWrapperProps = Omit<
|
type LiteTextReadOnlyEditorWrapperProps = Omit<
|
||||||
ILiteTextReadOnlyEditor,
|
ILiteTextReadOnlyEditor,
|
||||||
|
|
@ -15,10 +15,7 @@ type LiteTextReadOnlyEditorWrapperProps = Omit<
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
|
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
|
||||||
({ anchor, ...props }, ref) => {
|
({ anchor, ...props }, ref) => (
|
||||||
const { mentionHighlights } = useMention();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LiteTextReadOnlyEditorWithRef
|
<LiteTextReadOnlyEditorWithRef
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabledExtensions={[]}
|
disabledExtensions={[]}
|
||||||
|
|
@ -26,14 +23,13 @@ export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Lit
|
||||||
anchor,
|
anchor,
|
||||||
})}
|
})}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
// overriding the customClassName to add relative class passed
|
// overriding the customClassName to add relative class passed
|
||||||
containerClassName={cn(props.containerClassName, "relative p-2")}
|
containerClassName={cn(props.containerClassName, "relative p-2")}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor";
|
LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor";
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, IMentionHighlight, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
|
||||||
|
// components
|
||||||
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
|
|
||||||
|
|
@ -11,19 +13,11 @@ interface RichTextEditorWrapperProps
|
||||||
|
|
||||||
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
|
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
|
||||||
const { containerClassName, uploadFile, ...rest } = props;
|
const { containerClassName, uploadFile, ...rest } = props;
|
||||||
// store hooks
|
|
||||||
|
|
||||||
// use-mention
|
|
||||||
|
|
||||||
// file size
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RichTextEditorWithRef
|
<RichTextEditorWithRef
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: function (): Promise<IMentionHighlight[]> {
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
throw new Error("Function not implemented.");
|
|
||||||
},
|
|
||||||
suggestions: undefined,
|
|
||||||
}}
|
}}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabledExtensions={[]}
|
disabledExtensions={[]}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor";
|
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||||
|
// components
|
||||||
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
// hooks
|
|
||||||
import { useMention } from "@/hooks/use-mention";
|
|
||||||
|
|
||||||
type RichTextReadOnlyEditorWrapperProps = Omit<
|
type RichTextReadOnlyEditorWrapperProps = Omit<
|
||||||
IRichTextReadOnlyEditor,
|
IRichTextReadOnlyEditor,
|
||||||
|
|
@ -15,23 +15,21 @@ type RichTextReadOnlyEditorWrapperProps = Omit<
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
|
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
|
||||||
({ anchor, ...props }, ref) => {
|
({ anchor, ...props }, ref) => (
|
||||||
const { mentionHighlights } = useMention();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RichTextReadOnlyEditorWithRef
|
<RichTextReadOnlyEditorWithRef
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabledExtensions={[]}
|
disabledExtensions={[]}
|
||||||
fileHandler={getReadOnlyEditorFileHandlers({
|
fileHandler={getReadOnlyEditorFileHandlers({
|
||||||
anchor,
|
anchor,
|
||||||
})}
|
})}
|
||||||
mentionHandler={{ highlights: mentionHighlights }}
|
mentionHandler={{
|
||||||
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
// overriding the customClassName to add relative class passed
|
// overriding the customClassName to add relative class passed
|
||||||
containerClassName={cn("relative p-0 border-none", props.containerClassName)}
|
containerClassName={cn("relative p-0 border-none", props.containerClassName)}
|
||||||
/>
|
/>
|
||||||
);
|
)
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor";
|
RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor";
|
||||||
|
|
|
||||||
|
|
@ -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] : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -8,7 +8,6 @@ import { CycleStore, ICycleStore } from "./cycle.store";
|
||||||
import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store";
|
import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store";
|
||||||
import { IIssueLabelStore, LabelStore } from "./label.store";
|
import { IIssueLabelStore, LabelStore } from "./label.store";
|
||||||
import { IIssueMemberStore, MemberStore } from "./members.store";
|
import { IIssueMemberStore, MemberStore } from "./members.store";
|
||||||
import { IMentionsStore, MentionsStore } from "./mentions.store";
|
|
||||||
import { IIssueModuleStore, ModuleStore } from "./module.store";
|
import { IIssueModuleStore, ModuleStore } from "./module.store";
|
||||||
import { IPublishListStore, PublishListStore } from "./publish/publish_list.store";
|
import { IPublishListStore, PublishListStore } from "./publish/publish_list.store";
|
||||||
import { IStateStore, StateStore } from "./state.store";
|
import { IStateStore, StateStore } from "./state.store";
|
||||||
|
|
@ -20,7 +19,6 @@ export class CoreRootStore {
|
||||||
user: IUserStore;
|
user: IUserStore;
|
||||||
issue: IIssueStore;
|
issue: IIssueStore;
|
||||||
issueDetail: IIssueDetailStore;
|
issueDetail: IIssueDetailStore;
|
||||||
mentionStore: IMentionsStore;
|
|
||||||
state: IStateStore;
|
state: IStateStore;
|
||||||
label: IIssueLabelStore;
|
label: IIssueLabelStore;
|
||||||
module: IIssueModuleStore;
|
module: IIssueModuleStore;
|
||||||
|
|
@ -34,7 +32,6 @@ export class CoreRootStore {
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
this.issue = new IssueStore(this);
|
this.issue = new IssueStore(this);
|
||||||
this.issueDetail = new IssueDetailStore(this);
|
this.issueDetail = new IssueDetailStore(this);
|
||||||
this.mentionStore = new MentionsStore(this);
|
|
||||||
this.state = new StateStore(this);
|
this.state = new StateStore(this);
|
||||||
this.label = new LabelStore(this);
|
this.label = new LabelStore(this);
|
||||||
this.module = new ModuleStore(this);
|
this.module = new ModuleStore(this);
|
||||||
|
|
@ -57,7 +54,6 @@ export class CoreRootStore {
|
||||||
this.user = new UserStore(this);
|
this.user = new UserStore(this);
|
||||||
this.issue = new IssueStore(this);
|
this.issue = new IssueStore(this);
|
||||||
this.issueDetail = new IssueDetailStore(this);
|
this.issueDetail = new IssueDetailStore(this);
|
||||||
this.mentionStore = new MentionsStore(this);
|
|
||||||
this.state = new StateStore(this);
|
this.state = new StateStore(this);
|
||||||
this.label = new LabelStore(this);
|
this.label = new LabelStore(this);
|
||||||
this.module = new ModuleStore(this);
|
this.module = new ModuleStore(this);
|
||||||
|
|
|
||||||
1
space/ee/components/editor/index.ts
Normal file
1
space/ee/components/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/components/editor";
|
||||||
1
web/ce/components/editor/embeds/index.ts
Normal file
1
web/ce/components/editor/embeds/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./mentions";
|
||||||
1
web/ce/components/editor/embeds/mentions/index.ts
Normal file
1
web/ce/components/editor/embeds/mentions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
4
web/ce/components/editor/embeds/mentions/root.tsx
Normal file
4
web/ce/components/editor/embeds/mentions/root.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// plane editor
|
||||||
|
import { TMentionComponentProps } from "@plane/editor";
|
||||||
|
|
||||||
|
export const EditorAdditionalMentionsRoot: React.FC<TMentionComponentProps> = () => null;
|
||||||
1
web/ce/components/editor/index.ts
Normal file
1
web/ce/components/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./embeds";
|
||||||
4
web/ce/constants/editor.ts
Normal file
4
web/ce/constants/editor.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// plane types
|
||||||
|
import { TSearchEntities } from "@plane/types";
|
||||||
|
|
||||||
|
export const EDITOR_MENTION_TYPES: TSearchEntities[] = ["user_mention"];
|
||||||
41
web/ce/hooks/use-additional-editor-mention.tsx
Normal file
41
web/ce/hooks/use-additional-editor-mention.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
// plane editor
|
||||||
|
import { TMentionSection } from "@plane/editor";
|
||||||
|
// plane types
|
||||||
|
import { TSearchEntities, TSearchResponse } from "@plane/types";
|
||||||
|
|
||||||
|
export type TAdditionalEditorMentionHandlerArgs = {
|
||||||
|
response: TSearchResponse;
|
||||||
|
sections: TMentionSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAdditionalParseEditorContentArgs = {
|
||||||
|
id: string;
|
||||||
|
entityType: TSearchEntities;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TAdditionalParseEditorContentReturnType =
|
||||||
|
| {
|
||||||
|
redirectionPath: string;
|
||||||
|
textContent: string;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
export const useAdditionalEditorMention = () => {
|
||||||
|
const updateAdditionalSections = useCallback((args: TAdditionalEditorMentionHandlerArgs) => {
|
||||||
|
const {} = args;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const parseAdditionalEditorContent = useCallback(
|
||||||
|
(args: TAdditionalParseEditorContentArgs): TAdditionalParseEditorContentReturnType => {
|
||||||
|
const {} = args;
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateAdditionalSections,
|
||||||
|
parseAdditionalEditorContent,
|
||||||
|
};
|
||||||
|
};
|
||||||
1
web/core/components/editor/embeds/index.ts
Normal file
1
web/core/components/editor/embeds/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./mentions";
|
||||||
1
web/core/components/editor/embeds/mentions/index.ts
Normal file
1
web/core/components/editor/embeds/mentions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "./root";
|
||||||
17
web/core/components/editor/embeds/mentions/root.tsx
Normal file
17
web/core/components/editor/embeds/mentions/root.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// plane editor
|
||||||
|
import { TMentionComponentProps } from "@plane/editor";
|
||||||
|
// plane web components
|
||||||
|
import { EditorAdditionalMentionsRoot } from "@/plane-web/components/editor";
|
||||||
|
// local components
|
||||||
|
import { EditorUserMention } from "./user";
|
||||||
|
|
||||||
|
export const EditorMentionsRoot: React.FC<TMentionComponentProps> = (props) => {
|
||||||
|
const { entity_identifier, entity_name } = props;
|
||||||
|
|
||||||
|
switch (entity_name) {
|
||||||
|
case "user_mention":
|
||||||
|
return <EditorUserMention id={entity_identifier} />;
|
||||||
|
default:
|
||||||
|
return <EditorAdditionalMentionsRoot {...props} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
96
web/core/components/editor/embeds/mentions/user.tsx
Normal file
96
web/core/components/editor/embeds/mentions/user.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
import { usePopper } from "react-popper";
|
||||||
|
// plane ui
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
// constants
|
||||||
|
import { ROLE } from "@/constants/workspace";
|
||||||
|
// helpers
|
||||||
|
import { cn } from "@/helpers/common.helper";
|
||||||
|
import { getFileURL } from "@/helpers/file.helper";
|
||||||
|
// hooks
|
||||||
|
import { useMember, useUser } from "@/hooks/store";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EditorUserMention: React.FC<Props> = observer((props) => {
|
||||||
|
const { id } = props;
|
||||||
|
// states
|
||||||
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [referenceElement, setReferenceElement] = useState<HTMLAnchorElement | null>(null);
|
||||||
|
// params
|
||||||
|
const { workspaceSlug } = useParams();
|
||||||
|
// store hooks
|
||||||
|
const { data: currentUser } = useUser();
|
||||||
|
const {
|
||||||
|
getUserDetails,
|
||||||
|
project: { getProjectMemberDetails },
|
||||||
|
} = useMember();
|
||||||
|
// popper-js refs
|
||||||
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||||
|
placement: "bottom-start",
|
||||||
|
modifiers: [
|
||||||
|
{
|
||||||
|
name: "preventOverflow",
|
||||||
|
options: {
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// derived values
|
||||||
|
const userDetails = getUserDetails(id);
|
||||||
|
const roleDetails = getProjectMemberDetails(id)?.role;
|
||||||
|
const profileLink = `/${workspaceSlug}/profile/${id}`;
|
||||||
|
|
||||||
|
if (!userDetails) {
|
||||||
|
return (
|
||||||
|
<div className="not-prose inline px-1 py-0.5 rounded bg-custom-background-80 text-custom-text-300 no-underline">
|
||||||
|
@deactivated user
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"not-prose group/user-mention inline px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-500 no-underline",
|
||||||
|
{
|
||||||
|
"bg-custom-primary-100/20 text-custom-primary-100": id === currentUser?.id,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link href={profileLink} ref={setReferenceElement}>
|
||||||
|
@{userDetails?.display_name}
|
||||||
|
</Link>
|
||||||
|
<div
|
||||||
|
className="top-full left-0 z-10 min-w-60 bg-custom-background-90 shadow-custom-shadow-rg rounded-lg p-4 opacity-0 pointer-events-none group-hover/user-mention:opacity-100 group-hover/user-mention:pointer-events-auto transition-opacity"
|
||||||
|
ref={setPopperElement}
|
||||||
|
style={styles.popper}
|
||||||
|
{...attributes.popper}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-shrink-0 size-10 grid place-items-center">
|
||||||
|
<Avatar
|
||||||
|
src={getFileURL(userDetails?.avatar_url ?? "")}
|
||||||
|
name={userDetails?.display_name}
|
||||||
|
size={40}
|
||||||
|
className="text-xl"
|
||||||
|
showTooltip={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Link href={profileLink} className="not-prose font-medium text-custom-text-100 text-sm hover:underline">
|
||||||
|
{userDetails?.first_name} {userDetails?.last_name}
|
||||||
|
</Link>
|
||||||
|
{roleDetails && <p className="text-custom-text-200 text-xs">{ROLE[roleDetails]}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
export * from "./embeds";
|
||||||
export * from "./lite-text-editor";
|
export * from "./lite-text-editor";
|
||||||
export * from "./pdf";
|
export * from "./pdf";
|
||||||
export * from "./rich-text-editor";
|
export * from "./rich-text-editor";
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,22 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
// editor
|
// plane constants
|
||||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||||
|
// plane editor
|
||||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
||||||
// types
|
|
||||||
import { IUserLite } from "@plane/types";
|
|
||||||
// components
|
// components
|
||||||
import { IssueCommentToolbar } from "@/components/editor";
|
import { EditorMentionsRoot, IssueCommentToolbar } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
import { isCommentEmpty } from "@/helpers/string.helper";
|
import { isCommentEmpty } from "@/helpers/string.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
import { useEditorMention } from "@/hooks/use-editor-mention";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||||
|
// services
|
||||||
|
import { ProjectService } from "@/services/project";
|
||||||
|
const projectService = new ProjectService();
|
||||||
|
|
||||||
interface LiteTextEditorWrapperProps
|
interface LiteTextEditorWrapperProps
|
||||||
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
extends Omit<ILiteTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||||
|
|
@ -48,23 +50,12 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
} = props;
|
} = props;
|
||||||
// states
|
// states
|
||||||
const [isFocused, setIsFocused] = useState(showToolbarInitially);
|
const [isFocused, setIsFocused] = useState(showToolbarInitially);
|
||||||
// store hooks
|
|
||||||
const { data: currentUser } = useUser();
|
|
||||||
const {
|
|
||||||
getUserDetails,
|
|
||||||
project: { getProjectMemberIds },
|
|
||||||
} = useMember();
|
|
||||||
// editor flaggings
|
// editor flaggings
|
||||||
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||||
// derived values
|
// use editor mention
|
||||||
const projectMemberIds = getProjectMemberIds(projectId);
|
const { fetchMentions } = useEditorMention({
|
||||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
searchEntity: async (payload) =>
|
||||||
// use-mention
|
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
members: projectMemberDetails,
|
|
||||||
user: currentUser ?? undefined,
|
|
||||||
});
|
});
|
||||||
// file size
|
// file size
|
||||||
const { maxFileSize } = useFileSize();
|
const { maxFileSize } = useFileSize();
|
||||||
|
|
@ -92,8 +83,12 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
searchCallback: async (query) => {
|
||||||
suggestions: mentionSuggestions,
|
const res = await fetchMentions(query);
|
||||||
|
if (!res) throw new Error("Failed in fetching mentions");
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
containerClassName={cn(containerClassName, "relative")}
|
containerClassName={cn(containerClassName, "relative")}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// plane editor
|
||||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
|
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||||
|
// components
|
||||||
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
// hooks
|
|
||||||
import { useMention, useUser } from "@/hooks/store";
|
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
|
|
||||||
|
|
@ -19,11 +19,6 @@ type LiteTextReadOnlyEditorWrapperProps = Omit<
|
||||||
|
|
||||||
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
|
export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, LiteTextReadOnlyEditorWrapperProps>(
|
||||||
({ workspaceSlug, projectId, ...props }, ref) => {
|
({ workspaceSlug, projectId, ...props }, ref) => {
|
||||||
// store hooks
|
|
||||||
const { data: currentUser } = useUser();
|
|
||||||
const { mentionHighlights } = useMention({
|
|
||||||
user: currentUser,
|
|
||||||
});
|
|
||||||
// editor flaggings
|
// editor flaggings
|
||||||
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
const { liteTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||||
|
|
||||||
|
|
@ -36,7 +31,7 @@ export const LiteTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Lit
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
// overriding the containerClassName to add relative class passed
|
// overriding the containerClassName to add relative class passed
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,36 @@
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
|
import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/editor";
|
||||||
// types
|
// components
|
||||||
import { IUserLite } from "@plane/types";
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
import { useEditorMention } from "@/hooks/use-editor-mention";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
import { useFileSize } from "@/plane-web/hooks/use-file-size";
|
||||||
|
// services
|
||||||
|
import { ProjectService } from "@/services/project";
|
||||||
|
const projectService = new ProjectService();
|
||||||
|
|
||||||
interface RichTextEditorWrapperProps
|
interface RichTextEditorWrapperProps
|
||||||
extends Omit<IRichTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
extends Omit<IRichTextEditor, "disabledExtensions" | "fileHandler" | "mentionHandler"> {
|
||||||
workspaceSlug: string;
|
workspaceSlug: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
memberIds: string[];
|
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
uploadFile: (file: File) => Promise<string>;
|
uploadFile: (file: File) => Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
|
export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProps>((props, ref) => {
|
||||||
const { containerClassName, workspaceSlug, workspaceId, projectId, memberIds, uploadFile, ...rest } = props;
|
const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props;
|
||||||
// store hooks
|
|
||||||
const { data: currentUser } = useUser();
|
|
||||||
const { getUserDetails } = useMember();
|
|
||||||
// editor flaggings
|
// editor flaggings
|
||||||
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||||
// derived values
|
// use editor mention
|
||||||
const memberDetails = memberIds?.map((id) => getUserDetails(id) as IUserLite);
|
const { fetchMentions } = useEditorMention({
|
||||||
// use-mention
|
searchEntity: async (payload) =>
|
||||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
members: memberDetails,
|
|
||||||
user: currentUser ?? undefined,
|
|
||||||
});
|
});
|
||||||
// file size
|
// file size
|
||||||
const { maxFileSize } = useFileSize();
|
const { maxFileSize } = useFileSize();
|
||||||
|
|
@ -52,8 +47,12 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
searchCallback: async (query) => {
|
||||||
suggestions: mentionSuggestions,
|
const res = await fetchMentions(query);
|
||||||
|
if (!res) throw new Error("Failed in fetching mentions");
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
{...rest}
|
{...rest}
|
||||||
containerClassName={cn("relative pl-3", containerClassName)}
|
containerClassName={cn("relative pl-3", containerClassName)}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
// editor
|
// editor
|
||||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor";
|
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/editor";
|
||||||
|
// components
|
||||||
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn } from "@/helpers/common.helper";
|
import { cn } from "@/helpers/common.helper";
|
||||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
// hooks
|
|
||||||
import { useMention } from "@/hooks/store";
|
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
|
|
||||||
|
|
@ -19,7 +19,6 @@ type RichTextReadOnlyEditorWrapperProps = Omit<
|
||||||
|
|
||||||
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
|
export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, RichTextReadOnlyEditorWrapperProps>(
|
||||||
({ workspaceSlug, projectId, ...props }, ref) => {
|
({ workspaceSlug, projectId, ...props }, ref) => {
|
||||||
const { mentionHighlights } = useMention({});
|
|
||||||
// editor flaggings
|
// editor flaggings
|
||||||
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
const { richTextEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||||
|
|
||||||
|
|
@ -32,7 +31,7 @@ export const RichTextReadOnlyEditor = React.forwardRef<EditorReadOnlyRefApi, Ric
|
||||||
workspaceSlug,
|
workspaceSlug,
|
||||||
})}
|
})}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
// overriding the containerClassName to add relative class passed
|
// overriding the containerClassName to add relative class passed
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { ETabIndices } from "@/constants/tab-indices";
|
||||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember, useProjectInbox } from "@/hooks/store";
|
import { useProjectInbox } from "@/hooks/store";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
|
|
@ -51,11 +51,6 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
||||||
// hooks
|
// hooks
|
||||||
const { loader } = useProjectInbox();
|
const { loader } = useProjectInbox();
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
const {
|
|
||||||
project: { getProjectMemberIds },
|
|
||||||
} = useMember();
|
|
||||||
// derived values
|
|
||||||
const memberIds = getProjectMemberIds(projectId) ?? [];
|
|
||||||
|
|
||||||
const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile);
|
const { getIndex } = getTabIndex(ETabIndices.INTAKE_ISSUE_FORM, isMobile);
|
||||||
|
|
||||||
|
|
@ -73,7 +68,6 @@ export const InboxIssueDescription: FC<TInboxIssueDescription> = observer((props
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
memberIds={memberIds}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
dragDropEnabled={false}
|
dragDropEnabled={false}
|
||||||
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
|
onChange={(_description: object, description_html: string) => handleData("description_html", description_html)}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import { TIssueOperations } from "@/components/issues/issue-detail";
|
||||||
// helpers
|
// helpers
|
||||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember, useWorkspace } from "@/hooks/store";
|
import { useWorkspace } from "@/hooks/store";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
|
@ -46,12 +46,6 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
|
||||||
setIsSubmitting,
|
setIsSubmitting,
|
||||||
placeholder,
|
placeholder,
|
||||||
} = props;
|
} = props;
|
||||||
// store hooks
|
|
||||||
const {
|
|
||||||
project: { getProjectMemberIds },
|
|
||||||
} = useMember();
|
|
||||||
// derived values
|
|
||||||
const memberIds = getProjectMemberIds(projectId) ?? [];
|
|
||||||
|
|
||||||
const { handleSubmit, reset, control } = useForm<TIssue>({
|
const { handleSubmit, reset, control } = useForm<TIssue>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
|
@ -114,7 +108,6 @@ export const IssueDescriptionInput: FC<IssueDescriptionInputProps> = observer((p
|
||||||
value={swrIssueDescription ?? null}
|
value={swrIssueDescription ?? null}
|
||||||
workspaceSlug={workspaceSlug}
|
workspaceSlug={workspaceSlug}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
memberIds={memberIds}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
dragDropEnabled
|
dragDropEnabled
|
||||||
onChange={(_description: object, description_html: string) => {
|
onChange={(_description: object, description_html: string) => {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { ETabIndices } from "@/constants/tab-indices";
|
||||||
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
import { getDescriptionPlaceholder } from "@/helpers/issue.helper";
|
||||||
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
import { getTabIndex } from "@/helpers/tab-indices.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useInstance, useMember, useWorkspace } from "@/hooks/store";
|
import { useInstance, useWorkspace } from "@/hooks/store";
|
||||||
import useKeypress from "@/hooks/use-keypress";
|
import useKeypress from "@/hooks/use-keypress";
|
||||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||||
// services
|
// services
|
||||||
|
|
@ -76,11 +76,6 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
||||||
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
|
const workspaceId = getWorkspaceBySlug(workspaceSlug?.toString())?.id as string;
|
||||||
const { config } = useInstance();
|
const { config } = useInstance();
|
||||||
const { isMobile } = usePlatformOS();
|
const { isMobile } = usePlatformOS();
|
||||||
const {
|
|
||||||
project: { getProjectMemberIds },
|
|
||||||
} = useMember();
|
|
||||||
// derived values
|
|
||||||
const memberIds = projectId ? (getProjectMemberIds(projectId) ?? []) : [];
|
|
||||||
|
|
||||||
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
const { getIndex } = getTabIndex(ETabIndices.ISSUE_FORM, isMobile);
|
||||||
|
|
||||||
|
|
@ -184,7 +179,6 @@ export const IssueDescriptionEditor: React.FC<TIssueDescriptionEditorProps> = ob
|
||||||
value={descriptionHtmlData}
|
value={descriptionHtmlData}
|
||||||
workspaceSlug={workspaceSlug?.toString() as string}
|
workspaceSlug={workspaceSlug?.toString() as string}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
memberIds={memberIds}
|
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onChange={(_description: object, description_html: string) => {
|
onChange={(_description: object, description_html: string) => {
|
||||||
onChange(description_html);
|
onChange(description_html);
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,18 @@ import {
|
||||||
TServerHandler,
|
TServerHandler,
|
||||||
} from "@plane/editor";
|
} from "@plane/editor";
|
||||||
// types
|
// types
|
||||||
import { IUserLite } from "@plane/types";
|
|
||||||
import { EFileAssetType } from "@plane/types/src/enums";
|
import { EFileAssetType } from "@plane/types/src/enums";
|
||||||
// components
|
// components
|
||||||
import { Row } from "@plane/ui";
|
import { Row } from "@plane/ui";
|
||||||
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
|
||||||
// helpers
|
// helpers
|
||||||
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
|
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
|
||||||
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
import { generateRandomColor } from "@/helpers/string.helper";
|
import { generateRandomColor } from "@/helpers/string.helper";
|
||||||
// hooks
|
// 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";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
// plane web components
|
// plane web components
|
||||||
import { EditorAIMenu } from "@/plane-web/components/pages";
|
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";
|
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
|
import { ProjectService } from "@/services/project";
|
||||||
// store
|
// store
|
||||||
import { IPage } from "@/store/pages/page";
|
import { IPage } from "@/store/pages/page";
|
||||||
|
|
||||||
// services init
|
// services init
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
const projectService = new ProjectService();
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editorRef: React.RefObject<EditorRefApi>;
|
editorRef: React.RefObject<EditorRefApi>;
|
||||||
|
|
@ -53,23 +55,15 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||||
// store hooks
|
// store hooks
|
||||||
const { data: currentUser } = useUser();
|
const { data: currentUser } = useUser();
|
||||||
const { getWorkspaceBySlug } = useWorkspace();
|
const { getWorkspaceBySlug } = useWorkspace();
|
||||||
const {
|
|
||||||
getUserDetails,
|
|
||||||
project: { getProjectMemberIds },
|
|
||||||
} = useMember();
|
|
||||||
// derived values
|
// derived values
|
||||||
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
const workspaceId = workspaceSlug ? (getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "") : "";
|
||||||
const pageId = page?.id;
|
const pageId = page?.id;
|
||||||
const pageTitle = page?.name ?? "";
|
const pageTitle = page?.name ?? "";
|
||||||
const { isContentEditable, updateTitle } = page;
|
const { isContentEditable, updateTitle } = page;
|
||||||
const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : [];
|
// use editor mention
|
||||||
const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite);
|
const { fetchMentions } = useEditorMention({
|
||||||
// use-mention
|
searchEntity: async (payload) =>
|
||||||
const { mentionHighlights, mentionSuggestions } = useMention({
|
await projectService.searchEntity(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "", payload),
|
||||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
|
||||||
projectId: projectId?.toString() ?? "",
|
|
||||||
members: projectMemberDetails,
|
|
||||||
user: currentUser ?? undefined,
|
|
||||||
});
|
});
|
||||||
// editor flaggings
|
// editor flaggings
|
||||||
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString());
|
||||||
|
|
@ -199,8 +193,12 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
|
||||||
displayConfig={displayConfig}
|
displayConfig={displayConfig}
|
||||||
editorClassName="pl-10"
|
editorClassName="pl-10"
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
searchCallback: async (query) => {
|
||||||
suggestions: mentionSuggestions,
|
const res = await fetchMentions(query);
|
||||||
|
if (!res) throw new Error("Failed in fetching mentions");
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
embedHandler={{
|
embedHandler={{
|
||||||
issue: issueEmbedProps,
|
issue: issueEmbedProps,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import { copyTextToClipboard, copyUrlToClipboard } from "@/helpers/string.helper
|
||||||
// hooks
|
// hooks
|
||||||
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
import { useCollaborativePageActions } from "@/hooks/use-collaborative-page-actions";
|
||||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
import { useQueryParams } from "@/hooks/use-query-params";
|
import { useQueryParams } from "@/hooks/use-query-params";
|
||||||
// store
|
// store
|
||||||
import { IPage } from "@/store/pages/page";
|
import { IPage } from "@/store/pages/page";
|
||||||
|
|
@ -59,6 +60,8 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
const { updateQueryParams } = useQueryParams();
|
const { updateQueryParams } = useQueryParams();
|
||||||
// collaborative actions
|
// collaborative actions
|
||||||
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
|
const { executeCollaborativeAction } = useCollaborativePageActions(editorRef, page);
|
||||||
|
// parse editor content
|
||||||
|
const { replaceCustomComponentsFromMarkdownContent } = useParseEditorContent();
|
||||||
|
|
||||||
// menu items list
|
// menu items list
|
||||||
const MENU_ITEMS: {
|
const MENU_ITEMS: {
|
||||||
|
|
@ -72,7 +75,11 @@ export const PageOptionsDropdown: React.FC<Props> = observer((props) => {
|
||||||
key: "copy-markdown",
|
key: "copy-markdown",
|
||||||
action: () => {
|
action: () => {
|
||||||
if (!editorRef) return;
|
if (!editorRef) return;
|
||||||
copyTextToClipboard(editorRef.getMarkDown()).then(() =>
|
const markdownContent = editorRef.getMarkDown();
|
||||||
|
const parsedMarkdownContent = replaceCustomComponentsFromMarkdownContent({
|
||||||
|
markdownContent,
|
||||||
|
});
|
||||||
|
copyTextToClipboard(parsedMarkdownContent).then(() =>
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
title: "Success!",
|
title: "Success!",
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,8 @@ import { EditorRefApi } from "@plane/editor";
|
||||||
import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
|
||||||
// components
|
// components
|
||||||
import { PDFDocument } from "@/components/editor";
|
import { PDFDocument } from "@/components/editor";
|
||||||
// helpers
|
// hooks
|
||||||
import {
|
import { useParseEditorContent } from "@/hooks/use-parse-editor-content";
|
||||||
replaceCustomComponentsFromHTMLContent,
|
|
||||||
replaceCustomComponentsFromMarkdownContent,
|
|
||||||
} from "@/helpers/editor.helper";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
editorRef: EditorRefApi | null;
|
editorRef: EditorRefApi | null;
|
||||||
|
|
@ -104,6 +101,9 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
||||||
const { control, reset, watch } = useForm<TFormValues>({
|
const { control, reset, watch } = useForm<TFormValues>({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
// parse editor content
|
||||||
|
const { replaceCustomComponentsFromHTMLContent, replaceCustomComponentsFromMarkdownContent } =
|
||||||
|
useParseEditorContent();
|
||||||
// derived values
|
// derived values
|
||||||
const selectedExportFormat = watch("export_format");
|
const selectedExportFormat = watch("export_format");
|
||||||
const selectedPageFormat = watch("page_format");
|
const selectedPageFormat = watch("page_format");
|
||||||
|
|
@ -179,6 +179,7 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
||||||
});
|
});
|
||||||
handleClose();
|
handleClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error in exporting page:", error);
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.ERROR,
|
type: TOAST_TYPE.ERROR,
|
||||||
title: "Error!",
|
title: "Error!",
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,14 @@ import { useParams } from "next/navigation";
|
||||||
// plane editor
|
// plane editor
|
||||||
import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
|
import { DocumentReadOnlyEditorWithRef, TDisplayConfig } from "@plane/editor";
|
||||||
// plane types
|
// plane types
|
||||||
import { IUserLite, TPageVersion } from "@plane/types";
|
import { TPageVersion } from "@plane/types";
|
||||||
// plane ui
|
// plane ui
|
||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
// components
|
||||||
|
import { EditorMentionsRoot } from "@/components/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
import { getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
|
||||||
// hooks
|
// hooks
|
||||||
import { useMember, useMention, useUser } from "@/hooks/store";
|
|
||||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||||
// plane web hooks
|
// plane web hooks
|
||||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||||
|
|
@ -26,26 +27,10 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
|
||||||
const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails } = props;
|
const { activeVersion, currentVersionDescription, isCurrentVersionActive, versionDetails } = props;
|
||||||
// params
|
// params
|
||||||
const { workspaceSlug, projectId } = useParams();
|
const { workspaceSlug, projectId } = useParams();
|
||||||
// store hooks
|
|
||||||
const { data: currentUser } = useUser();
|
|
||||||
const {
|
|
||||||
getUserDetails,
|
|
||||||
project: { getProjectMemberIds },
|
|
||||||
} = useMember();
|
|
||||||
// editor flaggings
|
// editor flaggings
|
||||||
const { documentEditor: disabledExtensions } = useEditorFlagging(workspaceSlug?.toString() ?? "");
|
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
|
// issue-embed
|
||||||
const { issueEmbedProps } = useIssueEmbed(workspaceSlug?.toString() ?? "", projectId?.toString() ?? "");
|
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
|
// page filters
|
||||||
const { fontSize, fontStyle } = usePageFilters();
|
const { fontSize, fontStyle } = usePageFilters();
|
||||||
|
|
||||||
|
|
@ -112,7 +97,7 @@ export const PagesVersionEditor: React.FC<TVersionEditorProps> = observer((props
|
||||||
workspaceSlug: workspaceSlug?.toString() ?? "",
|
workspaceSlug: workspaceSlug?.toString() ?? "",
|
||||||
})}
|
})}
|
||||||
mentionHandler={{
|
mentionHandler={{
|
||||||
highlights: mentionHighlights,
|
renderComponent: (props) => <EditorMentionsRoot {...props} />,
|
||||||
}}
|
}}
|
||||||
embedHandler={{
|
embedHandler={{
|
||||||
issue: {
|
issue: {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ export * from "./use-issues";
|
||||||
export * from "./use-kanban-view";
|
export * from "./use-kanban-view";
|
||||||
export * from "./use-label";
|
export * from "./use-label";
|
||||||
export * from "./use-member";
|
export * from "./use-member";
|
||||||
export * from "./use-mention";
|
|
||||||
export * from "./use-module";
|
export * from "./use-module";
|
||||||
export * from "./use-module-filter";
|
export * from "./use-module-filter";
|
||||||
export * from "./use-multiple-select-store";
|
export * from "./use-multiple-select-store";
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
|
||||||
// plane editor
|
|
||||||
import { IMentionSuggestion } from "@plane/editor";
|
|
||||||
// plane types
|
|
||||||
import { IUser, IUserLite } from "@plane/types";
|
|
||||||
// helpers
|
|
||||||
import { getFileURL } from "@/helpers/file.helper";
|
|
||||||
// hooks
|
|
||||||
import { useMember } from "@/hooks/store/use-member";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
workspaceSlug?: string;
|
|
||||||
projectId?: string;
|
|
||||||
members?: IUserLite[] | undefined;
|
|
||||||
user?: IUser | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useMention = ({ workspaceSlug, projectId, members, user }: Props) => {
|
|
||||||
const projectMembersRef = useRef<IUserLite[] | undefined>();
|
|
||||||
const userRef = useRef<IUser | undefined>();
|
|
||||||
|
|
||||||
const {
|
|
||||||
project: { fetchProjectMembers },
|
|
||||||
} = useMember();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (members) projectMembersRef.current = members;
|
|
||||||
else {
|
|
||||||
if (!workspaceSlug || !projectId) return;
|
|
||||||
fetchProjectMembers(workspaceSlug.toString(), projectId.toString());
|
|
||||||
}
|
|
||||||
}, [fetchProjectMembers, members, projectId, workspaceSlug]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (userRef) userRef.current = user;
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const waitForUserData = async () =>
|
|
||||||
new Promise<IUser>((resolve) => {
|
|
||||||
const checkData = () => {
|
|
||||||
if (userRef.current) resolve(userRef.current);
|
|
||||||
else setTimeout(checkData, 100);
|
|
||||||
};
|
|
||||||
checkData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const mentionHighlights = async () => {
|
|
||||||
if (user && userRef.current) {
|
|
||||||
return [userRef.current.id];
|
|
||||||
} else {
|
|
||||||
const userData = await waitForUserData();
|
|
||||||
return [userData.id];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Polling function to wait for projectMembersRef.current to be populated
|
|
||||||
const waitForData = async () =>
|
|
||||||
new Promise<IUserLite[]>((resolve) => {
|
|
||||||
const checkData = () => {
|
|
||||||
if (projectMembersRef.current && projectMembersRef.current.length > 0) {
|
|
||||||
resolve(projectMembersRef.current);
|
|
||||||
} else {
|
|
||||||
setTimeout(checkData, 100); // Check every 100ms
|
|
||||||
}
|
|
||||||
};
|
|
||||||
checkData();
|
|
||||||
});
|
|
||||||
|
|
||||||
const mentionSuggestions = async (): Promise<IMentionSuggestion[]> => {
|
|
||||||
if (members && projectMembersRef.current && projectMembersRef.current.length > 0) {
|
|
||||||
// If data is already available, return it immediately
|
|
||||||
return projectMembersRef.current.map((memberDetails) => ({
|
|
||||||
entity_name: "user_mention",
|
|
||||||
entity_identifier: `${memberDetails?.id}`,
|
|
||||||
id: `${memberDetails?.id}`,
|
|
||||||
type: "User",
|
|
||||||
title: `${memberDetails?.display_name}`,
|
|
||||||
subtitle: memberDetails?.email ?? "",
|
|
||||||
avatar: getFileURL(memberDetails?.avatar_url) ?? "",
|
|
||||||
redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.id}`,
|
|
||||||
}));
|
|
||||||
} else {
|
|
||||||
// Wait for data to be available
|
|
||||||
const membersList = await waitForData();
|
|
||||||
return membersList.map((memberDetails) => ({
|
|
||||||
entity_name: "user_mention",
|
|
||||||
entity_identifier: `${memberDetails?.id}`,
|
|
||||||
id: `${memberDetails?.id}`,
|
|
||||||
type: "User",
|
|
||||||
title: `${memberDetails?.display_name}`,
|
|
||||||
subtitle: memberDetails?.email ?? "",
|
|
||||||
avatar: getFileURL(memberDetails?.avatar_url) ?? "",
|
|
||||||
redirect_uri: `/${workspaceSlug}/profile/${memberDetails?.id}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentionSuggestions,
|
|
||||||
mentionHighlights,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
76
web/core/hooks/use-editor-mention.tsx
Normal file
76
web/core/hooks/use-editor-mention.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
// plane editor
|
||||||
|
import { TMentionSection, TMentionSuggestion } from "@plane/editor";
|
||||||
|
// plane types
|
||||||
|
import { TSearchEntities, TSearchEntityRequestPayload, TSearchResponse, TUserSearchResponse } from "@plane/types";
|
||||||
|
// plane ui
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { getFileURL } from "@/helpers/file.helper";
|
||||||
|
// plane web constants
|
||||||
|
import { EDITOR_MENTION_TYPES } from "@/plane-web/constants/editor";
|
||||||
|
// plane web hooks
|
||||||
|
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
|
||||||
|
|
||||||
|
type TArgs = {
|
||||||
|
searchEntity: (payload: TSearchEntityRequestPayload) => Promise<TSearchResponse>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditorMention = (args: TArgs) => {
|
||||||
|
const { searchEntity } = args;
|
||||||
|
// additional mentions
|
||||||
|
const { updateAdditionalSections } = useAdditionalEditorMention();
|
||||||
|
// fetch mentions handler
|
||||||
|
const fetchMentions = useCallback(
|
||||||
|
async (query: string): Promise<TMentionSection[]> => {
|
||||||
|
try {
|
||||||
|
const res = await searchEntity({
|
||||||
|
count: 5,
|
||||||
|
query_type: EDITOR_MENTION_TYPES,
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
const suggestionSections: TMentionSection[] = [];
|
||||||
|
if (!res) {
|
||||||
|
throw new Error("No response found");
|
||||||
|
}
|
||||||
|
Object.keys(res).map((key) => {
|
||||||
|
const responseKey = key as TSearchEntities;
|
||||||
|
const response = res[responseKey];
|
||||||
|
if (responseKey === "user_mention" && response && response.length > 0) {
|
||||||
|
const items: TMentionSuggestion[] = (response as TUserSearchResponse[]).map((user) => ({
|
||||||
|
icon: (
|
||||||
|
<Avatar
|
||||||
|
className="flex-shrink-0"
|
||||||
|
src={getFileURL(user.member__avatar_url)}
|
||||||
|
name={user.member__display_name}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
id: user.member__id,
|
||||||
|
entity_identifier: user.member__id,
|
||||||
|
entity_name: "user_mention",
|
||||||
|
title: user.member__display_name,
|
||||||
|
}));
|
||||||
|
suggestionSections.push({
|
||||||
|
key: "users",
|
||||||
|
title: "Users",
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateAdditionalSections({
|
||||||
|
response: res,
|
||||||
|
sections: suggestionSections,
|
||||||
|
});
|
||||||
|
return suggestionSections;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error in fetching mentions for project pages:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[searchEntity, updateAdditionalSections]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
fetchMentions,
|
||||||
|
};
|
||||||
|
};
|
||||||
215
web/core/hooks/use-parse-editor-content.ts
Normal file
215
web/core/hooks/use-parse-editor-content.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useParams } from "next/navigation";
|
||||||
|
// plane types
|
||||||
|
import { TSearchEntities } from "@plane/types";
|
||||||
|
// helpers
|
||||||
|
import { getBase64Image } from "@/helpers/file.helper";
|
||||||
|
// hooks
|
||||||
|
import { useMember } from "@/hooks/store";
|
||||||
|
// plane web hooks
|
||||||
|
import { useAdditionalEditorMention } from "@/plane-web/hooks/use-additional-editor-mention";
|
||||||
|
|
||||||
|
export const useParseEditorContent = () => {
|
||||||
|
// params
|
||||||
|
const { workspaceSlug } = useParams();
|
||||||
|
// store hooks
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
// parse additional content
|
||||||
|
const { parseAdditionalEditorContent } = useAdditionalEditorMention();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description function to replace all the custom components from the html component to make it pdf compatible
|
||||||
|
* @param props
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
const replaceCustomComponentsFromHTMLContent = useCallback(
|
||||||
|
async (props: { htmlContent: string; noAssets?: boolean }): Promise<string> => {
|
||||||
|
const { htmlContent, noAssets = false } = props;
|
||||||
|
// create a DOM parser
|
||||||
|
const parser = new DOMParser();
|
||||||
|
// parse the HTML string into a DOM document
|
||||||
|
const doc = parser.parseFromString(htmlContent, "text/html");
|
||||||
|
// replace all mention-component elements
|
||||||
|
const mentionComponents = doc.querySelectorAll("mention-component");
|
||||||
|
mentionComponents.forEach((component) => {
|
||||||
|
// create a span element to replace the mention-component
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("data-node-type", "mention-block");
|
||||||
|
// get the user id from the component
|
||||||
|
const id = component.getAttribute("entity_identifier") || "";
|
||||||
|
const entityType = (component.getAttribute("entity_name") || "user_mention") as TSearchEntities;
|
||||||
|
let textContent = "user";
|
||||||
|
if (entityType === "user_mention") {
|
||||||
|
const userDetails = getUserDetails(id);
|
||||||
|
textContent = userDetails?.display_name ?? "";
|
||||||
|
} else {
|
||||||
|
const mentionDetails = parseAdditionalEditorContent({
|
||||||
|
id,
|
||||||
|
entityType,
|
||||||
|
});
|
||||||
|
if (mentionDetails) {
|
||||||
|
textContent = mentionDetails.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span.textContent = `@${textContent}`;
|
||||||
|
// replace the mention-component with the span element
|
||||||
|
component.replaceWith(span);
|
||||||
|
});
|
||||||
|
// handle code inside pre elements
|
||||||
|
const preElements = doc.querySelectorAll("pre");
|
||||||
|
preElements.forEach((preElement) => {
|
||||||
|
const codeElement = preElement.querySelector("code");
|
||||||
|
if (codeElement) {
|
||||||
|
// create a div element with the required attributes for code blocks
|
||||||
|
const div = doc.createElement("div");
|
||||||
|
div.setAttribute("data-node-type", "code-block");
|
||||||
|
div.setAttribute("class", "courier");
|
||||||
|
// transfer the content from the code block
|
||||||
|
div.innerHTML = codeElement.innerHTML.replace(/\n/g, "<br>") || "";
|
||||||
|
// replace the pre element with the new div
|
||||||
|
preElement.replaceWith(div);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// handle inline code elements (not inside pre tags)
|
||||||
|
const inlineCodeElements = doc.querySelectorAll("code");
|
||||||
|
inlineCodeElements.forEach((codeElement) => {
|
||||||
|
// check if the code element is inside a pre element
|
||||||
|
if (!codeElement.closest("pre")) {
|
||||||
|
// create a span element with the required attributes for inline code blocks
|
||||||
|
const span = doc.createElement("span");
|
||||||
|
span.setAttribute("data-node-type", "inline-code-block");
|
||||||
|
span.setAttribute("class", "courier-bold");
|
||||||
|
// transfer the code content
|
||||||
|
span.textContent = codeElement.textContent || "";
|
||||||
|
// replace the standalone code element with the new span
|
||||||
|
codeElement.replaceWith(span);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// handle image-component elements
|
||||||
|
const imageComponents = doc.querySelectorAll("image-component");
|
||||||
|
if (noAssets) {
|
||||||
|
// if no assets is enabled, remove the image component elements
|
||||||
|
imageComponents.forEach((component) => component.remove());
|
||||||
|
// remove default img elements
|
||||||
|
const imageElements = doc.querySelectorAll("img");
|
||||||
|
imageElements.forEach((img) => img.remove());
|
||||||
|
} else {
|
||||||
|
// if no assets is not enabled, replace the image component elements with img elements
|
||||||
|
imageComponents.forEach((component) => {
|
||||||
|
// get the image src from the component
|
||||||
|
const src = component.getAttribute("src") ?? "";
|
||||||
|
const height = component.getAttribute("height") ?? "";
|
||||||
|
const width = component.getAttribute("width") ?? "";
|
||||||
|
// create an img element to replace the image-component
|
||||||
|
const img = doc.createElement("img");
|
||||||
|
img.src = src;
|
||||||
|
img.style.height = height;
|
||||||
|
img.style.width = width;
|
||||||
|
// replace the image-component with the img element
|
||||||
|
component.replaceWith(img);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// convert all images to base64
|
||||||
|
const imgElements = doc.querySelectorAll("img");
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(imgElements).map(async (img) => {
|
||||||
|
// get the image src from the img element
|
||||||
|
const src = img.getAttribute("src");
|
||||||
|
if (src) {
|
||||||
|
try {
|
||||||
|
const base64Image = await getBase64Image(src);
|
||||||
|
img.src = base64Image;
|
||||||
|
} catch (error) {
|
||||||
|
// log the error if the image conversion fails
|
||||||
|
console.error("Failed to convert image to base64:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// replace all checkbox elements
|
||||||
|
const checkboxComponents = doc.querySelectorAll("input[type='checkbox']");
|
||||||
|
checkboxComponents.forEach((component) => {
|
||||||
|
// get the checked status from the element
|
||||||
|
const checked = component.getAttribute("checked");
|
||||||
|
// create a div element to replace the input element
|
||||||
|
const div = doc.createElement("div");
|
||||||
|
div.classList.value = "input-checkbox";
|
||||||
|
// add the checked class if the checkbox is checked
|
||||||
|
if (checked === "checked" || checked === "true") div.classList.add("checked");
|
||||||
|
// replace the input element with the div element
|
||||||
|
component.replaceWith(div);
|
||||||
|
});
|
||||||
|
// remove all issue-embed-component elements
|
||||||
|
const issueEmbedComponents = doc.querySelectorAll("issue-embed-component");
|
||||||
|
issueEmbedComponents.forEach((component) => component.remove());
|
||||||
|
// serialize the document back into a string
|
||||||
|
let serializedDoc = doc.body.innerHTML;
|
||||||
|
// remove null colors from table elements
|
||||||
|
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
||||||
|
return serializedDoc;
|
||||||
|
},
|
||||||
|
[getUserDetails]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description function to replace all the custom components from the markdown content
|
||||||
|
* @param props
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const replaceCustomComponentsFromMarkdownContent = useCallback(
|
||||||
|
(props: { markdownContent: string; noAssets?: boolean }): string => {
|
||||||
|
const start = performance.now();
|
||||||
|
const { markdownContent, noAssets = false } = props;
|
||||||
|
let parsedMarkdownContent = markdownContent;
|
||||||
|
// replace the matched mention components with [display_name](redirect_url)
|
||||||
|
const mentionRegex =
|
||||||
|
/<mention-component[^>]*entity_identifier="([^"]+)"[^>]*entity_name="([^"]+)"[^>]*><\/mention-component>/g;
|
||||||
|
const originUrl = typeof window !== "undefined" && (window.location.origin ?? "");
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(mentionRegex, (_match, id, entity_type) => {
|
||||||
|
const entityType = entity_type as TSearchEntities;
|
||||||
|
if (!id || !entityType) return "";
|
||||||
|
if (entityType === "user_mention") {
|
||||||
|
const userDetails = getUserDetails(id);
|
||||||
|
if (!userDetails) return "";
|
||||||
|
return `[${userDetails.display_name}](${originUrl}/${workspaceSlug}/profile/${id})`;
|
||||||
|
} else {
|
||||||
|
const mentionDetails = parseAdditionalEditorContent({
|
||||||
|
id,
|
||||||
|
entityType,
|
||||||
|
});
|
||||||
|
if (!mentionDetails) {
|
||||||
|
return "";
|
||||||
|
} else {
|
||||||
|
const { redirectionPath, textContent } = mentionDetails;
|
||||||
|
return `[${textContent}](${originUrl}/${redirectionPath})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// replace the matched image components with <img src={src} >
|
||||||
|
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
|
||||||
|
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
|
||||||
|
if (noAssets) {
|
||||||
|
// remove all image components
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
|
||||||
|
} else {
|
||||||
|
// replace the matched image components with <img src={src} >
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(
|
||||||
|
imageComponentRegex,
|
||||||
|
(_match, src) => `<img src="${src}" >`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// remove all issue-embed components
|
||||||
|
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
||||||
|
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
||||||
|
const end = performance.now();
|
||||||
|
console.log("Exec time:", end - start);
|
||||||
|
return parsedMarkdownContent;
|
||||||
|
},
|
||||||
|
[getUserDetails, workspaceSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
replaceCustomComponentsFromHTMLContent,
|
||||||
|
replaceCustomComponentsFromMarkdownContent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import type { GithubRepositoriesResponse, ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types";
|
import type {
|
||||||
|
GithubRepositoriesResponse,
|
||||||
|
ISearchIssueResponse,
|
||||||
|
TProjectIssuesSearchParams,
|
||||||
|
TSearchEntityRequestPayload,
|
||||||
|
TSearchResponse,
|
||||||
|
} from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||||
// plane web types
|
// plane web types
|
||||||
|
|
@ -164,4 +170,21 @@ export class ProjectService extends APIService {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async searchEntity(
|
||||||
|
workspaceSlug: string,
|
||||||
|
projectId: string,
|
||||||
|
params: TSearchEntityRequestPayload
|
||||||
|
): Promise<TSearchResponse> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/entity-search/`, {
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
query_type: params.query_type.join(","),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
web/ee/components/editor/index.ts
Normal file
1
web/ee/components/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/components/editor";
|
||||||
1
web/ee/constants/editor.ts
Normal file
1
web/ee/constants/editor.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "ce/constants/editor";
|
||||||
1
web/ee/hooks/use-additional-editor-mention.tsx
Normal file
1
web/ee/hooks/use-additional-editor-mention.tsx
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from "@/plane-web/hooks/use-additional-editor-mention";
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
// plane editor
|
// plane editor
|
||||||
import { TFileHandler } from "@plane/editor";
|
import { TFileHandler } from "@plane/editor";
|
||||||
// helpers
|
// helpers
|
||||||
import { getBase64Image, getFileURL } from "@/helpers/file.helper";
|
import { getFileURL } from "@/helpers/file.helper";
|
||||||
// services
|
// services
|
||||||
import { FileService } from "@/services/file.service";
|
import { FileService } from "@/services/file.service";
|
||||||
const fileService = new FileService();
|
const fileService = new FileService();
|
||||||
|
|
@ -111,161 +111,6 @@ export const getReadOnlyEditorFileHandlers = (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* @description function to replace all the custom components from the html component to make it pdf compatible
|
|
||||||
* @param props
|
|
||||||
* @returns {Promise<string>}
|
|
||||||
*/
|
|
||||||
export const replaceCustomComponentsFromHTMLContent = async (props: {
|
|
||||||
htmlContent: string;
|
|
||||||
noAssets?: boolean;
|
|
||||||
}): Promise<string> => {
|
|
||||||
const { htmlContent, noAssets = false } = props;
|
|
||||||
// create a DOM parser
|
|
||||||
const parser = new DOMParser();
|
|
||||||
// parse the HTML string into a DOM document
|
|
||||||
const doc = parser.parseFromString(htmlContent, "text/html");
|
|
||||||
// replace all mention-component elements
|
|
||||||
const mentionComponents = doc.querySelectorAll("mention-component");
|
|
||||||
mentionComponents.forEach((component) => {
|
|
||||||
// get the user label from the component (or use any other attribute)
|
|
||||||
const label = component.getAttribute("label") || "user";
|
|
||||||
// create a span element to replace the mention-component
|
|
||||||
const span = doc.createElement("span");
|
|
||||||
span.setAttribute("data-node-type", "mention-block");
|
|
||||||
span.textContent = `@${label}`;
|
|
||||||
// replace the mention-component with the anchor element
|
|
||||||
component.replaceWith(span);
|
|
||||||
});
|
|
||||||
// handle code inside pre elements
|
|
||||||
const preElements = doc.querySelectorAll("pre");
|
|
||||||
preElements.forEach((preElement) => {
|
|
||||||
const codeElement = preElement.querySelector("code");
|
|
||||||
if (codeElement) {
|
|
||||||
// create a div element with the required attributes for code blocks
|
|
||||||
const div = doc.createElement("div");
|
|
||||||
div.setAttribute("data-node-type", "code-block");
|
|
||||||
div.setAttribute("class", "courier");
|
|
||||||
// transfer the content from the code block
|
|
||||||
div.innerHTML = codeElement.innerHTML.replace(/\n/g, "<br>") || "";
|
|
||||||
// replace the pre element with the new div
|
|
||||||
preElement.replaceWith(div);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// handle inline code elements (not inside pre tags)
|
|
||||||
const inlineCodeElements = doc.querySelectorAll("code");
|
|
||||||
inlineCodeElements.forEach((codeElement) => {
|
|
||||||
// check if the code element is inside a pre element
|
|
||||||
if (!codeElement.closest("pre")) {
|
|
||||||
// create a span element with the required attributes for inline code blocks
|
|
||||||
const span = doc.createElement("span");
|
|
||||||
span.setAttribute("data-node-type", "inline-code-block");
|
|
||||||
span.setAttribute("class", "courier-bold");
|
|
||||||
// transfer the code content
|
|
||||||
span.textContent = codeElement.textContent || "";
|
|
||||||
// replace the standalone code element with the new span
|
|
||||||
codeElement.replaceWith(span);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// handle image-component elements
|
|
||||||
const imageComponents = doc.querySelectorAll("image-component");
|
|
||||||
if (noAssets) {
|
|
||||||
// if no assets is enabled, remove the image component elements
|
|
||||||
imageComponents.forEach((component) => component.remove());
|
|
||||||
// remove default img elements
|
|
||||||
const imageElements = doc.querySelectorAll("img");
|
|
||||||
imageElements.forEach((img) => img.remove());
|
|
||||||
} else {
|
|
||||||
// if no assets is not enabled, replace the image component elements with img elements
|
|
||||||
imageComponents.forEach((component) => {
|
|
||||||
// get the image src from the component
|
|
||||||
const src = component.getAttribute("src") ?? "";
|
|
||||||
const height = component.getAttribute("height") ?? "";
|
|
||||||
const width = component.getAttribute("width") ?? "";
|
|
||||||
// create an img element to replace the image-component
|
|
||||||
const img = doc.createElement("img");
|
|
||||||
img.src = src;
|
|
||||||
img.style.height = height;
|
|
||||||
img.style.width = width;
|
|
||||||
// replace the image-component with the img element
|
|
||||||
component.replaceWith(img);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// convert all images to base64
|
|
||||||
const imgElements = doc.querySelectorAll("img");
|
|
||||||
await Promise.all(
|
|
||||||
Array.from(imgElements).map(async (img) => {
|
|
||||||
// get the image src from the img element
|
|
||||||
const src = img.getAttribute("src");
|
|
||||||
if (src) {
|
|
||||||
try {
|
|
||||||
const base64Image = await getBase64Image(src);
|
|
||||||
img.src = base64Image;
|
|
||||||
} catch (error) {
|
|
||||||
// log the error if the image conversion fails
|
|
||||||
console.error("Failed to convert image to base64:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
// replace all checkbox elements
|
|
||||||
const checkboxComponents = doc.querySelectorAll("input[type='checkbox']");
|
|
||||||
checkboxComponents.forEach((component) => {
|
|
||||||
// get the checked status from the element
|
|
||||||
const checked = component.getAttribute("checked");
|
|
||||||
// create a div element to replace the input element
|
|
||||||
const div = doc.createElement("div");
|
|
||||||
div.classList.value = "input-checkbox";
|
|
||||||
// add the checked class if the checkbox is checked
|
|
||||||
if (checked === "checked" || checked === "true") div.classList.add("checked");
|
|
||||||
// replace the input element with the div element
|
|
||||||
component.replaceWith(div);
|
|
||||||
});
|
|
||||||
// remove all issue-embed-component elements
|
|
||||||
const issueEmbedComponents = doc.querySelectorAll("issue-embed-component");
|
|
||||||
issueEmbedComponents.forEach((component) => component.remove());
|
|
||||||
// serialize the document back into a string
|
|
||||||
let serializedDoc = doc.body.innerHTML;
|
|
||||||
// remove null colors from table elements
|
|
||||||
serializedDoc = serializedDoc.replace(/background-color: null/g, "").replace(/color: null/g, "");
|
|
||||||
return serializedDoc;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description function to replace all the custom components from the markdown content
|
|
||||||
* @param props
|
|
||||||
* @returns {string}
|
|
||||||
*/
|
|
||||||
export const replaceCustomComponentsFromMarkdownContent = (props: {
|
|
||||||
markdownContent: string;
|
|
||||||
noAssets?: boolean;
|
|
||||||
}): string => {
|
|
||||||
const { markdownContent, noAssets = false } = props;
|
|
||||||
let parsedMarkdownContent = markdownContent;
|
|
||||||
// replace the matched mention components with [label](redirect_uri)
|
|
||||||
const mentionRegex = /<mention-component[^>]*label="([^"]+)"[^>]*redirect_uri="([^"]+)"[^>]*><\/mention-component>/g;
|
|
||||||
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
|
||||||
|
|
||||||
parsedMarkdownContent = parsedMarkdownContent.replace(
|
|
||||||
mentionRegex,
|
|
||||||
(_match, label, redirectUri) => `[${label}](${originUrl}/${redirectUri})`
|
|
||||||
);
|
|
||||||
// replace the matched image components with <img src={src} >
|
|
||||||
const imageComponentRegex = /<image-component[^>]*src="([^"]+)"[^>]*>[^]*<\/image-component>/g;
|
|
||||||
const imgTagRegex = /<img[^>]*src="([^"]+)"[^>]*\/?>/g;
|
|
||||||
if (noAssets) {
|
|
||||||
// remove all image components
|
|
||||||
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, "").replace(imgTagRegex, "");
|
|
||||||
} else {
|
|
||||||
// replace the matched image components with <img src={src} >
|
|
||||||
parsedMarkdownContent = parsedMarkdownContent.replace(imageComponentRegex, (_match, src) => `<img src="${src}" >`);
|
|
||||||
}
|
|
||||||
// remove all issue-embed components
|
|
||||||
const issueEmbedRegex = /<issue-embed-component[^>]*>[^]*<\/issue-embed-component>/g;
|
|
||||||
parsedMarkdownContent = parsedMarkdownContent.replace(issueEmbedRegex, "");
|
|
||||||
return parsedMarkdownContent;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => {
|
export const getTextContent = (jsx: JSX.Element | React.ReactNode | null | undefined): string => {
|
||||||
if (!jsx) return "";
|
if (!jsx) return "";
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue