Compare commits

...

17 Commits

Author SHA1 Message Date
Aaryan Khandelwal
ac6d2f4f16 fix: workspace page version history 2024-11-14 20:50:28 +05:30
Aaryan Khandelwal
463cfd7731 fix: workspace page version history 2024-11-14 20:47:10 +05:30
Aaryan Khandelwal
2756c383ba feat: version history for issues 2024-11-14 20:42:42 +05:30
Aaryan Khandelwal
acd68595d5 feat: issue version history 2024-11-14 20:06:35 +05:30
NarayanBavisetti
07e8055fa2 chore: migration conflicts 2024-11-14 19:51:53 +05:30
NarayanBavisetti
ca5a10378f Merge branch 'runway-pages' of github.com:makeplane/plane-ee into chore/issue-version-history 2024-11-14 18:21:26 +05:30
NarayanBavisetti
5277788be4 chore: issue description version histroy 2024-11-14 18:17:02 +05:30
Palanikannan M
4614669fa1 fix: more stuff 2024-11-14 18:09:41 +05:30
Palanikannan M
bc092d6a98 fix: added to read only extension 2024-11-14 17:56:13 +05:30
Palanikannan M
06fd5295b3 fix: added lists for read only 2024-11-14 17:54:01 +05:30
Palanikannan M
0f3f2b54b0 Merge branch 'runway-pages' of https://github.com/makeplane/plane-ee into runway-pages 2024-11-14 17:18:42 +05:30
Palanikannan M
acc439934d fix: type error 2024-11-14 17:18:05 +05:30
M. Palanikannan
f66fb0c82c Flat list demo (#1714)
Flat list demo - drag drop and realtime unlock
2024-11-14 16:56:33 +05:30
Aaryan Khandelwal
869db76641 chore: update editor package json 2024-11-14 16:01:22 +05:30
Aaryan Khandelwal
5561260fec chore; updated yarn lock 2024-11-14 15:48:17 +05:30
Aaryan Khandelwal
1538e0a235 dev: conflict free issue description (#1712)
* chore: new description binary endpoints

* chore: conflict free issue description

* chore: fix submitting status

* chore: update yjs utils

* chore: handle component re-mounting

* chore: update buffer response type

* chore: add try catch for issue description update

* chore: update buffer response type

* chore: description binary in retrieve

* chore: update issue description hook

* chore: decode description binary

* chore: migrations fixes and cleanup

* chore: migration fixes

* fix: inbox issue description

* chore: move update operations to the issue store

* fix: merge conflicts

* chore: reverted the commit

* chore: removed the unwanted imports

* chore: remove unnecessary props

* chore: remove unused services

* chore: update live server error handling

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-11-14 13:48:25 +05:30
M. Palanikannan
3d5e86d227 Flat list demo (#1709)
* wip: added flat list

* fix: able to render prosemirror flat lists

* fix: types to insert list

* chore: renamed manual logger and logger middleware across the live server

* feat: old and new lists coexist

* feat: migration (but with duplication of content and persistence of old lists due to indexed db)

* fix: flat lists

* fix: migration script added

* fix: styles of ordered lists

* fix: table insertion

* fix: image attr type

* fix: insert table command for nesting it in lists

* fix: exiting code blocks inside lists

* revert: old fetch logic

* feat: trying out heading lists, nested styles and diving deeper

* fix: flat-list

* fix: parse html

* fix: css

* fix: add toggle list command
2024-11-13 22:16:16 +05:30
190 changed files with 10976 additions and 2666 deletions

View File

@@ -51,7 +51,7 @@ from plane.db.models import (
ProjectMember,
CycleIssue,
)
from plane.bgtasks.version_task import version_task
from .base import BaseAPIView
@@ -486,6 +486,13 @@ class IssueAPIEndpoint(BaseAPIView):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# Serialize the existing instance
existing_instance = json.dumps(
{
"description_html": issue.description_html,
},
cls=DjangoJSONEncoder,
)
project = Project.objects.get(pk=project_id)
current_instance = json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
@@ -522,6 +529,13 @@ class IssueAPIEndpoint(BaseAPIView):
)
serializer.save()
# Return a success response
version_task.delay(
entity_type="ISSUE",
entity_identifier=pk,
existing_instance=existing_instance,
user_id=request.user.id,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=requested_data,

View File

@@ -72,6 +72,8 @@ from .issue import (
IssueReactionLiteSerializer,
IssueAttachmentLiteSerializer,
IssueLinkLiteSerializer,
IssueDescriptionVersionSerializer,
IssueDescriptionVersionDetailSerializer,
)
from .module import (

View File

@@ -301,9 +301,11 @@ class DraftIssueSerializer(BaseSerializer):
class DraftIssueDetailSerializer(DraftIssueSerializer):
description_html = serializers.CharField()
description_binary = serializers.CharField()
class Meta(DraftIssueSerializer.Meta):
fields = DraftIssueSerializer.Meta.fields + [
"description_html",
"description_binary",
]
read_only_fields = fields

View File

@@ -1,3 +1,6 @@
# Python imports
import base64
# Django imports
from django.utils import timezone
from django.core.validators import URLValidator
@@ -33,6 +36,7 @@ from plane.db.models import (
IssueVote,
IssueRelation,
State,
IssueDescriptionVersion,
IssueType,
)
@@ -753,14 +757,31 @@ class IssueLiteSerializer(DynamicBaseSerializer):
read_only_fields = fields
class Base64BinaryField(serializers.CharField):
def to_representation(self, value):
# Encode the binary data to base64 string for JSON response
if value:
return base64.b64encode(value).decode("utf-8")
return None
def to_internal_value(self, data):
# Decode the base64 string to binary data when saving
try:
return base64.b64decode(data)
except (TypeError, ValueError):
raise serializers.ValidationError("Invalid base64-encoded data")
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
description_binary = Base64BinaryField()
is_subscribed = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
"description_binary",
]
read_only_fields = fields
@@ -802,3 +823,46 @@ class IssueSubscriberSerializer(BaseSerializer):
"project",
"issue",
]
class IssueDescriptionVersionSerializer(BaseSerializer):
class Meta:
model = IssueDescriptionVersion
fields = [
"id",
"workspace",
"issue",
"last_saved_at",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"issue",
]
class IssueDescriptionVersionDetailSerializer(BaseSerializer):
class Meta:
model = IssueDescriptionVersion
fields = [
"id",
"workspace",
"issue",
"last_saved_at",
"description_binary",
"description_html",
"description_json",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
]
read_only_fields = [
"workspace",
"issue",
]

View File

@@ -92,4 +92,14 @@ urlpatterns = [
),
name="inbox-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/inbox-issues/<uuid:pk>/description/",
IntakeIssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="inbox-issue-description",
),
]

View File

@@ -23,6 +23,7 @@ from plane.app.views import (
IssueDetailEndpoint,
IssueAttachmentV2Endpoint,
IssueBulkUpdateDateEndpoint,
IssueVersionEndpoint,
)
urlpatterns = [
@@ -65,6 +66,16 @@ urlpatterns = [
),
name="project-issue",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:pk>/description/",
IssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="project-issue-description",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issue-labels/",
LabelViewSet.as_view(
@@ -282,6 +293,15 @@ urlpatterns = [
),
name="project-issue-archive-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-issues/<uuid:pk>/description/",
IssueArchiveViewSet.as_view(
{
"get": "retrieve_description",
}
),
name="archive-issue-description",
),
## End Issue Archives
## Issue Relation
path(
@@ -314,4 +334,16 @@ urlpatterns = [
IssueBulkUpdateDateEndpoint.as_view(),
name="project-issue-dates",
),
# Issue Description versions
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/",
IssueVersionEndpoint.as_view(),
name="issue-versions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/issues/<uuid:issue_id>/versions/<uuid:pk>/",
IssueVersionEndpoint.as_view(),
name="issue-versions",
),
## End Issue Description versions
]

View File

@@ -276,6 +276,16 @@ urlpatterns = [
),
name="workspace-drafts-issues",
),
path(
"workspaces/<str:slug>/draft-issues/<uuid:pk>/description/",
WorkspaceDraftIssueViewSet.as_view(
{
"get": "retrieve_description",
"post": "update_description",
}
),
name="workspace-drafts-issues",
),
path(
"workspaces/<str:slug>/draft-to-issue/<uuid:draft_id>/",
WorkspaceDraftIssueViewSet.as_view({"post": "create_draft_to_issue"}),

View File

@@ -137,7 +137,9 @@ from .issue.activity import (
IssueActivityEndpoint,
)
from .issue.archive import IssueArchiveViewSet
from .issue.version import IssueVersionEndpoint
from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint
from .issue.attachment import (
IssueAttachmentEndpoint,

View File

@@ -1,5 +1,7 @@
# Python imports
import json
import requests
import base64
# Django import
from django.utils import timezone
@@ -9,6 +11,9 @@ from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.db.models import Value, UUIDField
from django.db.models.functions import Coalesce
from django.http import StreamingHttpResponse
from django.conf import settings
# Third party imports
from rest_framework import status
@@ -658,3 +663,82 @@ class IntakeIssueViewSet(BaseViewSet):
intake_issue.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def update_description(self, request, slug, project_id, pk):
issue = Issue.objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -7,13 +7,15 @@ from django.db.models import F, Func, OuterRef, Q, Prefetch, Exists, Subquery
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.http import StreamingHttpResponse
# Third Party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .. import BaseViewSet
from .. import BaseViewSet, BaseAPIView
from plane.app.serializers import (
IssueSerializer,
IssueFlatSerializer,
@@ -26,7 +28,7 @@ from plane.db.models import (
IssueLink,
IssueSubscriber,
IssueReaction,
CycleIssue
CycleIssue,
)
from plane.utils.grouper import (
issue_group_values,
@@ -39,7 +41,11 @@ from plane.utils.paginator import (
GroupedOffsetPaginator,
SubGroupedOffsetPaginator,
)
from plane.app.permissions import allow_permission, ROLE
from plane.app.permissions import (
allow_permission,
ROLE,
ProjectEntityPermission,
)
from plane.utils.error_codes import ERROR_CODES
@@ -268,10 +274,8 @@ class IssueArchiveViewSet(BaseViewSet):
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": ERROR_CODES[
"INVALID_ARCHIVE_STATE_GROUP"
],
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
"error_code": ERROR_CODES["INVALID_ARCHIVE_STATE_GROUP"],
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -325,3 +329,87 @@ class IssueArchiveViewSet(BaseViewSet):
issue.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
class BulkArchiveIssuesEndpoint(BaseAPIView):
permission_classes = [
ProjectEntityPermission,
]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id):
issue_ids = request.data.get("issue_ids", [])
if not len(issue_ids):
return Response(
{"error": "Issue IDs are required"},
status=status.HTTP_400_BAD_REQUEST,
)
issues = Issue.objects.filter(
workspace__slug=slug, project_id=project_id, pk__in=issue_ids
).select_related("state")
bulk_archive_issues = []
for issue in issues:
if issue.state.group not in ["completed", "cancelled"]:
return Response(
{
"error_code": ERROR_CODES[
"INVALID_ARCHIVE_STATE_GROUP"
],
"error_message": "INVALID_ARCHIVE_STATE_GROUP",
},
status=status.HTTP_400_BAD_REQUEST,
)
issue_activity.delay(
type="issue.activity.updated",
requested_data=json.dumps(
{
"archived_at": str(timezone.now().date()),
"automation": False,
}
),
actor_id=str(request.user.id),
issue_id=str(issue.id),
project_id=str(project_id),
current_instance=json.dumps(
IssueSerializer(issue).data, cls=DjangoJSONEncoder
),
epoch=int(timezone.now().timestamp()),
notification=True,
origin=request.META.get("HTTP_ORIGIN"),
)
issue.archived_at = timezone.now().date()
bulk_archive_issues.append(issue)
Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"])
return Response(
{"archived_at": str(timezone.now().date())},
status=status.HTTP_200_OK,
)

View File

@@ -1,5 +1,7 @@
# Python imports
import json
import requests
import base64
# Django imports
from django.contrib.postgres.aggregates import ArrayAgg
@@ -20,8 +22,10 @@ from django.db.models import (
)
from django.db.models.functions import Coalesce
from django.utils import timezone
from django.http import StreamingHttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.conf import settings
# Third Party imports
from rest_framework import status
@@ -65,6 +69,7 @@ from plane.utils.user_timezone_converter import user_timezone_converter
from plane.bgtasks.recent_visited_task import recent_visited_task
from plane.utils.global_paginator import paginate
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.version_task import version_task
class IssueListEndpoint(BaseAPIView):
@@ -484,9 +489,7 @@ class IssueViewSet(BaseViewSet):
project = Project.objects.get(pk=project_id, workspace__slug=slug)
issue = (
Issue.objects.filter(
project_id=self.kwargs.get("project_id")
)
Issue.objects.filter(project_id=self.kwargs.get("project_id"))
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -521,7 +524,7 @@ class IssueViewSet(BaseViewSet):
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
)
.filter(pk=pk)
.annotate(
label_ids=Coalesce(
@@ -760,6 +763,102 @@ class IssueViewSet(BaseViewSet):
)
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, project_id, pk):
issue = Issue.issue_objects.filter(
pk=pk, workspace__slug=slug, project_id=project_id
).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
def update_description(self, request, slug, project_id, pk):
print("in the update description")
issue = Issue.issue_objects.get(
workspace__slug=slug, project_id=project_id, pk=pk
)
# Serialize the existing instance
existing_instance = json.dumps(
{
"description_html": issue.description_html,
},
cls=DjangoJSONEncoder,
)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
# Return a success response
version_task.delay(
entity_type="ISSUE",
entity_identifier=pk,
existing_instance=existing_instance,
user_id=request.user.id,
)
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)
class IssueUserDisplayPropertyEndpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])

View File

