forked from github/plane
[WEB-460] refactor: editors, chore: pages list improvement (#4090)
* fix: stroing the transactions in page * fix: page details changes * chore: page response change * chore: removed duplicated endpoints * chore: optimised the urls * chore: removed archived and favorite pages * chore: revamping pages store and components * mentions loading state part done * fixed mentions not showing in modals * removed comments and cleaned up types * removed unused types * reset: head * chore: pages store and component updates * style: pages list item UI * fix: improved colors and drag handle width * fix: slash commands are no more shown in the code blocks * fix: cleanup/hide drag handles post drop * fix: hide/cleanup drag handles post drag start * fix: aligning the drag handles better with the node post css changes of the length * fix: juggling back and forth of drag handles in ordered and unordered lists * chore: fix imports, ts errors and other things * fix: clearing nodes to default node i.e paragraph before converting it to other types of nodes For more reference on what this does, please refer https://tiptap.dev/docs/editor/api/commands/clear-nodes * chore: clearNodes after delete in case of selections being present * fix: hiding link selector in the bubble menu if inline code block is selected * chore: filtering, ordering and searching implemented * chore: updated pages store and updated UI * chore: new core editor just for document editor created * chore: removed setIsSubmitting prop in doc editor * fix: fixed submitting state for image uploads * refactor: setShouldShowAlert removed * refactor: rerenderOnPropsChange prop removed * chore: type inference magic in ref to expose an api for controlling editor menu items from outside * fix: naming imports * chore: change names of the exposed functions and removing old types * refactor: remove debouncedUpdatesEnabled prop; * refactor: editor heading markings now parsed using html * chore: removed unrelated components from the document editor * refactor: page details granular components * fix: remove onActionCompleteHandler * refactor: removed rerenderOnProps change prop * feat: added getMarkDown function * chore: update dropdown option actions * fix: sidebar markings update logic * chore: add image and to-do list actions to the toolbar * fix: handling refs and populating them via callbacks * feat: scroll to node api exposed * cleaning up editor refs when the editor is destroyed * feat: scrolling added to read only instance of the editor * fix: markings logic * fix: build errors with types * fix: build erros * fix: subscribing to transactions of editor via ref * chore: remove debug statements * fix: type errors * fix: temporary different slash commands for document editor * chore: inline code extension style * chore: remove border from readOnly editor * fix: editor bottom padding * chore: pages improvements * chore: handle Enter key on the page title * feat: added loading indicator logic in mentions * fix: mentions and slash commands now work well with multiple editors in one place * refactor: page store structure, filtering logic * feat: added better seperation in inline code blocks * feat: list autojoining added * fix: pages folder structure * fix: image refocus from external parts * working lists somewhat * chore: implement page reactions * fix: build errors * fix: build errors * fixed drag handles stuff * task list item fixed * working * fix: working on multiple nested lists * chore: remove debug statements * fix: Tab key on first list item handled to not go out of editor focus * feat: threshold auto scroll support added and multi nested list selection fixed * fix: caret color bug with improved inline code blocks * fix: node range error when bulk deleting with list * fix: removed slash commands from working in code blocks * chore: update typography margins * chore: new field added in page model * fix: better type inference in slash commands * chore: code block UI * feat: image insertion at correct position using ref added * feat: added improved mentions support for space * fix: type errors in mentions for comments in web app * sync: core with document-core * fix: build errors * fix: fallback for appendTo not being able to find active container instantly * fix: page store * fix: page description * fix: css quality issues * chore: code cleanup * chore: removed placeholder text in codeblocks * chore: archived pages response change * chore: archived pages response change * fix: initial pages list fetch * fix: pages list filters and ordering * chore: add access change option in the quick actions dropdown * fix: inline code block caret fixed * regression: removing extra text * chore: caret color removed * feat: copy code button added in code blocks * fix: initial load of page details * fix: initial load of page details * fix: image resizing weird behavior on click/expanding it too much fixed now * chore: copy page response * fix: todo list spacing * chore: description html in the copy page * chore: handle latest description on refetch * fix: saner scroll behaviours * fix: block menu positioning * fix: updated empty string description * feat: tab change sync support added * fix: infinite rerendering with markings * fix: block menu finally * fix: intial load on reload bug fixed * fix: nested lists alignment * fix: editor padding * fix: first level list items copyable * chore: list spacing * fix: title change * fix: pages list block items interaction * fix: saving chip position * fix: delete action from block menu to focus properly * fix: margin-bottom as 0 to avoid weird spacing when a paragraph node follows a list node * style: table, chore: lite text editor toolbar * fix: page description tab sync * fix: lists spacing and alignment * refactor: document editor props * feat: rich text editor wrapper created and migrated core * feat: created wrapper around lite text editor and merged core * chore: add lite text editor toolbar * fix: build errors * fix: type errors and addead live updation of toolbar * chore: pages migration * fix: inbox issue * refactor: remove redundant package * refactor: unused files * fix: add dompurify to space app * fix: inline code margin * fix: editor className props * fix: build errors * fix: traversing up the tree before assuming the parent is not a list item * fix: drag handle positions for list items fixed * fix: removed focus at end logic after deleting block * fix: image wrapper overflow scroll fix with block menu's position * fix: selection and deletion logic for nested lists fixed!! * fix: hiding the block menu while scrolling in the document/app * fix: merge conflicts resolved from develop * fix: inbox issue description * chore: move page title to the web app * fix: handling edge cases for table selection * chore: lint issues * refactor: list item functions moved to same file * refactor: use mention hook * fix: added try catch blocks for mention suggestions * chore: remove unused code * fix: remove console logs * fix: remove console logs --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com> Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com> Co-authored-by: gurusainath <gurusainath007@gmail.com> Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
This commit is contained in:
parent
8b6035d315
commit
3e2355e223
@ -93,6 +93,7 @@ from .page import (
|
|||||||
PageSerializer,
|
PageSerializer,
|
||||||
PageLogSerializer,
|
PageLogSerializer,
|
||||||
SubPageSerializer,
|
SubPageSerializer,
|
||||||
|
PageDetailSerializer,
|
||||||
PageFavoriteSerializer,
|
PageFavoriteSerializer,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -3,9 +3,6 @@ from rest_framework import serializers
|
|||||||
|
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
from .issue import LabelLiteSerializer
|
|
||||||
from .workspace import WorkspaceLiteSerializer
|
|
||||||
from .project import ProjectLiteSerializer
|
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Page,
|
Page,
|
||||||
PageLog,
|
PageLog,
|
||||||
@ -17,22 +14,33 @@ from plane.db.models import (
|
|||||||
|
|
||||||
class PageSerializer(BaseSerializer):
|
class PageSerializer(BaseSerializer):
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
label_details = LabelLiteSerializer(
|
|
||||||
read_only=True, source="labels", many=True
|
|
||||||
)
|
|
||||||
labels = serializers.ListField(
|
labels = serializers.ListField(
|
||||||
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
|
||||||
write_only=True,
|
write_only=True,
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
project_detail = ProjectLiteSerializer(source="project", read_only=True)
|
|
||||||
workspace_detail = WorkspaceLiteSerializer(
|
|
||||||
source="workspace", read_only=True
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Page
|
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 = [
|
read_only_fields = [
|
||||||
"workspace",
|
"workspace",
|
||||||
"project",
|
"project",
|
||||||
@ -48,8 +56,12 @@ class PageSerializer(BaseSerializer):
|
|||||||
labels = validated_data.pop("labels", None)
|
labels = validated_data.pop("labels", None)
|
||||||
project_id = self.context["project_id"]
|
project_id = self.context["project_id"]
|
||||||
owned_by_id = self.context["owned_by_id"]
|
owned_by_id = self.context["owned_by_id"]
|
||||||
|
description_html = self.context["description_html"]
|
||||||
page = Page.objects.create(
|
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:
|
if labels is not None:
|
||||||
@ -91,6 +103,13 @@ class PageSerializer(BaseSerializer):
|
|||||||
return super().update(instance, validated_data)
|
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):
|
class SubPageSerializer(BaseSerializer):
|
||||||
entity_details = serializers.SerializerMethodField()
|
entity_details = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
@ -31,102 +31,51 @@ urlpatterns = [
|
|||||||
),
|
),
|
||||||
name="project-pages",
|
name="project-pages",
|
||||||
),
|
),
|
||||||
|
# favorite pages
|
||||||
path(
|
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(
|
PageFavoriteViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "list",
|
|
||||||
"post": "create",
|
"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",
|
"delete": "destroy",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="user-favorite-pages",
|
name="user-favorite-pages",
|
||||||
),
|
),
|
||||||
|
# archived pages
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
|
||||||
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/",
|
|
||||||
PageViewSet.as_view(
|
PageViewSet.as_view(
|
||||||
{
|
{
|
||||||
"post": "archive",
|
"post": "archive",
|
||||||
|
"delete": "unarchive",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="project-page-archive",
|
name="project-page-archive-unarchive",
|
||||||
),
|
),
|
||||||
|
# lock and unlock
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unarchive/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
|
||||||
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/",
|
|
||||||
PageViewSet.as_view(
|
PageViewSet.as_view(
|
||||||
{
|
{
|
||||||
"post": "lock",
|
"post": "lock",
|
||||||
|
"delete": "unlock",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
name="project-pages",
|
name="project-pages-lock-unlock",
|
||||||
),
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unlock/",
|
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
|
||||||
PageViewSet.as_view(
|
|
||||||
{
|
|
||||||
"post": "unlock",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
path(
|
|
||||||
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/",
|
|
||||||
PageLogEndpoint.as_view(),
|
PageLogEndpoint.as_view(),
|
||||||
name="page-transactions",
|
name="page-transactions",
|
||||||
),
|
),
|
||||||
path(
|
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(),
|
PageLogEndpoint.as_view(),
|
||||||
name="page-transactions",
|
name="page-transactions",
|
||||||
),
|
),
|
||||||
path(
|
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(),
|
SubPagesEndpoint.as_view(),
|
||||||
name="sub-page",
|
name="sub-page",
|
||||||
),
|
),
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
@ -17,6 +18,7 @@ from plane.app.serializers import (
|
|||||||
PageLogSerializer,
|
PageLogSerializer,
|
||||||
PageSerializer,
|
PageSerializer,
|
||||||
SubPageSerializer,
|
SubPageSerializer,
|
||||||
|
PageDetailSerializer,
|
||||||
)
|
)
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
Page,
|
Page,
|
||||||
@ -28,6 +30,8 @@ from plane.db.models import (
|
|||||||
# Module imports
|
# Module imports
|
||||||
from ..base import BaseAPIView, BaseViewSet
|
from ..base import BaseAPIView, BaseViewSet
|
||||||
|
|
||||||
|
from plane.bgtasks.page_transaction_task import page_transaction
|
||||||
|
|
||||||
|
|
||||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||||
# Your SQL query
|
# Your SQL query
|
||||||
@ -87,11 +91,21 @@ class PageViewSet(BaseViewSet):
|
|||||||
def create(self, request, slug, project_id):
|
def create(self, request, slug, project_id):
|
||||||
serializer = PageSerializer(
|
serializer = PageSerializer(
|
||||||
data=request.data,
|
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():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
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.data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
@ -125,9 +139,22 @@ class PageViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
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():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
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.data, status=status.HTTP_200_OK)
|
||||||
return Response(
|
return Response(
|
||||||
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
serializer.errors, status=status.HTTP_400_BAD_REQUEST
|
||||||
@ -140,18 +167,24 @@ class PageViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
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(
|
page = Page.objects.filter(
|
||||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
pk=pk, workspace__slug=slug, project_id=project_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
page.is_locked = True
|
page.is_locked = True
|
||||||
page.save()
|
page.save()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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(
|
page = Page.objects.filter(
|
||||||
pk=page_id, workspace__slug=slug, project_id=project_id
|
pk=pk, workspace__slug=slug, project_id=project_id
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
page.is_locked = False
|
page.is_locked = False
|
||||||
@ -160,13 +193,13 @@ class PageViewSet(BaseViewSet):
|
|||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
def list(self, request, slug, project_id):
|
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
|
pages = PageSerializer(queryset, many=True).data
|
||||||
return Response(pages, status=status.HTTP_200_OK)
|
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(
|
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
|
# only the owner or admin can archive the page
|
||||||
@ -184,13 +217,16 @@ class PageViewSet(BaseViewSet):
|
|||||||
status=status.HTTP_400_BAD_REQUEST,
|
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(
|
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
|
# only the owner or admin can un archive the page
|
||||||
@ -213,19 +249,10 @@ class PageViewSet(BaseViewSet):
|
|||||||
page.parent = None
|
page.parent = None
|
||||||
page.save(update_fields=["parent"])
|
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)
|
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):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
page = Page.objects.get(
|
page = Page.objects.get(
|
||||||
pk=pk, workspace__slug=slug, project_id=project_id
|
pk=pk, workspace__slug=slug, project_id=project_id
|
||||||
@ -269,29 +296,20 @@ class PageFavoriteViewSet(BaseViewSet):
|
|||||||
serializer_class = PageFavoriteSerializer
|
serializer_class = PageFavoriteSerializer
|
||||||
model = PageFavorite
|
model = PageFavorite
|
||||||
|
|
||||||
def get_queryset(self):
|
def create(self, request, slug, project_id, pk):
|
||||||
return self.filter_queryset(
|
_ = PageFavorite.objects.create(
|
||||||
super()
|
project_id=project_id,
|
||||||
.get_queryset()
|
page_id=pk,
|
||||||
.filter(archived_at__isnull=True)
|
user=request.user,
|
||||||
.filter(workspace__slug=self.kwargs.get("slug"))
|
|
||||||
.filter(user=self.request.user)
|
|
||||||
.select_related("page", "page__owned_by")
|
|
||||||
)
|
)
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
def create(self, request, slug, project_id):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
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):
|
|
||||||
page_favorite = PageFavorite.objects.get(
|
page_favorite = PageFavorite.objects.get(
|
||||||
project=project_id,
|
project=project_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
page_id=page_id,
|
page_id=pk,
|
||||||
)
|
)
|
||||||
page_favorite.delete()
|
page_favorite.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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.db.models import State, Issue
|
||||||
from plane.utils.cache import invalidate_cache
|
from plane.utils.cache import invalidate_cache
|
||||||
|
|
||||||
|
|
||||||
class StateViewSet(BaseViewSet):
|
class StateViewSet(BaseViewSet):
|
||||||
serializer_class = StateSerializer
|
serializer_class = StateSerializer
|
||||||
model = State
|
model = State
|
||||||
@ -38,7 +39,9 @@ class StateViewSet(BaseViewSet):
|
|||||||
.distinct()
|
.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):
|
def create(self, request, slug, project_id):
|
||||||
serializer = StateSerializer(data=request.data)
|
serializer = StateSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
@ -59,7 +62,9 @@ class StateViewSet(BaseViewSet):
|
|||||||
return Response(state_dict, status=status.HTTP_200_OK)
|
return Response(state_dict, status=status.HTTP_200_OK)
|
||||||
return Response(states, 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):
|
def mark_as_default(self, request, slug, project_id, pk):
|
||||||
# Select all the states which are marked as default
|
# Select all the states which are marked as default
|
||||||
_ = State.objects.filter(
|
_ = State.objects.filter(
|
||||||
@ -70,7 +75,9 @@ class StateViewSet(BaseViewSet):
|
|||||||
).update(default=True)
|
).update(default=True)
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
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):
|
def destroy(self, request, slug, project_id, pk):
|
||||||
state = State.objects.get(
|
state = State.objects.get(
|
||||||
is_triage=False,
|
is_triage=False,
|
||||||
|
@ -326,11 +326,11 @@ class IssueViewFavoriteViewSet(BaseViewSet):
|
|||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
def destroy(self, request, slug, project_id, view_id):
|
def destroy(self, request, slug, project_id, view_id):
|
||||||
view_favourite = IssueViewFavorite.objects.get(
|
view_favorite = IssueViewFavorite.objects.get(
|
||||||
project=project_id,
|
project=project_id,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
view_id=view_id,
|
view_id=view_id,
|
||||||
)
|
)
|
||||||
view_favourite.delete()
|
view_favorite.delete()
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
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()
|
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
|
from plane.utils.html_processor import strip_tags
|
||||||
|
|
||||||
|
|
||||||
|
def get_view_props():
|
||||||
|
return {"full_width": False}
|
||||||
|
|
||||||
|
|
||||||
class Page(ProjectBaseModel):
|
class Page(ProjectBaseModel):
|
||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
description = models.JSONField(default=dict, blank=True)
|
description = models.JSONField(default=dict, blank=True)
|
||||||
@ -35,6 +39,7 @@ class Page(ProjectBaseModel):
|
|||||||
)
|
)
|
||||||
archived_at = models.DateField(null=True)
|
archived_at = models.DateField(null=True)
|
||||||
is_locked = models.BooleanField(default=False)
|
is_locked = models.BooleanField(default=False)
|
||||||
|
view_props = models.JSONField(default=get_view_props)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Page"
|
verbose_name = "Page"
|
||||||
@ -81,7 +86,7 @@ class PageLog(ProjectBaseModel):
|
|||||||
ordering = ("-created_at",)
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.page.name} {self.type}"
|
return f"{self.page.name} {self.entity_name}"
|
||||||
|
|
||||||
|
|
||||||
class PageBlock(ProjectBaseModel):
|
class PageBlock(ProjectBaseModel):
|
||||||
|
@ -28,6 +28,7 @@
|
|||||||
"react-dom": "18.2.0"
|
"react-dom": "18.2.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@plane/ui": "*",
|
||||||
"@tiptap/core": "^2.1.13",
|
"@tiptap/core": "^2.1.13",
|
||||||
"@tiptap/extension-blockquote": "^2.1.13",
|
"@tiptap/extension-blockquote": "^2.1.13",
|
||||||
"@tiptap/extension-code-block-lowlight": "^2.1.13",
|
"@tiptap/extension-code-block-lowlight": "^2.1.13",
|
||||||
@ -39,6 +40,7 @@
|
|||||||
"@tiptap/extension-task-list": "^2.1.13",
|
"@tiptap/extension-task-list": "^2.1.13",
|
||||||
"@tiptap/extension-text-style": "^2.1.13",
|
"@tiptap/extension-text-style": "^2.1.13",
|
||||||
"@tiptap/extension-underline": "^2.1.13",
|
"@tiptap/extension-underline": "^2.1.13",
|
||||||
|
"prosemirror-codemark": "^0.4.2",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/react": "^2.1.13",
|
"@tiptap/react": "^2.1.13",
|
||||||
"@tiptap/starter-kit": "^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 { 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 { CoreEditorProps } from "src/ui/props";
|
||||||
import { CoreEditorExtensions } from "src/ui/extensions";
|
import { CoreEditorExtensions } from "src/ui/extensions";
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
import { getTrimmedHTML } from "src/lib/utils";
|
import { getTrimmedHTML } from "src/lib/utils";
|
||||||
import { DeleteImage } from "src/types/delete-image";
|
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 { RestoreImage } from "src/types/restore-image";
|
||||||
import { UploadImage } from "src/types/upload-image";
|
import { UploadImage } from "src/types/upload-image";
|
||||||
import { Selection } from "@tiptap/pm/state";
|
import { Selection } from "@tiptap/pm/state";
|
||||||
import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position";
|
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 {
|
interface CustomEditorProps {
|
||||||
|
id?: string;
|
||||||
uploadFile: UploadImage;
|
uploadFile: UploadImage;
|
||||||
restoreFile: RestoreImage;
|
restoreFile: RestoreImage;
|
||||||
rerenderOnPropsChange?: {
|
|
||||||
id: string;
|
|
||||||
description_html: string;
|
|
||||||
};
|
|
||||||
deleteFile: DeleteImage;
|
deleteFile: DeleteImage;
|
||||||
cancelUploadImage?: () => any;
|
cancelUploadImage?: () => any;
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
initialValue: string;
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
editorClassName: string;
|
||||||
value: string;
|
// undefined when prop is not passed, null if intentionally passed to stop
|
||||||
debouncedUpdatesEnabled?: boolean;
|
// swr syncing
|
||||||
onStart?: (json: any, html: string) => void;
|
value: string | null | undefined;
|
||||||
onChange?: (json: any, html: string) => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
forwardedRef?: any;
|
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||||
mentionHighlights?: string[];
|
mentionHandler: {
|
||||||
mentionSuggestions?: IMentionSuggestion[];
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
|
};
|
||||||
|
handleEditorReady?: (value: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useEditor = ({
|
export const useEditor = ({
|
||||||
uploadFile,
|
uploadFile,
|
||||||
|
id = "",
|
||||||
deleteFile,
|
deleteFile,
|
||||||
cancelUploadImage,
|
cancelUploadImage,
|
||||||
editorProps = {},
|
editorProps = {},
|
||||||
|
initialValue,
|
||||||
|
editorClassName,
|
||||||
value,
|
value,
|
||||||
rerenderOnPropsChange,
|
|
||||||
extensions = [],
|
extensions = [],
|
||||||
onStart,
|
|
||||||
onChange,
|
onChange,
|
||||||
setIsSubmitting,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
restoreFile,
|
restoreFile,
|
||||||
setShouldShowAlert,
|
handleEditorReady,
|
||||||
mentionHighlights,
|
mentionHandler,
|
||||||
mentionSuggestions,
|
|
||||||
}: CustomEditorProps) => {
|
}: CustomEditorProps) => {
|
||||||
const editor = useCustomEditor(
|
const editor = useCustomEditor({
|
||||||
{
|
|
||||||
editorProps: {
|
editorProps: {
|
||||||
...CoreEditorProps(uploadFile, setIsSubmitting),
|
...CoreEditorProps(uploadFile, editorClassName),
|
||||||
...editorProps,
|
...editorProps,
|
||||||
},
|
},
|
||||||
extensions: [
|
extensions: [
|
||||||
...CoreEditorExtensions(
|
...CoreEditorExtensions(
|
||||||
{
|
{
|
||||||
mentionSuggestions: mentionSuggestions ?? [],
|
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||||
mentionHighlights: mentionHighlights ?? [],
|
mentionHighlights: mentionHandler.highlights ?? [],
|
||||||
},
|
},
|
||||||
deleteFile,
|
deleteFile,
|
||||||
restoreFile,
|
restoreFile,
|
||||||
@ -68,28 +69,37 @@ export const useEditor = ({
|
|||||||
),
|
),
|
||||||
...extensions,
|
...extensions,
|
||||||
],
|
],
|
||||||
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||||
onCreate: async ({ editor }) => {
|
onCreate: async () => {
|
||||||
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
handleEditorReady?.(true);
|
||||||
},
|
},
|
||||||
onTransaction: async ({ editor }) => {
|
onTransaction: async ({ editor }) => {
|
||||||
setSavedSelection(editor.state.selection);
|
setSavedSelection(editor.state.selection);
|
||||||
},
|
},
|
||||||
onUpdate: async ({ editor }) => {
|
onUpdate: async ({ editor }) => {
|
||||||
setIsSubmitting?.("submitting");
|
|
||||||
setShouldShowAlert?.(true);
|
|
||||||
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
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);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
editorRef.current = editor;
|
|
||||||
|
|
||||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(
|
||||||
|
forwardedRef,
|
||||||
|
() => ({
|
||||||
clearEditor: () => {
|
clearEditor: () => {
|
||||||
editorRef.current?.commands.clearContent();
|
editorRef.current?.commands.clearContent();
|
||||||
},
|
},
|
||||||
@ -101,11 +111,68 @@ export const useEditor = ({
|
|||||||
insertContentAtSavedSelection(editorRef, content, savedSelection);
|
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) {
|
if (!editor) {
|
||||||
return null;
|
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;
|
return editor;
|
||||||
};
|
};
|
||||||
|
@ -1,53 +1,61 @@
|
|||||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
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 { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions";
|
||||||
import { CoreReadOnlyEditorProps } from "src/ui/read-only/props";
|
import { CoreReadOnlyEditorProps } from "src/ui/read-only/props";
|
||||||
import { EditorProps } from "@tiptap/pm/view";
|
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 {
|
interface CustomReadOnlyEditorProps {
|
||||||
value: string;
|
initialValue: string;
|
||||||
forwardedRef?: any;
|
editorClassName: string;
|
||||||
|
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
extensions?: any;
|
extensions?: any;
|
||||||
editorProps?: EditorProps;
|
editorProps?: EditorProps;
|
||||||
rerenderOnPropsChange?: {
|
handleEditorReady?: (value: boolean) => void;
|
||||||
id: string;
|
mentionHandler: {
|
||||||
description_html: string;
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
};
|
};
|
||||||
mentionHighlights?: string[];
|
|
||||||
mentionSuggestions?: IMentionSuggestion[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useReadOnlyEditor = ({
|
export const useReadOnlyEditor = ({
|
||||||
value,
|
initialValue,
|
||||||
|
editorClassName,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions = [],
|
extensions = [],
|
||||||
editorProps = {},
|
editorProps = {},
|
||||||
rerenderOnPropsChange,
|
handleEditorReady,
|
||||||
mentionHighlights,
|
mentionHandler,
|
||||||
mentionSuggestions,
|
|
||||||
}: CustomReadOnlyEditorProps) => {
|
}: CustomReadOnlyEditorProps) => {
|
||||||
const editor = useCustomEditor(
|
const editor = useCustomEditor({
|
||||||
{
|
|
||||||
editable: false,
|
editable: false,
|
||||||
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
|
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||||
editorProps: {
|
editorProps: {
|
||||||
...CoreReadOnlyEditorProps,
|
...CoreReadOnlyEditorProps(editorClassName),
|
||||||
...editorProps,
|
...editorProps,
|
||||||
},
|
},
|
||||||
|
onCreate: async () => {
|
||||||
|
handleEditorReady?.(true);
|
||||||
|
},
|
||||||
extensions: [
|
extensions: [
|
||||||
...CoreReadOnlyEditorExtensions({
|
...CoreReadOnlyEditorExtensions({
|
||||||
mentionSuggestions: mentionSuggestions ?? [],
|
mentionHighlights: mentionHandler.highlights,
|
||||||
mentionHighlights: mentionHighlights ?? [],
|
|
||||||
}),
|
}),
|
||||||
...extensions,
|
...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);
|
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||||
editorRef.current = editor;
|
|
||||||
|
|
||||||
useImperativeHandle(forwardedRef, () => ({
|
useImperativeHandle(forwardedRef, () => ({
|
||||||
clearEditor: () => {
|
clearEditor: () => {
|
||||||
@ -56,11 +64,20 @@ export const useReadOnlyEditor = ({
|
|||||||
setEditorValue: (content: string) => {
|
setEditorValue: (content: string) => {
|
||||||
editorRef.current?.commands.setContent(content);
|
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) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editorRef.current = editor;
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
@ -26,6 +26,7 @@ export * from "src/lib/editor-commands";
|
|||||||
// types
|
// types
|
||||||
export type { DeleteImage } from "src/types/delete-image";
|
export type { DeleteImage } from "src/types/delete-image";
|
||||||
export type { UploadImage } from "src/types/upload-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 { RestoreImage } from "src/types/restore-image";
|
||||||
export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
|
export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
|
||||||
export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion";
|
export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion";
|
||||||
|
@ -1,21 +1,22 @@
|
|||||||
import { Editor, Range } from "@tiptap/core";
|
import { Editor, Range } from "@tiptap/core";
|
||||||
import { startImageUpload } from "src/ui/plugins/upload-image";
|
import { startImageUpload } from "src/ui/plugins/upload-image";
|
||||||
import { findTableAncestor } from "src/lib/utils";
|
import { findTableAncestor } from "src/lib/utils";
|
||||||
|
import { Selection } from "@tiptap/pm/state";
|
||||||
import { UploadImage } from "src/types/upload-image";
|
import { UploadImage } from "src/types/upload-image";
|
||||||
|
|
||||||
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 1 }).run();
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||||
else editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run();
|
else editor.chain().focus().toggleHeading({ level: 1 }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
|
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 2 }).run();
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||||
else editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run();
|
else editor.chain().focus().toggleHeading({ level: 2 }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 3 }).run();
|
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||||
else editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run();
|
else editor.chain().focus().toggleHeading({ level: 3 }).run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
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
|
// Check if code block is active then toggle code block
|
||||||
if (editor.isActive("codeBlock")) {
|
if (editor.isActive("codeBlock")) {
|
||||||
if (range) {
|
if (range) {
|
||||||
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editor.chain().focus().clearNodes().toggleCodeBlock().run();
|
editor.chain().focus().toggleCodeBlock().run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,32 +50,32 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
|
|||||||
|
|
||||||
if (isSelectionEmpty) {
|
if (isSelectionEmpty) {
|
||||||
if (range) {
|
if (range) {
|
||||||
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
|
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editor.chain().focus().clearNodes().toggleCodeBlock().run();
|
editor.chain().focus().toggleCodeBlock().run();
|
||||||
} else {
|
} else {
|
||||||
if (range) {
|
if (range) {
|
||||||
editor.chain().focus().deleteRange(range).clearNodes().toggleCode().run();
|
editor.chain().focus().deleteRange(range).toggleCode().run();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
editor.chain().focus().clearNodes().toggleCode().run();
|
editor.chain().focus().toggleCode().run();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleOrderedList().run();
|
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||||
else editor.chain().focus().clearNodes().toggleOrderedList().run();
|
else editor.chain().focus().toggleOrderedList().run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBulletList().run();
|
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||||
else editor.chain().focus().clearNodes().toggleBulletList().run();
|
else editor.chain().focus().toggleBulletList().run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleTaskList = (editor: Editor, range?: Range) => {
|
export const toggleTaskList = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleTaskList().run();
|
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||||
else editor.chain().focus().clearNodes().toggleTaskList().run();
|
else editor.chain().focus().toggleTaskList().run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const toggleStrike = (editor: Editor, range?: Range) => {
|
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) => {
|
export const toggleBlockquote = (editor: Editor, range?: Range) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBlockquote().run();
|
if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run();
|
||||||
else editor.chain().focus().clearNodes().toggleBlockquote().run();
|
else editor.chain().focus().toggleBlockquote().run();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertTableCommand = (editor: Editor, range?: Range) => {
|
export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
const selection: any = window?.getSelection();
|
const selection = window.getSelection();
|
||||||
|
if (selection) {
|
||||||
if (selection.rangeCount !== 0) {
|
if (selection.rangeCount !== 0) {
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
if (findTableAncestor(range.startContainer)) {
|
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();
|
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();
|
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 = (
|
export const insertImageCommand = (
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
savedSelection?: Selection | null,
|
||||||
range?: Range
|
range?: Range
|
||||||
) => {
|
) => {
|
||||||
if (range) editor.chain().focus().deleteRange(range).run();
|
if (range) editor.chain().focus().deleteRange(range).run();
|
||||||
@ -122,8 +125,8 @@ export const insertImageCommand = (
|
|||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
const file = input.files[0];
|
const file = input.files[0];
|
||||||
const pos = editor.view.state.selection.from;
|
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
|
||||||
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
|
startImageUpload(file, editor.view, pos, uploadFile);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
input.click();
|
input.click();
|
||||||
|
@ -4,15 +4,17 @@ import { twMerge } from "tailwind-merge";
|
|||||||
interface EditorClassNames {
|
interface EditorClassNames {
|
||||||
noBorder?: boolean;
|
noBorder?: boolean;
|
||||||
borderOnFocus?: boolean;
|
borderOnFocus?: boolean;
|
||||||
customClassName?: string;
|
containerClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) =>
|
export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassName }: EditorClassNames) =>
|
||||||
cn(
|
cn(
|
||||||
"relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md",
|
"w-full max-w-full sm:rounded-lg focus:outline-none focus:border-0",
|
||||||
noBorder ? "" : "border border-custom-border-200",
|
{
|
||||||
borderOnFocus ? "focus:border border-custom-border-300" : "focus:border-0",
|
"border border-custom-border-200": !noBorder,
|
||||||
customClassName
|
"focus:border border-custom-border-300": borderOnFocus,
|
||||||
|
},
|
||||||
|
containerClassName
|
||||||
);
|
);
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
@ -7,10 +7,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* block quotes */
|
/* 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::before,
|
||||||
.ProseMirror blockquote p::after {
|
.ProseMirror blockquote p::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
/* end block quotes */
|
||||||
|
|
||||||
.ProseMirror code::before,
|
.ProseMirror code::before,
|
||||||
.ProseMirror code::after {
|
.ProseMirror code::after {
|
||||||
@ -28,8 +35,8 @@
|
|||||||
/* Custom image styles */
|
/* Custom image styles */
|
||||||
.ProseMirror img {
|
.ProseMirror img {
|
||||||
transition: filter 0.1s ease-in-out;
|
transition: filter 0.1s ease-in-out;
|
||||||
margin-top: 0 !important;
|
margin-top: 8px;
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -37,22 +44,52 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.ProseMirror-selectednode {
|
&.ProseMirror-selectednode {
|
||||||
outline: 3px solid #5abbf7;
|
outline: 3px solid rgba(var(--color-primary-100));
|
||||||
filter: brightness(90%);
|
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;
|
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/ */
|
/* 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 {
|
ul[data-type="taskList"] li > label {
|
||||||
margin-right: 0.2rem;
|
margin: 0.1rem 0.15rem 0 0;
|
||||||
user-select: none;
|
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) {
|
@media screen and (max-width: 768px) {
|
||||||
ul[data-type="taskList"] li > label {
|
ul[data-type="taskList"] li > label {
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
@ -60,6 +97,7 @@ ul[data-type="taskList"] li > label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||||
|
position: relative;
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background-color: rgb(var(--color-background-100));
|
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));
|
border: 1.5px solid rgb(var(--color-text-100));
|
||||||
margin-right: 0.2rem;
|
margin-right: 0.2rem;
|
||||||
margin-top: 0.15rem;
|
margin-top: 0.15rem;
|
||||||
display: grid;
|
|
||||||
place-content: center;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgb(var(--color-background-80));
|
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));
|
background-color: rgb(var(--color-background-90));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* check sign */
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
width: 0.5em;
|
width: 0.5em;
|
||||||
height: 0.5em;
|
height: 0.5em;
|
||||||
transform: scale(0);
|
transform: scale(0);
|
||||||
|
transform-origin: center;
|
||||||
transition: 120ms transform ease-in-out;
|
transition: 120ms transform ease-in-out;
|
||||||
box-shadow: inset 1em 1em;
|
box-shadow: inset 1em 1em;
|
||||||
transform-origin: center;
|
|
||||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:checked::before {
|
&:checked::before {
|
||||||
transform: scale(1);
|
transform: scale(1) translate(-50%, -50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
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: line-through;
|
||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
}
|
}
|
||||||
@ -133,12 +173,12 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
|||||||
-moz-appearance: textfield;
|
-moz-appearance: textfield;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fadeIn {
|
.fade-in {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: opacity 0.3s ease-in;
|
transition: opacity 0.3s ease-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fadeOut {
|
.fade-out {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s ease-out;
|
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-top: 0 !important;
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
|
|
||||||
&:before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -175,21 +215,13 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
|||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror table * p {
|
|
||||||
padding: 0px 1px;
|
|
||||||
margin: 6px 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror table * .is-empty::before {
|
.ProseMirror table * .is-empty::before {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror pre {
|
.ProseMirror pre {
|
||||||
background: rgba(var(--color-background-80));
|
font-family: JetBrainsMono, monospace;
|
||||||
border-radius: 0.5rem;
|
tab-size: 2;
|
||||||
color: rgba(var(--color-text-100));
|
|
||||||
font-family: "JetBrainsMono", monospace;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProseMirror pre code {
|
.ProseMirror pre code {
|
||||||
@ -214,3 +246,107 @@ div[data-type="horizontalRule"] {
|
|||||||
.moveable-control-box {
|
.moveable-control-box {
|
||||||
z-index: 10 !important;
|
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;
|
overflow-x: auto;
|
||||||
padding: 2px;
|
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table {
|
.table-wrapper table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
table-layout: fixed;
|
table-layout: fixed;
|
||||||
margin: 0;
|
margin: 0.5rem 0 1rem 0;
|
||||||
margin-bottom: 1rem;
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
border: 2px solid rgba(var(--color-border-300));
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table td,
|
.table-wrapper table p {
|
||||||
.tableWrapper table th {
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-wrapper table td,
|
||||||
|
.table-wrapper table th {
|
||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
border: 1px solid rgba(var(--color-border-300));
|
border: 1px solid rgba(var(--color-border-200));
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
@ -29,86 +31,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table td > *,
|
.table-wrapper table td > *,
|
||||||
.tableWrapper table th > * {
|
.table-wrapper table th > * {
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
padding: 0.25rem 0 !important;
|
padding: 0.25rem 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table td.has-focus,
|
.table-wrapper table td.has-focus,
|
||||||
.tableWrapper table th.has-focus {
|
.table-wrapper table th.has-focus {
|
||||||
box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important;
|
box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table th {
|
.table-wrapper table th {
|
||||||
font-weight: bold;
|
font-weight: 500;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
background-color: #d9e4ff;
|
background-color: rgba(var(--color-background-90));
|
||||||
color: #171717;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table th * {
|
.table-wrapper table .selectedCell {
|
||||||
font-weight: 600;
|
border-color: rgba(var(--color-primary-100));
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper table .selectedCell:after {
|
/* table dropdown */
|
||||||
z-index: 2;
|
.table-wrapper table .column-resize-handle {
|
||||||
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 {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -2px;
|
right: -2px;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: -2px;
|
width: 2px;
|
||||||
width: 4px;
|
height: 100%;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
background-color: #d9e4ff;
|
background-color: rgba(var(--color-primary-100));
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls {
|
.table-wrapper .table-controls {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .columnsControl,
|
.table-wrapper .table-controls .columns-control,
|
||||||
.tableWrapper .tableControls .rowsControl {
|
.table-wrapper .table-controls .rows-control {
|
||||||
transition: opacity ease-in 100ms;
|
transition: opacity ease-in 100ms;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 5;
|
z-index: 5;
|
||||||
@ -117,124 +78,50 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .columnsControl {
|
.table-wrapper .table-controls .columns-control {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
|
.table-wrapper .table-controls .columns-control .columns-control-div {
|
||||||
color: white;
|
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");
|
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;
|
width: 30px;
|
||||||
height: 15px;
|
height: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .rowsControl {
|
.table-wrapper .table-controls .rows-control {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
|
.table-wrapper .table-controls .rows-control .rows-control-div {
|
||||||
color: white;
|
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");
|
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;
|
height: 30px;
|
||||||
width: 15px;
|
width: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .rowsControlDiv {
|
.table-wrapper .table-controls .rows-control-div,
|
||||||
background-color: #d9e4ff;
|
.table-wrapper .table-controls .columns-control-div {
|
||||||
border: 1px solid rgba(var(--color-border-200));
|
background-color: rgba(var(--color-background-80));
|
||||||
border-radius: 2px;
|
border: 0.5px solid rgba(var(--color-border-200));
|
||||||
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;
|
|
||||||
border-radius: 4px;
|
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;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
|
.resize-cursor .table-wrapper .table-controls .rows-control,
|
||||||
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
|
.table-wrapper.controls--disabled .table-controls .rows-control,
|
||||||
background-color: rgba(var(--color-background-80), 0.6);
|
.resize-cursor .table-wrapper .table-controls .columns-control,
|
||||||
}
|
.table-wrapper.controls--disabled .table-controls .columns-control {
|
||||||
|
|
||||||
.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 {
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
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 = {
|
export type IMentionSuggestion = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
entity_name: string;
|
||||||
|
entity_identifier: string;
|
||||||
avatar: string;
|
avatar: string;
|
||||||
title: string;
|
title: string;
|
||||||
subtitle: string;
|
subtitle: string;
|
||||||
redirect_uri: string;
|
redirect_uri: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CommandProps = {
|
||||||
|
editor: Editor;
|
||||||
|
range: Range;
|
||||||
|
};
|
||||||
|
|
||||||
export type IMentionHighlight = string;
|
export type IMentionHighlight = string;
|
||||||
|
@ -4,13 +4,13 @@ import { cn } from "src/lib/utils";
|
|||||||
|
|
||||||
interface EditorContainerProps {
|
interface EditorContainerProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
editorClassNames: string;
|
editorContainerClassName: string;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
hideDragHandle?: () => void;
|
hideDragHandle?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||||
const { editor, editorClassNames, hideDragHandle, children } = props;
|
const { editor, editorContainerClassName, hideDragHandle, children } = props;
|
||||||
|
|
||||||
const handleContainerClick = () => {
|
const handleContainerClick = () => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
@ -51,10 +51,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
|||||||
<div
|
<div
|
||||||
id="editor-container"
|
id="editor-container"
|
||||||
onClick={handleContainerClick}
|
onClick={handleContainerClick}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={hideDragHandle}
|
||||||
hideDragHandle?.();
|
className={cn(
|
||||||
}}
|
"cursor-text relative",
|
||||||
className={cn(`cursor-text`, { "active-editor": editor?.isFocused && editor?.isEditable }, editorClassNames)}
|
{
|
||||||
|
"active-editor": editor?.isFocused && editor?.isEditable,
|
||||||
|
},
|
||||||
|
editorContainerClassName
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,22 +4,15 @@ import { ImageResizer } from "src/ui/extensions/image/image-resize";
|
|||||||
|
|
||||||
interface EditorContentProps {
|
interface EditorContentProps {
|
||||||
editor: Editor | null;
|
editor: Editor | null;
|
||||||
editorContentCustomClassNames: string | undefined;
|
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
|
||||||
const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
|
const { editor, tabIndex, children } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
|
||||||
className={`contentEditor ${editorContentCustomClassNames}`}
|
|
||||||
tabIndex={tabIndex}
|
|
||||||
onFocus={() => {
|
|
||||||
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
|
||||||
{children}
|
{children}
|
||||||
|
@ -32,7 +32,8 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
|||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
HTMLAttributes: {
|
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",
|
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);
|
lowlight.register("ts", ts);
|
||||||
|
|
||||||
import { Selection } from "@tiptap/pm/state";
|
import { Selection } from "@tiptap/pm/state";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import { CodeBlockComponent } from "./code-block-node-view";
|
||||||
|
|
||||||
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(CodeBlockComponent);
|
||||||
|
},
|
||||||
|
|
||||||
addKeyboardShortcuts() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
Tab: ({ editor }) => {
|
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() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
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 }) => {
|
Delete: ({ editor }) => {
|
||||||
let handled = false;
|
let handled = false;
|
||||||
|
|
||||||
|
@ -7,10 +7,19 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
|||||||
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
|
||||||
if (imageInfo) {
|
if (imageInfo) {
|
||||||
const selection = editor.state.selection;
|
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({
|
editor.commands.setImage({
|
||||||
src: imageInfo.src,
|
src: imageInfo.src,
|
||||||
width: Number(imageInfo.style.width.replace("px", "")),
|
width: width,
|
||||||
height: Number(imageInfo.style.height.replace("px", "")),
|
height: height,
|
||||||
} as any);
|
} as any);
|
||||||
editor.commands.setNodeSelection(selection.from);
|
editor.commands.setNodeSelection(selection.from);
|
||||||
}
|
}
|
||||||
@ -21,7 +30,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Moveable
|
<Moveable
|
||||||
target={document.querySelector(".ProseMirror-selectednode") as any}
|
target={document.querySelector(".ProseMirror-selectednode") as HTMLElement}
|
||||||
container={null}
|
container={null}
|
||||||
origin={false}
|
origin={false}
|
||||||
edge={false}
|
edge={false}
|
||||||
@ -37,27 +46,29 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
|
|||||||
setAspectRatio(originalWidth / originalHeight);
|
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]) {
|
if (delta[0]) {
|
||||||
const newWidth = Math.max(width, 100);
|
// Width change detected
|
||||||
const newHeight = newWidth / aspectRatio;
|
newWidth = Math.max(width, 100);
|
||||||
target!.style.width = `${newWidth}px`;
|
newHeight = newWidth / aspectRatio;
|
||||||
target!.style.height = `${newHeight}px`;
|
} else if (delta[1]) {
|
||||||
|
// Height change detected
|
||||||
|
newHeight = Math.max(height, 100);
|
||||||
|
newWidth = newHeight * aspectRatio;
|
||||||
}
|
}
|
||||||
if (delta[1]) {
|
target.style.width = `${newWidth}px`;
|
||||||
const newHeight = Math.max(height, 100);
|
target.style.height = `${newHeight}px`;
|
||||||
const newWidth = newHeight * aspectRatio;
|
|
||||||
target!.style.height = `${newHeight}px`;
|
|
||||||
target!.style.width = `${newWidth}px`;
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onResizeEnd={() => {
|
onResizeEnd={() => {
|
||||||
updateMediaSize();
|
updateMediaSize();
|
||||||
}}
|
}}
|
||||||
scalable
|
scalable
|
||||||
renderDirections={["w", "e"]}
|
renderDirections={["se"]}
|
||||||
onScale={({ target, transform }: any) => {
|
onScale={({ target, transform }) => {
|
||||||
target!.style.transform = transform;
|
target.style.transform = transform;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import { Color } from "@tiptap/extension-color";
|
|
||||||
import TaskItem from "@tiptap/extension-task-item";
|
import TaskItem from "@tiptap/extension-task-item";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
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 { CustomQuoteExtension } from "src/ui/extensions/quote";
|
||||||
|
|
||||||
import { DeleteImage } from "src/types/delete-image";
|
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 { RestoreImage } from "src/types/restore-image";
|
||||||
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||||
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||||
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
import { CustomTypographyExtension } from "src/ui/extensions/typography";
|
||||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||||
|
import { CustomCodeMarkPlugin } from "./custom-code-inline/inline-code-plugin";
|
||||||
|
|
||||||
export const CoreEditorExtensions = (
|
export const CoreEditorExtensions = (
|
||||||
mentionConfig: {
|
mentionConfig: {
|
||||||
mentionSuggestions: IMentionSuggestion[];
|
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
mentionHighlights: string[];
|
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||||
},
|
},
|
||||||
deleteFile: DeleteImage,
|
deleteFile: DeleteImage,
|
||||||
restoreFile: RestoreImage,
|
restoreFile: RestoreImage,
|
||||||
@ -41,17 +41,17 @@ export const CoreEditorExtensions = (
|
|||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
bulletList: {
|
bulletList: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "list-disc list-outside leading-3 -mt-2",
|
class: "list-disc pl-7 space-y-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderedList: {
|
orderedList: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "list-decimal list-outside leading-3 -mt-2",
|
class: "list-decimal pl-7 space-y-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
listItem: {
|
listItem: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "leading-normal -mb-2",
|
class: "not-prose space-y-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
code: false,
|
code: false,
|
||||||
@ -60,14 +60,17 @@ export const CoreEditorExtensions = (
|
|||||||
blockquote: false,
|
blockquote: false,
|
||||||
dropcursor: {
|
dropcursor: {
|
||||||
color: "rgba(var(--color-text-100))",
|
color: "rgba(var(--color-text-100))",
|
||||||
width: 2,
|
width: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
CustomQuoteExtension.configure({
|
// BulletList,
|
||||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
// OrderedList,
|
||||||
}),
|
// ListItem,
|
||||||
|
CustomQuoteExtension,
|
||||||
CustomHorizontalRule.configure({
|
CustomHorizontalRule.configure({
|
||||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
HTMLAttributes: {
|
||||||
|
class: "my-4",
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
CustomKeymap,
|
CustomKeymap,
|
||||||
ListKeymap,
|
ListKeymap,
|
||||||
@ -85,33 +88,40 @@ export const CoreEditorExtensions = (
|
|||||||
CustomTypographyExtension,
|
CustomTypographyExtension,
|
||||||
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "rounded-lg border border-custom-border-300",
|
class: "rounded-md",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
TiptapUnderline,
|
TiptapUnderline,
|
||||||
TextStyle,
|
TextStyle,
|
||||||
Color,
|
|
||||||
TaskList.configure({
|
TaskList.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "not-prose pl-2",
|
class: "not-prose pl-2 space-y-2",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "flex items-start my-4",
|
class: "flex",
|
||||||
},
|
},
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
CustomCodeBlockExtension,
|
CustomCodeBlockExtension.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
CustomCodeMarkPlugin,
|
||||||
CustomCodeInlineExtension,
|
CustomCodeInlineExtension,
|
||||||
Markdown.configure({
|
Markdown.configure({
|
||||||
html: true,
|
html: true,
|
||||||
transformCopiedText: true,
|
|
||||||
transformPastedText: true,
|
transformPastedText: true,
|
||||||
}),
|
}),
|
||||||
Table,
|
Table,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
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 { 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" {
|
declare module "@tiptap/core" {
|
||||||
// eslint-disable-next-line no-unused-vars
|
// 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({
|
export const CustomKeymap = Extension.create({
|
||||||
name: "CustomKeymap",
|
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() {
|
addKeyboardShortcuts() {
|
||||||
return {
|
return {
|
||||||
"Mod-a": ({ editor }) => {
|
"Mod-a": ({ editor }) => {
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
export const icons = {
|
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>`,
|
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>`,
|
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="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="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
|
insertLeftTableIcon: `<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
length={24}
|
length={12}
|
||||||
viewBox="0 -960 960 960"
|
viewBox="0 -960 960 960"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
@ -15,7 +15,7 @@ export const icons = {
|
|||||||
`,
|
`,
|
||||||
insertRightTableIcon: `<svg
|
insertRightTableIcon: `<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
length={24}
|
length={12}
|
||||||
viewBox="0 -960 960 960"
|
viewBox="0 -960 960 960"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
@ -35,8 +35,8 @@ export const icons = {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</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>`,
|
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="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="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
|
insertBottomTableIcon: `<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
length={24}
|
length={24}
|
||||||
|
@ -20,7 +20,7 @@ export function tableControls() {
|
|||||||
mousemove: (view, event) => {
|
mousemove: (view, event) => {
|
||||||
const pluginState = key.getState(view.state);
|
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(
|
return view.dispatch(
|
||||||
view.state.tr.setMeta(key, {
|
view.state.tr.setMeta(key, {
|
||||||
setHoveredTable: null,
|
setHoveredTable: null,
|
||||||
@ -34,7 +34,7 @@ export function tableControls() {
|
|||||||
top: event.clientY,
|
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")(
|
const table = findParentNode((node) => node.type.name === "table")(
|
||||||
TextSelection.create(view.state.doc, pos.pos)
|
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`
|
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Delete Row",
|
label: "Delete row",
|
||||||
icon: icons.deleteRow,
|
icon: icons.deleteRow,
|
||||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(),
|
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(),
|
||||||
},
|
},
|
||||||
@ -189,7 +189,7 @@ function createToolbox({
|
|||||||
tippyOptions,
|
tippyOptions,
|
||||||
onSelectColor,
|
onSelectColor,
|
||||||
onClickItem,
|
onClickItem,
|
||||||
colors = {},
|
colors,
|
||||||
}: {
|
}: {
|
||||||
triggerButton: Element | null;
|
triggerButton: Element | null;
|
||||||
items: ToolboxItem[];
|
items: ToolboxItem[];
|
||||||
@ -202,38 +202,44 @@ function createToolbox({
|
|||||||
const toolbox = tippy(triggerButton, {
|
const toolbox = tippy(triggerButton, {
|
||||||
content: h(
|
content: h(
|
||||||
"div",
|
"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") {
|
if (item.label === "Pick color") {
|
||||||
return h("div", { className: "flex flex-col" }, [
|
return h("div", { className: "flex flex-col" }, [
|
||||||
h("div", { className: "divider" }),
|
h("hr", { className: "my-2 border-custom-border-200" }),
|
||||||
h("div", { className: "colorPickerLabel" }, item.label),
|
h("div", { className: "text-custom-text-200 text-sm" }, item.label),
|
||||||
h(
|
h(
|
||||||
"div",
|
"div",
|
||||||
{ className: "colorPicker grid" },
|
{ className: "grid grid-cols-6 gap-x-1 gap-y-2.5 mt-2" },
|
||||||
Object.entries(colors).map(([colorName, colorValue]) =>
|
Object.entries(colors).map(([colorName, colorValue]) =>
|
||||||
h("div", {
|
h("div", {
|
||||||
className: "colorPickerItem flex items-center justify-center",
|
className: "grid place-items-center size-6 rounded cursor-pointer",
|
||||||
style: `background-color: ${colorValue.backgroundColor};
|
style: `background-color: ${colorValue.backgroundColor};color: ${colorValue.textColor || "inherit"};`,
|
||||||
color: ${colorValue.textColor || "inherit"};`,
|
|
||||||
innerHTML:
|
innerHTML:
|
||||||
colorValue.icon ?? `<span class="text-md" style:"color: ${colorValue.backgroundColor}>A</span>`,
|
colorValue.icon ?? `<span class="text-md" style:"color: ${colorValue.backgroundColor}>A</span>`,
|
||||||
onClick: () => onSelectColor(colorValue),
|
onClick: () => onSelectColor(colorValue),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
h("div", { className: "divider" }),
|
h("hr", { className: "my-2 border-custom-border-200" }),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
return h(
|
return h(
|
||||||
"div",
|
"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",
|
itemType: "div",
|
||||||
onClick: () => onClickItem(item),
|
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),
|
h("div", { className: "label" }, item.label),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
@ -290,27 +296,27 @@ export class TableView implements NodeView {
|
|||||||
if (editor.isEditable) {
|
if (editor.isEditable) {
|
||||||
this.rowsControl = h(
|
this.rowsControl = h(
|
||||||
"div",
|
"div",
|
||||||
{ className: "rowsControl" },
|
{ className: "rows-control" },
|
||||||
h("div", {
|
h("div", {
|
||||||
itemType: "button",
|
itemType: "button",
|
||||||
className: "rowsControlDiv",
|
className: "rows-control-div",
|
||||||
onClick: () => this.selectRow(),
|
onClick: () => this.selectRow(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.columnsControl = h(
|
this.columnsControl = h(
|
||||||
"div",
|
"div",
|
||||||
{ className: "columnsControl" },
|
{ className: "columns-control" },
|
||||||
h("div", {
|
h("div", {
|
||||||
itemType: "button",
|
itemType: "button",
|
||||||
className: "columnsControlDiv",
|
className: "columns-control-div",
|
||||||
onClick: () => this.selectColumn(),
|
onClick: () => this.selectColumn(),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
this.controls = h(
|
this.controls = h(
|
||||||
"div",
|
"div",
|
||||||
{ className: "tableControls", contentEditable: "false" },
|
{ className: "table-controls", contentEditable: "false" },
|
||||||
this.rowsControl,
|
this.rowsControl,
|
||||||
this.columnsControl
|
this.columnsControl
|
||||||
);
|
);
|
||||||
@ -331,7 +337,7 @@ export class TableView implements NodeView {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.columnsToolbox = createToolbox({
|
this.columnsToolbox = createToolbox({
|
||||||
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
|
triggerButton: this.columnsControl.querySelector(".columns-control-div"),
|
||||||
items: columnsToolboxItems,
|
items: columnsToolboxItems,
|
||||||
colors: columnColors,
|
colors: columnColors,
|
||||||
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
|
||||||
@ -380,7 +386,7 @@ export class TableView implements NodeView {
|
|||||||
this.root = h(
|
this.root = h(
|
||||||
"div",
|
"div",
|
||||||
{
|
{
|
||||||
className: "tableWrapper controls--disabled",
|
className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled",
|
||||||
},
|
},
|
||||||
this.controls,
|
this.controls,
|
||||||
this.table
|
this.table
|
||||||
|
@ -5,7 +5,7 @@ import { MentionNodeView } from "src/ui/mentions/mention-node-view";
|
|||||||
import { IMentionHighlight } from "src/types/mention-suggestion";
|
import { IMentionHighlight } from "src/types/mention-suggestion";
|
||||||
|
|
||||||
export interface CustomMentionOptions extends MentionOptions {
|
export interface CustomMentionOptions extends MentionOptions {
|
||||||
mentionHighlights: IMentionHighlight[];
|
mentionHighlights: () => Promise<IMentionHighlight[]>;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -32,6 +32,12 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
|
|||||||
redirect_uri: {
|
redirect_uri: {
|
||||||
default: "/",
|
default: "/",
|
||||||
},
|
},
|
||||||
|
entity_identifier: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
entity_name: {
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -43,17 +49,6 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
tag: "mention-component",
|
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 { 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({
|
CustomMention.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "mention",
|
class: "mention",
|
||||||
},
|
},
|
||||||
readonly: readonly,
|
readonly: readonly,
|
||||||
mentionHighlights: mentionHighlights,
|
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 { 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 { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
|
||||||
interface MentionListProps {
|
interface MentionListProps {
|
||||||
items: IMentionSuggestion[];
|
command: (item: {
|
||||||
command: (item: { id: string; label: string; target: string; redirect_uri: string }) => void;
|
id: string;
|
||||||
|
label: string;
|
||||||
|
entity_name: string;
|
||||||
|
entity_identifier: string;
|
||||||
|
target: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
}) => void;
|
||||||
|
query: string;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line react/display-name
|
|
||||||
export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
||||||
|
const { query, mentionSuggestions } = props;
|
||||||
|
const [items, setItems] = useState<IMentionSuggestion[]>([]);
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
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 selectItem = (index: number) => {
|
||||||
const item = props.items[index];
|
try {
|
||||||
|
const item = items[index];
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
props.command({
|
props.command({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
label: item.title,
|
label: item.title,
|
||||||
|
entity_identifier: item.entity_identifier,
|
||||||
|
entity_name: item.entity_name,
|
||||||
target: "users",
|
target: "users",
|
||||||
redirect_uri: item.redirect_uri,
|
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 = () => {
|
const upHandler = () => {
|
||||||
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
|
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const downHandler = () => {
|
const downHandler = () => {
|
||||||
setSelectedIndex((selectedIndex + 1) % props.items.length);
|
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
const enterHandler = () => {
|
const enterHandler = () => {
|
||||||
@ -39,7 +109,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedIndex(0);
|
setSelectedIndex(0);
|
||||||
}, [props.items]);
|
}, [items]);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||||
@ -62,38 +132,33 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return props.items && props.items.length !== 0 ? (
|
return (
|
||||||
<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">
|
<div
|
||||||
{props.items.length ? (
|
ref={commandListContainer}
|
||||||
props.items.map((item, index) => (
|
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
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`flex cursor-pointer items-center gap-2 rounded p-1 hover:bg-custom-background-80 ${
|
className={cn(
|
||||||
index === selectedIndex ? "bg-custom-background-80" : ""
|
"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)}
|
onClick={() => selectItem(index)}
|
||||||
>
|
>
|
||||||
<div className="grid h-4 w-4 flex-shrink-0 place-items-center overflow-hidden">
|
<Avatar name={item?.title} src={item?.avatar} />
|
||||||
{item.avatar && item.avatar.trim() !== "" ? (
|
<span className="flex-grow truncate">{item.title}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="item">No result</div>
|
<div className="text-center text-custom-text-400">No results</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<></>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -4,11 +4,21 @@ import { NodeViewWrapper } from "@tiptap/react";
|
|||||||
import { cn } from "src/lib/utils";
|
import { cn } from "src/lib/utils";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { IMentionHighlight } from "src/types/mention-suggestion";
|
import { IMentionHighlight } from "src/types/mention-suggestion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-anonymous-default-export
|
// eslint-disable-next-line import/no-anonymous-default-export
|
||||||
export const MentionNodeView = (props) => {
|
export const MentionNodeView = (props) => {
|
||||||
const router = useRouter();
|
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 = () => {
|
const handleClick = () => {
|
||||||
if (!props.extension.options.readonly) {
|
if (!props.extension.options.readonly) {
|
||||||
@ -20,13 +30,12 @@ export const MentionNodeView = (props) => {
|
|||||||
<NodeViewWrapper className="mention-component inline w-fit">
|
<NodeViewWrapper className="mention-component inline w-fit">
|
||||||
<span
|
<span
|
||||||
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
|
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,
|
"cursor-pointer": !props.extension.options.readonly,
|
||||||
// "hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
|
|
||||||
})}
|
})}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
data-mention-target={props.node.attrs.target}
|
|
||||||
data-mention-id={props.node.attrs.id}
|
|
||||||
>
|
>
|
||||||
@{props.node.attrs.label}
|
@{props.node.attrs.label}
|
||||||
</span>
|
</span>
|
||||||
|
@ -1,66 +1,17 @@
|
|||||||
import { ReactRenderer } from "@tiptap/react";
|
import { v4 as uuidv4 } from "uuid";
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import tippy from "tippy.js";
|
|
||||||
|
|
||||||
import { MentionList } from "src/ui/mentions/mention-list";
|
|
||||||
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
import { IMentionSuggestion } from "src/types/mention-suggestion";
|
||||||
|
|
||||||
export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
|
export const getSuggestionItems =
|
||||||
items: ({ query }: { query: string }) =>
|
(suggestions: IMentionSuggestion[]) =>
|
||||||
suggestions.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
|
({ query }: { query: string }) => {
|
||||||
render: () => {
|
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
|
||||||
let reactRenderer: ReactRenderer | null = null;
|
const transactionId = uuidv4();
|
||||||
let popup: any | null = null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
...suggestion,
|
||||||
props.editor.storage.mentionsOpen = true;
|
id: transactionId,
|
||||||
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();
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
return mappedSuggestions
|
||||||
|
.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase()))
|
||||||
|
.slice(0, 5);
|
||||||
|
};
|
||||||
|
@ -33,6 +33,7 @@ import {
|
|||||||
} from "src/lib/editor-commands";
|
} from "src/lib/editor-commands";
|
||||||
import { LucideIconType } from "src/types/lucide-icon";
|
import { LucideIconType } from "src/types/lucide-icon";
|
||||||
import { UploadImage } from "src/types/upload-image";
|
import { UploadImage } from "src/types/upload-image";
|
||||||
|
import { Selection } from "@tiptap/pm/state";
|
||||||
|
|
||||||
export interface EditorMenuItem {
|
export interface EditorMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
@ -41,104 +42,142 @@ export interface EditorMenuItem {
|
|||||||
icon: LucideIconType;
|
icon: LucideIconType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
|
export const HeadingOneItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "H1",
|
name: "H1",
|
||||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||||
command: () => toggleHeadingOne(editor),
|
command: () => toggleHeadingOne(editor),
|
||||||
icon: Heading1,
|
icon: Heading1,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
|
export const HeadingTwoItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "H2",
|
name: "H2",
|
||||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||||
command: () => toggleHeadingTwo(editor),
|
command: () => toggleHeadingTwo(editor),
|
||||||
icon: Heading2,
|
icon: Heading2,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
|
export const HeadingThreeItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "H3",
|
name: "H3",
|
||||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||||
command: () => toggleHeadingThree(editor),
|
command: () => toggleHeadingThree(editor),
|
||||||
icon: Heading3,
|
icon: Heading3,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const BoldItem = (editor: Editor): EditorMenuItem => ({
|
export const BoldItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "bold",
|
name: "bold",
|
||||||
isActive: () => editor?.isActive("bold"),
|
isActive: () => editor?.isActive("bold"),
|
||||||
command: () => toggleBold(editor),
|
command: () => toggleBold(editor),
|
||||||
icon: BoldIcon,
|
icon: BoldIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
|
export const ItalicItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "italic",
|
name: "italic",
|
||||||
isActive: () => editor?.isActive("italic"),
|
isActive: () => editor?.isActive("italic"),
|
||||||
command: () => toggleItalic(editor),
|
command: () => toggleItalic(editor),
|
||||||
icon: ItalicIcon,
|
icon: ItalicIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
|
export const UnderLineItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "underline",
|
name: "underline",
|
||||||
isActive: () => editor?.isActive("underline"),
|
isActive: () => editor?.isActive("underline"),
|
||||||
command: () => toggleUnderline(editor),
|
command: () => toggleUnderline(editor),
|
||||||
icon: UnderlineIcon,
|
icon: UnderlineIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
|
export const StrikeThroughItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "strike",
|
name: "strike",
|
||||||
isActive: () => editor?.isActive("strike"),
|
isActive: () => editor?.isActive("strike"),
|
||||||
command: () => toggleStrike(editor),
|
command: () => toggleStrike(editor),
|
||||||
icon: StrikethroughIcon,
|
icon: StrikethroughIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
|
export const BulletListItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "bullet-list",
|
name: "bullet-list",
|
||||||
isActive: () => editor?.isActive("bulletList"),
|
isActive: () => editor?.isActive("bulletList"),
|
||||||
command: () => toggleBulletList(editor),
|
command: () => toggleBulletList(editor),
|
||||||
icon: ListIcon,
|
icon: ListIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
|
export const TodoListItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "To-do List",
|
name: "To-do List",
|
||||||
isActive: () => editor.isActive("taskItem"),
|
isActive: () => editor.isActive("taskItem"),
|
||||||
command: () => toggleTaskList(editor),
|
command: () => toggleTaskList(editor),
|
||||||
icon: CheckSquare,
|
icon: CheckSquare,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const CodeItem = (editor: Editor): EditorMenuItem => ({
|
export const CodeItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "code",
|
name: "code",
|
||||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||||
command: () => toggleCodeBlock(editor),
|
command: () => toggleCodeBlock(editor),
|
||||||
icon: CodeIcon,
|
icon: CodeIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
|
export const NumberedListItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "ordered-list",
|
name: "ordered-list",
|
||||||
isActive: () => editor?.isActive("orderedList"),
|
isActive: () => editor?.isActive("orderedList"),
|
||||||
command: () => toggleOrderedList(editor),
|
command: () => toggleOrderedList(editor),
|
||||||
icon: ListOrderedIcon,
|
icon: ListOrderedIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
|
export const QuoteItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "quote",
|
name: "quote",
|
||||||
isActive: () => editor?.isActive("blockquote"),
|
isActive: () => editor?.isActive("blockquote"),
|
||||||
command: () => toggleBlockquote(editor),
|
command: () => toggleBlockquote(editor),
|
||||||
icon: QuoteIcon,
|
icon: QuoteIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const TableItem = (editor: Editor): EditorMenuItem => ({
|
export const TableItem = (editor: Editor) =>
|
||||||
|
({
|
||||||
name: "table",
|
name: "table",
|
||||||
isActive: () => editor?.isActive("table"),
|
isActive: () => editor?.isActive("table"),
|
||||||
command: () => insertTableCommand(editor),
|
command: () => insertTableCommand(editor),
|
||||||
icon: TableIcon,
|
icon: TableIcon,
|
||||||
});
|
}) as const satisfies EditorMenuItem;
|
||||||
|
|
||||||
export const ImageItem = (
|
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
|
||||||
editor: Editor,
|
({
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
): EditorMenuItem => ({
|
|
||||||
name: "image",
|
name: "image",
|
||||||
isActive: () => editor?.isActive("image"),
|
isActive: () => editor?.isActive("image"),
|
||||||
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
|
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
|
||||||
icon: ImageIcon,
|
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> {
|
export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||||
const resStatus = await deleteImage(assetUrlWithWorkspaceId);
|
await deleteImage(assetUrlWithWorkspaceId);
|
||||||
if (resStatus === 204) {
|
|
||||||
console.log("Image deleted successfully");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting image: ", 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> {
|
export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
|
||||||
const resStatus = await restoreImage(assetUrlWithWorkspaceId);
|
await restoreImage(assetUrlWithWorkspaceId);
|
||||||
if (resStatus === 204) {
|
|
||||||
console.log("Image restored successfully");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error restoring image: ", error);
|
console.error("Error restoring image: ", error);
|
||||||
}
|
}
|
||||||
|
@ -21,7 +21,7 @@ export const UploadImagesPlugin = (cancelUploadImage?: () => any) =>
|
|||||||
const placeholder = document.createElement("div");
|
const placeholder = document.createElement("div");
|
||||||
placeholder.setAttribute("class", "img-placeholder");
|
placeholder.setAttribute("class", "img-placeholder");
|
||||||
const image = document.createElement("img");
|
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;
|
image.src = src;
|
||||||
placeholder.appendChild(image);
|
placeholder.appendChild(image);
|
||||||
|
|
||||||
@ -73,13 +73,7 @@ const removePlaceholder = (view: EditorView, id: {}) => {
|
|||||||
view.dispatch(removePlaceholderTr);
|
view.dispatch(removePlaceholderTr);
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function startImageUpload(
|
export async function startImageUpload(file: File, view: EditorView, pos: number, uploadFile: UploadImage) {
|
||||||
file: File,
|
|
||||||
view: EditorView,
|
|
||||||
pos: number,
|
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
) {
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
alert("No file selected. Please select a file to upload.");
|
alert("No file selected. Please select a file to upload.");
|
||||||
return;
|
return;
|
||||||
@ -120,7 +114,7 @@ export async function startImageUpload(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
setIsSubmitting?.("submitting");
|
// setIsSubmitting?.("submitting");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const src = await UploadImageHandler(file, uploadFile);
|
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 } });
|
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
|
||||||
|
|
||||||
view.dispatch(transaction);
|
view.dispatch(transaction);
|
||||||
|
view.focus();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Upload error: ", error);
|
console.error("Upload error: ", error);
|
||||||
removePlaceholder(view, id);
|
removePlaceholder(view, id);
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { EditorProps } from "@tiptap/pm/view";
|
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 { UploadImage } from "src/types/upload-image";
|
||||||
import { startImageUpload } from "src/ui/plugins/upload-image";
|
import { startImageUpload } from "src/ui/plugins/upload-image";
|
||||||
|
|
||||||
export function CoreEditorProps(
|
export function CoreEditorProps(uploadFile: UploadImage, editorClassName: string): EditorProps {
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
): EditorProps {
|
|
||||||
return {
|
return {
|
||||||
attributes: {
|
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: {
|
handleDOMEvents: {
|
||||||
keydown: (_view, event) => {
|
keydown: (_view, event) => {
|
||||||
@ -36,7 +36,7 @@ export function CoreEditorProps(
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const file = event.clipboardData.files[0];
|
const file = event.clipboardData.files[0];
|
||||||
const pos = view.state.selection.from;
|
const pos = view.state.selection.from;
|
||||||
startImageUpload(file, view, pos, uploadFile, setIsSubmitting);
|
startImageUpload(file, view, pos, uploadFile);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@ -50,7 +50,7 @@ export function CoreEditorProps(
|
|||||||
top: event.clientY,
|
top: event.clientY,
|
||||||
});
|
});
|
||||||
if (coordinates) {
|
if (coordinates) {
|
||||||
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
|
startImageUpload(file, view, coordinates.pos - 1, uploadFile);
|
||||||
}
|
}
|
||||||
return true;
|
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 { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image";
|
||||||
import { isValidHttpUrl } from "src/lib/utils";
|
import { isValidHttpUrl } from "src/lib/utils";
|
||||||
import { Mentions } from "src/ui/mentions";
|
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 { CustomLinkExtension } from "src/ui/extensions/custom-link";
|
||||||
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
|
||||||
import { CustomQuoteExtension } from "src/ui/extensions/quote";
|
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";
|
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
|
||||||
|
|
||||||
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
||||||
mentionSuggestions: IMentionSuggestion[];
|
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||||
mentionHighlights: string[];
|
|
||||||
}) => [
|
}) => [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
bulletList: {
|
bulletList: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "list-disc list-outside leading-3 -mt-2",
|
class: "list-disc pl-7 space-y-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderedList: {
|
orderedList: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "list-decimal list-outside leading-3 -mt-2",
|
class: "list-decimal pl-7 space-y-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
listItem: {
|
listItem: {
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "leading-normal -mb-2",
|
class: "not-prose space-y-2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
code: false,
|
code: false,
|
||||||
@ -49,11 +48,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||||||
dropcursor: false,
|
dropcursor: false,
|
||||||
gapcursor: false,
|
gapcursor: false,
|
||||||
}),
|
}),
|
||||||
CustomQuoteExtension.configure({
|
CustomQuoteExtension,
|
||||||
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
|
|
||||||
}),
|
|
||||||
CustomHorizontalRule.configure({
|
CustomHorizontalRule.configure({
|
||||||
HTMLAttributes: { class: "mt-4 mb-4" },
|
HTMLAttributes: { class: "my-4" },
|
||||||
}),
|
}),
|
||||||
CustomLinkExtension.configure({
|
CustomLinkExtension.configure({
|
||||||
openOnClick: true,
|
openOnClick: true,
|
||||||
@ -69,7 +66,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||||||
CustomTypographyExtension,
|
CustomTypographyExtension,
|
||||||
ReadOnlyImageExtension.configure({
|
ReadOnlyImageExtension.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "rounded-lg border border-custom-border-300",
|
class: "rounded-md",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
TiptapUnderline,
|
TiptapUnderline,
|
||||||
@ -77,16 +74,20 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||||||
Color,
|
Color,
|
||||||
TaskList.configure({
|
TaskList.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "not-prose pl-2",
|
class: "not-prose pl-2 space-y-2",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
TaskItem.configure({
|
TaskItem.configure({
|
||||||
HTMLAttributes: {
|
HTMLAttributes: {
|
||||||
class: "flex items-start my-4",
|
class: "flex pointer-events-none",
|
||||||
},
|
},
|
||||||
nested: true,
|
nested: true,
|
||||||
}),
|
}),
|
||||||
CustomCodeBlockExtension,
|
CustomCodeBlockExtension.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4",
|
||||||
|
},
|
||||||
|
}),
|
||||||
CustomCodeInlineExtension,
|
CustomCodeInlineExtension,
|
||||||
Markdown.configure({
|
Markdown.configure({
|
||||||
html: true,
|
html: true,
|
||||||
@ -96,5 +97,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
TableRow,
|
TableRow,
|
||||||
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
|
Mentions({
|
||||||
|
mentionHighlights: mentionConfig.mentionHighlights,
|
||||||
|
readonly: true,
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { EditorProps } from "@tiptap/pm/view";
|
import { EditorProps } from "@tiptap/pm/view";
|
||||||
|
import { cn } from "src/lib/utils";
|
||||||
|
|
||||||
export const CoreReadOnlyEditorProps: EditorProps = {
|
export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({
|
||||||
attributes: {
|
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";
|
import { IMarking } from "src/types/editor-types";
|
||||||
|
|
||||||
export const useEditorMarkings = () => {
|
export const useEditorMarkings = () => {
|
||||||
const [markings, setMarkings] = useState<IMarking[]>([]);
|
const [markings, setMarkings] = useState<IMarking[]>([]);
|
||||||
|
|
||||||
const updateMarkings = (json: any) => {
|
const updateMarkings = useCallback((html: string) => {
|
||||||
const nodes = json.content as any[];
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, "text/html");
|
||||||
|
const headings = doc.querySelectorAll("h1, h2, h3");
|
||||||
const tempMarkings: IMarking[] = [];
|
const tempMarkings: IMarking[] = [];
|
||||||
let h1Sequence: number = 0;
|
let h1Sequence: number = 0;
|
||||||
let h2Sequence: number = 0;
|
let h2Sequence: number = 0;
|
||||||
let h3Sequence: number = 0;
|
let h3Sequence: number = 0;
|
||||||
if (nodes) {
|
|
||||||
nodes.forEach((node) => {
|
headings.forEach((heading) => {
|
||||||
if (
|
const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3
|
||||||
node.type === "heading" &&
|
|
||||||
(node.attrs.level === 1 || node.attrs.level === 2 || node.attrs.level === 3) &&
|
|
||||||
node.content
|
|
||||||
) {
|
|
||||||
tempMarkings.push({
|
tempMarkings.push({
|
||||||
type: "heading",
|
type: "heading",
|
||||||
level: node.attrs.level,
|
level: level,
|
||||||
text: node.content[0].text,
|
text: heading.textContent || "",
|
||||||
sequence: node.attrs.level === 1 ? ++h1Sequence : node.attrs.level === 2 ? ++h2Sequence : ++h3Sequence,
|
sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
setMarkings(tempMarkings);
|
setMarkings(tempMarkings);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updateMarkings,
|
updateMarkings,
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
export { DocumentEditor, DocumentEditorWithRef } from "src/ui";
|
export { DocumentEditor, DocumentEditorWithRef } from "src/ui";
|
||||||
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/readonly";
|
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 {
|
export interface IMarking {
|
||||||
type: "heading";
|
type: "heading";
|
||||||
level: number;
|
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 "./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 = () => {
|
const removeLink = () => {
|
||||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
||||||
linkRemoved.current = true;
|
linkRemoved.current = true;
|
||||||
viewProps.onActionCompleteHandler({
|
|
||||||
title: "Link successfully removed",
|
|
||||||
message: "The link was removed from the text.",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
viewProps.closeLinkView();
|
viewProps.closeLinkView();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -12,21 +12,11 @@ export const LinkPreview = ({
|
|||||||
|
|
||||||
const removeLink = () => {
|
const removeLink = () => {
|
||||||
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
|
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();
|
viewProps.closeLinkView();
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyLinkToClipboard = () => {
|
const copyLinkToClipboard = () => {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
viewProps.onActionCompleteHandler({
|
|
||||||
title: "Link successfully copied",
|
|
||||||
message: "The link was copied to the clipboard.",
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
viewProps.closeLinkView();
|
viewProps.closeLinkView();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -11,11 +11,6 @@ export interface LinkViewProps {
|
|||||||
to: number;
|
to: number;
|
||||||
url: string;
|
url: string;
|
||||||
closeLinkView: () => void;
|
closeLinkView: () => void;
|
||||||
onActionCompleteHandler: (action: {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
type: "success" | "error" | "warning" | "info";
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {
|
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 { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
|
||||||
import { Node } from "@tiptap/pm/model";
|
import { Node } from "@tiptap/pm/model";
|
||||||
import { EditorView } from "@tiptap/pm/view";
|
import { EditorView } from "@tiptap/pm/view";
|
||||||
import { Editor, ReactRenderer } from "@tiptap/react";
|
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 { LinkView, LinkViewProps } from "./links/link-view";
|
||||||
import {
|
import {
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
@ -15,40 +14,22 @@ import {
|
|||||||
useFloating,
|
useFloating,
|
||||||
useInteractions,
|
useInteractions,
|
||||||
} from "@floating-ui/react";
|
} from "@floating-ui/react";
|
||||||
|
import BlockMenu from "../menu//block-menu";
|
||||||
|
|
||||||
type IPageRenderer = {
|
type IPageRenderer = {
|
||||||
documentDetails: DocumentDetails;
|
|
||||||
updatePageTitle: (title: string) => void;
|
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
onActionCompleteHandler: (action: {
|
editorContainerClassName: string;
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
type: "success" | "error" | "warning" | "info";
|
|
||||||
}) => void;
|
|
||||||
editorClassNames: string;
|
|
||||||
editorContentCustomClassNames?: string;
|
|
||||||
hideDragHandle?: () => void;
|
hideDragHandle?: () => void;
|
||||||
readonly: boolean;
|
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PageRenderer = (props: IPageRenderer) => {
|
export const PageRenderer = (props: IPageRenderer) => {
|
||||||
const {
|
const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props;
|
||||||
documentDetails,
|
// states
|
||||||
tabIndex,
|
|
||||||
editor,
|
|
||||||
editorClassNames,
|
|
||||||
editorContentCustomClassNames,
|
|
||||||
updatePageTitle,
|
|
||||||
readonly,
|
|
||||||
hideDragHandle,
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const [pageTitle, setPagetitle] = useState(documentDetails.title);
|
|
||||||
|
|
||||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
|
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
|
||||||
|
const [cleanup, setCleanup] = useState(() => () => {});
|
||||||
|
|
||||||
const { refs, floatingStyles, context } = useFloating({
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
open: isOpen,
|
open: isOpen,
|
||||||
@ -63,18 +44,9 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
|
|
||||||
const { getFloatingProps } = useInteractions([dismiss]);
|
const { getFloatingProps } = useInteractions([dismiss]);
|
||||||
|
|
||||||
const handlePageTitleChange = (title: string) => {
|
|
||||||
setPagetitle(title);
|
|
||||||
updatePageTitle(title);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [cleanup, setcleanup] = useState(() => () => {});
|
|
||||||
|
|
||||||
const floatingElementRef = useRef<HTMLElement | null>(null);
|
const floatingElementRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const closeLinkView = () => {
|
const closeLinkView = () => setIsOpen(false);
|
||||||
setIsOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLinkHover = useCallback(
|
const handleLinkHover = useCallback(
|
||||||
(event: React.MouseEvent) => {
|
(event: React.MouseEvent) => {
|
||||||
@ -137,7 +109,6 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
setCoordinates({ x: x - 300, y: y - 50 });
|
setCoordinates({ x: x - 300, y: y - 50 });
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setLinkViewProps({
|
setLinkViewProps({
|
||||||
onActionCompleteHandler: props.onActionCompleteHandler,
|
|
||||||
closeLinkView: closeLinkView,
|
closeLinkView: closeLinkView,
|
||||||
view: "LinkPreview",
|
view: "LinkPreview",
|
||||||
url: href,
|
url: href,
|
||||||
@ -148,45 +119,32 @@ export const PageRenderer = (props: IPageRenderer) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setcleanup(cleanupFunc);
|
setCleanup(cleanupFunc);
|
||||||
},
|
},
|
||||||
[editor, cleanup]
|
[editor, cleanup]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
|
<>
|
||||||
{!readonly ? (
|
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
||||||
<input
|
<EditorContainer
|
||||||
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}
|
|
||||||
editor={editor}
|
editor={editor}
|
||||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
hideDragHandle={hideDragHandle}
|
||||||
/>
|
editorContainerClassName={editorContainerClassName}
|
||||||
|
>
|
||||||
|
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||||
|
{editor && editor.isEditable && <BlockMenu editor={editor} />}
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
</div>
|
</div>
|
||||||
{isOpen && linkViewProps && coordinates && (
|
{isOpen && linkViewProps && coordinates && (
|
||||||
<div
|
<div
|
||||||
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
|
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
|
||||||
className={`absolute`}
|
className="absolute"
|
||||||
ref={refs.setFloating}
|
ref={refs.setFloating}
|
||||||
>
|
>
|
||||||
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
|
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
|
||||||
</div>
|
</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 = (
|
export const DocumentEditorExtensions = (
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
|
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
|
||||||
) => [
|
) => [
|
||||||
SlashCommand(uploadFile, setIsSubmitting),
|
SlashCommand(uploadFile),
|
||||||
DragAndDrop(setHideDragHandle),
|
DragAndDrop(setHideDragHandle),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ editor, node }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
return `Heading ${node.attrs.level}`;
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,187 +1,97 @@
|
|||||||
"use client";
|
|
||||||
import React, { useState } from "react";
|
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 { 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 { 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 {
|
interface IDocumentEditor {
|
||||||
// document info
|
initialValue: string;
|
||||||
documentDetails: DocumentDetails;
|
value?: string;
|
||||||
value: string;
|
fileHandler: {
|
||||||
rerenderOnPropsChange?: {
|
cancel: () => void;
|
||||||
id: string;
|
delete: DeleteImage;
|
||||||
description_html: string;
|
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;
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
interface DocumentEditorProps extends IDocumentEditor {
|
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorHandle {
|
const DocumentEditor = (props: IDocumentEditor) => {
|
||||||
clearEditor: () => void;
|
const {
|
||||||
setEditorValue: (content: string) => void;
|
|
||||||
setEditorValueAtCursorPosition: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DocumentEditor = ({
|
|
||||||
documentDetails,
|
|
||||||
onChange,
|
onChange,
|
||||||
debouncedUpdatesEnabled,
|
initialValue,
|
||||||
setIsSubmitting,
|
|
||||||
setShouldShowAlert,
|
|
||||||
editorContentCustomClassNames,
|
|
||||||
value,
|
value,
|
||||||
uploadFile,
|
fileHandler,
|
||||||
deleteFile,
|
containerClassName,
|
||||||
restoreFile,
|
editorClassName = "",
|
||||||
isSubmitting,
|
mentionHandler,
|
||||||
customClassName,
|
handleEditorReady,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
duplicationConfig,
|
|
||||||
pageLockConfig,
|
|
||||||
pageArchiveConfig,
|
|
||||||
updatePageTitle,
|
|
||||||
cancelUploadImage,
|
|
||||||
onActionCompleteHandler,
|
|
||||||
rerenderOnPropsChange,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
}: IDocumentEditor) => {
|
} = props;
|
||||||
const { markings, updateMarkings } = useEditorMarkings();
|
// states
|
||||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
|
||||||
|
|
||||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
// 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
|
// loads such that we can invoke it from react when the cursor leaves the container
|
||||||
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
|
||||||
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
|
||||||
};
|
};
|
||||||
|
// use editor
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
onChange(json, html) {
|
onChange(json, html) {
|
||||||
updateMarkings(json);
|
|
||||||
onChange(json, html);
|
onChange(json, html);
|
||||||
},
|
},
|
||||||
onStart(json) {
|
editorClassName,
|
||||||
updateMarkings(json);
|
restoreFile: fileHandler.restore,
|
||||||
},
|
uploadFile: fileHandler.upload,
|
||||||
debouncedUpdatesEnabled,
|
deleteFile: fileHandler.delete,
|
||||||
restoreFile,
|
cancelUploadImage: fileHandler.cancel,
|
||||||
setIsSubmitting,
|
initialValue,
|
||||||
setShouldShowAlert,
|
|
||||||
value,
|
value,
|
||||||
uploadFile,
|
handleEditorReady,
|
||||||
deleteFile,
|
|
||||||
cancelUploadImage,
|
|
||||||
rerenderOnPropsChange,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting),
|
mentionHandler,
|
||||||
|
extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) {
|
const editorContainerClassNames = getEditorClassNames({
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KanbanMenuOptions = getMenuOptions({
|
|
||||||
editor: editor,
|
|
||||||
router: router,
|
|
||||||
duplicationConfig: duplicationConfig,
|
|
||||||
pageLockConfig: pageLockConfig,
|
|
||||||
pageArchiveConfig: pageArchiveConfig,
|
|
||||||
onActionCompleteHandler,
|
|
||||||
});
|
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
|
||||||
noBorder: true,
|
noBorder: true,
|
||||||
borderOnFocus: false,
|
borderOnFocus: false,
|
||||||
customClassName,
|
containerClassName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
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
|
<PageRenderer
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
onActionCompleteHandler={onActionCompleteHandler}
|
|
||||||
hideDragHandle={hideDragHandleOnMouseLeave}
|
|
||||||
readonly={false}
|
|
||||||
editor={editor}
|
editor={editor}
|
||||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
editorContainerClassName={editorContainerClassNames}
|
||||||
editorClassNames={editorClassNames}
|
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||||
documentDetails={documentDetails}
|
|
||||||
updatePageTitle={updatePageTitle}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="hidden w-56 flex-shrink-0 lg:block lg:w-72" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
|
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
|
||||||
<DocumentEditor {...props} forwardedRef={ref} />
|
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||||
));
|
));
|
||||||
|
|
||||||
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";
|
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 { forwardRef, MutableRefObject } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core";
|
||||||
import { useState, forwardRef, useEffect } from "react";
|
// components
|
||||||
import { EditorHeader } from "src/ui/components/editor-header";
|
|
||||||
import { PageRenderer } from "src/ui/components/page-renderer";
|
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";
|
import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget";
|
||||||
|
|
||||||
interface IDocumentReadOnlyEditor {
|
interface IDocumentReadOnlyEditor {
|
||||||
value: string;
|
initialValue: string;
|
||||||
rerenderOnPropsChange?: {
|
containerClassName: string;
|
||||||
id: string;
|
editorClassName?: 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;
|
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
|
handleEditorReady?: (value: boolean) => void;
|
||||||
|
mentionHandler: {
|
||||||
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
|
};
|
||||||
|
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
|
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
const {
|
||||||
}
|
containerClassName,
|
||||||
|
editorClassName = "",
|
||||||
interface EditorHandle {
|
initialValue,
|
||||||
clearEditor: () => void;
|
|
||||||
setEditorValue: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DocumentReadOnlyEditor = ({
|
|
||||||
noBorder,
|
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
value,
|
|
||||||
documentDetails,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
pageDuplicationConfig,
|
|
||||||
pageLockConfig,
|
|
||||||
pageArchiveConfig,
|
|
||||||
rerenderOnPropsChange,
|
|
||||||
onActionCompleteHandler,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
}: DocumentReadOnlyEditorProps) => {
|
handleEditorReady,
|
||||||
const router = useRouter();
|
mentionHandler,
|
||||||
const [sidePeekVisible, setSidePeekVisible] = useState(true);
|
} = props;
|
||||||
const { markings, updateMarkings } = useEditorMarkings();
|
|
||||||
|
|
||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
value,
|
initialValue,
|
||||||
|
editorClassName,
|
||||||
|
mentionHandler,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
rerenderOnPropsChange,
|
handleEditorReady,
|
||||||
extensions: [IssueWidgetPlaceholder()],
|
extensions: [IssueWidgetPlaceholder()],
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editor) {
|
|
||||||
updateMarkings(editor.getJSON());
|
|
||||||
}
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
const editorContainerClassName = getEditorClassNames({
|
||||||
noBorder,
|
containerClassName,
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const KanbanMenuOptions = getMenuOptions({
|
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorHandle, IDocumentReadOnlyEditor>((props, ref) => (
|
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref} />
|
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||||
));
|
));
|
||||||
|
|
||||||
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";
|
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": {
|
"dependencies": {
|
||||||
"@plane/editor-core": "*",
|
"@plane/editor-core": "*",
|
||||||
|
"@plane/ui": "*",
|
||||||
"@tiptap/core": "^2.1.13",
|
"@tiptap/core": "^2.1.13",
|
||||||
"@tiptap/pm": "^2.1.13",
|
"@tiptap/pm": "^2.1.13",
|
||||||
"@tiptap/react": "^2.1.13",
|
"@tiptap/react": "^2.1.13",
|
||||||
|
@ -1,9 +1,18 @@
|
|||||||
import { Extension } from "@tiptap/core";
|
import { Extension } from "@tiptap/core";
|
||||||
|
|
||||||
import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
|
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
// @ts-ignore
|
import { Fragment, Slice, Node } from "@tiptap/pm/model";
|
||||||
|
// @ts-expect-error __serializeForClipboard's is not exported
|
||||||
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
|
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 {
|
function createDragHandleElement(): HTMLElement {
|
||||||
const dragHandleElement = document.createElement("div");
|
const dragHandleElement = document.createElement("div");
|
||||||
@ -29,13 +38,8 @@ function createDragHandleElement(): HTMLElement {
|
|||||||
return dragHandleElement;
|
return dragHandleElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DragHandleOptions {
|
|
||||||
dragHandleWidth: number;
|
|
||||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function absoluteRect(node: Element) {
|
function absoluteRect(node: Element) {
|
||||||
const data = node?.getBoundingClientRect();
|
const data = node.getBoundingClientRect();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: data.top,
|
top: data.top,
|
||||||
@ -57,55 +61,77 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
|
|||||||
"pre",
|
"pre",
|
||||||
"blockquote",
|
"blockquote",
|
||||||
"h1, h2, h3",
|
"h1, h2, h3",
|
||||||
|
".table-wrapper",
|
||||||
"[data-type=horizontalRule]",
|
"[data-type=horizontalRule]",
|
||||||
".tableWrapper",
|
|
||||||
].join(", ")
|
].join(", ")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodePosAtDOM(node: Element, view: EditorView) {
|
function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) {
|
||||||
const boundingRect = node?.getBoundingClientRect();
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return view.posAtCoords({
|
return view.posAtCoords({
|
||||||
left: boundingRect.left + 1,
|
left: boundingRect.left + 50 + options.dragHandleWidth,
|
||||||
top: boundingRect.top + 1,
|
top: boundingRect.top + 1,
|
||||||
})?.inside;
|
})?.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) {
|
function DragHandle(options: DragHandleOptions) {
|
||||||
|
let listType = "";
|
||||||
function handleDragStart(event: DragEvent, view: EditorView) {
|
function handleDragStart(event: DragEvent, view: EditorView) {
|
||||||
view.focus();
|
view.focus();
|
||||||
|
|
||||||
if (!event.dataTransfer) return;
|
if (!event.dataTransfer) return;
|
||||||
|
|
||||||
const node = nodeDOMAtCoords({
|
const node = nodeDOMAtCoords({
|
||||||
x: event.clientX + options.dragHandleWidth + 50,
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!(node instanceof Element)) return;
|
if (!(node instanceof Element)) return;
|
||||||
|
|
||||||
const nodePos = nodePosAtDOM(node, view);
|
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||||
if (nodePos === null || nodePos === undefined || nodePos < 0) return;
|
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 slice = view.state.selection.content();
|
||||||
const { dom, text } = __serializeForClipboard(view, slice);
|
const { dom, text } = __serializeForClipboard(view, slice);
|
||||||
@ -123,8 +149,6 @@ function DragHandle(options: DragHandleOptions) {
|
|||||||
function handleClick(event: MouseEvent, view: EditorView) {
|
function handleClick(event: MouseEvent, view: EditorView) {
|
||||||
view.focus();
|
view.focus();
|
||||||
|
|
||||||
view.dom.classList.remove("dragging");
|
|
||||||
|
|
||||||
const node = nodeDOMAtCoords({
|
const node = nodeDOMAtCoords({
|
||||||
x: event.clientX + 50 + options.dragHandleWidth,
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
@ -132,11 +156,18 @@ function DragHandle(options: DragHandleOptions) {
|
|||||||
|
|
||||||
if (!(node instanceof Element)) return;
|
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;
|
let dragHandleElement: HTMLElement | null = null;
|
||||||
@ -166,11 +197,15 @@ function DragHandle(options: DragHandleOptions) {
|
|||||||
handleClick(e, view);
|
handleClick(e, view);
|
||||||
});
|
});
|
||||||
|
|
||||||
dragHandleElement.addEventListener("dragstart", (e) => {
|
dragHandleElement.addEventListener("drag", (e) => {
|
||||||
handleDragStart(e, view);
|
hideDragHandle();
|
||||||
});
|
const a = document.querySelector(".frame-renderer");
|
||||||
dragHandleElement.addEventListener("click", (e) => {
|
if (!a) return;
|
||||||
handleClick(e, view);
|
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();
|
hideDragHandle();
|
||||||
@ -192,11 +227,11 @@ function DragHandle(options: DragHandleOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const node = nodeDOMAtCoords({
|
const node = nodeDOMAtCoords({
|
||||||
x: event.clientX + options.dragHandleWidth,
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!(node instanceof Element)) {
|
if (!(node instanceof Element) || node.matches("ul, ol")) {
|
||||||
hideDragHandle();
|
hideDragHandle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -207,32 +242,74 @@ function DragHandle(options: DragHandleOptions) {
|
|||||||
|
|
||||||
const rect = absoluteRect(node);
|
const rect = absoluteRect(node);
|
||||||
|
|
||||||
rect.top += (lineHeight - 24) / 2;
|
rect.top += (lineHeight - 20) / 2;
|
||||||
rect.top += paddingTop;
|
rect.top += paddingTop;
|
||||||
// Li markers
|
// Li markers
|
||||||
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
|
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;
|
rect.width = options.dragHandleWidth;
|
||||||
|
|
||||||
if (!dragHandleElement) return;
|
if (!dragHandleElement) return;
|
||||||
|
|
||||||
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
||||||
dragHandleElement.style.top = `${rect.top + 3}px`;
|
dragHandleElement.style.top = `${rect.top}px`;
|
||||||
showDragHandle();
|
showDragHandle();
|
||||||
},
|
},
|
||||||
keydown: () => {
|
keydown: () => {
|
||||||
hideDragHandle();
|
hideDragHandle();
|
||||||
},
|
},
|
||||||
wheel: () => {
|
mousewheel: () => {
|
||||||
hideDragHandle();
|
hideDragHandle();
|
||||||
},
|
},
|
||||||
// dragging className is used for CSS
|
dragenter: (view) => {
|
||||||
dragstart: (view) => {
|
|
||||||
view.dom.classList.add("dragging");
|
view.dom.classList.add("dragging");
|
||||||
|
hideDragHandle();
|
||||||
},
|
},
|
||||||
drop: (view) => {
|
drop: (view, event) => {
|
||||||
view.dom.classList.remove("dragging");
|
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) => {
|
dragend: (view) => {
|
||||||
view.dom.classList.remove("dragging");
|
view.dom.classList.remove("dragging");
|
||||||
@ -250,6 +327,7 @@ export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: ()
|
|||||||
return [
|
return [
|
||||||
DragHandle({
|
DragHandle({
|
||||||
dragHandleWidth: 24,
|
dragHandleWidth: 24,
|
||||||
|
scrollThreshold: { up: 300, down: 100 },
|
||||||
setHideDragHandle,
|
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 });
|
props.command({ editor, range });
|
||||||
},
|
},
|
||||||
allow({ editor }: { editor: Editor }) {
|
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,
|
allowSpaces: true,
|
||||||
},
|
},
|
||||||
@ -71,11 +84,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getSuggestionItems =
|
const getSuggestionItems =
|
||||||
(
|
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
|
||||||
additionalOptions?: Array<ISlashCommandItem>
|
|
||||||
) =>
|
|
||||||
({ query }: { query: string }) => {
|
({ query }: { query: string }) => {
|
||||||
let slashCommands: ISlashCommandItem[] = [
|
let slashCommands: ISlashCommandItem[] = [
|
||||||
{
|
{
|
||||||
@ -186,7 +195,7 @@ const getSuggestionItems =
|
|||||||
searchTerms: ["img", "photo", "picture", "media"],
|
searchTerms: ["img", "photo", "picture", "media"],
|
||||||
icon: <ImageIcon className="h-3.5 w-3.5" />,
|
icon: <ImageIcon className="h-3.5 w-3.5" />,
|
||||||
command: ({ editor, range }: CommandProps) => {
|
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
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
className={cn(
|
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)}
|
onClick={() => selectItem(index)}
|
||||||
@ -315,19 +324,21 @@ const CommandList = ({ items, command }: { items: CommandItemProps[]; command: a
|
|||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItems = () => {
|
interface CommandListInstance {
|
||||||
let component: ReactRenderer | null = null;
|
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||||
let popup: any | null = null;
|
}
|
||||||
|
|
||||||
|
const renderItems = () => {
|
||||||
|
let component: ReactRenderer<CommandListInstance, typeof CommandList> | null = null;
|
||||||
|
let popup: any | null = null;
|
||||||
return {
|
return {
|
||||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||||
component = new ReactRenderer(CommandList, {
|
component = new ReactRenderer(CommandList, {
|
||||||
props,
|
props,
|
||||||
// @ts-ignore
|
|
||||||
editor: props.editor,
|
editor: props.editor,
|
||||||
});
|
});
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-expect-error Tippy overloads are messed up
|
||||||
popup = tippy("body", {
|
popup = tippy("body", {
|
||||||
getReferenceClientRect: props.clientRect,
|
getReferenceClientRect: props.clientRect,
|
||||||
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
|
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
|
||||||
@ -353,8 +364,10 @@ const renderItems = () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-ignore
|
if (component?.ref?.onKeyDown(props)) {
|
||||||
return component?.ref?.onKeyDown(props);
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
popup?.[0].destroy();
|
popup?.[0].destroy();
|
||||||
@ -363,14 +376,10 @@ const renderItems = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SlashCommand = (
|
export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
|
||||||
uploadFile: UploadImage,
|
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
|
||||||
additionalOptions?: Array<ISlashCommandItem>
|
|
||||||
) =>
|
|
||||||
Command.configure({
|
Command.configure({
|
||||||
suggestion: {
|
suggestion: {
|
||||||
items: getSuggestionItems(uploadFile, setIsSubmitting, additionalOptions),
|
items: getSuggestionItems(uploadFile, additionalOptions),
|
||||||
render: renderItems,
|
render: renderItems,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import "src/styles/drag-drop.css";
|
import "src/styles/drag-drop.css";
|
||||||
|
|
||||||
export { SlashCommand } from "src/extensions/slash-commands";
|
export { DragAndDrop, SlashCommand } from "src/extensions";
|
||||||
export { DragAndDrop } from "src/extensions/drag-drop";
|
|
||||||
|
@ -1,26 +1,31 @@
|
|||||||
|
/* drag handle */
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: opacity ease-in 0.2s;
|
transition: opacity ease-in 0.2s;
|
||||||
height: 18px;
|
height: 20px;
|
||||||
width: 15px;
|
width: 15px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
z-index: 10;
|
z-index: 5;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
border-radius: 2px;
|
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;
|
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;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 600px) {
|
@media screen and (max-width: 600px) {
|
||||||
.drag-handle {
|
.drag-handle {
|
||||||
@ -32,7 +37,6 @@
|
|||||||
.drag-handle-container {
|
.drag-handle-container {
|
||||||
height: 15px;
|
height: 15px;
|
||||||
width: 15px;
|
width: 15px;
|
||||||
cursor: grab;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
@ -46,8 +50,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle-dot {
|
.drag-handle-dot {
|
||||||
height: 2.75px;
|
height: 2.5px;
|
||||||
width: 3px;
|
width: 2.5px;
|
||||||
background-color: rgba(var(--color-text-200));
|
background-color: rgba(var(--color-text-300));
|
||||||
border-radius: 50%;
|
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 { 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 { 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,
|
EditorContentWrapper,
|
||||||
getEditorClassNames,
|
getEditorClassNames,
|
||||||
useEditor,
|
useEditor,
|
||||||
|
IMentionHighlight,
|
||||||
|
EditorRefApi,
|
||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
import { FixedMenu } from "src/ui/menus/fixed-menu";
|
|
||||||
import { LiteTextEditorExtensions } from "src/ui/extensions";
|
import { LiteTextEditorExtensions } from "src/ui/extensions";
|
||||||
|
|
||||||
interface ILiteTextEditor {
|
export interface ILiteTextEditor {
|
||||||
value: string;
|
initialValue: string;
|
||||||
uploadFile: UploadImage;
|
value?: string | null;
|
||||||
deleteFile: DeleteImage;
|
fileHandler: {
|
||||||
restoreFile: RestoreImage;
|
cancel: () => void;
|
||||||
|
delete: DeleteImage;
|
||||||
noBorder?: boolean;
|
upload: UploadImage;
|
||||||
borderOnFocus?: boolean;
|
restore: RestoreImage;
|
||||||
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";
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
containerClassName?: string;
|
||||||
|
editorClassName?: string;
|
||||||
|
onChange?: (json: object, html: string) => void;
|
||||||
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
onEnterKeyPress?: (e?: any) => void;
|
onEnterKeyPress?: (e?: any) => void;
|
||||||
cancelUploadImage?: () => any;
|
mentionHandler: {
|
||||||
mentionHighlights?: string[];
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
mentionSuggestions?: IMentionSuggestion[];
|
suggestions?: () => Promise<IMentionSuggestion[]>;
|
||||||
submitButton?: React.ReactNode;
|
};
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LiteTextEditorProps extends ILiteTextEditor {
|
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorHandle {
|
|
||||||
clearEditor: () => void;
|
|
||||||
setEditorValue: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiteTextEditor = (props: LiteTextEditorProps) => {
|
|
||||||
const {
|
const {
|
||||||
onChange,
|
onChange,
|
||||||
cancelUploadImage,
|
initialValue,
|
||||||
debouncedUpdatesEnabled,
|
fileHandler,
|
||||||
setIsSubmitting,
|
|
||||||
setShouldShowAlert,
|
|
||||||
editorContentCustomClassNames,
|
|
||||||
value,
|
value,
|
||||||
uploadFile,
|
containerClassName,
|
||||||
deleteFile,
|
editorClassName = "",
|
||||||
restoreFile,
|
|
||||||
noBorder,
|
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
commentAccessSpecifier,
|
|
||||||
onEnterKeyPress,
|
onEnterKeyPress,
|
||||||
mentionHighlights,
|
|
||||||
mentionSuggestions,
|
|
||||||
submitButton,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
|
mentionHandler,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
onChange,
|
onChange,
|
||||||
cancelUploadImage,
|
initialValue,
|
||||||
debouncedUpdatesEnabled,
|
|
||||||
setIsSubmitting,
|
|
||||||
setShouldShowAlert,
|
|
||||||
value,
|
value,
|
||||||
uploadFile,
|
editorClassName,
|
||||||
deleteFile,
|
restoreFile: fileHandler.restore,
|
||||||
restoreFile,
|
uploadFile: fileHandler.upload,
|
||||||
|
deleteFile: fileHandler.delete,
|
||||||
|
cancelUploadImage: fileHandler.cancel,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||||
mentionHighlights,
|
mentionHandler,
|
||||||
mentionSuggestions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
const editorContainerClassName = getEditorClassNames({
|
||||||
noBorder,
|
noBorder: true,
|
||||||
borderOnFocus,
|
borderOnFocus: false,
|
||||||
customClassName,
|
containerClassName,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EditorContentWrapper
|
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||||
tabIndex={tabIndex}
|
|
||||||
editor={editor}
|
|
||||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
|
||||||
/>
|
|
||||||
<div className="mt-4 w-full">
|
|
||||||
<FixedMenu
|
|
||||||
editor={editor}
|
|
||||||
uploadFile={uploadFile}
|
|
||||||
setIsSubmitting={setIsSubmitting}
|
|
||||||
commentAccessSpecifier={commentAccessSpecifier}
|
|
||||||
submitButton={submitButton}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>((props, ref) => (
|
const LiteTextEditorWithRef = React.forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||||
<LiteTextEditor {...props} forwardedRef={ref} />
|
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||||
));
|
));
|
||||||
|
|
||||||
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
|
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 * 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 {
|
export interface ILiteTextReadOnlyEditor {
|
||||||
value: string;
|
initialValue: string;
|
||||||
editorContentCustomClassNames?: string;
|
|
||||||
noBorder?: boolean;
|
|
||||||
borderOnFocus?: boolean;
|
borderOnFocus?: boolean;
|
||||||
customClassName?: string;
|
containerClassName?: string;
|
||||||
mentionHighlights: string[];
|
editorClassName?: string;
|
||||||
|
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
|
mentionHandler: {
|
||||||
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
|
};
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
const LiteTextReadOnlyEditor = ({
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
containerClassName,
|
||||||
}
|
editorClassName = "",
|
||||||
|
initialValue,
|
||||||
interface EditorHandle {
|
|
||||||
clearEditor: () => void;
|
|
||||||
setEditorValue: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LiteReadOnlyEditor = ({
|
|
||||||
editorContentCustomClassNames,
|
|
||||||
noBorder,
|
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
value,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHighlights,
|
mentionHandler,
|
||||||
tabIndex,
|
tabIndex,
|
||||||
}: EditorCoreProps) => {
|
}: ILiteTextReadOnlyEditor) => {
|
||||||
const editor = useReadOnlyEditor({
|
const editor = useReadOnlyEditor({
|
||||||
value,
|
initialValue,
|
||||||
|
editorClassName,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHighlights,
|
mentionHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
const editorContainerClassName = getEditorClassNames({
|
||||||
noBorder,
|
containerClassName,
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EditorContentWrapper
|
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||||
tabIndex={tabIndex}
|
|
||||||
editor={editor}
|
|
||||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const LiteReadOnlyEditorWithRef = React.forwardRef<EditorHandle, ICoreReadOnlyEditor>((props, ref) => (
|
const LiteTextReadOnlyEditorWithRef = React.forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
|
||||||
<LiteReadOnlyEditor {...props} forwardedRef={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 { RichTextEditor, RichTextEditorWithRef } from "src/ui";
|
||||||
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "src/ui/read-only";
|
export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "src/ui/read-only";
|
||||||
export type { RichTextEditorProps, IRichTextEditor } from "src/ui";
|
|
||||||
export type { IMentionHighlight, IMentionSuggestion } from "@plane/editor-core";
|
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 = (
|
export const RichTextEditorExtensions = (
|
||||||
uploadFile: UploadImage,
|
uploadFile: UploadImage,
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
|
|
||||||
dragDropEnabled?: boolean,
|
dragDropEnabled?: boolean,
|
||||||
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
|
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
|
||||||
) => [
|
) => [
|
||||||
SlashCommand(uploadFile, setIsSubmitting),
|
SlashCommand(uploadFile),
|
||||||
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
|
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
|
||||||
Placeholder.configure({
|
Placeholder.configure({
|
||||||
placeholder: ({ node }) => {
|
placeholder: ({ editor, node }) => {
|
||||||
if (node.type.name === "heading") {
|
if (node.type.name === "heading") {
|
||||||
return `Heading ${node.attrs.level}`;
|
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 "";
|
return "";
|
||||||
}
|
}
|
||||||
if (node.type.name === "codeBlock") {
|
|
||||||
return "Type in your code here...";
|
|
||||||
}
|
|
||||||
return "Press '/' for commands...";
|
return "Press '/' for commands...";
|
||||||
},
|
},
|
||||||
includeChildren: true,
|
includeChildren: true,
|
||||||
|
@ -4,73 +4,56 @@ import {
|
|||||||
EditorContainer,
|
EditorContainer,
|
||||||
EditorContentWrapper,
|
EditorContentWrapper,
|
||||||
getEditorClassNames,
|
getEditorClassNames,
|
||||||
|
IMentionHighlight,
|
||||||
IMentionSuggestion,
|
IMentionSuggestion,
|
||||||
RestoreImage,
|
RestoreImage,
|
||||||
UploadImage,
|
UploadImage,
|
||||||
useEditor,
|
useEditor,
|
||||||
|
EditorRefApi,
|
||||||
} from "@plane/editor-core";
|
} from "@plane/editor-core";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { RichTextEditorExtensions } from "src/ui/extensions";
|
import { RichTextEditorExtensions } from "src/ui/extensions";
|
||||||
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
|
||||||
|
|
||||||
export type IRichTextEditor = {
|
export type IRichTextEditor = {
|
||||||
value: string;
|
initialValue: string;
|
||||||
initialValue?: string;
|
value?: string | null;
|
||||||
dragDropEnabled?: boolean;
|
dragDropEnabled?: boolean;
|
||||||
uploadFile: UploadImage;
|
fileHandler: {
|
||||||
restoreFile: RestoreImage;
|
cancel: () => void;
|
||||||
deleteFile: DeleteImage;
|
delete: DeleteImage;
|
||||||
noBorder?: boolean;
|
upload: UploadImage;
|
||||||
borderOnFocus?: boolean;
|
restore: RestoreImage;
|
||||||
cancelUploadImage?: () => any;
|
|
||||||
rerenderOnPropsChange?: {
|
|
||||||
id: string;
|
|
||||||
description_html: string;
|
|
||||||
};
|
};
|
||||||
customClassName?: string;
|
id?: string;
|
||||||
editorContentCustomClassNames?: string;
|
containerClassName?: string;
|
||||||
onChange?: (json: any, html: string) => void;
|
editorClassName?: string;
|
||||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
onChange?: (json: object, html: string) => void;
|
||||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
|
||||||
forwardedRef?: any;
|
|
||||||
debouncedUpdatesEnabled?: boolean;
|
debouncedUpdatesEnabled?: boolean;
|
||||||
mentionHighlights?: string[];
|
mentionHandler: {
|
||||||
mentionSuggestions?: IMentionSuggestion[];
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
|
suggestions: () => Promise<IMentionSuggestion[]>;
|
||||||
|
};
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface RichTextEditorProps extends IRichTextEditor {
|
const RichTextEditor = (props: IRichTextEditor) => {
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
const {
|
||||||
}
|
|
||||||
|
|
||||||
interface EditorHandle {
|
|
||||||
clearEditor: () => void;
|
|
||||||
setEditorValue: (content: string) => void;
|
|
||||||
setEditorValueAtCursorPosition: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RichTextEditor = ({
|
|
||||||
onChange,
|
onChange,
|
||||||
dragDropEnabled,
|
dragDropEnabled,
|
||||||
debouncedUpdatesEnabled,
|
|
||||||
setIsSubmitting,
|
|
||||||
setShouldShowAlert,
|
|
||||||
editorContentCustomClassNames,
|
|
||||||
value,
|
|
||||||
initialValue,
|
initialValue,
|
||||||
uploadFile,
|
value,
|
||||||
deleteFile,
|
fileHandler,
|
||||||
noBorder,
|
containerClassName,
|
||||||
cancelUploadImage,
|
editorClassName = "",
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
restoreFile,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHighlights,
|
// rerenderOnPropsChange,
|
||||||
rerenderOnPropsChange,
|
id = "",
|
||||||
mentionSuggestions,
|
|
||||||
tabIndex,
|
tabIndex,
|
||||||
}: RichTextEditorProps) => {
|
mentionHandler,
|
||||||
|
} = props;
|
||||||
|
|
||||||
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
|
||||||
|
|
||||||
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
|
||||||
@ -80,50 +63,45 @@ const RichTextEditor = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
|
id,
|
||||||
|
editorClassName,
|
||||||
|
restoreFile: fileHandler.restore,
|
||||||
|
uploadFile: fileHandler.upload,
|
||||||
|
deleteFile: fileHandler.delete,
|
||||||
|
cancelUploadImage: fileHandler.cancel,
|
||||||
onChange,
|
onChange,
|
||||||
debouncedUpdatesEnabled,
|
initialValue,
|
||||||
setIsSubmitting,
|
|
||||||
setShouldShowAlert,
|
|
||||||
value,
|
value,
|
||||||
uploadFile,
|
|
||||||
cancelUploadImage,
|
|
||||||
deleteFile,
|
|
||||||
restoreFile,
|
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
rerenderOnPropsChange,
|
// rerenderOnPropsChange,
|
||||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled, setHideDragHandleFunction),
|
extensions: RichTextEditorExtensions(fileHandler.upload, dragDropEnabled, setHideDragHandleFunction),
|
||||||
mentionHighlights,
|
mentionHandler,
|
||||||
mentionSuggestions,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
const editorContainerClassName = getEditorClassNames({
|
||||||
noBorder,
|
noBorder: true,
|
||||||
borderOnFocus,
|
borderOnFocus: false,
|
||||||
customClassName,
|
containerClassName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// React.useEffect(() => {
|
|
||||||
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
|
|
||||||
// }, [editor, initialValue]);
|
|
||||||
//
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer
|
||||||
|
hideDragHandle={hideDragHandleOnMouseLeave}
|
||||||
|
editor={editor}
|
||||||
|
editorContainerClassName={editorContainerClassName}
|
||||||
|
>
|
||||||
{editor && <EditorBubbleMenu editor={editor} />}
|
{editor && <EditorBubbleMenu editor={editor} />}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EditorContentWrapper
|
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
|
||||||
tabIndex={tabIndex}
|
|
||||||
editor={editor}
|
|
||||||
editorContentCustomClassNames={editorContentCustomClassNames}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>((props, ref) => (
|
const RichTextEditorWithRef = React.forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
|
||||||
<RichTextEditor {...props} forwardedRef={ref} />
|
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||||
));
|
));
|
||||||
|
|
||||||
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
|
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
|
||||||
|
@ -1,62 +1,54 @@
|
|||||||
"use client";
|
"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";
|
import * as React from "react";
|
||||||
|
|
||||||
interface IRichTextReadOnlyEditor {
|
export interface IRichTextReadOnlyEditor {
|
||||||
value: string;
|
initialValue: string;
|
||||||
editorContentCustomClassNames?: string;
|
containerClassName?: string;
|
||||||
noBorder?: boolean;
|
editorClassName?: string;
|
||||||
borderOnFocus?: boolean;
|
|
||||||
customClassName?: string;
|
|
||||||
mentionHighlights?: string[];
|
|
||||||
tabIndex?: number;
|
tabIndex?: number;
|
||||||
|
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||||
|
mentionHandler: {
|
||||||
|
highlights: () => Promise<IMentionHighlight[]>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
|
const RichTextReadOnlyEditor = (props: IRichTextReadOnlyEditor) => {
|
||||||
forwardedRef?: React.Ref<EditorHandle>;
|
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({
|
const editor = useReadOnlyEditor({
|
||||||
value,
|
initialValue,
|
||||||
|
editorClassName,
|
||||||
forwardedRef,
|
forwardedRef,
|
||||||
mentionHighlights,
|
mentionHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const editorClassNames = getEditorClassNames({
|
const editorContainerClassName = getEditorClassNames({
|
||||||
noBorder,
|
containerClassName,
|
||||||
borderOnFocus,
|
|
||||||
customClassName,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
<EditorContentWrapper editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
</EditorContainer>
|
</EditorContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RichReadOnlyEditorWithRef = React.forwardRef<EditorHandle, IRichTextReadOnlyEditor>((props, ref) => (
|
const RichTextReadOnlyEditorWithRef = React.forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => (
|
||||||
<RichReadOnlyEditor {...props} forwardedRef={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" })
|
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
export type SelectIssue =
|
|
||||||
| (TIssue & { actionType: "edit" | "delete" | "create" })
|
|
||||||
| null;
|
|
||||||
|
|
||||||
export type CycleDateCheckData = {
|
export type CycleDateCheckData = {
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
|
@ -1,17 +1,9 @@
|
|||||||
|
import { EDurationFilters } from "./enums";
|
||||||
import { IIssueActivity, TIssuePriorities } from "./issues";
|
import { IIssueActivity, TIssuePriorities } from "./issues";
|
||||||
import { TIssue } from "./issues/issue";
|
import { TIssue } from "./issues/issue";
|
||||||
import { TIssueRelationTypes } from "./issues/issue_relation";
|
import { TIssueRelationTypes } from "./issues/issue_relation";
|
||||||
import { TStateGroups } from "./state";
|
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 =
|
export type TWidgetKeys =
|
||||||
| "overview_stats"
|
| "overview_stats"
|
||||||
| "assigned_issues"
|
| "assigned_issues"
|
@ -4,3 +4,23 @@ export enum EUserProjectRoles {
|
|||||||
MEMBER = 15,
|
MEMBER = 15,
|
||||||
ADMIN = 20,
|
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",
|
||||||
|
}
|
||||||
|
@ -4,6 +4,7 @@ import {
|
|||||||
TIssueActivityIssueDetail,
|
TIssueActivityIssueDetail,
|
||||||
TIssueActivityUserDetail,
|
TIssueActivityUserDetail,
|
||||||
} from "./base";
|
} from "./base";
|
||||||
|
import { EIssueCommentAccessSpecifier } from "../../enums";
|
||||||
|
|
||||||
export type TIssueComment = {
|
export type TIssueComment = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -20,14 +21,13 @@ export type TIssueComment = {
|
|||||||
created_by: string | undefined;
|
created_by: string | undefined;
|
||||||
updated_by: string | undefined;
|
updated_by: string | undefined;
|
||||||
attachments: any[];
|
attachments: any[];
|
||||||
|
|
||||||
comment_reactions: any[];
|
comment_reactions: any[];
|
||||||
comment_stripped: string;
|
comment_stripped: string;
|
||||||
comment_html: string;
|
comment_html: string;
|
||||||
comment_json: object;
|
comment_json: object;
|
||||||
external_id: string | undefined;
|
external_id: string | undefined;
|
||||||
external_source: string | undefined;
|
external_source: string | undefined;
|
||||||
access: "EXTERNAL" | "INTERNAL";
|
access: EIssueCommentAccessSpecifier;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssueCommentMap = {
|
export type TIssueCommentMap = {
|
||||||
|
13
packages/types/src/module/modules.d.ts
vendored
13
packages/types/src/module/modules.d.ts
vendored
@ -1,4 +1,11 @@
|
|||||||
import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types";
|
import type {
|
||||||
|
TIssue,
|
||||||
|
IIssueFilterOptions,
|
||||||
|
ILinkDetails,
|
||||||
|
TAssigneesDistribution,
|
||||||
|
TCompletionChartDistribution,
|
||||||
|
TLabelsDistribution,
|
||||||
|
} from "@plane/types";
|
||||||
|
|
||||||
export type TModuleStatus =
|
export type TModuleStatus =
|
||||||
| "backlog"
|
| "backlog"
|
||||||
@ -69,7 +76,3 @@ export type ModuleLink = {
|
|||||||
export type SelectModuleType =
|
export type SelectModuleType =
|
||||||
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
|
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
export type SelectIssue =
|
|
||||||
| (TIssue & { actionType: "edit" | "delete" | "create" })
|
|
||||||
| undefined;
|
|
||||||
|
4
packages/types/src/notifications.d.ts
vendored
4
packages/types/src/notifications.d.ts
vendored
@ -36,7 +36,7 @@ export interface IUserNotification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
issue: IIssueLite;
|
issue: INotificationIssueLite;
|
||||||
issue_activity: {
|
issue_activity: {
|
||||||
actor: string;
|
actor: string;
|
||||||
field: string;
|
field: string;
|
||||||
@ -48,7 +48,7 @@ export interface Data {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IIssueLite {
|
export interface INotificationIssueLite {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
identifier: string;
|
identifier: string;
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user