Compare commits

...

30 Commits

Author SHA1 Message Date
sharma01ketan 33a5d0a5d3 Merge branch 'preview' of https://github.com/makeplane/plane into preview 2024-10-10 14:21:16 +05:30
Bavisetti Narayan 8a866e440c chore: only admin can changed the project settings (#5766) 2024-10-10 14:06:14 +05:30
Prateek Shourya 7495a7d0cb [WEB-2605] fix: update URL regex pattern to allow complex links. (#5767) 2024-10-10 14:06:14 +05:30
M. Palanikannan 2b1da96c3f fix: drag handle scrolling fixed (#5619)
* fix: drag handle scrolling fixed

* fix: closest scrollable parent found and scrolled

* fix: removed overflow auto from framerenderer

* fix: make dragging dynamic and smoother
2024-10-10 14:06:14 +05:30
Aaryan Khandelwal daa06f1831 [WEB-2532] fix: custom theme mutation logic (#5685)
* fix: custom theme mutation logic

* chore: update querySelector element
2024-10-10 14:06:14 +05:30
M. Palanikannan b97fcfb46d fix: show the full screen toolbar in read only instances as well (#5746) 2024-10-10 14:06:14 +05:30
M. Palanikannan 852fc9bac1 [WEB-2603] fix: remove validation of roles from the live server (#5761)
* fix: remove validation of roles from the live server

* chore: remove the service

* fix: remove all validation of authorization

* fix: props updated
2024-10-10 14:06:14 +05:30
Akshita Goyal 55f44e0245 fix: spreadsheet flicker issue (#5769) 2024-10-10 14:06:14 +05:30
Prateek Shourya 8981e52dcc [WEB-2601] improvement: add click to copy issue identifier on peek-overview and issue detail page. (#5760) 2024-10-10 14:06:14 +05:30
Akshita Goyal d92dbaea72 [WEB-2589] Chore: inbox issue permissions (#5763)
* chore: changed permission in inbox issue

* chore: fixed permissions for intake

* fix: refactoring

* fix: lint

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-10-10 14:06:14 +05:30
dependabot[bot] 58f3d0a68c chore(deps): bump django in /apiserver/requirements (#5781)
Bumps [django](https://github.com/django/django) from 4.2.15 to 4.2.16.
- [Commits](https://github.com/django/django/compare/4.2.15...4.2.16)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-10 14:06:14 +05:30
Akshita Goyal 6d78418e79 fix: create cycle function (#5775)
* fix: create cycle function

* chore: draft and cycle version changes

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2024-10-08 20:01:15 +05:30
Prateek Shourya 6e52f1b434 [WEB-2601] improvement: add click to copy issue identifier on peek-overview and issue detail page. (#5760) 2024-10-08 18:43:13 +05:30
Aaryan Khandelwal c3c1ea727d [WEB-2494] feat: text color and highlight options for all editors (#5653)
* feat: add text color and highlight options to pages

* style: rich text editor floating toolbar

* chore: remove unused function

* refactor: slash command components

* chore: move default text and background options to the top

* fix: sections filtering logic
2024-10-08 18:42:47 +05:30
Aaryan Khandelwal 5afc576dec refactor: export components (#5773) 2024-10-08 18:41:08 +05:30
sriram veeraghanta d97ca68229 Merge pull request #5764 from makeplane/preview
release: v0.23.0
2024-10-07 18:54:49 +05:30
sriram veeraghanta 707570ca7a Merge pull request #5041 from makeplane/preview
release: v0.22-dev
2024-07-05 13:28:45 +05:30
sriram veeraghanta c76af7d7d6 Merge pull request #4688 from makeplane/preview
release: v0.21-dev
2024-06-03 18:54:06 +05:30
sriram veeraghanta 1dcea9bcc8 Merge pull request #4569 from makeplane/preview
release: v0.20-dev
2024-05-23 19:55:06 +05:30
sriram veeraghanta da957e06b6 Merge pull request #4349 from makeplane/preview
release: v0.19-dev
2024-05-03 20:36:07 +05:30
sriram veeraghanta a0b9596cb4 Merge pull request #4239 from makeplane/preview
chore:version update
2024-04-19 12:01:15 +05:30
sriram veeraghanta f71e8a3a0f Merge pull request #4238 from makeplane/preview
release: v0.18-dev
2024-04-19 11:56:03 +05:30
sriram veeraghanta 002fb4547b Merge pull request #4107 from makeplane/preview
release: v0.17-dev
2024-04-02 20:07:48 +05:30
sriram veeraghanta c1b1ba35c1 Merge pull request #3878 from makeplane/preview
release: v0.16-dev
2024-03-05 20:04:08 +05:30
sriram veeraghanta 4566d6e80c Merge pull request #3697 from makeplane/preview
release: 0.15.4-dev
2024-02-19 19:30:06 +05:30
sriram veeraghanta e8d359e625 Merge pull request #3674 from makeplane/preview
fix: build branch docker images push on release
2024-02-15 14:35:32 +05:30
sriram veeraghanta 351eba8d61 Merge pull request #3671 from makeplane/preview
release: peek overview issue description initial load bug (#3670)
2024-02-15 03:25:30 +05:30
sriram veeraghanta 1e27e37b51 Merge pull request #3666 from makeplane/preview
release: v0.15.2-dev
2024-02-14 19:41:55 +05:30
sriram veeraghanta 7df2e9cf11 Merge pull request #3632 from makeplane/preview
release: v0.15.1-dev
2024-02-12 20:59:56 +05:30
sriram veeraghanta c6e3f1b932 Merge pull request #3535 from makeplane/preview
release: 0.15-dev
2024-02-01 15:01:49 +05:30
52 changed files with 1580 additions and 622 deletions
+2 -6
View File
@@ -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 = {
+1 -1
View File
@@ -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
)
+30 -18
View File
@@ -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,
)
+5
View File
@@ -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",
),
]
+7 -4
View File
@@ -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])
+2 -2
View File
@@ -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
)
+180 -4
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.15
Django==4.2.16
# rest framework
djangorestframework==3.15.2
# postgres
+3 -1
View File
@@ -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();
}
}
};
+15 -5
View File
@@ -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
+22 -3
View File
@@ -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;
};
+3
View File
@@ -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}
+4 -2
View File
@@ -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
+4 -4
View File
@@ -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}`);
}
-1
View File
@@ -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),
+9 -8
View File
@@ -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
+1 -1
View File
@@ -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([
+2 -1
View File
@@ -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})`
+11
View File
@@ -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) {
+11 -1
View File
@@ -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==