@@ -0,0 +1,39 @@
# Third party imports
from rest_framework import status
from rest_framework.response import Response
# Module imports
from plane.db.models import IssueDescriptionVersion
from ..base import BaseAPIView
from plane.app.serializers import (
IssueDescriptionVersionSerializer,
IssueDescriptionVersionDetailSerializer,
)
from plane.app.permissions import allow_permission, ROLE
class IssueVersionEndpoint(BaseAPIView):
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, issue_id, pk=None):
# Check if pk is provided
if pk:
# Return a single issue version
issue_version = IssueDescriptionVersion.objects.get(
workspace__slug=slug,
issue_id=issue_id,
pk=pk,
)
# Serialize the issue version
serializer = IssueDescriptionVersionDetailSerializer(issue_version)
return Response(serializer.data, status=status.HTTP_200_OK)
# Return all issue versions
issue_versions = IssueDescriptionVersion.objects.filter(
workspace__slug=slug,
issue_id=issue_id,
).order_by("-last_saved_at")[:20]
# Serialize the issue versions
serializer = IssueDescriptionVersionSerializer(
issue_versions, many=True
)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -38,7 +38,7 @@ from plane.db.models import (
from plane.utils.error_codes import ERROR_CODES
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.version_task import version_task
from plane.bgtasks.recent_visited_task import recent_visited_task
@@ -635,8 +635,9 @@ class PagesDescriptionViewSet(BaseViewSet):
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
version_task.delay(
entity_type="PAGE",
entity_identifier=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)

View File

@@ -14,9 +14,7 @@ from plane.app.permissions import allow_permission, ROLE
class PageVersionEndpoint(BaseAPIView):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]
)
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id, page_id, pk=None):
# Check if pk is provided
if pk:
@@ -33,7 +31,7 @@ class PageVersionEndpoint(BaseAPIView):
page_versions = PageVersion.objects.filter(
workspace__slug=slug,
page_id=page_id,
)
).order_by("-last_saved_at")[:20]
# Serialize the page versions
serializer = PageVersionSerializer(page_versions, many=True)
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -1,5 +1,7 @@
# Python imports
import json
import requests
import base64
# Django imports
from django.utils import timezone
@@ -7,6 +9,7 @@ from django.core import serializers
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField
from django.http import StreamingHttpResponse
from django.db.models import (
Q,
UUIDField,
@@ -17,6 +20,7 @@ from django.db.models import (
from django.db.models.functions import Coalesce
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.conf import settings
# Third Party imports
from rest_framework import status
@@ -378,3 +382,78 @@ class WorkspaceDraftIssueViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve_description(self, request, slug, pk):
issue = DraftIssue.objects.filter(pk=pk, workspace__slug=slug).first()
if issue is None:
return Response(
{"error": "Issue not found"},
status=404,
)
binary_data = issue.description_binary
def stream_data():
if binary_data:
yield binary_data
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="draft_issue_description.bin"'
)
return response
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def update_description(self, request, slug, pk):
issue = DraftIssue.objects.get(workspace__slug=slug, pk=pk)
base64_description = issue.description_binary
# convert to base64 string
if base64_description:
base64_description = base64.b64encode(base64_description).decode(
"utf-8"
)
data = {
"original_document": base64_description,
"updates": request.data.get("description_binary"),
}
base_url = f"{settings.LIVE_BASE_URL}/resolve-document-conflicts/"
try:
response = requests.post(base_url, json=data, headers=None)
except requests.RequestException:
return Response(
{"error": "Failed to connect to the external service"},
status=status.HTTP_502_BAD_GATEWAY,
)
if response.status_code == 200:
issue.description = response.json().get(
"description", issue.description
)
issue.description_html = response.json().get("description_html")
response_description_binary = response.json().get(
"description_binary"
)
issue.description_binary = base64.b64decode(
response_description_binary
)
issue.save()
def stream_data():
if issue.description_binary:
yield issue.description_binary
else:
yield b""
response = StreamingHttpResponse(
stream_data(), content_type="application/octet-stream"
)
response["Content-Disposition"] = (
'attachment; filename="issue_description.bin"'
)
return response
return Response(status=status.HTTP_500_INTERNAL_SERVER_ERROR)

View File

@@ -1,53 +0,0 @@
# Python imports
import json
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Page, PageVersion
from plane.utils.exception_logger import log_exception
@shared_task
def page_version(
page_id,
existing_instance,
user_id,
):
try:
# Get the page
page = Page.objects.get(id=page_id)
# Get the current instance
current_instance = (
json.loads(existing_instance)
if existing_instance is not None
else {}
)
# Create a version if description_html is updated
if current_instance.get("description_html") != page.description_html:
# Create a new page version
PageVersion.objects.create(
page_id=page_id,
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
owned_by_id=user_id,
last_saved_at=page.updated_at,
)
# If page versions are greater than 20 delete the oldest one
if PageVersion.objects.filter(page_id=page_id).count() > 20:
# Delete the old page version
PageVersion.objects.filter(page_id=page_id).order_by(
"last_saved_at"
).first().delete()
return
except Page.DoesNotExist:
return
except Exception as e:
log_exception(e)
return

View File

@@ -0,0 +1,151 @@
# Python imports
import json
# Django imports
from django.utils import timezone
# Third party imports
from celery import shared_task
# Module imports
from plane.db.models import Page, PageVersion, Issue, IssueDescriptionVersion
from plane.utils.exception_logger import log_exception
@shared_task
def version_task(
entity_type,
entity_identifier,
existing_instance,
user_id,
):
try:
# Get the current instance
current_instance = (
json.loads(existing_instance)
if existing_instance is not None
else {}
)
if entity_type == "PAGE":
# Get the page
page = Page.objects.get(id=entity_identifier)
# Create a version if description_html is updated
if (
current_instance.get("description_html")
!= page.description_html
):
# Fetch the latest page version
page_version = (
PageVersion.objects.filter(page_id=entity_identifier)
.order_by("-last_saved_at")
.first()
)
# Get the latest page version if it exists and is owned by the user
if (
page_version
and str(page_version.owned_by_id) == str(user_id)
and (
timezone.now() - page_version.last_saved_at
).total_seconds()
<= 600
):
page_version.description_html = page.description_html
page_version.description_binary = page.description_binary
page_version.description_json = page.description
page_version.description_stripped = (
page.description_stripped
)
page_version.last_saved_at = timezone.now()
page_version.save(
update_fields=[
"description_html",
"description_binary",
"description_json",
"description_stripped",
"last_saved_at",
]
)
else:
# Create a new page version
PageVersion.objects.create(
page_id=entity_identifier,
workspace_id=page.workspace_id,
description_html=page.description_html,
description_binary=page.description_binary,
description_stripped=page.description_stripped,
owned_by_id=user_id,
last_saved_at=page.updated_at,
description_json=page.description,
created_by_id=user_id,
updated_by_id=user_id,
)
if entity_type == "ISSUE":
# Get the issue
issue = Issue.objects.get(id=entity_identifier)
# Create a version if description_html is updated
if (
current_instance.get("description_html")
!= issue.description_html
):
# Fetch the latest issue version
issue_version = (
IssueDescriptionVersion.objects.filter(
issue_id=entity_identifier
)
.order_by("-last_saved_at")
.first()
)
# Get the latest issue version if it exists and is owned by the user
if (
issue_version
and str(issue_version.owned_by_id) == str(user_id)
and (
timezone.now() - issue_version.last_saved_at
).total_seconds()
<= 600
):
issue_version.description_html = issue.description_html
issue_version.description_binary = issue.description_binary
issue_version.description_json = issue.description
issue_version.description_stripped = (
issue.description_stripped
)
issue_version.last_saved_at = timezone.now()
issue_version.save(
update_fields=[
"description_html",
"description_binary",
"description_json",
"description_stripped",
"last_saved_at",
]
)
else:
# Create a new issue version
IssueDescriptionVersion.objects.create(
issue_id=entity_identifier,
workspace_id=issue.workspace_id,
description_html=issue.description_html,
description_binary=issue.description_binary,
description_stripped=issue.description_stripped,
owned_by_id=user_id,
last_saved_at=issue.updated_at,
description_json=issue.description,
project_id=issue.project_id,
created_by_id=user_id,
updated_by_id=user_id,
)
return
except Issue.DoesNotExist:
return
except Page.DoesNotExist:
return
except Exception as e:
log_exception(e)
return

View File

@@ -1,9 +1,7 @@
# Generated by Django 4.2.15 on 2024-11-06 08:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):

View File

@@ -0,0 +1,43 @@
# Generated by Django 4.2.16 on 2024-11-14 13:16
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import uuid
class Migration(migrations.Migration):
dependencies = [
('db', '0085_intake_intakeissue_remove_inboxissue_created_by_and_more'),
]
operations = [
migrations.CreateModel(
name='IssueDescriptionVersion',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
('last_saved_at', models.DateTimeField(default=django.utils.timezone.now)),
('description_binary', models.BinaryField(null=True)),
('description_html', models.TextField(blank=True, default='<p></p>')),
('description_stripped', models.TextField(blank=True, null=True)),
('description_json', models.JSONField(blank=True, default=dict)),
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
('issue', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_description_versions', to='db.issue')),
('owned_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='issue_versions', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_%(class)s', to='db.project')),
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_%(class)s', to='db.workspace')),
],
options={
'verbose_name': 'Issue Description Version',
'verbose_name_plural': 'Issue Description Versions',
'db_table': 'issue_description_versions',
'ordering': ('-created_at',),
},
),
]

View File

@@ -41,6 +41,7 @@ from .issue import (
IssueSequence,
IssueSubscriber,
IssueVote,
IssueDescriptionVersion
)
from .module import (
Module,

View File

@@ -275,6 +275,39 @@ class IssueBlocker(ProjectBaseModel):
return f"{self.block.name} {self.blocked_by.name}"
class IssueDescriptionVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
related_name="issue_description_versions",
)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="issue_versions",
)
description_binary = models.BinaryField(null=True)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_json = models.JSONField(default=dict, blank=True)
class Meta:
verbose_name = "Issue Description Version"
verbose_name_plural = "Issue Description Versions"
db_table = "issue_description_versions"
ordering = ("-created_at",)
def save(self, *args, **kwargs):
# Strip the html tags using html parser
self.description_stripped = (
None
if (self.description_html == "" or self.description_html is None)
else strip_tags(self.description_html)
)
super(IssueDescriptionVersion, self).save(*args, **kwargs)
class IssueRelation(ProjectBaseModel):
RELATION_CHOICES = (
("duplicate", "Duplicate"),

View File

@@ -32,7 +32,7 @@ from plane.db.models import (
)
from plane.ee.views.base import BaseViewSet, BaseAPIView
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.version_task import version_task
from plane.bgtasks.page_transaction_task import page_transaction
from plane.payment.flags.flag_decorator import check_feature_flag
from plane.payment.flags.flag import FeatureFlag
@@ -423,8 +423,9 @@ class WorkspacePagesDescriptionViewSet(BaseViewSet):
page.description_html = request.data.get("description_html")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
version_task.delay(
entity_type="PAGE",
entity_identifier=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)

View File

@@ -412,6 +412,7 @@ CSRF_FAILURE_VIEW = "plane.authentication.views.common.csrf_failure"
ADMIN_BASE_URL = os.environ.get("ADMIN_BASE_URL", None)
SPACE_BASE_URL = os.environ.get("SPACE_BASE_URL", None)
APP_BASE_URL = os.environ.get("APP_BASE_URL")
LIVE_BASE_URL = os.environ.get("LIVE_BASE_URL")
HARD_DELETE_AFTER_DAYS = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 60))

View File

@@ -3,7 +3,6 @@ from django.urls import path
from plane.space.views import (
IntakeIssuePublicViewSet,
IssueVotePublicViewSet,
WorkspaceProjectDeployBoardEndpoint,
)

6
live/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
.next
.vercel
.tubro
out/
dist/
node_modules/

5
live/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "es5"
}

View File

@@ -16,16 +16,15 @@
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@hocuspocus/extension-database": "^2.13.7",
"@hocuspocus/extension-logger": "^2.13.7",
"@hocuspocus/extension-redis": "^2.13.7",
"@hocuspocus/server": "^2.13.7",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "^2.4.0",
"@tiptap/html": "^2.3.0",
"@tiptap/core": "^2.9.1",
"axios": "^1.7.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -39,14 +38,14 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.12",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-env": "^7.25.9",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",

View File

@@ -5,8 +5,9 @@ import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
// Core helpers and utilities
import { logger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
@@ -27,7 +28,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
logger.info(message);
},
}),
new Database({
@@ -40,7 +41,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
return new Promise(async (resolve, reject) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
@@ -57,9 +58,16 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
params,
});
}
if (!fetchedData) {
reject("Data is null");
return;
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
logger.error("Error in fetching document", error);
reject("Error in fetching document" + JSON.stringify(error));
}
});
},
@@ -92,7 +100,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
logger.error("Error in updating document:", error);
}
});
},
@@ -114,7 +122,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
) {
redisClient.disconnect();
}
manualLogger.warn(
logger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
@@ -123,18 +131,18 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
logger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
logger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
logger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}

View File

@@ -1,9 +1,9 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
import { logger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
logger.error(err);
// Set the response status
res.status(err.status || 500);

View File

@@ -18,7 +18,7 @@ const hooks = {
},
};
export const logger = pinoHttp({
export const coreLogger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
@@ -35,4 +35,4 @@ export const logger = pinoHttp({
},
});
export const manualLogger = logger.logger;
export const logger = coreLogger.logger;

View File

@@ -1,59 +0,0 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
} => {
// encode binary description data
const base64Data = Buffer.from(description).toString("base64");
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, description);
// convert to JSON
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
return {
contentBinaryEncoded: base64Data,
contentJSON,
contentHTML,
};
}
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}

View File

@@ -4,10 +4,9 @@ import { v4 as uuidv4 } from "uuid";
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
// editor types
import { TUserDetails } from "@plane/editor";
// types
import { type HocusPocusServerContext } from "@/core/types/common.js";
import { TUserDetails, DocumentEventResponses, TDocumentEventsServer } from "@plane/editor/lib";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
@@ -55,6 +54,12 @@ export const getHocusPocusServer = async () => {
throw Error("Authentication unsuccessful!");
}
},
async onStateless({ payload, document }) {
const response = DocumentEventResponses[payload as TDocumentEventsServer];
if (response) {
document.broadcastStateless(response);
}
},
extensions,
debounce: 10000,
});

View File

@@ -1,7 +1,7 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
import { logger } from "@/core/helpers/logger.js";
const userService = new UserService();
@@ -17,7 +17,7 @@ export const handleAuthentication = async (props: Props) => {
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
logger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== userId) {

View File

@@ -1,23 +1,21 @@
// helpers
// plane editor
import {
getAllDocumentFormatsFromBinaryData,
getBinaryDataFromHTMLString,
} from "@/core/helpers/page.js";
getAllDocumentFormatsFromDocumentEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
} from "@plane/editor/lib";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
import { logger } from "../helpers/logger.js";
const pageService = new PageService();
export const updatePageDescription = async (
params: URLSearchParams,
pageId: string,
updatedDescription: Uint8Array,
cookie: string | undefined,
cookie: string | undefined
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error(
"Invalid updatedDescription: must be an instance of Uint8Array",
);
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params.get("workspaceSlug")?.toString();
@@ -25,7 +23,7 @@ export const updatePageDescription = async (
if (!workspaceSlug || !projectId || !cookie) return;
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
getAllDocumentFormatsFromDocumentEditorBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
@@ -33,15 +31,9 @@ export const updatePageDescription = async (
description: contentJSON,
};
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
cookie,
);
await pageService.updateDescription(workspaceSlug, projectId, pageId, payload, cookie);
} catch (error) {
manualLogger.error("Update error:", error);
logger.error("Update error:", error);
throw error;
}
};
@@ -50,26 +42,16 @@ const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string,
cookie: string
) => {
if (!workspaceSlug || !projectId || !cookie) return;
try {
const pageDetails = await pageService.fetchDetails(
workspaceSlug,
projectId,
pageId,
cookie,
);
const { contentBinary } = getBinaryDataFromHTMLString(
pageDetails.description_html ?? "<p></p>",
);
const pageDetails = await pageService.fetchDetails(workspaceSlug, projectId, pageId, cookie);
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
logger.error("Error while transforming from HTML to Uint8Array", error);
throw error;
}
};
@@ -77,28 +59,18 @@ const fetchDescriptionHTMLAndTransform = async (
export const fetchPageDescriptionBinary = async (
params: URLSearchParams,
pageId: string,
cookie: string | undefined,
cookie: string | undefined
) => {
const workspaceSlug = params.get("workspaceSlug")?.toString();
const projectId = params.get("projectId")?.toString();
if (!workspaceSlug || !projectId || !cookie) return null;
try {
const response = await pageService.fetchDescriptionBinary(
workspaceSlug,
projectId,
pageId,
cookie,
);
const response = await pageService.fetchDescriptionBinary(workspaceSlug, projectId, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
workspaceSlug,
projectId,
pageId,
cookie,
);
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, projectId, pageId, cookie);
if (binary) {
return binary;
}
@@ -106,7 +78,7 @@ export const fetchPageDescriptionBinary = async (
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
logger.error("Fetch error:", error);
throw error;
}
};

View File

@@ -0,0 +1,49 @@
// plane editor
import {
applyUpdates,
convertBase64StringToBinaryData,
getAllDocumentFormatsFromRichTextEditorBinaryData,
} from "@plane/editor/lib";
export type TResolveConflictsRequestBody = {
original_document: string;
updates: string;
};
export type TResolveConflictsResponse = {
description_binary: string;
description_html: string;
description: object;
};
export const resolveDocumentConflicts = (body: TResolveConflictsRequestBody): TResolveConflictsResponse => {
const { original_document, updates } = body;
try {
// convert from base64 to buffer
const originalDocumentBuffer = original_document ? convertBase64StringToBinaryData(original_document) : null;
const updatesBuffer = updates ? convertBase64StringToBinaryData(updates) : null;
// decode req.body
const decodedOriginalDocument = originalDocumentBuffer ? new Uint8Array(originalDocumentBuffer) : new Uint8Array();
const decodedUpdates = updatesBuffer ? new Uint8Array(updatesBuffer) : new Uint8Array();
// resolve conflicts
let resolvedDocument: Uint8Array;
if (decodedOriginalDocument.length === 0) {
// use updates to create the document id original_description is null
resolvedDocument = applyUpdates(decodedUpdates);
} else {
// use original document and updates to resolve conflicts
resolvedDocument = applyUpdates(decodedOriginalDocument, decodedUpdates);
}
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromRichTextEditorBinaryData(resolvedDocument);
return {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
} catch (error) {
throw new Error("Internal server error");
}
};

View File

@@ -1,5 +1,8 @@
// helpers
import { getAllDocumentFormatsFromBinaryData, getBinaryDataFromHTMLString } from "@/core/helpers/page.js";
import {
getAllDocumentFormatsFromDocumentEditorBinaryData,
getBinaryDataFromDocumentEditorHTMLString,
} from "@plane/editor/lib";
// services
import { WorkspacePageService } from "../services/workspace-page.service.js";
const workspacePageService = new WorkspacePageService();
@@ -11,19 +14,14 @@ export const updateWorkspacePageDescription = async (
cookie: string | undefined
) => {
if (!(updatedDescription instanceof Uint8Array)) {
throw new Error(
"Invalid updatedDescription: must be an instance of Uint8Array"
);
throw new Error("Invalid updatedDescription: must be an instance of Uint8Array");
}
const workspaceSlug = params.get("workspaceSlug")?.toString();
if (!workspaceSlug || !cookie) return;
const {
contentBinaryEncoded,
contentHTML,
contentJSON
} = getAllDocumentFormatsFromBinaryData(updatedDescription);
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromDocumentEditorBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
@@ -31,32 +29,19 @@ export const updateWorkspacePageDescription = async (
description: contentJSON,
};
await workspacePageService.updateDescription(
workspaceSlug,
pageId,
payload,
cookie
);
await workspacePageService.updateDescription(workspaceSlug, pageId, payload, cookie);
} catch (error) {
console.error("Update error:", error);
throw error;
}
};
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
pageId: string,
cookie: string
) => {
const fetchDescriptionHTMLAndTransform = async (workspaceSlug: string, pageId: string, cookie: string) => {
if (!workspaceSlug || !cookie) return;
try {
const pageDetails = await workspacePageService.fetchDetails(
workspaceSlug,
pageId,
cookie
);
const { contentBinary } = getBinaryDataFromHTMLString(pageDetails.description_html ?? "<p></p>")
const pageDetails = await workspacePageService.fetchDetails(workspaceSlug, pageId, cookie);
const contentBinary = getBinaryDataFromDocumentEditorHTMLString(pageDetails.description_html ?? "<p></p>");
return contentBinary;
} catch (error) {
console.error("Error while transforming from HTML to Uint8Array", error);
@@ -73,19 +58,11 @@ export const fetchWorkspacePageDescriptionBinary = async (
if (!workspaceSlug || !cookie) return null;
try {
const response = await workspacePageService.fetchDescriptionBinary(
workspaceSlug,
pageId,
cookie
);
const response = await workspacePageService.fetchDescriptionBinary(workspaceSlug, pageId, cookie);
const binaryData = new Uint8Array(response);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
workspaceSlug,
pageId,
cookie
);
const binary = await fetchDescriptionHTMLAndTransform(workspaceSlug, pageId, cookie);
if (binary) {
return binary;
}

View File

@@ -5,16 +5,13 @@ import expressWs from "express-ws";
import * as Sentry from "@sentry/node";
import compression from "compression";
import helmet from "helmet";
// cors
import cors from "cors";
// core hocuspocus server
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { coreLogger as LoggerMiddleware, logger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
import { resolveDocumentConflicts, TResolveConflictsRequestBody } from "@/core/resolve-conflicts.js";
const app = express();
expressWs(app);
@@ -33,7 +30,7 @@ app.use(
);
// Logging middleware
app.use(logger);
app.use(LoggerMiddleware);
// Body parsing middleware
app.use(express.json());
@@ -45,7 +42,7 @@ app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
logger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
@@ -57,11 +54,31 @@ router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
logger.error("WebSocket connection error:", err);
ws.close();
}
});
router.post("/resolve-document-conflicts", (req, res) => {
const { original_document, updates } = req.body as TResolveConflictsRequestBody;
try {
if (original_document === undefined || updates === undefined) {
res.status(400).send({
message: "Missing required fields",
});
logger.error("Error in /resolve-document-conflicts endpoint:");
return;
}
const resolvedDocument = resolveDocumentConflicts(req.body);
res.status(200).json(resolvedDocument);
} catch (error) {
logger.error("Error in /resolve-document-conflicts endpoint:", error);
res.status(500).send({
message: "Internal server error",
});
}
});
app.use(process.env.LIVE_BASE_PATH || "/live", router);
app.use((_req, res) => {
@@ -73,46 +90,44 @@ Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
logger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
logger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully."
);
logger.info("HocusPocus server WebSocket connections closed gracefully.");
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
logger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
logger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
logger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
logger.info("Unhandled Rejection: ", err);
logger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
logger.info("Uncaught Exception: ", err);
logger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});

View File

@@ -38,24 +38,25 @@
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-collaboration-cursor": "^2.6.6",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-task-item": "^2.1.13",
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-align": "^2.8.0",
"@tiptap/extension-text-style": "^2.7.1",
"@tiptap/extension-underline": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.0.13",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-blockquote": "^2.9.1",
"@tiptap/extension-character-count": "^2.9.1",
"@tiptap/extension-collaboration": "^2.9.1",
"@tiptap/extension-collaboration-cursor": "^2.9.1",
"@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-list-item": "^2.9.1",
"@tiptap/extension-mention": "^2.9.1",
"@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-task-item": "^2.9.1",
"@tiptap/extension-task-list": "^2.9.1",
"@tiptap/extension-text-align": "^2.9.1",
"@tiptap/extension-text-style": "^2.9.1",
"@tiptap/extension-underline": "^2.9.1",
"@tiptap/html": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.9.1",
"@tiptap/starter-kit": "^2.9.1",
"@tiptap/suggestion": "^2.9.1",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"highlight.js": "^11.8.0",
@@ -64,17 +65,17 @@
"lowlight": "^3.0.0",
"lucide-react": "^0.378.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-flat-list": "^0.5.4",
"prosemirror-utils": "^1.2.2",
"react-moveable": "^0.54.2",
"smooth-scroll-into-view-if-needed": "^2.0.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-prosemirror": "^1.2.12",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "*",

View File

@@ -8,7 +8,7 @@ import { IssueWidget } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
import { useCollaborativeDocumentEditor } from "@/hooks/use-collaborative-document-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
@@ -43,7 +43,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentEditor({
onTransaction,
disabledExtensions,
editorClassName,

View File

@@ -8,7 +8,7 @@ import { IssueWidget } from "@/extensions";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyCollaborativeEditor } from "@/hooks/use-read-only-collaborative-editor";
import { useCollaborativeDocumentReadOnlyEditor } from "@/hooks/use-collaborative-document-read-only-editor";
// types
import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/types";
@@ -36,7 +36,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
);
}
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeDocumentReadOnlyEditor({
editorClassName,
extensions,
fileHandler,

View File

@@ -1,4 +1,4 @@
import { Editor, Extension } from "@tiptap/core";
import { AnyExtension, Editor } from "@tiptap/core";
// components
import { EditorContainer } from "@/components/editors";
// constants
@@ -12,7 +12,7 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
extensions: Extension<any, any>[];
extensions: AnyExtension[];
};
export const EditorWrapper: React.FC<Props> = (props) => {

View File

@@ -0,0 +1,72 @@
import React from "react";
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeRichTextEditor } from "@/hooks/use-collaborative-rich-text-editor";
// types
import { EditorRefApi, ICollaborativeRichTextEditor } from "@/types";
const CollaborativeRichTextEditor = (props: ICollaborativeRichTextEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
onChange,
placeholder,
tabIndex,
value,
} = props;
const { editor } = useCollaborativeRichTextEditor({
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
onChange,
placeholder,
tabIndex,
value,
});
const editorContainerClassName = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorBubbleMenu editor={editor} />
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
</div>
</EditorContainer>
);
};
const CollaborativeRichTextEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeRichTextEditor>(
(props, ref) => (
<CollaborativeRichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
);
CollaborativeRichTextEditorWithRef.displayName = "CollaborativeRichTextEditorWithRef";
export { CollaborativeRichTextEditorWithRef };

View File

@@ -0,0 +1,70 @@
import React from "react";
// components
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// helpers
import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeRichTextReadOnlyEditor } from "@/hooks/use-collaborative-rich-text-read-only-editor";
// types
import { EditorReadOnlyRefApi, ICollaborativeRichTextReadOnlyEditor } from "@/types";
const CollaborativeRichTextReadOnlyEditor = (props: ICollaborativeRichTextReadOnlyEditor) => {
const {
containerClassName,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
value,
} = props;
const { editor } = useCollaborativeRichTextReadOnlyEditor({
editorClassName,
fileHandler,
forwardedRef,
id,
mentionHandler,
value,
});
const editorContainerClassName = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer
displayConfig={displayConfig}
editor={editor}
editorContainerClassName={editorContainerClassName}
id={id}
>
<EditorBubbleMenu editor={editor} />
<div className="flex flex-col">
<EditorContentWrapper editor={editor} id={id} />
</div>
</EditorContainer>
);
};
const CollaborativeRichTextReadOnlyEditorWithRef = React.forwardRef<
EditorReadOnlyRefApi,
ICollaborativeRichTextReadOnlyEditor
>((props, ref) => (
<CollaborativeRichTextReadOnlyEditor
{...props}
forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>}
/>
));
CollaborativeRichTextReadOnlyEditorWithRef.displayName = "CollaborativeRichTextReadOnlyEditorWithRef";
export { CollaborativeRichTextReadOnlyEditorWithRef };

