mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
112 Commits
feat-bread
...
feat/flat-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1b174e31b | ||
|
|
81e2906cc8 | ||
|
|
072f2e2cac | ||
|
|
79c2dfd293 | ||
|
|
22a9d48ca3 | ||
|
|
fbcc8fc8a0 | ||
|
|
c1fa372c84 | ||
|
|
917e164d79 | ||
|
|
4f13d0a503 | ||
|
|
594fa82826 | ||
|
|
7045a1f2af | ||
|
|
f26b4d3d06 | ||
|
|
c3c1aef7a9 | ||
|
|
24e57009af | ||
|
|
2b7a17b484 | ||
|
|
64fd0b2830 | ||
|
|
8988cf9a85 | ||
|
|
eb5ffebcc6 | ||
|
|
414010688d | ||
|
|
171099667e | ||
|
|
d65f0e264e | ||
|
|
c7d17d00b7 | ||
|
|
b52ae00bb9 | ||
|
|
ac0c0717db | ||
|
|
2983be8049 | ||
|
|
ae6cad2ac3 | ||
|
|
17b910aebd | ||
|
|
f1db64827f | ||
|
|
8aa5cbccf3 | ||
|
|
cd146aa608 | ||
|
|
366ed387a3 | ||
|
|
44bd3c6c92 | ||
|
|
35376d4a61 | ||
|
|
bdce78e15c | ||
|
|
d064517462 | ||
|
|
1bdc1e845a | ||
|
|
0eac8a0556 | ||
|
|
291898a63e | ||
|
|
9112b4d381 | ||
|
|
c8ee874a59 | ||
|
|
f8ec83206f | ||
|
|
44296c4ff2 | ||
|
|
c785aac533 | ||
|
|
a7489eb4a3 | ||
|
|
6471ca6ec8 | ||
|
|
e5a5dc4117 | ||
|
|
cf41100003 | ||
|
|
157ba6c34f | ||
|
|
e6b9d155b1 | ||
|
|
d2a5faf54b | ||
|
|
b820a6724e | ||
|
|
1de79f1185 | ||
|
|
ce19996d4f | ||
|
|
c02b5e17ef | ||
|
|
fc6d99d22d | ||
|
|
f949a856ad | ||
|
|
27ba1b9035 | ||
|
|
34a18e74b0 | ||
|
|
2dea7e8feb | ||
|
|
81e1fb6b0b | ||
|
|
ec4f4e229b | ||
|
|
324e41b26d | ||
|
|
cdbce9fbbb | ||
|
|
feab2b218d | ||
|
|
4acc5989dc | ||
|
|
a1b91a58a1 | ||
|
|
5fafdddb1e | ||
|
|
fbf299fbc3 | ||
|
|
4e2a9668a5 | ||
|
|
e934300662 | ||
|
|
133091b580 | ||
|
|
5e2182343e | ||
|
|
9f6daaf0c7 | ||
|
|
e5c4614f58 | ||
|
|
9bca7316f8 | ||
|
|
c48247ecde | ||
|
|
8487bb348d | ||
|
|
61ded9611d | ||
|
|
e094b494f6 | ||
|
|
151dc428e6 | ||
|
|
97f30288e1 | ||
|
|
7536a7886a | ||
|
|
26f344220e | ||
|
|
ab02542691 | ||
|
|
f8d884809c | ||
|
|
278a8141f2 | ||
|
|
81796afad9 | ||
|
|
e2af5b40d4 | ||
|
|
d90bbcd9d0 | ||
|
|
f5e28ddb30 | ||
|
|
5fab502a98 | ||
|
|
bace1a07cf | ||
|
|
c5fde5f5a2 | ||
|
|
c0e40bcbde | ||
|
|
841d6ebe52 | ||
|
|
723ee1d598 | ||
|
|
8d8df45b90 | ||
|
|
0cdee27066 | ||
|
|
f66cb7cbf9 | ||
|
|
893fe6cc93 | ||
|
|
d5a5a247ba | ||
|
|
a530f642ab | ||
|
|
e80d308501 | ||
|
|
c89f4b56d2 | ||
|
|
62d778f5ae | ||
|
|
ecfc30696a | ||
|
|
7147cfd70e | ||
|
|
fc2b3aa443 | ||
|
|
649e43edf0 | ||
|
|
89d2f38127 | ||
|
|
76f0c8b6c1 | ||
|
|
8a12eba836 |
@@ -67,9 +67,8 @@ export const InstanceHeader: FC = observer(() => {
|
||||
{breadcrumbItems.length >= 0 && (
|
||||
<div>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
type="text"
|
||||
link={
|
||||
<Breadcrumbs.Item
|
||||
component={
|
||||
<BreadcrumbLink
|
||||
href="/general/"
|
||||
label="Settings"
|
||||
@@ -80,10 +79,9 @@ export const InstanceHeader: FC = observer(() => {
|
||||
{breadcrumbItems.map(
|
||||
(item) =>
|
||||
item.title && (
|
||||
<Breadcrumbs.BreadcrumbItem
|
||||
<Breadcrumbs.Item
|
||||
key={item.title}
|
||||
type="text"
|
||||
link={<BreadcrumbLink href={item.href} label={item.title} />}
|
||||
component={<BreadcrumbLink href={item.href} label={item.title} />}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
|
||||
urlpatterns = [
|
||||
# API Tokens
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/",
|
||||
"users/api-tokens/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
|
||||
"users/api-tokens/<uuid:pk>/",
|
||||
ApiTokenEndpoint.as_view(),
|
||||
name="api-tokens",
|
||||
name="api-tokens-details",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/service-api-tokens/",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Python import
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
|
||||
# Third party
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.request import Request
|
||||
from rest_framework import status
|
||||
|
||||
# Module import
|
||||
@@ -13,12 +15,9 @@ from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class ApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
def post(self, request: Request) -> Response:
|
||||
label = request.data.get("label", str(uuid4().hex))
|
||||
description = request.data.get("description", "")
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
expired_at = request.data.get("expired_at", None)
|
||||
|
||||
# Check the user type
|
||||
@@ -28,7 +27,6 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
label=label,
|
||||
description=description,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
user_type=user_type,
|
||||
expired_at=expired_at,
|
||||
)
|
||||
@@ -37,29 +35,23 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
# Token will be only visible while creating
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
def get(self, request, slug, pk=None):
|
||||
def get(self, request: Request, pk: Optional[str] = None) -> Response:
|
||||
if pk is None:
|
||||
api_tokens = APIToken.objects.filter(
|
||||
user=request.user, workspace__slug=slug, is_service=False
|
||||
)
|
||||
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
|
||||
serializer = APITokenReadSerializer(api_tokens, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
api_tokens = APIToken.objects.get(
|
||||
user=request.user, workspace__slug=slug, pk=pk
|
||||
)
|
||||
api_tokens = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenReadSerializer(api_tokens)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
def delete(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(
|
||||
workspace__slug=slug, user=request.user, pk=pk, is_service=False
|
||||
)
|
||||
def delete(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
|
||||
api_token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def patch(self, request, slug, pk):
|
||||
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
|
||||
def patch(self, request: Request, pk: str) -> Response:
|
||||
api_token = APIToken.objects.get(user=request.user, pk=pk)
|
||||
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
@@ -70,7 +62,7 @@ class ApiTokenEndpoint(BaseAPIView):
|
||||
class ServiceApiTokenEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceEntityPermission]
|
||||
|
||||
def post(self, request, slug):
|
||||
def post(self, request: Request, slug: str) -> Response:
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
|
||||
api_token = APIToken.objects.filter(
|
||||
|
||||
@@ -944,9 +944,33 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id):
|
||||
filters = issue_filters(request.query_params, "GET")
|
||||
|
||||
# check for the project member role, if the role is 5 then check for the guest_view_all_features
|
||||
# if it is true then show all the issues else show only the issues created by the user
|
||||
project_member_subquery = ProjectMember.objects.filter(
|
||||
project_id=OuterRef("project_id"),
|
||||
member=self.request.user,
|
||||
is_active=True,
|
||||
).filter(
|
||||
Q(role__gt=ROLE.GUEST.value)
|
||||
| Q(
|
||||
role=ROLE.GUEST.value, project__guest_view_all_features=True
|
||||
)
|
||||
)
|
||||
|
||||
# Main issue query
|
||||
issue = (
|
||||
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.filter(
|
||||
Q(Exists(project_member_subquery))
|
||||
| Q(
|
||||
project__project_projectmember__member=self.request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
project__project_projectmember__role=ROLE.GUEST.value,
|
||||
project__guest_view_all_features=False,
|
||||
created_by=self.request.user,
|
||||
)
|
||||
)
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
@@ -1014,6 +1038,7 @@ class IssueDetailEndpoint(BaseAPIView):
|
||||
.values("count")
|
||||
)
|
||||
)
|
||||
|
||||
issue = issue.filter(**filters)
|
||||
order_by_param = request.GET.get("order_by", "-created_at")
|
||||
# Issue queryset
|
||||
|
||||
@@ -27,7 +27,7 @@ def user_data():
|
||||
"email": "test@plane.so",
|
||||
"password": "test-password",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
"last_name": "User",
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def create_user(db, user_data):
|
||||
user = User.objects.create(
|
||||
email=user_data["email"],
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"]
|
||||
last_name=user_data["last_name"],
|
||||
)
|
||||
user.set_password(user_data["password"])
|
||||
user.save()
|
||||
@@ -69,10 +69,52 @@ def session_client(api_client, create_user):
|
||||
return api_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_bot_user(db):
|
||||
"""Create and return a bot user instance"""
|
||||
from uuid import uuid4
|
||||
|
||||
unique_id = uuid4().hex[:8]
|
||||
user = User.objects.create(
|
||||
email=f"bot-{unique_id}@plane.so",
|
||||
username=f"bot_user_{unique_id}",
|
||||
first_name="Bot",
|
||||
last_name="User",
|
||||
is_bot=True,
|
||||
)
|
||||
user.set_password("bot@123")
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_token_data():
|
||||
"""Return sample API token data for testing"""
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
|
||||
return {
|
||||
"label": "Test API Token",
|
||||
"description": "Test description for API token",
|
||||
"expired_at": (timezone.now() + timedelta(days=30)).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_api_token_for_user(db, create_user):
|
||||
"""Create and return an API token for a specific user"""
|
||||
return APIToken.objects.create(
|
||||
label="Test Token",
|
||||
description="Test token description",
|
||||
user=create_user,
|
||||
user_type=0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plane_server(live_server):
|
||||
"""
|
||||
Renamed version of live_server fixture to avoid name clashes.
|
||||
Returns a live Django server for testing HTTP requests.
|
||||
"""
|
||||
return live_server
|
||||
return live_server
|
||||
|
||||
372
apiserver/plane/tests/contract/app/test_api_token.py
Normal file
372
apiserver/plane/tests/contract/app/test_api_token.py
Normal file
@@ -0,0 +1,372 @@
|
||||
import pytest
|
||||
from datetime import timedelta
|
||||
from uuid import uuid4
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
|
||||
from plane.db.models import APIToken, User
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestApiTokenEndpoint:
|
||||
"""Test cases for ApiTokenEndpoint"""
|
||||
|
||||
# POST /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_success(
|
||||
self, session_client, create_user, api_token_data
|
||||
):
|
||||
"""Test successful API token creation"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert response.data["label"] == api_token_data["label"]
|
||||
assert response.data["description"] == api_token_data["description"]
|
||||
assert response.data["user_type"] == 0 # Human user
|
||||
|
||||
# Verify token was created in database
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.user == create_user
|
||||
assert token.label == api_token_data["label"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_for_bot_user(
|
||||
self, session_client, create_bot_user, api_token_data
|
||||
):
|
||||
"""Test API token creation for bot user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_bot_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data["user_type"] == 1 # Bot user
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_minimal_data(self, session_client, create_user):
|
||||
"""Test API token creation with minimal data"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, {}, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert "token" in response.data
|
||||
assert len(response.data["label"]) == 32 # UUID hex length
|
||||
assert response.data["description"] == ""
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_with_expiry(self, session_client, create_user):
|
||||
"""Test API token creation with expiry date"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
future_date = timezone.now() + timedelta(days=30)
|
||||
data = {"label": "Expiring Token", "expired_at": future_date.isoformat()}
|
||||
|
||||
# Act
|
||||
response = session_client.post(url, data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
# Verify expiry date was set
|
||||
token = APIToken.objects.get(pk=response.data["id"])
|
||||
assert token.expired_at is not None
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_api_token_unauthenticated(self, api_client, api_token_data):
|
||||
"""Test API token creation without authentication"""
|
||||
# Arrange
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = api_client.post(url, api_token_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
|
||||
# GET /user/api-tokens/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_all_api_tokens(self, session_client, create_user):
|
||||
"""Test retrieving all API tokens for user"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
|
||||
# Create multiple tokens
|
||||
APIToken.objects.create(label="Token 1", user=create_user, user_type=0)
|
||||
APIToken.objects.create(label="Token 2", user=create_user, user_type=0)
|
||||
# Create a service token (should be excluded)
|
||||
APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.data) == 2 # Only non-service tokens
|
||||
assert all(token["is_service"] is False for token in response.data)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_empty_api_tokens_list(self, session_client, create_user):
|
||||
"""Test retrieving API tokens when none exist"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens")
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == []
|
||||
|
||||
# GET /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_get_specific_api_token(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test retrieving a specific API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert str(response.data["id"]) == str(create_api_token_for_user.pk)
|
||||
assert response.data["label"] == create_api_token_for_user.label
|
||||
assert (
|
||||
"token" not in response.data
|
||||
) # Token should not be visible in read serializer
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test retrieving a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_get_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test retrieving another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"other-{unique_id}@plane.so"
|
||||
unique_username = f"other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.get(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# DELETE /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_delete_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token deletion"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not APIToken.objects.filter(pk=create_api_token_for_user.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test deleting a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test deleting another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"delete-other-{unique_id}@plane.so"
|
||||
unique_username = f"delete_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=other_token.pk).exists()
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_delete_service_api_token_forbidden(self, session_client, create_user):
|
||||
"""Test deleting a service API token (should fail)"""
|
||||
# Arrange
|
||||
service_token = APIToken.objects.create(
|
||||
label="Service Token", user=create_user, user_type=0, is_service=True
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": service_token.pk})
|
||||
|
||||
# Act
|
||||
response = session_client.delete(url)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
# Verify token still exists
|
||||
assert APIToken.objects.filter(pk=service_token.pk).exists()
|
||||
|
||||
# PATCH /user/api-tokens/<pk>/ tests
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_success(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test successful API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
update_data = {
|
||||
"label": "Updated Token Label",
|
||||
"description": "Updated description",
|
||||
}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == update_data["description"]
|
||||
|
||||
# Verify database was updated
|
||||
create_api_token_for_user.refresh_from_db()
|
||||
assert create_api_token_for_user.label == update_data["label"]
|
||||
assert create_api_token_for_user.description == update_data["description"]
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_api_token_partial_update(
|
||||
self, session_client, create_user, create_api_token_for_user
|
||||
):
|
||||
"""Test partial API token update"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": create_api_token_for_user.pk})
|
||||
original_description = create_api_token_for_user.description
|
||||
update_data = {"label": "Only Label Updated"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["label"] == update_data["label"]
|
||||
assert response.data["description"] == original_description
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_nonexistent_api_token(self, session_client, create_user):
|
||||
"""Test updating a non-existent API token"""
|
||||
# Arrange
|
||||
session_client.force_authenticate(user=create_user)
|
||||
fake_pk = uuid4()
|
||||
url = reverse("api-tokens", kwargs={"pk": fake_pk})
|
||||
update_data = {"label": "New Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_patch_other_users_api_token(self, session_client, create_user, db):
|
||||
"""Test updating another user's API token (should fail)"""
|
||||
# Arrange
|
||||
# Create another user and their token with unique email and username
|
||||
unique_id = uuid4().hex[:8]
|
||||
unique_email = f"patch-other-{unique_id}@plane.so"
|
||||
unique_username = f"patch_other_user_{unique_id}"
|
||||
other_user = User.objects.create(email=unique_email, username=unique_username)
|
||||
other_token = APIToken.objects.create(
|
||||
label="Other Token", user=other_user, user_type=0
|
||||
)
|
||||
session_client.force_authenticate(user=create_user)
|
||||
url = reverse("api-tokens", kwargs={"pk": other_token.pk})
|
||||
update_data = {"label": "Hacked Label"}
|
||||
|
||||
# Act
|
||||
response = session_client.patch(url, update_data, format="json")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
# Verify token was not updated
|
||||
other_token.refresh_from_db()
|
||||
assert other_token.label == "Other Token"
|
||||
|
||||
# Authentication tests
|
||||
@pytest.mark.django_db
|
||||
def test_all_endpoints_require_authentication(self, api_client):
|
||||
"""Test that all endpoints require authentication"""
|
||||
# Arrange
|
||||
endpoints = [
|
||||
(reverse("api-tokens"), "get"),
|
||||
(reverse("api-tokens"), "post"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "get"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "patch"),
|
||||
(reverse("api-tokens", kwargs={"pk": uuid4()}), "delete"),
|
||||
]
|
||||
|
||||
# Act & Assert
|
||||
for url, method in endpoints:
|
||||
response = getattr(api_client, method)(url)
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
@@ -23,8 +23,8 @@
|
||||
"@plane/constants": "*",
|
||||
"@plane/editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/html": "2.11.0",
|
||||
"@tiptap/core": "^2.22.3",
|
||||
"@tiptap/html": "^2.22.3",
|
||||
"axios": "^1.8.3",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -33,7 +33,8 @@
|
||||
"@babel/helpers": "7.26.10",
|
||||
"@babel/runtime": "7.26.10",
|
||||
"chokidar": "3.6.0",
|
||||
"tar-fs": "3.0.9"
|
||||
"tar-fs": "3.0.9",
|
||||
"prosemirror-view": "1.40.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -149,3 +149,12 @@ export const DEFAULT_PROJECT_FORM_VALUES: Partial<IProject> = {
|
||||
network: 2,
|
||||
project_lead: null,
|
||||
};
|
||||
|
||||
export enum EProjectFeatureKey {
|
||||
WORK_ITEMS = "work_items",
|
||||
CYCLES = "cycles",
|
||||
MODULES = "modules",
|
||||
VIEWS = "views",
|
||||
PAGES = "pages",
|
||||
INTAKE = "intake",
|
||||
}
|
||||
|
||||
@@ -39,32 +39,31 @@
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"@tiptap/extension-blockquote": "2.10.4",
|
||||
"@tiptap/extension-character-count": "2.11.0",
|
||||
"@tiptap/extension-collaboration": "2.11.0",
|
||||
"@tiptap/extension-image": "2.11.0",
|
||||
"@tiptap/extension-list-item": "2.11.0",
|
||||
"@tiptap/extension-mention": "2.11.0",
|
||||
"@tiptap/extension-placeholder": "2.11.0",
|
||||
"@tiptap/extension-task-item": "2.11.0",
|
||||
"@tiptap/extension-task-list": "2.11.0",
|
||||
"@tiptap/extension-text-align": "2.11.0",
|
||||
"@tiptap/extension-text-style": "2.11.0",
|
||||
"@tiptap/extension-underline": "2.11.0",
|
||||
"@tiptap/html": "2.11.0",
|
||||
"@tiptap/pm": "2.11.0",
|
||||
"@tiptap/react": "2.11.0",
|
||||
"@tiptap/starter-kit": "2.11.0",
|
||||
"@tiptap/suggestion": "2.11.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"@tiptap/core": "^2.22.3",
|
||||
"@tiptap/extension-blockquote": "^2.22.3",
|
||||
"@tiptap/extension-character-count": "^2.22.3",
|
||||
"@tiptap/extension-collaboration": "^2.22.3",
|
||||
"@tiptap/extension-image": "^2.22.3",
|
||||
"@tiptap/extension-list-item": "^2.22.3",
|
||||
"@tiptap/extension-mention": "^2.22.3",
|
||||
"@tiptap/extension-placeholder": "^2.22.3",
|
||||
"@tiptap/extension-task-item": "^2.22.3",
|
||||
"@tiptap/extension-task-list": "^2.22.3",
|
||||
"@tiptap/extension-text-align": "^2.22.3",
|
||||
"@tiptap/extension-text-style": "^2.22.3",
|
||||
"@tiptap/extension-underline": "^2.22.3",
|
||||
"@tiptap/html": "^2.22.3",
|
||||
"@tiptap/pm": "^2.22.3",
|
||||
"@tiptap/react": "^2.22.3",
|
||||
"@tiptap/starter-kit": "^2.22.3",
|
||||
"@tiptap/suggestion": "^2.22.3",
|
||||
"highlight.js": "^11.8.0",
|
||||
"jsx-dom-cjs": "^8.0.3",
|
||||
"linkifyjs": "^4.1.3",
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"prosemirror-safari-ime-span": "^1.0.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.10",
|
||||
"uuid": "^10.0.0",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
export type TCoreAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import type { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
import type { IReadOnlyEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions"
|
||||
>;
|
||||
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (
|
||||
props: TCoreReadOnlyEditorAdditionalExtensionsProps
|
||||
): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
|
||||
@@ -1,37 +1,39 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import { TEmbedConfig } from "@/plane-editor/types";
|
||||
import type { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TUserDetails } from "@/types";
|
||||
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
export type TDocumentEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
> & {
|
||||
embedConfig: TEmbedConfig | undefined;
|
||||
fileHandler: TFileHandler;
|
||||
provider?: HocuspocusProvider;
|
||||
userDetails: TUserDetails;
|
||||
};
|
||||
|
||||
export type TDocumentEditorAdditionalExtensionsRegistry = {
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
|
||||
};
|
||||
|
||||
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: ({ disabledExtensions }) => SlashCommands({ disabledExtensions }),
|
||||
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
|
||||
SlashCommands({ disabledExtensions, flaggedExtensions }),
|
||||
},
|
||||
];
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: TDocumentEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions = [] } = _props;
|
||||
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
|
||||
const documentExtensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.map((config) => config.getExtension(_props));
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((config) => config.getExtension(props));
|
||||
|
||||
return documentExtensions;
|
||||
};
|
||||
|
||||
@@ -2,19 +2,19 @@ import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
// extensions
|
||||
import { SlashCommands } from "@/extensions/slash-commands/root";
|
||||
// types
|
||||
import { TExtensions, TFileHandler } from "@/types";
|
||||
import { IEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TFileHandler;
|
||||
};
|
||||
export type TRichTextEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry entry configuration for extensions
|
||||
*/
|
||||
export type TRichTextEditorAdditionalExtensionsRegistry = {
|
||||
/** Determines if the extension should be enabled based on disabled extensions */
|
||||
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
|
||||
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
|
||||
/** Returns the extension instance(s) when enabled */
|
||||
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
|
||||
};
|
||||
@@ -22,18 +22,19 @@ export type TRichTextEditorAdditionalExtensionsRegistry = {
|
||||
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
|
||||
{
|
||||
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
|
||||
getExtension: ({ disabledExtensions }) =>
|
||||
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
|
||||
const { disabledExtensions } = props;
|
||||
const { disabledExtensions, flaggedExtensions } = props;
|
||||
|
||||
const extensions: Extensions = extensionRegistry
|
||||
.filter((config) => config.isEnabled(disabledExtensions))
|
||||
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
|
||||
.map((config) => config.getExtension(props))
|
||||
.filter((extension): extension is AnyExtension => extension !== undefined);
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AnyExtension, Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions, TReadOnlyFileHandler } from "@/types";
|
||||
import { IReadOnlyEditorProps, TExtensions } from "@/types";
|
||||
|
||||
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
};
|
||||
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
|
||||
IReadOnlyEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Registry entry configuration for extensions
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// extensions
|
||||
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
import type { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions?: TExtensions[];
|
||||
};
|
||||
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const {} = props;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { type HeadingExtensionStorage } from "@/extensions";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
|
||||
import { type CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { type ImageExtensionStorage } from "@/extensions/image";
|
||||
import { type MentionExtensionStorage } from "@/extensions/mentions";
|
||||
|
||||
@@ -13,10 +13,11 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
|
||||
// types
|
||||
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
|
||||
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
|
||||
|
||||
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
|
||||
const {
|
||||
onChange,
|
||||
onTransaction,
|
||||
aiHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
@@ -27,6 +28,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
@@ -56,10 +58,12 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
@@ -95,7 +99,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
|
||||
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
|
||||
(props, ref) => (
|
||||
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus"
|
||||
// types
|
||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||
|
||||
type IPageRenderer = {
|
||||
type Props = {
|
||||
aiHandler?: TAIHandler;
|
||||
bubbleMenuEnabled: boolean;
|
||||
displayConfig: TDisplayConfig;
|
||||
@@ -15,7 +15,7 @@ type IPageRenderer = {
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: IPageRenderer) => {
|
||||
export const PageRenderer = (props: Props) => {
|
||||
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { forwardRef, MutableRefObject } from "react";
|
||||
import React, { forwardRef, MutableRefObject } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -13,30 +13,9 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import {
|
||||
EditorReadOnlyRefApi,
|
||||
TDisplayConfig,
|
||||
TExtensions,
|
||||
TReadOnlyFileHandler,
|
||||
TReadOnlyMentionHandler,
|
||||
} from "@/types";
|
||||
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
displayConfig?: TDisplayConfig;
|
||||
editorClassName?: string;
|
||||
embedHandler: any;
|
||||
fileHandler: TReadOnlyFileHandler;
|
||||
tabIndex?: number;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: TReadOnlyMentionHandler;
|
||||
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
}
|
||||
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
@@ -44,6 +23,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
id,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
@@ -64,6 +44,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
initialValue,
|
||||
@@ -87,7 +68,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
|
||||
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
|
||||
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -53,17 +53,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
|
||||
const lastNode = lastNodePos.node();
|
||||
|
||||
// Check if the last node is a not paragraph
|
||||
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If last node is not a paragraph, insert a new paragraph at the end
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(endPosition + 1)
|
||||
.run();
|
||||
// Check if its last node and add new node
|
||||
if (lastNode) {
|
||||
const isLastNodeEmptyParagraph = lastNode.type.name === CORE_EXTENSIONS.PARAGRAPH && lastNode.content.size === 0;
|
||||
// Only insert a new paragraph if the last node is not an empty paragraph and not a doc node
|
||||
if (!isLastNodeEmptyParagraph && lastNode.type.name !== "doc") {
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).focus("end").run();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
|
||||
|
||||
@@ -26,6 +26,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
id,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
@@ -44,6 +45,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
enableHistory: true,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
|
||||
@@ -4,23 +4,25 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
|
||||
// extensions
|
||||
import { EnterKeyExtension } from "@/extensions";
|
||||
// types
|
||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
...externalExtensions,
|
||||
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
|
||||
],
|
||||
[externalExtensions, disabledExtensions, onEnterKeyPress]
|
||||
);
|
||||
const extensions = useMemo(() => {
|
||||
const resolvedExtensions = [...externalExtensions];
|
||||
|
||||
if (!disabledExtensions?.includes("enter-key")) {
|
||||
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
|
||||
}
|
||||
|
||||
return resolvedExtensions;
|
||||
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@ import { forwardRef } from "react";
|
||||
// components
|
||||
import { ReadOnlyEditorWrapper } from "@/components/editors";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
|
||||
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps } from "@/types";
|
||||
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
|
||||
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
|
||||
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
id,
|
||||
initialValue,
|
||||
@@ -28,6 +29,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
|
||||
@@ -7,15 +7,16 @@ import { SideMenuExtension } from "@/extensions";
|
||||
// plane editor imports
|
||||
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
|
||||
// types
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
import { EditorRefApi, IRichTextEditorProps } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
|
||||
const {
|
||||
bubbleMenuEnabled = true,
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
fileHandler,
|
||||
bubbleMenuEnabled = true,
|
||||
extensions: externalExtensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
} = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
@@ -28,11 +29,12 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
...RichTextEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
}),
|
||||
];
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler]);
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
@@ -41,7 +43,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
|
||||
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
|
||||
));
|
||||
|
||||
|
||||
@@ -2,23 +2,22 @@ import { forwardRef, useCallback } from "react";
|
||||
// plane editor extensions
|
||||
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
|
||||
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => {
|
||||
const { disabledExtensions, fileHandler } = props;
|
||||
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
|
||||
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
...RichTextReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
}),
|
||||
];
|
||||
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
});
|
||||
|
||||
return extensions;
|
||||
}, [disabledExtensions, fileHandler]);
|
||||
}, [disabledExtensions, fileHandler, flaggedExtensions]);
|
||||
|
||||
return (
|
||||
<ReadOnlyEditorWrapper
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
HeadingFiveItem,
|
||||
HeadingSixItem,
|
||||
EditorMenuItem,
|
||||
ToggleListItem,
|
||||
} from "@/components/menus";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
@@ -42,6 +43,7 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
|
||||
BulletListItem(editor),
|
||||
NumberedListItem(editor),
|
||||
TodoListItem(editor),
|
||||
ToggleListItem(editor),
|
||||
QuoteItem(editor),
|
||||
CodeItem(editor),
|
||||
];
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BubbleMenuLinkSelector,
|
||||
BubbleMenuNodeSelector,
|
||||
CodeItem,
|
||||
EditorMenuItem,
|
||||
ItalicItem,
|
||||
StrikeThroughItem,
|
||||
TextAlignItem,
|
||||
@@ -23,6 +24,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// local components
|
||||
import { TextAlignmentSelector } from "./alignment-selector";
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
@@ -31,19 +33,19 @@ export interface EditorStateType {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strike: boolean;
|
||||
strikethrough: boolean;
|
||||
left: boolean;
|
||||
right: boolean;
|
||||
center: boolean;
|
||||
color: { key: string; label: string; textColor: string; backgroundColor: string } | undefined;
|
||||
backgroundColor:
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Editor }) => {
|
||||
@@ -58,8 +60,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: BoldItem(props.editor),
|
||||
italic: ItalicItem(props.editor),
|
||||
underline: UnderLineItem(props.editor),
|
||||
strike: StrikeThroughItem(props.editor),
|
||||
textAlign: TextAlignItem(props.editor),
|
||||
strikethrough: StrikeThroughItem(props.editor),
|
||||
"text-align": TextAlignItem(props.editor),
|
||||
} satisfies {
|
||||
[K in TEditorCommands]?: EditorMenuItem<K>;
|
||||
};
|
||||
|
||||
const editorState: EditorStateType = useEditorState({
|
||||
@@ -69,10 +73,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
bold: formattingItems.bold.isActive(),
|
||||
italic: formattingItems.italic.isActive(),
|
||||
underline: formattingItems.underline.isActive(),
|
||||
strike: formattingItems.strike.isActive(),
|
||||
left: formattingItems.textAlign.isActive({ alignment: "left" }),
|
||||
right: formattingItems.textAlign.isActive({ alignment: "right" }),
|
||||
center: formattingItems.textAlign.isActive({ alignment: "center" }),
|
||||
strikethrough: formattingItems.strikethrough.isActive(),
|
||||
left: formattingItems["text-align"].isActive({ alignment: "left" }),
|
||||
right: formattingItems["text-align"].isActive({ alignment: "right" }),
|
||||
center: formattingItems["text-align"].isActive({ alignment: "center" }),
|
||||
color: COLORS_LIST.find((c) => TextColorItem(editor).isActive({ color: c.key })),
|
||||
backgroundColor: COLORS_LIST.find((c) => BackgroundColorItem(editor).isActive({ color: c.key })),
|
||||
}),
|
||||
@@ -80,7 +84,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
|
||||
const basicFormattingOptions = editorState.code
|
||||
? [formattingItems.code]
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
|
||||
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
...props,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
MinusSquare,
|
||||
Palette,
|
||||
AlignCenter,
|
||||
ListCollapse,
|
||||
} from "lucide-react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
@@ -35,13 +36,14 @@ import {
|
||||
toggleBackgroundColor,
|
||||
toggleBlockquote,
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleFlatBulletList,
|
||||
toggleFlatOrderedList,
|
||||
toggleFlatTaskList,
|
||||
toggleFlatToggleList,
|
||||
toggleHeading,
|
||||
toggleItalic,
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
toggleTaskList,
|
||||
toggleTextColor,
|
||||
toggleUnderline,
|
||||
} from "@/helpers/editor-commands";
|
||||
@@ -136,27 +138,35 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
|
||||
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
|
||||
key: "bulleted-list",
|
||||
name: "Bulleted list",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
|
||||
command: () => toggleBulletList(editor),
|
||||
isActive: () => editor?.isActive("list", { kind: "bullet" }),
|
||||
command: () => toggleFlatBulletList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
|
||||
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
|
||||
command: () => toggleOrderedList(editor),
|
||||
isActive: () => editor?.isActive("list", { kind: "ordered" }),
|
||||
command: () => toggleFlatOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
});
|
||||
|
||||
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
|
||||
command: () => toggleTaskList(editor),
|
||||
isActive: () => editor?.isActive("list", { kind: "task" }),
|
||||
command: () => toggleFlatTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
});
|
||||
|
||||
export const ToggleListItem = (editor: Editor): EditorMenuItem<"toggle-list"> => ({
|
||||
key: "toggle-list",
|
||||
name: "Toggle list",
|
||||
isActive: () => editor?.isActive("list", { kind: "toggle" }),
|
||||
command: () => toggleFlatToggleList(editor),
|
||||
icon: ListCollapse,
|
||||
});
|
||||
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
@@ -248,6 +258,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
|
||||
StrikeThroughItem(editor),
|
||||
BulletListItem(editor),
|
||||
TodoListItem(editor),
|
||||
ToggleListItem(editor),
|
||||
CodeItem(editor),
|
||||
NumberedListItem(editor),
|
||||
QuoteItem(editor),
|
||||
|
||||
@@ -41,4 +41,5 @@ export enum CORE_EXTENSIONS {
|
||||
UNDERLINE = "underline",
|
||||
UTILITY = "utility",
|
||||
WORK_ITEM_EMBED = "issue-embed-component",
|
||||
LIST = "list",
|
||||
}
|
||||
|
||||
@@ -10,14 +10,14 @@ import { EAttributeNames, TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
import { updateStoredBackgroundColor } from "./utils";
|
||||
|
||||
type Props = NodeViewProps & {
|
||||
export type CustomCalloutNodeViewProps = NodeViewProps & {
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: TCalloutBlockAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
|
||||
};
|
||||
|
||||
export const CustomCalloutBlock: React.FC<Props> = (props) => {
|
||||
export const CustomCalloutBlock: React.FC<CustomCalloutNodeViewProps> = (props) => {
|
||||
const { editor, node, updateAttributes } = props;
|
||||
// states
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomCalloutBlock } from "@/extensions";
|
||||
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// config
|
||||
@@ -63,6 +63,8 @@ export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomCalloutBlock);
|
||||
return ReactNodeViewRenderer((props) => (
|
||||
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomCalloutBlock } from "@/extensions";
|
||||
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
|
||||
// config
|
||||
import { CustomCalloutExtensionConfig } from "./extension-config";
|
||||
|
||||
@@ -9,6 +9,8 @@ export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.exten
|
||||
draggable: false,
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomCalloutBlock);
|
||||
return ReactNodeViewRenderer((props) => (
|
||||
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
|
||||
419
packages/editor/src/core/extensions/clipboard-new.ts
Normal file
419
packages/editor/src/core/extensions/clipboard-new.ts
Normal file
@@ -0,0 +1,419 @@
|
||||
import { Editor, Extension } from "@tiptap/core";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import { Fragment, Node } from "prosemirror-model";
|
||||
import { NodeSelection, Plugin } from "prosemirror-state";
|
||||
import { CellSelection } from "prosemirror-tables";
|
||||
import * as pmView from "prosemirror-view";
|
||||
|
||||
import { EditorView } from "prosemirror-view";
|
||||
|
||||
function fragmentToExternalHTML(view: pmView.EditorView, selectedFragment: Fragment, editor: Editor) {
|
||||
let isWithinBlockContent = false;
|
||||
const isWithinTable = view.state.selection instanceof CellSelection;
|
||||
|
||||
if (!isWithinTable) {
|
||||
// Checks whether block ancestry should be included when creating external
|
||||
// HTML. If the selection is within a block content node, the block ancestry
|
||||
// is excluded as we only care about the inline content.
|
||||
const fragmentWithoutParents = view.state.doc.slice(
|
||||
view.state.selection.from,
|
||||
view.state.selection.to,
|
||||
false
|
||||
).content;
|
||||
// __AUTO_GENERATED_PRINT_VAR_START__
|
||||
console.log(
|
||||
"fragmentToExternalHTML#if fragmentWithoutParents: ",
|
||||
fragmentWithoutParents,
|
||||
JSON.stringify(fragmentWithoutParents) === JSON.stringify(selectedFragment)
|
||||
); // __AUTO_GENERATED_PRINT_VAR_END__
|
||||
|
||||
const children: Node[] = [];
|
||||
for (let i = 0; i < fragmentWithoutParents.childCount; i++) {
|
||||
children.push(fragmentWithoutParents.child(i));
|
||||
}
|
||||
|
||||
isWithinBlockContent =
|
||||
children.find((child) => {
|
||||
// console.clear();
|
||||
console.log("child name:", child.type.name);
|
||||
console.log("child spec group:", child.type.spec.group);
|
||||
console.log("child isInGroup block:", child.type.isInGroup("block"));
|
||||
return child.type.isInGroup("block") || child.type.name === "block" || child.type.spec.group === "block";
|
||||
}) === undefined;
|
||||
console.log("isWithinBlockContent", isWithinBlockContent);
|
||||
if (isWithinBlockContent) {
|
||||
selectedFragment = fragmentWithoutParents;
|
||||
}
|
||||
}
|
||||
|
||||
let externalHTML: string;
|
||||
|
||||
const externalHTMLExporter = createExternalHTMLExporter(view.state.schema, editor);
|
||||
|
||||
// if (isWithinTable) {
|
||||
// // if (selectedFragment.firstChild?.type.name === "table") {
|
||||
// // // contentNodeToTableContent expects the fragment of the content of a table, not the table node itself
|
||||
// // // but cellselection.content() returns the table node itself if all cells and columns are selected
|
||||
// // selectedFragment = selectedFragment.firstChild.content;
|
||||
// // }
|
||||
// //
|
||||
// // // first convert selection to blocknote-style table content, and then
|
||||
// // // pass this to the exporter
|
||||
// // const ic = contentNodeToTableContent(
|
||||
// // selectedFragment as any,
|
||||
// // editor.schema.inlineContentSchema,
|
||||
// // editor.schema.styleSchema
|
||||
// // );
|
||||
// //
|
||||
// // // Wrap in table to ensure correct parsing by spreadsheet applications
|
||||
// // externalHTML = `<table>${externalHTMLExporter.exportInlineContent(ic as any, {})}</table>`;
|
||||
// if (isWithinBlockContent) {
|
||||
// // first convert selection to blocknote-style inline content, and then
|
||||
// // pass this to the exporter
|
||||
// const ic = contentNodeToInlineContent(
|
||||
// selectedFragment as any,
|
||||
// editor.schema.inlineContentSchema,
|
||||
// editor.schema.styleSchema
|
||||
// );
|
||||
// externalHTML = externalHTMLExporter.exportInlineContent(ic, {});
|
||||
// }
|
||||
// } else {
|
||||
// const blocks = fragmentToBlocks(selectedFragment, editor.schema);
|
||||
// externalHTML = externalHTMLExporter.exportBlocks(blocks, {});
|
||||
// }
|
||||
// return externalHTML;
|
||||
}
|
||||
|
||||
export function selectedFragmentToHTML(
|
||||
view: EditorView,
|
||||
editor: Editor
|
||||
): {
|
||||
clipboardHTML: string;
|
||||
externalHTML: string;
|
||||
markdown?: string;
|
||||
} {
|
||||
// Checks if a `blockContent` node is being copied and expands
|
||||
// the selection to the parent `blockContainer` node. This is
|
||||
// for the use-case in which only a block without content is
|
||||
// selected, e.g. an image block.
|
||||
if ("node" in view.state.selection && (view.state.selection.node as Node).type.spec.group === "blockContent") {
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.setSelection(new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1)))
|
||||
);
|
||||
}
|
||||
|
||||
// Uses default ProseMirror clipboard serialization.
|
||||
const clipboardHTML: string = (pmView as any).__serializeForClipboard(view, view.state.selection.content()).dom
|
||||
.innerHTML;
|
||||
|
||||
const selectedFragment = view.state.selection.content().content;
|
||||
console.log("selectedFragment", selectedFragment);
|
||||
|
||||
const externalHTML = fragmentToExternalHTML(view, selectedFragment, editor);
|
||||
|
||||
// const markdown = cleanHTMLToMarkdown(externalHTML);
|
||||
|
||||
return { clipboardHTML, externalHTML };
|
||||
}
|
||||
|
||||
const copyToClipboard = (editor: Editor, view: EditorView, event: ClipboardEvent) => {
|
||||
// Stops the default browser copy behaviour.
|
||||
event.preventDefault();
|
||||
event.clipboardData!.clearData();
|
||||
|
||||
const { clipboardHTML, externalHTML } = selectedFragmentToHTML(view, editor);
|
||||
|
||||
// TODO: Writing to other MIME types not working in Safari for
|
||||
// some reason.
|
||||
event.clipboardData!.setData("blocknote/html", clipboardHTML);
|
||||
event.clipboardData!.setData("text/html", externalHTML);
|
||||
// event.clipboardData!.setData("text/plain", markdown);
|
||||
};
|
||||
|
||||
export const createCopyToClipboardExtension = () =>
|
||||
Extension.create({
|
||||
name: "copyToClipboard",
|
||||
addProseMirrorPlugins(this) {
|
||||
const { editor } = this;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("copyToClipboard"),
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
copy(view, event) {
|
||||
copyToClipboard(editor, view, event);
|
||||
// Prevent default PM handler to be called
|
||||
return true;
|
||||
},
|
||||
cut(view, event) {
|
||||
copyToClipboard(editor, view, event);
|
||||
if (view.editable) {
|
||||
view.dispatch(view.state.tr.deleteSelection());
|
||||
}
|
||||
// Prevent default PM handler to be called
|
||||
return true;
|
||||
},
|
||||
// This is for the use-case in which only a block without content
|
||||
// is selected, e.g. an image block, and dragged (not using the
|
||||
// drag handle).
|
||||
// dragstart(view, event) {
|
||||
// // Checks if a `NodeSelection` is active.
|
||||
// if (!("node" in view.state.selection)) {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Checks if a `blockContent` node is being dragged.
|
||||
// if ((view.state.selection.node as Node).type.spec.group !== "blockContent") {
|
||||
// return;
|
||||
// }
|
||||
//
|
||||
// // Expands the selection to the parent `blockContainer` node.
|
||||
// editor.dispatch(
|
||||
// editor._tiptapEditor.state.tr.setSelection(
|
||||
// new NodeSelection(view.state.doc.resolve(view.state.selection.from - 1))
|
||||
// )
|
||||
// );
|
||||
//
|
||||
// // Stops the default browser drag start behaviour.
|
||||
// event.preventDefault();
|
||||
// event.dataTransfer!.clearData();
|
||||
//
|
||||
// const { clipboardHTML, externalHTML, markdown } = selectedFragmentToHTML(view, editor);
|
||||
//
|
||||
// // TODO: Writing to other MIME types not working in Safari for
|
||||
// // some reason.
|
||||
// event.dataTransfer!.setData("blocknote/html", clipboardHTML);
|
||||
// event.dataTransfer!.setData("text/html", externalHTML);
|
||||
// event.dataTransfer!.setData("text/plain", markdown);
|
||||
//
|
||||
// // Prevent default PM handler to be called
|
||||
// return true;
|
||||
// },
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export function contentNodeToInlineContent(contentNode: Node, inlineContentSchema: any, styleSchema: any) {
|
||||
const content = [];
|
||||
let currentContent;
|
||||
|
||||
// Most of the logic below is for handling links because in ProseMirror links are marks
|
||||
// while in BlockNote links are a type of inline content
|
||||
contentNode.content.forEach((node) => {
|
||||
// hardBreak nodes do not have an InlineContent equivalent, instead we
|
||||
// add a newline to the previous node.
|
||||
if (node.type.name === "hardBreak") {
|
||||
if (currentContent) {
|
||||
// Current content exists.
|
||||
if (isStyledTextInlineContent(currentContent)) {
|
||||
// Current content is text.
|
||||
currentContent.text += "\n";
|
||||
} else if (isLinkInlineContent(currentContent)) {
|
||||
// Current content is a link.
|
||||
currentContent.content[currentContent.content.length - 1].text += "\n";
|
||||
} else {
|
||||
throw new Error("unexpected");
|
||||
}
|
||||
} else {
|
||||
// Current content does not exist.
|
||||
currentContent = {
|
||||
type: "text",
|
||||
text: "\n",
|
||||
styles: {},
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.type.name !== "link" && node.type.name !== "text" && inlineContentSchema[node.type.name]) {
|
||||
if (currentContent) {
|
||||
content.push(currentContent);
|
||||
currentContent = undefined;
|
||||
}
|
||||
|
||||
content.push(nodeToCustomInlineContent(node, inlineContentSchema, styleSchema));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const styles = {};
|
||||
let linkMark;
|
||||
|
||||
for (const mark of node.marks) {
|
||||
if (mark.type.name === "link") {
|
||||
linkMark = mark;
|
||||
} else {
|
||||
const config = styleSchema[mark.type.name];
|
||||
if (!config) {
|
||||
throw new Error(`style ${mark.type.name} not found in styleSchema`);
|
||||
}
|
||||
if (config.propSchema === "boolean") {
|
||||
(styles as any)[config.type] = true;
|
||||
} else if (config.propSchema === "string") {
|
||||
(styles as any)[config.type] = mark.attrs.stringValue;
|
||||
} else {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parsing links and text.
|
||||
// Current content exists.
|
||||
if (currentContent) {
|
||||
// Current content is text.
|
||||
if (isStyledTextInlineContent(currentContent)) {
|
||||
if (!linkMark) {
|
||||
// Node is text (same type as current content).
|
||||
if (JSON.stringify(currentContent.styles) === JSON.stringify(styles)) {
|
||||
// Styles are the same.
|
||||
currentContent.text += node.textContent;
|
||||
} else {
|
||||
// Styles are different.
|
||||
content.push(currentContent);
|
||||
currentContent = {
|
||||
type: "text",
|
||||
text: node.textContent,
|
||||
styles,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Node is a link (different type to current content).
|
||||
content.push(currentContent);
|
||||
currentContent = {
|
||||
type: "link",
|
||||
href: linkMark.attrs.href,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: node.textContent,
|
||||
styles,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} else if (isLinkInlineContent(currentContent)) {
|
||||
// Current content is a link.
|
||||
if (linkMark) {
|
||||
// Node is a link (same type as current content).
|
||||
// Link URLs are the same.
|
||||
if (currentContent.href === linkMark.attrs.href) {
|
||||
// Styles are the same.
|
||||
if (
|
||||
JSON.stringify(currentContent.content[currentContent.content.length - 1].styles) ===
|
||||
JSON.stringify(styles)
|
||||
) {
|
||||
currentContent.content[currentContent.content.length - 1].text += node.textContent;
|
||||
} else {
|
||||
// Styles are different.
|
||||
currentContent.content.push({
|
||||
type: "text",
|
||||
text: node.textContent,
|
||||
styles,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Link URLs are different.
|
||||
content.push(currentContent);
|
||||
currentContent = {
|
||||
type: "link",
|
||||
href: linkMark.attrs.href,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: node.textContent,
|
||||
styles,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Node is text (different type to current content).
|
||||
content.push(currentContent);
|
||||
currentContent = {
|
||||
type: "text",
|
||||
text: node.textContent,
|
||||
styles,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
// Current content does not exist.
|
||||
else {
|
||||
// Node is text.
|
||||
if (!linkMark) {
|
||||
currentContent = {
|
||||
type: "text",
|
||||
text: node.textContent,
|
||||
styles,
|
||||
};
|
||||
}
|
||||
// Node is a link.
|
||||
else {
|
||||
currentContent = {
|
||||
type: "link",
|
||||
href: linkMark.attrs.href,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: node.textContent,
|
||||
styles,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (currentContent) {
|
||||
content.push(currentContent);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function isLinkInlineContent(content: { type: string }): boolean {
|
||||
return content.type === "link";
|
||||
}
|
||||
|
||||
export function isStyledTextInlineContent(content: { type: string }): boolean {
|
||||
return typeof content !== "string" && content.type === "text";
|
||||
}
|
||||
|
||||
export function nodeToCustomInlineContent(node: Node, inlineContentSchema: any, styleSchema: any) {
|
||||
if (node.type.name === "text" || node.type.name === "link") {
|
||||
throw new Error("unexpected");
|
||||
}
|
||||
const props: any = {};
|
||||
const icConfig = inlineContentSchema[node.type.name];
|
||||
for (const [attr, value] of Object.entries(node.attrs)) {
|
||||
if (!icConfig) {
|
||||
throw Error("ic node is of an unrecognized type: " + node.type.name);
|
||||
}
|
||||
|
||||
const propSchema = icConfig.propSchema;
|
||||
|
||||
if (attr in propSchema) {
|
||||
props[attr] = value;
|
||||
}
|
||||
}
|
||||
|
||||
let content;
|
||||
|
||||
if (icConfig.content === "styled") {
|
||||
content = contentNodeToInlineContent(node, inlineContentSchema, styleSchema) as any; // TODO: is this safe? could we have Links here that are undesired?
|
||||
} else {
|
||||
content = undefined;
|
||||
}
|
||||
|
||||
const ic = {
|
||||
type: node.type.name,
|
||||
props,
|
||||
content,
|
||||
};
|
||||
return ic;
|
||||
}
|
||||
89
packages/editor/src/core/extensions/clipboard.ts
Normal file
89
packages/editor/src/core/extensions/clipboard.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Fragment, Node } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const markdownSerializer = this.editor.storage.markdown.serializer;
|
||||
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
|
||||
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
||||
|
||||
if (nodeSelect) {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
}
|
||||
|
||||
const processTableContent = (tableNode: Node | Fragment) => {
|
||||
let result = "";
|
||||
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
|
||||
tableRowNode.content?.forEach?.((cell: Node) => {
|
||||
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
|
||||
result += cellContent + "\n";
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
if (isTableRow) {
|
||||
const rowsCount = slice.content?.childCount || 0;
|
||||
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
|
||||
if (rowsCount === 1 || cellsCount === 1) {
|
||||
return processTableContent(slice.content);
|
||||
} else {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
}
|
||||
}
|
||||
|
||||
const traverseToParentOfLeaf = (
|
||||
node: Node | null,
|
||||
parent: Fragment | Node,
|
||||
depth: number
|
||||
): Node | Fragment => {
|
||||
let currentNode = node;
|
||||
let currentParent = parent;
|
||||
let currentDepth = depth;
|
||||
|
||||
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
|
||||
if (currentNode.content?.childCount > 1) {
|
||||
if (currentNode.content.firstChild?.type?.name === "listItem") {
|
||||
return currentParent;
|
||||
} else {
|
||||
return currentNode.content;
|
||||
}
|
||||
}
|
||||
|
||||
currentParent = currentNode;
|
||||
currentNode = currentNode.content?.firstChild || null;
|
||||
currentDepth--;
|
||||
}
|
||||
|
||||
return currentParent;
|
||||
};
|
||||
|
||||
if (slice.content.childCount > 1) {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
} else {
|
||||
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
|
||||
|
||||
let currentNode = targetNode;
|
||||
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
|
||||
currentNode = currentNode.firstChild;
|
||||
}
|
||||
if (currentNode instanceof Node && currentNode.isText) {
|
||||
return currentNode.text;
|
||||
}
|
||||
|
||||
return markdownSerializer.serialize(targetNode);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import { Selection, TextSelection } from "@tiptap/pm/state";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
@@ -14,28 +14,169 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
|
||||
return ReactNodeViewRenderer(CodeBlockComponent);
|
||||
},
|
||||
|
||||
//@ts-expect-error todo
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Enter: ({ editor }) => {
|
||||
if (editor.isActive("codeBlock")) {
|
||||
return editor.commands.newlineInCode();
|
||||
}
|
||||
},
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
const { $from, $to, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
if ($from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use ProseMirror's insertText transaction to insert the tab character
|
||||
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
|
||||
editor.view.dispatch(tr);
|
||||
let tr = state.tr;
|
||||
|
||||
// Store initial selection positions
|
||||
const initialFrom = selection.from;
|
||||
const initialTo = selection.to;
|
||||
let offset = 0;
|
||||
|
||||
// Handle selection case
|
||||
if (!empty) {
|
||||
// Find the start of the first line in selection
|
||||
let startPos = $from.pos;
|
||||
while (startPos > $from.start() && !/[\n\r]/.test(state.doc.textBetween(startPos - 1, startPos))) {
|
||||
startPos--;
|
||||
}
|
||||
|
||||
// Find the end of the last line in selection
|
||||
let endPos = $to.pos;
|
||||
while (endPos < $to.end() && !/[\n\r]/.test(state.doc.textBetween(endPos, endPos + 1))) {
|
||||
endPos++;
|
||||
}
|
||||
|
||||
// Get the text content between start and end
|
||||
const selectedText = state.doc.textBetween(startPos, endPos);
|
||||
const lines = selectedText.split("\n");
|
||||
|
||||
// Add tabs to each line
|
||||
let currentOffset = 0;
|
||||
lines.forEach((line, index) => {
|
||||
const pos = startPos + currentOffset;
|
||||
tr = tr.insertText("\t", pos, pos);
|
||||
currentOffset += line.length + 1 + 1; // +1 for newline, +1 for the inserted tab
|
||||
|
||||
// Update the total offset for selection adjustment
|
||||
if (pos < initialFrom) offset++;
|
||||
});
|
||||
|
||||
// Restore selection with adjusted positions
|
||||
const newSelection = TextSelection.create(tr.doc, initialFrom + offset, initialTo + offset);
|
||||
tr = tr.setSelection(newSelection);
|
||||
} else {
|
||||
// Single line case
|
||||
let lineStart = $from.pos;
|
||||
while (lineStart > $from.start() && !/[\n\r]/.test(state.doc.textBetween(lineStart - 1, lineStart))) {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
tr = tr.insertText("\t", lineStart, lineStart);
|
||||
|
||||
// Adjust cursor position
|
||||
const newSelection = TextSelection.create(tr.doc, initialFrom + 1, initialTo + 1);
|
||||
tr = tr.setSelection(newSelection);
|
||||
}
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Shift-Tab": ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, $to, empty } = selection;
|
||||
|
||||
if ($from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let tr = state.tr;
|
||||
|
||||
// Store initial selection positions
|
||||
const initialFrom = selection.from;
|
||||
const initialTo = selection.to;
|
||||
let offset = 0;
|
||||
|
||||
// Handle selection case
|
||||
if (!empty) {
|
||||
// Find the start of the first line in selection
|
||||
let startPos = $from.pos;
|
||||
while (startPos > $from.start() && !/[\n\r]/.test(state.doc.textBetween(startPos - 1, startPos))) {
|
||||
startPos--;
|
||||
}
|
||||
|
||||
// Find the end of the last line in selection
|
||||
let endPos = $to.pos;
|
||||
while (endPos < $to.end() && !/[\n\r]/.test(state.doc.textBetween(endPos, endPos + 1))) {
|
||||
endPos++;
|
||||
}
|
||||
|
||||
// Get the text content between start and end
|
||||
const selectedText = state.doc.textBetween(startPos, endPos);
|
||||
const lines = selectedText.split("\n");
|
||||
|
||||
// Remove tabs from each line
|
||||
let currentOffset = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const pos = startPos + currentOffset;
|
||||
const firstChar = state.doc.textBetween(pos, pos + 1);
|
||||
|
||||
if (firstChar === "\t") {
|
||||
tr = tr.delete(pos, pos + 1);
|
||||
if (pos < initialFrom) offset--;
|
||||
currentOffset += lines[i].length; // Don't add 1 for the deleted tab
|
||||
} else {
|
||||
currentOffset += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
}
|
||||
|
||||
// Restore selection with adjusted positions
|
||||
const newSelection = TextSelection.create(
|
||||
tr.doc,
|
||||
Math.max(initialFrom + offset, 0),
|
||||
Math.max(initialTo + offset, 0)
|
||||
);
|
||||
tr = tr.setSelection(newSelection);
|
||||
} else {
|
||||
// Single line case
|
||||
let lineStart = $from.pos;
|
||||
while (lineStart > $from.start() && !/[\n\r]/.test(state.doc.textBetween(lineStart - 1, lineStart))) {
|
||||
lineStart--;
|
||||
}
|
||||
|
||||
const firstChar = state.doc.textBetween(lineStart, lineStart + 1);
|
||||
if (firstChar === "\t") {
|
||||
tr = tr.delete(lineStart, lineStart + 1);
|
||||
|
||||
// Adjust cursor position
|
||||
const newSelection = TextSelection.create(
|
||||
tr.doc,
|
||||
Math.max(initialFrom - 1, lineStart),
|
||||
Math.max(initialTo - 1, lineStart)
|
||||
);
|
||||
tr = tr.setSelection(newSelection);
|
||||
}
|
||||
}
|
||||
|
||||
editor.view.dispatch(tr);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Shift-Tab in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowUp: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
// components
|
||||
@@ -7,108 +6,7 @@ import { CodeBlockLowlight } from "./code-block-lowlight";
|
||||
const lowlight = createLowlight(common);
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use ProseMirror's insertText transaction to insert the tab character
|
||||
const tr = state.tr.insertText("\t", $from.pos, $from.pos);
|
||||
editor.view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error handling Tab in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowUp: ({ editor }) => {
|
||||
try {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtStart = $from.parentOffset === 0;
|
||||
|
||||
if (!isAtStart) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if codeBlock is the first node
|
||||
const isFirstNode = $from.depth === 1 && $from.index($from.depth - 1) === 0;
|
||||
|
||||
if (isFirstNode) {
|
||||
// Insert a new paragraph at the start of the document and move the cursor to it
|
||||
return editor.commands.command(({ tr }) => {
|
||||
const node = editor.schema.nodes.paragraph.create();
|
||||
tr.insert(0, node);
|
||||
tr.setSelection(Selection.near(tr.doc.resolve(1)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowUp in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
ArrowDown: ({ editor }) => {
|
||||
try {
|
||||
if (!this.options.exitOnArrowDown) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
|
||||
if (!isAtEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
} catch (error) {
|
||||
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
defaultLanguage: "plaintext",
|
||||
exitOnTripleEnter: false,
|
||||
|
||||
@@ -12,10 +12,10 @@ import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { CustomImageExtensionConfig } from "./custom-image/extension-config";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { ImageExtensionConfig } from "./image";
|
||||
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
@@ -72,12 +72,8 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtensionWithoutProps.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageComponentWithoutProps,
|
||||
ImageExtensionConfig,
|
||||
CustomImageExtensionConfig,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
|
||||
@@ -1,68 +1,42 @@
|
||||
import { NodeSelection } from "@tiptap/pm/state";
|
||||
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
// local imports
|
||||
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
|
||||
import { ensurePixelString } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
import { ImageToolbarRoot } from "./toolbar";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
|
||||
type Pixel = `${number}px`;
|
||||
|
||||
type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type ImageAttributes = {
|
||||
src: string | null;
|
||||
width: PixelAttribute<"35%" | number>;
|
||||
height: PixelAttribute<"auto" | number>;
|
||||
aspectRatio: number | null;
|
||||
id: string | null;
|
||||
};
|
||||
|
||||
type Size = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
|
||||
imageFromFileSystem: string | undefined;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
type CustomImageBlockProps = CustomImageNodeViewProps & {
|
||||
editorContainer: HTMLDivElement | null;
|
||||
imageFromFileSystem: string | undefined;
|
||||
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
src: string | undefined;
|
||||
};
|
||||
|
||||
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
// props
|
||||
const {
|
||||
node,
|
||||
updateAttributes,
|
||||
setFailedToLoadImage,
|
||||
imageFromFileSystem,
|
||||
selected,
|
||||
getPos,
|
||||
editor,
|
||||
editorContainer,
|
||||
src: resolvedImageSrc,
|
||||
extension,
|
||||
getPos,
|
||||
imageFromFileSystem,
|
||||
node,
|
||||
selected,
|
||||
setEditorContainer,
|
||||
setFailedToLoadImage,
|
||||
src: resolvedImageSrc,
|
||||
updateAttributes,
|
||||
} = props;
|
||||
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
|
||||
// states
|
||||
const [size, setSize] = useState<Size>({
|
||||
const [size, setSize] = useState<TCustomImageSize>({
|
||||
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
|
||||
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
|
||||
aspectRatio: nodeAspectRatio || null,
|
||||
@@ -77,7 +51,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
|
||||
|
||||
const updateAttributesSafely = useCallback(
|
||||
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
|
||||
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
|
||||
try {
|
||||
updateAttributes(attributes);
|
||||
} catch (error) {
|
||||
@@ -114,7 +88,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
|
||||
const initialHeight = initialWidth / aspectRatioCalculated;
|
||||
|
||||
const initialComputedSize = {
|
||||
const initialComputedSize: TCustomImageSize = {
|
||||
width: `${Math.round(initialWidth)}px` satisfies Pixel,
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatioCalculated,
|
||||
@@ -139,7 +113,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
}
|
||||
}
|
||||
setInitialResizeComplete(true);
|
||||
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
|
||||
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
|
||||
|
||||
// for real time resizing
|
||||
useLayoutEffect(() => {
|
||||
@@ -168,7 +142,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
const handleResizeEnd = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
updateAttributesSafely(size, "Failed to update attributes at the end of resizing:");
|
||||
}, [size, updateAttributes]);
|
||||
}, [size, updateAttributesSafely]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -242,7 +216,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
onLoad={handleImageLoad}
|
||||
onError={async (e) => {
|
||||
// for old image extension this command doesn't exist or if the image failed to load for the first time
|
||||
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
|
||||
if (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
|
||||
setFailedToLoadImage(true);
|
||||
return;
|
||||
}
|
||||
@@ -253,7 +227,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
if (!imgNodeSrc) {
|
||||
throw new Error("No source image to restore from");
|
||||
}
|
||||
await editor?.commands.restoreImage?.(imgNodeSrc);
|
||||
await extension.options.restoreImage?.(imgNodeSrc);
|
||||
if (!imageRef.current) {
|
||||
throw new Error("Image reference not found");
|
||||
}
|
||||
@@ -289,10 +263,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
|
||||
}
|
||||
image={{
|
||||
src: resolvedImageSrc,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
height: size.height,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
|
||||
src: resolvedImageSrc,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./toolbar";
|
||||
export * from "./image-block";
|
||||
export * from "./image-node";
|
||||
export * from "./image-uploader";
|
||||
@@ -2,25 +2,26 @@ import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import type { CustomImageExtension, TCustomImageAttributes } from "../types";
|
||||
import { CustomImageBlock } from "./block";
|
||||
import { CustomImageUploader } from "./uploader";
|
||||
|
||||
export type CustomBaseImageNodeViewProps = {
|
||||
export type CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
|
||||
extension: CustomImageExtension;
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: NodeViewProps["node"] & {
|
||||
attrs: ImageAttributes;
|
||||
attrs: TCustomImageAttributes;
|
||||
};
|
||||
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
|
||||
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
|
||||
const { editor, extension, node } = props;
|
||||
const { src: imgNodeSrc } = node.attrs;
|
||||
|
||||
const [isUploaded, setIsUploaded] = useState(false);
|
||||
@@ -50,41 +51,37 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
}, [resolvedSrc]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!imgNodeSrc) {
|
||||
setResolvedSrc(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const getImageSource = async () => {
|
||||
// @ts-expect-error function not expected here, but will still work and don't remove await
|
||||
const url: string = await editor?.commands?.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url as string);
|
||||
const url = await extension.options.getImageSource?.(imgNodeSrc);
|
||||
setResolvedSrc(url);
|
||||
};
|
||||
getImageSource();
|
||||
}, [imgNodeSrc]);
|
||||
}, [imgNodeSrc, extension.options]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
|
||||
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
|
||||
<CustomImageBlock
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
editorContainer={editorContainer}
|
||||
editor={editor}
|
||||
src={resolvedSrc}
|
||||
getPos={getPos}
|
||||
node={node}
|
||||
imageFromFileSystem={imageFromFileSystem}
|
||||
setEditorContainer={setEditorContainer}
|
||||
setFailedToLoadImage={setFailedToLoadImage}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
src={resolvedSrc}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<CustomImageUploader
|
||||
editor={editor}
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
updateAttributes={updateAttributes}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,14 +1,14 @@
|
||||
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type Props = {
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
};
|
||||
isOpen: boolean;
|
||||
toggleFullScreenMode: (val: boolean) => void;
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { useState } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
// local imports
|
||||
import { ImageFullScreenAction } from "./full-screen";
|
||||
|
||||
type Props = {
|
||||
containerClassName?: string;
|
||||
image: {
|
||||
src: string;
|
||||
height: string;
|
||||
width: string;
|
||||
height: string;
|
||||
aspectRatio: number;
|
||||
src: string;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
import { ImageIcon } from "lucide-react";
|
||||
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { EFileError } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
// local imports
|
||||
import { getImageComponentImageFileMap } from "../utils";
|
||||
import type { CustomImageNodeViewProps } from "./node-view";
|
||||
|
||||
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
type CustomImageUploaderProps = CustomImageNodeViewProps & {
|
||||
failedToLoadImage: boolean;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
maxFileSize: number;
|
||||
setIsUploaded: (isUploaded: boolean) => void;
|
||||
};
|
||||
|
||||
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
const {
|
||||
editor,
|
||||
extension,
|
||||
failedToLoadImage,
|
||||
getPos,
|
||||
loadImageFromFileSystem,
|
||||
@@ -71,12 +73,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
|
||||
const uploadImageEditorCommand = useCallback(
|
||||
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
|
||||
[editor, imageEntityId]
|
||||
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
|
||||
[extension.options, imageEntityId]
|
||||
);
|
||||
|
||||
const handleProgressStatus = useCallback(
|
||||
@@ -93,7 +96,6 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
// hooks
|
||||
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||
editorCommand: uploadImageEditorCommand,
|
||||
handleProgressStatus,
|
||||
loadFileFromFileSystem: loadImageFromFileSystem,
|
||||
@@ -128,7 +130,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
|
||||
}
|
||||
}
|
||||
}, [meta, uploadFile, imageComponentImageFileMap]);
|
||||
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
|
||||
|
||||
const onFileChange = useCallback(
|
||||
async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -163,7 +165,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
}
|
||||
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
|
||||
getImageSource?: (path: string) => () => Promise<string>;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export interface CustomImageExtensionStorage {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
}
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
upload,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
uploadImage: (blockId, file) => async () => {
|
||||
const fileUrl = await upload(blockId, file);
|
||||
return fileUrl;
|
||||
},
|
||||
getImageSource: (path) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { type CustomImageExtension, ECustomImageAttributeNames, type InsertImageComponentProps } from "./types";
|
||||
import { DEFAULT_CUSTOM_IMAGE_ATTRIBUTES } from "./utils";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomImageExtensionConfig: CustomImageExtension = BaseImageExtension.extend({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addAttributes() {
|
||||
const attributes = {
|
||||
...this.parent?.(),
|
||||
...Object.values(ECustomImageAttributeNames).reduce((acc, value) => {
|
||||
acc[value] = {
|
||||
default: DEFAULT_CUSTOM_IMAGE_ATTRIBUTES[value],
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
};
|
||||
|
||||
return attributes;
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
123
packages/editor/src/core/extensions/custom-image/extension.tsx
Normal file
123
packages/editor/src/core/extensions/custom-image/extension.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// types
|
||||
import type { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageNodeView, CustomImageNodeViewProps } from "./components/node-view";
|
||||
import { CustomImageExtensionConfig } from "./extension-config";
|
||||
import { getImageComponentImageFileMap } from "./utils";
|
||||
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const CustomImageExtension = (props: Props) => {
|
||||
const { fileHandler, isEditable } = props;
|
||||
// derived values
|
||||
const { getAssetSrc, restore: restoreImageFn } = fileHandler;
|
||||
|
||||
return CustomImageExtensionConfig.extend({
|
||||
selectable: isEditable,
|
||||
draggable: isEditable,
|
||||
|
||||
addOptions() {
|
||||
const upload = "upload" in fileHandler ? fileHandler.upload : undefined;
|
||||
|
||||
return {
|
||||
...this.parent?.(),
|
||||
getImageSource: getAssetSrc,
|
||||
restoreImage: restoreImageFn,
|
||||
uploadImage: upload,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
const maxFileSize = "validation" in fileHandler ? fileHandler.validation?.maxFileSize : 0;
|
||||
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertImageComponent:
|
||||
(props) =>
|
||||
({ commands }) => {
|
||||
// Early return if there's an invalid file being dropped
|
||||
if (
|
||||
props?.file &&
|
||||
!isFileValid({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize: this.storage.maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate a unique id for the image to keep track of dropped
|
||||
// files' file data
|
||||
const fileId = uuidv4();
|
||||
|
||||
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
|
||||
|
||||
if (imageComponentImageFileMap) {
|
||||
if (props?.event === "drop" && props.file) {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
file: props.file,
|
||||
event: props.event,
|
||||
});
|
||||
} else if (props.event === "insert") {
|
||||
imageComponentImageFileMap.set(fileId, {
|
||||
event: props.event,
|
||||
hasOpenedFileInputOnce: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
id: fileId,
|
||||
};
|
||||
|
||||
if (props.pos) {
|
||||
return commands.insertContentAt(props.pos, {
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
}
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs: attributes,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((props) => (
|
||||
<CustomImageNodeView {...props} node={props.node as CustomImageNodeViewProps["node"]} />
|
||||
));
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./components";
|
||||
export * from "./custom-image";
|
||||
export * from "./read-only-custom-image";
|
||||
@@ -1,79 +0,0 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: false,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
getImageSource: (path: string) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CustomImageNode);
|
||||
},
|
||||
});
|
||||
};
|
||||
51
packages/editor/src/core/extensions/custom-image/types.ts
Normal file
51
packages/editor/src/core/extensions/custom-image/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Node } from "@tiptap/core";
|
||||
// types
|
||||
import type { TFileHandler } from "@/types";
|
||||
|
||||
export enum ECustomImageAttributeNames {
|
||||
ID = "id",
|
||||
WIDTH = "width",
|
||||
HEIGHT = "height",
|
||||
ASPECT_RATIO = "aspectRatio",
|
||||
SOURCE = "src",
|
||||
}
|
||||
|
||||
export type Pixel = `${number}px`;
|
||||
|
||||
export type PixelAttribute<TDefault> = Pixel | TDefault;
|
||||
|
||||
export type TCustomImageSize = {
|
||||
width: PixelAttribute<"35%">;
|
||||
height: PixelAttribute<"auto">;
|
||||
aspectRatio: number | null;
|
||||
};
|
||||
|
||||
export type TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.ID]: string | null;
|
||||
[ECustomImageAttributeNames.WIDTH]: PixelAttribute<"35%" | number> | null;
|
||||
[ECustomImageAttributeNames.HEIGHT]: PixelAttribute<"auto" | number> | null;
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: number | null;
|
||||
[ECustomImageAttributeNames.SOURCE]: string | null;
|
||||
};
|
||||
|
||||
export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File }) & { hasOpenedFileInputOnce?: boolean };
|
||||
|
||||
export type InsertImageComponentProps = {
|
||||
file?: File;
|
||||
pos?: number;
|
||||
event: "insert" | "drop";
|
||||
};
|
||||
|
||||
export type CustomImageExtensionOptions = {
|
||||
getImageSource: TFileHandler["getAssetSrc"];
|
||||
restoreImage: TFileHandler["restore"];
|
||||
uploadImage?: TFileHandler["upload"];
|
||||
};
|
||||
|
||||
export type CustomImageExtensionStorage = {
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
maxFileSize: number;
|
||||
};
|
||||
|
||||
export type CustomImageExtension = Node<CustomImageExtensionOptions, CustomImageExtensionStorage>;
|
||||
33
packages/editor/src/core/extensions/custom-image/utils.ts
Normal file
33
packages/editor/src/core/extensions/custom-image/utils.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// local imports
|
||||
import { ECustomImageAttributeNames, type Pixel, type TCustomImageAttributes } from "./types";
|
||||
|
||||
export const DEFAULT_CUSTOM_IMAGE_ATTRIBUTES: TCustomImageAttributes = {
|
||||
[ECustomImageAttributeNames.SOURCE]: null,
|
||||
[ECustomImageAttributeNames.ID]: null,
|
||||
[ECustomImageAttributeNames.WIDTH]: "35%",
|
||||
[ECustomImageAttributeNames.HEIGHT]: "auto",
|
||||
[ECustomImageAttributeNames.ASPECT_RATIO]: null,
|
||||
};
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export const ensurePixelString = <TDefault>(
|
||||
value: Pixel | TDefault | number | undefined | null,
|
||||
defaultValue?: TDefault
|
||||
) => {
|
||||
if (!value || value === defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return `${value}px` satisfies Pixel;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./list-keymap";
|
||||
@@ -1,377 +0,0 @@
|
||||
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
|
||||
import { Node, NodeType } from "@tiptap/pm/model";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let currentNode: Node | null = null;
|
||||
let currentDepth = $from.depth;
|
||||
let currentPos = $from.pos;
|
||||
let targetDepth: number | null = null;
|
||||
|
||||
while (currentDepth > 0 && targetDepth === null) {
|
||||
currentNode = $from.node(currentDepth);
|
||||
|
||||
if (currentNode.type === nodeType) {
|
||||
targetDepth = currentDepth;
|
||||
} else {
|
||||
currentDepth -= 1;
|
||||
currentPos -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetDepth === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
|
||||
};
|
||||
|
||||
const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth > listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getNextListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
const pos = listItemPos.$pos;
|
||||
|
||||
// Adjust the position to ensure we're within the list item, especially for edge cases
|
||||
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
|
||||
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (
|
||||
[CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes(
|
||||
node.type.name as CORE_EXTENSIONS
|
||||
)
|
||||
) {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
|
||||
// Subtract 1 from the calculated depth to get the parent list's depth
|
||||
// This adjustment is necessary because the depth calculation includes the current list
|
||||
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
|
||||
depth = depth > 0 ? depth - 1 : 0;
|
||||
|
||||
// Double the depth value to get results as 2, 4, 6, 8, etc.
|
||||
depth = depth * 2;
|
||||
|
||||
return depth;
|
||||
};
|
||||
|
||||
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
|
||||
// this is required to still handle the undo handling
|
||||
if (editor.commands.undoInputRule()) {
|
||||
return true;
|
||||
}
|
||||
// Check if a node range is selected, and if so, fall back to default backspace functionality
|
||||
const { from, to } = editor.state.selection;
|
||||
if (from !== to) {
|
||||
// A range is selected, not just a cursor position; fall back to default behavior
|
||||
return false; // Let the editor handle backspace by default
|
||||
}
|
||||
|
||||
// if the current item is NOT inside a list item &
|
||||
// the previous item is a list (orderedList or bulletList)
|
||||
// move the cursor into the list and delete the current item
|
||||
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
|
||||
const { $anchor } = editor.state.selection;
|
||||
|
||||
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
|
||||
|
||||
const listDescendants: Array<{ node: Node; pos: number }> = [];
|
||||
|
||||
$listPos.node().descendants((node, pos) => {
|
||||
if (node.type.name === name) {
|
||||
listDescendants.push({ node, pos });
|
||||
}
|
||||
});
|
||||
|
||||
const lastItem = listDescendants.at(-1);
|
||||
|
||||
if (!lastItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
|
||||
|
||||
// Check if positions are within the valid range
|
||||
const startPos = $anchor.start() - 1;
|
||||
const endPos = $anchor.end() + 1;
|
||||
if (startPos < 0 || endPos > editor.state.doc.content.size) {
|
||||
return false; // Invalid position, abort operation
|
||||
}
|
||||
|
||||
return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run();
|
||||
}
|
||||
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the start of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtStartOfNode(editor.state)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// is the paragraph node inside of the current list item (maybe with a hard break)
|
||||
const isParaSibling = isCurrentParagraphASibling(editor.state);
|
||||
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
|
||||
const listItemPos = findListItemPos(name, editor.state);
|
||||
const nextListItemIsSibling = nextListIsSibling(name, editor.state);
|
||||
|
||||
if (!listItemPos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentNode = listItemPos.$pos.node(listItemPos.depth);
|
||||
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList && isCurrentListItemSublist) {
|
||||
editor.chain().liftListItem(name).run();
|
||||
return editor.commands.joinItemBackward();
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist && nextListItemIsSibling) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isCurrentListItemSublist) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (hasListItemBefore(name, editor.state)) {
|
||||
return editor.chain().liftListItem(name).run();
|
||||
}
|
||||
|
||||
if (!currentListItemHasSubList) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// otherwise in the end, a backspace should
|
||||
// always just lift the list item if
|
||||
// joining / merging is not possible
|
||||
return editor.chain().liftListItem(name).run();
|
||||
};
|
||||
|
||||
export const handleDelete = (editor: Editor, name: string) => {
|
||||
// if the cursor is not inside the current node type
|
||||
// do nothing and proceed
|
||||
if (!isNodeActive(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the cursor is not at the end of a node
|
||||
// do nothing and proceed
|
||||
if (!isAtEndOfNode(editor.state, name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if the next node is a list with a deeper depth
|
||||
if (nextListIsDeeper(name, editor.state)) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus(editor.state.selection.from + 4)
|
||||
.lift(name)
|
||||
.joinBackward()
|
||||
.run();
|
||||
}
|
||||
|
||||
if (nextListIsHigher(name, editor.state)) {
|
||||
return editor.chain().joinForward().joinBackward().run();
|
||||
}
|
||||
|
||||
return editor.commands.joinItemForward();
|
||||
};
|
||||
|
||||
const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
|
||||
const { $anchor } = editorState.selection;
|
||||
|
||||
const previousNodePos = Math.max(0, $anchor.pos - 2);
|
||||
|
||||
const previousNode = editorState.doc.resolve(previousNodePos).node();
|
||||
|
||||
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const prevListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getPrevListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const nextListIsSibling = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth === listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
|
||||
const listDepth = getNextListDepth(typeOrName, state);
|
||||
const listItemPos = findListItemPos(typeOrName, state);
|
||||
|
||||
if (!listItemPos || !listDepth) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (listDepth < listItemPos.depth) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
|
||||
if (!node) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let hasSubList = false;
|
||||
|
||||
node.descendants((child) => {
|
||||
if (child.type === nodeType) {
|
||||
hasSubList = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasSubList;
|
||||
};
|
||||
|
||||
const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
const { $from } = state.selection;
|
||||
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
|
||||
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (
|
||||
currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH &&
|
||||
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS)
|
||||
) {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
paragraphNodesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// If there are more than one paragraph nodes, the current paragraph is a sibling.
|
||||
return paragraphNodesCount > 1;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export function isCursorInSubList(editor: Editor) {
|
||||
const { selection } = editor.state;
|
||||
const { $from } = selection;
|
||||
|
||||
// Check if the current node is a list item
|
||||
const listItem = editor.schema.nodes.listItem;
|
||||
const taskItem = editor.schema.nodes.taskItem;
|
||||
|
||||
// Traverse up the document tree from the current position
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth);
|
||||
if (node.type === listItem || node.type === taskItem) {
|
||||
// If the parent of the list item is also a list, it's a sub-list
|
||||
const parent = $from.node(depth - 1);
|
||||
if (
|
||||
parent &&
|
||||
(parent.type === editor.schema.nodes.bulletList ||
|
||||
parent.type === editor.schema.nodes.orderedList ||
|
||||
parent.type === editor.schema.nodes.taskList)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
|
||||
const { $anchor } = state.selection;
|
||||
|
||||
const $targetPos = state.doc.resolve($anchor.pos - 2);
|
||||
|
||||
if ($targetPos.index() === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers";
|
||||
|
||||
export type ListKeymapOptions = {
|
||||
listTypes: Array<{
|
||||
itemName: string;
|
||||
wrapperNames: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
Extension.create<ListKeymapOptions>({
|
||||
name: "listKeymap",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
listTypes: [
|
||||
{
|
||||
itemName: "listItem",
|
||||
wrapperNames: ["bulletList", "orderedList"],
|
||||
},
|
||||
{
|
||||
itemName: "taskItem",
|
||||
wrapperNames: ["taskList"],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
if (tabIndex !== undefined && tabIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
"Shift-Tab": () => {
|
||||
if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
if (tabIndex !== undefined && tabIndex !== null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
Delete: ({ editor }) => {
|
||||
try {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
handled = true;
|
||||
}
|
||||
});
|
||||
|
||||
return handled;
|
||||
} catch (e) {
|
||||
console.log("Error in handling Delete:", e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"Mod-Delete": ({ editor }) => {
|
||||
let handled = false;
|
||||
|
||||
this.options.listTypes.forEach(({ itemName }) => {
|
||||
if (editor.state.schema.nodes[itemName] === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handleDelete(editor, itemName)) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
476
packages/editor/src/core/extensions/drop-cursor.ts
Normal file
476
packages/editor/src/core/extensions/drop-cursor.ts
Normal file
@@ -0,0 +1,476 @@
|
||||
import { Editor, Extension } from "@tiptap/core";
|
||||
import { Plugin, EditorState, PluginKey, NodeSelection } from "@tiptap/pm/state";
|
||||
import { dropPoint } from "@tiptap/pm/transform";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function dropCursor(options: DropCursorOptions = {}, tiptapEditorOptions: { editor: Editor }): Plugin {
|
||||
const pluginKey = new PluginKey("dropCursor");
|
||||
|
||||
return new Plugin({
|
||||
key: pluginKey,
|
||||
state: {
|
||||
init() {
|
||||
return { dropPosByDropCursorPos: null };
|
||||
},
|
||||
apply(tr, state) {
|
||||
// Get the new state from meta
|
||||
const meta = tr.getMeta(pluginKey);
|
||||
if (meta) {
|
||||
return { dropPosByDropCursorPos: meta.dropPosByDropCursorPos };
|
||||
}
|
||||
return state;
|
||||
},
|
||||
},
|
||||
view(editorView) {
|
||||
return new DropCursorView(editorView, options, tiptapEditorOptions.editor, pluginKey);
|
||||
},
|
||||
props: {
|
||||
handleDrop(view, event, slice, moved) {
|
||||
const { isBetweenFlatLists, isHoveringOverListContent } =
|
||||
rawIsBetweenFlatListsFn(event, tiptapEditorOptions.editor) || {};
|
||||
|
||||
const state = pluginKey.getState(view.state);
|
||||
let dropPosByDropCursorPos = state?.dropPosByDropCursorPos;
|
||||
if (isHoveringOverListContent) {
|
||||
dropPosByDropCursorPos -= 1;
|
||||
}
|
||||
|
||||
if (isBetweenFlatLists && dropPosByDropCursorPos) {
|
||||
const tr = view.state.tr;
|
||||
|
||||
if (moved) {
|
||||
// Get the size of content to be deleted
|
||||
const selection = tr.selection;
|
||||
const deleteSize = selection.to - selection.from;
|
||||
|
||||
// Adjust drop position if it's after the deletion point
|
||||
if (dropPosByDropCursorPos > selection.from) {
|
||||
dropPosByDropCursorPos -= deleteSize;
|
||||
}
|
||||
|
||||
tr.deleteSelection();
|
||||
}
|
||||
|
||||
// Insert the content
|
||||
tr.insert(dropPosByDropCursorPos, slice.content);
|
||||
|
||||
// Create a NodeSelection on the newly inserted content
|
||||
const $pos = tr.doc.resolve(dropPosByDropCursorPos);
|
||||
const node = $pos.nodeAfter;
|
||||
|
||||
if (node) {
|
||||
const nodeSelection = NodeSelection.create(tr.doc, dropPosByDropCursorPos);
|
||||
tr.setSelection(nodeSelection);
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add disableDropCursor to NodeSpec
|
||||
declare module "prosemirror-model" {
|
||||
interface NodeSpec {
|
||||
disableDropCursor?:
|
||||
| boolean
|
||||
| ((view: EditorView, pos: { pos: number; inside: number }, event: DragEvent) => boolean);
|
||||
}
|
||||
}
|
||||
|
||||
class DropCursorView {
|
||||
private width: number;
|
||||
private color: string | undefined;
|
||||
private class: string | undefined;
|
||||
private cursorPos: number | null = null;
|
||||
private element: HTMLElement | null = null;
|
||||
private timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private handlers: { name: string; handler: (event: Event) => void }[];
|
||||
private editor: Editor;
|
||||
|
||||
// Throttled version of our isBetweenFlatListsFn
|
||||
private isBetweenFlatListsFn: (event: DragEvent) => ReturnType<typeof rawIsBetweenFlatListsFn>;
|
||||
|
||||
constructor(
|
||||
private readonly editorView: EditorView,
|
||||
options: DropCursorOptions,
|
||||
editor: Editor,
|
||||
private readonly pluginKey: PluginKey
|
||||
) {
|
||||
this.width = options.width ?? 1;
|
||||
this.color = options.color === false ? undefined : options.color || `rgb(115, 115, 115)`;
|
||||
this.class = options.class;
|
||||
this.editor = editor;
|
||||
|
||||
// Create the throttled function and store for use in dragover
|
||||
this.isBetweenFlatListsFn = createThrottledIsBetweenFlatListsFn(editor);
|
||||
|
||||
this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
|
||||
const 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, isBetweenFlatLists?: boolean) {
|
||||
this.cursorPos = pos;
|
||||
if (pos == null) {
|
||||
if (this.element?.parentNode) {
|
||||
this.element.parentNode.removeChild(this.element);
|
||||
}
|
||||
this.element = null;
|
||||
} else {
|
||||
this.updateOverlay(isBetweenFlatLists);
|
||||
}
|
||||
}
|
||||
|
||||
updateOverlay(isBetweenFlatLists?: boolean) {
|
||||
const isBetweenFlatList = isBetweenFlatLists ?? false;
|
||||
const $pos = this.editorView.state.doc.resolve(this.cursorPos!);
|
||||
const isBlock = !$pos.parent.inlineContent;
|
||||
let rect: Partial<DOMRect> | undefined;
|
||||
const editorDOM = this.editorView.dom;
|
||||
const editorRect = editorDOM.getBoundingClientRect();
|
||||
const scaleX = editorRect.width / editorDOM.offsetWidth;
|
||||
const scaleY = editorRect.height / editorDOM.offsetHeight;
|
||||
|
||||
if (isBlock) {
|
||||
const before = $pos.nodeBefore;
|
||||
const after = $pos.nodeAfter;
|
||||
if (before || after) {
|
||||
const node = this.editorView.nodeDOM(this.cursorPos! - (before ? before.nodeSize : 0));
|
||||
if (node) {
|
||||
const 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;
|
||||
}
|
||||
const halfWidth = (this.width / 2) * scaleY;
|
||||
rect = {
|
||||
left: nodeRect.left,
|
||||
right: nodeRect.right,
|
||||
top: top - halfWidth,
|
||||
bottom: top + halfWidth,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!rect) {
|
||||
const coords = this.editorView.coordsAtPos(this.cursorPos!);
|
||||
const halfWidth = (this.width / 2) * scaleX;
|
||||
rect = {
|
||||
left: coords.left - halfWidth,
|
||||
right: coords.left + halfWidth,
|
||||
top: coords.top,
|
||||
bottom: coords.bottom,
|
||||
};
|
||||
}
|
||||
|
||||
const 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: number, parentTop: number;
|
||||
if (!parent || (parent == document.body && getComputedStyle(parent).position == "static")) {
|
||||
parentLeft = -window.scrollX;
|
||||
parentTop = -window.scrollY;
|
||||
} else {
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const parentScaleX = parentRect.width / parent.offsetWidth;
|
||||
const parentScaleY = parentRect.height / parent.offsetHeight;
|
||||
parentLeft = parentRect.left - parent.scrollLeft * parentScaleX;
|
||||
parentTop = parentRect.top - parent.scrollTop * parentScaleY;
|
||||
}
|
||||
|
||||
// Adjust left if we're between flat lists
|
||||
const finalLeft = (rect.left! - parentLeft) / scaleX;
|
||||
const finalTop = (rect.top! - parentTop) / scaleY;
|
||||
const finalWidth = (rect.right! - rect.left!) / scaleX;
|
||||
const finalHeight = (rect.bottom! - rect.top!) / scaleY;
|
||||
this.element.style.transform = isBetweenFlatList ? `translateX(${-20}px` : `translateX(0px)`;
|
||||
this.element.style.left = finalLeft + "px";
|
||||
this.element.style.top = finalTop + "px";
|
||||
this.element.style.width = finalWidth + "px";
|
||||
this.element.style.height = finalHeight + "px";
|
||||
}
|
||||
|
||||
scheduleRemoval(timeout: number) {
|
||||
if (this.timeout) clearTimeout(this.timeout);
|
||||
this.timeout = setTimeout(() => this.setCursor(null), timeout);
|
||||
}
|
||||
|
||||
dragover(event: DragEvent) {
|
||||
if (!this.editorView.editable) return;
|
||||
|
||||
const pos = this.editorView.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!pos) return;
|
||||
|
||||
// Throttled call to the function
|
||||
const result = this.isBetweenFlatListsFn(event);
|
||||
|
||||
let isHoveringOverListContentVar = false;
|
||||
let isBetweenFlatListsVar = false;
|
||||
if (result) {
|
||||
if ("pos" in result) {
|
||||
const { isBetweenFlatLists, pos: posList, isHoveringOverListContent } = result;
|
||||
isBetweenFlatListsVar = isBetweenFlatLists;
|
||||
isHoveringOverListContentVar = isHoveringOverListContent;
|
||||
if (isBetweenFlatLists && this.element) {
|
||||
pos.pos = posList;
|
||||
}
|
||||
} else {
|
||||
const { isBetweenFlatLists, isHoveringOverListContent } = result;
|
||||
isBetweenFlatListsVar = isBetweenFlatLists;
|
||||
isHoveringOverListContentVar = isHoveringOverListContent;
|
||||
}
|
||||
}
|
||||
|
||||
const node = pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
|
||||
const disableDropCursor = node && node.type.spec.disableDropCursor;
|
||||
const disabled =
|
||||
typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor;
|
||||
|
||||
if (pos && !disabled) {
|
||||
let target = pos.pos;
|
||||
if (this.editorView.dragging && this.editorView.dragging.slice) {
|
||||
const point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice);
|
||||
if (point != null) target = point;
|
||||
}
|
||||
this.dropPosByDropCursorPos = target;
|
||||
this.setCursor(target, !!isBetweenFlatListsVar && !isHoveringOverListContentVar);
|
||||
this.scheduleRemoval(5000);
|
||||
}
|
||||
}
|
||||
|
||||
dragend() {
|
||||
this.scheduleRemoval(20);
|
||||
}
|
||||
|
||||
drop() {
|
||||
this.scheduleRemoval(20);
|
||||
}
|
||||
|
||||
dragleave(event: DragEvent) {
|
||||
const relatedTarget = event.relatedTarget as Node | null;
|
||||
if (relatedTarget && !this.editorView.dom.contains(relatedTarget)) {
|
||||
this.setCursor(null);
|
||||
}
|
||||
}
|
||||
|
||||
set dropPosByDropCursorPos(pos: number | null) {
|
||||
const tr = this.editorView.state.tr;
|
||||
tr.setMeta(this.pluginKey, { dropPosByDropCursorPos: pos });
|
||||
this.editorView.dispatch(tr);
|
||||
}
|
||||
|
||||
get dropPosByDropCursorPos(): number | null {
|
||||
return this.pluginKey.getState(this.editorView.state)?.dropPosByDropCursorPos;
|
||||
}
|
||||
}
|
||||
|
||||
export const DropCursorExtension = Extension.create({
|
||||
name: "dropCursor",
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
dropCursor(
|
||||
{
|
||||
width: 2,
|
||||
class: "transition-all duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
|
||||
},
|
||||
this
|
||||
),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
function findDirectChild(element: HTMLElement, parentClass: string) {
|
||||
const parent = element.closest(`.${parentClass}`);
|
||||
if (!parent) return null;
|
||||
|
||||
// Get all direct children of parent that contain our element
|
||||
const directChildren = Array.from(parent.children);
|
||||
return directChildren.find((child) => child.contains(element));
|
||||
}
|
||||
|
||||
function rawIsBetweenFlatListsFn(event: DragEvent, editor: Editor) {
|
||||
const coords = {
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
};
|
||||
|
||||
const positionCache = new WeakMap();
|
||||
|
||||
const elementUnderDrag = document.elementFromPoint(coords.left, coords.top);
|
||||
if (!elementUnderDrag) return null;
|
||||
|
||||
const currentFlatList = elementUnderDrag.closest(".prosemirror-flat-list");
|
||||
if (!currentFlatList) return null;
|
||||
const currentListContent = currentFlatList.querySelector(".list-content");
|
||||
|
||||
const children = Array.from(currentListContent?.childNodes || []);
|
||||
|
||||
if (event.target instanceof HTMLElement) {
|
||||
const child = findDirectChild(event.target as HTMLElement, "list-content");
|
||||
const offset = children.indexOf(child as HTMLElement);
|
||||
if (offset > 0) {
|
||||
return { isBetweenFlatLists: false, isHoveringOverListContent: false };
|
||||
}
|
||||
}
|
||||
|
||||
let isInsideToggleOrTask = false;
|
||||
if (
|
||||
currentFlatList.getAttribute("data-list-kind") === "toggle" ||
|
||||
currentFlatList.getAttribute("data-list-kind") === "task"
|
||||
) {
|
||||
isInsideToggleOrTask = true;
|
||||
}
|
||||
|
||||
const state = {
|
||||
isHoveringOverListContent: !elementUnderDrag.classList.contains("prosemirror-flat-list"),
|
||||
isBetweenFlatLists: true,
|
||||
hasNestedLists: false,
|
||||
pos: null as number | null,
|
||||
listLevel: 0,
|
||||
isNestedList: false,
|
||||
};
|
||||
|
||||
if (isInsideToggleOrTask) {
|
||||
const firstChildListMarker = currentFlatList.firstChild as HTMLElement;
|
||||
state.isHoveringOverListContent = firstChildListMarker?.classList.contains("list-marker");
|
||||
}
|
||||
|
||||
const getPositionFromElement = (element: Element, some?: boolean): number | null => {
|
||||
if (positionCache.has(element)) {
|
||||
return positionCache.get(element);
|
||||
}
|
||||
|
||||
const pos = editor.view.posAtDOM(element, 0);
|
||||
function getNodeAtPos(state: EditorState, pos: number) {
|
||||
const $pos = state.doc.resolve(pos);
|
||||
return $pos.node();
|
||||
}
|
||||
const editorNode = getNodeAtPos(editor.view.state, pos);
|
||||
|
||||
let result = pos ?? null;
|
||||
if (some) {
|
||||
result = pos + editorNode.nodeSize;
|
||||
}
|
||||
positionCache.set(element, result);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Check for child list within the current list item
|
||||
const childList = currentFlatList?.querySelector(".prosemirror-flat-list");
|
||||
if (childList) {
|
||||
state.pos = getPositionFromElement(childList);
|
||||
state.hasNestedLists = true;
|
||||
state.isNestedList = true;
|
||||
} else {
|
||||
// Existing logic for other cases
|
||||
const sibling = currentFlatList.nextElementSibling;
|
||||
const firstNestedList = currentFlatList.querySelector(":scope > .prosemirror-flat-list");
|
||||
|
||||
const level = getListLevelOptimized(currentFlatList);
|
||||
state.listLevel = level;
|
||||
state.isNestedList = level >= 1;
|
||||
|
||||
if (sibling) {
|
||||
state.pos = getPositionFromElement(sibling);
|
||||
} else if (firstNestedList) {
|
||||
state.pos = getPositionFromElement(firstNestedList);
|
||||
state.hasNestedLists = true;
|
||||
} else if (level >= 1 && !sibling) {
|
||||
const parent = currentFlatList.parentElement?.parentElement;
|
||||
const poss = getPositionFromElement(currentFlatList as Element, true);
|
||||
if (parent) {
|
||||
state.pos = poss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.pos) return null;
|
||||
|
||||
return {
|
||||
...state,
|
||||
pos: state.pos - 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Optimized list level calculation
|
||||
function getListLevelOptimized(element: Element): number {
|
||||
let level = 0;
|
||||
let current = element.parentElement;
|
||||
|
||||
// Use a more efficient selector matching
|
||||
while (current && !current.classList.contains("ProseMirror")) {
|
||||
if (current.classList.contains("prosemirror-flat-list")) {
|
||||
level++;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return level;
|
||||
}
|
||||
|
||||
function createThrottledIsBetweenFlatListsFn(
|
||||
editor: Editor,
|
||||
moveThreshold = 8 // px of mouse movement before re-checking
|
||||
) {
|
||||
let lastX = 0;
|
||||
let lastY = 0;
|
||||
let lastResult: ReturnType<typeof rawIsBetweenFlatListsFn> | null = null;
|
||||
|
||||
return function throttledIsBetweenFlatListsFn(event: DragEvent) {
|
||||
const dx = Math.abs(event.clientX - lastX);
|
||||
const dy = Math.abs(event.clientY - lastY);
|
||||
|
||||
// Only recalc if we moved enough OR enough time passed
|
||||
if (dx < moveThreshold && dy < moveThreshold) {
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
lastX = event.clientX;
|
||||
lastY = event.clientY;
|
||||
lastResult = rawIsBetweenFlatListsFn(event, editor);
|
||||
return lastResult;
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
// import BulletList from "@tiptap/extension-bullet-list";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
// import ListItem from "@tiptap/extension-list-item";
|
||||
// import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
// 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";
|
||||
@@ -16,7 +19,6 @@ import {
|
||||
CustomCodeInlineExtension,
|
||||
CustomColorExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomImageExtension,
|
||||
CustomKeymap,
|
||||
CustomLinkExtension,
|
||||
CustomMentionExtension,
|
||||
@@ -24,12 +26,13 @@ import {
|
||||
CustomTextAlignExtension,
|
||||
CustomTypographyExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
FlatListExtension,
|
||||
UtilityExtension,
|
||||
DropCursorExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
@@ -37,38 +40,27 @@ import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||
import type { IEditorProps } from "@/types";
|
||||
// local imports
|
||||
import { CustomImageExtension } from "./custom-image/extension";
|
||||
|
||||
type TArguments = {
|
||||
disabledExtensions: TExtensions[];
|
||||
type TArguments = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler" | "mentionHandler" | "placeholder" | "tabIndex"
|
||||
> & {
|
||||
enableHistory: boolean;
|
||||
fileHandler: TFileHandler;
|
||||
mentionHandler: TMentionHandler;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
|
||||
const { disabledExtensions, enableHistory, fileHandler, flaggedExtensions, mentionHandler, placeholder, editable } =
|
||||
args;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal pl-7 space-y-[--list-spacing-y]",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "not-prose space-y-2",
|
||||
},
|
||||
},
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
listItem: false,
|
||||
code: false,
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
@@ -83,12 +75,78 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
class: "editor-heading-block",
|
||||
},
|
||||
},
|
||||
dropcursor: {
|
||||
class:
|
||||
"text-custom-text-300 transition-all motion-reduce:transition-none motion-reduce:hover:transform-none duration-200 ease-[cubic-bezier(0.165, 0.84, 0.44, 1)]",
|
||||
},
|
||||
// dropcursor: {
|
||||
// class: "text-custom-text-300",
|
||||
// },
|
||||
dropcursor: false,
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
DropCursorExtension,
|
||||
FlatListExtension,
|
||||
// BulletList.extend({
|
||||
// parseHTML() {
|
||||
// return [];
|
||||
// },
|
||||
// addInputRules() {
|
||||
// return [];
|
||||
// },
|
||||
// }).configure({
|
||||
// HTMLAttributes: {
|
||||
// class: "list-disc pl-7 space-y-2",
|
||||
// },
|
||||
// }),
|
||||
// OrderedList.extend({
|
||||
// parseHTML() {
|
||||
// return [];
|
||||
// },
|
||||
// addInputRules() {
|
||||
// return [];
|
||||
// },
|
||||
// }).configure({
|
||||
// HTMLAttributes: {
|
||||
// class: "list-decimal pl-7 space-y-2",
|
||||
// },
|
||||
// }),
|
||||
// ListItem.extend({
|
||||
// parseHTML() {
|
||||
// return [];
|
||||
// },
|
||||
// addInputRules() {
|
||||
// return [];
|
||||
// },
|
||||
// }).configure({
|
||||
// HTMLAttributes: {
|
||||
// class: "not-prose space-y-2",
|
||||
// },
|
||||
// }),
|
||||
// TaskList.extend({
|
||||
// parseHTML() {
|
||||
// return [];
|
||||
// },
|
||||
// addInputRules() {
|
||||
// return [];
|
||||
// },
|
||||
// }).configure({
|
||||
// HTMLAttributes: {
|
||||
// class: "not-prose pl-2 space-y-2",
|
||||
// },
|
||||
// }),
|
||||
// TaskItem.extend({
|
||||
// parseHTML() {
|
||||
// return [];
|
||||
// },
|
||||
// addInputRules() {
|
||||
// return [];
|
||||
// },
|
||||
// addKeyboardShortcuts() {
|
||||
// return {};
|
||||
// },
|
||||
// }).configure({
|
||||
// HTMLAttributes: {
|
||||
// class: "relative",
|
||||
// },
|
||||
// nested: true,
|
||||
// }),
|
||||
CustomQuoteExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
@@ -96,7 +154,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
},
|
||||
}),
|
||||
CustomKeymap,
|
||||
ListKeymap({ tabIndex }),
|
||||
// ListKeymap({ tabIndex }),
|
||||
CustomLinkExtension.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
@@ -111,24 +169,58 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomTypographyExtension,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2 space-y-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "relative",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
CustomCodeBlockExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
Markdown.extend({
|
||||
addMarkdownSerializerRules() {
|
||||
return {
|
||||
list: (
|
||||
state: {
|
||||
write: (text: string) => void;
|
||||
ensureNewLine: () => void;
|
||||
serializeFragment: (fragment: any) => string;
|
||||
},
|
||||
node: { attrs: Record<string, any>; content: any }
|
||||
) => {
|
||||
// Custom serializer for flat-list nodes
|
||||
const attrs = node.attrs as { kind?: string; order?: number; checked?: boolean; collapsed?: boolean };
|
||||
const listKind = attrs.kind || "bullet";
|
||||
const isChecked = attrs.checked;
|
||||
const isCollapsed = attrs.collapsed;
|
||||
|
||||
// Serialize the content of this list item
|
||||
const content = state.serializeFragment(node.content);
|
||||
|
||||
// Create the appropriate markdown based on list type
|
||||
switch (listKind) {
|
||||
case "task":
|
||||
state.write(`- [${isChecked ? "x" : " "}] ${content}`);
|
||||
break;
|
||||
case "toggle": {
|
||||
const togglePrefix = isCollapsed ? "▶" : "▼";
|
||||
state.write(`- ${togglePrefix} ${content}`);
|
||||
break;
|
||||
}
|
||||
case "ordered": {
|
||||
const orderNum = attrs.order || 1;
|
||||
state.write(`${orderNum}. ${content}`);
|
||||
break;
|
||||
}
|
||||
case "bullet":
|
||||
default:
|
||||
state.write(`- ${content}`);
|
||||
break;
|
||||
}
|
||||
|
||||
state.ensureNewLine();
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({
|
||||
html: true,
|
||||
transformCopiedText: false,
|
||||
transformPastedText: true,
|
||||
@@ -177,21 +269,22 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
flaggedExtensions,
|
||||
fileHandler,
|
||||
}),
|
||||
];
|
||||
|
||||
if (!disabledExtensions.includes("image")) {
|
||||
extensions.push(
|
||||
ImageExtension(fileHandler).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
ImageExtension({
|
||||
fileHandler,
|
||||
}),
|
||||
CustomImageExtension(fileHandler)
|
||||
CustomImageExtension({
|
||||
fileHandler,
|
||||
isEditable: editable,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error tiptap types are incorrect
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Fragment, NodeRange, Slice } from "@tiptap/pm/model"
|
||||
import { type Command, type Transaction } from "@tiptap/pm/state"
|
||||
import { ReplaceAroundStep } from "@tiptap/pm/transform"
|
||||
|
||||
import { withAutoFixList } from '../utils/auto-fix-list'
|
||||
import {
|
||||
atEndBlockBoundary,
|
||||
atStartBlockBoundary,
|
||||
} from '../utils/block-boundary'
|
||||
import { getListType } from '../utils/get-list-type'
|
||||
import { isListNode } from '../utils/is-list-node'
|
||||
import { findListsRange, isListsRange } from '../utils/list-range'
|
||||
import { mapPos } from '../utils/map-pos'
|
||||
import { safeLift } from '../utils/safe-lift'
|
||||
import { zoomInRange } from '../utils/zoom-in-range'
|
||||
|
||||
import { withVisibleSelection } from './set-safe-selection'
|
||||
|
||||
/**
|
||||
* @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(withAutoFixList(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
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { chainCommands, createParagraphNear, newlineInCode, splitBlock } from "@tiptap/pm/commands";
|
||||
import { type Command } from "@tiptap/pm/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);
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Fragment, type NodeRange, Slice } from "@tiptap/pm/model"
|
||||
import { type Command, type Transaction } from "@tiptap/pm/state"
|
||||
import { ReplaceAroundStep } from "@tiptap/pm/transform"
|
||||
|
||||
import { type ListAttributes } from '../types'
|
||||
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'
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type ResolvedPos } from "@tiptap/pm/model"
|
||||
import { type Command, TextSelection } from "@tiptap/pm/state"
|
||||
|
||||
import { type ListAttributes } from '../types'
|
||||
import { atTextblockStart } from '../utils/at-textblock-start'
|
||||
import { isListNode } from '../utils/is-list-node'
|
||||
|
||||
import { joinTextblocksAround } from './join-textblocks-around'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NodeRange, type ResolvedPos } from "@tiptap/pm/model";
|
||||
import { TextSelection, type Command, type EditorState, type Transaction } from "@tiptap/pm/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 before = $cursor.pos - 1;
|
||||
const $before = state.doc.resolve(before);
|
||||
const nodeBefore = $before.nodeBefore;
|
||||
|
||||
// Handle case when there's a list node before
|
||||
if (
|
||||
nodeBefore?.type.name === "list" &&
|
||||
nodeBefore?.lastChild?.isBlock &&
|
||||
!nodeBefore.lastChild.type.name.startsWith("paragraph")
|
||||
) {
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
|
||||
// Get the last child of the list
|
||||
const lastChild = nodeBefore.lastChild;
|
||||
if (!lastChild) return false;
|
||||
|
||||
// Calculate positions
|
||||
const deleteFrom = $before.pos;
|
||||
const deleteTo = deleteFrom + $cursor.parent.nodeSize;
|
||||
|
||||
// Get the content to join
|
||||
const contentToJoin = $cursor.parent.content;
|
||||
|
||||
// Delete the current paragraph
|
||||
tr.delete(deleteFrom, deleteTo);
|
||||
|
||||
// Calculate the position to insert at (end of last list item's content)
|
||||
const insertPos = $before.pos - 1;
|
||||
|
||||
// Insert the content at the end of the last list item
|
||||
tr.insert(insertPos, contentToJoin);
|
||||
|
||||
// Calculate the position of the last child
|
||||
const lastChildPos = $before.pos;
|
||||
|
||||
// Set selection to the end of the last child
|
||||
const $lastChildPos = tr.doc.resolve(lastChildPos);
|
||||
tr.setSelection(TextSelection.near($lastChildPos, -1));
|
||||
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/* eslint-disable prefer-const */
|
||||
|
||||
import { type ResolvedPos, Slice } from "@tiptap/pm/model"
|
||||
import { TextSelection, type Transaction } from "@tiptap/pm/state"
|
||||
import { replaceStep, ReplaceStep } from "@tiptap/pm/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 }
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
chainCommands,
|
||||
deleteSelection,
|
||||
joinTextblockBackward,
|
||||
joinTextblockForward,
|
||||
selectNodeBackward,
|
||||
selectNodeForward,
|
||||
} from "@tiptap/pm/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(),
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
import { type Command, type Transaction } from "@tiptap/pm/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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Command } from "@tiptap/pm/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
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { type ResolvedPos } from "@tiptap/pm/model"
|
||||
import {
|
||||
type Selection,
|
||||
TextSelection,
|
||||
type Transaction,
|
||||
} from "@tiptap/pm/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)
|
||||
@@ -0,0 +1,161 @@
|
||||
import { chainCommands } from "@tiptap/pm/commands";
|
||||
import { Fragment, type Node as ProsemirrorNode, Slice } from "@tiptap/pm/model";
|
||||
import { type Command, type EditorState, Selection, TextSelection, type Transaction } from "@tiptap/pm/state";
|
||||
import { canSplit } from "@tiptap/pm/transform";
|
||||
|
||||
import { type ListAttributes } from "../types";
|
||||
import { withAutoFixList } from "../utils/auto-fix-list";
|
||||
import { createAndFill } from "../utils/create-and-fill";
|
||||
import { isBlockNodeSelection } from "../utils/is-block-node-selection";
|
||||
import { isListNode } from "../utils/is-list-node";
|
||||
import { isTextSelection } from "../utils/is-text-selection";
|
||||
|
||||
import { enterWithoutLift } from "./enter-without-lift";
|
||||
|
||||
/**
|
||||
* Returns a command that split the current list node.
|
||||
*
|
||||
* @public @group Commands
|
||||
*
|
||||
*/
|
||||
export function createSplitListCommand(): Command {
|
||||
return withAutoFixList(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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const parent = $from.parent;
|
||||
|
||||
const indexInList = $from.index(listDepth);
|
||||
const parentEmpty = parent.content.size === 0;
|
||||
|
||||
// When the cursor is inside the first child of the list:
|
||||
// Split and create a new list node.
|
||||
// When the cursor is inside the second or further children of the list:
|
||||
// Create a new paragraph.
|
||||
if (indexInList === 0) {
|
||||
return doSplitList(state, listNode, dispatch);
|
||||
} else {
|
||||
if (parentEmpty) {
|
||||
return enterWithoutLift(state, dispatch);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @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;
|
||||
const atEnd = parentOffset == $to.parent.content.size;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type Command } from "@tiptap/pm/state"
|
||||
|
||||
import { type ListAttributes, type ProsemirrorNode } from '../types'
|
||||
import { isListNode } from '../utils/is-list-node'
|
||||
|
||||
import { setSafeSelection } from './set-safe-selection'
|
||||
|
||||
/**
|
||||
* @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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { chainCommands } from "@tiptap/pm/commands";
|
||||
import { type Command } from "@tiptap/pm/state";
|
||||
|
||||
import { type ListAttributes } from "../types";
|
||||
|
||||
import { createUnwrapListCommand } from "./unwrap-list";
|
||||
import { createWrapInListCommand } from "./wrap-in-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);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type NodeRange } from "@tiptap/pm/model"
|
||||
import { type Command } from "@tiptap/pm/state"
|
||||
|
||||
import { type ListAttributes, type ProsemirrorNode } from '../types'
|
||||
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'
|
||||
|
||||
/**
|
||||
* @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
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { NodeRange } from "@tiptap/pm/model"
|
||||
import { type Command } from "@tiptap/pm/state"
|
||||
import { findWrapping } from "@tiptap/pm/transform"
|
||||
|
||||
import { type ListAttributes } from '../types'
|
||||
import { getListType } from '../utils/get-list-type'
|
||||
import { isListNode } from '../utils/is-list-node'
|
||||
import { setNodeAttributes } from '../utils/set-node-attributes'
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { type Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
import { type Command } from "@tiptap/pm/state";
|
||||
import { type EditorView } from "@tiptap/pm/view";
|
||||
|
||||
import { withSafeSelection } from "./commands/set-safe-selection";
|
||||
import { type ListAttributes } from "./types";
|
||||
import { isListNode } from "./utils/is-list-node";
|
||||
import { setNodeAttributes } from "./utils/set-node-attributes";
|
||||
|
||||
/** @internal */
|
||||
export function handleListMarkerMouseDown({
|
||||
view,
|
||||
event,
|
||||
onListClick = defaultListClickHandler,
|
||||
}: {
|
||||
view: EditorView;
|
||||
event: MouseEvent;
|
||||
onListClick?: ListClickHandler;
|
||||
}): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
|
||||
if (target?.closest(".list-marker-click-target")) {
|
||||
event.preventDefault();
|
||||
|
||||
const pos = view.posAtDOM(target, -10, -10);
|
||||
return handleMouseDown(pos, onListClick)(view.state, (tr) => view.dispatch(tr));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleMouseDown(pos: number, onListClick: ListClickHandler): Command {
|
||||
const mouseDown: Command = (state, dispatch) => {
|
||||
const tr = state.tr;
|
||||
const $pos = tr.doc.resolve(pos);
|
||||
const list = $pos.parent;
|
||||
if (!isListNode(list)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const listPos = $pos.before($pos.depth);
|
||||
const attrs = onListClick(list);
|
||||
if (setNodeAttributes(tr, listPos, list.attrs, attrs)) {
|
||||
dispatch?.(tr);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return withSafeSelection(mouseDown);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export type ListClickHandler = (node: ProsemirrorNode) => ListAttributes;
|
||||
|
||||
/** @internal */
|
||||
export const defaultListClickHandler: ListClickHandler = (node) => {
|
||||
const attrs = node.attrs as ListAttributes;
|
||||
if (attrs.kind === "task") {
|
||||
return { ...attrs, checked: !attrs.checked };
|
||||
} else if (attrs.kind === "toggle") {
|
||||
return { ...attrs, collapsed: !attrs.collapsed };
|
||||
} else {
|
||||
return attrs;
|
||||
}
|
||||
};
|
||||
38
packages/editor/src/core/extensions/flat-list/core/index.ts
Normal file
38
packages/editor/src/core/extensions/flat-list/core/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export { createDedentListCommand, type DedentListOptions } from "./commands/dedent-list";
|
||||
export { enterWithoutLift } from "./commands/enter-without-lift";
|
||||
export { createIndentListCommand, type IndentListOptions } from "./commands/indent-list";
|
||||
export { joinCollapsedListBackward } from "./commands/join-collapsed-backward";
|
||||
export { joinListUp } from "./commands/join-list-up";
|
||||
export { backspaceCommand, deleteCommand, enterCommand, listKeymap } from "./commands/keymap";
|
||||
export { createMoveListCommand } from "./commands/move-list";
|
||||
export { protectCollapsed } from "./commands/protect-collapsed";
|
||||
export { setSafeSelection } from "./commands/set-safe-selection";
|
||||
export { createSplitListCommand, doSplitList } from "./commands/split-list";
|
||||
export { createToggleCollapsedCommand, type ToggleCollapsedOptions } from "./commands/toggle-collapsed";
|
||||
export { createToggleListCommand } from "./commands/toggle-list";
|
||||
export { createUnwrapListCommand, type UnwrapListOptions } from "./commands/unwrap-list";
|
||||
export { createWrapInListCommand, type WrapInListGetAttrs } from "./commands/wrap-in-list";
|
||||
export { defaultListClickHandler, handleListMarkerMouseDown, type ListClickHandler } from "./dom-events";
|
||||
export { listInputRules, wrappingListInputRule, type ListInputRuleAttributesGetter } from "./input-rule";
|
||||
export { migrateDocJSON } from "./migrate";
|
||||
export { createListNodeView } from "./node-view";
|
||||
export {
|
||||
createListClipboardPlugin,
|
||||
createListEventPlugin,
|
||||
createListPlugins,
|
||||
createListRenderingPlugin,
|
||||
createSafariInputMethodWorkaroundPlugin,
|
||||
} from "./plugins";
|
||||
export { createListSpec, flatListGroup } from "./schema/node-spec";
|
||||
export { createParseDomRules } from "./schema/parse-dom";
|
||||
export { defaultAttributesGetter, defaultMarkerGetter, listToDOM, type ListToDOMOptions } from "./schema/to-dom";
|
||||
export type { ListAttributes, ListKind, ProsemirrorNode, ProsemirrorNodeJSON } from "./types";
|
||||
export { getListType } from "./utils/get-list-type";
|
||||
export { isCollapsedListNode } from "./utils/is-collapsed-list-node";
|
||||
export { isListNode } from "./utils/is-list-node";
|
||||
export { isListType } from "./utils/is-list-type";
|
||||
export { findListsRange, isListsRange } from "./utils/list-range";
|
||||
export { ListDOMSerializer, joinListElements } from "./utils/list-serializer";
|
||||
export { parseInteger } from "./utils/parse-integer";
|
||||
export { rangeToString } from "./utils/range-to-string";
|
||||
export { unwrapListSlice } from "./utils/unwrap-list-slice";
|
||||
103
packages/editor/src/core/extensions/flat-list/core/input-rule.ts
Normal file
103
packages/editor/src/core/extensions/flat-list/core/input-rule.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { InputRule } from "@tiptap/pm/inputrules";
|
||||
import { type Attrs } from "@tiptap/pm/model";
|
||||
import { type Transaction } from "@tiptap/pm/state";
|
||||
import { findWrapping } from "@tiptap/pm/transform";
|
||||
|
||||
import { type ListAttributes } from "./types";
|
||||
import { getListType } from "./utils/get-list-type";
|
||||
import { isListNode } from "./utils/is-list-node";
|
||||
import { parseInteger } from "./utils/parse-integer";
|
||||
|
||||
/**
|
||||
* A callback function to get the attributes for a list input rule.
|
||||
*
|
||||
* @public @group Input Rules
|
||||
*/
|
||||
export type ListInputRuleAttributesGetter<T extends ListAttributes = ListAttributes> = (options: {
|
||||
/**
|
||||
* The match result of the regular expression.
|
||||
*/
|
||||
match: RegExpMatchArray;
|
||||
|
||||
/**
|
||||
* The previous attributes of the existing list node, if it exists.
|
||||
*/
|
||||
attributes?: T;
|
||||
}) => T;
|
||||
|
||||
/**
|
||||
* Build an input rule for automatically wrapping a textblock into a list node
|
||||
* when a given string is typed.
|
||||
*
|
||||
* @public @group Input Rules
|
||||
*/
|
||||
export function wrappingListInputRule<T extends ListAttributes = ListAttributes>(
|
||||
regexp: RegExp,
|
||||
getAttrs: T | ListInputRuleAttributesGetter<T>
|
||||
): InputRule {
|
||||
return new InputRule(regexp, (state, match, start, end): Transaction | null => {
|
||||
const tr = state.tr;
|
||||
tr.deleteRange(start, end);
|
||||
|
||||
const $pos = tr.selection.$from;
|
||||
const listNode = $pos.index(-1) === 0 && $pos.node(-1);
|
||||
if (listNode && isListNode(listNode)) {
|
||||
const oldAttrs: Attrs = listNode.attrs as ListAttributes;
|
||||
const newAttrs: Attrs =
|
||||
typeof getAttrs === "function" ? getAttrs({ match, attributes: oldAttrs as T }) : getAttrs;
|
||||
|
||||
const entries = Object.entries(newAttrs).filter(([key, value]) => oldAttrs[key] !== value);
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
const pos = $pos.before(-1);
|
||||
for (const [key, value] of entries) {
|
||||
tr.setNodeAttribute(pos, key, value);
|
||||
}
|
||||
return tr;
|
||||
}
|
||||
}
|
||||
|
||||
const $start = tr.doc.resolve(start);
|
||||
const range = $start.blockRange();
|
||||
if (!range) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newAttrs: Attrs = typeof getAttrs === "function" ? getAttrs({ match }) : getAttrs;
|
||||
const wrapping = findWrapping(range, getListType(state.schema), newAttrs);
|
||||
if (!wrapping) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tr.wrap(range, wrapping);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* All input rules for lists.
|
||||
*
|
||||
* @public @group Input Rules
|
||||
*/
|
||||
export const listInputRules: InputRule[] = [
|
||||
wrappingListInputRule<ListAttributes>(/^\s?([*-])\s$/, {
|
||||
kind: "bullet",
|
||||
collapsed: false,
|
||||
}),
|
||||
wrappingListInputRule<ListAttributes>(/^\s?(\d+)\.\s$/, ({ match }) => {
|
||||
const order = parseInteger(match[1]);
|
||||
return {
|
||||
kind: "ordered",
|
||||
collapsed: false,
|
||||
order: order != null && order >= 2 ? order : null,
|
||||
};
|
||||
}),
|
||||
wrappingListInputRule<ListAttributes>(/^\s?\[([\sXx]?)]\s$/, ({ match }) => ({
|
||||
kind: "task",
|
||||
checked: ["x", "X"].includes(match[1]),
|
||||
collapsed: false,
|
||||
})),
|
||||
wrappingListInputRule<ListAttributes>(/^\s?>>\s$/, {
|
||||
kind: "toggle",
|
||||
}),
|
||||
];
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { ListAttributes, ListKind, ProsemirrorNodeJSON } from "./types";
|
||||
|
||||
function migrateNodes(nodes: ProsemirrorNodeJSON[]): [ProsemirrorNodeJSON[], boolean] {
|
||||
const content: ProsemirrorNodeJSON[] = [];
|
||||
let updated = false;
|
||||
|
||||
for (const node of nodes) {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type Node as ProsemirrorNode, DOMSerializer } from "@tiptap/pm/model";
|
||||
import { type NodeViewConstructor } from "@tiptap/pm/view";
|
||||
|
||||
import * as browser from "./utils/browser";
|
||||
|
||||
/**
|
||||
* A simple node view that is used to render the list node. It ensures that the
|
||||
* list node get updated when its marker styling should changes.
|
||||
*
|
||||
* @public @group Plugins
|
||||
*/
|
||||
export const createListNodeView: NodeViewConstructor = (node) => {
|
||||
let prevNode = node;
|
||||
const prevNested = node.firstChild?.type === node.type;
|
||||
const prevSingleChild = node.childCount === 1;
|
||||
|
||||
const spec = node.type.spec.toDOM!(node);
|
||||
const { dom, contentDOM } = DOMSerializer.renderSpec(document, spec);
|
||||
|
||||
// iOS Safari will jump the text selection around with a toggle list since the element is empty,
|
||||
// and adding an empty span as a child to the click target prevents that behavior
|
||||
// See https://github.com/ocavue/prosemirror-flat-list/issues/89
|
||||
if (browser.safari && node.attrs.kind === "toggle") {
|
||||
(dom as HTMLElement).querySelector(".list-marker-click-target")?.appendChild(document.createElement("span"));
|
||||
}
|
||||
|
||||
const update = (node: ProsemirrorNode): boolean => {
|
||||
if (!node.sameMarkup(prevNode)) return false;
|
||||
const nested = node.firstChild?.type === node.type;
|
||||
const singleChild = node.childCount === 1;
|
||||
if (prevNested !== nested || prevSingleChild !== singleChild) return false;
|
||||
prevNode = node;
|
||||
return true;
|
||||
};
|
||||
|
||||
return { dom, contentDOM, update };
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { type Schema } from "@tiptap/pm/model";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
|
||||
import { ListDOMSerializer } from "../utils/list-serializer";
|
||||
import { unwrapListSlice } from "../utils/unwrap-list-slice";
|
||||
|
||||
/**
|
||||
* Serialize list nodes into native HTML list elements (i.e. `<ul>`, `<ol>`) to
|
||||
* clipboard. See {@link ListDOMSerializer}.
|
||||
*
|
||||
* @public @group Plugins
|
||||
*/
|
||||
export function createListClipboardPlugin(schema: Schema): Plugin {
|
||||
return new Plugin({
|
||||
props: {
|
||||
clipboardSerializer: ListDOMSerializer.fromSchema(schema),
|
||||
|
||||
transformCopied: unwrapListSlice,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
|
||||
import { handleListMarkerMouseDown } from "../dom-events";
|
||||
|
||||
/**
|
||||
* Handle DOM events for list.
|
||||
*
|
||||
* @public @group Plugins
|
||||
*/
|
||||
export function createListEventPlugin(): Plugin {
|
||||
return new Plugin({
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousedown: (view, event) => handleListMarkerMouseDown({ view, event }),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { type Schema } from "@tiptap/pm/model";
|
||||
import { type Plugin } from "@tiptap/pm/state";
|
||||
|
||||
import { createListClipboardPlugin } from "./clipboard";
|
||||
import { createListEventPlugin } from "./event";
|
||||
import { createListRenderingPlugin } from "./rendering";
|
||||
import { createSafariInputMethodWorkaroundPlugin } from "./safari-workaround";
|
||||
|
||||
/**
|
||||
* This function returns an array of plugins that are required for list to work.
|
||||
*
|
||||
* The plugins are shown below. You can pick and choose which plugins you want
|
||||
* to use if you want to customize some behavior.
|
||||
*
|
||||
* - {@link createListEventPlugin}
|
||||
* - {@link createListRenderingPlugin}
|
||||
* - {@link createListClipboardPlugin}
|
||||
* - {@link createSafariInputMethodWorkaroundPlugin}
|
||||
*
|
||||
* @public @group Plugins
|
||||
*/
|
||||
export function createListPlugins({ schema }: { schema: Schema }): Plugin[] {
|
||||
return [
|
||||
createListEventPlugin(),
|
||||
createListRenderingPlugin(),
|
||||
createListClipboardPlugin(schema),
|
||||
createSafariInputMethodWorkaroundPlugin(),
|
||||
];
|
||||
}
|
||||
|
||||
export {
|
||||
createListEventPlugin,
|
||||
createListClipboardPlugin,
|
||||
createListRenderingPlugin,
|
||||
createSafariInputMethodWorkaroundPlugin,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
|
||||
import { createListNodeView } from "../node-view";
|
||||
|
||||
/**
|
||||
* Handle the list node rendering.
|
||||
*
|
||||
* @public @group Plugins
|
||||
*/
|
||||
export function createListRenderingPlugin(): Plugin {
|
||||
return new Plugin({
|
||||
props: {
|
||||
nodeViews: {
|
||||
list: createListNodeView,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { imeSpan } from "prosemirror-safari-ime-span";
|
||||
import { type Plugin } from "@tiptap/pm/state";
|
||||
|
||||
/**
|
||||
* Return a plugin as a workaround for a bug in Safari that causes the composition
|
||||
* based IME to remove the empty HTML element with CSS `position: relative`.
|
||||
*
|
||||
* See also https://github.com/ProseMirror/prosemirror/issues/934
|
||||
*
|
||||
* @public @group Plugins
|
||||
*/
|
||||
export function createSafariInputMethodWorkaroundPlugin(): Plugin {
|
||||
return imeSpan;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { type DOMOutputSpec, type NodeSpec } from "@tiptap/pm/model";
|
||||
|
||||
import { createParseDomRules } from "./parse-dom";
|
||||
import { listToDOM } from "./to-dom";
|
||||
|
||||
/**
|
||||
* The default group name for list nodes. This is used to find the list node
|
||||
* type from the schema.
|
||||
*
|
||||
* @internal Schema
|
||||
*/
|
||||
export const flatListGroup = "flatList";
|
||||
|
||||
export interface ListSpecOptions {
|
||||
content?: string;
|
||||
listTypeName?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the spec for list node.
|
||||
*
|
||||
* @public @group Schema
|
||||
*/
|
||||
export function createListSpec(options: ListSpecOptions = {}): NodeSpec {
|
||||
const { content = "block+", listTypeName = "list", group = `${flatListGroup} block` } = options;
|
||||
|
||||
return {
|
||||
// what content could be inside the block
|
||||
content,
|
||||
// what is the group (an entity specified by which someone could refer
|
||||
// to flatLists if they want to allow it in their content) of the current flatList node
|
||||
group,
|
||||
// AI
|
||||
definingForContent: true,
|
||||
// when selecting and pasting some content over flat lists, do we need
|
||||
// to replace the entire content or not
|
||||
definingAsContext: false,
|
||||
attrs: {
|
||||
kind: {
|
||||
default: "bullet",
|
||||
},
|
||||
order: {
|
||||
default: null,
|
||||
},
|
||||
checked: {
|
||||
default: false,
|
||||
},
|
||||
collapsed: {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
toDOM: (node): DOMOutputSpec => listToDOM({ node }),
|
||||
parseDOM: createParseDomRules(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { type TagParseRule } from "@tiptap/pm/model";
|
||||
|
||||
import { type ListAttributes, type ListKind } from "../types";
|
||||
import { parseInteger } from "../utils/parse-integer";
|
||||
|
||||
/**
|
||||
* Returns a set of rules for parsing HTML into ProseMirror list nodes.
|
||||
*
|
||||
* @public @group Schema
|
||||
*/
|
||||
export function createParseDomRules(): readonly TagParseRule[] {
|
||||
return [
|
||||
{
|
||||
tag: "div[data-list-kind]",
|
||||
getAttrs: (element): ListAttributes => {
|
||||
if (typeof element === "string") {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: (element.getAttribute("data-list-kind") || "bullet") as ListKind,
|
||||
order: parseInteger(element.getAttribute("data-list-order")),
|
||||
checked: element.hasAttribute("data-list-checked"),
|
||||
collapsed: element.hasAttribute("data-list-collapsed"),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "div[data-list]",
|
||||
getAttrs: (element): ListAttributes => {
|
||||
if (typeof element === "string") {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: (element.getAttribute("data-list-kind") || "bullet") as ListKind,
|
||||
order: parseInteger(element.getAttribute("data-list-order")),
|
||||
checked: element.hasAttribute("data-list-checked"),
|
||||
collapsed: element.hasAttribute("data-list-collapsed"),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "ul > li",
|
||||
getAttrs: (element): ListAttributes => {
|
||||
if (typeof element !== "string") {
|
||||
let checkbox = element.firstChild as HTMLElement | null;
|
||||
|
||||
for (let i = 0; i < 3 && checkbox; i++) {
|
||||
if (["INPUT", "UL", "OL", "LI"].includes(checkbox.nodeName)) {
|
||||
break;
|
||||
}
|
||||
checkbox = checkbox.firstChild as HTMLElement | null;
|
||||
}
|
||||
|
||||
if (checkbox && checkbox.nodeName === "INPUT" && checkbox.getAttribute("type") === "checkbox") {
|
||||
return {
|
||||
kind: "task",
|
||||
checked: checkbox.hasAttribute("checked"),
|
||||
};
|
||||
}
|
||||
|
||||
if (element.hasAttribute("data-task-list-item") || element.getAttribute("data-list-kind") === "task") {
|
||||
return {
|
||||
kind: "task",
|
||||
checked: element.hasAttribute("data-list-checked") || element.hasAttribute("data-checked"),
|
||||
};
|
||||
}
|
||||
|
||||
if (element.hasAttribute("data-toggle-list-item") || element.getAttribute("data-list-kind") === "toggle") {
|
||||
return {
|
||||
kind: "toggle",
|
||||
collapsed: element.hasAttribute("data-list-collapsed"),
|
||||
};
|
||||
}
|
||||
|
||||
if (element.firstChild?.nodeType === 3 /* document.TEXT_NODE */) {
|
||||
const textContent = element.firstChild.textContent;
|
||||
if (textContent && /^\[[\sx|]]\s{1,2}/.test(textContent)) {
|
||||
element.firstChild.textContent = textContent.replace(/^\[[\sx|]]\s{1,2}/, "");
|
||||
return {
|
||||
kind: "task",
|
||||
checked: textContent.startsWith("[x]"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "bullet",
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
tag: "ol > li",
|
||||
getAttrs: (element): ListAttributes => {
|
||||
if (typeof element === "string") {
|
||||
return {
|
||||
kind: "ordered",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ordered",
|
||||
order: parseInteger(element.getAttribute("data-list-order")),
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
// This rule is for handling nested lists copied from Dropbox Paper. It's
|
||||
// technically invalid HTML structure.
|
||||
tag: ":is(ul, ol) > :is(ul, ol)",
|
||||
getAttrs: (): ListAttributes => {
|
||||
return {
|
||||
kind: "bullet",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { type DOMOutputSpec, type Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
import { type ListAttributes } from "../types";
|
||||
|
||||
/** @public */
|
||||
export interface ListToDOMOptions {
|
||||
/**
|
||||
* The list node to be rendered.
|
||||
*/
|
||||
node: ProsemirrorNode;
|
||||
|
||||
/**
|
||||
* If `true`, the list will be rendered as a native `<ul>` or `<ol>` element.
|
||||
* You might want to use {@link joinListElements} to join the list elements
|
||||
* afterward.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
nativeList?: boolean;
|
||||
|
||||
/**
|
||||
* An optional function to get elements inside `<div class="list-marker">`.
|
||||
* Return `null` to hide the marker.
|
||||
*/
|
||||
getMarkers?: (node: ProsemirrorNode) => DOMOutputSpec[] | null;
|
||||
|
||||
/**
|
||||
* An optional function to get the attributes added to HTML element.
|
||||
*/
|
||||
getAttributes?: (node: ProsemirrorNode) => Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a list node to DOM output spec.
|
||||
*
|
||||
* @public @group Schema
|
||||
*/
|
||||
export function listToDOM({
|
||||
node,
|
||||
nativeList = false,
|
||||
getMarkers = defaultMarkerGetter,
|
||||
getAttributes = defaultAttributesGetter,
|
||||
}: ListToDOMOptions): DOMOutputSpec {
|
||||
const attrs = node.attrs as ListAttributes;
|
||||
const markerHidden = node.firstChild?.type === node.type;
|
||||
const markers = markerHidden ? null : getMarkers(node);
|
||||
const domAttrs = getAttributes(node);
|
||||
const contentContainer: DOMOutputSpec = ["div", { class: "list-content" }, 0];
|
||||
const markerContainer: DOMOutputSpec | null = markers && [
|
||||
"div",
|
||||
{
|
||||
class: "list-marker list-marker-click-target",
|
||||
// Set `contenteditable` to `false` so that the cursor won't be
|
||||
// moved into the mark container when clicking on it.
|
||||
contenteditable: "false",
|
||||
},
|
||||
...markers,
|
||||
];
|
||||
|
||||
if (nativeList) {
|
||||
const listTag = attrs.kind === "ordered" ? "ol" : "ul";
|
||||
if (markerContainer) {
|
||||
return [listTag, ["li", domAttrs, markerContainer, contentContainer]];
|
||||
} else {
|
||||
return [listTag, ["li", domAttrs, 0]];
|
||||
}
|
||||
} else {
|
||||
if (markerContainer) {
|
||||
return ["div", domAttrs, markerContainer, contentContainer];
|
||||
} else {
|
||||
return ["div", domAttrs, contentContainer];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function defaultMarkerGetter(node: ProsemirrorNode): DOMOutputSpec[] | null {
|
||||
const attrs = node.attrs as ListAttributes;
|
||||
switch (attrs.kind) {
|
||||
case "task":
|
||||
// Use a `label` element here so that the area around the checkbox is also checkable.
|
||||
return [["label", ["input", { type: "checkbox", checked: attrs.checked ? "" : undefined }]]];
|
||||
case "toggle":
|
||||
return [];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
export function defaultAttributesGetter(node: ProsemirrorNode) {
|
||||
const attrs = node.attrs as ListAttributes;
|
||||
const markerHidden = node.firstChild?.type === node.type;
|
||||
const markerType = markerHidden ? undefined : attrs.kind || "bullet";
|
||||
const domAttrs = {
|
||||
class: "prosemirror-flat-list",
|
||||
"data-list-kind": markerType,
|
||||
"data-list-order": attrs.order != null ? String(attrs.order) : undefined,
|
||||
"data-list-checked": attrs.checked ? "" : undefined,
|
||||
"data-list-collapsed": attrs.collapsed ? "" : undefined,
|
||||
"data-list-collapsable": node.childCount >= 2 ? "" : undefined,
|
||||
style: attrs.order != null ? `--prosemirror-flat-list-order: ${attrs.order};` : undefined,
|
||||
};
|
||||
|
||||
return domAttrs;
|
||||
}
|
||||
106
packages/editor/src/core/extensions/flat-list/core/style.css
Normal file
106
packages/editor/src/core/extensions/flat-list/core/style.css
Normal file
@@ -0,0 +1,106 @@
|
||||
.prosemirror-flat-list {
|
||||
& {
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
margin-left: 32px;
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
display: list-item;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
&[data-list-kind="bullet"] {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
&[data-list-kind="ordered"] {
|
||||
/*
|
||||
Ensure that the counters in children don't escape, so that the sub lists
|
||||
won't affect the counter of the parent list.
|
||||
|
||||
See also https://github.com/ocavue/prosemirror-flat-list/issues/23
|
||||
*/
|
||||
& > * {
|
||||
contain: style;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
font-variant-numeric: tabular-nums;
|
||||
content: counter(prosemirror-flat-list-counter, decimal) ". ";
|
||||
}
|
||||
|
||||
counter-increment: prosemirror-flat-list-counter;
|
||||
|
||||
/*
|
||||
Reset the counter for the first list node in the sequence.
|
||||
*/
|
||||
&:first-child,
|
||||
:not(&) + & {
|
||||
counter-reset: prosemirror-flat-list-counter;
|
||||
|
||||
/*
|
||||
If the first list node has a custom order number, set the counter to that value.
|
||||
*/
|
||||
&[data-list-order] {
|
||||
@supports (counter-set: prosemirror-flat-list-counter 1) {
|
||||
counter-set: prosemirror-flat-list-counter var(--prosemirror-flat-list-order);
|
||||
}
|
||||
|
||||
/*
|
||||
Safari older than version 17.2 doesn't support `counter-set`
|
||||
*/
|
||||
@supports not (counter-set: prosemirror-flat-list-counter 1) {
|
||||
counter-increment: prosemirror-flat-list-counter var(--prosemirror-flat-list-order);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-list-kind="task"] {
|
||||
& > .list-marker {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
text-align: center;
|
||||
width: 1.5em;
|
||||
width: 1lh;
|
||||
|
||||
&,
|
||||
& * {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-list-kind="toggle"] {
|
||||
& > .list-marker {
|
||||
position: absolute;
|
||||
right: 100%;
|
||||
text-align: center;
|
||||
width: 1.5em;
|
||||
width: 1lh;
|
||||
}
|
||||
|
||||
& > .list-marker::before {
|
||||
content: "\23F7"; /* Black Medium Down-Pointing Triangle */
|
||||
}
|
||||
&[data-list-collapsable][data-list-collapsed] > .list-marker::before {
|
||||
content: "\23F5"; /* Black Medium Right-Pointing Triangle */
|
||||
}
|
||||
|
||||
&[data-list-collapsable] > .list-marker {
|
||||
cursor: pointer;
|
||||
}
|
||||
&:not([data-list-collapsable]) > .list-marker {
|
||||
opacity: 40%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* If collapsed, hide the second and futher children */
|
||||
&[data-list-collapsable][data-list-collapsed] > .list-content > *:nth-child(n + 2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/editor/src/core/extensions/flat-list/core/types.ts
Normal file
27
packages/editor/src/core/extensions/flat-list/core/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Attrs, Node } from "@tiptap/pm/model";
|
||||
|
||||
/**
|
||||
* All default list node kinds.
|
||||
*
|
||||
* @public @group Schema
|
||||
*/
|
||||
export type ListKind = "bullet" | "ordered" | "task" | "toggle";
|
||||
|
||||
/** @public */
|
||||
export interface ListAttributes {
|
||||
kind?: string;
|
||||
order?: number | null;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ProsemirrorNodeJSON {
|
||||
type: string;
|
||||
marks?: Array<{ type: string; attrs?: Attrs } | string>;
|
||||
text?: string;
|
||||
content?: ProsemirrorNodeJSON[];
|
||||
attrs?: Attrs;
|
||||
}
|
||||
|
||||
export type { Node as ProsemirrorNode };
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type ResolvedPos } from "@tiptap/pm/model";
|
||||
import { type EditorState, type TextSelection } from "@tiptap/pm/state";
|
||||
import { type EditorView } from "@tiptap/pm/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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ResolvedPos } from "@tiptap/pm/model";
|
||||
import { type EditorState, type TextSelection } from "@tiptap/pm/state";
|
||||
import { type EditorView } from "@tiptap/pm/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;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { type Transaction } from "@tiptap/pm/state";
|
||||
import { canJoin, canSplit } from "@tiptap/pm/transform";
|
||||
|
||||
import { type ProsemirrorNode } from "../types";
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type ResolvedPos } from "@tiptap/pm/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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copied from https://github.com/prosemirror/prosemirror-view/blob/1.30.1/src/browser.ts
|
||||
|
||||
const nav = typeof navigator != "undefined" ? navigator : null;
|
||||
const agent = (nav && nav.userAgent) || "";
|
||||
|
||||
const ie_edge = /Edge\/(\d+)/.exec(agent);
|
||||
const ie_upto10 = /MSIE \d/.exec(agent);
|
||||
const ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(agent);
|
||||
|
||||
const ie = !!(ie_upto10 || ie_11up || ie_edge);
|
||||
|
||||
export const safari = !ie && !!nav && /Apple Computer/.test(nav.vendor);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type Attrs, type Fragment, type Mark, type Node as ProsemirrorNode, type NodeType } from "@tiptap/pm/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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type Fragment } from "@tiptap/pm/model";
|
||||
|
||||
export function cutByIndex(fragment: Fragment, from: number, to: number): Fragment {
|
||||
// @ts-expect-error fragment.cutByIndex is internal API
|
||||
return fragment.cutByIndex(from, to);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { type NodeType, type Schema } from "@tiptap/pm/model";
|
||||
|
||||
import { flatListGroup } from "../schema/node-spec";
|
||||
|
||||
/** @internal */
|
||||
export function getListType(schema: Schema, listTypeName: string = "list"): NodeType {
|
||||
const cacheKey = `PROSEMIRROR_FLAT_LIST_${listTypeName.toUpperCase()}_TYPE_NAME`;
|
||||
|
||||
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];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { type ResolvedPos } from "@tiptap/pm/model";
|
||||
|
||||
import { type ListAttributes } from "../types";
|
||||
|
||||
import { isListNode } from "./is-list-node";
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type NodeSelection, type Selection } from "@tiptap/pm/state";
|
||||
|
||||
import { isNodeSelection } from "./is-node-selection";
|
||||
|
||||
export function isBlockNodeSelection(selection: Selection): selection is NodeSelection {
|
||||
return isNodeSelection(selection) && selection.node.type.isBlock;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { type ListAttributes, type ProsemirrorNode } from "../types";
|
||||
|
||||
import { isListNode } from "./is-list-node";
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
export function isCollapsedListNode(node: ProsemirrorNode): boolean {
|
||||
return !!(isListNode(node) && (node.attrs as ListAttributes).collapsed);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { type Node as ProsemirrorNode } from "@tiptap/pm/model";
|
||||
|
||||
import { isListType } from "./is-list-type";
|
||||
|
||||
/** @public */
|
||||
export function isListNode(node: ProsemirrorNode | null | undefined): boolean {
|
||||
if (!node) return false;
|
||||
return isListType(node.type);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type NodeType } from "@tiptap/pm/model";
|
||||
|
||||
import { getListType } from "./get-list-type";
|
||||
|
||||
/** @public */
|
||||
export function isListType(type: NodeType): boolean {
|
||||
return getListType(type.schema) === type;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user