Compare commits

..

39 Commits

Author SHA1 Message Date
Palanikannan M 6c71d7c87b wip: optimization of pages binary data 2024-12-10 18:05:04 +05:30
Palanikannan M d6f27f7019 Merge branch 'preview' into fix/pages-loading-optimization 2024-12-09 20:34:10 +05:30
Palanikannan M 97ff3832fd Merge branch 'chore/page-description' into fix/pages-loading-optimization 2024-12-09 20:32:30 +05:30
Palanikannan M d29ab80762 fix: removing readonly editor 2024-12-09 20:30:44 +05:30
Bavisetti Narayan ff8bbed6f9 chore: changed the soft deletion logic (#6171) 2024-12-09 20:29:30 +05:30
Vamsi Krishna d04619477b [WEB-2382]chore: notifications code improvement (#6172)
* chore: adjusted  increment/decrement  for unread count

* chore: improved param handling for unread notification count function
2024-12-09 18:06:56 +05:30
sriram veeraghanta 547c138084 fix: ui package module resolution 2024-12-09 15:56:20 +05:30
Anmol Singh Bhatia 5c907db0e2 [WEB-2818] chore: project navigation items code refactor (#6170)
* chore: project navigation items code refactor

* fix: build error

* chore: code refactor

* chore: code refactor
2024-12-09 14:37:04 +05:30
Aaryan Khandelwal a85e592ada fix: creating a new sub-issue from workspace level (#6169) 2024-12-09 12:15:10 +05:30
sriram veeraghanta b21d190ce0 fix: added github pull request template 2024-12-09 02:55:09 +05:30
sriram veeraghanta cba41e0755 fix: upgrading the express version 2024-12-09 02:35:48 +05:30
sriram veeraghanta 02308eeb15 fix: django version upgrade 2024-12-09 02:28:06 +05:30
guru_sainath 9ee41ece98 fix: email check validation to handle case in-sensitive email (#6168) 2024-12-07 17:55:50 +05:30
Vamsi Krishna 666ddf73b6 [WEB-2382]chore:notification snooze modal (#6164)
* modified notification store

* notification snooze types fix

* handled promise

* modified notifications layout

* incresed pagination count for notifications
2024-12-06 16:27:45 +05:30
Satish Gandham 4499a5fa25 Sync issues and workspace data when the issue properties like labels/modules/cycles etc are deleted from the project (#6165) 2024-12-06 16:27:07 +05:30
sriram veeraghanta 727dd4002e fix: updated lint command in packages 2024-12-06 15:00:11 +05:30
sriram veeraghanta 4b5a2bc4e5 chore: lint related changes and packaging fixes (#6163)
* fix: lint related changes and packaging fixes

* adding color validations
2024-12-06 14:56:49 +05:30
sriram veeraghanta b1c340b199 fix: build branch workflow upload artifacts 2024-12-05 16:51:20 +05:30
rahulramesha a612a17d28 chore remove unnecessary CTA (#6161) 2024-12-05 16:37:55 +05:30
Prateek Shourya d55ee6d5b8 fix: remove unwanted states fetching logic to avoid multiple API calls. (#6158) 2024-12-05 15:26:35 +05:30
Prateek Shourya aa1e192a50 improvement: update fetch map during workspace-level module fetch to reduce redundant API calls (#6159) 2024-12-05 15:26:15 +05:30
guru_sainath 6cd8af1092 chore: updated powered by (#6160) 2024-12-05 15:12:37 +05:30
rahulramesha 66652a5d71 refactor project states to ake way for new features (#6156) 2024-12-05 12:46:51 +05:30
sriram veeraghanta 3bccda0c86 chore: formatting and typo fixes 2024-12-04 19:40:37 +05:30
sriram veeraghanta fb3295f5f4 fix: sites opengraph title and description added 2024-12-04 17:58:23 +05:30
sriram veeraghanta fa3aa362a9 fix: lint errors 2024-12-04 17:22:41 +05:30
Bavisetti Narayan b73ea37798 chore: improve the cascading logic (#6152) 2024-12-04 16:15:57 +05:30
Vamsi Krishna d537e560e3 [WEB-2802]fix: dorpdown visibility issue in safari (#6151)
* filters drop down fix safari

* added comments for translation

* fixed drop down visibility issue
2024-12-04 15:27:34 +05:30
guru_sainath 1b92a18ef8 chore: updated the ssr rendering on sites (#6145)
* fix: refactoring

* fix: site ssr implementation

* chore: fixed auto reload on file change in sites

* chore: updated constant imports and globalised powerBy component

* chore: resolved lint and updated the env

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-12-04 14:24:53 +05:30
rahulramesha 31b6d52417 fix root issue store to have updated url params at all times (#6147) 2024-12-04 13:57:33 +05:30
Vamsi Krishna a153de34d6 fixed piority icons shape (#6144) 2024-12-04 13:57:14 +05:30
Aaryan Khandelwal 64a44f4fce style: add custom class to editor paragraph and heading blocks (#6143) 2024-12-04 13:43:52 +05:30
guru_sainath bb8a156bdd fix: removed changelog endpoint (#6146) 2024-12-04 13:42:15 +05:30
Akshita Goyal f02a2b04a5 fix: export btn overlap issue (#6149) 2024-12-04 13:41:48 +05:30
Bavisetti Narayan b6ab853c57 chore: filter out the removed cycle from issue detail (#6138) 2024-12-03 16:48:14 +05:30
Aaryan Khandelwal fe43300aa7 fix: pages empty state authorization (#6141) 2024-12-03 14:53:02 +05:30
Prateek Shourya 849d9891d2 chore: community edition product updates link (#6132)
* chore: community edition product updates link

* fix: iframe embed for changelog

---------

Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
2024-12-03 13:28:28 +05:30
Vamsi Krishna 2768f560ad [WEB-2802]fix:filters drop down fix safari (#6133)
* filters drop down fix safari

* added comments for translation
2024-12-03 12:51:39 +05:30
NarayanBavisetti e5ebee664b chore: removed binary decoding 2024-11-27 00:41:34 +05:30
318 changed files with 4683 additions and 12125 deletions
+20
View File
@@ -0,0 +1,20 @@
### Description
<!-- Provide a detailed description of the changes in this PR -->
### Type of Change
<!-- Put an 'x' in the boxes that apply -->
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] Feature (non-breaking change which adds functionality)
- [ ] Improvement (change that would cause existing functionality to not work as expected)
- [ ] Code refactoring
- [ ] Performance improvements
- [ ] Documentation update
### Screenshots and Media (if applicable)
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
### Test Scenarios
<!-- Please describe the tests that you ran to verify your changes -->
### References
<!-- Link related issues if there are any -->
+2 -2
View File
@@ -314,8 +314,8 @@ jobs:
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
attach_assets_to_build:
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
name: Attach Assets to Build
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
name: Attach Assets to Release
runs-on: ubuntu-20.04
needs: [branch_build_setup]
steps:
-2
View File
@@ -85,5 +85,3 @@ deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css
dev-server
+1 -1
View File
@@ -3,7 +3,7 @@
import { FC, useEffect, useRef } from "react";
import { observer } from "mobx-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/helpers";
import { useOutsideClickDetector } from "@plane/hooks";
// components
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
// hooks
+3 -2
View File
@@ -14,9 +14,10 @@
"dependencies": {
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/helpers": "*",
"@plane/hooks": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
@@ -26,7 +27,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.12",
"next": "^14.2.20",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
Binary file not shown.
+1 -3
View File
@@ -68,9 +68,7 @@ urlpatterns = [
# user workspace invitations
path(
"users/me/workspaces/invitations/",
UserWorkspaceInvitationsViewSet.as_view(
{"get": "list", "post": "create"}
),
UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}),
name="user-workspace-invitations",
),
path(
+4 -8
View File
@@ -15,8 +15,6 @@ from django.db.models import (
UUIDField,
Value,
Subquery,
Case,
When,
)
from django.db.models.functions import Coalesce
from django.utils import timezone
@@ -445,12 +443,10 @@ class IssueViewSet(BaseViewSet):
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Case(
When(
issue_cycle__cycle__deleted_at__isnull=True,
then=F("issue_cycle__cycle_id"),
),
default=None,
cycle_id=Subquery(
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
:1
]
)
)
.annotate(
+30 -24
View File
@@ -39,6 +39,7 @@ 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.recent_visited_task import recent_visited_task
from rest_framework.parsers import MultiPartParser, FormParser
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -470,6 +471,8 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet):
parser_classes = [MultiPartParser]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
page = (
@@ -526,30 +529,33 @@ class PagesDescriptionViewSet(BaseViewSet):
existing_instance = json.dumps(
{"description_html": page.description_html}, cls=DjangoJSONEncoder
)
print("before the variables")
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
return Response({"message": "Updated successfully"})
else:
if not request.data.get("description_binary"):
return Response({"error": "No binary data provided"})
# Store the updated binary data
page.description_html = request.data.get(
"description_html", page.description_html
)
page.description = request.data.get("description", page.description)
page.description_binary = request.POST.get("description_binary")
page.description_binary = base64.b64decode(
request.POST.get("description_binary")
)
print("before the ssssave")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})
+9 -31
View File
@@ -16,12 +16,7 @@ from plane.app.permissions import (
WorkspaceUserPermission,
)
from plane.db.models import (
Project,
ProjectMember,
IssueUserProperty,
WorkspaceMember,
)
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE
@@ -83,10 +78,7 @@ class ProjectMemberViewSet(BaseViewSet):
workspace_member_role = WorkspaceMember.objects.get(
workspace__slug=slug, member=member, is_active=True
).role
if workspace_member_role in [20] and member_roles.get(member) in [
5,
15,
]:
if workspace_member_role in [20] and member_roles.get(member) in [5, 15]:
return Response(
{
"error": "You cannot add a user with role lower than the workspace role"
@@ -94,10 +86,7 @@ class ProjectMemberViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
if workspace_member_role in [5] and member_roles.get(member) in [
15,
20,
]:
if workspace_member_role in [5] and member_roles.get(member) in [15, 20]:
return Response(
{
"error": "You cannot add a user with role higher than the workspace role"
@@ -135,8 +124,7 @@ class ProjectMemberViewSet(BaseViewSet):
sort_order = [
project_member.get("sort_order")
for project_member in project_members
if str(project_member.get("member_id"))
== str(member.get("member_id"))
if str(project_member.get("member_id")) == str(member.get("member_id"))
]
# Create a new project member
bulk_project_members.append(
@@ -145,9 +133,7 @@ class ProjectMemberViewSet(BaseViewSet):
role=member.get("role", 5),
project_id=project_id,
workspace_id=project.workspace_id,
sort_order=(
sort_order[0] - 10000 if len(sort_order) else 65535
),
sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535),
)
)
# Create a new issue property
@@ -238,9 +224,7 @@ class ProjectMemberViewSet(BaseViewSet):
> requested_project_member.role
):
return Response(
{
"error": "You cannot update a role that is higher than your own role"
},
{"error": "You cannot update a role that is higher than your own role"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -280,9 +264,7 @@ class ProjectMemberViewSet(BaseViewSet):
# User cannot deactivate higher role
if requesting_project_member.role < project_member.role:
return Response(
{
"error": "You cannot remove a user having role higher than you"
},
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -303,10 +285,7 @@ class ProjectMemberViewSet(BaseViewSet):
if (
project_member.role == 20
and not ProjectMember.objects.filter(
workspace__slug=slug,
project_id=project_id,
role=20,
is_active=True,
workspace__slug=slug, project_id=project_id, role=20, is_active=True
).count()
> 1
):
@@ -344,7 +323,6 @@ class UserProjectRolesEndpoint(BaseAPIView):
).values("project_id", "role")
project_members = {
str(member["project_id"]): member["role"]
for member in project_members
str(member["project_id"]): member["role"] for member in project_members
}
return Response(project_members, status=status.HTTP_200_OK)
+3 -2
View File
@@ -41,6 +41,7 @@ from django.views.decorators.vary import vary_on_cookie
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
from plane.license.utils.instance_value import get_configuration_value
class WorkSpaceViewSet(BaseViewSet):
model = Workspace
serializer_class = WorkSpaceSerializer
@@ -81,12 +82,12 @@ class WorkSpaceViewSet(BaseViewSet):
def create(self, request):
try:
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
[
{
"key": "DISABLE_WORKSPACE_CREATION",
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
},
}
]
)
+8 -33
View File
@@ -1,22 +1,12 @@
# Django imports
from django.db.models import (
Count,
Q,
OuterRef,
Subquery,
IntegerField,
)
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.db.models.functions import Coalesce
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
WorkspaceEntityPermission,
allow_permission,
ROLE,
)
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
# Module imports
from plane.app.serializers import (
@@ -26,12 +16,7 @@ from plane.app.serializers import (
WorkSpaceMemberSerializer,
)
from plane.app.views.base import BaseAPIView
from plane.db.models import (
Project,
ProjectMember,
WorkspaceMember,
DraftIssue,
)
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.utils.cache import invalidate_cache
from .. import BaseViewSet
@@ -119,9 +104,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
if requesting_workspace_member.role < workspace_member.role:
return Response(
{
"error": "You cannot remove a user having role higher than you"
},
{"error": "You cannot remove a user having role higher than you"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -148,9 +131,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
workspace_member.is_active = False
@@ -164,9 +145,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
multiple=True,
)
@invalidate_cache(path="/api/users/me/settings/")
@invalidate_cache(
path="api/users/me/workspaces/", user=False, multiple=True
)
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
)
@@ -213,9 +192,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=workspace_member.member_id,
is_active=True,
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False)
# # Deactivate the user
@@ -279,9 +256,7 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug, project_id__in=project_ids, is_active=True
).select_related("project", "member", "workspace")
project_members = ProjectMemberRoleSerializer(
project_members, many=True
).data
project_members = ProjectMemberRoleSerializer(project_members, many=True).data
project_members_dict = dict()
@@ -60,6 +60,9 @@ class EmailCheckEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
# Lower the email
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
@@ -60,6 +60,7 @@ class EmailCheckSpaceEndpoint(APIView):
)
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
email = str(email).lower().strip()
# Validate email
try:
validate_email(email)
+92 -24
View File
@@ -3,7 +3,8 @@ from django.utils import timezone
from django.apps import apps
from django.conf import settings
from django.db import models
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.fields.related import OneToOneRel
# Third party imports
from celery import shared_task
@@ -11,31 +12,98 @@ from celery import shared_task
@shared_task
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
"""
Soft delete related objects for a given model instance
"""
# Get the model class using app registry
model_class = apps.get_model(app_label, model_name)
instance = model_class.all_objects.get(pk=instance_pk)
related_fields = instance._meta.get_fields()
for field in related_fields:
if field.one_to_many or field.one_to_one:
try:
# Check if the field has CASCADE on delete
if (
not hasattr(field.remote_field, "on_delete")
or field.remote_field.on_delete == models.CASCADE
):
if field.one_to_many:
related_objects = getattr(instance, field.name).all()
elif field.one_to_one:
related_object = getattr(instance, field.name)
related_objects = (
[related_object] if related_object is not None else []
)
for obj in related_objects:
if obj:
obj.deleted_at = timezone.now()
obj.save(using=using)
except ObjectDoesNotExist:
pass
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
try:
instance = model_class.all_objects.get(pk=instance_pk)
except model_class.DoesNotExist:
return
# Get all related fields that are reverse relationships
all_related = [
f
for f in instance._meta.get_fields()
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
]
# Handle each related field
for relation in all_related:
related_name = relation.get_accessor_name()
# Skip if the relation doesn't exist
if not hasattr(instance, related_name):
continue
# Get the on_delete behavior name
on_delete_name = (
relation.on_delete.__name__
if hasattr(relation.on_delete, "__name__")
else ""
)
if on_delete_name == "DO_NOTHING":
continue
elif on_delete_name == "SET_NULL":
# Handle SET_NULL relationships
if isinstance(relation, OneToOneRel):
# For OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj and isinstance(related_obj, models.Model):
setattr(related_obj, relation.remote_field.name, None)
related_obj.save(update_fields=[relation.remote_field.name])
else:
# For other relationships
related_queryset = getattr(instance, related_name).all()
related_queryset.update(**{relation.remote_field.name: None})
else:
# Handle CASCADE and other delete behaviors
try:
if relation.one_to_one:
# Handle OneToOne relationships
related_obj = getattr(instance, related_name, None)
if related_obj:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
else:
# Handle other relationships
related_queryset = getattr(instance, related_name).all()
for related_obj in related_queryset:
if hasattr(related_obj, "deleted_at"):
if not related_obj.deleted_at:
related_obj.deleted_at = timezone.now()
related_obj.save()
# Recursively handle related objects
soft_delete_related_objects(
related_obj._meta.app_label,
related_obj._meta.model_name,
related_obj.pk,
using,
)
except Exception as e:
# Log the error or handle as needed
print(f"Error handling relation {related_name}: {str(e)}")
continue
# Finally, soft delete the instance itself if it hasn't been deleted yet
if hasattr(instance, "deleted_at") and not instance.deleted_at:
instance.deleted_at = timezone.now()
instance.save()
# @shared_task
+5 -13
View File
@@ -162,8 +162,7 @@ def generate_table_row(issue):
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
(
@@ -197,8 +196,7 @@ def generate_json_row(issue):
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"]
and issue["created_by__last_name"]
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
"Assignee": (
@@ -208,17 +206,11 @@ def generate_json_row(issue):
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(
issue["issue_cycle__cycle__start_date"]
),
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(
issue["issue_module__module__start_date"]
),
"Module Target Date": dateConverter(
issue["issue_module__module__target_date"]
),
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
+3 -1
View File
@@ -257,7 +257,9 @@ def notifications(
)
new_mentions = [
str(mention) for mention in new_mentions if mention in set(project_members)
str(mention)
for mention in new_mentions
if mention in set(project_members)
]
removed_mention = get_removed_mentions(
requested_instance=requested_data, current_instance=current_instance
@@ -13,28 +13,14 @@ from plane.db.models import (
class Command(BaseCommand):
help = "Add a member to a project. If present in the workspace"
def add_arguments(self, parser):
# Positional argument
parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
parser.add_argument(
"--project_id",
type=str,
nargs="?",
help="Project ID",
)
parser.add_argument(
"--user_email",
type=str,
nargs="?",
help="User Email",
)
parser.add_argument(
"--role",
type=int,
nargs="?",
help="Role of the user in the project",
"--role", type=int, nargs="?", help="Role of the user in the project"
)
def handle(self, *args: Any, **options: Any):
@@ -67,9 +53,7 @@ class Command(BaseCommand):
# Get the smallest sort order
smallest_sort_order = (
ProjectMember.objects.filter(
workspace_id=project.workspace_id,
)
ProjectMember.objects.filter(workspace_id=project.workspace_id)
.order_by("sort_order")
.first()
)
@@ -79,22 +63,15 @@ class Command(BaseCommand):
else:
sort_order = 65535
if ProjectMember.objects.filter(
project=project,
member=user,
).exists():
if ProjectMember.objects.filter(project=project, member=user).exists():
# Update the project member
ProjectMember.objects.filter(
project=project,
member=user,
).update(is_active=True, sort_order=sort_order, role=role)
ProjectMember.objects.filter(project=project, member=user).update(
is_active=True, sort_order=sort_order, role=role
)
else:
# Create the project member
ProjectMember.objects.create(
project=project,
member=user,
role=role,
sort_order=sort_order,
project=project, member=user, role=role, sort_order=sort_order
)
# Issue Property
@@ -102,9 +79,7 @@ class Command(BaseCommand):
# Success message
self.stdout.write(
self.style.SUCCESS(
f"User {user_email} added to project {project_id}"
)
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
)
return
except CommandError as e:
-10
View File
@@ -53,7 +53,6 @@ from .project import (
ProjectMemberInvite,
ProjectPublicMember,
)
from .deploy_board import DeployBoard
from .session import Session
from .social_connection import SocialLoginConnection
from .state import State
@@ -69,23 +68,14 @@ from .workspace import (
WorkspaceUserProperties,
)
from .importer import Importer
from .page import Page, PageLog, PageLabel
from .estimate import Estimate, EstimatePoint
from .intake import Intake, IntakeIssue
from .analytic import AnalyticView
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
from .exporter import ExporterHistory
from .webhook import Webhook, WebhookLog
from .dashboard import Dashboard, DashboardWidget, Widget
from .favorite import UserFavorite
+6 -26
View File
@@ -44,45 +44,25 @@ class FileAsset(BaseModel):
"db.User", on_delete=models.CASCADE, null=True, related_name="assets"
)
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
)
draft_issue = models.ForeignKey(
"db.DraftIssue",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets"
)
project = models.ForeignKey(
"db.Project",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.Project", on_delete=models.CASCADE, null=True, related_name="assets"
)
issue = models.ForeignKey(
"db.Issue", on_delete=models.CASCADE, null=True, related_name="assets"
)
comment = models.ForeignKey(
"db.IssueComment",
on_delete=models.CASCADE,
null=True,
related_name="assets",
"db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
)
entity_type = models.CharField(
max_length=255,
null=True,
blank=True,
)
entity_identifier = models.CharField(
max_length=255,
null=True,
blank=True,
)
entity_type = models.CharField(max_length=255, null=True, blank=True)
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
is_deleted = models.BooleanField(default=False)
is_archived = models.BooleanField(default=False)
external_id = models.CharField(max_length=255, null=True, blank=True)
+8 -31
View File
@@ -661,9 +661,7 @@ class IssueVote(ProjectBaseModel):
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
related_name="versions",
"db.Issue", on_delete=models.CASCADE, related_name="versions"
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
@@ -688,9 +686,7 @@ class IssueVersion(ProjectBaseModel):
)
start_date = models.DateField(null=True, blank=True)
target_date = models.DateField(null=True, blank=True)
sequence_id = models.IntegerField(
default=1, verbose_name="Issue Sequence ID"
)
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
@@ -700,25 +696,10 @@ class IssueVersion(ProjectBaseModel):
type = models.UUIDField(blank=True, null=True)
last_saved_at = models.DateTimeField(default=timezone.now)
owned_by = models.UUIDField()
assignees = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
labels = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
cycle = models.UUIDField(
null=True,
blank=True,
)
modules = ArrayField(
models.UUIDField(),
blank=True,
default=list,
)
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
labels = ArrayField(models.UUIDField(), blank=True, default=list)
cycle = models.UUIDField(null=True, blank=True)
modules = ArrayField(models.UUIDField(), blank=True, default=list)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
@@ -741,9 +722,7 @@ class IssueVersion(ProjectBaseModel):
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
cycle_issue = CycleIssue.objects.filter(
issue=issue,
).first()
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
cls.objects.create(
issue=issue,
@@ -771,9 +750,7 @@ class IssueVersion(ProjectBaseModel):
assignees=issue.assignees,
labels=issue.labels,
cycle=cycle_issue.cycle if cycle_issue else None,
modules=Module.objects.filter(issue=issue).values_list(
"id", flat=True
),
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
owned_by=user,
)
return True
+1 -3
View File
@@ -29,9 +29,7 @@ def validate_domain(value):
class Webhook(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_webhooks",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
)
url = models.URLField(
validators=[validate_schema, validate_domain], max_length=1024
+7 -25
View File
@@ -102,12 +102,7 @@ def get_default_display_properties():
def get_issue_props():
return {
"subscribed": True,
"assigned": True,
"created": True,
"all_issues": True,
}
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
def slug_validator(value):
@@ -136,9 +131,7 @@ class Workspace(BaseModel):
max_length=48, db_index=True, unique=True, validators=[slug_validator]
)
organization_size = models.CharField(max_length=20, blank=True, null=True)
timezone = models.CharField(
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
)
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
def __str__(self):
"""Return name of the Workspace"""
@@ -167,10 +160,7 @@ class WorkspaceBaseModel(BaseModel):
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
)
project = models.ForeignKey(
"db.Project",
models.CASCADE,
related_name="project_%(class)s",
null=True,
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
)
class Meta:
@@ -184,9 +174,7 @@ class WorkspaceBaseModel(BaseModel):
class WorkspaceMember(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
)
member = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -221,9 +209,7 @@ class WorkspaceMember(BaseModel):
class WorkspaceMemberInvite(BaseModel):
workspace = models.ForeignKey(
"db.Workspace",
on_delete=models.CASCADE,
related_name="workspace_member_invite",
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite"
)
email = models.CharField(max_length=255)
accepted = models.BooleanField(default=False)
@@ -283,9 +269,7 @@ class WorkspaceTheme(BaseModel):
)
name = models.CharField(max_length=300)
actor = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="themes",
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
)
colors = models.JSONField(default=dict)
@@ -320,9 +304,7 @@ class WorkspaceUserProperties(BaseModel):
)
filters = models.JSONField(default=get_default_filters)
display_filters = models.JSONField(default=get_default_display_filters)
display_properties = models.JSONField(
default=get_default_display_properties
)
display_properties = models.JSONField(default=get_default_display_properties)
class Meta:
unique_together = ["workspace", "user", "deleted_at"]
@@ -2,4 +2,4 @@ from .instance import InstanceSerializer
from .configuration import InstanceConfigurationSerializer
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
from .workspace import WorkspaceSerializer
from .workspace import WorkspaceSerializer
@@ -1,6 +1,8 @@
from .base import BaseSerializer
from plane.db.models import User
class UserLiteSerializer(BaseSerializer):
class Meta:
model = User
fields = ["id", "email", "first_name", "last_name",]
fields = ["id", "email", "first_name", "last_name"]
@@ -13,6 +13,8 @@ from .admin import (
InstanceAdminUserSessionEndpoint,
)
from .changelog import ChangeLogEndpoint
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
from .workspace import (
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
@@ -1,33 +0,0 @@
# Python imports
import requests
# Django imports
from django.conf import settings
# Third party imports
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import AllowAny
# plane imports
from .base import BaseAPIView
class ChangeLogEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def fetch_change_logs(self):
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
response.raise_for_status()
return response.json()
def get(self, request):
# Fetch the changelog
if settings.INSTANCE_CHANGELOG_URL:
data = self.fetch_change_logs()
return Response(data, status=status.HTTP_200_OK)
else:
return Response(
{"error": "could not fetch changelog please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)
@@ -43,19 +43,19 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
member_count = (
WorkspaceMember.objects.filter(
workspace=OuterRef("id"), member__is_bot=False, is_active=True
).select_related("owner")
)
.select_related("owner")
.order_by()
.annotate(count=Func(F("id"), function="Count"))
.values("count")
)
workspaces = Workspace.objects.annotate(
total_projects=project_count,
total_members=member_count,
total_projects=project_count, total_members=member_count
)
# Add search functionality
@@ -66,16 +66,14 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
return self.paginate(
request=request,
queryset=workspaces,
on_results=lambda results: WorkspaceSerializer(
results, many=True,
).data,
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
max_per_page=10,
default_per_page=10,
)
def post(self, request):
try:
serializer = WorkspaceSerializer (data=request.data)
serializer = WorkspaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
+1 -7
View File
@@ -11,14 +11,12 @@ from plane.license.api.views import (
InstanceAdminUserMeEndpoint,
InstanceAdminSignOutEndpoint,
InstanceAdminUserSessionEndpoint,
ChangeLogEndpoint,
InstanceWorkSpaceAvailabilityCheckEndpoint,
InstanceWorkSpaceEndpoint,
)
urlpatterns = [
path("", InstanceEndpoint.as_view(), name="instance"),
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
path(
@@ -62,9 +60,5 @@ urlpatterns = [
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
name="instance-workspace-availability",
),
path(
"workspaces/",
InstanceWorkSpaceEndpoint.as_view(),
name="instance-workspace",
),
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
]
+6
View File
@@ -10,9 +10,15 @@ from plane.space.views import (
ProjectStatesEndpoint,
ProjectLabelsEndpoint,
ProjectMembersEndpoint,
ProjectMetaDataEndpoint,
)
urlpatterns = [
path(
"anchor/<str:anchor>/meta/",
ProjectMetaDataEndpoint.as_view(),
name="project-meta",
),
path(
"anchor/<str:anchor>/settings/",
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
+2
View File
@@ -25,3 +25,5 @@ from .state import ProjectStatesEndpoint
from .label import ProjectLabelsEndpoint
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
from .meta import ProjectMetaDataEndpoint
+34
View File
@@ -0,0 +1,34 @@
# third party
from rest_framework.permissions import AllowAny
from rest_framework import status
from rest_framework.response import Response
from plane.db.models import DeployBoard, Project
from .base import BaseAPIView
from plane.space.serializer.project import ProjectLiteSerializer
class ProjectMetaDataEndpoint(BaseAPIView):
permission_classes = [AllowAny]
def get(self, request, anchor):
try:
deploy_board = DeployBoard.objects.filter(
anchor=anchor, entity_name="project"
).first()
except DeployBoard.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
try:
project_id = deploy_board.entity_identifier
project = Project.objects.get(id=project_id)
except Project.DoesNotExist:
return Response(
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
)
serializer = ProjectLiteSerializer(project)
return Response(serializer.data, status=status.HTTP_200_OK)
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.16
Django==4.2.17
# rest framework
djangorestframework==3.15.2
# postgres
+13 -13
View File
@@ -6,31 +6,31 @@
"private": true,
"type": "module",
"scripts": {
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint . --ext .ts,.tsx",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.13.7",
"@hocuspocus/extension-logger": "^2.13.7",
"@hocuspocus/extension-redis": "^2.13.7",
"@hocuspocus/server": "^2.13.7",
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "^2.9.1",
"@tiptap/html": "^2.9.1",
"@tiptap/core": "^2.4.0",
"@tiptap/html": "^2.3.0",
"axios": "^1.7.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"helmet": "^7.1.0",
"ioredis": "^5.4.1",
@@ -39,14 +39,14 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.12",
"y-prosemirror": "^1.2.9",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.14"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.9",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
+10 -18
View File
@@ -5,9 +5,8 @@ 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 { logger } from "@/core/helpers/logger.js";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
@@ -28,7 +27,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
new Logger({
onChange: false,
log: (message) => {
logger.info(message);
manualLogger.info(message);
},
}),
new Database({
@@ -41,7 +40,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, reject) => {
return new Promise(async (resolve) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
@@ -58,16 +57,9 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
params,
});
}
if (!fetchedData) {
reject("Data is null");
return;
}
resolve(fetchedData);
} catch (error) {
logger.error("Error in fetching document", error);
reject("Error in fetching document" + JSON.stringify(error));
manualLogger.error("Error in fetching document", error);
}
});
},
@@ -100,7 +92,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
});
}
} catch (error) {
logger.error("Error in updating document:", error);
manualLogger.error("Error in updating document:", error);
}
});
},
@@ -122,7 +114,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
) {
redisClient.disconnect();
}
logger.warn(
manualLogger.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,
);
@@ -131,18 +123,18 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
logger.info("Redis Client connected ✅");
manualLogger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
logger.warn(
manualLogger.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 {
logger.warn(
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { ErrorRequestHandler } from "express";
import { logger } from "@/core/helpers/logger.js";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
logger.error(err);
manualLogger.error(err);
// Set the response status
res.status(err.status || 500);
+2 -2
View File
@@ -18,7 +18,7 @@ const hooks = {
},
};
export const coreLogger = pinoHttp({
export const logger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
@@ -35,4 +35,4 @@ export const coreLogger = pinoHttp({
},
});
export const logger = coreLogger.logger;
export const manualLogger = logger.logger;
+1 -1
View File
@@ -15,7 +15,7 @@ const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
...DocumentEditorExtensionsWithoutProps,
];
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array,
+2 -2
View File
@@ -1,7 +1,7 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { logger } from "@/core/helpers/logger.js";
import { manualLogger } 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) {
logger.error("Failed to fetch current user:", error);
manualLogger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== userId) {
+37 -10
View File
@@ -5,7 +5,7 @@ import {
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service.js";
import { logger } from "../helpers/logger.js";
import { manualLogger } from "../helpers/logger.js";
const pageService = new PageService();
export const updatePageDescription = async (
@@ -26,26 +26,51 @@ export const updatePageDescription = async (
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
// Generate a unique boundary
const boundary = `----FormBoundary${Date.now().toString()}`;
// Construct the multipart form data manually
let formData = "";
// Add binary content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_binary"\r\n';
formData += "Content-Type: application/octet-stream\r\n\r\n";
formData += updatedDescription + "\r\n";
// Add HTML content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_html"\r\n';
formData += "Content-Type: text/html\r\n\r\n";
formData += contentHTML + "\r\n";
// Add JSON content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description"\r\n';
formData += "Content-Type: application/json\r\n\r\n";
formData += JSON.stringify(contentJSON) + "\r\n";
// End boundary
formData += `--${boundary}--\r\n`;
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
formData,
boundary,
cookie,
);
} catch (error) {
logger.error("Update error:", error);
manualLogger.error("Update error:", error);
throw error;
}
};
// Update the service method
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
@@ -66,7 +91,7 @@ const fetchDescriptionHTMLAndTransform = async (
);
return contentBinary;
} catch (error) {
logger.error(
manualLogger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
@@ -90,7 +115,9 @@ export const fetchPageDescriptionBinary = async (
pageId,
cookie,
);
console.log("response", response);
const binaryData = new Uint8Array(response);
console.log("binaryData", binaryData);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(
@@ -106,7 +133,7 @@ export const fetchPageDescriptionBinary = async (
return binaryData;
} catch (error) {
logger.error("Fetch error:", error);
manualLogger.error("Fetch error:", error);
throw error;
}
};
+10 -12
View File
@@ -12,7 +12,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<TPage> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
@@ -20,7 +20,7 @@ export class PageService extends APIService {
headers: {
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -32,7 +32,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
@@ -42,7 +42,7 @@ export class PageService extends APIService {
Cookie: cookie,
},
responseType: "arraybuffer",
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -54,21 +54,19 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
formData: string,
boundary: string,
cookie: string,
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
data,
formData,
{
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {
+16 -17
View File
@@ -13,10 +13,7 @@ import cors from "cors";
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import {
coreLogger as LoggerMiddleware,
logger,
} from "@/core/helpers/logger.js";
import { logger, manualLogger } from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
const app = express();
@@ -36,7 +33,7 @@ app.use(
);
// Logging middleware
app.use(LoggerMiddleware);
app.use(logger);
// Body parsing middleware
app.use(express.json());
@@ -48,7 +45,7 @@ app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
logger.error("Failed to initialize HocusPocusServer:", err);
manualLogger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
@@ -60,7 +57,7 @@ router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
logger.error("WebSocket connection error:", err);
manualLogger.error("WebSocket connection error:", err);
ws.close();
}
});
@@ -76,44 +73,46 @@ Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
logger.info(`Plane Live server has started at port ${app.get("port")}`);
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
logger.info("Starting graceful shutdown...");
manualLogger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
logger.info("HocusPocus server WebSocket connections closed gracefully.");
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
// Close the Express server
liveServer.close(() => {
logger.info("Express server closed gracefully.");
manualLogger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
logger.error("Error during shutdown:", err);
manualLogger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
logger.error("Forcing shutdown...");
manualLogger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
logger.info("Unhandled Rejection: ", err);
logger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
logger.info("Uncaught Exception: ", err);
logger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
+3
View File
@@ -13,3 +13,6 @@ export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}/`);
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
// plane website url
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
+22 -22
View File
@@ -27,6 +27,7 @@
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -36,25 +37,25 @@
"dependencies": {
"@floating-ui/react": "^0.26.4",
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@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-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-style": "^2.9.1",
"@tiptap/extension-underline": "^2.9.1",
"@tiptap/extension-text-align": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.9.1",
"@tiptap/starter-kit": "^2.9.1",
"@tiptap/suggestion": "^2.9.1",
"@plane/utils": "*",
"@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-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",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"highlight.js": "^11.8.0",
@@ -63,17 +64,16 @@
"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",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"tiptap-markdown": "^0.8.9",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.12",
"y-prosemirror": "^1.2.5",
"y-protocols": "^1.0.6",
"yjs": "^13.6.20"
"yjs": "^13.6.15"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
@@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
embedHandler,
fileHandler,
@@ -43,23 +44,25 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
onTransaction,
disabledExtensions,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const { editor, hasServerConnectionFailed, hasServerSynced, localProvider, hasIndexedDbSynced } =
useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
@@ -67,9 +70,30 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
});
const [hasIndexedDbEntry, setHasIndexedDbEntry] = useState(null);
useEffect(() => {
async function documentIndexedDbEntry(dbName: string) {
try {
const databases = await indexedDB.databases();
const hasEntry = databases.some((db) => db.name === dbName);
setHasIndexedDbEntry(hasEntry);
} catch (error) {
console.error("Error checking database existence:", error);
return false;
}
}
documentIndexedDbEntry(id);
}, [id, localProvider]);
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
// Wait until we know about IndexedDB status
if (hasIndexedDbEntry === null) return null;
if (hasServerConnectionFailed || (!hasIndexedDbEntry && !hasServerSynced) || !hasIndexedDbSynced) {
return <DocumentContentLoader />;
}
return (
<PageRenderer
@@ -129,6 +129,7 @@ export const PageRenderer = (props: IPageRenderer) => {
[editor, cleanup]
);
console.log("rendered");
return (
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
@@ -139,12 +140,12 @@ export const PageRenderer = (props: IPageRenderer) => {
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<>
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
{/* {editor.isEditable && ( */}
{/* <> */}
{/* <BlockMenu editor={editor} /> */}
{/* <AIFeaturesMenu menu={aiHandler?.menu} /> */}
{/* </> */}
{/* )} */}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
@@ -21,6 +21,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
extensions,
id,
@@ -38,6 +39,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
} = props;
const editor = useEditor({
editable,
disabledExtensions,
editorClassName,
enableHistory: true,
@@ -35,10 +35,6 @@ import {
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatTaskList,
toggleFlatToggleList,
toggleHeadingFive,
toggleHeadingFour,
toggleHeadingOne,
@@ -157,35 +153,27 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("list", { type: "bullet" }),
command: () => toggleFlatBulletList(editor),
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("list", { type: "ordered" }),
command: () => toggleFlatOrderedList(editor),
icon: ListIcon,
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor?.isActive("list", { type: "task" }),
command: () => toggleFlatTaskList(editor),
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
export const ToggleListItem = (editor: Editor): EditorMenuItem<"toggle-list"> => ({
key: "toggle-list",
name: "Toggle list",
isActive: () => editor?.isActive("list", { type: "toggle" }),
command: () => toggleFlatToggleList(editor),
icon: ListIcon,
});
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
@@ -277,7 +265,6 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
HorizontalRuleItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
ToggleListItem(editor),
TextAlignItem(editor),
];
};
@@ -1,5 +1,5 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/helpers";
import { convertHexEmojiToDecimal } from "@plane/utils";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
@@ -1,5 +1,5 @@
// plane helpers
import { sanitizeHTML } from "@plane/helpers";
import { sanitizeHTML } from "@plane/utils";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
@@ -1,5 +1,5 @@
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export interface CodeBlockOptions {
/**
@@ -150,7 +150,6 @@ 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;
}
@@ -184,6 +183,8 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
try {
if (!this.options.exitOnArrowDown) {
@@ -204,12 +205,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
// 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);
const after = $from.after();
if (after === undefined) {
return false;
@@ -218,15 +214,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
return false;
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
console.error("Error handling ArrowDown in code block:", error);
return false;
}
},
@@ -91,12 +91,7 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
return false;
}
// 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);
const after = $from.after();
if (after === undefined) {
return false;
@@ -19,7 +19,6 @@ 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";
// plane editor extensions
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
@@ -44,6 +43,16 @@ export const CoreEditorExtensionsWithoutProps = [
codeBlock: false,
horizontalRule: false,
blockquote: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: false,
}),
CustomQuoteExtension,
@@ -92,7 +101,6 @@ export const CoreEditorExtensionsWithoutProps = [
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
FlatListExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
@@ -127,24 +127,30 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
return "Uploading...";
}
if (draggedInside) {
if (draggedInside && editor.isEditable) {
return "Drop image here";
}
return "Add an image";
if (!editor.isEditable) {
return "Viewing Mode: Image Upload Disabled";
} else {
return "Add an image";
}
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 bg-custom-background-90 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
{
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
"hover:text-custom-text-200 hover:bg-custom-background-80 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside && editor.isEditable,
"text-custom-primary-200 bg-custom-primary-100/10 border-custom-primary-200/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200":
selected && editor.isEditable,
"text-red-500 cursor-default": failedToLoadImage,
"hover:text-red-500": failedToLoadImage && editor.isEditable,
"bg-red-500/10": failedToLoadImage && selected,
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable,
}
)}
onDrop={onDrop}
@@ -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;
},
};
},
});
@@ -1,234 +0,0 @@
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)];
},
});
+54 -45
View File
@@ -2,58 +2,67 @@ import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export const DropHandlerExtension = () =>
Extension.create({
name: "dropHandler",
priority: 1000,
export const DropHandlerExtension = Extension.create({
name: "dropHandler",
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (
editor.isEditable &&
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (
editor.isEditable &&
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
}
return false;
},
}
return false;
},
}),
];
},
});
},
}),
];
},
});
export const insertImagesSafely = async ({
editor,
files,
@@ -1,11 +1,11 @@
import { Extensions } from "@tiptap/core";
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";
import BulletList from "@tiptap/extension-bullet-list";
import OrderedList from "@tiptap/extension-ordered-list";
import { Markdown } from "tiptap-markdown";
// extensions
import {
@@ -24,6 +24,7 @@ import {
CustomTypographyExtension,
DropHandlerExtension,
ImageExtension,
ListKeymap,
Table,
TableCell,
TableHeader,
@@ -33,14 +34,8 @@ import {
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
import { FlatListExtension } from "./flat-list/flat-list";
import { multipleSelectionExtension } from "./selections/multipleSelections";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import ListItem from "@tiptap/extension-list-item";
type TArguments = {
disabledExtensions: TExtensions[];
@@ -52,63 +47,57 @@ type TArguments = {
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable?: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
return [
StarterKit.configure({
bulletList: false,
orderedList: false,
listItem: false,
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",
},
},
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
// dropcursor: false,
paragraph: {
HTMLAttributes: {
class: "editor-paragraph-block",
},
},
heading: {
HTMLAttributes: {
class: "editor-heading-block",
},
},
dropcursor: {
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
BulletList.extend({
addInputRules() {
return [];
},
addKeyboardShortcuts() {
return {};
},
}).configure({
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
}),
OrderedList.extend({
addInputRules() {
return [];
},
addKeyboardShortcuts() {
return {};
},
}).configure({
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
}),
ListItem.configure({
HTMLAttributes: {
class: "not-prose space-y-2",
},
}),
CustomQuoteExtension,
DropHandlerExtension(),
DropHandlerExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "py-4 border-custom-border-400",
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
@@ -157,9 +146,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
TableCell,
TableRow,
CustomMention({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
readonly: !editable,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {
@@ -188,9 +177,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
FlatListExtension,
multipleSelectionExtension,
// FlatHeadingListExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
@@ -1,575 +0,0 @@
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)
})
})
@@ -1,223 +0,0 @@
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;
}
@@ -1,19 +0,0 @@
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,
)
@@ -1,787 +0,0 @@
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)
})
})
@@ -1,153 +0,0 @@
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;
}
@@ -1,56 +0,0 @@
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;
}
@@ -1,76 +0,0 @@
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)
}
}
@@ -1,36 +0,0 @@
/* 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 }
@@ -1,208 +0,0 @@
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')),
),
),
)
})
})
})
@@ -1,91 +0,0 @@
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(),
}
@@ -1,84 +0,0 @@
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>
`,
)
})
})
@@ -1,86 +0,0 @@
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
}
}
}
@@ -1,41 +0,0 @@
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'),
),
),
)
})
})
@@ -1,43 +0,0 @@
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
}
@@ -1,154 +0,0 @@
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'),
),
),
)
})
})
@@ -1,70 +0,0 @@
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)
@@ -1,516 +0,0 @@
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
`)
})
})
@@ -1,179 +0,0 @@
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);
}
@@ -1,63 +0,0 @@
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')))),
)
})
})
@@ -1,60 +0,0 @@
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);
}
@@ -1,46 +0,0 @@
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)
})
})
@@ -1,26 +0,0 @@
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);
}
@@ -1,77 +0,0 @@
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)
})
})
@@ -1,86 +0,0 @@
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;
}
@@ -1,163 +0,0 @@
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)
})
})
@@ -1,89 +0,0 @@
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;
}
@@ -1,153 +0,0 @@
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];
},
});
@@ -1,112 +0,0 @@
import { Node } from "@tiptap/core";
import {
createListSpec,
createListPlugins,
listKeymap,
listInputRules,
ListAttributes,
createWrapInListCommand,
DedentListOptions,
IndentListOptions,
createIndentListCommand,
createDedentListCommand,
} 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);
return ans;
}
return false;
},
};
},
addProseMirrorPlugins() {
return [...createListPlugins({ schema: this.editor.schema }), listKeymapPlugin, listInputRulePlugin];
},
});
@@ -1,83 +0,0 @@
import type { ListAttributes, ListKind, ProsemirrorNodeJSON } from "prosemirror-flat-list";
function migrateNodes(nodes: ProsemirrorNodeJSON[]): [ProsemirrorNodeJSON[], boolean] {
const content: ProsemirrorNodeJSON[] = [];
let updated = false;
for (const node of nodes) {
console.log("node", node.type);
if (node.type === "bullet_list" || node.type === "bulletList") {
updated = true;
for (const child of node.content ?? []) {
const [migratedChild, childUpdated] = migrateNode(child, {
kind: "bullet",
});
content.push(migratedChild);
updated = updated || childUpdated;
}
} else if (node.type === "ordered_list" || node.type === "orderedList") {
updated = true;
for (const child of node.content ?? []) {
const [migratedChild, childUpdated] = migrateNode(child, {
kind: "ordered",
});
content.push(migratedChild);
updated = updated || childUpdated;
}
} else if (node.type === "task_list" || node.type === "taskList") {
updated = true;
for (const child of node.content ?? []) {
const [migratedChild, childUpdated] = migrateNode(child, {
kind: "task",
});
content.push(migratedChild);
updated = updated || childUpdated;
}
} else {
// Handle other node types, including those that may contain list items
const [migratedContent, contentUpdated] = migrateNodes(node.content ?? []);
content.push({ ...node, content: migratedContent });
updated = updated || contentUpdated;
}
}
return [content, updated];
}
function migrateNode(node: ProsemirrorNodeJSON, { kind }: { kind?: ListKind } = {}): [ProsemirrorNodeJSON, boolean] {
// Check if the node is a list item
if (node.type === "list_item" || node.type === "listItem" || node.type === "taskItem") {
const [content, updated] = migrateNodes(node.content ?? []);
return [
{
...node,
type: "list",
attrs: {
collapsed: Boolean(node.attrs?.closed),
...node.attrs,
kind: kind ?? "bullet",
} satisfies ListAttributes,
content,
},
true,
];
} else if (node.content) {
// If the node has content, we need to check for nested list items
const [content, updated] = migrateNodes(node.content);
return [{ ...node, content }, updated];
} else {
return [node, false];
}
}
/**
* Migrate a ProseMirror document JSON object from the old list structure to the
* new. A new document JSON object is returned if the document is updated,
* otherwise `null` is returned.
*
* @public
*/
export function migrateDocJSON(docJSON: ProsemirrorNodeJSON): ProsemirrorNodeJSON | null {
const [migrated, updated] = migrateNode(docJSON);
return updated ? migrated : null;
}
@@ -1,175 +0,0 @@
// 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>
`;
@@ -1,19 +0,0 @@
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
}
@@ -1,17 +0,0 @@
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
}
@@ -1,50 +0,0 @@
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'),
),
),
),
)
})
})
@@ -1,104 +0,0 @@
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);
@@ -1,51 +0,0 @@
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)
})
})
@@ -1,32 +0,0 @@
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
}
@@ -1,12 +0,0 @@
// 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)
@@ -1,15 +0,0 @@
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;
}
@@ -1,10 +0,0 @@
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)
}
@@ -1,24 +0,0 @@
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];
}
@@ -1,38 +0,0 @@
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)
})
})
@@ -1,17 +0,0 @@
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;
}

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