View File

@@ -1,2 +1,4 @@
export * from "./collaborative-editor";
export * from "./collaborative-read-only-editor";
export * from "./editor";
export * from "./read-only-editor";

View File

@@ -35,6 +35,10 @@ import {
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatTaskList,
toggleFlatToggleList,
toggleHeadingFive,
toggleHeadingFour,
toggleHeadingOne,
@@ -152,28 +156,60 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
name: "Flat Bulleted list",
isActive: () => editor?.isActive("list", { type: "bullet" }),
command: () => toggleFlatBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
isActive: () => editor?.isActive("list", { type: "ordered" }),
command: () => toggleFlatOrderedList(editor),
icon: ListIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
isActive: () => editor?.isActive("list", { type: "task" }),
command: () => toggleFlatTaskList(editor),
icon: CheckSquare,
});
export const FlatBulletListItem = (editor: Editor): EditorMenuItem<"flat-bulleted-list"> => ({
key: "flat-bulleted-list",
name: "Flat Bulleted list",
isActive: () => editor?.isActive("list", { type: "bullet" }),
command: () => toggleFlatBulletList(editor),
icon: ListIcon,
});
export const FlatNumberedListItem = (editor: Editor): EditorMenuItem<"flat-numbered-list"> => ({
key: "flat-numbered-list",
name: "Flat Numbered list",
isActive: () => editor?.isActive("list", { type: "ordered" }),
command: () => toggleFlatOrderedList(editor),
icon: ListIcon,
});
export const FlatTaskListItem = (editor: Editor): EditorMenuItem<"flat-check-list"> => ({
key: "flat-check-list",
name: "Flat Check list",
isActive: () => editor?.isActive("list", { type: "task" }),
command: () => toggleFlatTaskList(editor),
icon: ListIcon,
});
export const FlatToggleListItem = (editor: Editor): EditorMenuItem<"flat-toggle-list"> => ({
key: "flat-toggle-list",
name: "Flat Toggle list",
isActive: () => editor?.isActive("list", { type: "toggle" }),
command: () => toggleFlatToggleList(editor),
icon: ListIcon,
});
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
@@ -265,6 +301,10 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
HorizontalRuleItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
FlatBulletListItem(editor),
FlatNumberedListItem(editor),
FlatTaskListItem(editor),
FlatToggleListItem(editor),
TextAlignItem(editor),
];
};

View File

