mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into preview
This commit is contained in:
commit
384624a21b
@ -93,6 +93,7 @@ from .page import (
|
||||
PageSerializer,
|
||||
PageLogSerializer,
|
||||
SubPageSerializer,
|
||||
PageDetailSerializer,
|
||||
PageFavoriteSerializer,
|
||||
)
|
||||
|
||||
|
@ -3,9 +3,6 @@ from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer
|
||||
from .issue import LabelLiteSerializer
|
||||
from .workspace import WorkspaceLiteSerializer
|
||||
from .project import ProjectLiteSerializer
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
PageLog,
|
||||
@ -17,22 +14,33 @@ from plane.db.models import (
|
||||
|
||||
class PageSerializer(BaseSerializer):
|
||||
is_favorite = serializers.BooleanField(read_only=True)
|
||||
label_details = LabelLiteSerializer(
|
||||
read_only=True, source="labels", many=True
|
||||
)
|
||||
labels = serializers.ListField(
|
||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||
write_only=True,
|
||||
required=False,
|
||||
)
|
||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
||||
workspace_detail = WorkspaceLiteSerializer(
|
||||
source="workspace", read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Page
|
||||
fields = "__all__"
|
||||
fields = [
|
||||
"id",
|
||||
"name",
|
||||
"owned_by",
|
||||
"access",
|
||||
"color",
|
||||
"labels",
|
||||
"parent",
|
||||
"is_favorite",
|
||||
"is_locked",
|
||||
"archived_at",
|
||||
"workspace",
|
||||
"project",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"created_by",
|
||||
"updated_by",
|
||||
"view_props",
|
||||
]
|
||||
read_only_fields = [
|
||||
"workspace",
|
||||
"project",
|
||||
@ -48,8 +56,12 @@ class PageSerializer(BaseSerializer):
|
||||
labels = validated_data.pop("labels", None)
|
||||
project_id = self.context["project_id"]
|
||||
owned_by_id = self.context["owned_by_id"]
|
||||
description_html = self.context["description_html"]
|
||||
page = Page.objects.create(
|
||||
**validated_data, project_id=project_id, owned_by_id=owned_by_id
|
||||
**validated_data,
|
||||
description_html=description_html,
|
||||
project_id=project_id,
|
||||
owned_by_id=owned_by_id,
|
||||
)
|
||||
|
||||
if labels is not None:
|
||||
@ -91,6 +103,13 @@ class PageSerializer(BaseSerializer):
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class PageDetailSerializer(PageSerializer):
|
||||
description_html = serializers.CharField()
|
||||
|
||||
class Meta(PageSerializer.Meta):
|
||||
fields = PageSerializer.Meta.fields + ["description_html"]
|
||||
|
||||
|
||||
class SubPageSerializer(BaseSerializer):
|
||||
entity_details = serializers.SerializerMethodField()
|
||||
|
||||
|
@ -31,102 +31,51 @@ urlpatterns = [
|
||||
),
|
||||
name="project-pages",
|
||||
),
|
||||
# favorite pages
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
|
||||
PageFavoriteViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="user-favorite-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/<uuid:page_id>/",
|
||||
PageFavoriteViewSet.as_view(
|
||||
{
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="user-favorite-pages",
|
||||
),
|
||||
# archived pages
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"get": "list",
|
||||
"post": "create",
|
||||
}
|
||||
),
|
||||
name="project-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"get": "retrieve",
|
||||
"patch": "partial_update",
|
||||
"delete": "destroy",
|
||||
}
|
||||
),
|
||||
name="project-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"post": "archive",
|
||||
"delete": "unarchive",
|
||||
}
|
||||
),
|
||||
name="project-page-archive",
|
||||
name="project-page-archive-unarchive",
|
||||
),
|
||||
# lock and unlock
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unarchive/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"post": "unarchive",
|
||||
}
|
||||
),
|
||||
name="project-page-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-pages/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"get": "archive_list",
|
||||
}
|
||||
),
|
||||
name="project-pages",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"post": "lock",
|
||||
"delete": "unlock",
|
||||
}
|
||||
),
|
||||
name="project-pages",
|
||||
name="project-pages-lock-unlock",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unlock/",
|
||||
PageViewSet.as_view(
|
||||
{
|
||||
"post": "unlock",
|
||||
}
|
||||
),
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
|
||||
PageLogEndpoint.as_view(),
|
||||
name="page-transactions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/<uuid:transaction>/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/<uuid:transaction>/",
|
||||
PageLogEndpoint.as_view(),
|
||||
name="page-transactions",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/sub-pages/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
|
||||
SubPagesEndpoint.as_view(),
|
||||
name="sub-page",
|
||||
),
|
||||
|
@ -24,6 +24,7 @@ from plane.db.models import (
|
||||
State,
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
Project,
|
||||
ProjectMember,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
@ -239,23 +240,23 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
# create an issue
|
||||
issue = Issue.objects.create(
|
||||
name=request.data.get("issue", {}).get("name"),
|
||||
description=request.data.get("issue", {}).get("description", {}),
|
||||
description_html=request.data.get("issue", {}).get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
priority=request.data.get("issue", {}).get("priority", "low"),
|
||||
project_id=project_id,
|
||||
state=state,
|
||||
project = Project.objects.get(pk=project_id)
|
||||
serializer = IssueCreateSerializer(
|
||||
data=request.data.get("issue"),
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"workspace_id": project.workspace_id,
|
||||
"default_assignee_id": project.default_assignee_id,
|
||||
},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# Create an Issue Activity
|
||||
issue_activity.delay(
|
||||
type="issue.activity.created",
|
||||
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
|
||||
actor_id=str(request.user.id),
|
||||
issue_id=str(issue.id),
|
||||
issue_id=str(serializer.data["id"]),
|
||||
project_id=str(project_id),
|
||||
current_instance=None,
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
@ -269,11 +270,45 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
inbox_issue = InboxIssue.objects.create(
|
||||
inbox_id=inbox_id.id,
|
||||
project_id=project_id,
|
||||
issue=issue,
|
||||
issue_id=serializer.data["id"],
|
||||
source=request.data.get("source", "in-app"),
|
||||
)
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.select_related("issue")
|
||||
.prefetch_related(
|
||||
"issue__labels",
|
||||
"issue__assignees",
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__labels__id",
|
||||
distinct=True,
|
||||
filter=~Q(issue__labels__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(issue__assignees__id__isnull=True),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
),
|
||||
)
|
||||
.get(
|
||||
inbox_id=inbox_id.id,
|
||||
issue_id=serializer.data["id"],
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
serializer = InboxIssueDetailSerializer(inbox_issue)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
def partial_update(self, request, slug, project_id, issue_id):
|
||||
inbox_id = Inbox.objects.filter(
|
||||
@ -395,6 +430,42 @@ class InboxIssueViewSet(BaseViewSet):
|
||||
issue.state = state
|
||||
issue.save()
|
||||
|
||||
inbox_issue = (
|
||||
InboxIssue.objects.select_related("issue")
|
||||
.prefetch_related(
|
||||
"issue__labels",
|
||||
"issue__assignees",
|
||||
)
|
||||
.annotate(
|
||||
label_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__labels__id",
|
||||
distinct=True,
|
||||
filter=~Q(issue__labels__id__isnull=True),
|
||||
),
|
||||
Value(
|
||||
[],
|
||||
output_field=ArrayField(UUIDField()),
|
||||
),
|
||||
),
|
||||
assignee_ids=Coalesce(
|
||||
ArrayAgg(
|
||||
"issue__assignees__id",
|
||||
distinct=True,
|
||||
filter=~Q(issue__assignees__id__isnull=True),
|
||||
),
|
||||
Value(
|
||||
[],
|
||||
output_field=ArrayField(UUIDField()),
|
||||
),
|
||||
),
|
||||
)
|
||||
.get(
|
||||
inbox_id=inbox_id.id,
|
||||
issue_id=serializer.data["id"],
|
||||
project_id=project_id,
|
||||
)
|
||||
)
|
||||
serializer = InboxIssueDetailSerializer(inbox_issue).data
|
||||
return Response(serializer, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
|
@ -1,4 +1,5 @@
|
||||
# Python imports
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
@ -17,6 +18,7 @@ from plane.app.serializers import (
|
||||
PageLogSerializer,
|
||||
PageSerializer,
|
||||
SubPageSerializer,
|
||||
PageDetailSerializer,
|
||||
)
|
||||
from plane.db.models import (
|
||||
Page,
|
||||
@ -28,6 +30,8 @@ from plane.db.models import (
|
||||
# Module imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
# Your SQL query
|
||||
@ -87,11 +91,21 @@ class PageViewSet(BaseViewSet):
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = PageSerializer(
|
||||
data=request.data,
|
||||
context={"project_id": project_id, "owned_by_id": request.user.id},
|
||||
context={
|
||||
"project_id": project_id,
|
||||
"owned_by_id": request.user.id,
|
||||
"description_html": request.data.get(
|
||||
"description_html", "<p></p>"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# capture the page transaction
|
||||
page_transaction.delay(request.data, None, serializer.data["id"])
|
||||
page = Page.objects.get(pk=serializer.data["id"])
|
||||
serializer = PageDetailSerializer(page)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@ -125,9 +139,22 @@ class PageViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
serializer = PageSerializer(page, data=request.data, partial=True)
|
||||
serializer = PageDetailSerializer(
|
||||
page, data=request.data, partial=True
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
# capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_value=request.data,
|
||||
old_value=json.dumps(
|
||||
{
|
||||
"description_html": page.description_html,
|
||||
}
|
||||
),
|
||||
page_id=pk,
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(
|
||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||
@ -140,18 +167,24 @@ class PageViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
def lock(self, request, slug, project_id, page_id):
|
||||
def retrieve(self, request, slug, project_id, pk=None):
|
||||
page = self.get_queryset().filter(pk=pk).first()
|
||||
return Response(
|
||||
PageDetailSerializer(page).data, status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
def lock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
page.is_locked = True
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def unlock(self, request, slug, project_id, page_id):
|
||||
def unlock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
).first()
|
||||
|
||||
page.is_locked = False
|
||||
@ -160,13 +193,13 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
queryset = self.get_queryset()
|
||||
pages = PageSerializer(queryset, many=True).data
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
def archive(self, request, slug, project_id, page_id):
|
||||
def archive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# only the owner or admin can archive the page
|
||||
@ -184,13 +217,16 @@ class PageViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
unarchive_archive_page_and_descendants(page_id, datetime.now())
|
||||
unarchive_archive_page_and_descendants(pk, datetime.now())
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(
|
||||
{"archived_at": str(datetime.now())},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def unarchive(self, request, slug, project_id, page_id):
|
||||
def unarchive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
)
|
||||
|
||||
# only the owner or admin can un archive the page
|
||||
@ -213,19 +249,10 @@ class PageViewSet(BaseViewSet):
|
||||
page.parent = None
|
||||
page.save(update_fields=["parent"])
|
||||
|
||||
unarchive_archive_page_and_descendants(page_id, None)
|
||||
unarchive_archive_page_and_descendants(pk, None)
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def archive_list(self, request, slug, project_id):
|
||||
pages = Page.objects.filter(
|
||||
project_id=project_id,
|
||||
workspace__slug=slug,
|
||||
).filter(archived_at__isnull=False)
|
||||
|
||||
pages = PageSerializer(pages, many=True).data
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id
|
||||
@ -269,29 +296,20 @@ class PageFavoriteViewSet(BaseViewSet):
|
||||
serializer_class = PageFavoriteSerializer
|
||||
model = PageFavorite
|
||||
|
||||
def get_queryset(self):
|
||||
return self.filter_queryset(
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(archived_at__isnull=True)
|
||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
||||
.filter(user=self.request.user)
|
||||
.select_related("page", "page__owned_by")
|
||||
def create(self, request, slug, project_id, pk):
|
||||
_ = PageFavorite.objects.create(
|
||||
project_id=project_id,
|
||||
page_id=pk,
|
||||
user=request.user,
|
||||
)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = PageFavoriteSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(user=request.user, project_id=project_id)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, slug, project_id, page_id):
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page_favorite = PageFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
page_id=page_id,
|
||||
page_id=pk,
|
||||
)
|
||||
page_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
@ -14,6 +14,7 @@ from plane.app.permissions import (
|
||||
from plane.db.models import State, Issue
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
|
||||
class StateViewSet(BaseViewSet):
|
||||
serializer_class = StateSerializer
|
||||
model = State
|
||||
@ -38,7 +39,9 @@ class StateViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="workspaces/:slug/states/", url_params=True, user=False
|
||||
)
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = StateSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
@ -59,7 +62,9 @@ class StateViewSet(BaseViewSet):
|
||||
return Response(state_dict, status=status.HTTP_200_OK)
|
||||
return Response(states, status=status.HTTP_200_OK)
|
||||
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="workspaces/:slug/states/", url_params=True, user=False
|
||||
)
|
||||
def mark_as_default(self, request, slug, project_id, pk):
|
||||
# Select all the states which are marked as default
|
||||
_ = State.objects.filter(
|
||||
@ -70,7 +75,9 @@ class StateViewSet(BaseViewSet):
|
||||
).update(default=True)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
|
||||
@invalidate_cache(
|
||||
path="workspaces/:slug/states/", url_params=True, user=False
|
||||
)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
state = State.objects.get(
|
||||
is_triage=False,
|
||||
|
@ -326,11 +326,11 @@ class IssueViewFavoriteViewSet(BaseViewSet):
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
def destroy(self, request, slug, project_id, view_id):
|
||||
view_favourite = IssueViewFavorite.objects.get(
|
||||
view_favorite = IssueViewFavorite.objects.get(
|
||||
project=project_id,
|
||||
user=request.user,
|
||||
workspace__slug=slug,
|
||||
view_id=view_id,
|
||||
)
|
||||
view_favourite.delete()
|
||||
view_favorite.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
@ -1,6 +1,6 @@
|
||||
# Python imports
|
||||
import random
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Django imports
|
||||
from django.db.models import Max
|
||||
@ -12,7 +12,6 @@ from faker import Faker
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
User,
|
||||
Project,
|
||||
ProjectMember,
|
||||
@ -27,26 +26,13 @@ from plane.db.models import (
|
||||
IssueActivity,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
Page,
|
||||
PageLabel,
|
||||
Inbox,
|
||||
InboxIssue,
|
||||
)
|
||||
|
||||
|
||||
def create_workspace_members(workspace, members):
|
||||
members = User.objects.filter(email__in=members)
|
||||
|
||||
_ = WorkspaceMember.objects.bulk_create(
|
||||
[
|
||||
WorkspaceMember(
|
||||
workspace=workspace,
|
||||
member=member,
|
||||
role=20,
|
||||
)
|
||||
for member in members
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
def create_project(workspace, user_id):
|
||||
fake = Faker()
|
||||
name = fake.name()
|
||||
@ -57,6 +43,7 @@ def create_project(workspace, user_id):
|
||||
: random.randint(2, 12 if len(name) - 1 >= 12 else len(name) - 1)
|
||||
].upper(),
|
||||
created_by_id=user_id,
|
||||
inbox_view=True,
|
||||
)
|
||||
|
||||
# Add current member as project member
|
||||
@ -244,12 +231,67 @@ def create_modules(workspace, project, user_id, module_count):
|
||||
return Module.objects.bulk_create(modules, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_pages(workspace, project, user_id, pages_count):
|
||||
fake = Faker()
|
||||
Faker.seed(0)
|
||||
|
||||
pages = []
|
||||
for _ in range(0, pages_count):
|
||||
text = fake.text(max_nb_chars=60000)
|
||||
pages.append(
|
||||
Page(
|
||||
name=fake.name(),
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
owned_by_id=user_id,
|
||||
access=random.randint(0, 1),
|
||||
color=fake.hex_color(),
|
||||
description_html=f"<p>{text}</p>",
|
||||
archived_at=None,
|
||||
is_locked=False,
|
||||
)
|
||||
)
|
||||
|
||||
return Page.objects.bulk_create(pages, ignore_conflicts=True)
|
||||
|
||||
|
||||
def create_page_labels(workspace, project, user_id, pages_count):
|
||||
# labels
|
||||
labels = Label.objects.filter(project=project).values_list("id", flat=True)
|
||||
pages = random.sample(
|
||||
list(
|
||||
Page.objects.filter(project=project).values_list("id", flat=True)
|
||||
),
|
||||
int(pages_count / 2),
|
||||
)
|
||||
|
||||
# Bulk page labels
|
||||
bulk_page_labels = []
|
||||
for page in pages:
|
||||
for label in random.sample(
|
||||
list(labels), random.randint(0, len(labels) - 1)
|
||||
):
|
||||
bulk_page_labels.append(
|
||||
PageLabel(
|
||||
page_id=page,
|
||||
label_id=label,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
)
|
||||
)
|
||||
|
||||
# Page labels
|
||||
PageLabel.objects.bulk_create(
|
||||
bulk_page_labels, batch_size=1000, ignore_conflicts=True
|
||||
)
|
||||
|
||||
|
||||
def create_issues(workspace, project, user_id, issue_count):
|
||||
fake = Faker()
|
||||
Faker.seed(0)
|
||||
|
||||
states = State.objects.values_list("id", flat=True)
|
||||
creators = ProjectMember.objects.values_list("member_id", flat=True)
|
||||
states = State.objects.filter(workspace=workspace, project=project).exclude(group="Triage").values_list("id", flat=True)
|
||||
creators = ProjectMember.objects.filter(workspace=workspace, project=project).values_list("member_id", flat=True)
|
||||
|
||||
issues = []
|
||||
|
||||
@ -283,15 +325,15 @@ def create_issues(workspace, project, user_id, issue_count):
|
||||
)
|
||||
)
|
||||
|
||||
sentence = fake.sentence()
|
||||
text = fake.text(max_nb_chars=60000)
|
||||
issues.append(
|
||||
Issue(
|
||||
state_id=states[random.randint(0, len(states) - 1)],
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
name=sentence[:254],
|
||||
description_html=f"<p>{sentence}</p>",
|
||||
description_stripped=sentence,
|
||||
name=text[:254],
|
||||
description_html=f"<p>{text}</p>",
|
||||
description_stripped=text,
|
||||
sequence_id=last_id,
|
||||
sort_order=largest_sort_order,
|
||||
start_date=start_date,
|
||||
@ -339,7 +381,35 @@ def create_issues(workspace, project, user_id, issue_count):
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
return
|
||||
return issues
|
||||
|
||||
|
||||
def create_inbox_issues(workspace, project, user_id, inbox_issue_count):
|
||||
issues = create_issues(workspace, project, user_id, inbox_issue_count)
|
||||
inbox, create = Inbox.objects.get_or_create(
|
||||
name="Inbox",
|
||||
project=project,
|
||||
is_default=True,
|
||||
)
|
||||
InboxIssue.objects.bulk_create(
|
||||
[
|
||||
InboxIssue(
|
||||
issue=issue,
|
||||
inbox=inbox,
|
||||
status=(status := [-2, -1, 0, 1, 2][random.randint(0, 4)]),
|
||||
snoozed_till=(
|
||||
datetime.now() + timedelta(days=random.randint(1, 30))
|
||||
if status == 0
|
||||
else None
|
||||
),
|
||||
source="in-app",
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
)
|
||||
for issue in issues
|
||||
],
|
||||
batch_size=100,
|
||||
)
|
||||
|
||||
|
||||
def create_issue_parent(workspace, project, user_id, issue_count):
|
||||
@ -396,7 +466,7 @@ def create_issue_assignees(workspace, project, user_id, issue_count):
|
||||
|
||||
|
||||
def create_issue_labels(workspace, project, user_id, issue_count):
|
||||
# assignees
|
||||
# labels
|
||||
labels = Label.objects.filter(project=project).values_list("id", flat=True)
|
||||
issues = random.sample(
|
||||
list(
|
||||
@ -420,7 +490,7 @@ def create_issue_labels(workspace, project, user_id, issue_count):
|
||||
)
|
||||
)
|
||||
|
||||
# Issue assignees
|
||||
# Issue labels
|
||||
IssueLabel.objects.bulk_create(
|
||||
bulk_issue_labels, batch_size=1000, ignore_conflicts=True
|
||||
)
|
||||
@ -487,16 +557,20 @@ def create_module_issues(workspace, project, user_id, issue_count):
|
||||
|
||||
@shared_task
|
||||
def create_dummy_data(
|
||||
slug, email, members, issue_count, cycle_count, module_count
|
||||
slug,
|
||||
email,
|
||||
members,
|
||||
issue_count,
|
||||
cycle_count,
|
||||
module_count,
|
||||
pages_count,
|
||||
inbox_issue_count,
|
||||
):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
user = User.objects.get(email=email)
|
||||
user_id = user.id
|
||||
|
||||
# create workspace members
|
||||
create_workspace_members(workspace=workspace, members=members)
|
||||
|
||||
# Create a project
|
||||
project = create_project(workspace=workspace, user_id=user_id)
|
||||
|
||||
@ -527,6 +601,22 @@ def create_dummy_data(
|
||||
module_count=module_count,
|
||||
)
|
||||
|
||||
# create pages
|
||||
create_pages(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
user_id=user_id,
|
||||
pages_count=pages_count,
|
||||
)
|
||||
|
||||
# create page labels
|
||||
create_page_labels(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
user_id=user_id,
|
||||
pages_count=pages_count,
|
||||
)
|
||||
|
||||
# create issues
|
||||
create_issues(
|
||||
workspace=workspace,
|
||||
@ -535,6 +625,14 @@ def create_dummy_data(
|
||||
issue_count=issue_count,
|
||||
)
|
||||
|
||||
# create inbox issues
|
||||
create_inbox_issues(
|
||||
workspace=workspace,
|
||||
project=project,
|
||||
user_id=user_id,
|
||||
inbox_issue_count=inbox_issue_count,
|
||||
)
|
||||
|
||||
# create issue parent
|
||||
create_issue_parent(
|
||||
workspace=workspace,
|
||||
|
76
apiserver/plane/bgtasks/page_transaction_task.py
Normal file
76
apiserver/plane/bgtasks/page_transaction_task.py
Normal file
@ -0,0 +1,76 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Third-party imports
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageLog
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
def extract_components(value, tag):
|
||||
try:
|
||||
mentions = []
|
||||
html = value.get("description_html")
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
mention_tags = soup.find_all(tag)
|
||||
|
||||
for mention_tag in mention_tags:
|
||||
mention = {
|
||||
"id": mention_tag.get("id"),
|
||||
"entity_identifier": mention_tag.get("entity_identifier"),
|
||||
"entity_name": mention_tag.get("entity_name"),
|
||||
}
|
||||
mentions.append(mention)
|
||||
|
||||
return mentions
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@shared_task
|
||||
def page_transaction(new_value, old_value, page_id):
|
||||
page = Page.objects.get(pk=page_id)
|
||||
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
|
||||
|
||||
old_value = json.loads(old_value)
|
||||
|
||||
new_transactions = []
|
||||
deleted_transaction_ids = set()
|
||||
|
||||
# TODO - Add "issue-embed-component", "img", "todo" components
|
||||
components = ["mention-component"]
|
||||
for component in components:
|
||||
old_mentions = extract_components(old_value, component)
|
||||
new_mentions = extract_components(new_value, component)
|
||||
|
||||
new_mentions_ids = {mention["id"] for mention in new_mentions}
|
||||
old_mention_ids = {mention["id"] for mention in old_mentions}
|
||||
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
|
||||
|
||||
new_transactions.extend(
|
||||
PageLog(
|
||||
transaction=mention["id"],
|
||||
page_id=page_id,
|
||||
entity_identifier=mention["entity_identifier"],
|
||||
entity_name=mention["entity_name"],
|
||||
workspace_id=page.workspace_id,
|
||||
project_id=page.project_id,
|
||||
created_at=timezone.now(),
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
for mention in new_mentions
|
||||
if mention["id"] not in old_mention_ids or not new_page_mention
|
||||
)
|
||||
|
||||
# Create new PageLog objects for new transactions
|
||||
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
|
||||
|
||||
# Delete the removed transactions
|
||||
PageLog.objects.filter(
|
||||
transaction__in=deleted_transaction_ids
|
||||
).delete()
|
@ -35,17 +35,6 @@ class Command(BaseCommand):
|
||||
|
||||
members = input("Enter Member emails (comma separated): ")
|
||||
members = members.split(",") if members != "" else []
|
||||
|
||||
issue_count = int(
|
||||
input("Number of issues to be created: ")
|
||||
)
|
||||
cycle_count = int(
|
||||
input("Number of cycles to be created: ")
|
||||
)
|
||||
module_count = int(
|
||||
input("Number of modules to be created: ")
|
||||
)
|
||||
|
||||
# Create workspace
|
||||
workspace = Workspace.objects.create(
|
||||
slug=workspace_slug,
|
||||
@ -56,6 +45,31 @@ class Command(BaseCommand):
|
||||
WorkspaceMember.objects.create(
|
||||
workspace=workspace, role=20, member=user
|
||||
)
|
||||
user_ids = User.objects.filter(email__in=members)
|
||||
|
||||
_ = WorkspaceMember.objects.bulk_create(
|
||||
[
|
||||
WorkspaceMember(
|
||||
workspace=workspace,
|
||||
member=user_id,
|
||||
role=20,
|
||||
)
|
||||
for user_id in user_ids
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
project_count = int(input("Number of projects to be created: "))
|
||||
|
||||
for i in range(project_count):
|
||||
print(f"Please provide the following details for project {i+1}:")
|
||||
issue_count = int(input("Number of issues to be created: "))
|
||||
cycle_count = int(input("Number of cycles to be created: "))
|
||||
module_count = int(input("Number of modules to be created: "))
|
||||
pages_count = int(input("Number of pages to be created: "))
|
||||
inbox_issue_count = int(
|
||||
input("Number of inbox issues to be created: ")
|
||||
)
|
||||
|
||||
from plane.bgtasks.dummy_data_task import create_dummy_data
|
||||
|
||||
@ -66,6 +80,8 @@ class Command(BaseCommand):
|
||||
issue_count=issue_count,
|
||||
cycle_count=cycle_count,
|
||||
module_count=module_count,
|
||||
pages_count=pages_count,
|
||||
inbox_issue_count=inbox_issue_count,
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
|
20
apiserver/plane/db/migrations/0064_auto_20240409_1134.py
Normal file
20
apiserver/plane/db/migrations/0064_auto_20240409_1134.py
Normal file
@ -0,0 +1,20 @@
|
||||
# Generated by Django 4.2.10 on 2024-04-09 11:34
|
||||
|
||||
from django.db import migrations, models
|
||||
import plane.db.models.page
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0063_state_is_triage_alter_state_group'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="page",
|
||||
name="view_props",
|
||||
field=models.JSONField(
|
||||
default=plane.db.models.page.get_view_props
|
||||
),
|
||||
),
|
||||
]
|
@ -9,6 +9,10 @@ from . import ProjectBaseModel
|
||||
from plane.utils.html_processor import strip_tags
|
||||
|
||||
|
||||
def get_view_props():
|
||||
return {"full_width": False}
|
||||
|
||||
|
||||
class Page(ProjectBaseModel):
|
||||
name = models.CharField(max_length=255)
|
||||
description = models.JSONField(default=dict, blank=True)
|
||||
@ -35,6 +39,7 @@ class Page(ProjectBaseModel):
|
||||
)
|
||||
archived_at = models.DateField(null=True)
|
||||
is_locked = models.BooleanField(default=False)
|
||||
view_props = models.JSONField(default=get_view_props)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Page"
|
||||
@ -81,7 +86,7 @@ class PageLog(ProjectBaseModel):
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.page.name} {self.type}"
|
||||
return f"{self.page.name} {self.entity_name}"
|
||||
|
||||
|
||||
class PageBlock(ProjectBaseModel):
|
||||
|
@ -28,6 +28,7 @@
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/ui": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.13",
|
||||
@ -39,6 +40,7 @@
|
||||
"@tiptap/extension-task-list": "^2.1.13",
|
||||
"@tiptap/extension-text-style": "^2.1.13",
|
||||
"@tiptap/extension-underline": "^2.1.13",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
"@tiptap/starter-kit": "^2.1.13",
|
||||
|
40
packages/editor/core/src/helpers/scroll-to-node.ts
Normal file
40
packages/editor/core/src/helpers/scroll-to-node.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
export interface IMarking {
|
||||
type: "heading";
|
||||
level: number;
|
||||
text: string;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
function findNthH1(editor: Editor, n: number, level: number): number {
|
||||
let count = 0;
|
||||
let pos = 0;
|
||||
editor.state.doc.descendants((node, position) => {
|
||||
if (node.type.name === "heading" && node.attrs.level === level) {
|
||||
count++;
|
||||
if (count === n) {
|
||||
pos = position;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
});
|
||||
return pos;
|
||||
}
|
||||
|
||||
function scrollToNode(editor: Editor, pos: number): void {
|
||||
const headingNode = editor.state.doc.nodeAt(pos);
|
||||
if (headingNode) {
|
||||
const headingDOM = editor.view.nodeDOM(pos);
|
||||
if (headingDOM instanceof HTMLElement) {
|
||||
headingDOM.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scrollSummary(editor: Editor, marking: IMarking) {
|
||||
if (editor) {
|
||||
const pos = findNthH1(editor, marking.sequence, marking.level);
|
||||
scrollToNode(editor, pos);
|
||||
}
|
||||
}
|
@ -1,66 +1,67 @@
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useState } from "react";
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
|
||||
import { CoreEditorProps } from "src/ui/props";
|
||||
import { CoreEditorExtensions } from "src/ui/extensions";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { getTrimmedHTML } from "src/lib/utils";
|
||||
import { DeleteImage } from "src/types/delete-image";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { RestoreImage } from "src/types/restore-image";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position";
|
||||
import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items";
|
||||
import { EditorRefApi } from "src/types/editor-ref-api";
|
||||
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
|
||||
|
||||
interface CustomEditorProps {
|
||||
id?: string;
|
||||
uploadFile: UploadImage;
|
||||
restoreFile: RestoreImage;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
deleteFile: DeleteImage;
|
||||
cancelUploadImage?: () => any;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
value: string;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
onStart?: (json: any, html: string) => void;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
initialValue: string;
|
||||
editorClassName: string;
|
||||
// undefined when prop is not passed, null if intentionally passed to stop
|
||||
// swr syncing
|
||||
value: string | null | undefined;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: any;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const useEditor = ({
|
||||
uploadFile,
|
||||
id = "",
|
||||
deleteFile,
|
||||
cancelUploadImage,
|
||||
editorProps = {},
|
||||
initialValue,
|
||||
editorClassName,
|
||||
value,
|
||||
rerenderOnPropsChange,
|
||||
extensions = [],
|
||||
onStart,
|
||||
onChange,
|
||||
setIsSubmitting,
|
||||
forwardedRef,
|
||||
restoreFile,
|
||||
setShouldShowAlert,
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
}: CustomEditorProps) => {
|
||||
const editor = useCustomEditor(
|
||||
{
|
||||
const editor = useCustomEditor({
|
||||
editorProps: {
|
||||
...CoreEditorProps(uploadFile, setIsSubmitting),
|
||||
...CoreEditorProps(uploadFile, editorClassName),
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [
|
||||
...CoreEditorExtensions(
|
||||
{
|
||||
mentionSuggestions: mentionSuggestions ?? [],
|
||||
mentionHighlights: mentionHighlights ?? [],
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights ?? [],
|
||||
},
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
@ -68,28 +69,37 @@ export const useEditor = ({
|
||||
),
|
||||
...extensions,
|
||||
],
|
||||
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
onCreate: async ({ editor }) => {
|
||||
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: async () => {
|
||||
handleEditorReady?.(true);
|
||||
},
|
||||
onTransaction: async ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
},
|
||||
onUpdate: async ({ editor }) => {
|
||||
setIsSubmitting?.("submitting");
|
||||
setShouldShowAlert?.(true);
|
||||
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
||||
},
|
||||
onDestroy: async () => {
|
||||
handleEditorReady?.(false);
|
||||
},
|
||||
[rerenderOnPropsChange]
|
||||
);
|
||||
});
|
||||
|
||||
// for syncing swr data on tab refocus etc, can remove it once this is merged
|
||||
// https://github.com/ueberdosis/tiptap/pull/4453
|
||||
useEffect(() => {
|
||||
// value is null when intentionally passed where syncing is not yet
|
||||
// supported and value is undefined when the data from swr is not populated
|
||||
if (value === null || value === undefined) return;
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(value);
|
||||
}, [editor, value, id]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
() => ({
|
||||
clearEditor: () => {
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
@ -101,11 +111,68 @@ export const useEditor = ({
|
||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
||||
}
|
||||
},
|
||||
}));
|
||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
||||
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
|
||||
|
||||
const item = getEditorMenuItem(itemName);
|
||||
if (item) {
|
||||
if (item.name === "image") {
|
||||
item.command(savedSelection);
|
||||
} else {
|
||||
item.command();
|
||||
}
|
||||
} else {
|
||||
console.warn(`No command found for item: ${itemName}`);
|
||||
}
|
||||
},
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
|
||||
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
|
||||
|
||||
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
|
||||
const item = getEditorMenuItem(itemName);
|
||||
return item ? item.isActive() : false;
|
||||
},
|
||||
onStateChange: (callback: () => void) => {
|
||||
// Subscribe to editor state changes
|
||||
editorRef.current?.on("transaction", () => {
|
||||
callback();
|
||||
});
|
||||
// Return a function to unsubscribe to the continuous transactions of
|
||||
// the editor on unmounting the component that has subscribed to this
|
||||
// method
|
||||
return () => {
|
||||
editorRef.current?.off("transaction");
|
||||
};
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
},
|
||||
setFocusAtPosition: (position: number) => {
|
||||
if (!editorRef.current) return;
|
||||
editorRef.current
|
||||
.chain()
|
||||
.insertContentAt(position, [{ type: "paragraph" }])
|
||||
.focus()
|
||||
.run();
|
||||
},
|
||||
}),
|
||||
[editorRef, savedSelection, uploadFile]
|
||||
);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// the editorRef is used to access the editor instance from outside the hook
|
||||
// and should only be used after editor is initialized
|
||||
editorRef.current = editor;
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
@ -1,53 +1,61 @@
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
||||
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
|
||||
import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions";
|
||||
import { CoreReadOnlyEditorProps } from "src/ui/read-only/props";
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { EditorReadOnlyRefApi } from "src/types/editor-ref-api";
|
||||
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
|
||||
import { IMentionHighlight } from "src/types/mention-suggestion";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
value: string;
|
||||
forwardedRef?: any;
|
||||
initialValue: string;
|
||||
editorClassName: string;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
}
|
||||
|
||||
export const useReadOnlyEditor = ({
|
||||
value,
|
||||
initialValue,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
extensions = [],
|
||||
editorProps = {},
|
||||
rerenderOnPropsChange,
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
}: CustomReadOnlyEditorProps) => {
|
||||
const editor = useCustomEditor(
|
||||
{
|
||||
const editor = useCustomEditor({
|
||||
editable: false,
|
||||
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps,
|
||||
...CoreReadOnlyEditorProps(editorClassName),
|
||||
...editorProps,
|
||||
},
|
||||
onCreate: async () => {
|
||||
handleEditorReady?.(true);
|
||||
},
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
mentionSuggestions: mentionSuggestions ?? [],
|
||||
mentionHighlights: mentionHighlights ?? [],
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
onDestroy: () => {
|
||||
handleEditorReady?.(false);
|
||||
},
|
||||
[rerenderOnPropsChange]
|
||||
);
|
||||
});
|
||||
|
||||
// for syncing swr data on tab refocus etc
|
||||
useEffect(() => {
|
||||
if (initialValue === null || initialValue === undefined) return;
|
||||
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
|
||||
}, [editor, initialValue]);
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: () => {
|
||||
@ -56,11 +64,20 @@ export const useReadOnlyEditor = ({
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
},
|
||||
getMarkDown: (): string => {
|
||||
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
|
||||
return markdownOutput;
|
||||
},
|
||||
scrollSummary: (marking: IMarking): void => {
|
||||
if (!editorRef.current) return;
|
||||
scrollSummary(editorRef.current, marking);
|
||||
},
|
||||
}));
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
editorRef.current = editor;
|
||||
return editor;
|
||||
};
|
||||
|
@ -26,6 +26,7 @@ export * from "src/lib/editor-commands";
|
||||
// types
|
||||
export type { DeleteImage } from "src/types/delete-image";
|
||||
export type { UploadImage } from "src/types/upload-image";
|
||||
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";
|
||||
export type { RestoreImage } from "src/types/restore-image";
|
||||
export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion";
|
||||
|
@ -1,21 +1,22 @@
|
||||
import { Editor, Range } from "@tiptap/core";
|
||||
import { startImageUpload } from "src/ui/plugins/upload-image";
|
||||
import { findTableAncestor } from "src/lib/utils";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
|
||||
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 1 }).run();
|
||||
else editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 2 }).run();
|
||||
else editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||
};
|
||||
|
||||
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 3 }).run();
|
||||
else editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run();
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||
};
|
||||
|
||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||
@ -37,10 +38,10 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
// Check if code block is active then toggle code block
|
||||
if (editor.isActive("codeBlock")) {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().clearNodes().toggleCodeBlock().run();
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -49,32 +50,32 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
||||
|
||||
if (isSelectionEmpty) {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().clearNodes().toggleCodeBlock().run();
|
||||
editor.chain().focus().toggleCodeBlock().run();
|
||||
} else {
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).clearNodes().toggleCode().run();
|
||||
editor.chain().focus().deleteRange(range).toggleCode().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().clearNodes().toggleCode().run();
|
||||
editor.chain().focus().toggleCode().run();
|
||||
}
|
||||
};
|
||||
|
||||
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleOrderedList().run();
|
||||
else editor.chain().focus().clearNodes().toggleOrderedList().run();
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
else editor.chain().focus().toggleOrderedList().run();
|
||||
};
|
||||
|
||||
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBulletList().run();
|
||||
else editor.chain().focus().clearNodes().toggleBulletList().run();
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
else editor.chain().focus().toggleBulletList().run();
|
||||
};
|
||||
|
||||
export const toggleTaskList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleTaskList().run();
|
||||
else editor.chain().focus().clearNodes().toggleTaskList().run();
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
else editor.chain().focus().toggleTaskList().run();
|
||||
};
|
||||
|
||||
export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||
@ -83,13 +84,14 @@ export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||
};
|
||||
|
||||
export const toggleBlockquote = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBlockquote().run();
|
||||
else editor.chain().focus().clearNodes().toggleBlockquote().run();
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||
else editor.chain().focus().toggleBlockquote().run();
|
||||
};
|
||||
|
||||
export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
if (typeof window !== "undefined") {
|
||||
const selection: any = window?.getSelection();
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
if (selection.rangeCount !== 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
if (findTableAncestor(range.startContainer)) {
|
||||
@ -97,6 +99,7 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
|
||||
};
|
||||
@ -112,7 +115,7 @@ export const setLinkEditor = (editor: Editor, url: string) => {
|
||||
export const insertImageCommand = (
|
||||
editor: Editor,
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||
savedSelection?: Selection | null,
|
||||
range?: Range
|
||||
) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).run();
|
||||
@ -122,8 +125,8 @@ export const insertImageCommand = (
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
|
||||
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
|
||||
startImageUpload(file, editor.view, pos, uploadFile);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
|
@ -4,15 +4,17 @@ import { twMerge } from "tailwind-merge";
|
||||
interface EditorClassNames {
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
containerClassName?: string;
|
||||
}
|
||||
|
||||
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) =>
|
||||
export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassName }: EditorClassNames) =>
|
||||
cn(
|
||||
"relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md",
|
||||
noBorder ? "" : "border border-custom-border-200",
|
||||
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0",
|
||||
customClassName
|
||||
"w-full max-w-full sm:rounded-lg focus:outline-none focus:border-0",
|
||||
{
|
||||
"border border-custom-border-200": !noBorder,
|
||||
"focus:border border-custom-border-300": borderOnFocus,
|
||||
},
|
||||
containerClassName
|
||||
);
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
|
@ -7,10 +7,17 @@
|
||||
}
|
||||
|
||||
/* block quotes */
|
||||
.ProseMirror blockquote {
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
border-left: 3px solid rgb(var(--color-border-300));
|
||||
}
|
||||
|
||||
.ProseMirror blockquote p::before,
|
||||
.ProseMirror blockquote p::after {
|
||||
display: none;
|
||||
}
|
||||
/* end block quotes */
|
||||
|
||||
.ProseMirror code::before,
|
||||
.ProseMirror code::after {
|
||||
@ -28,8 +35,8 @@
|
||||
/* Custom image styles */
|
||||
.ProseMirror img {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
@ -37,22 +44,52 @@
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid #5abbf7;
|
||||
outline: 3px solid rgba(var(--color-primary-100));
|
||||
filter: brightness(90%);
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
/* Custom list item styles */
|
||||
|
||||
/* Custom gap cursor styles */
|
||||
.ProseMirror-gapcursor::after {
|
||||
border-top: 1px solid rgb(var(--color-text-100)) !important;
|
||||
}
|
||||
|
||||
/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
|
||||
|
||||
ul[data-type="taskList"] li {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.2rem;
|
||||
margin: 0.1rem 0.15rem 0 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
border: 1px solid rgba(var(--color-border-300)) !important;
|
||||
outline: none;
|
||||
border-radius: 2px;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"]:hover {
|
||||
background-color: rgba(var(--color-background-80)) !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"]:checked {
|
||||
background-color: rgba(var(--color-primary-100)) !important;
|
||||
border-color: rgba(var(--color-primary-100)) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
|
||||
background-color: rgba(var(--color-primary-300)) !important;
|
||||
border-color: rgba(var(--color-primary-300)) !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.5rem;
|
||||
@ -60,6 +97,7 @@ ul[data-type="taskList"] li > label {
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
position: relative;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: rgb(var(--color-background-100));
|
||||
@ -71,8 +109,6 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
border: 1.5px solid rgb(var(--color-text-100));
|
||||
margin-right: 0.2rem;
|
||||
margin-top: 0.15rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(var(--color-background-80));
|
||||
@ -82,24 +118,28 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
background-color: rgb(var(--color-background-90));
|
||||
}
|
||||
|
||||
/* check sign */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
transform: scale(0);
|
||||
transform-origin: center;
|
||||
transition: 120ms transform ease-in-out;
|
||||
box-shadow: inset 1em 1em;
|
||||
transform-origin: center;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
transform: scale(1);
|
||||
transform: scale(1) translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
color: rgb(var(--color-text-200));
|
||||
color: rgb(var(--color-text-400));
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
@ -133,12 +173,12 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
.fade-in {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.fadeOut {
|
||||
.fade-out {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
@ -149,7 +189,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
|
||||
&:before {
|
||||
&::before {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
@ -175,21 +215,13 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.ProseMirror table * p {
|
||||
padding: 0px 1px;
|
||||
margin: 6px 2px;
|
||||
}
|
||||
|
||||
.ProseMirror table * .is-empty::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
background: rgba(var(--color-background-80));
|
||||
border-radius: 0.5rem;
|
||||
color: rgba(var(--color-text-100));
|
||||
font-family: "JetBrainsMono", monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
font-family: JetBrainsMono, monospace;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.ProseMirror pre code {
|
||||
@ -214,3 +246,107 @@ div[data-type="horizontalRule"] {
|
||||
.moveable-control-box {
|
||||
z-index: 10 !important;
|
||||
}
|
||||
|
||||
/* Cursor styles for the inline code blocks */
|
||||
@keyframes blink {
|
||||
49% {
|
||||
border-color: unset;
|
||||
}
|
||||
|
||||
50% {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
99% {
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.no-cursor {
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
div:focus .fake-cursor,
|
||||
span:focus .fake-cursor {
|
||||
margin-right: -1px;
|
||||
border-left-width: 1.5px;
|
||||
border-left-style: solid;
|
||||
animation: blink 1s;
|
||||
animation-iteration-count: infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* number, bulleted and to-do lists */
|
||||
.prose ol:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
|
||||
.prose
|
||||
ul:not([data-type="taskList"]):where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
|
||||
.prose ul[data-type="taskList"]:where(.prose > :first-child) {
|
||||
margin-top: 0.25rem !important;
|
||||
margin-bottom: 1px !important;
|
||||
}
|
||||
|
||||
.prose ol:not(:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))),
|
||||
.prose
|
||||
ul:not([data-type="taskList"]):not(
|
||||
:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))
|
||||
),
|
||||
.prose ul[data-type="taskList"]:not(:where(.prose > :first-child)) {
|
||||
margin-top: calc(0.25rem + 3px) !important;
|
||||
margin-bottom: 1px !important;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ol ul:not([data-type="taskList"]),
|
||||
ul:not([data-type="taskList"]) ul:not([data-type="taskList"]),
|
||||
ul:not([data-type="taskList"]) ol {
|
||||
margin-top: 0.45rem !important;
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] ul[data-type="taskList"] {
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
/* end number, bulleted and to-do lists */
|
||||
|
||||
/* tailwind typography */
|
||||
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 4px;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 1.4rem;
|
||||
margin-bottom: 1px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1px;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 1px;
|
||||
padding: 3px 2px;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
|
||||
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.prose :where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* end tailwind typography */
|
||||
|
@ -1,23 +1,25 @@
|
||||
.tableWrapper {
|
||||
.table-wrapper {
|
||||
overflow-x: auto;
|
||||
padding: 2px;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.tableWrapper table {
|
||||
.table-wrapper table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0;
|
||||
margin-bottom: 1rem;
|
||||
border: 2px solid rgba(var(--color-border-300));
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tableWrapper table td,
|
||||
.tableWrapper table th {
|
||||
.table-wrapper table p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.table-wrapper table td,
|
||||
.table-wrapper table th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
padding: 10px 15px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
@ -29,86 +31,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper table td > *,
|
||||
.tableWrapper table th > * {
|
||||
.table-wrapper table td > *,
|
||||
.table-wrapper table th > * {
|
||||
margin: 0 !important;
|
||||
padding: 0.25rem 0 !important;
|
||||
}
|
||||
|
||||
.tableWrapper table td.has-focus,
|
||||
.tableWrapper table th.has-focus {
|
||||
.table-wrapper table td.has-focus,
|
||||
.table-wrapper table th.has-focus {
|
||||
box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important;
|
||||
}
|
||||
|
||||
.tableWrapper table th {
|
||||
font-weight: bold;
|
||||
.table-wrapper table th {
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
background-color: #d9e4ff;
|
||||
color: #171717;
|
||||
background-color: rgba(var(--color-background-90));
|
||||
}
|
||||
|
||||
.tableWrapper table th * {
|
||||
font-weight: 600;
|
||||
.table-wrapper table .selectedCell {
|
||||
border-color: rgba(var(--color-primary-100));
|
||||
}
|
||||
|
||||
.tableWrapper table .selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.colorPicker {
|
||||
display: grid;
|
||||
padding: 8px 8px;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.colorPickerLabel {
|
||||
font-size: 0.85rem;
|
||||
color: #6b7280;
|
||||
padding: 8px 8px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.colorPickerItem {
|
||||
margin: 2px 0px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.divider {
|
||||
background-color: #e5e7eb;
|
||||
height: 1px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
|
||||
.tableWrapper table .column-resize-handle {
|
||||
/* table dropdown */
|
||||
.table-wrapper table .column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
z-index: 5;
|
||||
background-color: #d9e4ff;
|
||||
background-color: rgba(var(--color-primary-100));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls {
|
||||
.table-wrapper .table-controls {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .columnsControl,
|
||||
.tableWrapper .tableControls .rowsControl {
|
||||
.table-wrapper .table-controls .columns-control,
|
||||
.table-wrapper .table-controls .rows-control {
|
||||
transition: opacity ease-in 100ms;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
@ -117,124 +78,50 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .columnsControl {
|
||||
.table-wrapper .table-controls .columns-control {
|
||||
height: 20px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
|
||||
.table-wrapper .table-controls .columns-control .columns-control-div {
|
||||
color: white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
width: 30px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .rowsControl {
|
||||
.table-wrapper .table-controls .rows-control {
|
||||
width: 20px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
|
||||
.table-wrapper .table-controls .rows-control .rows-control-div {
|
||||
color: white;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
|
||||
height: 30px;
|
||||
width: 15px;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .rowsControlDiv {
|
||||
background-color: #d9e4ff;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border-radius: 2px;
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
transform ease-out 100ms,
|
||||
background-color ease-out 100ms;
|
||||
outline: none;
|
||||
box-shadow: #000 0px 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .columnsControlDiv {
|
||||
background-color: #d9e4ff;
|
||||
border: 1px solid rgba(var(--color-border-200));
|
||||
border-radius: 2px;
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
transform ease-out 100ms,
|
||||
background-color ease-out 100ms;
|
||||
outline: none;
|
||||
box-shadow: #000 0px 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tableWrapper .tableControls .tableToolbox,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox {
|
||||
border: 1px solid rgba(var(--color-border-300));
|
||||
background-color: rgba(var(--color-background-100));
|
||||
border-radius: 5px;
|
||||
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
padding: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: max-content;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem {
|
||||
background-color: rgba(var(--color-background-100));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border: none;
|
||||
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
|
||||
.table-wrapper .table-controls .rows-control-div,
|
||||
.table-wrapper .table-controls .columns-control-div {
|
||||
background-color: rgba(var(--color-background-80));
|
||||
border: 0.5px solid rgba(var(--color-border-200));
|
||||
border-radius: 4px;
|
||||
background-size: 1.25rem;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
transition:
|
||||
transform ease-out 100ms,
|
||||
background-color ease-out 100ms;
|
||||
outline: none;
|
||||
box-shadow: rgba(var(--color-shadow-2xs));
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
|
||||
background-color: rgba(var(--color-background-80), 0.6);
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
|
||||
padding: 4px 0px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.tableToolbox {
|
||||
background-color: rgba(var(--color-background-100));
|
||||
}
|
||||
|
||||
.tableWrapper .tableControls .tableToolbox .toolboxItem .label,
|
||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label {
|
||||
font-size: 0.85rem;
|
||||
color: rgba(var(--color-text-300));
|
||||
}
|
||||
|
||||
.resize-cursor .tableWrapper .tableControls .rowsControl,
|
||||
.tableWrapper.controls--disabled .tableControls .rowsControl,
|
||||
.resize-cursor .tableWrapper .tableControls .columnsControl,
|
||||
.tableWrapper.controls--disabled .tableControls .columnsControl {
|
||||
.resize-cursor .table-wrapper .table-controls .rows-control,
|
||||
.table-wrapper.controls--disabled .table-controls .rows-control,
|
||||
.resize-cursor .table-wrapper .table-controls .columns-control,
|
||||
.table-wrapper.controls--disabled .table-controls .columns-control {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
17
packages/editor/core/src/types/editor-ref-api.ts
Normal file
17
packages/editor/core/src/types/editor-ref-api.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { IMarking } from "src/helpers/scroll-to-node";
|
||||
import { EditorMenuItemNames } from "src/ui/menus/menu-items";
|
||||
|
||||
export type EditorReadOnlyRefApi = {
|
||||
getMarkDown: () => string;
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
scrollSummary: (marking: IMarking) => void;
|
||||
};
|
||||
|
||||
export interface EditorRefApi extends EditorReadOnlyRefApi {
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
|
||||
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
|
||||
onStateChange: (callback: () => void) => () => void;
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
}
|
@ -1,10 +1,18 @@
|
||||
import { Editor, Range } from "@tiptap/react";
|
||||
export type IMentionSuggestion = {
|
||||
id: string;
|
||||
type: string;
|
||||
entity_name: string;
|
||||
entity_identifier: string;
|
||||
avatar: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
|
||||
export type CommandProps = {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
};
|
||||
|
||||
export type IMentionHighlight = string;
|
||||
|
@ -4,13 +4,13 @@ import { cn } from "src/lib/utils";
|
||||
|
||||
interface EditorContainerProps {
|
||||
editor: Editor | null;
|
||||
editorClassNames: string;
|
||||
editorContainerClassName: string;
|
||||
children: ReactNode;
|
||||
hideDragHandle?: () => void;
|
||||
}
|
||||
|
||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const { editor, editorClassNames, hideDragHandle, children } = props;
|
||||
const { editor, editorContainerClassName, hideDragHandle, children } = props;
|
||||
|
||||
const handleContainerClick = () => {
|
||||
if (!editor) return;
|
||||
@ -51,10 +51,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
<div
|
||||
id="editor-container"
|
||||
onClick={handleContainerClick}
|
||||
onMouseLeave={() => {
|
||||
hideDragHandle?.();
|
||||
}}
|
||||
className={cn(`cursor-text`, { "active-editor": editor?.isFocused && editor?.isEditable }, editorClassNames)}
|
||||
onMouseLeave={hideDragHandle}
|
||||
className={cn(
|
||||
"cursor-text relative",
|
||||
{
|
||||
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||
},
|
||||
editorContainerClassName
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
@ -4,22 +4,15 @@ import { ImageResizer } from "src/ui/extensions/image/image-resize";
|
||||
|
||||
interface EditorContentProps {
|
||||
editor: Editor | null;
|
||||
editorContentCustomClassNames: string | undefined;
|
||||
children?: ReactNode;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||
const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
|
||||
const { editor, tabIndex, children } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`contentEditor ${editorContentCustomClassNames}`}
|
||||
tabIndex={tabIndex}
|
||||
onFocus={() => {
|
||||
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
|
||||
}}
|
||||
>
|
||||
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||
{children}
|
||||
|
@ -32,7 +32,8 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
|
||||
class:
|
||||
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm",
|
||||
spellcheck: "false",
|
||||
},
|
||||
};
|
||||
|
@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
// we just have ts support for now
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
interface CodeBlockComponentProps {
|
||||
node: ProseMirrorNode;
|
||||
}
|
||||
|
||||
export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(node.textContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
} catch (error) {
|
||||
setCopied(false);
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="code-block relative">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group absolute top-2 right-2 z-10 flex items-center justify-center w-8 h-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out",
|
||||
{
|
||||
"bg-green-500/10 hover:bg-green-500/10 active:bg-green-500/10": copied,
|
||||
}
|
||||
)}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="h-3 w-3 text-green-500" strokeWidth={3} />
|
||||
) : (
|
||||
<CopyIcon className="h-3 w-3 text-custom-text-300 group-hover:text-custom-text-100" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4">
|
||||
<NodeViewContent as="code" />
|
||||
</pre>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
@ -7,8 +7,14 @@ const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { CodeBlockComponent } from "./code-block-node-view";
|
||||
|
||||
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CodeBlockComponent);
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
|
@ -0,0 +1,9 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import codemark from "prosemirror-codemark";
|
||||
|
||||
export const CustomCodeMarkPlugin = Extension.create({
|
||||
name: "codemarkPlugin",
|
||||
addProseMirrorPlugins() {
|
||||
return codemark({ markType: this.editor.schema.marks.code });
|
||||
},
|
||||
});
|
@ -0,0 +1,363 @@
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
|
||||
import { Node, NodeType } from "@tiptap/pm/model";
|
||||
|
||||
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let currentNode = null;
|
||||
let currentDepth = $from.depth;
|
||||
let currentPos = $from.pos;
|
||||
let targetDepth: number | null = null;
|
||||
|
||||
while (currentDepth > 0 && targetDepth === null) {
|
||||
currentNode = $from.node(currentDepth);
|
||||
|
||||
if (currentNode.type === nodeType) {
|
||||
targetDepth = currentDepth;
|
||||
} else {
|
||||
currentDepth -= 1;
|
||||
currentPos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDepth === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
|
||||
};
|
||||
|
||||
const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth > listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNextListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
const pos = listItemPos.$pos;
|
||||
|
||||
// Adjust the position to ensure we're within the list item, especially for edge cases
|
||||
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
|
||||
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList") {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract 1 from the calculated depth to get the parent list's depth
|
||||
// This adjustment is necessary because the depth calculation includes the current list
|
||||
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
|
||||
depth = depth > 0 ? depth - 1 : 0;
|
||||
|
||||
// Double the depth value to get results as 2, 4, 6, 8, etc.
|
||||
depth = depth * 2;
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
|
||||
// this is required to still handle the undo handling
|
||||
if (editor.commands.undoInputRule()) {
|
||||
return true;
|
||||
}
|
||||
// Check if a node range is selected, and if so, fall back to default backspace functionality
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from !== to) {
|
||||
// A range is selected, not just a cursor position; fall back to default behavior
|
||||
return false; // Let the editor handle backspace by default
|
||||
}
|
||||
|
||||
// if the current item is NOT inside a list item &
|
||||
// the previous item is a list (orderedList or bulletList)
|
||||
// move the cursor into the list and delete the current item
|
||||
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
|
||||
const { $anchor } = editor.state.selection;
|
||||
|
||||
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
|
||||
|
||||
const listDescendants: Array<{ node: Node; pos: number }> = [];
|
||||
|
||||
$listPos.node().descendants((node, pos) => {
|
||||
if (node.type.name === name) {
|
||||
listDescendants.push({ node, pos });
|
||||
}
|
||||
});
|
||||
|
||||
const lastItem = listDescendants.at(-1);
|
||||
|
||||
if (!lastItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
|
||||
|
||||
// Check if positions are within the valid range
|
||||
const startPos = $anchor.start() - 1;
|
||||
const endPos = $anchor.end() + 1;
|
||||
if (startPos < 0 || endPos > editor.state.doc.content.size) {
|
||||
return false; // Invalid position, abort operation
|
||||
}
|
||||
|
||||
return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run();
|
||||
}
|
||||
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the start of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false;
|
||||
}
|
||||
const isParaSibling = isCurrentParagraphASibling(editor.state);
|
||||
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
|
||||
const listItemPos = findListItemPos(name, editor.state);
|
||||
const nextListItemIsSibling = nextListIsSibling(name, editor.state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentNode = listItemPos.$pos.node(listItemPos.depth);
|
||||
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist) {
|
||||
editor.chain().liftListItem(name).run();
|
||||
return editor.commands.joinItemBackward();
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist && nextListItemIsSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasListItemBefore(name, editor.state)) {
|
||||
return editor.chain().liftListItem(name).run();
|
||||
}
|
||||
|
||||
if (!currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// otherwise in the end, a backspace should
|
||||
// always just lift the list item if
|
||||
// joining / merging is not possible
|
||||
return editor.chain().liftListItem(name).run();
|
||||
};
|
||||
|
||||
export const handleDelete = (editor: Editor, name: string) => {
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the end of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtEndOfNode(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the next node is a list with a deeper depth
|
||||
if (nextListIsDeeper(name, editor.state)) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus(editor.state.selection.from + 4)
|
||||
.lift(name)
|
||||
.joinBackward()
|
||||
.run();
|
||||
}
|
||||
|
||||
if (nextListIsHigher(name, editor.state)) {
|
||||
return editor.chain().joinForward().joinBackward().run();
|
||||
}
|
||||
|
||||
return editor.commands.joinItemForward();
|
||||
};
|
||||
|
||||
const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
|
||||
const { $anchor } = editorState.selection;
|
||||
|
||||
const previousNodePos = Math.max(0, $anchor.pos - 2);
|
||||
|
||||
const previousNode = editorState.doc.resolve(previousNodePos).node();
|
||||
|
||||
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const prevListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getPrevListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextListIsSibling = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth === listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let hasSubList = false;
|
||||
|
||||
node.descendants((child) => {
|
||||
if (child.type === nodeType) {
|
||||
hasSubList = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasSubList;
|
||||
};
|
||||
|
||||
const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
const { $from } = state.selection;
|
||||
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
|
||||
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === "paragraph") {
|
||||
paragraphNodesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// If there are more than one paragraph nodes, the current paragraph is a sibling.
|
||||
return paragraphNodesCount > 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export function isCursorInSubList(editor: Editor) {
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
|
||||
// Check if the current node is a list item
|
||||
const listItem = editor.schema.nodes.listItem;
|
||||
|
||||
// Traverse up the document tree from the current position
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type === listItem) {
|
||||
// If the parent of the list item is also a list, it's a sub-list
|
||||
const parent = $from.node(depth - 1);
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection;
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - 2);
|
||||
|
||||
if ($targetPos.index() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
@ -1,30 +0,0 @@
|
||||
import { getNodeType } from "@tiptap/core";
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let currentNode = null;
|
||||
let currentDepth = $from.depth;
|
||||
let currentPos = $from.pos;
|
||||
let targetDepth: number | null = null;
|
||||
|
||||
while (currentDepth > 0 && targetDepth === null) {
|
||||
currentNode = $from.node(currentDepth);
|
||||
|
||||
if (currentNode.type === nodeType) {
|
||||
targetDepth = currentDepth;
|
||||
} else {
|
||||
currentDepth -= 1;
|
||||
currentPos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDepth === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
|
||||
};
|
@ -1,16 +0,0 @@
|
||||
import { getNodeAtPosition } from "@tiptap/core";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
|
||||
|
||||
export const getNextListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
|
||||
|
||||
return depth;
|
||||
};
|
@ -1,66 +0,0 @@
|
||||
import { Editor, isAtStartOfNode, isNodeActive } from "@tiptap/core";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
|
||||
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
|
||||
import { hasListBefore } from "src/ui/extensions/custom-list-keymap/list-helpers/has-list-before";
|
||||
|
||||
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
|
||||
// this is required to still handle the undo handling
|
||||
if (editor.commands.undoInputRule()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// if the cursor is not at the start of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the current item is NOT inside a list item &
|
||||
// the previous item is a list (orderedList or bulletList)
|
||||
// move the cursor into the list and delete the current item
|
||||
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
|
||||
const { $anchor } = editor.state.selection;
|
||||
|
||||
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
|
||||
|
||||
const listDescendants: Array<{ node: Node; pos: number }> = [];
|
||||
|
||||
$listPos.node().descendants((node, pos) => {
|
||||
if (node.type.name === name) {
|
||||
listDescendants.push({ node, pos });
|
||||
}
|
||||
});
|
||||
|
||||
const lastItem = listDescendants.at(-1);
|
||||
|
||||
if (!lastItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
|
||||
|
||||
return editor
|
||||
.chain()
|
||||
.cut({ from: $anchor.start() - 1, to: $anchor.end() + 1 }, $lastItemPos.end())
|
||||
.joinForward()
|
||||
.run();
|
||||
}
|
||||
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const listItemPos = findListItemPos(name, editor.state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if current node is a list item and cursor it at start of a list node,
|
||||
// simply lift the list item i.e. remove it as a list item (task/bullet/ordered)
|
||||
// irrespective of above node being a list or not
|
||||
return editor.chain().liftListItem(name).run();
|
||||
};
|
@ -1,34 +0,0 @@
|
||||
import { Editor, isAtEndOfNode, isNodeActive } from "@tiptap/core";
|
||||
|
||||
import { nextListIsDeeper } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper";
|
||||
import { nextListIsHigher } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher";
|
||||
|
||||
export const handleDelete = (editor: Editor, name: string) => {
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the end of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtEndOfNode(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the next node is a list with a deeper depth
|
||||
if (nextListIsDeeper(name, editor.state)) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus(editor.state.selection.from + 4)
|
||||
.lift(name)
|
||||
.joinBackward()
|
||||
.run();
|
||||
}
|
||||
|
||||
if (nextListIsHigher(name, editor.state)) {
|
||||
return editor.chain().joinForward().joinBackward().run();
|
||||
}
|
||||
|
||||
return editor.commands.joinItemForward();
|
||||
};
|
@ -1,15 +0,0 @@
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
export const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
|
||||
const { $anchor } = editorState.selection;
|
||||
|
||||
const previousNodePos = Math.max(0, $anchor.pos - 2);
|
||||
|
||||
const previousNode = editorState.doc.resolve(previousNodePos).node();
|
||||
|
||||
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection;
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2);
|
||||
|
||||
if ($targetPos.index() === $targetPos.parent.childCount - 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($targetPos.nodeAfter?.type.name !== typeOrName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
@ -1,17 +0,0 @@
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection;
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - 2);
|
||||
|
||||
if ($targetPos.index() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
@ -1,9 +0,0 @@
|
||||
export * from "./find-list-item-pos";
|
||||
export * from "./get-next-list-depth";
|
||||
export * from "./handle-backspace";
|
||||
export * from "./handle-delete";
|
||||
export * from "./has-list-before";
|
||||
export * from "./has-list-item-after";
|
||||
export * from "./has-list-item-before";
|
||||
export * from "./next-list-is-deeper";
|
||||
export * from "./next-list-is-higher";
|
@ -1,19 +0,0 @@
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
|
||||
import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth";
|
||||
|
||||
export const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth > listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
@ -1,19 +0,0 @@
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
|
||||
import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth";
|
||||
|
||||
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
@ -29,6 +29,22 @@ export const ListKeymap = Extension.create<ListKeymapOptions>({
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.commands.sinkListItem("listItem")) {
|
||||
return true;
|
||||
} else if (this.editor.commands.sinkListItem("taskItem")) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
"Shift-Tab": () => {
|
||||
if (this.editor.commands.liftListItem("listItem")) {
|
||||
return true;
|
||||
} else if (this.editor.commands.liftListItem("taskItem")) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
let handled = false;
|
||||
|
||||
|
@ -7,10 +7,19 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
||||
if (imageInfo) {
|
||||
const selection = editor.state.selection;
|
||||
|
||||
// Use the style width/height if available, otherwise fall back to the element's natural width/height
|
||||
const width = imageInfo.style.width
|
||||
? Number(imageInfo.style.width.replace("px", ""))
|
||||
: imageInfo.getAttribute("width");
|
||||
const height = imageInfo.style.height
|
||||
? Number(imageInfo.style.height.replace("px", ""))
|
||||
: imageInfo.getAttribute("height");
|
||||
|
||||
editor.commands.setImage({
|
||||
src: imageInfo.src,
|
||||
width: Number(imageInfo.style.width.replace("px", "")),
|
||||
height: Number(imageInfo.style.height.replace("px", "")),
|
||||
width: width,
|
||||
height: height,
|
||||
} as any);
|
||||
editor.commands.setNodeSelection(selection.from);
|
||||
}
|
||||
@ -21,7 +30,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
return (
|
||||
<>
|
||||
<Moveable
|
||||
target={document.querySelector(".ProseMirror-selectednode") as any}
|
||||
target={document.querySelector(".ProseMirror-selectednode") as HTMLElement}
|
||||
container={null}
|
||||
origin={false}
|
||||
edge={false}
|
||||
@ -37,27 +46,29 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
||||
setAspectRatio(originalWidth / originalHeight);
|
||||
}
|
||||
}}
|
||||
onResize={({ target, width, height, delta }: any) => {
|
||||
onResize={({ target, width, height, delta }) => {
|
||||
if (delta[0] || delta[1]) {
|
||||
let newWidth, newHeight;
|
||||
if (delta[0]) {
|
||||
const newWidth = Math.max(width, 100);
|
||||
const newHeight = newWidth / aspectRatio;
|
||||
target!.style.width = `${newWidth}px`;
|
||||
target!.style.height = `${newHeight}px`;
|
||||
// Width change detected
|
||||
newWidth = Math.max(width, 100);
|
||||
newHeight = newWidth / aspectRatio;
|
||||
} else if (delta[1]) {
|
||||
// Height change detected
|
||||
newHeight = Math.max(height, 100);
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
if (delta[1]) {
|
||||
const newHeight = Math.max(height, 100);
|
||||
const newWidth = newHeight * aspectRatio;
|
||||
target!.style.height = `${newHeight}px`;
|
||||
target!.style.width = `${newWidth}px`;
|
||||
target.style.width = `${newWidth}px`;
|
||||
target.style.height = `${newHeight}px`;
|
||||
}
|
||||
}}
|
||||
onResizeEnd={() => {
|
||||
updateMediaSize();
|
||||
}}
|
||||
scalable
|
||||
renderDirections={["w", "e"]}
|
||||
onScale={({ target, transform }: any) => {
|
||||
target!.style.transform = transform;
|
||||
renderDirections={["se"]}
|
||||
onScale={({ target, transform }) => {
|
||||
target.style.transform = transform;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
@ -22,17 +21,18 @@ import { CustomKeymap } from "src/ui/extensions/keymap";
|
||||
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||
|
||||
import { DeleteImage } from "src/types/delete-image";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { RestoreImage } from "src/types/restore-image";
|
||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||
import { CustomCodeMarkPlugin } from "./custom-code-inline/inline-code-plugin";
|
||||
|
||||
export const CoreEditorExtensions = (
|
||||
mentionConfig: {
|
||||
mentionSuggestions: IMentionSuggestion[];
|
||||
mentionHighlights: string[];
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
},
|
||||
deleteFile: DeleteImage,
|
||||
restoreFile: RestoreImage,
|
||||
@ -41,17 +41,17 @@ export const CoreEditorExtensions = (
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc list-outside leading-3 -mt-2",
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal list-outside leading-3 -mt-2",
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "leading-normal -mb-2",
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
@ -60,14 +60,17 @@ export const CoreEditorExtensions = (
|
||||
blockquote: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
width: 1,
|
||||
},
|
||||
}),
|
||||
CustomQuoteExtension.configure({
|
||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||
}),
|
||||
// BulletList,
|
||||
// OrderedList,
|
||||
// ListItem,
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
HTMLAttributes: {
|
||||
class: "my-4",
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
ListKeymap,
|
||||
@ -85,33 +88,40 @@ export const CoreEditorExtensions = (
|
||||
CustomTypographyExtension,
|
||||
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2",
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "flex items-start my-4",
|
||||
class: "flex",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeMarkPlugin,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: true,
|
||||
transformPastedText: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
|
||||
Mentions({
|
||||
mentionSuggestions: mentionConfig.mentionSuggestions,
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: false,
|
||||
}),
|
||||
];
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
import { canJoin } from "@tiptap/pm/transform";
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
@ -12,6 +15,51 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
|
||||
if (!tr.isGeneric) return false;
|
||||
|
||||
// Find all ranges where we might want to join.
|
||||
const ranges: Array<number> = [];
|
||||
for (let i = 0; i < tr.mapping.maps.length; i++) {
|
||||
const map = tr.mapping.maps[i];
|
||||
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]);
|
||||
map.forEach((_s, _e, from, to) => ranges.push(from, to));
|
||||
}
|
||||
|
||||
// Figure out which joinable points exist inside those ranges,
|
||||
// by checking all node boundaries in their parent nodes.
|
||||
const joinable = [];
|
||||
for (let i = 0; i < ranges.length; i += 2) {
|
||||
const from = ranges[i],
|
||||
to = ranges[i + 1];
|
||||
const $from = tr.doc.resolve(from),
|
||||
depth = $from.sharedDepth(to),
|
||||
parent = $from.node(depth);
|
||||
for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) {
|
||||
const after = parent.maybeChild(index);
|
||||
if (!after) break;
|
||||
if (index && joinable.indexOf(pos) == -1) {
|
||||
const before = parent.child(index - 1);
|
||||
if (before.type == after.type && before.type === nodeType) joinable.push(pos);
|
||||
}
|
||||
pos += after.nodeSize;
|
||||
}
|
||||
}
|
||||
|
||||
let joined = false;
|
||||
|
||||
// Join the joinable points
|
||||
joinable.sort((a, b) => a - b);
|
||||
for (let i = joinable.length - 1; i >= 0; i--) {
|
||||
if (canJoin(tr.doc, joinable[i])) {
|
||||
newTr.join(joinable[i]);
|
||||
joined = true;
|
||||
}
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
export const CustomKeymap = Extension.create({
|
||||
name: "CustomKeymap",
|
||||
|
||||
@ -32,6 +80,42 @@ export const CustomKeymap = Extension.create({
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("ordered-list-merging"),
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
// Create a new transaction.
|
||||
const newTr = newState.tr;
|
||||
|
||||
let joined = false;
|
||||
for (const transaction of transactions) {
|
||||
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]);
|
||||
joined = anotherJoin || joined;
|
||||
}
|
||||
if (joined) {
|
||||
return newTr;
|
||||
}
|
||||
},
|
||||
}),
|
||||
new Plugin({
|
||||
key: new PluginKey("unordered-list-merging"),
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
// Create a new transaction.
|
||||
const newTr = newState.tr;
|
||||
|
||||
let joined = false;
|
||||
for (const transaction of transactions) {
|
||||
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]);
|
||||
joined = anotherJoin || joined;
|
||||
}
|
||||
if (joined) {
|
||||
return newTr;
|
||||
}
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
"Mod-a": ({ editor }) => {
|
||||
|
@ -1,10 +1,10 @@
|
||||
export const icons = {
|
||||
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
|
||||
insertLeftTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
length={12}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
@ -15,7 +15,7 @@ export const icons = {
|
||||
`,
|
||||
insertRightTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
length={12}
|
||||
viewBox="0 -960 960 960"
|
||||
>
|
||||
<path
|
||||
@ -35,8 +35,8 @@ export const icons = {
|
||||
/>
|
||||
</svg>
|
||||
`,
|
||||
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
|
||||
insertBottomTableIcon: `<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
length={24}
|
||||
|
@ -20,7 +20,7 @@ export function tableControls() {
|
||||
mousemove: (view, event) => {
|
||||
const pluginState = key.getState(view.state);
|
||||
|
||||
if (!(event.target as HTMLElement).closest(".tableWrapper") && pluginState.values.hoveredTable) {
|
||||
if (!(event.target as HTMLElement).closest(".table-wrapper") && pluginState.values.hoveredTable) {
|
||||
return view.dispatch(
|
||||
view.state.tr.setMeta(key, {
|
||||
setHoveredTable: null,
|
||||
@ -34,7 +34,7 @@ export function tableControls() {
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!pos) return;
|
||||
if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return;
|
||||
|
||||
const table = findParentNode((node) => node.type.name === "table")(
|
||||
TextSelection.create(view.state.doc, pos.pos)
|
||||
|
@ -177,7 +177,7 @@ const rowsToolboxItems: ToolboxItem[] = [
|
||||
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||
},
|
||||
{
|
||||
label: "Delete Row",
|
||||
label: "Delete row",
|
||||
icon: icons.deleteRow,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(),
|
||||
},
|
||||
@ -189,7 +189,7 @@ function createToolbox({
|
||||
tippyOptions,
|
||||
onSelectColor,
|
||||
onClickItem,
|
||||
colors = {},
|
||||
colors,
|
||||
}: {
|
||||
triggerButton: Element | null;
|
||||
items: ToolboxItem[];
|
||||
@ -202,38 +202,44 @@ function createToolbox({
|
||||
const toolbox = tippy(triggerButton, {
|
||||
content: h(
|
||||
"div",
|
||||
{ className: "tableToolbox" },
|
||||
items.map((item, index) => {
|
||||
{
|
||||
className:
|
||||
"rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg min-w-[12rem] whitespace-nowrap",
|
||||
},
|
||||
items.map((item) => {
|
||||
if (item.label === "Pick color") {
|
||||
return h("div", { className: "flex flex-col" }, [
|
||||
h("div", { className: "divider" }),
|
||||
h("div", { className: "colorPickerLabel" }, item.label),
|
||||
h("hr", { className: "my-2 border-custom-border-200" }),
|
||||
h("div", { className: "text-custom-text-200 text-sm" }, item.label),
|
||||
h(
|
||||
"div",
|
||||
{ className: "colorPicker grid" },
|
||||
{ className: "grid grid-cols-6 gap-x-1 gap-y-2.5 mt-2" },
|
||||
Object.entries(colors).map(([colorName, colorValue]) =>
|
||||
h("div", {
|
||||
className: "colorPickerItem flex items-center justify-center",
|
||||
style: `background-color: ${colorValue.backgroundColor};
|
||||
color: ${colorValue.textColor || "inherit"};`,
|
||||
className: "grid place-items-center size-6 rounded cursor-pointer",
|
||||
style: `background-color: ${colorValue.backgroundColor};color: ${colorValue.textColor || "inherit"};`,
|
||||
innerHTML:
|
||||
colorValue.icon ?? `<span class="text-md" style:"color: ${colorValue.backgroundColor}>A</span>`,
|
||||
onClick: () => onSelectColor(colorValue),
|
||||
})
|
||||
)
|
||||
),
|
||||
h("div", { className: "divider" }),
|
||||
h("hr", { className: "my-2 border-custom-border-200" }),
|
||||
]);
|
||||
} else {
|
||||
return h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
className:
|
||||
"flex items-center gap-2 px-1 py-1.5 bg-custom-background-100 hover:bg-custom-background-80 text-sm text-custom-text-200 rounded cursor-pointer",
|
||||
itemType: "div",
|
||||
onClick: () => onClickItem(item),
|
||||
},
|
||||
[
|
||||
h("div", { className: "iconContainer", innerHTML: item.icon }),
|
||||
h("span", {
|
||||
className: "h-3 w-3 flex-shrink-0",
|
||||
innerHTML: item.icon,
|
||||
}),
|
||||
h("div", { className: "label" }, item.label),
|
||||
]
|
||||
);
|
||||
@ -290,27 +296,27 @@ export class TableView implements NodeView {
|
||||
if (editor.isEditable) {
|
||||
this.rowsControl = h(
|
||||
"div",
|
||||
{ className: "rowsControl" },
|
||||
{ className: "rows-control" },
|
||||
h("div", {
|
||||
itemType: "button",
|
||||
className: "rowsControlDiv",
|
||||
className: "rows-control-div",
|
||||
onClick: () => this.selectRow(),
|
||||
})
|
||||
);
|
||||
|
||||
this.columnsControl = h(
|
||||
"div",
|
||||
{ className: "columnsControl" },
|
||||
{ className: "columns-control" },
|
||||
h("div", {
|
||||
itemType: "button",
|
||||
className: "columnsControlDiv",
|
||||
className: "columns-control-div",
|
||||
onClick: () => this.selectColumn(),
|
||||
})
|
||||
);
|
||||
|
||||
this.controls = h(
|
||||
"div",
|
||||
{ className: "tableControls", contentEditable: "false" },
|
||||
{ className: "table-controls", contentEditable: "false" },
|
||||
this.rowsControl,
|
||||
this.columnsControl
|
||||
);
|
||||
@ -331,7 +337,7 @@ export class TableView implements NodeView {
|
||||
};
|
||||
|
||||
this.columnsToolbox = createToolbox({
|
||||
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
||||
triggerButton: this.columnsControl.querySelector(".columns-control-div"),
|
||||
items: columnsToolboxItems,
|
||||
colors: columnColors,
|
||||
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
||||
@ -380,7 +386,7 @@ export class TableView implements NodeView {
|
||||
this.root = h(
|
||||
"div",
|
||||
{
|
||||
className: "tableWrapper controls--disabled",
|
||||
className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled",
|
||||
},
|
||||
this.controls,
|
||||
this.table
|
||||
|
@ -5,7 +5,7 @@ import { MentionNodeView } from "src/ui/mentions/mention-node-view";
|
||||
import { IMentionHighlight } from "src/types/mention-suggestion";
|
||||
|
||||
export interface CustomMentionOptions extends MentionOptions {
|
||||
mentionHighlights: IMentionHighlight[];
|
||||
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
@ -32,6 +32,12 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||
redirect_uri: {
|
||||
default: "/",
|
||||
},
|
||||
entity_identifier: {
|
||||
default: null,
|
||||
},
|
||||
entity_name: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@ -43,17 +49,6 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
|
||||
return [
|
||||
{
|
||||
tag: "mention-component",
|
||||
getAttrs: (node: string | HTMLElement) => {
|
||||
if (typeof node === "string") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: node.getAttribute("data-mention-id") || "",
|
||||
target: node.getAttribute("data-mention-target") || "",
|
||||
label: node.innerText.slice(1) || "",
|
||||
redirect_uri: node.getAttribute("redirect_uri"),
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
|
@ -1,15 +1,90 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { Suggestion } from "src/ui/mentions/suggestion";
|
||||
import { CustomMention } from "src/ui/mentions/custom";
|
||||
import { IMentionHighlight } from "src/types/mention-suggestion";
|
||||
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) =>
|
||||
import { MentionList } from "src/ui/mentions/mention-list";
|
||||
|
||||
export const Mentions = ({
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
readonly,
|
||||
}: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
readonly: boolean;
|
||||
}) =>
|
||||
CustomMention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
},
|
||||
readonly: readonly,
|
||||
mentionHighlights: mentionHighlights,
|
||||
suggestion: Suggestion(mentionSuggestions),
|
||||
suggestion: {
|
||||
// @ts-expect-error - Tiptap types are incorrect
|
||||
render: () => {
|
||||
if (!mentionSuggestions) return;
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
component = new ReactRenderer(MentionList, {
|
||||
props: { ...props, mentionSuggestions },
|
||||
editor: props.editor,
|
||||
});
|
||||
props.editor.storage.mentionsOpen = true;
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
component?.updateProps(props);
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
// @ts-expect-error - Tippy types are incorrect
|
||||
component?.ref?.onKeyDown(props);
|
||||
event?.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
||||
props.editor.storage.mentionsOpen = false;
|
||||
popup?.[0].destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
@ -1,36 +1,106 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { cn } from "src/lib/utils";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { Avatar } from "@plane/ui";
|
||||
|
||||
interface MentionListProps {
|
||||
items: IMentionSuggestion[];
|
||||
command: (item: { id: string; label: string; target: string; redirect_uri: string }) => void;
|
||||
command: (item: {
|
||||
id: string;
|
||||
label: string;
|
||||
entity_name: string;
|
||||
entity_identifier: string;
|
||||
target: string;
|
||||
redirect_uri: string;
|
||||
}) => void;
|
||||
query: string;
|
||||
editor: Editor;
|
||||
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/display-name
|
||||
export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
const { query, mentionSuggestions } = props;
|
||||
const [items, setItems] = useState<IMentionSuggestion[]>([]);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSuggestions = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const suggestions = await mentionSuggestions();
|
||||
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
|
||||
const transactionId = uuidv4();
|
||||
return {
|
||||
...suggestion,
|
||||
id: transactionId,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredSuggestions = mappedSuggestions.filter((suggestion) =>
|
||||
suggestion.title.toLowerCase().startsWith(query.toLowerCase())
|
||||
);
|
||||
|
||||
setItems(filteredSuggestions);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch suggestions:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSuggestions();
|
||||
}, [query, mentionSuggestions]);
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
const item = props.items[index];
|
||||
try {
|
||||
const item = items[index];
|
||||
|
||||
if (item) {
|
||||
props.command({
|
||||
id: item.id,
|
||||
label: item.title,
|
||||
entity_identifier: item.entity_identifier,
|
||||
entity_name: item.entity_name,
|
||||
target: "users",
|
||||
redirect_uri: item.redirect_uri,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error selecting item:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const commandListContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainer?.current;
|
||||
|
||||
const item = container?.children[selectedIndex] as HTMLElement;
|
||||
|
||||
if (item && container) updateScrollView(container, item);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
|
||||
const containerHeight = container.offsetHeight;
|
||||
const itemHeight = item ? item.offsetHeight : 0;
|
||||
|
||||
const top = item.offsetTop;
|
||||
const bottom = top + itemHeight;
|
||||
|
||||
if (top < container.scrollTop) {
|
||||
container.scrollTop -= container.scrollTop - top + 5;
|
||||
} else if (bottom > containerHeight + container.scrollTop) {
|
||||
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
|
||||
}
|
||||
};
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
@ -39,7 +109,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [props.items]);
|
||||
}, [items]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
@ -62,38 +132,33 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||
},
|
||||
}));
|
||||
|
||||
return props.items && props.items.length !== 0 ? (
|
||||
<div className="mentions absolute max-h-40 w-48 space-y-0.5 overflow-y-auto rounded-md bg-custom-background-100 p-1 text-sm text-custom-text-300 shadow-custom-shadow-sm">
|
||||
{props.items.length ? (
|
||||
props.items.map((item, index) => (
|
||||
return (
|
||||
<div
|
||||
ref={commandListContainer}
|
||||
className="mentions absolute max-h-48 min-w-[12rem] rounded-md bg-custom-background-100 border-[0.5px] border-custom-border-300 px-2 py-2.5 text-xs shadow-custom-shadow-rg overflow-y-scroll"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-center text-custom-text-400">Loading...</div>
|
||||
) : items.length ? (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded p-1 hover:bg-custom-background-80 ${
|
||||
index === selectedIndex ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
|
||||
{
|
||||
"bg-custom-background-80": index === selectedIndex,
|
||||
}
|
||||
)}
|
||||
onClick={() => selectItem(index)}
|
||||
>
|
||||
<div className="grid h-4 w-4 flex-shrink-0 place-items-center overflow-hidden">
|
||||
{item.avatar && item.avatar.trim() !== "" ? (
|
||||
<img src={item.avatar} className="h-full w-full rounded-sm object-cover" alt={item.title} />
|
||||
) : (
|
||||
<div className="grid h-full w-full place-items-center rounded-sm bg-gray-700 text-xs capitalize text-white">
|
||||
{item.title[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow space-y-1 truncate">
|
||||
<p className="truncate text-sm font-medium">{item.title}</p>
|
||||
{/* <p className="text-xs text-gray-400">{item.subtitle}</p> */}
|
||||
</div>
|
||||
<Avatar name={item?.title} src={item?.avatar} />
|
||||
<span className="flex-grow truncate">{item.title}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="item">No result</div>
|
||||
<div className="text-center text-custom-text-400">No results</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -4,11 +4,21 @@ import { NodeViewWrapper } from "@tiptap/react";
|
||||
import { cn } from "src/lib/utils";
|
||||
import { useRouter } from "next/router";
|
||||
import { IMentionHighlight } from "src/types/mention-suggestion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// eslint-disable-next-line import/no-anonymous-default-export
|
||||
export const MentionNodeView = (props) => {
|
||||
const router = useRouter();
|
||||
const highlights = props.extension.options.mentionHighlights as IMentionHighlight[];
|
||||
const [highlightsState, setHighlightsState] = useState<IMentionHighlight[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.extension.options.mentionHighlights) return;
|
||||
const hightlights = async () => {
|
||||
const userId = await props.extension.options.mentionHighlights();
|
||||
setHighlightsState(userId);
|
||||
};
|
||||
hightlights();
|
||||
}, [props.extension.options]);
|
||||
|
||||
const handleClick = () => {
|
||||
if (!props.extension.options.readonly) {
|
||||
@ -20,13 +30,12 @@ export const MentionNodeView = (props) => {
|
||||
<NodeViewWrapper className="mention-component inline w-fit">
|
||||
<span
|
||||
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
|
||||
"bg-yellow-500/20 text-yellow-500": highlights ? highlights.includes(props.node.attrs.id) : false,
|
||||
"bg-yellow-500/20 text-yellow-500": highlightsState
|
||||
? highlightsState.includes(props.node.attrs.entity_identifier)
|
||||
: false,
|
||||
"cursor-pointer": !props.extension.options.readonly,
|
||||
// "hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
|
||||
})}
|
||||
onClick={handleClick}
|
||||
data-mention-target={props.node.attrs.target}
|
||||
data-mention-id={props.node.attrs.id}
|
||||
>
|
||||
@{props.node.attrs.label}
|
||||
</span>
|
||||
|
@ -1,66 +1,17 @@
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import tippy from "tippy.js";
|
||||
|
||||
import { MentionList } from "src/ui/mentions/mention-list";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
|
||||
export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
||||
items: ({ query }: { query: string }) =>
|
||||
suggestions.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
|
||||
render: () => {
|
||||
let reactRenderer: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
|
||||
export const getSuggestionItems =
|
||||
(suggestions: IMentionSuggestion[]) =>
|
||||
({ query }: { query: string }) => {
|
||||
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
|
||||
const transactionId = uuidv4();
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
props.editor.storage.mentionsOpen = true;
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
// @ts-ignore
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
reactRenderer?.updateProps(props);
|
||||
|
||||
popup &&
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect: props.clientRect,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0].hide();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
|
||||
|
||||
if (navigationKeys.includes(props.event.key)) {
|
||||
// @ts-ignore
|
||||
reactRenderer?.ref?.onKeyDown(props);
|
||||
event?.stopPropagation();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
|
||||
props.editor.storage.mentionsOpen = false;
|
||||
popup?.[0].destroy();
|
||||
reactRenderer?.destroy();
|
||||
},
|
||||
...suggestion,
|
||||
id: transactionId,
|
||||
};
|
||||
},
|
||||
});
|
||||
return mappedSuggestions
|
||||
.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase()))
|
||||
.slice(0, 5);
|
||||
};
|
||||
|
@ -33,6 +33,7 @@ import {
|
||||
} from "src/lib/editor-commands";
|
||||
import { LucideIconType } from "src/types/lucide-icon";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
name: string;
|
||||
@ -41,104 +42,142 @@ export interface EditorMenuItem {
|
||||
icon: LucideIconType;
|
||||
}
|
||||
|
||||
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const HeadingOneItem = (editor: Editor) =>
|
||||
({
|
||||
name: "H1",
|
||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||
command: () => toggleHeadingOne(editor),
|
||||
icon: Heading1,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const HeadingTwoItem = (editor: Editor) =>
|
||||
({
|
||||
name: "H2",
|
||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||
command: () => toggleHeadingTwo(editor),
|
||||
icon: Heading2,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const HeadingThreeItem = (editor: Editor) =>
|
||||
({
|
||||
name: "H3",
|
||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||
command: () => toggleHeadingThree(editor),
|
||||
icon: Heading3,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const BoldItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const BoldItem = (editor: Editor) =>
|
||||
({
|
||||
name: "bold",
|
||||
isActive: () => editor?.isActive("bold"),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const ItalicItem = (editor: Editor) =>
|
||||
({
|
||||
name: "italic",
|
||||
isActive: () => editor?.isActive("italic"),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const UnderLineItem = (editor: Editor) =>
|
||||
({
|
||||
name: "underline",
|
||||
isActive: () => editor?.isActive("underline"),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const StrikeThroughItem = (editor: Editor) =>
|
||||
({
|
||||
name: "strike",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const BulletListItem = (editor: Editor) =>
|
||||
({
|
||||
name: "bullet-list",
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const TodoListItem = (editor: Editor) =>
|
||||
({
|
||||
name: "To-do List",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const CodeItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const CodeItem = (editor: Editor) =>
|
||||
({
|
||||
name: "code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const NumberedListItem = (editor: Editor) =>
|
||||
({
|
||||
name: "ordered-list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const QuoteItem = (editor: Editor) =>
|
||||
({
|
||||
name: "quote",
|
||||
isActive: () => editor?.isActive("blockquote"),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: QuoteIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const TableItem = (editor: Editor): EditorMenuItem => ({
|
||||
export const TableItem = (editor: Editor) =>
|
||||
({
|
||||
name: "table",
|
||||
isActive: () => editor?.isActive("table"),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableIcon,
|
||||
});
|
||||
}) as const satisfies EditorMenuItem;
|
||||
|
||||
export const ImageItem = (
|
||||
editor: Editor,
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
): EditorMenuItem => ({
|
||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
||||
({
|
||||
name: "image",
|
||||
isActive: () => editor?.isActive("image"),
|
||||
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
|
||||
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
|
||||
icon: ImageIcon,
|
||||
});
|
||||
}) as const;
|
||||
|
||||
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
|
||||
if (!editor) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
HeadingThreeItem(editor),
|
||||
BoldItem(editor),
|
||||
ItalicItem(editor),
|
||||
UnderLineItem(editor),
|
||||
StrikeThroughItem(editor),
|
||||
BulletListItem(editor),
|
||||
TodoListItem(editor),
|
||||
CodeItem(editor),
|
||||
NumberedListItem(editor),
|
||||
QuoteItem(editor),
|
||||
TableItem(editor),
|
||||
ImageItem(editor, uploadFile),
|
||||
];
|
||||
}
|
||||
|
||||
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
|
||||
? U extends { name: infer N }
|
||||
? N
|
||||
: never
|
||||
: never;
|
||||
|
@ -57,10 +57,7 @@ export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
|
||||
export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
const resStatus = await deleteImage(assetUrlWithWorkspaceId);
|
||||
if (resStatus === 204) {
|
||||
console.log("Image deleted successfully");
|
||||
}
|
||||
await deleteImage(assetUrlWithWorkspaceId);
|
||||
} catch (error) {
|
||||
console.error("Error deleting image: ", error);
|
||||
}
|
||||
@ -69,10 +66,7 @@ export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Prom
|
||||
export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
|
||||
try {
|
||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||
const resStatus = await restoreImage(assetUrlWithWorkspaceId);
|
||||
if (resStatus === 204) {
|
||||
console.log("Image restored successfully");
|
||||
}
|
||||
await restoreImage(assetUrlWithWorkspaceId);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ export const UploadImagesPlugin = (cancelUploadImage?: () => any) =>
|
||||
const placeholder = document.createElement("div");
|
||||
placeholder.setAttribute("class", "img-placeholder");
|
||||
const image = document.createElement("img");
|
||||
image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300");
|
||||
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
|
||||
image.src = src;
|
||||
placeholder.appendChild(image);
|
||||
|
||||
@ -73,13 +73,7 @@ const removePlaceholder = (view: EditorView, id: {}) => {
|
||||
view.dispatch(removePlaceholderTr);
|
||||
};
|
||||
|
||||
export async function startImageUpload(
|
||||
file: File,
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) {
|
||||
export async function startImageUpload(file: File, view: EditorView, pos: number, uploadFile: UploadImage) {
|
||||
if (!file) {
|
||||
alert("No file selected. Please select a file to upload.");
|
||||
return;
|
||||
@ -120,7 +114,7 @@ export async function startImageUpload(
|
||||
return;
|
||||
};
|
||||
|
||||
setIsSubmitting?.("submitting");
|
||||
// setIsSubmitting?.("submitting");
|
||||
|
||||
try {
|
||||
const src = await UploadImageHandler(file, uploadFile);
|
||||
@ -134,6 +128,7 @@ export async function startImageUpload(
|
||||
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
|
||||
|
||||
view.dispatch(transaction);
|
||||
view.focus();
|
||||
} catch (error) {
|
||||
console.error("Upload error: ", error);
|
||||
removePlaceholder(view, id);
|
||||
|
@ -1,15 +1,15 @@
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { findTableAncestor } from "src/lib/utils";
|
||||
import { cn, findTableAncestor } from "src/lib/utils";
|
||||
import { UploadImage } from "src/types/upload-image";
|
||||
import { startImageUpload } from "src/ui/plugins/upload-image";
|
||||
|
||||
export function CoreEditorProps(
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
): EditorProps {
|
||||
export function CoreEditorProps(uploadFile: UploadImage, editorClassName: string): EditorProps {
|
||||
return {
|
||||
attributes: {
|
||||
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
|
||||
class: cn(
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
|
||||
editorClassName
|
||||
),
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_view, event) => {
|
||||
@ -36,7 +36,7 @@ export function CoreEditorProps(
|
||||
event.preventDefault();
|
||||
const file = event.clipboardData.files[0];
|
||||
const pos = view.state.selection.from;
|
||||
startImageUpload(file, view, pos, uploadFile, setIsSubmitting);
|
||||
startImageUpload(file, view, pos, uploadFile);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -50,7 +50,7 @@ export function CoreEditorProps(
|
||||
top: event.clientY,
|
||||
});
|
||||
if (coordinates) {
|
||||
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
|
||||
startImageUpload(file, view, coordinates.pos - 1, uploadFile);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
@ -14,7 +14,7 @@ import { TableRow } from "src/ui/extensions/table/table-row/table-row";
|
||||
import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image";
|
||||
import { isValidHttpUrl } from "src/lib/utils";
|
||||
import { Mentions } from "src/ui/mentions";
|
||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||
import { IMentionHighlight } from "src/types/mention-suggestion";
|
||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||
@ -23,23 +23,22 @@ import { CustomCodeBlockExtension } from "src/ui/extensions/code";
|
||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
mentionSuggestions: IMentionSuggestion[];
|
||||
mentionHighlights: string[];
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
}) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc list-outside leading-3 -mt-2",
|
||||
class: "list-disc pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal list-outside leading-3 -mt-2",
|
||||
class: "list-decimal pl-7 space-y-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "leading-normal -mb-2",
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
code: false,
|
||||
@ -49,11 +48,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
dropcursor: false,
|
||||
gapcursor: false,
|
||||
}),
|
||||
CustomQuoteExtension.configure({
|
||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
||||
HTMLAttributes: { class: "my-4" },
|
||||
}),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
@ -69,7 +66,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
CustomTypographyExtension,
|
||||
ReadOnlyImageExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
TiptapUnderline,
|
||||
@ -77,16 +74,20 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
Color,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2",
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "flex items-start my-4",
|
||||
class: "flex pointer-events-none",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4",
|
||||
},
|
||||
}),
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
@ -96,5 +97,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
|
||||
Mentions({
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: true,
|
||||
}),
|
||||
];
|
||||
|
@ -1,7 +1,11 @@
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
export const CoreReadOnlyEditorProps: EditorProps = {
|
||||
export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({
|
||||
attributes: {
|
||||
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
|
||||
class: cn(
|
||||
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
|
||||
editorClassName
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
@ -1,33 +1,30 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { IMarking } from "src/types/editor-types";
|
||||
|
||||
export const useEditorMarkings = () => {
|
||||
const [markings, setMarkings] = useState<IMarking[]>([]);
|
||||
|
||||
const updateMarkings = (json: any) => {
|
||||
const nodes = json.content as any[];
|
||||
const updateMarkings = useCallback((html: string) => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const headings = doc.querySelectorAll("h1, h2, h3");
|
||||
const tempMarkings: IMarking[] = [];
|
||||
let h1Sequence: number = 0;
|
||||
let h2Sequence: number = 0;
|
||||
let h3Sequence: number = 0;
|
||||
if (nodes) {
|
||||
nodes.forEach((node) => {
|
||||
if (
|
||||
node.type === "heading" &&
|
||||
(node.attrs.level === 1 || node.attrs.level === 2 || node.attrs.level === 3) &&
|
||||
node.content
|
||||
) {
|
||||
|
||||
headings.forEach((heading) => {
|
||||
const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3
|
||||
tempMarkings.push({
|
||||
type: "heading",
|
||||
level: node.attrs.level,
|
||||
text: node.content[0].text,
|
||||
sequence: node.attrs.level === 1 ? ++h1Sequence : node.attrs.level === 2 ? ++h2Sequence : ++h3Sequence,
|
||||
level: level,
|
||||
text: heading.textContent || "",
|
||||
sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setMarkings(tempMarkings);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
updateMarkings,
|
||||
|
@ -1,3 +1,9 @@
|
||||
export { DocumentEditor, DocumentEditorWithRef } from "src/ui";
|
||||
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/readonly";
|
||||
export { FixedMenu } from "src/ui/menu/fixed-menu";
|
||||
|
||||
// hooks
|
||||
export { useEditorMarkings } from "src/hooks/use-editor-markings";
|
||||
|
||||
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";
|
||||
|
||||
export type { IMarking } from "src/types/editor-types";
|
||||
|
@ -1,10 +1,3 @@
|
||||
export interface DocumentDetails {
|
||||
title: string;
|
||||
created_by: string;
|
||||
created_on: Date;
|
||||
last_updated_by: string;
|
||||
last_updated_at: Date;
|
||||
}
|
||||
export interface IMarking {
|
||||
type: "heading";
|
||||
level: number;
|
||||
|
@ -1,20 +0,0 @@
|
||||
import { LucideIconType } from "@plane/editor-core";
|
||||
|
||||
interface IAlertLabelProps {
|
||||
Icon?: LucideIconType;
|
||||
backgroundColor: string;
|
||||
textColor?: string;
|
||||
label: string;
|
||||
}
|
||||
export const AlertLabel = (props: IAlertLabelProps) => {
|
||||
const { Icon, backgroundColor, textColor, label } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-7 items-center gap-2 rounded-full px-3 py-0.5 text-xs font-medium ${backgroundColor} ${textColor}`}
|
||||
>
|
||||
{Icon && <Icon className="h-3 w-3" />}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,40 +0,0 @@
|
||||
import { HeadingComp, HeadingThreeComp, SubheadingComp } from "src/ui/components/heading-component";
|
||||
import { IMarking } from "src/types/editor-types";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { scrollSummary } from "src/utils/editor-summary-utils";
|
||||
|
||||
interface ContentBrowserProps {
|
||||
editor: Editor;
|
||||
markings: IMarking[];
|
||||
setSidePeekVisible?: (sidePeekState: boolean) => void;
|
||||
}
|
||||
|
||||
export const ContentBrowser = (props: ContentBrowserProps) => {
|
||||
const { editor, markings, setSidePeekVisible } = props;
|
||||
|
||||
const handleOnClick = (marking: IMarking) => {
|
||||
scrollSummary(editor, marking);
|
||||
if (setSidePeekVisible) setSidePeekVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<h2 className="font-medium">Outline</h2>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{markings.length !== 0 ? (
|
||||
markings.map((marking) =>
|
||||
marking.level === 1 ? (
|
||||
<HeadingComp onClick={() => handleOnClick(marking)} heading={marking.text} />
|
||||
) : marking.level === 2 ? (
|
||||
<SubheadingComp onClick={() => handleOnClick(marking)} subHeading={marking.text} />
|
||||
) : (
|
||||
<HeadingThreeComp heading={marking.text} onClick={() => handleOnClick(marking)} />
|
||||
)
|
||||
)
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-custom-text-400">Headings will be displayed here for navigation</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,99 +0,0 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Archive, RefreshCw, Lock } from "lucide-react";
|
||||
import { IMarking, DocumentDetails } from "src/types/editor-types";
|
||||
import { FixedMenu } from "src/ui/menu";
|
||||
import { UploadImage } from "@plane/editor-core";
|
||||
import { AlertLabel } from "src/ui/components/alert-label";
|
||||
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "src/ui/components/vertical-dropdown-menu";
|
||||
import { SummaryPopover } from "src/ui/components/summary-popover";
|
||||
import { InfoPopover } from "src/ui/components/info-popover";
|
||||
import { getDate } from "src/utils/date-utils";
|
||||
|
||||
interface IEditorHeader {
|
||||
editor: Editor;
|
||||
KanbanMenuOptions: IVerticalDropdownItemProps[];
|
||||
sidePeekVisible: boolean;
|
||||
setSidePeekVisible: (sidePeekState: boolean) => void;
|
||||
markings: IMarking[];
|
||||
isLocked: boolean;
|
||||
isArchived: boolean;
|
||||
archivedAt?: Date;
|
||||
readonly: boolean;
|
||||
uploadFile?: UploadImage;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
documentDetails: DocumentDetails;
|
||||
isSubmitting?: "submitting" | "submitted" | "saved";
|
||||
}
|
||||
|
||||
export const EditorHeader = (props: IEditorHeader) => {
|
||||
const {
|
||||
documentDetails,
|
||||
archivedAt,
|
||||
editor,
|
||||
sidePeekVisible,
|
||||
readonly,
|
||||
setSidePeekVisible,
|
||||
markings,
|
||||
uploadFile,
|
||||
setIsSubmitting,
|
||||
KanbanMenuOptions,
|
||||
isArchived,
|
||||
isLocked,
|
||||
isSubmitting,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-custom-border-200 md:px-5 px-3 py-2">
|
||||
<div className="md:w-56 flex-shrink-0 lg:w-72 w-fit">
|
||||
<SummaryPopover
|
||||
editor={editor}
|
||||
markings={markings}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 hidden md:flex">
|
||||
{!readonly && uploadFile && (
|
||||
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-grow items-center justify-end gap-3">
|
||||
{isLocked && (
|
||||
<AlertLabel
|
||||
Icon={Lock}
|
||||
backgroundColor="bg-custom-background-80"
|
||||
textColor="text-custom-text-300"
|
||||
label="Locked"
|
||||
/>
|
||||
)}
|
||||
{isArchived && archivedAt && (
|
||||
<AlertLabel
|
||||
Icon={Archive}
|
||||
backgroundColor="bg-blue-500/20"
|
||||
textColor="text-blue-500"
|
||||
label={`Archived at ${getDate(archivedAt)?.toLocaleString()}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLocked && !isArchived ? (
|
||||
<div
|
||||
className={`absolute right-[120px] flex items-center gap-x-2 transition-all duration-300 ${
|
||||
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
|
||||
}`}
|
||||
>
|
||||
{isSubmitting !== "submitted" && isSubmitting !== "saved" && (
|
||||
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
|
||||
)}
|
||||
<span className="text-sm text-custom-text-300">
|
||||
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
|
||||
<VerticalDropdownMenu items={KanbanMenuOptions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,47 +0,0 @@
|
||||
export const HeadingComp = ({
|
||||
heading,
|
||||
onClick,
|
||||
}: {
|
||||
heading: string;
|
||||
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||
}) => (
|
||||
<h3
|
||||
onClick={onClick}
|
||||
className="ml-4 mt-3 cursor-pointer text-sm font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
|
||||
role="button"
|
||||
>
|
||||
{heading}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export const SubheadingComp = ({
|
||||
subHeading,
|
||||
onClick,
|
||||
}: {
|
||||
subHeading: string;
|
||||
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||
}) => (
|
||||
<p
|
||||
onClick={onClick}
|
||||
className="ml-6 mt-2 cursor-pointer text-xs font-medium tracking-tight text-gray-400 hover:text-custom-primary"
|
||||
role="button"
|
||||
>
|
||||
{subHeading}
|
||||
</p>
|
||||
);
|
||||
|
||||
export const HeadingThreeComp = ({
|
||||
heading,
|
||||
onClick,
|
||||
}: {
|
||||
heading: string;
|
||||
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
|
||||
}) => (
|
||||
<p
|
||||
onClick={onClick}
|
||||
className="ml-8 mt-2 cursor-pointer text-xs font-medium tracking-tight text-gray-400 hover:text-custom-primary"
|
||||
role="button"
|
||||
>
|
||||
{heading}
|
||||
</p>
|
||||
);
|
@ -1,9 +1 @@
|
||||
export * from "./alert-label";
|
||||
export * from "./content-browser";
|
||||
export * from "./editor-header";
|
||||
export * from "./heading-component";
|
||||
export * from "./info-popover";
|
||||
export * from "./page-renderer";
|
||||
export * from "./summary-popover";
|
||||
export * from "./summary-side-bar";
|
||||
export * from "./vertical-dropdown-menu";
|
||||
|
@ -115,11 +115,6 @@ export const LinkEditView = ({
|
||||
const removeLink = () => {
|
||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||
linkRemoved.current = true;
|
||||
viewProps.onActionCompleteHandler({
|
||||
title: "Link successfully removed",
|
||||
message: "The link was removed from the text.",
|
||||
type: "success",
|
||||
});
|
||||
viewProps.closeLinkView();
|
||||
};
|
||||
|
||||
|
@ -12,21 +12,11 @@ export const LinkPreview = ({
|
||||
|
||||
const removeLink = () => {
|
||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||
viewProps.onActionCompleteHandler({
|
||||
title: "Link successfully removed",
|
||||
message: "The link was removed from the text.",
|
||||
type: "success",
|
||||
});
|
||||
viewProps.closeLinkView();
|
||||
};
|
||||
|
||||
const copyLinkToClipboard = () => {
|
||||
navigator.clipboard.writeText(url);
|
||||
viewProps.onActionCompleteHandler({
|
||||
title: "Link successfully copied",
|
||||
message: "The link was copied to the clipboard.",
|
||||
type: "success",
|
||||
});
|
||||
viewProps.closeLinkView();
|
||||
};
|
||||
|
||||
|
@ -11,11 +11,6 @@ export interface LinkViewProps {
|
||||
to: number;
|
||||
url: string;
|
||||
closeLinkView: () => void;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
import { Editor, ReactRenderer } from "@tiptap/react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { DocumentDetails } from "src/types/editor-types";
|
||||
import { LinkView, LinkViewProps } from "./links/link-view";
|
||||
import {
|
||||
autoUpdate,
|
||||
@ -15,40 +14,22 @@ import {
|
||||
useFloating,
|
||||
useInteractions,
|
||||
} from "@floating-ui/react";
|
||||
import BlockMenu from "../menu//block-menu";
|
||||
|
||||
type IPageRenderer = {
|
||||
documentDetails: DocumentDetails;
|
||||
updatePageTitle: (title: string) => void;
|
||||
editor: Editor;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
editorClassNames: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
editorContainerClassName: string;
|
||||
hideDragHandle?: () => void;
|
||||
readonly: boolean;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
const {
|
||||
documentDetails,
|
||||
tabIndex,
|
||||
editor,
|
||||
editorClassNames,
|
||||
editorContentCustomClassNames,
|
||||
updatePageTitle,
|
||||
readonly,
|
||||
hideDragHandle,
|
||||
} = props;
|
||||
|
||||
const [pageTitle, setPagetitle] = useState(documentDetails.title);
|
||||
|
||||
const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props;
|
||||
// states
|
||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
|
||||
const [cleanup, setCleanup] = useState(() => () => {});
|
||||
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
open: isOpen,
|
||||
@ -63,18 +44,9 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
|
||||
const { getFloatingProps } = useInteractions([dismiss]);
|
||||
|
||||
const handlePageTitleChange = (title: string) => {
|
||||
setPagetitle(title);
|
||||
updatePageTitle(title);
|
||||
};
|
||||
|
||||
const [cleanup, setcleanup] = useState(() => () => {});
|
||||
|
||||
const floatingElementRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const closeLinkView = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
const closeLinkView = () => setIsOpen(false);
|
||||
|
||||
const handleLinkHover = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
@ -137,7 +109,6 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
setCoordinates({ x: x - 300, y: y - 50 });
|
||||
setIsOpen(true);
|
||||
setLinkViewProps({
|
||||
onActionCompleteHandler: props.onActionCompleteHandler,
|
||||
closeLinkView: closeLinkView,
|
||||
view: "LinkPreview",
|
||||
url: href,
|
||||
@ -148,45 +119,32 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
});
|
||||
});
|
||||
|
||||
setcleanup(cleanupFunc);
|
||||
setCleanup(cleanupFunc);
|
||||
},
|
||||
[editor, cleanup]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
|
||||
{!readonly ? (
|
||||
<input
|
||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||
className="-mt-2 w-full break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
|
||||
value={pageTitle}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
onChange={(e) => handlePageTitleChange(e.target.value)}
|
||||
className="-mt-2 w-full overflow-x-clip break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
|
||||
value={pageTitle}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
|
||||
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
<>
|
||||
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
||||
<EditorContainer
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
hideDragHandle={hideDragHandle}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
>
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
{editor && editor.isEditable && <BlockMenu editor={editor} />}
|
||||
</EditorContainer>
|
||||
</div>
|
||||
{isOpen && linkViewProps && coordinates && (
|
||||
<div
|
||||
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
|
||||
className={`absolute`}
|
||||
className="absolute"
|
||||
ref={refs.setFloating}
|
||||
>
|
||||
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,19 +0,0 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { IMarking } from "src/types/editor-types";
|
||||
import { ContentBrowser } from "src/ui/components/content-browser";
|
||||
|
||||
interface ISummarySideBarProps {
|
||||
editor: Editor;
|
||||
markings: IMarking[];
|
||||
sidePeekVisible: boolean;
|
||||
}
|
||||
|
||||
export const SummarySideBar = ({ editor, markings, sidePeekVisible }: ISummarySideBarProps) => (
|
||||
<div
|
||||
className={`h-full transform overflow-hidden p-5 transition-all duration-200 ${
|
||||
sidePeekVisible ? "translate-x-0" : "-translate-x-full"
|
||||
}`}
|
||||
>
|
||||
<ContentBrowser editor={editor} markings={markings} />
|
||||
</div>
|
||||
);
|
@ -1,46 +0,0 @@
|
||||
import { LucideIconType } from "@plane/editor-core";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
|
||||
type TMenuItems =
|
||||
| "archive_page"
|
||||
| "unarchive_page"
|
||||
| "lock_page"
|
||||
| "unlock_page"
|
||||
| "copy_markdown"
|
||||
| "close_page"
|
||||
| "copy_page_link"
|
||||
| "duplicate_page";
|
||||
|
||||
export interface IVerticalDropdownItemProps {
|
||||
key: number;
|
||||
type: TMenuItems;
|
||||
Icon: LucideIconType;
|
||||
label: string;
|
||||
action: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface IVerticalDropdownMenuProps {
|
||||
items: IVerticalDropdownItemProps[];
|
||||
}
|
||||
|
||||
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => (
|
||||
<CustomMenu.MenuItem onClick={action} className="flex items-center gap-2">
|
||||
<Icon className="h-3 w-3" />
|
||||
<div className="text-custom-text-300">{label}</div>
|
||||
</CustomMenu.MenuItem>
|
||||
);
|
||||
|
||||
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => (
|
||||
<CustomMenu
|
||||
maxHeight={"md"}
|
||||
className={"h-4.5 mt-1"}
|
||||
placement={"bottom-start"}
|
||||
optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "}
|
||||
customButton={<MoreVertical size={14} />}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<VerticalDropdownItem key={item.key} type={item.type} Icon={item.Icon} label={item.label} action={item.action} />
|
||||
))}
|
||||
</CustomMenu>
|
||||
);
|
@ -6,17 +6,17 @@ import { UploadImage } from "@plane/editor-core";
|
||||
|
||||
export const DocumentEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
|
||||
) => [
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
SlashCommand(uploadFile),
|
||||
DragAndDrop(setHideDragHandle),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
|
||||
if (editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image")) {
|
||||
return "";
|
||||
}
|
||||
|
||||
|
@ -1,187 +1,97 @@
|
||||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-core";
|
||||
import {
|
||||
UploadImage,
|
||||
DeleteImage,
|
||||
RestoreImage,
|
||||
getEditorClassNames,
|
||||
useEditor,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
} from "@plane/editor-core";
|
||||
import { DocumentEditorExtensions } from "src/ui/extensions";
|
||||
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions";
|
||||
import { EditorHeader } from "src/ui/components/editor-header";
|
||||
import { useEditorMarkings } from "src/hooks/use-editor-markings";
|
||||
import { SummarySideBar } from "src/ui/components/summary-side-bar";
|
||||
import { DocumentDetails } from "src/types/editor-types";
|
||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||
import { getMenuOptions } from "src/utils/menu-options";
|
||||
import { useRouter } from "next/router";
|
||||
import { FixedMenu } from "src";
|
||||
|
||||
interface IDocumentEditor {
|
||||
// document info
|
||||
documentDetails: DocumentDetails;
|
||||
value: string;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
initialValue: string;
|
||||
value?: string;
|
||||
fileHandler: {
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange: (json: object, html: string) => void;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
|
||||
// file operations
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
restoreFile: RestoreImage;
|
||||
cancelUploadImage: () => any;
|
||||
|
||||
// editor state managers
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
updatePageTitle: (title: string) => void;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
|
||||
// embed configuration
|
||||
duplicationConfig?: IDuplicationConfig;
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
|
||||
tabIndex?: number;
|
||||
}
|
||||
interface DocumentEditorProps extends IDocumentEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
}
|
||||
|
||||
const DocumentEditor = ({
|
||||
documentDetails,
|
||||
const DocumentEditor = (props: IDocumentEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
initialValue,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
isSubmitting,
|
||||
customClassName,
|
||||
fileHandler,
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
mentionHandler,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
duplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
updatePageTitle,
|
||||
cancelUploadImage,
|
||||
onActionCompleteHandler,
|
||||
rerenderOnPropsChange,
|
||||
tabIndex,
|
||||
}: IDocumentEditor) => {
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||
} = props;
|
||||
// states
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
// loads such that we can invoke it from react when the cursor leaves the container
|
||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||
};
|
||||
|
||||
// use editor
|
||||
const editor = useEditor({
|
||||
onChange(json, html) {
|
||||
updateMarkings(json);
|
||||
onChange(json, html);
|
||||
},
|
||||
onStart(json) {
|
||||
updateMarkings(json);
|
||||
},
|
||||
debouncedUpdatesEnabled,
|
||||
restoreFile,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorClassName,
|
||||
restoreFile: fileHandler.restore,
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
initialValue,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
cancelUploadImage,
|
||||
rerenderOnPropsChange,
|
||||
handleEditorReady,
|
||||
forwardedRef,
|
||||
extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting),
|
||||
mentionHandler,
|
||||
extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction),
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const KanbanMenuOptions = getMenuOptions({
|
||||
editor: editor,
|
||||
router: router,
|
||||
duplicationConfig: duplicationConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
onActionCompleteHandler,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
customClassName,
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<EditorHeader
|
||||
readonly={false}
|
||||
KanbanMenuOptions={KanbanMenuOptions}
|
||||
editor={editor}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={(val) => setSidePeekVisible(val)}
|
||||
markings={markings}
|
||||
uploadFile={uploadFile}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
|
||||
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||
documentDetails={documentDetails}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
|
||||
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
|
||||
</div>
|
||||
<div className="flex h-full w-full overflow-y-auto frame-renderer">
|
||||
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
|
||||
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
|
||||
</div>
|
||||
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<PageRenderer
|
||||
tabIndex={tabIndex}
|
||||
onActionCompleteHandler={onActionCompleteHandler}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
readonly={false}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
editorClassNames={editorClassNames}
|
||||
documentDetails={documentDetails}
|
||||
updatePageTitle={updatePageTitle}
|
||||
editorContainerClassName={editorContainerClassNames}
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-56 flex-shrink-0 lg:block lg:w-72" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
|
||||
<DocumentEditor {...props} forwardedRef={ref} />
|
||||
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
|
||||
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
||||
|
149
packages/editor/document-editor/src/ui/menu/block-menu.tsx
Normal file
149
packages/editor/document-editor/src/ui/menu/block-menu.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
interface BlockMenuProps {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
export default function BlockMenu(props: BlockMenuProps) {
|
||||
const { editor } = props;
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const popup = useRef<Instance | null>(null);
|
||||
|
||||
const handleClickDragHandle = useCallback((event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
|
||||
event.preventDefault();
|
||||
|
||||
popup.current?.setProps({
|
||||
getReferenceClientRect: () => target.getBoundingClientRect(),
|
||||
});
|
||||
|
||||
popup.current?.show();
|
||||
return;
|
||||
}
|
||||
|
||||
popup.current?.hide();
|
||||
return;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (menuRef.current) {
|
||||
menuRef.current.remove();
|
||||
menuRef.current.style.visibility = "visible";
|
||||
|
||||
// @ts-expect-error - tippy types are incorrect
|
||||
popup.current = tippy(document.body, {
|
||||
getReferenceClientRect: null,
|
||||
content: menuRef.current,
|
||||
appendTo: () => document.querySelector(".frame-renderer"),
|
||||
trigger: "manual",
|
||||
interactive: true,
|
||||
arrow: false,
|
||||
placement: "left-start",
|
||||
animation: "shift-away",
|
||||
maxWidth: 500,
|
||||
hideOnClick: true,
|
||||
onShown: () => {
|
||||
menuRef.current?.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
popup.current?.destroy();
|
||||
popup.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = () => {
|
||||
popup.current?.hide();
|
||||
};
|
||||
|
||||
const handleScroll = () => {
|
||||
popup.current?.hide();
|
||||
};
|
||||
document.addEventListener("click", handleClickDragHandle);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
document.addEventListener("scroll", handleScroll, true); // Using capture phase
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickDragHandle);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
document.removeEventListener("scroll", handleScroll, true);
|
||||
};
|
||||
}, [handleClickDragHandle]);
|
||||
|
||||
const MENU_ITEMS: {
|
||||
icon: LucideIcon;
|
||||
key: string;
|
||||
label: string;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
isDisabled?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
icon: Trash2,
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
onClick: (e) => {
|
||||
editor.chain().deleteSelection().focus().run();
|
||||
popup.current?.hide();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: Copy,
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
|
||||
onClick: (e) => {
|
||||
const { view } = editor;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(selection.to, selection.content().content.firstChild!.toJSON(), {
|
||||
updateSelection: true,
|
||||
})
|
||||
.focus(selection.to + 1, { scrollIntoView: false })
|
||||
.run();
|
||||
|
||||
popup.current?.hide();
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="z-10 max-h-60 min-w-[7rem] overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
// Skip rendering the button if it should be disabled
|
||||
if (item.isDisabled && item.key === "duplicate") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={item.onClick}
|
||||
disabled={item.isDisabled}
|
||||
>
|
||||
<item.icon className="h-3 w-3" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,141 +0,0 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
BoldItem,
|
||||
BulletListItem,
|
||||
isCellSelection,
|
||||
cn,
|
||||
CodeItem,
|
||||
ImageItem,
|
||||
ItalicItem,
|
||||
NumberedListItem,
|
||||
QuoteItem,
|
||||
StrikeThroughItem,
|
||||
TableItem,
|
||||
UnderLineItem,
|
||||
HeadingOneItem,
|
||||
HeadingTwoItem,
|
||||
HeadingThreeItem,
|
||||
findTableAncestor,
|
||||
EditorMenuItem,
|
||||
UploadImage,
|
||||
} from "@plane/editor-core";
|
||||
|
||||
export type BubbleMenuItem = EditorMenuItem;
|
||||
|
||||
type EditorBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
uploadFile: UploadImage;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
};
|
||||
|
||||
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
const { editor, uploadFile, setIsSubmitting } = props;
|
||||
|
||||
const basicMarkItems: BubbleMenuItem[] = [
|
||||
HeadingOneItem(editor),
|
||||
HeadingTwoItem(editor),
|
||||
HeadingThreeItem(editor),
|
||||
BoldItem(editor),
|
||||
ItalicItem(editor),
|
||||
UnderLineItem(editor),
|
||||
StrikeThroughItem(editor),
|
||||
];
|
||||
|
||||
const listItems: BubbleMenuItem[] = [BulletListItem(editor), NumberedListItem(editor)];
|
||||
|
||||
const userActionItems: BubbleMenuItem[] = [QuoteItem(editor), CodeItem(editor)];
|
||||
|
||||
function getComplexItems(): BubbleMenuItem[] {
|
||||
const items: BubbleMenuItem[] = [TableItem(editor)];
|
||||
|
||||
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
|
||||
return items;
|
||||
}
|
||||
|
||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
{basicMarkItems.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 px-2">
|
||||
{listItems.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 px-2">
|
||||
{userActionItems.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 pl-2">
|
||||
{complexItems.map((item) => (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export { FixedMenu } from "./fixed-menu";
|
@ -1,132 +1,53 @@
|
||||
import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState, forwardRef, useEffect } from "react";
|
||||
import { EditorHeader } from "src/ui/components/editor-header";
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core";
|
||||
// components
|
||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
||||
import { SummarySideBar } from "src/ui/components/summary-side-bar";
|
||||
import { useEditorMarkings } from "src/hooks/use-editor-markings";
|
||||
import { DocumentDetails } from "src/types/editor-types";
|
||||
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "src/types/menu-actions";
|
||||
import { getMenuOptions } from "src/utils/menu-options";
|
||||
import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
value: string;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
};
|
||||
noBorder: boolean;
|
||||
borderOnFocus: boolean;
|
||||
customClassName: string;
|
||||
documentDetails: DocumentDetails;
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
pageDuplicationConfig?: IDuplicationConfig;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
editorClassName?: string;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
|
||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const DocumentReadOnlyEditor = ({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
value,
|
||||
documentDetails,
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
pageDuplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
rerenderOnPropsChange,
|
||||
onActionCompleteHandler,
|
||||
tabIndex,
|
||||
}: DocumentReadOnlyEditorProps) => {
|
||||
const router = useRouter();
|
||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
||||
const { markings, updateMarkings } = useEditorMarkings();
|
||||
|
||||
handleEditorReady,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
initialValue,
|
||||
editorClassName,
|
||||
mentionHandler,
|
||||
forwardedRef,
|
||||
rerenderOnPropsChange,
|
||||
handleEditorReady,
|
||||
extensions: [IssueWidgetPlaceholder()],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
updateMarkings(editor.getJSON());
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
const KanbanMenuOptions = getMenuOptions({
|
||||
editor: editor,
|
||||
router: router,
|
||||
pageArchiveConfig: pageArchiveConfig,
|
||||
pageLockConfig: pageLockConfig,
|
||||
duplicationConfig: pageDuplicationConfig,
|
||||
onActionCompleteHandler,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<EditorHeader
|
||||
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
|
||||
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
|
||||
readonly
|
||||
editor={editor}
|
||||
sidePeekVisible={sidePeekVisible}
|
||||
setSidePeekVisible={setSidePeekVisible}
|
||||
KanbanMenuOptions={KanbanMenuOptions}
|
||||
markings={markings}
|
||||
documentDetails={documentDetails}
|
||||
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
|
||||
/>
|
||||
<div className="flex h-full w-full overflow-y-auto frame-renderer">
|
||||
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-80">
|
||||
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
|
||||
</div>
|
||||
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
|
||||
<PageRenderer
|
||||
tabIndex={tabIndex}
|
||||
onActionCompleteHandler={onActionCompleteHandler}
|
||||
updatePageTitle={() => Promise.resolve()}
|
||||
readonly
|
||||
editor={editor}
|
||||
editorClassNames={editorClassNames}
|
||||
documentDetails={documentDetails}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-56 flex-shrink-0 lg:block lg:w-80" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorHandle, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref} />
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
|
||||
|
@ -1,12 +0,0 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
|
||||
export const copyMarkdownToClipboard = (editor: Editor | null) => {
|
||||
const markdownOutput = editor?.storage.markdown.getMarkdown();
|
||||
navigator.clipboard.writeText(markdownOutput);
|
||||
};
|
||||
|
||||
export const CopyPageLink = () => {
|
||||
if (window) {
|
||||
navigator.clipboard.writeText(window.location.toString());
|
||||
}
|
||||
};
|
@ -1,153 +0,0 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Archive, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock } from "lucide-react";
|
||||
import { NextRouter } from "next/router";
|
||||
import { IVerticalDropdownItemProps } from "src/ui/components/vertical-dropdown-menu";
|
||||
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions";
|
||||
import { copyMarkdownToClipboard, CopyPageLink } from "src/utils/menu-actions";
|
||||
|
||||
export interface MenuOptionsProps {
|
||||
editor: Editor;
|
||||
router: NextRouter;
|
||||
duplicationConfig?: IDuplicationConfig;
|
||||
pageLockConfig?: IPageLockConfig;
|
||||
pageArchiveConfig?: IPageArchiveConfig;
|
||||
onActionCompleteHandler: (action: {
|
||||
title: string;
|
||||
message: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const getMenuOptions = ({
|
||||
editor,
|
||||
router,
|
||||
duplicationConfig,
|
||||
pageLockConfig,
|
||||
pageArchiveConfig,
|
||||
onActionCompleteHandler,
|
||||
}: MenuOptionsProps) => {
|
||||
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
|
||||
{
|
||||
key: 1,
|
||||
type: "copy_markdown",
|
||||
Icon: ClipboardIcon,
|
||||
action: () => {
|
||||
onActionCompleteHandler({
|
||||
title: "Markdown Copied",
|
||||
message: "Page Copied as Markdown",
|
||||
type: "success",
|
||||
});
|
||||
copyMarkdownToClipboard(editor);
|
||||
},
|
||||
label: "Copy markdown",
|
||||
},
|
||||
// {
|
||||
// key: 2,
|
||||
// type: "close_page",
|
||||
// Icon: XCircle,
|
||||
// action: () => router.back(),
|
||||
// label: "Close page",
|
||||
// },
|
||||
{
|
||||
key: 3,
|
||||
type: "copy_page_link",
|
||||
Icon: Link,
|
||||
action: () => {
|
||||
onActionCompleteHandler({
|
||||
title: "Link Copied",
|
||||
message: "Link to the page has been copied to clipboard",
|
||||
type: "success",
|
||||
});
|
||||
CopyPageLink();
|
||||
},
|
||||
label: "Copy page link",
|
||||
},
|
||||
];
|
||||
|
||||
// If duplicateConfig is given, page duplication will be allowed
|
||||
if (duplicationConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: "duplicate_page",
|
||||
Icon: Copy,
|
||||
action: () => {
|
||||
duplicationConfig
|
||||
.action()
|
||||
.then(() => {
|
||||
onActionCompleteHandler({
|
||||
title: "Page Copied",
|
||||
message: "Page has been copied as 'Copy of' followed by page title",
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
onActionCompleteHandler({
|
||||
title: "Copy Failed",
|
||||
message: "Sorry, page cannot be copied, please try again later.",
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
label: "Make a copy",
|
||||
});
|
||||
}
|
||||
// If Lock Configuration is given then, lock page option will be available in the kanban menu
|
||||
if (pageLockConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
|
||||
Icon: pageLockConfig.is_locked ? Unlock : Lock,
|
||||
label: pageLockConfig.is_locked ? "Unlock page" : "Lock page",
|
||||
action: () => {
|
||||
const state = pageLockConfig.is_locked ? "Unlocked" : "Locked";
|
||||
pageLockConfig
|
||||
.action()
|
||||
.then(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page ${state}`,
|
||||
message: `Page has been ${state}, no one will be able to change the state of lock except you.`,
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page cannot be ${state}`,
|
||||
message: `Sorry, page cannot be ${state}, please try again later`,
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
|
||||
if (pageArchiveConfig) {
|
||||
KanbanMenuOptions.push({
|
||||
key: KanbanMenuOptions.length++,
|
||||
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
|
||||
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
|
||||
label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page",
|
||||
action: () => {
|
||||
const state = pageArchiveConfig.is_archived ? "Unarchived" : "Archived";
|
||||
pageArchiveConfig
|
||||
.action()
|
||||
.then(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page ${state}`,
|
||||
message: `Page has been ${state}, you can checkout all archived tab and can restore the page later.`,
|
||||
type: "success",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
onActionCompleteHandler({
|
||||
title: `Page cannot be ${state}`,
|
||||
message: `Sorry, page cannot be ${state}, please try again later.`,
|
||||
type: "success",
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return KanbanMenuOptions;
|
||||
};
|
@ -29,6 +29,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/editor-core": "*",
|
||||
"@plane/ui": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/pm": "^2.1.13",
|
||||
"@tiptap/react": "^2.1.13",
|
||||
|
@ -1,9 +1,18 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
|
||||
import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
|
||||
// @ts-ignore
|
||||
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||
// @ts-expect-error __serializeForClipboard's is not exported
|
||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
||||
import React from "react";
|
||||
|
||||
export interface DragHandleOptions {
|
||||
dragHandleWidth: number;
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
scrollThreshold: {
|
||||
up: number;
|
||||
down: number;
|
||||
};
|
||||
}
|
||||
|
||||
function createDragHandleElement(): HTMLElement {
|
||||
const dragHandleElement = document.createElement("div");
|
||||
@ -29,13 +38,8 @@ function createDragHandleElement(): HTMLElement {
|
||||
return dragHandleElement;
|
||||
}
|
||||
|
||||
export interface DragHandleOptions {
|
||||
dragHandleWidth: number;
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
||||
}
|
||||
|
||||
function absoluteRect(node: Element) {
|
||||
const data = node?.getBoundingClientRect();
|
||||
const data = node.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
top: data.top,
|
||||
@ -57,55 +61,77 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1, h2, h3",
|
||||
".table-wrapper",
|
||||
"[data-type=horizontalRule]",
|
||||
".tableWrapper",
|
||||
].join(", ")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function nodePosAtDOM(node: Element, view: EditorView) {
|
||||
const boundingRect = node?.getBoundingClientRect();
|
||||
|
||||
if (node.nodeName === "IMG") {
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 1,
|
||||
top: boundingRect.top + 1,
|
||||
})?.pos;
|
||||
}
|
||||
|
||||
if (node.nodeName === "PRE") {
|
||||
return (
|
||||
view.posAtCoords({
|
||||
left: boundingRect.left + 1,
|
||||
top: boundingRect.top + 1,
|
||||
})?.pos! - 1
|
||||
);
|
||||
}
|
||||
function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) {
|
||||
const boundingRect = node.getBoundingClientRect();
|
||||
|
||||
return view.posAtCoords({
|
||||
left: boundingRect.left + 1,
|
||||
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||
top: boundingRect.top + 1,
|
||||
})?.inside;
|
||||
}
|
||||
|
||||
function calcNodePos(pos: number, view: EditorView) {
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if ($pos.depth > 1) return $pos.before($pos.depth);
|
||||
return pos;
|
||||
}
|
||||
|
||||
function DragHandle(options: DragHandleOptions) {
|
||||
let listType = "";
|
||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
if (!event.dataTransfer) return;
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + options.dragHandleWidth + 50,
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view);
|
||||
if (nodePos === null || nodePos === undefined || nodePos < 0) return;
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
draggedNodePos = calcNodePos(draggedNodePos, view);
|
||||
|
||||
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)));
|
||||
const { from, to } = view.state.selection;
|
||||
const diff = from - to;
|
||||
|
||||
const fromSelectionPos = calcNodePos(from, view);
|
||||
let differentNodeSelected = false;
|
||||
|
||||
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
||||
|
||||
// Check if nodePos points to the top level node
|
||||
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
|
||||
else {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
||||
// Check if the node where the drag event started is part of the current selection
|
||||
differentNodeSelected = !(
|
||||
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
||||
);
|
||||
}
|
||||
|
||||
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
||||
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
||||
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
||||
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
|
||||
} else {
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
|
||||
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
||||
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
|
||||
listType = node.parentElement!.tagName;
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
@ -123,8 +149,6 @@ function DragHandle(options: DragHandleOptions) {
|
||||
function handleClick(event: MouseEvent, view: EditorView) {
|
||||
view.focus();
|
||||
|
||||
view.dom.classList.remove("dragging");
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
@ -132,11 +156,18 @@ function DragHandle(options: DragHandleOptions) {
|
||||
|
||||
if (!(node instanceof Element)) return;
|
||||
|
||||
const nodePos = nodePosAtDOM(node, view);
|
||||
let nodePos = nodePosAtDOM(node, view, options);
|
||||
|
||||
if (nodePos === null || nodePos === undefined || nodePos < 0) return;
|
||||
if (nodePos === null || nodePos === undefined) return;
|
||||
|
||||
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)));
|
||||
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
|
||||
nodePos = calcNodePos(nodePos, view);
|
||||
|
||||
// Use NodeSelection to select the node at the calculated position
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
|
||||
|
||||
// Dispatch the transaction to update the selection
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
}
|
||||
|
||||
let dragHandleElement: HTMLElement | null = null;
|
||||
@ -166,11 +197,15 @@ function DragHandle(options: DragHandleOptions) {
|
||||
handleClick(e, view);
|
||||
});
|
||||
|
||||
dragHandleElement.addEventListener("dragstart", (e) => {
|
||||
handleDragStart(e, view);
|
||||
});
|
||||
dragHandleElement.addEventListener("click", (e) => {
|
||||
handleClick(e, view);
|
||||
dragHandleElement.addEventListener("drag", (e) => {
|
||||
hideDragHandle();
|
||||
const a = document.querySelector(".frame-renderer");
|
||||
if (!a) return;
|
||||
if (e.clientY < options.scrollThreshold.up) {
|
||||
a.scrollBy({ top: -70, behavior: "smooth" });
|
||||
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
|
||||
a.scrollBy({ top: 70, behavior: "smooth" });
|
||||
}
|
||||
});
|
||||
|
||||
hideDragHandle();
|
||||
@ -192,11 +227,11 @@ function DragHandle(options: DragHandleOptions) {
|
||||
}
|
||||
|
||||
const node = nodeDOMAtCoords({
|
||||
x: event.clientX + options.dragHandleWidth,
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
if (!(node instanceof Element)) {
|
||||
if (!(node instanceof Element) || node.matches("ul, ol")) {
|
||||
hideDragHandle();
|
||||
return;
|
||||
}
|
||||
@ -207,32 +242,74 @@ function DragHandle(options: DragHandleOptions) {
|
||||
|
||||
const rect = absoluteRect(node);
|
||||
|
||||
rect.top += (lineHeight - 24) / 2;
|
||||
rect.top += (lineHeight - 20) / 2;
|
||||
rect.top += paddingTop;
|
||||
// Li markers
|
||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
||||
rect.left -= options.dragHandleWidth;
|
||||
rect.top += 4;
|
||||
rect.left -= 18;
|
||||
}
|
||||
rect.width = options.dragHandleWidth;
|
||||
|
||||
if (!dragHandleElement) return;
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||
dragHandleElement.style.top = `${rect.top + 3}px`;
|
||||
dragHandleElement.style.top = `${rect.top}px`;
|
||||
showDragHandle();
|
||||
},
|
||||
keydown: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
wheel: () => {
|
||||
mousewheel: () => {
|
||||
hideDragHandle();
|
||||
},
|
||||
// dragging className is used for CSS
|
||||
dragstart: (view) => {
|
||||
dragenter: (view) => {
|
||||
view.dom.classList.add("dragging");
|
||||
hideDragHandle();
|
||||
},
|
||||
drop: (view) => {
|
||||
drop: (view, event) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
hideDragHandle();
|
||||
let droppedNode: Node | null = null;
|
||||
const dropPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!dropPos) return;
|
||||
|
||||
if (view.state.selection instanceof NodeSelection) {
|
||||
droppedNode = view.state.selection.node;
|
||||
}
|
||||
if (!droppedNode) return;
|
||||
|
||||
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
||||
let isDroppedInsideList = false;
|
||||
|
||||
// Traverse up the document tree to find if we're inside a list item
|
||||
for (let i = resolvedPos.depth; i > 0; i--) {
|
||||
if (resolvedPos.node(i).type.name === "listItem") {
|
||||
isDroppedInsideList = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
||||
if (
|
||||
view.state.selection instanceof NodeSelection &&
|
||||
view.state.selection.node.type.name === "listItem" &&
|
||||
!isDroppedInsideList &&
|
||||
listType == "OL"
|
||||
) {
|
||||
const text = droppedNode.textContent;
|
||||
if (!text) return;
|
||||
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
|
||||
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
|
||||
|
||||
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
|
||||
const slice = new Slice(Fragment.from(newList), 0, 0);
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
},
|
||||
dragend: (view) => {
|
||||
view.dom.classList.remove("dragging");
|
||||
@ -250,6 +327,7 @@ export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: ()
|
||||
return [
|
||||
DragHandle({
|
||||
dragHandleWidth: 24,
|
||||
scrollThreshold: { up: 300, down: 100 },
|
||||
setHideDragHandle,
|
||||
}),
|
||||
];
|
||||
|
2
packages/editor/extensions/src/extensions/index.ts
Normal file
2
packages/editor/extensions/src/extensions/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./drag-drop";
|
||||
export * from "./slash-commands";
|
@ -54,7 +54,20 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
props.command({ editor, range });
|
||||
},
|
||||
allow({ editor }: { editor: Editor }) {
|
||||
return !editor.isActive("table");
|
||||
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;
|
||||
},
|
||||
allowSpaces: true,
|
||||
},
|
||||
@ -71,11 +84,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
});
|
||||
|
||||
const getSuggestionItems =
|
||||
(
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||
additionalOptions?: Array<ISlashCommandItem>
|
||||
) =>
|
||||
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
({ query }: { query: string }) => {
|
||||
let slashCommands: ISlashCommandItem[] = [
|
||||
{
|
||||
@ -186,7 +195,7 @@ const getSuggestionItems =
|
||||
searchTerms: ["img", "photo", "picture", "media"],
|
||||
icon: <ImageIcon className="h-3.5 w-3.5" />,
|
||||
command: ({ editor, range }: CommandProps) => {
|
||||
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
|
||||
insertImageCommand(editor, uploadFile, null, range);
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -300,9 +309,9 @@ const CommandList = ({ items, command }: { items: CommandItemProps[]; command: a
|
||||
<button
|
||||
key={item.key}
|
||||
className={cn(
|
||||
`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-custom-text-100 hover:bg-custom-primary-100/5`,
|
||||
`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-custom-text-100 hover:bg-custom-background-80`,
|
||||
{
|
||||
"bg-custom-primary-100/5": index === selectedIndex,
|
||||
"bg-custom-background-80": index === selectedIndex,
|
||||
}
|
||||
)}
|
||||
onClick={() => selectItem(index)}
|
||||
@ -315,19 +324,21 @@ const CommandList = ({ items, command }: { items: CommandItemProps[]; command: a
|
||||
) : null;
|
||||
};
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer | null = null;
|
||||
let popup: any | null = null;
|
||||
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,
|
||||
// @ts-ignore
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
// @ts-expect-error Tippy overloads are messed up
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
|
||||
@ -353,8 +364,10 @@ const renderItems = () => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return component?.ref?.onKeyDown(props);
|
||||
if (component?.ref?.onKeyDown(props)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
popup?.[0].destroy();
|
||||
@ -363,14 +376,10 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
export const SlashCommand = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||
additionalOptions?: Array<ISlashCommandItem>
|
||||
) =>
|
||||
export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSuggestionItems(uploadFile, setIsSubmitting, additionalOptions),
|
||||
items: getSuggestionItems(uploadFile, additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
@ -1,4 +1,3 @@
|
||||
import "src/styles/drag-drop.css";
|
||||
|
||||
export { SlashCommand } from "src/extensions/slash-commands";
|
||||
export { DragAndDrop } from "src/extensions/drag-drop";
|
||||
export { DragAndDrop, SlashCommand } from "src/extensions";
|
||||
|
@ -1,26 +1,31 @@
|
||||
/* drag handle */
|
||||
.drag-handle {
|
||||
position: fixed;
|
||||
opacity: 1;
|
||||
transition: opacity ease-in 0.2s;
|
||||
height: 18px;
|
||||
height: 20px;
|
||||
width: 15px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
z-index: 10;
|
||||
z-index: 5;
|
||||
cursor: grab;
|
||||
border-radius: 2px;
|
||||
background-color: rgb(var(--color-background-90));
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background-color: rgb(var(--color-background-80));
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(var(--color-background-80));
|
||||
}
|
||||
|
||||
.drag-handle.hidden {
|
||||
&:active {
|
||||
background-color: rgba(var(--color-background-80));
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.drag-handle {
|
||||
@ -32,7 +37,6 @@
|
||||
.drag-handle-container {
|
||||
height: 15px;
|
||||
width: 15px;
|
||||
cursor: grab;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
@ -46,8 +50,46 @@
|
||||
}
|
||||
|
||||
.drag-handle-dot {
|
||||
height: 2.75px;
|
||||
width: 3px;
|
||||
background-color: rgba(var(--color-text-200));
|
||||
height: 2.5px;
|
||||
width: 2.5px;
|
||||
background-color: rgba(var(--color-text-300));
|
||||
border-radius: 50%;
|
||||
}
|
||||
/* end drag handle */
|
||||
|
||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
outline: none !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.ProseMirror:not(.dragging) .ProseMirror-selectednode::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -5px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: rgba(var(--color-primary-100), 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ProseMirror img {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
filter: brightness(90%);
|
||||
}
|
||||
}
|
||||
|
||||
:not(.dragging) .ProseMirror-selectednode.table-wrapper {
|
||||
padding: 4px 2px;
|
||||
background-color: rgba(var(--color-primary-300), 0.1) !important;
|
||||
box-shadow: rgba(var(--color-primary-100)) 0px 0px 0px 2px inset !important;
|
||||
}
|
||||
|
@ -1,3 +1,7 @@
|
||||
export { LiteTextEditor, LiteTextEditorWithRef } from "src/ui";
|
||||
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "src/ui/read-only";
|
||||
export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "src/ui/read-only";
|
||||
export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core";
|
||||
|
||||
export type { ILiteTextEditor } from "src/ui";
|
||||
export type { ILiteTextReadOnlyEditor } from "src/ui/read-only";
|
||||
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";
|
||||
|
@ -8,124 +8,79 @@ import {
|
||||
EditorContentWrapper,
|
||||
getEditorClassNames,
|
||||
useEditor,
|
||||
IMentionHighlight,
|
||||
EditorRefApi,
|
||||
} from "@plane/editor-core";
|
||||
import { FixedMenu } from "src/ui/menus/fixed-menu";
|
||||
import { LiteTextEditorExtensions } from "src/ui/extensions";
|
||||
|
||||
interface ILiteTextEditor {
|
||||
value: string;
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
restoreFile: RestoreImage;
|
||||
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
commentAccessSpecifier?: {
|
||||
accessValue: string;
|
||||
onAccessChange: (accessKey: string) => void;
|
||||
showAccessSpecifier: boolean;
|
||||
commentAccess: {
|
||||
icon: any;
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
}[];
|
||||
export interface ILiteTextEditor {
|
||||
initialValue: string;
|
||||
value?: string | null;
|
||||
fileHandler: {
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
cancelUploadImage?: () => any;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
submitButton?: React.ReactNode;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
interface LiteTextEditorProps extends ILiteTextEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const LiteTextEditor = (props: LiteTextEditorProps) => {
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
cancelUploadImage,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
forwardedRef,
|
||||
commentAccessSpecifier,
|
||||
onEnterKeyPress,
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
submitButton,
|
||||
tabIndex,
|
||||
mentionHandler,
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
cancelUploadImage,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
initialValue,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
editorClassName,
|
||||
restoreFile: fileHandler.restore,
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
forwardedRef,
|
||||
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
<div className="mt-4 w-full">
|
||||
<FixedMenu
|
||||
editor={editor}
|
||||
uploadFile={uploadFile}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
commentAccessSpecifier={commentAccessSpecifier}
|
||||
submitButton={submitButton}
|
||||
/>
|
||||
</div>
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref} />
|
||||
const LiteTextEditorWithRef = React.forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
|
||||
|
@ -1,10 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
iconName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
|
||||
<span className={`material-symbols-rounded text-sm font-light leading-5 ${className}`}>{iconName}</span>
|
||||
);
|
@ -1,199 +0,0 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
|
||||
import {
|
||||
BoldItem,
|
||||
BulletListItem,
|
||||
cn,
|
||||
CodeItem,
|
||||
findTableAncestor,
|
||||
ImageItem,
|
||||
isCellSelection,
|
||||
ItalicItem,
|
||||
LucideIconType,
|
||||
NumberedListItem,
|
||||
QuoteItem,
|
||||
StrikeThroughItem,
|
||||
TableItem,
|
||||
UnderLineItem,
|
||||
UploadImage,
|
||||
} from "@plane/editor-core";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: LucideIconType;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
commentAccessSpecifier?: {
|
||||
accessValue: string;
|
||||
onAccessChange: (accessKey: string) => void;
|
||||
showAccessSpecifier: boolean;
|
||||
commentAccess:
|
||||
| {
|
||||
icon: any;
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
}[]
|
||||
| undefined;
|
||||
};
|
||||
uploadFile: UploadImage;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
submitButton: React.ReactNode;
|
||||
};
|
||||
|
||||
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
const basicTextFormattingItems: BubbleMenuItem[] = [
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
];
|
||||
|
||||
const listFormattingItems: BubbleMenuItem[] = [BulletListItem(props.editor), NumberedListItem(props.editor)];
|
||||
|
||||
const userActionItems: BubbleMenuItem[] = [QuoteItem(props.editor), CodeItem(props.editor)];
|
||||
|
||||
function getComplexItems(): BubbleMenuItem[] {
|
||||
const items: BubbleMenuItem[] = [TableItem(props.editor)];
|
||||
|
||||
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
const complexItems: BubbleMenuItem[] = getComplexItems();
|
||||
|
||||
const handleAccessChange = (accessKey: string) => {
|
||||
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-9 w-full items-stretch gap-1.5 overflow-x-scroll">
|
||||
{props.commentAccessSpecifier && (
|
||||
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1">
|
||||
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
|
||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAccessChange(access.key)}
|
||||
className={`grid aspect-square place-items-center rounded-sm p-1 hover:bg-custom-background-90 ${
|
||||
props.commentAccessSpecifier?.accessValue === access.key ? "bg-custom-background-90" : ""
|
||||
}`}
|
||||
>
|
||||
<access.icon
|
||||
className={`h-3.5 w-3.5 ${
|
||||
props.commentAccessSpecifier?.accessValue === access.key
|
||||
? "text-custom-text-100"
|
||||
: "text-custom-text-400"
|
||||
}`}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-stretch justify-between gap-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 p-1">
|
||||
<div className="flex items-stretch">
|
||||
<div className="flex items-stretch gap-0.5 border-r border-custom-border-200 pr-2.5">
|
||||
{basicTextFormattingItems.map((item) => (
|
||||
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5">
|
||||
{listFormattingItems.map((item) => (
|
||||
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5">
|
||||
{userActionItems.map((item) => (
|
||||
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-stretch gap-0.5 pl-2.5">
|
||||
{complexItems.map((item) => (
|
||||
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
|
||||
{
|
||||
"bg-custom-background-80 text-custom-text-100": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-3.5 w-3.5", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sticky right-1">{props.submitButton}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -1,66 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
|
||||
import {
|
||||
EditorContainer,
|
||||
EditorContentWrapper,
|
||||
EditorReadOnlyRefApi,
|
||||
getEditorClassNames,
|
||||
IMentionHighlight,
|
||||
useReadOnlyEditor,
|
||||
} from "@plane/editor-core";
|
||||
|
||||
interface ICoreReadOnlyEditor {
|
||||
value: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
noBorder?: boolean;
|
||||
export interface ILiteTextReadOnlyEditor {
|
||||
initialValue: string;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
mentionHighlights: string[];
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const LiteReadOnlyEditor = ({
|
||||
editorContentCustomClassNames,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
value,
|
||||
const LiteTextReadOnlyEditor = ({
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
initialValue,
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
mentionHandler,
|
||||
tabIndex,
|
||||
}: EditorCoreProps) => {
|
||||
}: ILiteTextReadOnlyEditor) => {
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
initialValue,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const LiteReadOnlyEditorWithRef = React.forwardRef<EditorHandle, ICoreReadOnlyEditor>((props, ref) => (
|
||||
<LiteReadOnlyEditor {...props} forwardedRef={ref} />
|
||||
const LiteTextReadOnlyEditorWithRef = React.forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
|
||||
<LiteTextReadOnlyEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
|
||||
LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
|
||||
|
||||
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef };
|
||||
export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef };
|
||||
|
@ -1,4 +1,8 @@
|
||||
export { RichTextEditor, RichTextEditorWithRef } from "src/ui";
|
||||
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "src/ui/read-only";
|
||||
export type { RichTextEditorProps, IRichTextEditor } from "src/ui";
|
||||
export type { IMentionHighlight, IMentionSuggestion } from "@plane/editor-core";
|
||||
export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "src/ui/read-only";
|
||||
|
||||
export type { IRichTextEditor } from "src/ui";
|
||||
|
||||
export type { IRichTextReadOnlyEditor } from "src/ui/read-only";
|
||||
export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core";
|
||||
export type { EditorRefApi, EditorReadOnlyRefApi } from "@plane/editor-core";
|
||||
|
@ -4,23 +4,21 @@ import Placeholder from "@tiptap/extension-placeholder";
|
||||
|
||||
export const RichTextEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
||||
dragDropEnabled?: boolean,
|
||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
|
||||
) => [
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
SlashCommand(uploadFile),
|
||||
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
|
||||
if (editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image")) {
|
||||
return "";
|
||||
}
|
||||
if (node.type.name === "codeBlock") {
|
||||
return "Type in your code here...";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
|
@ -4,73 +4,56 @@ import {
|
||||
EditorContainer,
|
||||
EditorContentWrapper,
|
||||
getEditorClassNames,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
RestoreImage,
|
||||
UploadImage,
|
||||
useEditor,
|
||||
EditorRefApi,
|
||||
} from "@plane/editor-core";
|
||||
import * as React from "react";
|
||||
import { RichTextEditorExtensions } from "src/ui/extensions";
|
||||
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
||||
|
||||
export type IRichTextEditor = {
|
||||
value: string;
|
||||
initialValue?: string;
|
||||
initialValue: string;
|
||||
value?: string | null;
|
||||
dragDropEnabled?: boolean;
|
||||
uploadFile: UploadImage;
|
||||
restoreFile: RestoreImage;
|
||||
deleteFile: DeleteImage;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
cancelUploadImage?: () => any;
|
||||
rerenderOnPropsChange?: {
|
||||
id: string;
|
||||
description_html: string;
|
||||
fileHandler: {
|
||||
cancel: () => void;
|
||||
delete: DeleteImage;
|
||||
upload: UploadImage;
|
||||
restore: RestoreImage;
|
||||
};
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
id?: string;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
mentionHighlights?: string[];
|
||||
mentionSuggestions?: IMentionSuggestion[];
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||
};
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export interface RichTextEditorProps extends IRichTextEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
setEditorValueAtCursorPosition: (content: string) => void;
|
||||
}
|
||||
|
||||
const RichTextEditor = ({
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const {
|
||||
onChange,
|
||||
dragDropEnabled,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
initialValue,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
noBorder,
|
||||
cancelUploadImage,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
restoreFile,
|
||||
value,
|
||||
fileHandler,
|
||||
containerClassName,
|
||||
editorClassName = "",
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
rerenderOnPropsChange,
|
||||
mentionSuggestions,
|
||||
// rerenderOnPropsChange,
|
||||
id = "",
|
||||
tabIndex,
|
||||
}: RichTextEditorProps) => {
|
||||
mentionHandler,
|
||||
} = props;
|
||||
|
||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||
|
||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||
@ -80,50 +63,45 @@ const RichTextEditor = ({
|
||||
};
|
||||
|
||||
const editor = useEditor({
|
||||
id,
|
||||
editorClassName,
|
||||
restoreFile: fileHandler.restore,
|
||||
uploadFile: fileHandler.upload,
|
||||
deleteFile: fileHandler.delete,
|
||||
cancelUploadImage: fileHandler.cancel,
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
initialValue,
|
||||
value,
|
||||
uploadFile,
|
||||
cancelUploadImage,
|
||||
deleteFile,
|
||||
restoreFile,
|
||||
forwardedRef,
|
||||
rerenderOnPropsChange,
|
||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled, setHideDragHandleFunction),
|
||||
mentionHighlights,
|
||||
mentionSuggestions,
|
||||
// rerenderOnPropsChange,
|
||||
extensions: RichTextEditorExtensions(fileHandler.upload, dragDropEnabled, setHideDragHandleFunction),
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
noBorder: true,
|
||||
borderOnFocus: false,
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
// React.useEffect(() => {
|
||||
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
|
||||
// }, [editor, initialValue]);
|
||||
//
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContainer
|
||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||
editor={editor}
|
||||
editorContainerClassName={editorContainerClassName}
|
||||
>
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper
|
||||
tabIndex={tabIndex}
|
||||
editor={editor}
|
||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
||||
/>
|
||||
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref} />
|
||||
const RichTextEditorWithRef = React.forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
|
||||
|
@ -1,62 +1,54 @@
|
||||
"use client";
|
||||
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
EditorContainer,
|
||||
EditorContentWrapper,
|
||||
getEditorClassNames,
|
||||
IMentionHighlight,
|
||||
useReadOnlyEditor,
|
||||
} from "@plane/editor-core";
|
||||
import * as React from "react";
|
||||
|
||||
interface IRichTextReadOnlyEditor {
|
||||
value: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
mentionHighlights?: string[];
|
||||
export interface IRichTextReadOnlyEditor {
|
||||
initialValue: string;
|
||||
containerClassName?: string;
|
||||
editorClassName?: string;
|
||||
tabIndex?: number;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
mentionHandler: {
|
||||
highlights: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
}
|
||||
|
||||
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
const RichTextReadOnlyEditor = (props: IRichTextReadOnlyEditor) => {
|
||||
const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props;
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const RichReadOnlyEditor = ({
|
||||
editorContentCustomClassNames,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
value,
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
}: RichTextReadOnlyEditorProps) => {
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
initialValue,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
mentionHighlights,
|
||||
mentionHandler,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
const editorContainerClassName = getEditorClassNames({
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
<EditorContentWrapper editor={editor} />
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const RichReadOnlyEditorWithRef = React.forwardRef<EditorHandle, IRichTextReadOnlyEditor>((props, ref) => (
|
||||
<RichReadOnlyEditor {...props} forwardedRef={ref} />
|
||||
const RichTextReadOnlyEditorWithRef = React.forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => (
|
||||
<RichTextReadOnlyEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
|
||||
RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
|
||||
|
||||
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef };
|
||||
export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef };
|
||||
|
4
packages/types/src/cycle/cycle.d.ts
vendored
4
packages/types/src/cycle/cycle.d.ts
vendored
@ -97,10 +97,6 @@ export type SelectCycleType =
|
||||
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||
| undefined;
|
||||
|
||||
export type SelectIssue =
|
||||
| (TIssue & { actionType: "edit" | "delete" | "create" })
|
||||
| null;
|
||||
|
||||
export type CycleDateCheckData = {
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
|
@ -1,17 +1,9 @@
|
||||
import { EDurationFilters } from "./enums";
|
||||
import { IIssueActivity, TIssuePriorities } from "./issues";
|
||||
import { TIssue } from "./issues/issue";
|
||||
import { TIssueRelationTypes } from "./issues/issue_relation";
|
||||
import { TStateGroups } from "./state";
|
||||
|
||||
enum EDurationFilters {
|
||||
NONE = "none",
|
||||
TODAY = "today",
|
||||
THIS_WEEK = "this_week",
|
||||
THIS_MONTH = "this_month",
|
||||
THIS_YEAR = "this_year",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
export type TWidgetKeys =
|
||||
| "overview_stats"
|
||||
| "assigned_issues"
|
@ -4,3 +4,23 @@ export enum EUserProjectRoles {
|
||||
MEMBER = 15,
|
||||
ADMIN = 20,
|
||||
}
|
||||
|
||||
// project pages
|
||||
export enum EPageAccess {
|
||||
PUBLIC = 0,
|
||||
PRIVATE = 1,
|
||||
}
|
||||
|
||||
export enum EDurationFilters {
|
||||
NONE = "none",
|
||||
TODAY = "today",
|
||||
THIS_WEEK = "this_week",
|
||||
THIS_MONTH = "this_month",
|
||||
THIS_YEAR = "this_year",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
export enum EIssueCommentAccessSpecifier {
|
||||
EXTERNAL = "EXTERNAL",
|
||||
INTERNAL = "INTERNAL",
|
||||
}
|
||||
|
1
packages/types/src/inbox.d.ts
vendored
1
packages/types/src/inbox.d.ts
vendored
@ -29,6 +29,7 @@ export type TInboxIssueFilter = {
|
||||
} & {
|
||||
[key in TInboxIssueFilterDateKeys]: string[] | undefined;
|
||||
} & {
|
||||
state: string[] | undefined;
|
||||
status: TInboxIssueStatus[] | undefined;
|
||||
priority: TIssuePriorities[] | undefined;
|
||||
labels: string[] | undefined;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user