Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 33a5d0a5d3 | |||
| 8a866e440c | |||
| 7495a7d0cb | |||
| 2b1da96c3f | |||
| daa06f1831 | |||
| b97fcfb46d | |||
| 852fc9bac1 | |||
| 55f44e0245 | |||
| 8981e52dcc | |||
| d92dbaea72 | |||
| 58f3d0a68c | |||
| 6d78418e79 | |||
| 6e52f1b434 | |||
| c3c1ea727d | |||
| 5afc576dec | |||
| d97ca68229 | |||
| 707570ca7a | |||
| c76af7d7d6 | |||
| 1dcea9bcc8 | |||
| da957e06b6 | |||
| a0b9596cb4 | |||
| f71e8a3a0f | |||
| 002fb4547b | |||
| c1b1ba35c1 | |||
| 4566d6e80c | |||
| e8d359e625 | |||
| 351eba8d61 | |||
| 1e27e37b51 | |||
| 7df2e9cf11 | |||
| c6e3f1b932 |
@@ -207,8 +207,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
# Incomplete Cycles
|
||||
if cycle_view == "incomplete":
|
||||
queryset = queryset.filter(
|
||||
Q(end_date__gte=timezone.now())
|
||||
| Q(end_date__isnull=True),
|
||||
Q(end_date__gte=timezone.now()) | Q(end_date__isnull=True),
|
||||
)
|
||||
return self.paginate(
|
||||
request=request,
|
||||
@@ -309,10 +308,7 @@ class CycleAPIEndpoint(BaseAPIView):
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now()
|
||||
):
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order
|
||||
request_data = {
|
||||
|
||||
@@ -285,7 +285,7 @@ class InboxIssueAPIEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
# Only project admins and members can edit inbox issue attributes
|
||||
if project_member.role > 5:
|
||||
if project_member.role > 15:
|
||||
serializer = InboxIssueSerializer(
|
||||
inbox_issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
@@ -49,7 +49,6 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"created_at",
|
||||
@@ -83,11 +82,13 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
modules = self.initial_data.get("module_ids", None)
|
||||
|
||||
workspace_id = self.context["workspace_id"]
|
||||
project_id = self.context["project_id"]
|
||||
|
||||
# Create Issue
|
||||
issue = DraftIssue.objects.create(
|
||||
**validated_data,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
)
|
||||
|
||||
# Issue Audit Users
|
||||
@@ -99,8 +100,9 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
[
|
||||
DraftIssueAssignee(
|
||||
assignee=user,
|
||||
issue=issue,
|
||||
draft_issue=issue,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
@@ -114,7 +116,8 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
[
|
||||
DraftIssueLabel(
|
||||
label=label,
|
||||
issue=issue,
|
||||
draft_issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
@@ -128,6 +131,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
DraftIssueCycle.objects.create(
|
||||
cycle_id=cycle_id,
|
||||
draft_issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
@@ -139,6 +143,7 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
DraftIssueModule(
|
||||
module_id=module_id,
|
||||
draft_issue=issue,
|
||||
project_id=project_id,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
@@ -153,22 +158,25 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
def update(self, instance, validated_data):
|
||||
assignees = validated_data.pop("assignee_ids", None)
|
||||
labels = validated_data.pop("label_ids", None)
|
||||
cycle_id = self.initial_data.get("cycle_id", None)
|
||||
cycle_id = self.context.get("cycle_id", None)
|
||||
modules = self.initial_data.get("module_ids", None)
|
||||
|
||||
# Related models
|
||||
workspace_id = instance.workspace_id
|
||||
project_id = instance.project_id
|
||||
|
||||
created_by_id = instance.created_by_id
|
||||
updated_by_id = instance.updated_by_id
|
||||
|
||||
if assignees is not None:
|
||||
DraftIssueAssignee.objects.filter(issue=instance).delete()
|
||||
DraftIssueAssignee.objects.filter(draft_issue=instance).delete()
|
||||
DraftIssueAssignee.objects.bulk_create(
|
||||
[
|
||||
DraftIssueAssignee(
|
||||
assignee=user,
|
||||
issue=instance,
|
||||
draft_issue=instance,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
@@ -178,13 +186,14 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
)
|
||||
|
||||
if labels is not None:
|
||||
DraftIssueLabel.objects.filter(issue=instance).delete()
|
||||
DraftIssueLabel.objects.filter(draft_issue=instance).delete()
|
||||
DraftIssueLabel.objects.bulk_create(
|
||||
[
|
||||
DraftIssueLabel(
|
||||
label=label,
|
||||
issue=instance,
|
||||
draft_issue=instance,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
@@ -193,28 +202,31 @@ class DraftIssueCreateSerializer(BaseSerializer):
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
if cycle_id is not None:
|
||||
if cycle_id != "not_provided":
|
||||
DraftIssueCycle.objects.filter(draft_issue=instance).delete()
|
||||
DraftIssueCycle.objects.create(
|
||||
cycle_id=cycle_id,
|
||||
draft_issue=instance,
|
||||
workspace_id=workspace_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
if cycle_id is not None:
|
||||
DraftIssueCycle.objects.create(
|
||||
cycle_id=cycle_id,
|
||||
draft_issue=instance,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
|
||||
if modules is not None:
|
||||
DraftIssueModule.objects.filter(draft_issue=instance).delete()
|
||||
DraftIssueModule.objects.bulk_create(
|
||||
[
|
||||
DraftIssueModule(
|
||||
module=module,
|
||||
module_id=module_id,
|
||||
draft_issue=instance,
|
||||
workspace_id=workspace_id,
|
||||
project_id=project_id,
|
||||
created_by_id=created_by_id,
|
||||
updated_by_id=updated_by_id,
|
||||
)
|
||||
for module in modules
|
||||
for module_id in modules
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
@@ -276,4 +276,9 @@ urlpatterns = [
|
||||
),
|
||||
name="workspace-drafts-issues",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/draft-to-issue/<uuid:draft_id>/",
|
||||
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),
|
||||
name="workspace-drafts-issues",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -187,6 +187,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
"created_by",
|
||||
)
|
||||
|
||||
@@ -216,6 +217,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"completed_issues",
|
||||
"assignee_ids",
|
||||
"status",
|
||||
"version",
|
||||
"created_by",
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
@@ -255,6 +257,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"logo_props",
|
||||
"version",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
@@ -306,10 +309,7 @@ class CycleViewSet(BaseViewSet):
|
||||
|
||||
request_data = request.data
|
||||
|
||||
if (
|
||||
cycle.end_date is not None
|
||||
and cycle.end_date < timezone.now()
|
||||
):
|
||||
if cycle.end_date is not None and cycle.end_date < timezone.now():
|
||||
if "sort_order" in request_data:
|
||||
# Can only change sort order for a completed cycle``
|
||||
request_data = {
|
||||
@@ -347,6 +347,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
"logo_props",
|
||||
"version",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
@@ -412,6 +413,7 @@ class CycleViewSet(BaseViewSet):
|
||||
"progress_snapshot",
|
||||
"sub_issues",
|
||||
"logo_props",
|
||||
"version",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
@@ -1148,6 +1150,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class CycleAnalyticsEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
|
||||
@@ -323,7 +323,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], creator=True, model=Issue)
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
inbox_id = Inbox.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id
|
||||
@@ -418,7 +418,7 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# Only project admins and members can edit inbox issue attributes
|
||||
if project_member.role > 5:
|
||||
if project_member.role > 15:
|
||||
serializer = InboxIssueSerializer(
|
||||
inbox_issue, data=request.data, partial=True
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core import serializers
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.db.models import (
|
||||
@@ -26,9 +32,15 @@ from plane.app.serializers import (
|
||||
from plane.db.models import (
|
||||
Issue,
|
||||
DraftIssue,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
DraftIssueModule,
|
||||
DraftIssueCycle,
|
||||
Workspace,
|
||||
)
|
||||
from .. import BaseViewSet
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
|
||||
|
||||
class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
@@ -40,6 +52,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
def list(self, request, slug):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
issues = (
|
||||
DraftIssue.objects.filter(workspace__slug=slug)
|
||||
.filter(created_by=request.user)
|
||||
@@ -81,8 +94,16 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
.order_by("-created_at")
|
||||
)
|
||||
|
||||
serializer = DraftIssueSerializer(issues, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
issues = issues.filter(**filters)
|
||||
# List Paginate
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(issues),
|
||||
on_results=lambda issues: DraftIssueSerializer(
|
||||
issues,
|
||||
many=True,
|
||||
).data,
|
||||
)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
@@ -94,6 +115,7 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
data=request.data,
|
||||
context={
|
||||
"workspace_id": workspace.id,
|
||||
"project_id": request.data.get("project_id", None),
|
||||
},
|
||||
)
|
||||
if serializer.is_valid():
|
||||
@@ -156,8 +178,14 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
serializer = IssueCreateSerializer(
|
||||
issue, data=request.data, partial=True
|
||||
serializer = DraftIssueCreateSerializer(
|
||||
issue,
|
||||
data=request.data,
|
||||
partial=True,
|
||||
context={
|
||||
"project_id": request.data.get("project_id", None),
|
||||
"cycle_id": request.data.get("cycle_id", "not_provided"),
|
||||
},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
@@ -234,3 +262,151 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
draft_issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk)
|
||||
draft_issue.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
|
||||
level="WORKSPACE",
|
||||
)
|
||||
def create_draft_to_issue(self, request, slug, draft_id):
|
||||
draft_issue = (
|
||||
DraftIssue.objects.filter(workspace__slug=slug, pk=draft_id)
|
||||
.annotate(cycle_id=F("draft_issue_cycle__cycle_id"))
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"labels__id",
|
||||
distinct=True,
|
||||
filter=~Q(labels__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(assignees__id__isnull=True)
|
||||
& Q(assignees__member_project__is_active=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
module_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"draft_issue_module__module_id",
|
||||
distinct=True,
|
||||
filter=~Q(draft_issue_module__module_id__isnull=True)
|
||||
& Q(
|
||||
draft_issue_module__module__archived_at__isnull=True
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.select_related("project", "workspace")
|
||||
.first()
|
||||
)
|
||||
|
||||
if not draft_issue.project_id:
|
||||
return Response(
|
||||
{"error": "Project is required to create an issue."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = IssueCreateSerializer(
|
||||
data=request.data,
|
||||
context={
|
||||
"project_id": draft_issue.project_id,
|
||||
"workspace_id": draft_issue.project.workspace_id,
|
||||
"default_assignee_id": draft_issue.project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(
|
||||
self.request.data, cls=DjangoJSONEncoder
|
||||
),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(serializer.data.get("id", None)),
|
||||
project_id=str(draft_issue.project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
if draft_issue.cycle_id:
|
||||
created_records = CycleIssue.objects.create(
|
||||
cycle_id=draft_issue.cycle_id,
|
||||
issue_id=serializer.data.get("id", None),
|
||||
project_id=draft_issue.project_id,
|
||||
workspace_id=draft_issue.workspace_id,
|
||||
created_by_id=draft_issue.created_by_id,
|
||||
updated_by_id=draft_issue.updated_by_id,
|
||||
)
|
||||
# Capture Issue Activity
|
||||
issue_activity.delay(
|
||||
type="cycle.activity.created",
|
||||
requested_data=None,
|
||||
actor_id=str(self.request.user.id),
|
||||
issue_id=None,
|
||||
project_id=str(self.kwargs.get("project_id", None)),
|
||||
current_instance=json.dumps(
|
||||
{
|
||||
"updated_cycle_issues": None,
|
||||
"created_cycle_issues": serializers.serialize(
|
||||
"json", created_records
|
||||
),
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
|
||||
if draft_issue.module_ids:
|
||||
# bulk create the module
|
||||
ModuleIssue.objects.bulk_create(
|
||||
[
|
||||
ModuleIssue(
|
||||
module_id=module,
|
||||
issue_id=serializer.data.get("id", None),
|
||||
workspace_id=draft_issue.workspace_id,
|
||||
project_id=draft_issue.project_id,
|
||||
created_by_id=draft_issue.created_by_id,
|
||||
updated_by_id=draft_issue.updated_by_id,
|
||||
)
|
||||
for module in draft_issue.module_ids
|
||||
],
|
||||
batch_size=10,
|
||||
)
|
||||
# Bulk Update the activity
|
||||
_ = [
|
||||
issue_activity.delay(
|
||||
type="module.activity.created",
|
||||
requested_data=json.dumps({"module_id": str(module)}),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=serializer.data.get("id", None),
|
||||
project_id=draft_issue.project_id,
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
origin=request.META.get("HTTP_ORIGIN"),
|
||||
)
|
||||
for module in draft_issue.module_ids
|
||||
]
|
||||
|
||||
# delete the draft issue
|
||||
draft_issue.delete()
|
||||
|
||||
# delete the draft issue module
|
||||
DraftIssueModule.objects.filter(draft_issue=draft_issue).delete()
|
||||
|
||||
# delete the draft issue cycle
|
||||
DraftIssueCycle.objects.filter(draft_issue=draft_issue).delete()
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.15
|
||||
Django==4.2.16
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -42,13 +42,15 @@
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@tiptap/extension-character-count": "^2.6.5",
|
||||
"@tiptap/extension-collaboration": "^2.3.2",
|
||||
"@tiptap/extension-color": "^2.7.1",
|
||||
"@tiptap/extension-highlight": "^2.7.1",
|
||||
"@tiptap/extension-image": "^2.1.13",
|
||||
"@tiptap/extension-list-item": "^2.1.13",
|
||||
"@tiptap/extension-mention": "^2.1.13",
|
||||
"@tiptap/extension-placeholder": "^2.3.0",
|
||||
"@tiptap/extension-task-item": "^2.1.13",
|
||||
"@tiptap/extension-task-list": "^2.1.13",
|
||||
"@tiptap/extension-text-style": "^2.1.13",
|
||||
"@tiptap/extension-text-style": "^2.7.1",
|
||||
"@tiptap/extension-underline": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { SlashCommand } from "@/extensions";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TIssueEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
@@ -14,7 +14,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const extensions: Extensions = [SlashCommand()];
|
||||
const extensions: Extensions = [SlashCommands()];
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { forwardRef, useCallback } from "react";
|
||||
import { EditorWrapper } from "@/components/editors";
|
||||
import { EditorBubbleMenu } from "@/components/menus";
|
||||
// extensions
|
||||
import { SideMenuExtension, SlashCommand } from "@/extensions";
|
||||
import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
@@ -11,7 +11,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { dragDropEnabled } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [SlashCommand()];
|
||||
const extensions = [SlashCommands()];
|
||||
|
||||
extensions.push(
|
||||
SideMenuExtension({
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ALargeSmall, Ban } from "lucide-react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
import { BackgroundColorItem, TextColorItem } from "../menu-items";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuColorSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
|
||||
const activeTextColor = COLORS_LIST.find((c) => editor.getAttributes("textStyle").color === c.textColor);
|
||||
const activeBackgroundColor = COLORS_LIST.find((c) =>
|
||||
editor.isActive("highlight", {
|
||||
color: c.backgroundColor,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
|
||||
>
|
||||
<span>Color</span>
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||
{
|
||||
"bg-custom-background-100": !activeBackgroundColor,
|
||||
}
|
||||
)}
|
||||
style={
|
||||
activeBackgroundColor
|
||||
? {
|
||||
backgroundColor: activeBackgroundColor.backgroundColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ALargeSmall
|
||||
className={cn("size-3.5", {
|
||||
"text-custom-text-100": !activeTextColor,
|
||||
})}
|
||||
style={
|
||||
activeTextColor
|
||||
? {
|
||||
color: activeTextColor.textColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<section className="fixed top-full z-[99999] mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 space-y-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.textColor}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.textColor,
|
||||
}}
|
||||
onClick={() => TextColorItem(editor).command(color.textColor)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => TextColorItem(editor).command(undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.backgroundColor}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
onClick={() => BackgroundColorItem(editor).command(color.backgroundColor)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => BackgroundColorItem(editor).command(undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./color-selector";
|
||||
export * from "./link-selector";
|
||||
export * from "./node-selector";
|
||||
export * from "./root";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Trash } from "lucide-react";
|
||||
import { Check, Link, Trash } from "lucide-react";
|
||||
// helpers
|
||||
import { cn, isValidHttpUrl } from "@/helpers/common";
|
||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||
@@ -11,7 +11,9 @@ type Props = {
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
// refs
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onLinkSubmit = useCallback(() => {
|
||||
@@ -28,26 +30,23 @@ export const BubbleMenuLinkSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative h-full">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-full items-center space-x-2 px-3 py-1.5 text-sm font-medium text-custom-text-300 hover:bg-custom-background-100 active:bg-custom-background-100",
|
||||
{ "bg-custom-background-100": isOpen }
|
||||
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
|
||||
{
|
||||
"bg-custom-background-80": isOpen,
|
||||
"text-custom-text-100": editor.isActive("link"),
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<p className="text-base">↗</p>
|
||||
<p
|
||||
className={cn("underline underline-offset-4", {
|
||||
"text-custom-text-100": editor.isActive("link"),
|
||||
})}
|
||||
>
|
||||
Link
|
||||
</p>
|
||||
<span>Link</span>
|
||||
<Link className="flex-shrink-0 size-3" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
HeadingFourItem,
|
||||
HeadingFiveItem,
|
||||
HeadingSixItem,
|
||||
BubbleMenuItem,
|
||||
EditorMenuItem,
|
||||
} from "@/components/menus";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
@@ -26,8 +26,10 @@ type Props = {
|
||||
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen }) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
||||
const { editor, isOpen, setIsOpen } = props;
|
||||
|
||||
const items: EditorMenuItem[] = [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
@@ -42,7 +44,7 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
CodeItem(editor),
|
||||
];
|
||||
|
||||
const activeItem = items.filter((item) => item.isActive()).pop() ?? {
|
||||
const activeItem = items.filter((item) => item.isActive("")).pop() ?? {
|
||||
name: "Multiple",
|
||||
};
|
||||
|
||||
@@ -54,12 +56,11 @@ export const BubbleMenuNodeSelector: FC<Props> = ({ editor, isOpen, setIsOpen })
|
||||
setIsOpen(!isOpen);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className="flex h-full items-center gap-1 whitespace-nowrap p-2 text-sm font-medium text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5"
|
||||
className="flex items-center gap-1 h-full whitespace-nowrap px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors"
|
||||
>
|
||||
<span>{activeItem?.name}</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
<ChevronDown className="flex-shrink-0 size-3" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<section className="fixed top-full z-[99999] mt-1 flex w-48 flex-col overflow-hidden rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
|
||||
{items.map((item) => (
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { BubbleMenu, BubbleMenuProps, isNodeSelection } from "@tiptap/react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
// components
|
||||
import {
|
||||
BoldItem,
|
||||
BubbleMenuColorSelector,
|
||||
BubbleMenuLinkSelector,
|
||||
BubbleMenuNodeSelector,
|
||||
CodeItem,
|
||||
EditorMenuItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
UnderLineItem,
|
||||
@@ -16,34 +17,23 @@ import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
key: string;
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
...(props.editor.isActive("code")
|
||||
? []
|
||||
: [
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
]),
|
||||
CodeItem(props.editor),
|
||||
];
|
||||
// states
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
|
||||
const items: EditorMenuItem[] = props.editor.isActive("code")
|
||||
? [CodeItem(props.editor)]
|
||||
: [BoldItem(props.editor), ItalicItem(props.editor), UnderLineItem(props.editor), StrikeThroughItem(props.editor)];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
shouldShow: ({ state, editor }) => {
|
||||
const { selection } = state;
|
||||
|
||||
const { empty } = selection;
|
||||
|
||||
if (
|
||||
@@ -63,15 +53,11 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
onHidden: () => {
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const [isNodeSelectorOpen, setIsNodeSelectorOpen] = useState(false);
|
||||
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
|
||||
|
||||
const [isSelecting, setIsSelecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown() {
|
||||
function handleMouseMove() {
|
||||
@@ -102,51 +88,66 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
return (
|
||||
<BubbleMenu
|
||||
{...bubbleMenuProps}
|
||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||
className="flex py-2 divide-x divide-custom-border-200 rounded-lg border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-rg"
|
||||
>
|
||||
{isSelecting ? null : (
|
||||
{!isSelecting && (
|
||||
<>
|
||||
{!props.editor.isActive("table") && (
|
||||
<BubbleMenuNodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen(!isNodeSelectorOpen);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!props.editor.isActive("code") && (
|
||||
<BubbleMenuLinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen(!isLinkSelectorOpen);
|
||||
setIsNodeSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex">
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("table") && (
|
||||
<BubbleMenuNodeSelector
|
||||
editor={props.editor!}
|
||||
isOpen={isNodeSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsNodeSelectorOpen((prev) => !prev);
|
||||
setIsLinkSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("code") && (
|
||||
<BubbleMenuLinkSelector
|
||||
editor={props.editor}
|
||||
isOpen={isLinkSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsLinkSelectorOpen((prev) => !prev);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsColorSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-2">
|
||||
{!props.editor.isActive("code") && (
|
||||
<BubbleMenuColorSelector
|
||||
editor={props.editor}
|
||||
isOpen={isColorSelectorOpen}
|
||||
setIsOpen={() => {
|
||||
setIsColorSelectorOpen((prev) => !prev);
|
||||
setIsNodeSelectorOpen(false);
|
||||
setIsLinkSelectorOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-0.5 px-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
item.command();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 transition-colors hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5",
|
||||
"size-7 grid place-items-center rounded text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 transition-colors",
|
||||
{
|
||||
"bg-custom-primary-100/5 text-custom-text-100": item.isActive(),
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(""),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
<item.icon className="size-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,14 @@ import {
|
||||
Heading6,
|
||||
CaseSensitive,
|
||||
LucideIcon,
|
||||
Palette,
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import {
|
||||
insertImage,
|
||||
insertTableCommand,
|
||||
setText,
|
||||
toggleBackgroundColor,
|
||||
toggleBlockquote,
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
@@ -40,18 +42,26 @@ import {
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
toggleTaskList,
|
||||
toggleTextColor,
|
||||
toggleUnderline,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
import { TColorEditorCommands, TNonColorEditorCommands } from "@/types";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
key: TEditorCommands;
|
||||
export type EditorMenuItem = {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
command: (...args: any) => void;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
} & (
|
||||
| {
|
||||
key: TNonColorEditorCommands;
|
||||
isActive: () => boolean;
|
||||
}
|
||||
| {
|
||||
key: TColorEditorCommands;
|
||||
isActive: (color: string | undefined) => boolean;
|
||||
}
|
||||
);
|
||||
|
||||
export const TextItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "text",
|
||||
@@ -198,10 +208,25 @@ export const ImageItem = (editor: Editor) =>
|
||||
icon: ImageIcon,
|
||||
}) as const;
|
||||
|
||||
export function getEditorMenuItems(editor: Editor | null) {
|
||||
if (!editor) {
|
||||
return [];
|
||||
}
|
||||
export const TextColorItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "text-color",
|
||||
name: "Color",
|
||||
isActive: (color) => editor.getAttributes("textStyle").color === color,
|
||||
command: (color: string) => toggleTextColor(color, editor),
|
||||
icon: Palette,
|
||||
});
|
||||
|
||||
export const BackgroundColorItem = (editor: Editor): EditorMenuItem => ({
|
||||
key: "background-color",
|
||||
name: "Background color",
|
||||
isActive: (color) => editor.isActive("highlight", { color }),
|
||||
command: (color: string) => toggleBackgroundColor(color, editor),
|
||||
icon: Palette,
|
||||
});
|
||||
|
||||
export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem[] => {
|
||||
if (!editor) return [];
|
||||
|
||||
return [
|
||||
TextItem(editor),
|
||||
HeadingOneItem(editor),
|
||||
@@ -221,5 +246,7 @@ export function getEditorMenuItems(editor: Editor | null) {
|
||||
QuoteItem(editor),
|
||||
TableItem(editor),
|
||||
ImageItem(editor),
|
||||
TextColorItem(editor),
|
||||
BackgroundColorItem(editor),
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
export const COLORS_LIST: {
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
label: string;
|
||||
}[] = [
|
||||
// {
|
||||
// backgroundColor: "#1c202426",
|
||||
// textColor: "#1c2024",
|
||||
// label: "Black",
|
||||
// },
|
||||
{
|
||||
backgroundColor: "#5c5e6326",
|
||||
textColor: "#5c5e63",
|
||||
label: "Gray",
|
||||
},
|
||||
{
|
||||
backgroundColor: "#ff5b5926",
|
||||
textColor: "#ff5b59",
|
||||
label: "Peach",
|
||||
},
|
||||
{
|
||||
backgroundColor: "#f6538526",
|
||||
textColor: "#f65385",
|
||||
label: "Pink",
|
||||
},
|
||||
{
|
||||
backgroundColor: "#fd903826",
|
||||
textColor: "#fd9038",
|
||||
label: "Orange",
|
||||
},
|
||||
{
|
||||
backgroundColor: "#0fc27b26",
|
||||
textColor: "#0fc27b",
|
||||
label: "Green",
|
||||
},
|
||||
{
|
||||
backgroundColor: "#17bee926",
|
||||
textColor: "#17bee9",
|
||||
label: "Light blue",
|
||||
},
|
||||
{
|
||||
backgroundColor: "#266df026",
|
||||
textColor: "#266df0",
|
||||
label: "Dark blue",
|
||||
},
|
||||
{
|
||||
backgroundColor: "#9162f926",
|
||||
textColor: "#9162f9",
|
||||
label: "Purple",
|
||||
},
|
||||
];
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
@@ -83,6 +85,10 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMentionWithoutProps(),
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
@@ -166,4 +168,8 @@ export const CoreEditorExtensions = ({
|
||||
includeChildren: true,
|
||||
}),
|
||||
CharacterCount,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
export * from "./issue-embed";
|
||||
export * from "./mentions";
|
||||
export * from "./slash-commands";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./core-without-props";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
@@ -109,5 +111,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
readonly: true,
|
||||
}),
|
||||
CharacterCount,
|
||||
Color,
|
||||
Highlight.configure({
|
||||
multicolor: true,
|
||||
}),
|
||||
HeadingListExtension,
|
||||
];
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useState, useEffect, useCallback, ReactNode, useRef, useLayoutEffect } from "react";
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import {
|
||||
CaseSensitive,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
MinusSquare,
|
||||
Quote,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
import {
|
||||
insertTableCommand,
|
||||
toggleBlockquote,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
insertImage,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||
|
||||
interface CommandItemProps {
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
export type SlashCommandOptions = {
|
||||
suggestion: Omit<SuggestionOptions, "editor">;
|
||||
};
|
||||
|
||||
const Command = Extension.create<SlashCommandOptions>({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
allow({ editor }: { editor: Editor }) {
|
||||
const { selection } = editor.state;
|
||||
|
||||
const parentNode = selection.$from.node(selection.$from.depth);
|
||||
const blockType = parentNode.type.name;
|
||||
|
||||
if (blockType === "codeBlock") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor.isActive("table")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const getSuggestionItems =
|
||||
(additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
({ query }: { query: string }) => {
|
||||
let slashCommands: ISlashCommandItem[] = [
|
||||
{
|
||||
key: "text",
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <CaseSensitive className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
}
|
||||
editor.chain().focus().clearNodes().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h1",
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingOne(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h2",
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingTwo(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h3",
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingThree(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h4",
|
||||
title: "Heading 4",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading4 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingFour(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h5",
|
||||
title: "Heading 5",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading5 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingFive(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "h6",
|
||||
title: "Heading 6",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading6 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleHeadingSix(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "to-do-list",
|
||||
title: "To do",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <ListTodo className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleTaskList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "bulleted-list",
|
||||
title: "Bullet list",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleBulletList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "numbered-list",
|
||||
title: "Numbered list",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
toggleOrderedList(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "table",
|
||||
title: "Table",
|
||||
description: "Create a table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertTableCommand(editor, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "quote",
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <Quote className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code2 className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
key: "image",
|
||||
title: "Image",
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
},
|
||||
{
|
||||
key: "divider",
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks.",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (additionalOptions) {
|
||||
additionalOptions.map((item) => {
|
||||
slashCommands.push(item);
|
||||
});
|
||||
}
|
||||
|
||||
slashCommands = slashCommands.filter((item) => {
|
||||
if (typeof query === "string" && query.length > 0) {
|
||||
const search = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(search) ||
|
||||
item.description.toLowerCase().includes(search) ||
|
||||
(item.searchTerms && item.searchTerms.some((term: string) => term.includes(search)))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return slashCommands;
|
||||
};
|
||||
|
||||
export 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 CommandList = ({ items, command }: { items: CommandItemProps[]; command: any; editor: any; range: any }) => {
|
||||
// states
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
// refs
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (item) command(item);
|
||||
},
|
||||
[command, items]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
if (e.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(selectedIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [items, selectedIndex, setSelectedIndex, selectItem]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [items]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (items.length <= 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="z-10 max-h-80 min-w-[12rem] 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"
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80": index === selectedIndex,
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(index);
|
||||
}}
|
||||
>
|
||||
<span className="grid place-items-center flex-shrink-0">{item.icon}</span>
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CommandListInstance {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer<CommandListInstance, typeof CommandList> | null = null;
|
||||
let popup: any | null = null;
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(CommandList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const tippyContainer =
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
|
||||
|
||||
// @ts-expect-error Tippy overloads are messed up
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: tippyContainer,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (component?.ref?.onKeyDown(props)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommand = (additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
ALargeSmall,
|
||||
CaseSensitive,
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Heading4,
|
||||
Heading5,
|
||||
Heading6,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
MinusSquare,
|
||||
Quote,
|
||||
Table,
|
||||
} from "lucide-react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// helpers
|
||||
import {
|
||||
insertTableCommand,
|
||||
toggleBlockquote,
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
toggleTextColor,
|
||||
toggleBackgroundColor,
|
||||
insertImage,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||
|
||||
export type TSlashCommandSection = {
|
||||
key: string;
|
||||
title?: string;
|
||||
items: ISlashCommandItem[];
|
||||
};
|
||||
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
{
|
||||
key: "general",
|
||||
items: [
|
||||
{
|
||||
commandKey: "text",
|
||||
key: "text",
|
||||
title: "Text",
|
||||
description: "Just start typing with plain text.",
|
||||
searchTerms: ["p", "paragraph"],
|
||||
icon: <CaseSensitive className="size-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().run();
|
||||
}
|
||||
editor.chain().focus().clearNodes().run();
|
||||
},
|
||||
},
|
||||
{
|
||||
commandKey: "h1",
|
||||
key: "h1",
|
||||
title: "Heading 1",
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingOne(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h2",
|
||||
key: "h2",
|
||||
title: "Heading 2",
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingTwo(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h3",
|
||||
key: "h3",
|
||||
title: "Heading 3",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingThree(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h4",
|
||||
key: "h4",
|
||||
title: "Heading 4",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading4 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFour(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h5",
|
||||
key: "h5",
|
||||
title: "Heading 5",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading5 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFive(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h6",
|
||||
key: "h6",
|
||||
title: "Heading 6",
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading6 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingSix(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "to-do-list",
|
||||
key: "to-do-list",
|
||||
title: "To do",
|
||||
description: "Track tasks with a to-do list.",
|
||||
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||
icon: <ListTodo className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleTaskList(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "bulleted-list",
|
||||
key: "bulleted-list",
|
||||
title: "Bullet list",
|
||||
description: "Create a simple bullet list.",
|
||||
searchTerms: ["unordered", "point"],
|
||||
icon: <List className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleBulletList(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "numbered-list",
|
||||
key: "numbered-list",
|
||||
title: "Numbered list",
|
||||
description: "Create a list with numbering.",
|
||||
searchTerms: ["ordered"],
|
||||
icon: <ListOrdered className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleOrderedList(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "table",
|
||||
key: "table",
|
||||
title: "Table",
|
||||
description: "Create a table",
|
||||
searchTerms: ["table", "cell", "db", "data", "tabular"],
|
||||
icon: <Table className="size-3.5" />,
|
||||
command: ({ editor, range }) => insertTableCommand(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "quote",
|
||||
key: "quote",
|
||||
title: "Quote",
|
||||
description: "Capture a quote.",
|
||||
searchTerms: ["blockquote"],
|
||||
icon: <Quote className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleBlockquote(editor, range),
|
||||
},
|
||||
{
|
||||
commandKey: "code",
|
||||
key: "code",
|
||||
title: "Code",
|
||||
description: "Capture a code snippet.",
|
||||
searchTerms: ["codeblock"],
|
||||
icon: <Code2 className="size-3.5" />,
|
||||
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
commandKey: "image",
|
||||
key: "image",
|
||||
title: "Image",
|
||||
icon: <ImageIcon className="size-3.5" />,
|
||||
description: "Insert an image",
|
||||
searchTerms: ["img", "photo", "picture", "media", "upload"],
|
||||
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
|
||||
},
|
||||
{
|
||||
commandKey: "divider",
|
||||
key: "divider",
|
||||
title: "Divider",
|
||||
description: "Visually divide blocks.",
|
||||
searchTerms: ["line", "divider", "horizontal", "rule", "separate"],
|
||||
icon: <MinusSquare className="size-3.5" />,
|
||||
command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "text-color",
|
||||
title: "Colors",
|
||||
items: [
|
||||
{
|
||||
commandKey: "text-color",
|
||||
key: "text-color-default",
|
||||
title: "Default",
|
||||
description: "Change text color",
|
||||
searchTerms: ["color", "text", "default"],
|
||||
icon: (
|
||||
<ALargeSmall
|
||||
className="size-3.5"
|
||||
style={{
|
||||
color: "rgba(var(--color-text-100))",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
|
||||
},
|
||||
...COLORS_LIST.map(
|
||||
(color) =>
|
||||
({
|
||||
commandKey: "text-color",
|
||||
key: `text-color-${color.textColor}`,
|
||||
title: color.label,
|
||||
description: "Change text color",
|
||||
searchTerms: ["color", "text", color.label],
|
||||
icon: (
|
||||
<ALargeSmall
|
||||
className="size-3.5"
|
||||
style={{
|
||||
color: color.textColor,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
command: ({ editor, range }) => toggleTextColor(color.textColor, editor, range),
|
||||
}) as ISlashCommandItem
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "background-color",
|
||||
title: "Background colors",
|
||||
items: [
|
||||
{
|
||||
commandKey: "background-color",
|
||||
key: "background-color-default",
|
||||
title: "Default background",
|
||||
description: "Change background color",
|
||||
searchTerms: ["color", "bg", "background", "default"],
|
||||
icon: <ALargeSmall className="size-3.5" />,
|
||||
iconContainerStyle: {
|
||||
borderRadius: "4px",
|
||||
backgroundColor: "rgba(var(--color-background-100))",
|
||||
border: "1px solid rgba(var(--color-border-300))",
|
||||
},
|
||||
command: ({ editor, range }) => toggleTextColor(undefined, editor, range),
|
||||
},
|
||||
...COLORS_LIST.map(
|
||||
(color) =>
|
||||
({
|
||||
commandKey: "background-color",
|
||||
key: `background-color-${color.backgroundColor}`,
|
||||
title: `${color.label} background`,
|
||||
description: "Change background color",
|
||||
searchTerms: ["color", "bg", "background", color.label],
|
||||
icon: <ALargeSmall className="size-3.5" />,
|
||||
iconContainerStyle: {
|
||||
borderRadius: "4px",
|
||||
backgroundColor: color.backgroundColor,
|
||||
},
|
||||
command: ({ editor, range }) => toggleBackgroundColor(color.backgroundColor, editor, range),
|
||||
}) as ISlashCommandItem
|
||||
),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const getSlashCommandFilteredSections =
|
||||
(additionalOptions?: ISlashCommandItem[]) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
if (additionalOptions) {
|
||||
additionalOptions.map((item) => SLASH_COMMAND_SECTIONS?.[0]?.items.push(item));
|
||||
}
|
||||
|
||||
const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) => {
|
||||
if (typeof query !== "string") return;
|
||||
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(lowercaseQuery) ||
|
||||
item.description.toLowerCase().includes(lowercaseQuery) ||
|
||||
item.searchTerms.some((t) => t.includes(lowercaseQuery))
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
return filteredSlashSections.filter((s) => s.items.length !== 0);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common";
|
||||
// types
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
|
||||
type Props = {
|
||||
isSelected: boolean;
|
||||
item: ISlashCommandItem;
|
||||
itemIndex: number;
|
||||
onClick: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
onMouseEnter: () => void;
|
||||
sectionIndex: number;
|
||||
};
|
||||
|
||||
export const CommandMenuItem: React.FC<Props> = (props) => {
|
||||
const { isSelected, item, itemIndex, onClick, onMouseEnter, sectionIndex } = props;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={`item-${sectionIndex}-${itemIndex}`}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full rounded px-1 py-1.5 text-sm text-left truncate text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": isSelected,
|
||||
}
|
||||
)}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<span className="size-5 grid place-items-center flex-shrink-0" style={item.iconContainerStyle}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<p className="flex-grow truncate">{item.title}</p>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
// components
|
||||
import { TSlashCommandSection } from "./command-items-list";
|
||||
import { CommandMenuItem } from "./command-menu-item";
|
||||
|
||||
type Props = {
|
||||
items: TSlashCommandSection[];
|
||||
command: any;
|
||||
editor: any;
|
||||
range: any;
|
||||
};
|
||||
|
||||
export const SlashCommandsMenu = (props: Props) => {
|
||||
const { items: sections, command } = props;
|
||||
// states
|
||||
const [selectedIndex, setSelectedIndex] = useState({
|
||||
section: 0,
|
||||
item: 0,
|
||||
});
|
||||
// refs
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(sectionIndex: number, itemIndex: number) => {
|
||||
const item = sections[sectionIndex].items[itemIndex];
|
||||
if (item) command(item);
|
||||
},
|
||||
[command, sections]
|
||||
);
|
||||
// handle arrow key navigation
|
||||
useEffect(() => {
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (navigationKeys.includes(e.key)) {
|
||||
e.preventDefault();
|
||||
const currentSection = selectedIndex.section;
|
||||
const currentItem = selectedIndex.item;
|
||||
let nextSection = currentSection;
|
||||
let nextItem = currentItem;
|
||||
|
||||
if (e.key === "ArrowUp") {
|
||||
nextItem = currentItem - 1;
|
||||
if (nextItem < 0) {
|
||||
nextSection = currentSection - 1;
|
||||
if (nextSection < 0) nextSection = sections.length - 1;
|
||||
nextItem = sections[nextSection].items.length - 1;
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
nextItem = currentItem + 1;
|
||||
if (nextItem >= sections[currentSection].items.length) {
|
||||
nextSection = currentSection + 1;
|
||||
if (nextSection >= sections.length) nextSection = 0;
|
||||
nextItem = 0;
|
||||
}
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
selectItem(currentSection, currentItem);
|
||||
}
|
||||
setSelectedIndex({
|
||||
section: nextSection,
|
||||
item: nextItem,
|
||||
});
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [sections, selectedIndex, setSelectedIndex, selectItem]);
|
||||
// initialize the select index to 0 by default
|
||||
useEffect(() => {
|
||||
setSelectedIndex({
|
||||
section: 0,
|
||||
item: 0,
|
||||
});
|
||||
}, [sections]);
|
||||
// scroll to the dropdown item when navigating via keyboard
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
if (!container) return;
|
||||
|
||||
const item = container.querySelector(`#item-${selectedIndex.section}-${selectedIndex.item}`) as HTMLElement;
|
||||
|
||||
// use scroll into view to bring the item in view if it is not in view
|
||||
item?.scrollIntoView({ block: "nearest" });
|
||||
}, [sections, selectedIndex]);
|
||||
|
||||
const areSearchResultsEmpty = sections.map((s) => s.items.length).reduce((acc, curr) => acc + curr, 0) === 0;
|
||||
|
||||
if (areSearchResultsEmpty) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
id="slash-command"
|
||||
ref={commandListContainer}
|
||||
className="z-10 max-h-80 min-w-[12rem] 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"
|
||||
>
|
||||
{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>}
|
||||
<div>
|
||||
{section.items.map((item, itemIndex) => (
|
||||
<CommandMenuItem
|
||||
key={item.key}
|
||||
isSelected={sectionIndex === selectedIndex.section && itemIndex === selectedIndex.item}
|
||||
item={item}
|
||||
itemIndex={itemIndex}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
selectItem(sectionIndex, itemIndex);
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex({
|
||||
section: sectionIndex,
|
||||
item: itemIndex,
|
||||
})
|
||||
}
|
||||
sectionIndex={sectionIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
// types
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
// components
|
||||
import { getSlashCommandFilteredSections } from "./command-items-list";
|
||||
import { SlashCommandsMenu } from "./command-menu";
|
||||
|
||||
export type SlashCommandOptions = {
|
||||
suggestion: Omit<SuggestionOptions, "editor">;
|
||||
};
|
||||
|
||||
const Command = Extension.create<SlashCommandOptions>({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
command: ({ editor, range, props }: { editor: Editor; range: Range; props: any }) => {
|
||||
props.command({ editor, range });
|
||||
},
|
||||
allow({ editor }: { editor: Editor }) {
|
||||
const { selection } = editor.state;
|
||||
|
||||
const parentNode = selection.$from.node(selection.$from.depth);
|
||||
const blockType = parentNode.type.name;
|
||||
|
||||
if (blockType === "codeBlock") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor.isActive("table")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
interface CommandListInstance {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer<CommandListInstance, typeof SlashCommandsMenu> | null = null;
|
||||
let popup: any | null = null;
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer(SlashCommandsMenu, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const tippyContainer =
|
||||
document.querySelector(".active-editor") ?? document.querySelector('[id^="editor-container"]');
|
||||
|
||||
// @ts-expect-error Tippy overloads are messed up
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: tippyContainer,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
popup?.[0]?.setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (component?.ref?.onKeyDown(props)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSlashCommandFilteredSections(additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
@@ -154,3 +154,42 @@ export const unsetLinkEditor = (editor: Editor) => {
|
||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
export const toggleTextColor = (color: string | undefined, editor: Editor, range?: Range) => {
|
||||
if (color) {
|
||||
if (range) editor.chain().focus().deleteRange(range).setColor(color).run();
|
||||
else editor.chain().focus().setColor(color).run();
|
||||
} else {
|
||||
if (range) editor.chain().focus().deleteRange(range).unsetColor().run();
|
||||
else editor.chain().focus().unsetColor().run();
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleBackgroundColor = (color: string | undefined, editor: Editor, range?: Range) => {
|
||||
if (color) {
|
||||
if (range) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.setHighlight({
|
||||
color,
|
||||
})
|
||||
.run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setHighlight({
|
||||
color,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
} else {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).unsetHighlight().run();
|
||||
} else {
|
||||
editor.chain().focus().unsetHighlight().run();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,7 +136,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||
}
|
||||
},
|
||||
executeMenuItemCommand: (itemKey: TEditorCommands) => {
|
||||
executeMenuItemCommand: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
@@ -145,6 +146,8 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (item) {
|
||||
if (item.key === "image") {
|
||||
item.command(savedSelectionRef.current);
|
||||
} else if (itemKey === "text-color" || itemKey === "background-color") {
|
||||
item.command(props.color);
|
||||
} else {
|
||||
item.command();
|
||||
}
|
||||
@@ -152,12 +155,19 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
console.warn(`No command found for item: ${itemKey}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (itemName: TEditorCommands): boolean => {
|
||||
isMenuItemActive: (props) => {
|
||||
const { itemKey } = props;
|
||||
const editorItems = getEditorMenuItems(editorRef.current);
|
||||
|
||||
const getEditorMenuItem = (itemName: TEditorCommands) => editorItems.find((item) => item.key === itemName);
|
||||
const item = getEditorMenuItem(itemName);
|
||||
return item ? item.isActive() : false;
|
||||
const getEditorMenuItem = (itemKey: TEditorCommands) => editorItems.find((item) => item.key === itemKey);
|
||||
const item = getEditorMenuItem(itemKey);
|
||||
if (!item) return false;
|
||||
|
||||
if (itemKey === "text-color" || itemKey === "background-color") {
|
||||
return item.isActive(props.color);
|
||||
} else {
|
||||
return item.isActive("");
|
||||
}
|
||||
},
|
||||
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
|
||||
// Subscribe to update event emitted from headers extension
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TAIHandler,
|
||||
TColorEditorCommands,
|
||||
TDisplayConfig,
|
||||
TEditorCommands,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TNonColorEditorCommands,
|
||||
TServerHandler,
|
||||
} from "@/types";
|
||||
|
||||
// editor refs
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
@@ -36,8 +37,26 @@ export type EditorReadOnlyRefApi = {
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
executeMenuItemCommand: (itemKey: TEditorCommands) => void;
|
||||
isMenuItemActive: (itemKey: TEditorCommands) => boolean;
|
||||
executeMenuItemCommand: (
|
||||
props:
|
||||
| {
|
||||
itemKey: TNonColorEditorCommands;
|
||||
}
|
||||
| {
|
||||
itemKey: TColorEditorCommands;
|
||||
color: string | undefined;
|
||||
}
|
||||
) => void;
|
||||
isMenuItemActive: (
|
||||
props:
|
||||
| {
|
||||
itemKey: TNonColorEditorCommands;
|
||||
}
|
||||
| {
|
||||
itemKey: TColorEditorCommands;
|
||||
color: string | undefined;
|
||||
}
|
||||
) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
isEditorReadyToDiscard: () => boolean;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from "react";
|
||||
import { CSSProperties } from "react";
|
||||
import { Editor, Range } from "@tiptap/core";
|
||||
|
||||
export type TEditorCommands =
|
||||
@@ -21,7 +21,12 @@ export type TEditorCommands =
|
||||
| "table"
|
||||
| "image"
|
||||
| "divider"
|
||||
| "issue-embed";
|
||||
| "issue-embed"
|
||||
| "text-color"
|
||||
| "background-color";
|
||||
|
||||
export type TColorEditorCommands = Extract<TEditorCommands, "text-color" | "background-color">;
|
||||
export type TNonColorEditorCommands = Exclude<TEditorCommands, "text-color" | "background-color">;
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
@@ -29,10 +34,12 @@ export type CommandProps = {
|
||||
};
|
||||
|
||||
export type ISlashCommandItem = {
|
||||
key: TEditorCommands;
|
||||
commandKey: TEditorCommands;
|
||||
key: string;
|
||||
title: string;
|
||||
description: string;
|
||||
searchTerms: string[];
|
||||
icon: ReactNode;
|
||||
icon: React.ReactNode;
|
||||
iconContainerStyle?: CSSProperties;
|
||||
command: ({ editor, range }: CommandProps) => void;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,9 @@ export {
|
||||
|
||||
export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
|
||||
// constants
|
||||
export * from "@/constants/common";
|
||||
|
||||
// helpers
|
||||
export * from "@/helpers/common";
|
||||
export * from "@/helpers/editor-commands";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
// editor
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
|
||||
// components
|
||||
import { IssueCommentToolbar } from "@/components/editor";
|
||||
// helpers
|
||||
@@ -56,7 +56,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
<IssueCommentToolbar
|
||||
executeCommand={(key) => {
|
||||
if (isMutableRefObject<EditorRefApi>(ref)) {
|
||||
ref.current?.executeMenuItemCommand(key);
|
||||
ref.current?.executeMenuItemCommand({
|
||||
itemKey: key as TNonColorEditorCommands,
|
||||
});
|
||||
}
|
||||
}}
|
||||
isSubmitting={isSubmitting}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
// editor
|
||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
||||
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
|
||||
// ui
|
||||
import { Button, Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
@@ -34,7 +34,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
// Assert that editorRef.current is not null
|
||||
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key);
|
||||
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({
|
||||
itemKey: item.key as TNonColorEditorCommands,
|
||||
});
|
||||
});
|
||||
setActiveStates(newActiveStates);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { IIssueDisplayProperties } from "@plane/types";
|
||||
// ui
|
||||
import { setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
@@ -11,6 +13,7 @@ type TIssueIdentifierBaseProps = {
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
textContainerClassName?: string;
|
||||
displayProperties?: IIssueDisplayProperties | undefined;
|
||||
enableClickToCopyIdentifier?: boolean;
|
||||
};
|
||||
|
||||
type TIssueIdentifierFromStore = TIssueIdentifierBaseProps & {
|
||||
@@ -23,9 +26,48 @@ type TIssueIdentifierWithDetails = TIssueIdentifierBaseProps & {
|
||||
issueSequenceId: string | number;
|
||||
};
|
||||
|
||||
type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails;
|
||||
export type TIssueIdentifierProps = TIssueIdentifierFromStore | TIssueIdentifierWithDetails;
|
||||
|
||||
type TIdentifierTextProps = {
|
||||
identifier: string;
|
||||
enableClickToCopyIdentifier?: boolean;
|
||||
textContainerClassName?: string;
|
||||
};
|
||||
|
||||
export const IdentifierText: React.FC<TIdentifierTextProps> = (props) => {
|
||||
const { identifier, enableClickToCopyIdentifier = false, textContainerClassName } = props;
|
||||
// handlers
|
||||
const handleCopyIssueIdentifier = () => {
|
||||
if (enableClickToCopyIdentifier) {
|
||||
navigator.clipboard.writeText(identifier).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Issue ID copied to clipboard",
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip tooltipContent="Click to copy" disabled={!enableClickToCopyIdentifier} position="top">
|
||||
<span
|
||||
className={cn(
|
||||
"text-base font-medium text-custom-text-300",
|
||||
{
|
||||
"cursor-pointer": enableClickToCopyIdentifier,
|
||||
},
|
||||
textContainerClassName
|
||||
)}
|
||||
onClick={handleCopyIssueIdentifier}
|
||||
>
|
||||
{identifier}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props) => {
|
||||
const { projectId, textContainerClassName, displayProperties } = props;
|
||||
const { projectId, textContainerClassName, displayProperties, enableClickToCopyIdentifier = false } = props;
|
||||
// store hooks
|
||||
const { getProjectIdentifierById } = useProject();
|
||||
const {
|
||||
@@ -43,9 +85,11 @@ export const IssueIdentifier: React.FC<TIssueIdentifierProps> = observer((props)
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={cn("text-base font-medium text-custom-text-300", textContainerClassName)}>
|
||||
{projectIdentifier}-{issueSequenceId}
|
||||
</span>
|
||||
<IdentifierText
|
||||
identifier={`${projectIdentifier}-${issueSequenceId}`}
|
||||
enableClickToCopyIdentifier={enableClickToCopyIdentifier}
|
||||
textContainerClassName={textContainerClassName}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,5 +20,5 @@ export const IssueTypeSwitcher: React.FC<TIssueTypeSwitcherProps> = observer((pr
|
||||
|
||||
if (!issue || !issue.project_id) return <></>;
|
||||
|
||||
return <IssueIdentifier issueId={issueId} projectId={issue.project_id} size="md" />;
|
||||
return <IssueIdentifier issueId={issueId} projectId={issue.project_id} size="md" enableClickToCopyIdentifier />;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
// editor
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/editor";
|
||||
import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef, TNonColorEditorCommands } from "@plane/editor";
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
// components
|
||||
@@ -87,7 +87,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
accessSpecifier={accessSpecifier}
|
||||
executeCommand={(key) => {
|
||||
if (isMutableRefObject<EditorRefApi>(ref)) {
|
||||
ref.current?.executeMenuItemCommand(key);
|
||||
ref.current?.executeMenuItemCommand({
|
||||
itemKey: key as TNonColorEditorCommands,
|
||||
});
|
||||
}
|
||||
}}
|
||||
handleAccessChange={handleAccessChange}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Globe2, Lock, LucideIcon } from "lucide-react";
|
||||
// editor
|
||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
||||
import { EditorRefApi, TEditorCommands, TNonColorEditorCommands } from "@plane/editor";
|
||||
// ui
|
||||
import { Button, Tooltip } from "@plane/ui";
|
||||
// constants
|
||||
@@ -69,7 +69,9 @@ export const IssueCommentToolbar: React.FC<Props> = (props) => {
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
// Assert that editorRef.current is not null
|
||||
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key);
|
||||
newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive({
|
||||
itemKey: item.key as TNonColorEditorCommands,
|
||||
});
|
||||
});
|
||||
setActiveStates(newActiveStates);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
const canDelete =
|
||||
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, workspaceSlug, projectId) ||
|
||||
issue?.created_by === currentUser?.id;
|
||||
const isProjectAdmin = allowPermissions(
|
||||
[EUserPermissions.ADMIN],
|
||||
EUserPermissionsLevel.PROJECT,
|
||||
workspaceSlug,
|
||||
projectId
|
||||
);
|
||||
const isAcceptedOrDeclined = inboxIssue?.status ? [-1, 1, 2].includes(inboxIssue.status) : undefined;
|
||||
// days left for snooze
|
||||
const numberOfDaysLeft = findHowManyDaysLeft(inboxIssue?.snoozed_till);
|
||||
@@ -199,6 +205,17 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
[handleInboxIssueNavigation]
|
||||
);
|
||||
|
||||
const handleActionWithPermission = (isAdmin: boolean, action: () => void, errorMessage: string) => {
|
||||
if (isAdmin) action();
|
||||
else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Permission denied",
|
||||
message: errorMessage,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNotificationEmbed) document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
@@ -293,7 +310,13 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
size="sm"
|
||||
prependIcon={<CircleCheck className="w-3 h-3" />}
|
||||
className="text-green-500 border-0.5 border-green-500 bg-green-500/20 focus:bg-green-500/20 focus:text-green-500 hover:bg-green-500/40 bg-opacity-20"
|
||||
onClick={() => setAcceptIssueModal(true)}
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setAcceptIssueModal(true),
|
||||
"Only project admins can accept issues"
|
||||
)
|
||||
}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
@@ -307,7 +330,13 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
size="sm"
|
||||
prependIcon={<CircleX className="w-3 h-3" />}
|
||||
className="text-red-500 border-0.5 border-red-500 bg-red-500/20 focus:bg-red-500/20 focus:text-red-500 hover:bg-red-500/40 bg-opacity-20"
|
||||
onClick={() => setDeclineIssueModal(true)}
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setDeclineIssueModal(true),
|
||||
"Only project admins can deny issues"
|
||||
)
|
||||
}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
@@ -341,7 +370,15 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
{isAllowed && (
|
||||
<CustomMenu verticalEllipsis placement="bottom-start">
|
||||
{canMarkAsAccepted && (
|
||||
<CustomMenu.MenuItem onClick={handleIssueSnoozeAction}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
handleIssueSnoozeAction,
|
||||
"Only project admins can snooze/Un-snooze issues"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} strokeWidth={2} />
|
||||
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0
|
||||
@@ -351,7 +388,15 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDuplicate && (
|
||||
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setSelectDuplicateIssue(true),
|
||||
"Only project admins can mark issues as duplicate"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileStack size={14} strokeWidth={2} />
|
||||
Mark as duplicate
|
||||
@@ -401,6 +446,8 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isNotificationEmbed={isNotificationEmbed}
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
isProjectAdmin={isProjectAdmin}
|
||||
handleActionWithPermission={handleActionWithPermission}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -47,6 +47,8 @@ type Props = {
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
isNotificationEmbed: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
isProjectAdmin: boolean;
|
||||
handleActionWithPermission: (isAdmin: boolean, action: () => void, errorMessage: string) => void;
|
||||
};
|
||||
|
||||
export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) => {
|
||||
@@ -70,6 +72,8 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
|
||||
setIsMobileSidebar,
|
||||
isNotificationEmbed,
|
||||
embedRemoveCurrentNotification,
|
||||
isProjectAdmin,
|
||||
handleActionWithPermission,
|
||||
} = props;
|
||||
const router = useAppRouter();
|
||||
const issue = inboxIssue?.issue;
|
||||
@@ -139,7 +143,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsAccepted && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={handleIssueSnoozeAction}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
handleIssueSnoozeAction,
|
||||
"Only project admins can snooze/Un-snooze issues"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={14} strokeWidth={2} />
|
||||
{inboxIssue?.snoozed_till && numberOfDaysLeft && numberOfDaysLeft > 0 ? "Un-snooze" : "Snooze"}
|
||||
@@ -147,7 +159,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDuplicate && !isAcceptedOrDeclined && (
|
||||
<CustomMenu.MenuItem onClick={() => setSelectDuplicateIssue(true)}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setSelectDuplicateIssue(true),
|
||||
"Only project admins can mark issues as duplicate"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileStack size={14} strokeWidth={2} />
|
||||
Mark as duplicate
|
||||
@@ -155,7 +175,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsAccepted && (
|
||||
<CustomMenu.MenuItem onClick={() => setAcceptIssueModal(true)}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setAcceptIssueModal(true),
|
||||
"Only project admins can accept issues"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-green-500">
|
||||
<CircleCheck size={14} strokeWidth={2} />
|
||||
Accept
|
||||
@@ -163,7 +191,15 @@ export const InboxIssueActionsMobileHeader: React.FC<Props> = observer((props) =
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
{canMarkAsDeclined && (
|
||||
<CustomMenu.MenuItem onClick={() => setDeclineIssueModal(true)}>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() =>
|
||||
handleActionWithPermission(
|
||||
isProjectAdmin,
|
||||
() => setDeclineIssueModal(true),
|
||||
"Only project admins can deny issues"
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<CircleX size={14} strokeWidth={2} />
|
||||
Decline
|
||||
|
||||
@@ -62,10 +62,10 @@ export const InboxContentRoot: FC<TInboxContentRoot> = observer((props) => {
|
||||
}
|
||||
);
|
||||
|
||||
const isEditable = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
const isEditable =
|
||||
allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT) ||
|
||||
inboxIssue.created_by === currentUser?.id;
|
||||
|
||||
const isGuest = projectPermissionsByWorkspaceSlugAndProjectId(workspaceSlug, projectId) === EUserPermissions.GUEST;
|
||||
const isOwner = inboxIssue?.issue.created_by === currentUser?.id;
|
||||
const readOnly = !isOwner && isGuest;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
import { ALargeSmall, Ban } from "lucide-react";
|
||||
// plane editor
|
||||
import { COLORS_LIST, TColorEditorCommands } from "@plane/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
handleColorSelect: (key: TColorEditorCommands, color: string | undefined) => void;
|
||||
isColorActive: (key: TColorEditorCommands, color: string | undefined) => boolean;
|
||||
};
|
||||
|
||||
export const ColorDropdown: React.FC<Props> = memo((props) => {
|
||||
const { handleColorSelect, isColorActive } = props;
|
||||
|
||||
const activeTextColor = COLORS_LIST.find((c) => isColorActive("text-color", c.textColor));
|
||||
const activeBackgroundColor = COLORS_LIST.find((c) => isColorActive("background-color", c.backgroundColor));
|
||||
|
||||
return (
|
||||
<Popover as="div" className="h-7 px-2">
|
||||
<Popover.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className={({ open }) =>
|
||||
cn("h-full", {
|
||||
"outline-none": open,
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ open }) => (
|
||||
<span
|
||||
className={cn(
|
||||
"h-full px-2 text-custom-text-300 text-sm flex items-center gap-1.5 rounded hover:bg-custom-background-80",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-background-80": open,
|
||||
}
|
||||
)}
|
||||
>
|
||||
Color
|
||||
<span
|
||||
className={cn(
|
||||
"flex-shrink-0 size-6 grid place-items-center rounded border-[0.5px] border-custom-border-300",
|
||||
{
|
||||
"bg-custom-background-100": !activeBackgroundColor,
|
||||
}
|
||||
)}
|
||||
style={
|
||||
activeBackgroundColor
|
||||
? {
|
||||
backgroundColor: activeBackgroundColor.backgroundColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<ALargeSmall
|
||||
className={cn("size-3.5", {
|
||||
"text-custom-text-100": !activeTextColor,
|
||||
})}
|
||||
style={
|
||||
activeTextColor
|
||||
? {
|
||||
color: activeTextColor.textColor,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</Popover.Button>
|
||||
<Popover.Panel
|
||||
as="div"
|
||||
className="fixed z-20 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg p-2 space-y-2"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.textColor}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.textColor,
|
||||
}}
|
||||
onClick={() => handleColorSelect("text-color", color.textColor)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => handleColorSelect("text-color", undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.backgroundColor}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
onClick={() => handleColorSelect("background-color", color.backgroundColor)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => handleColorSelect("background-color", undefined)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./color-dropdown";
|
||||
export * from "./extra-options";
|
||||
export * from "./info-popover";
|
||||
export * from "./options-dropdown";
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import React, { useEffect, useState, useCallback } from "react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
// editor
|
||||
import { EditorRefApi, TEditorCommands } from "@plane/editor";
|
||||
import { EditorRefApi, TNonColorEditorCommands } from "@plane/editor";
|
||||
// ui
|
||||
import { CustomMenu, Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { ColorDropdown } from "@/components/pages";
|
||||
// constants
|
||||
import { TOOLBAR_ITEMS, TYPOGRAPHY_ITEMS, ToolbarMenuItem } from "@/constants/editor";
|
||||
// helpers
|
||||
@@ -18,7 +20,7 @@ type Props = {
|
||||
type ToolbarButtonProps = {
|
||||
item: ToolbarMenuItem;
|
||||
isActive: boolean;
|
||||
executeCommand: (commandKey: TEditorCommands) => void;
|
||||
executeCommand: EditorRefApi["executeMenuItemCommand"];
|
||||
};
|
||||
|
||||
const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
||||
@@ -36,7 +38,11 @@ const ToolbarButton: React.FC<ToolbarButtonProps> = React.memo((props) => {
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => executeCommand(item.key)}
|
||||
onClick={() =>
|
||||
executeCommand({
|
||||
itemKey: item.key as TNonColorEditorCommands,
|
||||
})
|
||||
}
|
||||
className={cn("grid size-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80", {
|
||||
"bg-custom-background-80 text-custom-text-100": isActive,
|
||||
})}
|
||||
@@ -56,6 +62,7 @@ ToolbarButton.displayName = "ToolbarButton";
|
||||
const toolbarItems = TOOLBAR_ITEMS.document;
|
||||
|
||||
export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||
// states
|
||||
const [activeStates, setActiveStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
const updateActiveStates = useCallback(() => {
|
||||
@@ -63,7 +70,9 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||
Object.values(toolbarItems)
|
||||
.flat()
|
||||
.forEach((item) => {
|
||||
newActiveStates[item.key] = editorRef.isMenuItemActive(item.key);
|
||||
newActiveStates[item.key] = editorRef.isMenuItemActive({
|
||||
itemKey: item.key as TNonColorEditorCommands,
|
||||
});
|
||||
});
|
||||
setActiveStates(newActiveStates);
|
||||
}, [editorRef]);
|
||||
@@ -74,7 +83,11 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||
return () => unsubscribe();
|
||||
}, [editorRef, updateActiveStates]);
|
||||
|
||||
const activeTypography = TYPOGRAPHY_ITEMS.find((item) => editorRef.isMenuItemActive(item.key));
|
||||
const activeTypography = TYPOGRAPHY_ITEMS.find((item) =>
|
||||
editorRef.isMenuItemActive({
|
||||
itemKey: item.key as TNonColorEditorCommands,
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||
@@ -94,7 +107,11 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||
<CustomMenu.MenuItem
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => editorRef.executeMenuItemCommand(item.key)}
|
||||
onClick={() =>
|
||||
editorRef.executeMenuItemCommand({
|
||||
itemKey: item.key as TNonColorEditorCommands,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<item.icon className="size-3" />
|
||||
@@ -104,6 +121,20 @@ export const PageToolbar: React.FC<Props> = ({ editorRef }) => {
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
<ColorDropdown
|
||||
handleColorSelect={(key, color) =>
|
||||
editorRef.executeMenuItemCommand({
|
||||
itemKey: key,
|
||||
color,
|
||||
})
|
||||
}
|
||||
isColorActive={(key, color) =>
|
||||
editorRef.isMenuItemActive({
|
||||
itemKey: key,
|
||||
color,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{Object.keys(toolbarItems).map((key) => (
|
||||
<div key={key} className="flex items-center gap-0.5 px-2 first:pl-0 last:pr-0">
|
||||
{toolbarItems[key].map((item) => (
|
||||
|
||||
@@ -109,7 +109,10 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
||||
const selectedPageFormat = watch("page_format");
|
||||
const selectedContentVariety = watch("content_variety");
|
||||
const isPDFSelected = selectedExportFormat === "pdf";
|
||||
const fileName = pageTitle?.toLowerCase()?.replace(/ /g, "-");
|
||||
const fileName = pageTitle
|
||||
?.toLowerCase()
|
||||
?.replace(/[^a-z0-9-_]/g, "-")
|
||||
.replace(/-+/g, "-");
|
||||
// handle modal close
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
@@ -117,6 +120,18 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
||||
reset();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const initiateDownload = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// handle export as a PDF
|
||||
const handleExportAsPDF = async () => {
|
||||
try {
|
||||
@@ -127,13 +142,7 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
||||
});
|
||||
|
||||
const blob = await pdf(<PDFDocument content={parsedPageContent} pageFormat={selectedPageFormat} />).toBlob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
initiateDownload(blob, `${fileName}-${selectedPageFormat.toString().toLowerCase()}.pdf`);
|
||||
} catch (error) {
|
||||
throw new Error(`Error in exporting as a PDF: ${error}`);
|
||||
}
|
||||
@@ -148,13 +157,7 @@ export const ExportPageModal: React.FC<Props> = (props) => {
|
||||
});
|
||||
|
||||
const blob = new Blob([parsedMarkdownContent], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${fileName}.md`;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
initiateDownload(blob, `${fileName}.md`);
|
||||
} catch (error) {
|
||||
throw new Error(`Error in exporting as markdown: ${error}`);
|
||||
}
|
||||
|
||||
@@ -252,7 +252,6 @@ const EDITOR_PDF_CODE_STYLES: Styles = {
|
||||
fontSize: convertRemToPixel(0.7),
|
||||
},
|
||||
// inline code block
|
||||
// TODO: update width
|
||||
"[data-node-type='inline-code-block']": {
|
||||
margin: 0,
|
||||
paddingVertical: convertRemToPixel(0.25 / 4 + 0.25 / 8),
|
||||
|
||||
@@ -144,7 +144,6 @@ export class CycleStore implements ICycleStore {
|
||||
fetchActiveCycleProgress: action,
|
||||
fetchActiveCycleAnalytics: action,
|
||||
fetchCycleDetails: action,
|
||||
createCycle: action,
|
||||
updateCycleDetails: action,
|
||||
deleteCycle: action,
|
||||
addCycleToFavorites: action,
|
||||
@@ -617,13 +616,15 @@ export class CycleStore implements ICycleStore {
|
||||
* @param data
|
||||
* @returns
|
||||
*/
|
||||
createCycle = async (workspaceSlug: string, projectId: string, data: Partial<ICycle>) =>
|
||||
await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [response.id], response);
|
||||
});
|
||||
return response;
|
||||
});
|
||||
createCycle = action(
|
||||
async (workspaceSlug: string, projectId: string, data: Partial<ICycle>) =>
|
||||
await this.cycleService.createCycle(workspaceSlug, projectId, data).then((response) => {
|
||||
runInAction(() => {
|
||||
set(this.cycleMap, [response.id], response);
|
||||
});
|
||||
return response;
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* @description updates cycle details
|
||||
|
||||
@@ -423,7 +423,7 @@ export class ProjectInboxStore implements IProjectInboxStore {
|
||||
|
||||
if (inboxIssue && issueId) {
|
||||
runInAction(() => {
|
||||
set(this.inboxIssues, [issueId], new InboxIssueStore(workspaceSlug, projectId, inboxIssue, this.store));
|
||||
this.createOrUpdateInboxIssue([inboxIssue], workspaceSlug, projectId);
|
||||
set(this, "loader", undefined);
|
||||
});
|
||||
await Promise.all([
|
||||
|
||||
@@ -134,7 +134,8 @@ export const replaceCustomComponentsFromMarkdownContent = (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 = window?.location?.origin ?? "";
|
||||
const originUrl = typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||
|
||||
parsedMarkdownContent = parsedMarkdownContent.replace(
|
||||
mentionRegex,
|
||||
(_match, label, redirectUri) => `[${label}](${originUrl}/${redirectUri})`
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
* @returns
|
||||
*/
|
||||
export const getBase64Image = async (url: string): Promise<string> => {
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new Error("Invalid URL provided");
|
||||
}
|
||||
|
||||
// Try to create a URL object to validate the URL
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
throw new Error("Invalid URL format");
|
||||
}
|
||||
|
||||
const response = await fetch(url);
|
||||
// check if the response is OK
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -3636,6 +3636,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-collaboration/-/extension-collaboration-2.8.0.tgz#db7a1e600c80229ed24a9d004f290d9e8bd4d0f6"
|
||||
integrity sha512-Ae5NZWj2aq8ZElsGxQiq3cAxxbb0cR7VHvmIG1mPA6USvrQL6/xtBVutersBqINFELmIuxh/jm8qVffBm2qXyg==
|
||||
|
||||
"@tiptap/extension-color@^2.7.1":
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-color/-/extension-color-2.8.0.tgz#597e1ea2e675e3c01ba64933008eacd296913abd"
|
||||
integrity sha512-b0ZIDaZKTDVdTb0PMgtOiPzgCkYhvDldjzdWyPLsjWup5x9/zPasH5X/2SfMuwtjt+cKj6YBPveJjF7w5ApK7w==
|
||||
|
||||
"@tiptap/extension-document@^2.8.0":
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.8.0.tgz#7dc5d2622168ad5b81134a92fccf49d7be53f141"
|
||||
@@ -3668,6 +3673,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.8.0.tgz#1b7711860fe9f4336fb8933110a129150faa4e39"
|
||||
integrity sha512-4inWgrTPiqlivPmEHFOM5ck2UsmOsbKKPtqga6bALvWPmCv24S6/EBwFp8Jz4YABabXDnkviihmGu0LpP9D69w==
|
||||
|
||||
"@tiptap/extension-highlight@^2.7.1":
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-highlight/-/extension-highlight-2.8.0.tgz#3970f42a5a116745fbb2b82cfc5055adb04158e9"
|
||||
integrity sha512-vyqX7D449nuARhI0AyRqtIZReFg3sfc/U/q1p3JOjtUoW6z2jmDTzshiKRrSg+Jf7Hhzj1pqwU+6+CpelPPDpA==
|
||||
|
||||
"@tiptap/extension-history@^2.8.0":
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.8.0.tgz#06505cbdaa29a9791911eddbee54304ee32b1d5c"
|
||||
@@ -3728,7 +3738,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-task-list/-/extension-task-list-2.8.0.tgz#b4124b638ee50251cae0bad9280a07350dc39e76"
|
||||
integrity sha512-2Zkq0UojPh+Y4trJcNSsjkdHsYczxFReUqhzl5T0/OuPzIcDYL2OicUrsp36y16KKnH+WSOUOR8twDfR9LHygg==
|
||||
|
||||
"@tiptap/extension-text-style@^2.1.13":
|
||||
"@tiptap/extension-text-style@^2.7.1":
|
||||
version "2.8.0"
|
||||
resolved "https://registry.yarnpkg.com/@tiptap/extension-text-style/-/extension-text-style-2.8.0.tgz#32e30ccf3853202eba2169ba5db30b9470df9644"
|
||||
integrity sha512-jJp0vcZ2Ty7RvIL0VU6dm1y+fTfXq1lN2GwtYzYM0ueFuESa+Qo8ticYOImyWZ3wGJGVrjn7OV9r0ReW0/NYkQ==
|
||||
|
||||
Reference in New Issue
Block a user