@@ -1,5 +1,5 @@
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
export interface CodeBlockOptions {
/**
@@ -150,6 +150,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
// exit node on triple enter
Enter: ({ editor }) => {
try {
console.log("ran in code block");
if (!this.options.exitOnTripleEnter) {
return false;
}
@@ -183,8 +184,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
try {
if (!this.options.exitOnArrowDown) {
@@ -205,7 +204,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
const after = $from.after();
// if the code block is directly on the root level, then just
// find the next node at same depth (basically the root and code block are at the same level)
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
const after = $from.after(parentDepth);
if (after === undefined) {
return false;
@@ -214,12 +218,15 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return false;
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in code block:", error);
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
return false;
}
},

View File

@@ -91,7 +91,12 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
return false;
}
const after = $from.after();
// if the code block is directly on the root level, then just
// find the next node at same depth (basically the root and code block are at the same level)
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
const after = $from.after(parentDepth);
if (after === undefined) {
return false;

View File

@@ -19,6 +19,7 @@ import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
import { FlatListExtension } from "./flat-list/flat-list";
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
@@ -89,6 +90,7 @@ export const CoreEditorExtensionsWithoutProps = [
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
FlatListExtension,
];
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];

View File

@@ -88,41 +88,41 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return handled;
},
Backspace: ({ editor }) => {
try {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
} catch (e) {
console.log("Error in handling Backspace:", e);
return false;
}
},
"Mod-Backspace": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
},
// Backspace: ({ editor }) => {
// try {
// let handled = false;
//
// this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
// if (editor.state.schema.nodes[itemName] === undefined) {
// return;
// }
//
// if (handleBackspace(editor, itemName, wrapperNames)) {
// handled = true;
// }
// });
//
// return handled;
// } catch (e) {
// console.log("Error in handling Backspace:", e);
// return false;
// }
// },
// "Mod-Backspace": ({ editor }) => {
// let handled = false;
//
// this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
// if (editor.state.schema.nodes[itemName] === undefined) {
// return;
// }
//
// if (handleBackspace(editor, itemName, wrapperNames)) {
// handled = true;
// }
// });
//
// return handled;
// },
};
},
});

View File

@@ -0,0 +1,234 @@
import { Plugin, EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { dropPoint } from "prosemirror-transform";
import { Extension } from "@tiptap/core";
export function dropCursor(options: DropCursorOptions = {}): Plugin {
return new Plugin({
view(editorView) {
return new DropCursorView(editorView, {
...options,
// Add custom behavior for list nodes
disableDropCursor: (view: EditorView, pos: { pos: number; inside: number }, event: DragEvent) => {
console.log("adf");
if (!pos) return true;
const $pos = view.state.doc.resolve(pos.pos);
const parentNode = $pos.parent;
// If we're between two list items, only show cursor at the list boundary
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
const nodeBefore = $pos.nodeBefore;
const nodeAfter = $pos.nodeAfter;
// Only show cursor at list boundaries
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
// Only allow cursor at the exact position between lists
return $pos.pos !== pos.pos;
}
}
return false;
},
});
},
});
}
interface DropCursorOptions {
/// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class.
color?: string | false;
/// The precise width of the cursor in pixels. Defaults to 1.
width?: number;
/// A CSS class name to add to the cursor element.
class?: string;
}
/// Create a plugin that, when added to a ProseMirror instance,
/// causes a decoration to show up at the drop position when something
/// is dragged over the editor.
///
/// Nodes may add a `disableDropCursor` property to their spec to
/// control the showing of a drop cursor inside them. This may be a
/// boolean or a function, which will be called with a view and a
/// position, and should return a boolean.
// export function dropCursor(options: DropCursorOptions = {}): Plugin {
// return new Plugin({
// view(editorView) {
// return new DropCursorView(editorView, options);
// },
// });
// }
class DropCursorView {
width: number;
color: string | undefined;
class: string | undefined;
cursorPos: number | null = null;
element: HTMLElement | null = null;
timeout: number = -1;
handlers: { name: string; handler: (event: Event) => void }[];
constructor(
readonly editorView: EditorView,
options: DropCursorOptions
) {
this.width = options.width ?? 1;
this.color = options.color === false ? undefined : options.color || "black";
this.class = options.class;
this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
let handler = (e: Event) => {
(this as any)[name](e);
};
editorView.dom.addEventListener(name, handler);
return { name, handler };
});
}
destroy() {
this.handlers.forEach(({ name, handler }) => this.editorView.dom.removeEventListener(name, handler));
}
update(editorView: EditorView, prevState: EditorState) {
if (this.cursorPos != null && prevState.doc != editorView.state.doc) {
if (this.cursorPos > editorView.state.doc.content.size) this.setCursor(null);
else this.updateOverlay();
}
}
setCursor(pos: number | null) {
if (pos == this.cursorPos) return;
this.cursorPos = pos;
if (pos == null) {
this.element!.parentNode!.removeChild(this.element!);
this.element = null;
} else {
this.updateOverlay();
}
}
updateOverlay() {
let $pos = this.editorView.state.doc.resolve(this.cursorPos!);
let isBlock = !$pos.parent.inlineContent,
rect;
let editorDOM = this.editorView.dom,
editorRect = editorDOM.getBoundingClientRect();
let scaleX = editorRect.width / editorDOM.offsetWidth,
scaleY = editorRect.height / editorDOM.offsetHeight;
if (isBlock) {
let before = $pos.nodeBefore,
after = $pos.nodeAfter;
if (before || after) {
let node = this.editorView.nodeDOM(this.cursorPos! - (before ? before.nodeSize : 0));
if (node) {
let nodeRect = (node as HTMLElement).getBoundingClientRect();
let top = before ? nodeRect.bottom : nodeRect.top;
if (before && after)
top = (top + (this.editorView.nodeDOM(this.cursorPos!) as HTMLElement).getBoundingClientRect().top) / 2;
let halfWidth = (this.width / 2) * scaleY;
rect = { left: nodeRect.left, right: nodeRect.right, top: top - halfWidth, bottom: top + halfWidth };
}
}
}
if (!rect) {
let coords = this.editorView.coordsAtPos(this.cursorPos!);
let halfWidth = (this.width / 2) * scaleX;
rect = { left: coords.left - halfWidth, right: coords.left + halfWidth, top: coords.top, bottom: coords.bottom };
}
let parent = this.editorView.dom.offsetParent as HTMLElement;
if (!this.element) {
this.element = parent.appendChild(document.createElement("div"));
if (this.class) this.element.className = this.class;
this.element.style.cssText = "position: absolute; z-index: 50; pointer-events: none;";
if (this.color) {
this.element.style.backgroundColor = this.color;
}
}
this.element.classList.toggle("prosemirror-dropcursor-block", isBlock);
this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock);
let parentLeft, parentTop;
if (!parent || (parent == document.body && getComputedStyle(parent).position == "static")) {
parentLeft = -pageXOffset;
parentTop = -pageYOffset;
} else {
let rect = parent.getBoundingClientRect();
let parentScaleX = rect.width / parent.offsetWidth,
parentScaleY = rect.height / parent.offsetHeight;
parentLeft = rect.left - parent.scrollLeft * parentScaleX;
parentTop = rect.top - parent.scrollTop * parentScaleY;
}
this.element.style.left = (rect.left - parentLeft) / scaleX + "px";
this.element.style.top = (rect.top - parentTop) / scaleY + "px";
this.element.style.width = (rect.right - rect.left) / scaleX + "px";
this.element.style.height = (rect.bottom - rect.top) / scaleY + "px";
}
scheduleRemoval(timeout: number) {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.setCursor(null), timeout);
}
dragover(event: DragEvent) {
if (!this.editorView.editable) return;
let pos = this.editorView.posAtCoords({ left: event.clientX, top: event.clientY });
let node = pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
let disableDropCursor = node && node.type.spec.disableDropCursor;
let disabled =
typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor;
if (!pos) return true;
const $pos = this.editorView.state.doc.resolve(pos.pos);
const parentNode = $pos.parent;
// If we're between two list items, only show cursor at the list boundary
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
const nodeBefore = $pos.nodeBefore;
const nodeAfter = $pos.nodeAfter;
console.log(nodeBefore.type.name, nodeAfter.type.name);
// Only show cursor at list boundaries
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
// Only allow cursor at the exact position between lists
return $pos.pos !== pos.pos;
}
}
// return false;
if (pos && !disabled) {
let target: number | null = pos.pos;
if (this.editorView.dragging && this.editorView.dragging.slice) {
let point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice);
if (point != null) target = point;
}
this.setCursor(target);
this.scheduleRemoval(5000);
}
}
dragend() {
this.scheduleRemoval(20);
}
drop() {
this.scheduleRemoval(20);
}
dragleave(event: DragEvent) {
if (event.target == this.editorView.dom || !this.editorView.dom.contains((event as any).relatedTarget))
this.setCursor(null);
}
}
export const dropCursorExtension = (options: DropCursorOptions) =>
Extension.create({
addProseMirrorPlugins() {
return [dropCursor(options)];
},
});

View File

@@ -1,7 +1,5 @@
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
@@ -20,10 +18,8 @@ import {
CustomMention,
CustomQuoteExtension,
CustomTextAlignExtension,
CustomTypographyExtension,
DropHandlerExtension,
ImageExtension,
ListKeymap,
Table,
TableCell,
TableHeader,
@@ -33,6 +29,8 @@ import {
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
import { FlatListExtension } from "./flat-list/flat-list";
import { multipleSelectionExtension } from "./selections/multipleSelections";
type TArguments = {
enableHistory: boolean;
@@ -50,21 +48,24 @@ export const CoreEditorExtensions = (args: TArguments) => {
return [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
// bulletList: {
// HTMLAttributes: {
// class: "list-disc pl-7 space-y-2",
// },
// },
// orderedList: {
// HTMLAttributes: {
// class: "list-decimal pl-7 space-y-2",
// },
// },
// listItem: {
// HTMLAttributes: {
// class: "not-prose space-y-2",
// },
// },
bulletList: false,
orderedList: false,
listItem: false,
code: false,
codeBlock: false,
horizontalRule: false,
@@ -82,7 +83,7 @@ export const CoreEditorExtensions = (args: TArguments) => {
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
// ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
@@ -94,7 +95,7 @@ export const CoreEditorExtensions = (args: TArguments) => {
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
CustomTypographyExtension,
// CustomTypographyExtension,
ImageExtension(fileHandler).configure({
HTMLAttributes: {
class: "rounded-md",
@@ -103,17 +104,17 @@ export const CoreEditorExtensions = (args: TArguments) => {
CustomImageExtension(fileHandler),
TiptapUnderline,
TextStyle,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "relative",
},
nested: true,
}),
// TaskList.configure({
// HTMLAttributes: {
// class: "not-prose pl-2 space-y-2",
// },
// }),
// TaskItem.configure({
// HTMLAttributes: {
// class: "relative",
// },
// nested: true,
// }),
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
@@ -162,5 +163,8 @@ export const CoreEditorExtensions = (args: TArguments) => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
FlatListExtension,
multipleSelectionExtension,
// FlatHeadingListExtension,
];
};

View File

@@ -0,0 +1,575 @@
import { type TaggedProsemirrorNode } from 'jest-remirror'
import { type Node as ProsemirrorNode } from 'prosemirror-model'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createDedentListCommand } from './dedent-list'
describe('dedentList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
it('can dedent a list node to outer list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B<cursor>1
`,
markdown`
- A1
- B<cursor>1
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- - <cursor>B1
- A1
`,
markdown`
- B1
- A1
`,
)
})
it('can dedent a paragraph node to outer list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1a
B1b<cursor>
`,
markdown`
- A1
- B1a
B1b<cursor>
`,
)
})
it('can unwrap a list node', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
paragraph
`,
markdown`
A1<cursor>
paragraph
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
- A2
`,
markdown`
A1<cursor>
- A2
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
A2
`,
)
})
it('can unwrap multiple list nodes', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<start>
- A2<end>
`,
markdown`
A1
A2
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- A2<start>
- A3<end>
- A4
`,
markdown`
- A1
A2
A3
- A4
`,
)
})
it('can keep siblings after the lifted items at the same position', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<start>
- B3
- C1<end>
B3
- B4
`,
markdown`
- A1
- B1
- B2<start>
- B3
- C1<end>
B3
- B4
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<cursor>
A1
`,
markdown`
- A1
- B1
- B2<cursor>
A1
`,
)
})
it('can only dedent selected part when the selection across multiple depth of a nested lists', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
`,
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
- C2
`,
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
- - C2
`,
)
})
it('can wrap unselected paragraphs with a list node if necessary', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<start>
- B3<end>
B3
B3
- B4
`,
markdown`
- A1
- B1
- B2<start>
- B3<end>
- B3
B3
- B4
`,
)
})
it('can keep the indentation of sub list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1<cursor>
- C1
`,
markdown`
- A1
- B1<cursor>
- - C1
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
- B1
`,
markdown`
A1<cursor>
- - B1
`,
)
})
it('do nothing when not inside a list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
Hello<cursor>
paragraph
`,
markdown`
Hello<cursor>
paragraph
`,
)
})
it('can dedent a nested list item', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- - B1<cursor>
B1
A1
`,
markdown`
- B1
- B1
A1
`,
)
})
it('can dedent a blockquote inside a list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- > A1
>
> A2<cursor>
`,
markdown`
- > A1
A2<cursor>
`,
)
})
it('can accept custom positions', () => {
t.applyCommand(
createDedentListCommand({ from: 13, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
//
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2')),
t.p('A3'),
),
)
t.applyCommand(
createDedentListCommand({ from: 10, to: 14 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
//
t.bulletList(t.p('A1')),
t.p('A2'),
t.p('A3'),
),
)
})
it('can handle some complex nested lists', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
markdown`
- A1
- B1
- <start>B2
A2
- B3
- C1<end>
- - D1
- B4
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1
- D1
D1<start>
- A2
- B3
- C2
C2
- D2<end>
C2
- C3
`,
markdown`
- A1
- B1
- B2
- C1
- D1
D1<start>
A2
- B3
- C2
C2
- D2<end>
C2
- C3
`,
)
})
it('only needs one step for some of the most comment indent action', () => {
const countSteps = (
doc: TaggedProsemirrorNode,
expected: TaggedProsemirrorNode,
) => {
t.add(doc)
const state = t.view.state
const command = createDedentListCommand()
let count = -1
let actual: ProsemirrorNode | null = null
command(state, (tr) => {
count = tr.steps.length
actual = tr.doc
})
expect(actual).not.equal(null)
expect(actual).toEqualRemirrorDocument(expected)
return count
}
expect(
countSteps(
markdown`
- A1
- B1<cursor>
`,
markdown`
- A1
- B1<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
A2<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
# heading
- A1<cursor>
`,
markdown`
# heading
A1<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
# heading
- - A1<cursor>
`,
markdown`
# heading
- A1<cursor>
`,
),
).toBe(1)
})
})

View File

@@ -0,0 +1,223 @@
import { Fragment, NodeRange, Slice } from "prosemirror-model";
import { type Command, type Transaction } from "prosemirror-state";
import { ReplaceAroundStep } from "prosemirror-transform";
import { withVisibleSelection } from "./set-safe-selection";
import { findListsRange, isListNode, isListsRange, getListType } from "prosemirror-flat-list";
import { atStartBlockBoundary, atEndBlockBoundary } from "../utils/block-boundary";
import { mapPos } from "../utils/map-pos";
import { safeLift } from "../utils/safe-lift";
import { zoomInRange } from "../utils/zoom-in-range";
/**
* @public
*/
export interface DedentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number;
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number;
}
/**
* Returns a command function that decreases the indentation of selected list nodes.
*
* @public @group Commands
*/
export function createDedentListCommand(options?: DedentListOptions): Command {
const dedentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr;
const $from = options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from);
const $to = options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to);
const range = findListsRange($from, $to);
if (!range) return false;
if (dedentRange(range, tr)) {
dispatch?.(tr);
return true;
}
return false;
};
return withVisibleSelection(dedentListCommand);
}
function dedentRange(range: NodeRange, tr: Transaction, startBoundary?: boolean, endBoundary?: boolean): boolean {
const { depth, $from, $to } = range;
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1);
if (!startBoundary) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? dedentRange(contentRange, tr) : false;
} else {
return splitAndDedentRange(range, tr, startIndex + 1);
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1);
if (!endBoundary) {
fixEndBoundary(range, tr);
const endOfParent = $to.end(depth);
range = new NodeRange(tr.doc.resolve($from.pos), tr.doc.resolve(endOfParent), depth);
return dedentRange(range, tr, undefined, true);
}
if (range.startIndex === 0 && range.endIndex === range.parent.childCount && isListNode(range.parent)) {
return dedentNodeRange(new NodeRange($from, $to, depth - 1), tr);
}
return dedentNodeRange(range, tr);
}
/**
* Split a range into two parts, and dedent them separately.
*/
function splitAndDedentRange(range: NodeRange, tr: Transaction, splitIndex: number): boolean {
const { $from, $to, depth } = range;
const splitPos = $from.posAtIndex(splitIndex, depth);
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1));
if (!range1) return false;
const getRange2From = mapPos(tr, splitPos + 1);
const getRange2To = mapPos(tr, $to.pos);
dedentRange(range1, tr, undefined, true);
let range2 = tr.doc.resolve(getRange2From()).blockRange(tr.doc.resolve(getRange2To()));
if (range2 && range2.depth >= depth) {
range2 = new NodeRange(range2.$from, range2.$to, depth);
dedentRange(range2, tr, true, undefined);
}
return true;
}
export function dedentNodeRange(range: NodeRange, tr: Transaction) {
if (isListNode(range.parent)) {
return safeLiftRange(tr, range);
} else if (isListsRange(range)) {
return dedentOutOfList(tr, range);
} else {
return safeLiftRange(tr, range);
}
}
function safeLiftRange(tr: Transaction, range: NodeRange): boolean {
if (moveRangeSiblings(tr, range)) {
const $from = tr.doc.resolve(range.$from.pos);
const $to = tr.doc.resolve(range.$to.pos);
range = new NodeRange($from, $to, range.depth);
}
return safeLift(tr, range);
}
function moveRangeSiblings(tr: Transaction, range: NodeRange): boolean {
const listType = getListType(tr.doc.type.schema);
const { $to, depth, end, parent, endIndex } = range;
const endOfParent = $to.end(depth);
if (end < endOfParent) {
// There are siblings after the lifted items, which must become
// children of the last item
const lastChild = parent.maybeChild(endIndex - 1);
if (!lastChild) return false;
const canAppend =
endIndex < parent.childCount &&
lastChild.canReplace(lastChild.childCount, lastChild.childCount, parent.content, endIndex, parent.childCount);
if (canAppend) {
tr.step(
new ReplaceAroundStep(
end - 1,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 1, 0),
0,
true
)
);
return true;
} else {
tr.step(
new ReplaceAroundStep(
end,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 0, 0),
1,
true
)
);
return true;
}
}
return false;
}
function fixEndBoundary(range: NodeRange, tr: Transaction): void {
if (range.endIndex - range.startIndex >= 2) {
range = new NodeRange(
range.$to.doc.resolve(range.$to.posAtIndex(range.endIndex - 1, range.depth)),
range.$to,
range.depth
);
}
const contentRange = zoomInRange(range);
if (contentRange) {
fixEndBoundary(contentRange, tr);
range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(range.$to.pos), range.depth);
}
moveRangeSiblings(tr, range);
}
export function dedentOutOfList(tr: Transaction, range: NodeRange): boolean {
const { startIndex, endIndex, parent } = range;
const getRangeStart = mapPos(tr, range.start);
const getRangeEnd = mapPos(tr, range.end);
// Merge the list nodes into a single big list node
for (let end = getRangeEnd(), i = endIndex - 1; i > startIndex; i--) {
end -= parent.child(i).nodeSize;
tr.delete(end - 1, end + 1);
}
const $start = tr.doc.resolve(getRangeStart());
const listNode = $start.nodeAfter;
if (!listNode) return false;
const start = range.start;
const end = start + listNode.nodeSize;
if (getRangeEnd() !== end) return false;
if (!$start.parent.canReplace(startIndex, startIndex + 1, Fragment.from(listNode))) {
return false;
}
tr.step(new ReplaceAroundStep(start, end, start + 1, end - 1, new Slice(Fragment.empty, 0, 0), 0, true));
return true;
}

View File

@@ -0,0 +1,19 @@
import {
chainCommands,
createParagraphNear,
newlineInCode,
splitBlock,
} from 'prosemirror-commands'
import { type Command } from 'prosemirror-state'
/**
* This command has the same behavior as the `Enter` keybinding from
* `prosemirror-commands`, but without the `liftEmptyBlock` command.
*
* @internal
*/
export const enterWithoutLift: Command = chainCommands(
newlineInCode,
createParagraphNear,
splitBlock,
)

View File

@@ -0,0 +1,787 @@
import { type TaggedProsemirrorNode } from 'jest-remirror'
import { type Node as ProsemirrorNode } from 'prosemirror-model'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createIndentListCommand } from './indent-list'
describe('indentList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
const indentList = createIndentListCommand()
it('can indent a list node and append it to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A<cursor>2
`,
markdown`
- A1
- A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- ## A<cursor>2
`,
markdown`
- A1
- ## A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- > ## A<cursor>2
`,
markdown`
- A1
- > ## A<cursor>2
`,
)
})
it('can indent multiple list nodes and append them to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2<start>
- A3<end>
`,
markdown`
- A1
- A2<start>
- A3<end>
`,
)
})
it('should not wrap a paragraph with a new list node when it will bring a new visual bullet', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2a
A2b<cursor>
`,
markdown`
- A1
- A2a
A2b<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2a
A2b<cursor>
A2c
`,
markdown`
- A1
- A2a
A2b<cursor>
A2c
`,
)
})
it('can indent a paragraph and append it to the previous sibling list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2a
- B1
A2b<cursor>
`,
markdown`
- A1
- A2a
- B1
A2b<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2a
- B1
A2b<cursor>
- B2
`,
markdown`
- A1
- A2a
- B1
A2b<cursor>
- B2
`,
)
})
it('can only indent selected part when the selection across multiple depth of a nested lists', () => {
t.applyCommand(
indentList,
markdown`
- A1a
- B1a
- C1
B1b<start>
A1b<end>
`,
markdown`
- A1a
- B1a
- C1
B1b<start>
A1b<end>
`,
)
t.applyCommand(
indentList,
markdown`
- A1a
- B1a
- C1
B1b<start>
- B2
- B3
- B4
A1b<end>
`,
markdown`
- A1a
- B1a
- C1
B1b<start>
- B2
- B3
- B4
A1b<end>
`,
)
})
it('can indent multiple list nodes and append them to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A<start>2
- A<end>3
`,
markdown`
- A1
- A<start>2
- A<end>3
`,
)
})
it('can add ambitious indentations', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B<cursor>2
`,
markdown`
- A1
- - B<cursor>2
`,
)
})
it('can split the list when necessary', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B<cursor>2a
B2b
B2c
`,
markdown`
- A1
- - B<cursor>2a
- B2b
B2c
`,
)
})
it('can keep attributes', () => {
t.applyCommand(
indentList,
markdown`
- [ ] A1
- [x] A<cursor>2
`,
markdown`
- [ ] A1
- [x] A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
1. A1
2. A<cursor>2
- B1
`,
markdown`
1. A1
1. A<cursor>2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- [x] A1
- B1
- [x] A<cursor>2
1. B2
`,
markdown`
- [x] A1
- B1
- [x] A<cursor>2
1. B2
`,
)
})
it('can keep the indentation of sub list nodes', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2
- A3<cursor>
- B1
- B2
- B3
`,
markdown`
- A1
- A2
- A3<cursor>
- B1
- B2
- B3
`,
)
})
it('can move all collapsed content', () => {
t.applyCommand(
indentList,
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2')),
t.collapsedToggleList(
t.p('A3<cursor>'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
),
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(
t.p('A2'),
t.collapsedToggleList(
t.p('A3<cursor>'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
),
),
)
})
it('can expand a collapsed list node if something is indent into it', () => {
t.applyCommand(
indentList,
t.doc(
t.collapsedToggleList(
t.p('A1'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
t.p('<cursor>'),
),
t.doc(
t.expandedToggleList(
t.p('A1'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
t.p('<cursor>'),
),
),
)
})
it('can keep the indentation of sub list nodes when moving multiple list', () => {
t.applyCommand(
indentList,
markdown`
- A1
- <start>A2
- A3<end>
- B1
- B2
- B3
`,
markdown`
- A1
- <start>A2
- A3<end>
- B1
- B2
- B3
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- B1
- A2<start>
- B2<end>
- C1
`,
markdown`
- A1
- B1
- A2<start>
- B2<end>
- C1
`,
)
})
it('can keep the indentation of siblings around the indented item', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2<cursor>
A2
`,
markdown`
- A1
- A2<cursor>
A2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2<cursor>
A2
- B1
`,
markdown`
- A1
- A2<cursor>
A2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2
- B1
A2<cursor>
A2
- B1
`,
markdown`
- A1
- A2
- B1
A2<cursor>
A2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- A1
A1
- <cursor>A2
`,
markdown`
- A1
A1
- <cursor>A2
`,
)
})
it('can indent a paragraph that not inside a list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
P1<cursor>
`,
markdown`
- A1
P1<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
P1<start>
P2<end>
P3
`,
markdown`
- A1
P1<start>
P2<end>
P3
`,
)
})
it('can accept custom positions', () => {
t.applyCommand(
createIndentListCommand({ from: 13, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2'), t.bulletList(t.p('A3'))),
),
)
t.applyCommand(
createIndentListCommand({ from: 10, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
t.bulletList(
t.p('A1'),
t.bulletList(t.p('A2')),
t.bulletList(t.p('A3')),
),
),
)
})
it('can handle some complex nested lists', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
)
})
it('only needs one step for some of the most comment indent action', () => {
const countSteps = (
doc: TaggedProsemirrorNode,
expected: TaggedProsemirrorNode,
) => {
t.add(doc)
const state = t.view.state
const command = createIndentListCommand()
let count = -1
let actual: ProsemirrorNode | null = null
command(state, (tr) => {
count = tr.steps.length
actual = tr.doc
})
expect(actual).not.equal(null)
expect(actual).toEqualRemirrorDocument(expected)
return count
}
expect(
countSteps(
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
- A2<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
- A1
- [ ] A2<cursor>
- [x] A3
`,
markdown`
- A1
- [ ] A2<cursor>
- [x] A3
`,
),
).toBe(1)
expect(
countSteps(
markdown`
1. A1
2. <start>A2
3. A3<end>
4. A4
`,
markdown`
1. A1
1. <start>A2
2. A3<end>
2. A4
`,
),
).toBe(1)
expect(
countSteps(
markdown`
1. A1
- B1
- <start>B2
- B3
- B4<end>
`,
markdown`
1. A1
- B1
- <start>B2
- B3
- B4<end>
`,
),
).toBe(1)
// For more complex (and less common) cases, more steps is acceptable
expect(
countSteps(
markdown`
- A1
- B1
- C1
- D1
D1b
- <start>D2
C1b
C1c
- B2
- C2
- A2
- A3
- B3
B3b
- B4<end>
A3b
`,
markdown`
- A1
- B1
- C1
- D1
D1b
- <start>D2
C1b
C1c
- B2
- C2
- A2
- A3
- B3
B3b
- B4<end>
A3b
`,
),
).toBeGreaterThan(1)
})
})

View File

@@ -0,0 +1,153 @@
import { Fragment, type NodeRange, Slice } from "prosemirror-model";
import { type Command, type Transaction } from "prosemirror-state";
import { ReplaceAroundStep } from "prosemirror-transform";
import { withAutoFixList } from "../utils/auto-fix-list";
import { atEndBlockBoundary, atStartBlockBoundary } from "../utils/block-boundary";
import { getListType } from "../utils/get-list-type";
import { inCollapsedList } from "../utils/in-collapsed-list";
import { isListNode } from "../utils/is-list-node";
import { findListsRange } from "../utils/list-range";
import { mapPos } from "../utils/map-pos";
import { zoomInRange } from "../utils/zoom-in-range";
import { withVisibleSelection } from "./set-safe-selection";
import { ListAttributes } from "prosemirror-flat-list";
/**
* @public
*/
export interface IndentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number;
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number;
}
/**
* Returns a command function that increases the indentation of selected list
* nodes.
*
* @public @group Commands
*/
export function createIndentListCommand(options?: IndentListOptions): Command {
const indentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr;
const $from = options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from);
const $to = options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to);
const range = findListsRange($from, $to) || $from.blockRange($to);
if (!range) return false;
if (indentRange(range, tr)) {
dispatch?.(tr);
return true;
}
return false;
};
return withVisibleSelection(withAutoFixList(indentListCommand));
}
function indentRange(range: NodeRange, tr: Transaction, startBoundary?: boolean, endBoundary?: boolean): boolean {
const { depth, $from, $to } = range;
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1);
if (!startBoundary) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? indentRange(contentRange, tr) : false;
} else {
return splitAndIndentRange(range, tr, startIndex + 1);
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1);
if (!endBoundary && !inCollapsedList($to)) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? indentRange(contentRange, tr) : false;
} else {
return splitAndIndentRange(range, tr, endIndex - 1);
}
}
return indentNodeRange(range, tr);
}
/**
* Split a range into two parts, and indent them separately.
*/
function splitAndIndentRange(range: NodeRange, tr: Transaction, splitIndex: number): boolean {
const { $from, $to, depth } = range;
const splitPos = $from.posAtIndex(splitIndex, depth);
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1));
if (!range1) return false;
const getRange2From = mapPos(tr, splitPos + 1);
const getRange2To = mapPos(tr, $to.pos);
indentRange(range1, tr, undefined, true);
const range2 = tr.doc.resolve(getRange2From()).blockRange(tr.doc.resolve(getRange2To()));
if (range2) {
indentRange(range2, tr, true, undefined);
}
return true;
}
/**
* Increase the indentation of a block range.
*/
function indentNodeRange(range: NodeRange, tr: Transaction): boolean {
const listType = getListType(tr.doc.type.schema);
const { parent, startIndex } = range;
const prevChild = startIndex >= 1 && parent.child(startIndex - 1);
// If the previous node before the range is a list node, move the range into
// the previous list node as its children
if (prevChild && isListNode(prevChild)) {
const { start, end } = range;
tr.step(
new ReplaceAroundStep(start - 1, end, start, end, new Slice(Fragment.from(listType.create(null)), 1, 0), 0, true)
);
return true;
}
// If we can avoid to add a new bullet visually, we can wrap the range with a
// new list node.
const isParentListNode = isListNode(parent);
const isFirstChildListNode = isListNode(parent.maybeChild(startIndex));
if ((startIndex === 0 && isParentListNode) || isFirstChildListNode) {
const { start, end } = range;
const listAttrs: ListAttributes | null = isFirstChildListNode
? parent.child(startIndex).attrs
: isParentListNode
? parent.attrs
: null;
tr.step(
new ReplaceAroundStep(start, end, start, end, new Slice(Fragment.from(listType.create(listAttrs)), 0, 0), 1, true)
);
return true;
}
// Otherwise we cannot indent
return false;
}

View File

@@ -0,0 +1,56 @@
import { type ResolvedPos } from "prosemirror-model";
import { type Command, TextSelection } from "prosemirror-state";
import { atTextblockStart } from "../utils/at-textblock-start";
import { isListNode } from "../utils/is-list-node";
import { joinTextblocksAround } from "./join-textblocks-around";
import { ListAttributes } from "prosemirror-flat-list";
/**
* If the selection is empty and at the start of a block, and there is a
* collapsed list node right before the cursor, move current block and append it
* to the first child of the collapsed list node (i.e. skip the hidden content).
*
* @public @group Commands
*/
export const joinCollapsedListBackward: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view);
if (!$cursor) return false;
const $cut = findCutBefore($cursor);
if (!$cut) return false;
const { nodeBefore, nodeAfter } = $cut;
if (
nodeBefore &&
nodeAfter &&
isListNode(nodeBefore) &&
(nodeBefore.attrs as ListAttributes).collapsed &&
nodeAfter.isBlock
) {
const tr = state.tr;
const listPos = $cut.pos - nodeBefore.nodeSize;
tr.delete($cut.pos, $cut.pos + nodeAfter.nodeSize);
const insert = listPos + 1 + nodeBefore.child(0).nodeSize;
tr.insert(insert, nodeAfter);
const $insert = tr.doc.resolve(insert);
tr.setSelection(TextSelection.near($insert));
if (joinTextblocksAround(tr, $insert, dispatch)) {
return true;
}
}
return false;
};
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L150
function findCutBefore($pos: ResolvedPos): ResolvedPos | null {
if (!$pos.parent.type.spec.isolating)
for (let i = $pos.depth - 1; i >= 0; i--) {
if ($pos.index(i) > 0) return $pos.doc.resolve($pos.before(i + 1));
if ($pos.node(i).type.spec.isolating) break;
}
return null;
}

View File

@@ -0,0 +1,76 @@
import { NodeRange, type ResolvedPos } from 'prosemirror-model'
import {
type Command,
type EditorState,
type Transaction,
} from 'prosemirror-state'
import { atTextblockStart } from '../utils/at-textblock-start'
import { isListNode } from '../utils/is-list-node'
import { safeLift } from '../utils/safe-lift'
/**
* If the text cursor is at the start of the first child of a list node, lift
* all content inside the list. If the text cursor is at the start of the last
* child of a list node, lift this child.
*
* @public @group Commands
*/
export const joinListUp: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view)
if (!$cursor) return false
const { depth } = $cursor
if (depth < 2) return false
const listDepth = depth - 1
const listNode = $cursor.node(listDepth)
if (!isListNode(listNode)) return false
const indexInList = $cursor.index(listDepth)
if (indexInList === 0) {
if (dispatch) {
liftListContent(state, dispatch, $cursor)
}
return true
}
if (indexInList === listNode.childCount - 1) {
if (dispatch) {
liftParent(state, dispatch, $cursor)
}
return true
}
return false
}
function liftListContent(
state: EditorState,
dispatch: (tr: Transaction) => void,
$cursor: ResolvedPos,
) {
const tr = state.tr
const listDepth = $cursor.depth - 1
const range = new NodeRange(
$cursor,
tr.doc.resolve($cursor.end(listDepth)),
listDepth,
)
if (safeLift(tr, range)) {
dispatch(tr)
}
}
function liftParent(
state: EditorState,
dispatch: (tr: Transaction) => void,
$cursor: ResolvedPos,
) {
const tr = state.tr
const range = $cursor.blockRange()
if (range && safeLift(tr, range)) {
dispatch(tr)
}
}

View File

@@ -0,0 +1,36 @@
/* eslint-disable prefer-const */
import { type ResolvedPos, Slice } from 'prosemirror-model'
import { TextSelection, type Transaction } from 'prosemirror-state'
import { replaceStep, ReplaceStep } from 'prosemirror-transform'
// prettier-ignore
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L94
function joinTextblocksAround(tr: Transaction, $cut: ResolvedPos, dispatch?: (tr: Transaction) => void) {
let before = $cut.nodeBefore!, beforeText = before, beforePos = $cut.pos - 1
for (; !beforeText.isTextblock; beforePos--) {
if (beforeText.type.spec.isolating) return false
let child = beforeText.lastChild
if (!child) return false
beforeText = child
}
let after = $cut.nodeAfter!, afterText = after, afterPos = $cut.pos + 1
for (; !afterText.isTextblock; afterPos++) {
if (afterText.type.spec.isolating) return false
let child = afterText.firstChild
if (!child) return false
afterText = child
}
let step = replaceStep(tr.doc, beforePos, afterPos, Slice.empty) as ReplaceStep | null
if (!step || step.from != beforePos ||
step instanceof ReplaceStep && step.slice.size >= afterPos - beforePos) return false
if (dispatch) {
tr.step(step)
tr.setSelection(TextSelection.create(tr.doc, beforePos))
dispatch(tr.scrollIntoView())
}
return true
}
export { joinTextblocksAround }

View File

@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { backspaceCommand } from './keymap'
describe('Keymap', () => {
const t = setupTestingEditor()
const markdown = t.markdown
describe('Backspace', () => {
it('should delete the empty paragraph between two list nodes', () => {
t.applyCommand(
backspaceCommand,
t.doc(
t.bulletList(t.p('A1')),
t.p('<cursor>'),
t.bulletList(t.p('A2')),
),
t.doc(t.bulletList(t.p('A1')), t.bulletList(t.p('A2'))),
)
})
it('can handle nested list', () => {
const doc1 = markdown`
- A1
- B1
- <cursor>B2
`
const doc2 = markdown`
- A1
- B1
<cursor>B2
`
const doc3 = markdown`
- A1
- B1
<cursor>B2
`
const doc4 = markdown`
- A1
- B1<cursor>B2
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc4)
})
it('can handle nested list with multiple children', () => {
const doc1 = markdown`
- A1
- B1
- <cursor>B2a
B2b
B2c
`
const doc2 = markdown`
- A1
- B1
<cursor>B2a
B2b
B2c
`
const doc3 = markdown`
- A1
- B1<cursor>B2a
B2b
B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
})
it('can handle cursor in the middle child', () => {
const doc1 = markdown`
- A1
- B1
- B2a
<cursor>B2b
B2c
`
const doc2 = markdown`
- A1
- B1
- B2a<cursor>B2b
B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
})
it('can handle cursor in the last child', () => {
const doc1 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
const doc2 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
const doc3 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
})
it('can skip collapsed content', () => {
t.applyCommand(
backspaceCommand,
t.doc(
t.collapsedToggleList(
//
t.p('A1'),
t.bulletList(t.p('B1')),
),
t.p('<cursor>A2'),
),
t.doc(
t.collapsedToggleList(
//
t.p('A1<cursor>A2'),
t.bulletList(t.p('B1')),
),
),
)
t.applyCommand(
backspaceCommand,
t.doc(
t.collapsedToggleList(
//
t.p('A1'),
t.bulletList(t.p('B1')),
),
t.blockquote(t.p('<cursor>A2')),
),
t.doc(
t.collapsedToggleList(
//
t.p('A1<cursor>A2'),
t.bulletList(t.p('B1')),
),
),
)
})
})
})

View File

@@ -0,0 +1,91 @@
import {
chainCommands,
deleteSelection,
joinTextblockBackward,
joinTextblockForward,
selectNodeBackward,
selectNodeForward,
} from 'prosemirror-commands'
import { createDedentListCommand } from './dedent-list'
import { createIndentListCommand } from './indent-list'
import { joinCollapsedListBackward } from './join-collapsed-backward'
import { joinListUp } from './join-list-up'
import { protectCollapsed } from './protect-collapsed'
import { createSplitListCommand } from './split-list'
/**
* Keybinding for `Enter`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - {@link createSplitListCommand}
*
* @public @group Commands
*/
export const enterCommand = chainCommands(
protectCollapsed,
createSplitListCommand(),
)
/**
* Keybinding for `Backspace`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - {@link joinListUp}
* - {@link joinCollapsedListBackward}
* - [joinTextblockBackward](https://prosemirror.net/docs/ref/#commands.joinTextblockBackward)
* - [selectNodeBackward](https://prosemirror.net/docs/ref/#commands.selectNodeBackward)
*
* @public @group Commands
*
*/
export const backspaceCommand = chainCommands(
protectCollapsed,
deleteSelection,
joinListUp,
joinCollapsedListBackward,
joinTextblockBackward,
selectNodeBackward,
)
/**
* Keybinding for `Delete`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - [joinTextblockForward](https://prosemirror.net/docs/ref/#commands.joinTextblockForward)
* - [selectNodeForward](https://prosemirror.net/docs/ref/#commands.selectNodeForward)
*
* @public @group Commands
*
*/
export const deleteCommand = chainCommands(
protectCollapsed,
deleteSelection,
joinTextblockForward,
selectNodeForward,
)
/**
* Returns an object containing the keymap for the list commands.
*
* - `Enter`: See {@link enterCommand}.
* - `Backspace`: See {@link backspaceCommand}.
* - `Delete`: See {@link deleteCommand}.
* - `Mod-[`: Decrease indentation. See {@link createDedentListCommand}.
* - `Mod-]`: Increase indentation. See {@link createIndentListCommand}.
*
* @public @group Commands
*/
export const listKeymap = {
Enter: enterCommand,
Backspace: backspaceCommand,
Delete: deleteCommand,
'Mod-[': createDedentListCommand(),
'Mod-]': createIndentListCommand(),
}

View File

@@ -0,0 +1,84 @@
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createMoveListCommand } from './move-list'
describe('moveList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
const moveUp = createMoveListCommand('up')
const moveDown = createMoveListCommand('down')
it('can move up list nodes', () => {
t.applyCommand(
moveUp,
markdown`
- A1
- A2<start>
- A3<end>
`,
markdown`
- A2<start>
- A3<end>
- A1
`,
)
})
it('can move up and dedent list nodes to parent list', () => {
t.applyCommand(
moveUp,
markdown`
- A1
- A2
- B1<start>
- B2<end>
- B3
`,
markdown`
- A1
- B1<start>
- B2<end>
- A2
- B3
`,
)
})
it('can move down list nodes', () => {
t.applyCommand(
moveDown,
markdown`
- A1<start>
- A2<end>
- A3
`,
markdown`
- A3
- A1<start>
- A2<end>
`,
)
})
it('can move down and dedent list nodes to parent list', () => {
t.applyCommand(
moveDown,
markdown`
- A1
- A2
- B1<start>
- B2<end>
- A3
`,
markdown`
- A1
- A2
- A3
- B1<start>
- B2<end>
`,
)
})
})

View File

@@ -0,0 +1,86 @@
import { type Command, type Transaction } from 'prosemirror-state'
import { withAutoFixList } from '../utils/auto-fix-list'
import { cutByIndex } from '../utils/cut-by-index'
import { isListNode } from '../utils/is-list-node'
import { findListsRange } from '../utils/list-range'
import { safeLift } from '../utils/safe-lift'
/**
* Returns a command function that moves up or down selected list nodes.
*
* @public @group Commands
*
*/
export function createMoveListCommand(direction: 'up' | 'down'): Command {
const moveList: Command = (state, dispatch): boolean => {
const tr = state.tr
if (doMoveList(tr, direction, true, !!dispatch)) {
dispatch?.(tr)
return true
}
return false
}
return withAutoFixList(moveList)
}
/** @internal */
export function doMoveList(
tr: Transaction,
direction: 'up' | 'down',
canDedent: boolean,
dispatch: boolean,
): boolean {
const { $from, $to } = tr.selection
const range = findListsRange($from, $to)
if (!range) return false
const { parent, depth, startIndex, endIndex } = range
if (direction === 'up') {
if (startIndex >= 2 || (startIndex === 1 && isListNode(parent.child(0)))) {
const before = cutByIndex(parent.content, startIndex - 1, startIndex)
const selected = cutByIndex(parent.content, startIndex, endIndex)
if (
parent.canReplace(startIndex - 1, endIndex, selected.append(before))
) {
if (dispatch) {
tr.insert($from.posAtIndex(endIndex, depth), before)
tr.delete(
$from.posAtIndex(startIndex - 1, depth),
$from.posAtIndex(startIndex, depth),
)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
} else {
if (endIndex < parent.childCount) {
const selected = cutByIndex(parent.content, startIndex, endIndex)
const after = cutByIndex(parent.content, endIndex, endIndex + 1)
if (parent.canReplace(startIndex, endIndex + 1, after.append(selected))) {
if (dispatch) {
tr.delete(
$from.posAtIndex(endIndex, depth),
$from.posAtIndex(endIndex + 1, depth),
)
tr.insert($from.posAtIndex(startIndex, depth), after)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
describe('protectCollapsed', () => {
const { add, doc, p, editor, collapsedToggleList, expandedToggleList } =
setupTestingEditor()
it('can skip collapsed content', () => {
// Cursor in the last paragraph of the item
add(
doc(
collapsedToggleList(
//
p('1<start>23'),
p('456'),
),
collapsedToggleList(
//
p('123'),
p('4<end>56'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
expandedToggleList(
//
p('1<start>23'),
p('456'),
),
expandedToggleList(
//
p('123'),
p('4<end>56'),
),
),
)
})
})

View File

@@ -0,0 +1,43 @@
import type { Command } from 'prosemirror-state'
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
/**
* This command will protect the collapsed items from being deleted.
*
* If current selection contains a collapsed item, we don't want the user to
* delete this selection by pressing Backspace or Delete, because this could
* be unintentional.
*
* In such case, we will stop the delete action and expand the collapsed items
* instead. Therefore the user can clearly know what content he is trying to
* delete.
*
* @public @group Commands
*
*/
export const protectCollapsed: Command = (state, dispatch): boolean => {
const tr = state.tr
let found = false
const { from, to } = state.selection
state.doc.nodesBetween(from, to, (node, pos, parent, index) => {
if (found && !dispatch) {
return false
}
if (parent && isCollapsedListNode(parent) && index >= 1) {
found = true
if (!dispatch) {
return false
}
const $pos = state.doc.resolve(pos)
tr.setNodeAttribute($pos.before($pos.depth), 'collapsed', false)
}
})
if (found) {
dispatch?.(tr)
}
return found
}

View File

@@ -0,0 +1,154 @@
import { type Command } from 'prosemirror-state'
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { setSafeSelection } from './set-safe-selection'
describe('setSafeSelection', () => {
const {
doc,
p,
collapsedToggleList,
expandedToggleList,
bulletList,
applyCommand,
} = setupTestingEditor()
const command: Command = (state, dispatch) => {
dispatch?.(setSafeSelection(state.tr))
return true
}
it('can move cursor outside of collapsed content', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('123'),
p('45<cursor>6'),
),
),
doc(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
)
})
it('can move cursor outside of collapsed and deep sub list', () => {
applyCommand(
command,
doc(
bulletList(
bulletList(
bulletList(
collapsedToggleList(
//
p('123'),
p('45<cursor>6'),
),
),
),
),
),
doc(
bulletList(
bulletList(
bulletList(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
),
),
),
)
})
it('does not change if the cursor is visible ', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('12<cursor>3'),
p('456'),
),
),
doc(
collapsedToggleList(
//
p('12<cursor>3'),
p('456'),
),
),
)
})
it('can handle from position', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('123'),
p('45<start>6'),
),
expandedToggleList(
//
p('12<end>3'),
p('456'),
),
),
doc(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
expandedToggleList(
//
p('123'),
p('456'),
),
),
)
})
it('can handle to position', () => {
applyCommand(
command,
doc(
expandedToggleList(
//
p('1<start>23'),
p('456'),
),
collapsedToggleList(
//
p('123'),
p('4<end>56'),
),
),
doc(
expandedToggleList(
//
p('123'),
p('456'),
),
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
)
})
})

View File

@@ -0,0 +1,70 @@
import { type ResolvedPos } from 'prosemirror-model'
import {
type Selection,
TextSelection,
type Transaction,
} from 'prosemirror-state'
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
import { patchCommand } from '../utils/patch-command'
import { setListAttributes } from '../utils/set-list-attributes'
function moveOutOfCollapsed(
$pos: ResolvedPos,
minDepth: number,
): Selection | null {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
const before = $pos.posAtIndex(1, depth)
const $before = $pos.doc.resolve(before)
return TextSelection.near($before, -1)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, move the selection outside of it
*
* @internal
*/
export function setSafeSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const selection =
moveOutOfCollapsed($from, 0) ||
moveOutOfCollapsed($to, $from.sharedDepth(to))
if (selection) {
tr.setSelection(selection)
}
return tr
}
export const withSafeSelection = patchCommand(setSafeSelection)
function getCollapsedPosition($pos: ResolvedPos, minDepth: number) {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
return $pos.before(depth)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, expand it
*
* @internal
*/
export function setVisibleSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const pos =
getCollapsedPosition($from, 0) ??
getCollapsedPosition($to, $from.sharedDepth(to))
if (pos != null) {
tr.doc.resolve(pos)
setListAttributes(tr, pos, { collapsed: false })
}
return tr
}
export const withVisibleSelection = patchCommand(setVisibleSelection)

View File

@@ -0,0 +1,516 @@
import { NodeSelection } from 'prosemirror-state'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { enterCommand } from './keymap'
describe('splitList', () => {
const {
add,
doc,
p,
bulletList,
blockquote,
editor,
markdown,
applyCommand,
collapsedToggleList,
expandedToggleList,
checkedTaskList,
uncheckedTaskList,
} = setupTestingEditor()
it('can split non-empty item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- 234<cursor>
paragraph
`,
markdown`
- 123
- 234
- <cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- 123
- 23<cursor>4
`,
markdown`
- 123
- 23
- <cursor>4
`,
)
applyCommand(
enterCommand,
markdown`
- 1<cursor>23
- 234
`,
markdown`
- 1
- <cursor>23
- 234
`,
)
})
it('can split non-empty sub item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- 456<cursor>
paragraph
`,
markdown`
- 123
- 456
- <cursor>
paragraph
`,
)
})
it('can delete empty item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- <cursor>
paragraph
`,
markdown`
- 123
<cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- 123
- <cursor>
- 456
`,
markdown`
- 123
<cursor>
- 456
`,
)
applyCommand(
enterCommand,
markdown`
- <cursor>
- 123
`,
markdown`
<cursor>
- 123
`,
)
})
it('can dedent the last empty sub item', () => {
applyCommand(
enterCommand,
markdown`
- A1
- <cursor>
paragraph
`,
markdown`
- A1
- <cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- A1
- B1
- <cursor>
paragraph
`,
markdown`
- A1
- B1
- <cursor>
paragraph
`,
)
})
it('can delete selected text', () => {
applyCommand(
enterCommand,
markdown`
- <start>123<end>
- 456
`,
markdown`
-
- <cusror>
- 456
`,
)
})
it('can set attributes correctly', () => {
applyCommand(
enterCommand,
doc(
checkedTaskList(p('<cursor>A1')),
uncheckedTaskList(p('A2')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('')),
checkedTaskList(p('<cursor>A1')),
uncheckedTaskList(p('A2')),
uncheckedTaskList(p('A3')),
),
)
applyCommand(
enterCommand,
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A2<cursor>')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A2')),
uncheckedTaskList(p('<cursor>')),
uncheckedTaskList(p('A3')),
),
)
applyCommand(
enterCommand,
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A<cursor>2')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A')),
uncheckedTaskList(p('<cursor>2')),
uncheckedTaskList(p('A3')),
),
)
})
it('escapes the item when the cursor is in the first paragraph of the item', () => {
applyCommand(
enterCommand,
markdown`
- 123<cursor>
456
789
`,
markdown`
- 123
- <cursor>
456
789
`,
)
// Nested list item
applyCommand(
enterCommand,
markdown`
- Parent
- 123<cursor>
456
789
`,
markdown`
- Parent
- 123
- <cursor>
456
789
`,
)
})
it('can create new paragraph when the caret is not inside the first child of the list', () => {
// Cursor in the last paragraph of the item
add(
doc(
bulletList(
//
p('123'),
p('456<cursor>'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p('456'),
p('<cursor>'),
),
),
)
// Cursor in the middle paragraph of the item
add(
doc(
bulletList(
//
p('123'),
p('456<cursor>'),
p('789'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p('456'),
p('<cursor>'),
p('789'),
),
),
)
// Cursor in the last paragraph of the item (nested list item)
add(
doc(
bulletList(
p('parent'),
bulletList(
//
p('123'),
p('<cursor>456'),
),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
p('parent'),
bulletList(
//
p('123'),
p(''),
p('<cursor>456'),
),
),
),
)
add(
doc(
bulletList(
//
p('123'),
p('<cursor>'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p(''),
p('<cursor>'),
),
),
)
add(
doc(
bulletList(
//
p('123'),
p('<cursor>'),
p('456'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p(''),
p('<cursor>'),
p('456'),
),
),
)
})
it('can skip collapsed content', () => {
// Cursor in the last paragraph of the item
add(
doc(
collapsedToggleList(
//
p('1<start>23<end>'),
p('456'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
collapsedToggleList(
//
p('1'),
p('456'),
),
expandedToggleList(
//
p('<cursor>'),
),
),
)
})
it("won't effect non-list document", () => {
applyCommand(
enterCommand,
markdown`
# h1
1<cursor>23
`,
null,
)
applyCommand(
enterCommand,
markdown`
# h1
123
> 4<cursor>56
`,
null,
)
add(
doc(
blockquote(
p('123'),
blockquote(
//
p('4<cursor>56'),
),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
blockquote(
p('123'),
blockquote(
//
p('4'),
p('<cursor>56'),
),
),
),
)
})
it('can split list node for a block node selection', () => {
add(markdown`
# h1
1. ***
`)
let hrPos = -1
editor.doc.descendants((node, pos) => {
if (node.type.name === 'horizontalRule') {
hrPos = pos
}
})
expect(hrPos > -1).toBe(true)
const nodeSelection = NodeSelection.create(editor.state.doc, hrPos)
editor.view.dispatch(editor.view.state.tr.setSelection(nodeSelection))
expect(editor.view.state.selection.toJSON()).toMatchInlineSnapshot(`
{
"anchor": 5,
"type": "node",
}
`)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(markdown`
# h1
1. ***
2. <cursor>\n
`)
})
})

View File

@@ -0,0 +1,179 @@
import { chainCommands } from "@tiptap/pm/commands";
import { isTextSelection } from "@tiptap/core";
import { canSplit } from "@tiptap/pm/transform";
import {
type NodeSelection,
type Command,
type EditorState,
Selection,
TextSelection,
type Transaction,
} from "@tiptap/pm/state";
import { NodeType, Attrs, Mark, Fragment, type Node as ProsemirrorNode, Slice } from "@tiptap/pm/model";
import { ListAttributes, isListNode } from "prosemirror-flat-list";
/**
* Returns a command that split the current list node.
*
* @public @group Commands
*
*/
export function createSplitListCommand(): Command {
return chainCommands(splitBlockNodeSelectionInListCommand, splitListCommand);
}
function deriveListAttributes(listNode: ProsemirrorNode): ListAttributes {
// For the new list node, we don't want to inherit any list attribute (For example: `checked`) other than `kind`
return { kind: (listNode.attrs as ListAttributes).kind };
}
const splitBlockNodeSelectionInListCommand: Command = (state, dispatch) => {
if (!isBlockNodeSelection(state.selection)) {
return false;
}
const selection = state.selection;
const { $to, node } = selection;
const parent = $to.parent;
// We only cover the case that
// 1. the list node only contains one child node
// 2. this child node is not a list node
if (isListNode(node) || !isListNode(parent) || parent.childCount !== 1 || parent.firstChild !== node) {
return false;
}
const listType = parent.type;
const nextList = listType.createAndFill(deriveListAttributes(parent));
if (!nextList) {
return false;
}
if (dispatch) {
const tr = state.tr;
const cutPoint = $to.pos;
tr.replace(cutPoint, cutPoint, new Slice(Fragment.fromArray([listType.create(), nextList]), 1, 1));
const newSelection = TextSelection.near(tr.doc.resolve(cutPoint));
if (isTextSelection(newSelection)) {
tr.setSelection(newSelection);
dispatch(tr);
}
}
return true;
};
const splitListCommand: Command = (state, dispatch): boolean => {
if (isBlockNodeSelection(state.selection)) {
return false;
}
console.log("aaya 2");
const { $from, $to } = state.selection;
if (!$from.sameParent($to)) {
return false;
}
if ($from.depth < 2) {
return false;
}
const listDepth = $from.depth - 1;
const listNode = $from.node(listDepth);
if (!isListNode(listNode)) {
return false;
}
return doSplitList(state, listNode, dispatch);
};
/**
* @internal
*/
export function doSplitList(
state: EditorState,
listNode: ProsemirrorNode,
dispatch?: (tr: Transaction) => void
): boolean {
const tr = state.tr;
const listType = listNode.type;
const attrs: ListAttributes = listNode.attrs;
const newAttrs: ListAttributes = deriveListAttributes(listNode);
tr.delete(tr.selection.from, tr.selection.to);
const { $from, $to } = tr.selection;
const { parentOffset } = $to;
const atStart = parentOffset == 0 && $from.index($from.depth - 1) === 0;
const atEnd = parentOffset == $to.parent.content.size;
const currentNode = $from.node($from.depth);
// // __AUTO_GENERATED_PRINT_VAR_START__
// console.log("doSplitList currentNode: %s", currentNode.ty); // __AUTO_GENERATED_PRINT_VAR_END__
if (currentNode.type.name !== "paragraph") {
console.log("ran fasle");
return false;
}
// is at start and not the second child of a list
if (atStart) {
if (dispatch) {
const pos = $from.before(-1);
tr.insert(pos, createAndFill(listType, newAttrs));
dispatch(tr.scrollIntoView());
}
return true;
}
if (atEnd && attrs.collapsed) {
if (dispatch) {
const pos = $from.after(-1);
tr.insert(pos, createAndFill(listType, newAttrs));
tr.setSelection(Selection.near(tr.doc.resolve(pos)));
dispatch(tr.scrollIntoView());
}
return true;
}
// If split the list at the start or at the middle, we want to inherit the
// current parent type (e.g. heading); otherwise, we want to create a new
// default block type (typically paragraph)
const nextType = atEnd ? listNode.contentMatchAt(0).defaultType : undefined;
const typesAfter = [{ type: listType, attrs: newAttrs }, nextType ? { type: nextType } : null];
if (!canSplit(tr.doc, $from.pos, 2, typesAfter)) {
return false;
}
dispatch?.(tr.split($from.pos, 2, typesAfter).scrollIntoView());
return true;
}
export function createAndFill(
type: NodeType,
attrs?: Attrs | null,
content?: Fragment | ProsemirrorNode | readonly ProsemirrorNode[] | null,
marks?: readonly Mark[]
) {
const node = type.createAndFill(attrs, content, marks);
if (!node) {
throw new RangeError(`Failed to create '${type.name}' node`);
}
node.check();
return node;
}
export function isBlockNodeSelection(selection: Selection): selection is NodeSelection {
const isNodeSelectionBool = isNodeSelection(selection) && selection.node.type.isBlock;
return isNodeSelectionBool;
}
export function isNodeSelection(selection: Selection): selection is NodeSelection {
return Boolean((selection as NodeSelection).node);
}

View File

@@ -0,0 +1,63 @@
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createToggleCollapsedCommand } from './toggle-collapsed'
describe('toggleCollapsed', () => {
const t = setupTestingEditor()
it('can toggle collapsed attribute', () => {
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
})
it('can set collapsed value', () => {
t.applyCommand(
createToggleCollapsedCommand({ collapsed: true }),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand({ collapsed: true }),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand({ collapsed: false }),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand({ collapsed: false }),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
})
it('can skip non-collapsed node', () => {
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.expandedToggleList(t.p('A1<cursor>'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'))),
)
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.expandedToggleList(t.p('A1'), t.orderedList(t.p('B1<cursor>')))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.orderedList(t.p('B1')))),
)
})
})

View File

@@ -0,0 +1,60 @@
import { type Command } from "prosemirror-state";
import { isListNode } from "../utils/is-list-node";
import { setSafeSelection } from "./set-safe-selection";
import { ProsemirrorNode, ListAttributes } from "prosemirror-flat-list";
/**
* @public
*/
export interface ToggleCollapsedOptions {
/**
* If this value exists, the command will set the `collapsed` attribute to
* this value instead of toggle it.
*/
collapsed?: boolean;
/**
* An optional function to accept a list node and return whether or not this
* node can toggle its `collapsed` attribute.
*/
isToggleable?: (node: ProsemirrorNode) => boolean;
}
/**
* Return a command function that toggle the `collapsed` attribute of the list node.
*
* @public @group Commands
*/
export function createToggleCollapsedCommand({
collapsed = undefined,
isToggleable = defaultIsToggleable,
}: ToggleCollapsedOptions = {}): Command {
const toggleCollapsed: Command = (state, dispatch) => {
const { $from } = state.selection;
for (let depth = $from.depth; depth >= 0; depth--) {
const node = $from.node(depth);
if (isListNode(node) && isToggleable(node)) {
if (dispatch) {
const pos = $from.before(depth);
const attrs = node.attrs as ListAttributes;
const tr = state.tr;
tr.setNodeAttribute(pos, "collapsed", collapsed ?? !attrs.collapsed);
dispatch(setSafeSelection(tr));
}
return true;
}
}
return false;
};
return toggleCollapsed;
}
function defaultIsToggleable(node: ProsemirrorNode): boolean {
const attrs = node.attrs as ListAttributes;
return attrs.kind === "toggle" && node.childCount >= 2 && !isListNode(node.firstChild);
}

View File

@@ -0,0 +1,46 @@
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createToggleListCommand } from './toggle-list'
describe('toggleList', () => {
const t = setupTestingEditor()
const { doc, p, orderedList, bulletList, uncheckedTaskList } = t
const toggleList = createToggleListCommand({ kind: 'ordered' })
it('can toggle list', () => {
const doc1 = doc(p('P1<cursor>'), p('P2'))
const doc2 = doc(orderedList(p('P1<cursor>')), p('P2'))
t.applyCommand(toggleList, doc1, doc2)
t.applyCommand(toggleList, doc2, doc1)
})
it('can toggle list with multiple selected paragraphs', () => {
const doc1 = doc(p('P1'), p('<start>P2'), p('P3<end>'), p('P4'))
const doc2 = doc(
p('P1'),
orderedList(p('<start>P2')),
orderedList(p('P3<end>')),
p('P4'),
)
t.applyCommand(toggleList, doc1, doc2)
t.applyCommand(toggleList, doc2, doc1)
})
it('can toggle a list to another kind', () => {
const toggleBullet = createToggleListCommand({ kind: 'bullet' })
const toggleTask = createToggleListCommand({ kind: 'task' })
const doc1 = doc(p('P1<cursor>'), p('P2'))
const doc2 = doc(uncheckedTaskList(p('P1<cursor>')), p('P2'))
const doc3 = doc(bulletList(p('P1<cursor>')), p('P2'))
t.applyCommand(toggleTask, doc1, doc2)
t.applyCommand(toggleBullet, doc2, doc3)
t.applyCommand(toggleTask, doc3, doc2)
t.applyCommand(toggleTask, doc2, doc1)
})
})

View File

@@ -0,0 +1,26 @@
import { chainCommands } from "prosemirror-commands";
import { type Command } from "prosemirror-state";
import { createUnwrapListCommand } from "./unwrap-list";
import { createWrapInListCommand } from "./wrap-in-list";
import { ListAttributes } from "prosemirror-flat-list";
/**
* Returns a command function that wraps the selection in a list with the given
* type and attributes, or change the list kind if the selection is already in
* another kind of list, or unwrap the selected list if otherwise.
*
* @public
*/
export function createToggleListCommand<T extends ListAttributes = ListAttributes>(
/**
* The list node attributes to toggle.
*
* @public
*/
attrs: T
): Command {
const unwrapList = createUnwrapListCommand({ kind: attrs.kind });
const wrapInList = createWrapInListCommand(attrs);
return chainCommands(unwrapList, wrapInList);
}

View File

@@ -0,0 +1,77 @@
import { NodeSelection } from 'prosemirror-state'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { type ListAttributes } from '../types'
import { createUnwrapListCommand } from './unwrap-list'
describe('unwrapList', () => {
const t = setupTestingEditor()
const { doc, p, bulletList, orderedList, checkedTaskList } = t
const unwrapList = createUnwrapListCommand()
it('can unwrap a list node selection', () => {
const doc1 = doc(bulletList(p('P1'), p('P2')))
const doc2 = doc(p('P1'), p('P2'))
t.add(doc1)
const selection = new NodeSelection(t.view.state.doc.resolve(0))
expect(selection.node.type.name).toEqual('list')
t.view.dispatch(t.view.state.tr.setSelection(selection))
expect(t.dispatchCommand(unwrapList)).toEqual(true)
expect(t.editor.state).toEqualRemirrorState(doc2)
})
it('can unwrap a list node selection in a nested list', () => {
const doc1 = doc(orderedList(checkedTaskList(p('P1'), p('P2'))))
const doc2 = doc(orderedList(p('P1'), p('P2')))
t.add(doc1)
const selection = new NodeSelection(t.view.state.doc.resolve(1))
expect(selection.node.type.name).toEqual('list')
expect((selection.node.attrs as ListAttributes).kind).toEqual('task')
t.view.dispatch(t.view.state.tr.setSelection(selection))
expect(t.dispatchCommand(unwrapList)).toEqual(true)
expect(t.editor.state).toEqualRemirrorState(doc2)
})
it('can unwrap a paragraph inside a list node', () => {
const doc1 = doc(orderedList(p('P<cursor>1')))
const doc2 = doc(p('P<cursor>1'))
t.applyCommand(unwrapList, doc1, doc2)
})
it('can unwrap all paragraphs inside a list node event only part of them are selected', () => {
const doc1 = doc(orderedList(p('P1'), p('P2<cursor>'), p('P3')))
const doc2 = doc(p('P1'), p('P2<cursor>'), p('P3'))
t.applyCommand(unwrapList, doc1, doc2)
})
it('can unwrap all paragraphs inside a list node', () => {
const doc1 = doc(orderedList(p('<start>P1'), p('P2'), p('P3<end>')))
const doc2 = doc(p('<start>P1'), p('P2'), p('P3<end>'))
t.applyCommand(unwrapList, doc1, doc2)
})
it('can unwrap multiple lists', () => {
const doc1 = doc(
p('P1'),
orderedList(p('P2<start>')),
orderedList(p('P3')),
orderedList(p('P4<end>'), p('P5')),
orderedList(p('P6')),
)
const doc2 = doc(
p('P1'),
p('P2<start>'),
p('P3'),
p('P4<end>'),
p('P5'),
orderedList(p('P6')),
)
t.applyCommand(unwrapList, doc1, doc2)
})
})

View File

@@ -0,0 +1,86 @@
import { type NodeRange } from "prosemirror-model";
import { type Command } from "prosemirror-state";
import { isListNode } from "../utils/is-list-node";
import { isNodeSelection } from "../utils/is-node-selection";
import { safeLiftFromTo } from "../utils/safe-lift";
import { dedentOutOfList } from "./dedent-list";
import { ProsemirrorNode, ListAttributes } from "prosemirror-flat-list";
/**
* @public
*/
export interface UnwrapListOptions {
/**
* If given, only this kind of list will be unwrap.
*/
kind?: string;
}
/**
* Returns a command function that unwraps the list around the selection.
*
* @public
*/
export function createUnwrapListCommand(options?: UnwrapListOptions): Command {
const kind = options?.kind;
const unwrapList: Command = (state, dispatch) => {
const selection = state.selection;
if (isNodeSelection(selection) && isTargetList(selection.node, kind)) {
if (dispatch) {
const tr = state.tr;
safeLiftFromTo(tr, tr.selection.from + 1, tr.selection.to - 1);
dispatch(tr.scrollIntoView());
}
return true;
}
const range = selection.$from.blockRange(selection.$to);
if (range && isTargetListsRange(range, kind)) {
const tr = state.tr;
if (dedentOutOfList(tr, range)) {
dispatch?.(tr);
return true;
}
}
if (range && isTargetList(range.parent, kind)) {
if (dispatch) {
const tr = state.tr;
safeLiftFromTo(tr, range.$from.start(range.depth), range.$to.end(range.depth));
dispatch(tr.scrollIntoView());
}
return true;
}
return false;
};
return unwrapList;
}
function isTargetList(node: ProsemirrorNode, kind: string | undefined) {
if (isListNode(node)) {
if (kind) {
return (node.attrs as ListAttributes).kind === kind;
}
return true;
}
return false;
}
function isTargetListsRange(range: NodeRange, kind: string | undefined): boolean {
const { startIndex, endIndex, parent } = range;
for (let i = startIndex; i < endIndex; i++) {
if (!isTargetList(parent.child(i), kind)) {
return false;
}
}
return true;
}

View File

@@ -0,0 +1,163 @@
import { Selection } from 'prosemirror-state'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createWrapInListCommand } from './wrap-in-list'
describe('wrapInList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
const wrapInBulletList = createWrapInListCommand({ kind: 'bullet' })
const wrapInOrderedList = createWrapInListCommand({ kind: 'ordered' })
const wrapInTaskList = createWrapInListCommand({ kind: 'task' })
it('can wrap a paragraph node to a list node', () => {
t.applyCommand(
wrapInBulletList,
markdown`
P1
P2<cursor>
`,
markdown`
P1
- P2
`,
)
})
it('can wrap multiple paragraph nodes to list nodes', () => {
t.applyCommand(
wrapInTaskList,
markdown`
P1
P2<start>
P3<end>
`,
markdown`
P1
- [ ] P2
- [ ] P3
`,
)
})
it('can change the type of an existing list node', () => {
t.applyCommand(
wrapInOrderedList,
markdown`
- P1
- P2<cursor>
`,
markdown`
- P1
1. P2
`,
)
})
it('can change the type of multiple existing list nodes', () => {
t.applyCommand(
wrapInTaskList,
markdown`
- P1
- P2<start>
1. P3<end>
`,
markdown`
- P1
- [ ] P2
- [ ] P3
`,
)
})
it('can keep the type of a list node with multiple paragraphs', () => {
t.applyCommand(
wrapInBulletList,
markdown`
- P1<cursor>
P2
P3
P4
`,
markdown`
- P1<cursor>
P2
P3
P4
`,
)
})
it('can wrap a paragraph inside a list node to a sub-list node', () => {
t.applyCommand(
wrapInBulletList,
markdown`
- P1
P2<cursor>
P3
`,
markdown`
- P1
- P2<cursor>
P3
`,
)
})
it('can wrap multiple paragraphs inside a list node to a sub-list node', () => {
t.applyCommand(
wrapInBulletList,
markdown`
- P1
P2<start>
P3<end>
`,
markdown`
- P1
- P2<start>
- P3<end>
`,
)
})
it('should handle block node without content', () => {
const doc1 = t.doc(/*0*/ t.p() /*2*/, t.horizontalRule() /*3*/)
const doc2 = t.doc(t.p(), t.bulletList(t.horizontalRule()))
t.add(doc1)
const view = t.view
const selection = Selection.atEnd(view.state.doc)
expect(selection.from).toBe(2)
view.dispatch(view.state.tr.setSelection(selection))
wrapInBulletList(view.state, view.dispatch.bind(view), view)
expect(view.state.doc).toEqualRemirrorDocument(doc2)
})
})

View File

@@ -0,0 +1,89 @@
import { NodeRange } from "prosemirror-model";
import { type Command } from "prosemirror-state";
import { findWrapping } from "prosemirror-transform";
import { getListType } from "../utils/get-list-type";
import { isListNode } from "../utils/is-list-node";
import { setNodeAttributes } from "../utils/set-node-attributes";
import { ListAttributes } from "prosemirror-flat-list";
/**
* The list node attributes or a callback function to take the current
* selection block range and return list node attributes. If this callback
* function returns null, the command won't do anything.
*
* @public
*/
export type WrapInListGetAttrs<T extends ListAttributes> = T | ((range: NodeRange) => T | null);
/**
* Returns a command function that wraps the selection in a list with the given
* type and attributes.
*
* @public @group Commands
*/
export function createWrapInListCommand<T extends ListAttributes = ListAttributes>(
getAttrs: WrapInListGetAttrs<T>
): Command {
const wrapInList: Command = (state, dispatch): boolean => {
const { $from, $to } = state.selection;
let range = $from.blockRange($to);
if (!range) {
return false;
}
if (rangeAllowInlineContent(range) && isListNode(range.parent) && range.depth > 0 && range.startIndex === 0) {
range = new NodeRange($from, $to, range.depth - 1);
}
const attrs: T | null = typeof getAttrs === "function" ? getAttrs(range) : getAttrs;
if (!attrs) {
return false;
}
const { parent, startIndex, endIndex, depth } = range;
const tr = state.tr;
const listType = getListType(state.schema);
for (let i = endIndex - 1; i >= startIndex; i--) {
const node = parent.child(i);
if (isListNode(node)) {
const oldAttrs: T = node.attrs as T;
const newAttrs: T = { ...oldAttrs, ...attrs };
setNodeAttributes(tr, $from.posAtIndex(i, depth), oldAttrs, newAttrs);
} else {
const beforeNode = $from.posAtIndex(i, depth);
const afterNode = $from.posAtIndex(i + 1, depth);
let nodeStart = beforeNode + 1;
let nodeEnd = afterNode - 1;
if (nodeStart > nodeEnd) {
[nodeStart, nodeEnd] = [nodeEnd, nodeStart];
}
const range = new NodeRange(tr.doc.resolve(nodeStart), tr.doc.resolve(nodeEnd), depth);
const wrapping = findWrapping(range, listType, attrs);
if (wrapping) {
tr.wrap(range, wrapping);
}
}
}
dispatch?.(tr);
return true;
};
return wrapInList;
}
function rangeAllowInlineContent(range: NodeRange): boolean {
const { parent, startIndex, endIndex } = range;
for (let i = startIndex; i < endIndex; i++) {
if (parent.child(i).inlineContent) {
return true;
}
}
return false;
}

View File

@@ -0,0 +1,153 @@
import { Node } from "@tiptap/core";
import {
createListSpec,
createListPlugins,
listKeymap,
listInputRules,
ListAttributes,
createWrapInListCommand,
DedentListOptions,
IndentListOptions,
createIndentListCommand,
createDedentListCommand,
enterWithoutLift,
} from "prosemirror-flat-list";
import { keymap } from "@tiptap/pm/keymap";
import { inputRules } from "@tiptap/pm/inputrules";
import { createSplitListCommand } from "./commands/split-list";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
flatHeadingListComponent: {
createList: (attrs: ListAttributes) => ReturnType;
indentList: (attrs: IndentListOptions) => ReturnType;
dedentList: (attrs: DedentListOptions) => ReturnType;
splitList: () => ReturnType;
createHeadedList: (attrs: ListAttributes & { title: string }) => ReturnType;
};
}
}
const { attrs, parseDOM, toDOM, group, definingForContent, definingAsContext } = createListSpec();
const listKeymapPlugin = keymap(listKeymap);
const listInputRulePlugin = inputRules({ rules: listInputRules });
export const FlatHeadingListExtension = Node.create({
name: "headingList",
content: "heading block*",
group,
definingForContent,
definingAsContext,
addAttributes() {
return attrs;
},
parseHTML() {
return parseDOM;
},
renderHTML({ node }) {
return toDOM(node);
},
addCommands() {
return {
createList:
(attrs: ListAttributes) =>
({ state, view }) => {
const wrapInList = createWrapInListCommand<ListAttributes>(attrs);
return wrapInList(state, view.dispatch);
},
indentList:
(attrs: IndentListOptions) =>
({ state, view }) => {
const indentList = createIndentListCommand(attrs);
return indentList(state, view.dispatch);
},
dedentList:
(attrs: DedentListOptions) =>
({ state, view }) => {
const dedentList = createDedentListCommand(attrs);
return dedentList(state, view.dispatch);
},
splitList:
() =>
({ state, view }) => {
const splitList = createSplitListCommand();
return splitList(state, view.dispatch);
},
createHeadedList:
(attrs: ListAttributes & { title: string }) =>
({ state, chain, commands }) => {
try {
chain()
.focus()
.setHeading({ level: 1 })
.setTextSelection(state.selection.from - 1)
.run();
return commands.createList({
kind: attrs.kind || "bullet",
order: attrs.order,
checked: attrs.checked,
collapsed: attrs.collapsed,
});
} catch (error) {
console.error("Error in creating heading list", error);
return false;
}
},
};
},
addKeyboardShortcuts(this) {
return {
Tab: ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
if (editor.isActive(this.name)) {
editor.chain().focus().indentList({ from: $from.pos });
return true;
}
return false;
},
"Shift-Tab": ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
if (editor.isActive(this.name)) {
editor.chain().focus().dedentList({ from: $from.pos });
return true;
}
return false;
},
Enter: ({ editor }) => {
if (editor.isActive(this.name)) {
editor.chain().focus().splitList();
return true;
}
return false;
},
"Shift-Enter": ({ editor }) => {
if (editor.isActive(this.name)) {
return enterWithoutLift(editor.state, editor.view.dispatch);
}
return false;
},
"Mod-Shift-7": ({ editor }) => {
try {
return editor.commands.createHeadedList({ title: "a", kind: "bullet" });
} catch (error) {
console.error("Error in creating heading list", error);
return false;
}
},
"Mod-Shift-8": ({ editor }) => {
try {
return editor.commands.createHeadedList({ title: "a", kind: "ordered" });
} catch (error) {
console.error("Error in creating heading list", error);
return false;
}
},
};
},
addProseMirrorPlugins() {
return [...createListPlugins({ schema: this.editor.schema }), listKeymapPlugin, listInputRulePlugin];
},
});

View File

@@ -0,0 +1,116 @@
import { Node } from "@tiptap/core";
import {
createListSpec,
createListPlugins,
listKeymap,
listInputRules,
ListAttributes,
createWrapInListCommand,
DedentListOptions,
IndentListOptions,
createIndentListCommand,
createDedentListCommand,
parseInteger,
// createSplitListCommand,
} from "prosemirror-flat-list";
import { keymap } from "@tiptap/pm/keymap";
import { inputRules } from "@tiptap/pm/inputrules";
import { createSplitListCommand } from "./commands/split-list";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
flatListComponent: {
createList: (attrs: ListAttributes) => ReturnType;
indentList: (attrs: IndentListOptions) => ReturnType;
dedentList: (attrs: DedentListOptions) => ReturnType;
splitList: () => ReturnType;
// unwrapList: (attrs: UnwrapListOptions) => ReturnType;
};
}
}
const { attrs, parseDOM, toDOM, content, group, definingForContent, definingAsContext } = createListSpec();
const listKeymapPlugin = keymap(listKeymap);
const listInputRulePlugin = inputRules({ rules: listInputRules });
export const FlatListExtension = Node.create({
name: "list",
content,
group,
definingForContent,
definingAsContext,
disableDropCursor: true,
addAttributes() {
return attrs;
},
parseHTML() {
return parseDOM;
},
renderHTML({ node }) {
return toDOM(node);
},
addCommands() {
return {
createList:
(attrs: ListAttributes) =>
({ state, view }) => {
const wrapInList = createWrapInListCommand<ListAttributes>(attrs);
return wrapInList(state, view.dispatch);
},
indentList:
(attrs: IndentListOptions) =>
({ state, view }) => {
const indentList = createIndentListCommand(attrs);
return indentList(state, view.dispatch);
},
dedentList:
(attrs: DedentListOptions) =>
({ state, view }) => {
const dedentList = createDedentListCommand(attrs);
return dedentList(state, view.dispatch);
},
splitList:
() =>
({ state, view }) => {
const splitList = createSplitListCommand();
return splitList(state, view.dispatch);
},
};
},
addKeyboardShortcuts(this) {
return {
Tab: ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
if (editor.isActive(this.name)) {
// return editor.chain().focus().indentList({ from: $from.pos });
const indentList = createIndentListCommand({ from: $from.pos });
return indentList(editor.state, editor.view.dispatch);
}
return false;
},
"Shift-Tab": ({ editor }) => {
const { selection } = editor.state;
const { $from } = selection;
if (editor.isActive(this.name)) {
const dedentList = createDedentListCommand({ from: $from.pos });
return dedentList(editor.state, editor.view.dispatch);
}
return false;
},
Enter: ({ editor }) => {
if (editor.isActive(this.name)) {
const splitList = createSplitListCommand();
const ans = splitList(editor.state, editor.view.dispatch);
// __AUTO_GENERATED_PRINT_VAR_START__
console.log("addKeyboardShortcuts#(anon)#if ans: %s", ans); // __AUTO_GENERATED_PRINT_VAR_END__
return ans;
}
return false;
},
};
},
addProseMirrorPlugins() {
return [...createListPlugins({ schema: this.editor.schema }), listKeymapPlugin, listInputRulePlugin];
},
});

View File

@@ -0,0 +1,175 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ListDOMSerializer > can serialize list nodes into <ol> 1`] = `
<DocumentFragment>
<ol>
<li
class="prosemirror-flat-list"
data-list-kind="ordered"
>
<p>
A
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="ordered"
>
<p>
B
</p>
</li>
</ol>
</DocumentFragment>
`;
exports[`ListDOMSerializer > can serialize list nodes into <ul> 1`] = `
<DocumentFragment>
<ul>
<li
class="prosemirror-flat-list"
data-list-kind="bullet"
>
<p>
A
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="bullet"
>
<p>
B
</p>
</li>
</ul>
</DocumentFragment>
`;
exports[`ListDOMSerializer > can serialize list nodes with different types into a single <ul> 1`] = `
<DocumentFragment>
<ul>
<li
class="prosemirror-flat-list"
data-list-kind="bullet"
>
<p>
A
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="task"
>
<p>
B
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="toggle"
>
<p>
C
</p>
</li>
</ul>
<ol>
<li
class="prosemirror-flat-list"
data-list-kind="ordered"
>
<p>
D
</p>
</li>
</ol>
<ul>
<li
class="prosemirror-flat-list"
data-list-kind="bullet"
>
<p>
D
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="task"
>
<p>
E
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="toggle"
>
<p>
D
</p>
</li>
</ul>
</DocumentFragment>
`;
exports[`ListDOMSerializer > can serialize nested list node 1`] = `
<DocumentFragment>
<ul>
<li
class="prosemirror-flat-list"
data-list-collapsable=""
data-list-kind="bullet"
>
<p>
A
</p>
<ol>
<li
class="prosemirror-flat-list"
data-list-kind="ordered"
>
<p>
B
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="ordered"
>
<p>
C
</p>
</li>
</ol>
</li>
<li
class="prosemirror-flat-list"
data-list-collapsable=""
data-list-kind="bullet"
>
<p>
D
</p>
<ol>
<li
class="prosemirror-flat-list"
data-list-kind="ordered"
>
<p>
E
</p>
</li>
<li
class="prosemirror-flat-list"
data-list-kind="ordered"
>
<p>
F
</p>
</li>
</ol>
</li>
</ul>
</DocumentFragment>
`;

View File

@@ -0,0 +1,19 @@
import { type ResolvedPos } from 'prosemirror-model'
import { type EditorState, type TextSelection } from 'prosemirror-state'
import { type EditorView } from 'prosemirror-view'
// Copied from https://github.com/prosemirror/prosemirror-commands/blob/1.5.0/src/commands.ts#L157
export function atTextblockEnd(
state: EditorState,
view?: EditorView,
): ResolvedPos | null {
const { $cursor } = state.selection as TextSelection
if (
!$cursor ||
(view
? !view.endOfTextblock('forward', state)
: $cursor.parentOffset < $cursor.parent.content.size)
)
return null
return $cursor
}

View File

@@ -0,0 +1,17 @@
import { type ResolvedPos } from 'prosemirror-model'
import { type EditorState, type TextSelection } from 'prosemirror-state'
import { type EditorView } from 'prosemirror-view'
// Copied from https://github.com/prosemirror/prosemirror-commands/blob/1.5.0/src/commands.ts#L15
export function atTextblockStart(
state: EditorState,
view?: EditorView,
): ResolvedPos | null {
const { $cursor } = state.selection as TextSelection
if (
!$cursor ||
(view ? !view.endOfTextblock('backward', state) : $cursor.parentOffset > 0)
)
return null
return $cursor
}

View File

@@ -0,0 +1,50 @@
import { type Command } from 'prosemirror-state'
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { withAutoFixList } from './auto-fix-list'
describe('autoJoinList', () => {
const t = setupTestingEditor()
it('should join two lists', () => {
const command: Command = withAutoFixList((state, dispatch) => {
const schema = state.schema
dispatch?.(state.tr.replaceWith(8, 9, schema.text('C')))
return true
})
t.applyCommand(
command,
t.doc(
/*0*/
t.bulletList(
/*1*/
t.p(/*2*/ 'A' /*3*/),
/*4*/
),
/*5*/
t.bulletList(
/*6*/
t.bulletList(
/*7*/
t.p(/*8*/ 'B' /*9*/),
/*10*/
),
),
),
t.doc(
t.bulletList(
t.p('A'),
t.bulletList(
//
t.p('C'),
),
),
),
)
})
})

View File

@@ -0,0 +1,104 @@
import { type Transaction } from "prosemirror-state";
import { canJoin, canSplit } from "prosemirror-transform";
import { Node as ProsemirrorNode } from "prosemirror-model";
import { isListNode } from "./is-list-node";
import { patchCommand } from "./patch-command";
/** @internal */
export function* getTransactionRanges(tr: Transaction): Generator<number[], never> {
const ranges: number[] = [];
let i = 0;
while (true) {
for (; 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((_oldStart, _oldEnd, newStart, newEnd) => ranges.push(newStart, newEnd));
}
yield ranges;
}
}
/** @internal */
export function findBoundaries(
positions: number[],
doc: ProsemirrorNode,
prediction: (before: ProsemirrorNode, after: ProsemirrorNode, parent: ProsemirrorNode, index: number) => boolean
): number[] {
const boundaries = new Set<number>();
const joinable: number[] = [];
for (const pos of positions) {
const $pos = doc.resolve(pos);
for (let depth = $pos.depth; depth >= 0; depth--) {
const boundary = $pos.before(depth + 1);
if (boundaries.has(boundary)) {
break;
}
boundaries.add(boundary);
const index = $pos.index(depth);
const parent = $pos.node(depth);
const before = parent.maybeChild(index - 1);
if (!before) continue;
const after = parent.maybeChild(index);
if (!after) continue;
if (prediction(before, after, parent, index)) {
joinable.push(boundary);
}
}
}
// Sort in the descending order
return joinable.sort((a, b) => b - a);
}
function isListJoinable(before: ProsemirrorNode, after: ProsemirrorNode): boolean {
return isListNode(before) && isListNode(after) && isListNode(after.firstChild);
}
function isListSplitable(
before: ProsemirrorNode,
after: ProsemirrorNode,
parent: ProsemirrorNode,
index: number
): boolean {
if (index === 1 && isListNode(parent) && isListNode(before) && !isListNode(after)) {
return true;
}
return false;
}
function fixList(tr: Transaction): Transaction {
const ranges = getTransactionRanges(tr);
const joinable = findBoundaries(ranges.next().value, tr.doc, isListJoinable);
for (const pos of joinable) {
if (canJoin(tr.doc, pos)) {
tr.join(pos);
}
}
const splitable = findBoundaries(ranges.next().value, tr.doc, isListSplitable);
for (const pos of splitable) {
if (canSplit(tr.doc, pos)) {
tr.split(pos);
}
}
return tr;
}
/** @internal */
export const withAutoFixList = patchCommand(fixList);

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { atEndBlockBoundary, atStartBlockBoundary } from './block-boundary'
describe('boundary', () => {
const t = setupTestingEditor()
const doc = t.doc(
/*0*/
t.bulletList(
/*1*/
t.p(/*2*/ 'A1' /*4*/),
/*5*/
t.p(/*6*/ 'A2' /*8*/),
/*9*/
),
/*10*/
t.bulletList(
/*11*/
t.bulletList(
/*12*/
t.p(/*13*/ 'B1' /*15*/),
/*16*/
),
),
)
it('atStartBoundary', () => {
expect(atStartBlockBoundary(doc.resolve(14), 3)).toBe(true)
expect(atStartBlockBoundary(doc.resolve(14), 2)).toBe(true)
expect(atStartBlockBoundary(doc.resolve(14), 1)).toBe(true)
expect(atStartBlockBoundary(doc.resolve(14), 0)).toBe(false)
expect(atStartBlockBoundary(doc.resolve(8), 2)).toBe(true)
expect(atStartBlockBoundary(doc.resolve(8), 1)).toBe(false)
expect(atStartBlockBoundary(doc.resolve(8), 0)).toBe(false)
})
it('atEndBoundary', () => {
expect(atEndBlockBoundary(doc.resolve(14), 3)).toBe(true)
expect(atEndBlockBoundary(doc.resolve(14), 2)).toBe(true)
expect(atEndBlockBoundary(doc.resolve(14), 1)).toBe(true)
expect(atEndBlockBoundary(doc.resolve(14), 0)).toBe(true)
expect(atEndBlockBoundary(doc.resolve(6), 2)).toBe(true)
expect(atEndBlockBoundary(doc.resolve(6), 1)).toBe(true)
expect(atEndBlockBoundary(doc.resolve(6), 0)).toBe(false)
})
})

View File

@@ -0,0 +1,32 @@
import { type ResolvedPos } from 'prosemirror-model'
export function atStartBlockBoundary(
$pos: ResolvedPos,
depth: number,
): boolean {
for (let d = depth; d <= $pos.depth; d++) {
if ($pos.node(d).isTextblock) {
continue
}
const index = $pos.index(d)
if (index !== 0) {
return false
}
}
return true
}
export function atEndBlockBoundary($pos: ResolvedPos, depth: number): boolean {
for (let d = depth; d <= $pos.depth; d++) {
if ($pos.node(d).isTextblock) {
continue
}
const index = $pos.index(d)
if (index !== $pos.node(d).childCount - 1) {
return false
}
}
return true
}

View File

@@ -0,0 +1,12 @@
// Copied from https://github.com/prosemirror/prosemirror-view/blob/1.30.1/src/browser.ts
const nav = typeof navigator != 'undefined' ? navigator : null
const agent = (nav && nav.userAgent) || ''
const ie_edge = /Edge\/(\d+)/.exec(agent)
const ie_upto10 = /MSIE \d/.exec(agent)
const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent)
const ie = !!(ie_upto10 || ie_11up || ie_edge)
export const safari = !ie && !!nav && /Apple Computer/.test(nav.vendor)

View File

@@ -0,0 +1,15 @@
import { type Attrs, type Fragment, type Mark, type Node as ProsemirrorNode, type NodeType } from "prosemirror-model";
export function createAndFill(
type: NodeType,
attrs?: Attrs | null,
content?: Fragment | ProsemirrorNode | readonly ProsemirrorNode[] | null,
marks?: readonly Mark[]
) {
const node = type.createAndFill(attrs, content, marks);
if (!node) {
throw new RangeError(`Failed to create '${type.name}' node`);
}
node.check();
return node;
}

View File

@@ -0,0 +1,10 @@
import { type Fragment } from 'prosemirror-model'
export function cutByIndex(
fragment: Fragment,
from: number,
to: number,
): Fragment {
// @ts-expect-error fragment.cutByIndex is internal API
return fragment.cutByIndex(from, to)
}

View File

@@ -0,0 +1,24 @@
import { flatListGroup } from "prosemirror-flat-list";
import { type NodeType, type Schema } from "prosemirror-model";
/** @internal */
export function getListType(schema: Schema): NodeType {
let name: string = schema.cached["PROSEMIRROR_FLAT_LIST_LIST_TYPE_NAME"];
if (!name) {
for (const type of Object.values(schema.nodes)) {
if ((type.spec.group || "").split(" ").includes(flatListGroup)) {
name = type.name;
break;
}
}
if (!name) {
throw new TypeError("[prosemirror-flat-list] Unable to find a flat list type in the schema");
}
schema.cached["PROSEMIRROR_FLAT_LIST_LIST_TYPE_NAME"] = name;
}
return schema.nodes[name];
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { inCollapsedList } from './in-collapsed-list'
describe('inCollapsedList', () => {
const t = setupTestingEditor()
it('returns false in a normal paragraph', () => {
t.add(t.doc(t.p('Hello world<cursor>')))
expect(inCollapsedList(t.view.state.selection.$from)).toBe(false)
})
it('returns true in a collapsed list node', () => {
t.add(
t.doc(
t.collapsedToggleList(
t.p('Visible content<cursor>'),
t.p('Hidden content'),
),
),
)
expect(inCollapsedList(t.view.state.selection.$from)).toBe(true)
})
it('returns false in a expanded list node', () => {
t.add(
t.doc(
t.expandedToggleList(
t.p('Visible content'),
t.p('Visible content<cursor>'),
),
),
)
expect(inCollapsedList(t.view.state.selection.$from)).toBe(false)
})
})

View File

@@ -0,0 +1,17 @@
import { type ResolvedPos } from "prosemirror-model";
import { isListNode } from "./is-list-node";
import { ListAttributes } from "prosemirror-flat-list";
export function inCollapsedList($pos: ResolvedPos): boolean {
for (let depth = $pos.depth; depth >= 0; depth--) {
const node = $pos.node(depth);
if (isListNode(node)) {
const attrs = node.attrs as ListAttributes;
if (attrs.collapsed) {
return true;
}
}
}
return false;
}

View File

@@ -0,0 +1,9 @@
import { type NodeSelection, type Selection } from 'prosemirror-state'
import { isNodeSelection } from './is-node-selection'
export function isBlockNodeSelection(
selection: Selection,
): selection is NodeSelection {
return isNodeSelection(selection) && selection.node.type.isBlock
}

View File

@@ -0,0 +1,9 @@
import { ProsemirrorNode, ListAttributes } from "prosemirror-flat-list";
import { isListNode } from "./is-list-node";
/**
* @internal
*/
export function isCollapsedListNode(node: ProsemirrorNode): boolean {
return !!(isListNode(node) && (node.attrs as ListAttributes).collapsed);
}

View File

@@ -0,0 +1,9 @@
import { type Node as ProsemirrorNode } from 'prosemirror-model'
import { isListType } from './is-list-type'
/** @public */
export function isListNode(node: ProsemirrorNode | null | undefined): boolean {
if (!node) return false
return isListType(node.type)
}

View File

@@ -0,0 +1,8 @@
import { type NodeType } from 'prosemirror-model'
import { getListType } from './get-list-type'
/** @public */
export function isListType(type: NodeType): boolean {
return getListType(type.schema) === type
}

View File

@@ -0,0 +1,7 @@
import { type NodeSelection, type Selection } from 'prosemirror-state'
export function isNodeSelection(
selection: Selection,
): selection is NodeSelection {
return Boolean((selection as NodeSelection).node)
}

View File

@@ -0,0 +1,5 @@
import { TextSelection } from 'prosemirror-state'
export function isTextSelection(value?: unknown): value is TextSelection {
return Boolean(value && value instanceof TextSelection)
}

View File

@@ -0,0 +1,47 @@
import { NodeRange, type ResolvedPos } from 'prosemirror-model'
import { isListNode } from './is-list-node'
/**
* Returns a minimal block range that includes the given two positions and
* represents one or multiple sibling list nodes.
*
* @public
*/
export function findListsRange(
$from: ResolvedPos,
$to: ResolvedPos = $from,
): NodeRange | null {
if ($to.pos < $from.pos) {
return findListsRange($to, $from)
}
let range = $from.blockRange($to)
while (range) {
if (isListsRange(range)) {
return range
}
if (range.depth <= 0) {
break
}
range = new NodeRange($from, $to, range.depth - 1)
}
return null
}
/** @internal */
export function isListsRange(range: NodeRange): boolean {
const { startIndex, endIndex, parent } = range
for (let i = startIndex; i < endIndex; i++) {
if (!isListNode(parent.child(i))) {
return false
}
}
return true
}

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { ListDOMSerializer } from './list-serializer'
describe('ListDOMSerializer', () => {
const {
add,
doc,
p,
bulletList,
orderedList,
uncheckedTaskList: taskList,
expandedToggleList: toggleList,
schema,
} = setupTestingEditor()
let editor: ReturnType<typeof add>
it('can serialize list nodes into <ul>', () => {
editor = add(doc(bulletList(p('A')), bulletList(p('B'))))
const serializer = ListDOMSerializer.fromSchema(schema)
const serialized = serializer.serializeFragment(editor.state.doc.content)
expect(serialized.querySelectorAll('ul').length).toBe(1)
expect(serialized.querySelectorAll('ol').length).toBe(0)
expect(serialized.querySelectorAll('ul > li').length).toBe(2)
expect(serialized).toMatchSnapshot()
})
it('can serialize list nodes into <ol>', () => {
editor = add(doc(orderedList(p('A')), orderedList(p('B'))))
const serializer = ListDOMSerializer.fromSchema(schema)
const serialized = serializer.serializeFragment(editor.state.doc.content)
expect(serialized.querySelectorAll('ul').length).toBe(0)
expect(serialized.querySelectorAll('ol').length).toBe(1)
expect(serialized.querySelectorAll('ol > li').length).toBe(2)
expect(serialized).toMatchSnapshot()
})
it('can serialize list nodes with different types into a single <ul>', () => {
editor = add(
doc(
bulletList(p('A')),
taskList(p('B')),
toggleList(p('C')),
orderedList(p('D')),
bulletList(p('D')),
taskList(p('E')),
toggleList(p('D')),
),
)
const serializer = ListDOMSerializer.fromSchema(schema)
const serialized = serializer.serializeFragment(editor.state.doc.content)
expect(serialized.querySelectorAll('ul').length).toBe(2)
expect(serialized.querySelectorAll('ol').length).toBe(1)
expect(serialized.querySelectorAll('ul > li').length).toBe(6)
expect(serialized.querySelectorAll('ol > li').length).toBe(1)
expect(serialized).toMatchSnapshot()
})
it('can serialize nested list node ', () => {
editor = add(
doc(
bulletList(p('A'), orderedList(p('B')), orderedList(p('C'))),
bulletList(p('D'), orderedList(p('E')), orderedList(p('F'))),
),
)
const serializer = ListDOMSerializer.fromSchema(schema)
const serialized = serializer.serializeFragment(editor.state.doc.content)
expect(serialized.querySelectorAll('ul').length).toBe(1)
expect(serialized.querySelectorAll('ol').length).toBe(2)
expect(serialized.querySelectorAll('ul > li').length).toBe(2)
expect(serialized.querySelectorAll('ol > li').length).toBe(4)
expect(serialized).toMatchSnapshot()
})
})

Some files were not shown because too many files have changed in this diff Show More