Compare commits

..
Author SHA1 Message Date
VipinDevelops 5a1f8e6e39 Merge branch 'refactor-editor_package' into preview 2025-06-02 19:14:53 +05:30
1151 changed files with 16830 additions and 17178 deletions
-1
View File
@@ -290,6 +290,5 @@ jobs:
${{ github.workspace }}/deploy/selfhost/setup.sh
${{ github.workspace }}/deploy/selfhost/swarm.sh
${{ github.workspace }}/deploy/selfhost/restore.sh
${{ github.workspace }}/deploy/selfhost/restore-airgapped.sh
${{ github.workspace }}/deploy/selfhost/docker-compose.yml
${{ github.workspace }}/deploy/selfhost/variables.env
+3 -3
View File
@@ -69,14 +69,14 @@ chmod +x setup.sh
docker compose -f docker-compose-local.yml up
```
4. Start web apps:
5. Start web apps:
```bash
yarn dev
```
5. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
6. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
6. Open your browser to http://localhost:3001/god-mode/ and register yourself as instance admin
7. Open up your browser to http://localhost:3000 then log in using the same credentials from the previous step
Thats it! Youre all set to begin coding. Remember to refresh your browser if changes dont auto-reload. Happy contributing! 🎉
+6 -4
View File
@@ -67,8 +67,9 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.length >= 0 && (
<div>
<Breadcrumbs>
<Breadcrumbs.Item
component={
<Breadcrumbs.BreadcrumbItem
type="text"
link={
<BreadcrumbLink
href="/general/"
label="Settings"
@@ -79,9 +80,10 @@ export const InstanceHeader: FC = observer(() => {
{breadcrumbItems.map(
(item) =>
item.title && (
<Breadcrumbs.Item
<Breadcrumbs.BreadcrumbItem
key={item.title}
component={<BreadcrumbLink href={item.href} label={item.title} />}
type="text"
link={<BreadcrumbLink href={item.href} label={item.title} />}
/>
)
)}
@@ -1,11 +1,11 @@
import { FC } from "react";
import { Info, X } from "lucide-react";
// plane constants
import { TAdminAuthErrorInfo } from "@plane/constants";
import { TAuthErrorInfo } from "@plane/constants";
type TAuthBanner = {
bannerData: TAdminAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAdminAuthErrorInfo | undefined) => void;
bannerData: TAuthErrorInfo | undefined;
handleBannerData?: (bannerData: TAuthErrorInfo | undefined) => void;
};
export const AuthBanner: FC<TAuthBanner> = (props) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { FC, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Eye, EyeOff } from "lucide-react";
// plane internal packages
import { API_BASE_URL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { API_BASE_URL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { AuthService } from "@plane/services";
import { Button, Input, Spinner } from "@plane/ui";
// components
@@ -54,7 +54,7 @@ export const InstanceSignInForm: FC = (props) => {
const [csrfToken, setCsrfToken] = useState<string | undefined>(undefined);
const [formData, setFormData] = useState<TFormData>(defaultFromData);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorInfo, setErrorInfo] = useState<TAdminAuthErrorInfo | undefined>(undefined);
const [errorInfo, setErrorInfo] = useState<TAuthErrorInfo | undefined>(undefined);
const handleFormChange = (key: keyof TFormData, value: string | boolean) =>
setFormData((prev) => ({ ...prev, [key]: value }));
+2 -2
View File
@@ -3,7 +3,7 @@ import Image from "next/image";
import Link from "next/link";
import { KeyRound, Mails } from "lucide-react";
// plane packages
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAdminAuthErrorInfo } from "@plane/constants";
import { SUPPORT_EMAIL, EAdminAuthErrorCodes, TAuthErrorInfo } from "@plane/constants";
import { TGetBaseAuthenticationModeProps, TInstanceAuthenticationModes } from "@plane/types";
import { resolveGeneralTheme } from "@plane/utils";
// components
@@ -89,7 +89,7 @@ const errorCodeMessages: {
export const authErrorHandler = (
errorCode: EAdminAuthErrorCodes,
email?: string | undefined
): TAdminAuthErrorInfo | undefined => {
): TAuthErrorInfo | undefined => {
const bannerAlertErrorCodes = [
EAdminAuthErrorCodes.ADMIN_ALREADY_EXIST,
EAdminAuthErrorCodes.REQUIRED_ADMIN_EMAIL_PASSWORD_FIRST_NAME,
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"description": "Admin UI for Plane",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"scripts": {
@@ -31,7 +31,7 @@
"lucide-react": "^0.469.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "14.2.30",
"next": "^14.2.29",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -50,6 +50,6 @@
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plane-api",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"description": "API server powering Plane's backend"
+1 -7
View File
@@ -58,7 +58,7 @@ from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
from .base import BaseAPIView
from plane.utils.host import base_host
from plane.bgtasks.webhook_task import model_activity
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
class WorkspaceIssueAPIEndpoint(BaseAPIView):
"""
@@ -692,9 +692,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
link = IssueLink.objects.get(pk=serializer.data["id"])
link.created_by_id = request.data.get("created_by", request.user.id)
@@ -722,9 +719,6 @@ class IssueLinkAPIEndpoint(BaseAPIView):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
type="link.activity.updated",
requested_data=requested_data,
+3 -8
View File
@@ -1,5 +1,7 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -196,7 +198,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -214,14 +215,8 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
return project.identifier if project else None
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
+3 -3
View File
@@ -4,14 +4,14 @@ from plane.app.views import ApiTokenEndpoint, ServiceApiTokenEndpoint
urlpatterns = [
# API Tokens
path(
"users/api-tokens/",
"workspaces/<str:slug>/api-tokens/",
ApiTokenEndpoint.as_view(),
name="api-tokens",
),
path(
"users/api-tokens/<uuid:pk>/",
"workspaces/<str:slug>/api-tokens/<uuid:pk>/",
ApiTokenEndpoint.as_view(),
name="api-tokens-details",
name="api-tokens",
),
path(
"workspaces/<str:slug>/service-api-tokens/",
-12
View File
@@ -13,8 +13,6 @@ from plane.app.views import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
@@ -91,14 +89,4 @@ urlpatterns = [
AssetCheckEndpoint.as_view(),
name="asset-check",
),
path(
"assets/v2/workspaces/<str:slug>/download/<uuid:asset_id>/",
WorkspaceAssetDownloadEndpoint.as_view(),
name="workspace-asset-download",
),
path(
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/download/<uuid:asset_id>/",
ProjectAssetDownloadEndpoint.as_view(),
name="project-asset-download",
),
]
-2
View File
@@ -107,8 +107,6 @@ from .asset.v2 import (
ProjectAssetEndpoint,
ProjectBulkAssetEndpoint,
AssetCheckEndpoint,
WorkspaceAssetDownloadEndpoint,
ProjectAssetDownloadEndpoint,
)
from .issue.base import (
IssueListEndpoint,
+19 -11
View File
@@ -1,10 +1,8 @@
# 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
@@ -15,9 +13,12 @@ from plane.app.permissions import WorkspaceEntityPermission
class ApiTokenEndpoint(BaseAPIView):
def post(self, request: Request) -> Response:
permission_classes = [WorkspaceEntityPermission]
def post(self, request, slug):
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
@@ -27,6 +28,7 @@ class ApiTokenEndpoint(BaseAPIView):
label=label,
description=description,
user=request.user,
workspace=workspace,
user_type=user_type,
expired_at=expired_at,
)
@@ -35,23 +37,29 @@ class ApiTokenEndpoint(BaseAPIView):
# Token will be only visible while creating
return Response(serializer.data, status=status.HTTP_201_CREATED)
def get(self, request: Request, pk: Optional[str] = None) -> Response:
def get(self, request, slug, pk=None):
if pk is None:
api_tokens = APIToken.objects.filter(user=request.user, is_service=False)
api_tokens = APIToken.objects.filter(
user=request.user, workspace__slug=slug, 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, pk=pk)
api_tokens = APIToken.objects.get(
user=request.user, workspace__slug=slug, pk=pk
)
serializer = APITokenReadSerializer(api_tokens)
return Response(serializer.data, status=status.HTTP_200_OK)
def delete(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk, is_service=False)
def delete(self, request, slug, pk):
api_token = APIToken.objects.get(
workspace__slug=slug, user=request.user, pk=pk, is_service=False
)
api_token.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
def patch(self, request: Request, pk: str) -> Response:
api_token = APIToken.objects.get(user=request.user, pk=pk)
def patch(self, request, slug, pk):
api_token = APIToken.objects.get(workspace__slug=slug, user=request.user, pk=pk)
serializer = APITokenSerializer(api_token, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
@@ -62,7 +70,7 @@ class ApiTokenEndpoint(BaseAPIView):
class ServiceApiTokenEndpoint(BaseAPIView):
permission_classes = [WorkspaceEntityPermission]
def post(self, request: Request, slug: str) -> Response:
def post(self, request, slug):
workspace = Workspace.objects.get(slug=slug)
api_token = APIToken.objects.filter(
-53
View File
@@ -718,56 +718,3 @@ class AssetCheckEndpoint(BaseAPIView):
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
).exists()
return Response({"exists": asset}, status=status.HTTP_200_OK)
class WorkspaceAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
def get(self, request, slug, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
class ProjectAssetDownloadEndpoint(BaseAPIView):
"""Endpoint to generate a download link for an asset with content-disposition=attachment."""
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="PROJECT")
def get(self, request, slug, project_id, asset_id):
try:
asset = FileAsset.objects.get(
id=asset_id,
workspace__slug=slug,
project_id=project_id,
is_uploaded=True,
)
except FileAsset.DoesNotExist:
return Response(
{"error": "The requested asset could not be found."},
status=status.HTTP_404_NOT_FOUND,
)
storage = S3Storage(request=request)
signed_url = storage.generate_presigned_url(
object_name=asset.asset.name,
disposition=f"attachment; filename={asset.asset.name}",
)
return HttpResponseRedirect(signed_url)
+1 -26
View File
@@ -944,33 +944,9 @@ 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)
.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,
)
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(
cycle_id=Subquery(
@@ -1038,7 +1014,6 @@ class IssueDetailEndpoint(BaseAPIView):
.values("count")
)
)
issue = issue.filter(**filters)
order_by_param = request.GET.get("order_by", "-created_at")
# Issue queryset
+2 -2
View File
@@ -45,7 +45,7 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
crawl_work_item_link_title.delay(
crawl_work_item_link_title(
serializer.data.get("id"), serializer.data.get("url")
)
issue_activity.delay(
@@ -78,7 +78,7 @@ class IssueLinkViewSet(BaseViewSet):
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
crawl_work_item_link_title.delay(
crawl_work_item_link_title(
serializer.data.get("id"), serializer.data.get("url")
)
+2 -8
View File
@@ -341,10 +341,7 @@ class ProjectViewSet(BaseViewSet):
except IntegrityError as e:
if "already exists" in str(e):
return Response(
{
"name": "The project name is already taken",
"code": "PROJECT_NAME_ALREADY_EXIST",
},
{"name": "The project name is already taken"},
status=status.HTTP_409_CONFLICT,
)
except Workspace.DoesNotExist:
@@ -353,10 +350,7 @@ class ProjectViewSet(BaseViewSet):
)
except serializers.ValidationError:
return Response(
{
"identifier": "The project identifier is already taken",
"code": "PROJECT_IDENTIFIER_ALREADY_EXIST",
},
{"identifier": "The project identifier is already taken"},
status=status.HTTP_409_CONFLICT,
)
+1 -7
View File
@@ -168,8 +168,6 @@ class ProjectMemberViewSet(BaseViewSet):
workspace__slug=slug,
member__is_bot=False,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
).select_related("project", "member", "workspace")
serializer = ProjectMemberRoleSerializer(
@@ -315,11 +313,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
def get(self, request, slug):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
member__member_workspace__workspace__slug=slug,
member__member_workspace__is_active=True,
workspace__slug=slug, member_id=request.user.id, is_active=True
).values("project_id", "role")
project_members = {
+5 -2
View File
@@ -36,7 +36,7 @@ from plane.db.models import (
Workspace,
WorkspaceMember,
WorkspaceTheme,
Profile,
Profile
)
from plane.app.permissions import ROLE, allow_permission
from django.utils.decorators import method_decorator
@@ -159,13 +159,14 @@ class WorkSpaceViewSet(BaseViewSet):
def partial_update(self, request, *args, **kwargs):
return super().partial_update(request, *args, **kwargs)
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
"""
Remove the last workspace id from the user settings
"""
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
return
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
def destroy(self, request, *args, **kwargs):
# Get the workspace
@@ -178,6 +179,8 @@ class UserWorkSpacesEndpoint(BaseAPIView):
search_fields = ["name"]
filterset_fields = ["owner"]
@method_decorator(cache_control(private=True, max_age=12))
@method_decorator(vary_on_cookie)
def get(self, request):
fields = [field for field in request.GET.get("fields", "").split(",") if field]
member_count = (
@@ -1,6 +1,5 @@
# Django imports
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
from django.utils import timezone
from django.db.models.functions import Coalesce
# Third party modules
@@ -134,7 +133,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
workspace_member.is_active = False
workspace_member.save()
@@ -195,7 +194,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
# # Deactivate the users from the projects where the user is part of
_ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now())
).update(is_active=False)
# # Deactivate the user
workspace_member.is_active = False
@@ -284,7 +284,6 @@ def send_email_notification(
"project": str(issue.project.name),
"user_preference": f"{base_api}/profile/preferences/email",
"comments": comments,
"entity_type": "issue",
}
html_content = render_to_string(
"emails/notifications/issue-updates.html", context
+44 -36
View File
@@ -19,6 +19,17 @@ logger = logging.getLogger("plane.worker")
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"""
Crawls a URL to extract the title and favicon.
@@ -46,18 +57,17 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
}
soup = None
title = None
# Fetch the main page
response = requests.get(url, headers=headers, timeout=2)
try:
response = requests.get(url, headers=headers, timeout=1)
response.raise_for_status()
soup = BeautifulSoup(response.content, "html.parser")
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else None
# Parse HTML
soup = BeautifulSoup(response.content, "html.parser")
except requests.RequestException as e:
logger.warning(f"Failed to fetch HTML for title: {str(e)}")
# Extract title
title_tag = soup.find("title")
title = title_tag.get_text().strip() if title_tag else None
# Fetch and encode favicon
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
@@ -72,6 +82,14 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
return result
except requests.RequestException as e:
log_exception(e)
return {
"error": f"Request failed: {str(e)}",
"title": None,
"favicon": None,
"url": url,
}
except Exception as e:
log_exception(e)
return {
@@ -82,7 +100,7 @@ def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
}
def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[str]:
def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
"""
Find the favicon URL from HTML soup.
@@ -93,20 +111,18 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
Returns:
str: Absolute URL to favicon or None
"""
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
if soup is not None:
# Look for various favicon link tags
favicon_selectors = [
'link[rel="icon"]',
'link[rel="shortcut icon"]',
'link[rel="apple-touch-icon"]',
'link[rel="apple-touch-icon-precomposed"]',
]
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
for selector in favicon_selectors:
favicon_tag = soup.select_one(selector)
if favicon_tag and favicon_tag.get("href"):
return urljoin(base_url, favicon_tag["href"])
# Fallback to /favicon.ico
parsed_url = urlparse(base_url)
@@ -115,6 +131,7 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
# Check if fallback exists
try:
response = requests.head(fallback_url, timeout=2)
response.raise_for_status()
if response.status_code == 200:
return fallback_url
except requests.RequestException as e:
@@ -125,8 +142,8 @@ def find_favicon_url(soup: Optional[BeautifulSoup], base_url: str) -> Optional[s
def fetch_and_encode_favicon(
headers: Dict[str, str], soup: Optional[BeautifulSoup], url: str
) -> Dict[str, Optional[str]]:
headers: Dict[str, str], soup: BeautifulSoup, url: str
) -> Optional[Dict[str, str]]:
"""
Fetch favicon and encode it as base64.
@@ -145,7 +162,8 @@ def fetch_and_encode_favicon(
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
response = requests.get(favicon_url, headers=headers, timeout=1)
response = requests.get(favicon_url, headers=headers, timeout=2)
response.raise_for_status()
# Get content type
content_type = response.headers.get("content-type", "image/x-icon")
@@ -165,13 +183,3 @@ def fetch_and_encode_favicon(
"favicon_url": None,
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
}
@shared_task
def crawl_work_item_link_title(id: str, url: str) -> None:
meta_data = crawl_work_item_link_title_and_favicon(url)
issue_link = IssueLink.objects.get(id=id)
issue_link.metadata = meta_data
issue_link.save()
@@ -1,23 +0,0 @@
# Generated by Django 4.2.21 on 2025-06-06 12:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0096_user_is_email_valid_user_masked_at'),
]
operations = [
migrations.AddField(
model_name='project',
name='external_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='project',
name='external_source',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
-3
View File
@@ -122,9 +122,6 @@ class Project(BaseModel):
# timezone
TIMEZONE_CHOICES = tuple(zip(pytz.all_timezones, pytz.all_timezones))
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
# external_id for imports
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
@property
def cover_image_url(self):
+3 -45
View File
@@ -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,52 +69,10 @@ 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
@@ -1,372 +0,0 @@
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
@@ -1,75 +0,0 @@
import pytest
from plane.db.models import (
Workspace,
Project,
Issue,
User,
IssueAssignee,
WorkspaceMember,
ProjectMember,
)
from plane.app.serializers.workspace import IssueRecentVisitSerializer
from django.utils import timezone
@pytest.mark.unit
class TestIssueRecentVisitSerializer:
"""Test the IssueRecentVisitSerializer"""
def test_issue_recent_visit_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
test_user_1 = User.objects.create(
email="test_user_1@example.com", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", owner=test_user_1
)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.22
Django==4.2.21
# rest framework
djangorestframework==3.15.2
# postgres
+1 -1
View File
@@ -9,4 +9,4 @@ factory-boy==3.3.0
freezegun==1.2.2
coverage==7.2.7
httpx==0.24.1
requests==2.32.4
requests==2.32.2
@@ -3,7 +3,7 @@
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Updates on {{entity_type}}</title>
<title>Updates on issue</title>
<style type="text/css" emogrify="no"> html { font-family: system-ui; } p, h1, h2, h3, h4, ol, ul { margin: 0; } h-full { height: 100%; } a:hover { color: #3358d4 !important; } </style>
<style> *[class="gmail-fix"] { display: none !important; } </style>
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: " \03D1"; } } </style>
@@ -37,7 +37,7 @@
{% else %}
<p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> {{summary}} <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {% if data|length > 0 %} {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name}} {% else %} {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name}} {% endif %} </span>and others. </p>
{% endif %} <!-- {% if actors_involved == 1 %} {% if data|length > 0 and comments|length == 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} to the issue. </p> {% elif data|length == 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ comments.0.actor_detail.first_name}} {{comments.0.actor_detail.last_name }} </span> added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %}. </p> {% elif data|length > 0 and comments|length > 0 %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> <span style="font-size: 1rem; font-weight: 700; line-height: 28px"> {{ data.0.actor_detail.first_name}} {{data.0.actor_detail.last_name }} </span> made {{total_updates}} {% if total_updates > 1 %}updates{% else %}update{% endif %} and added {{total_comments}} new {% if total_comments > 1 %}comments{% else %}comment{% endif %} on the issue. </p> {% endif %} {% else %} <p style="font-size: 1rem;color: #1f2d5c; line-height: 28px"> There are {{ total_updates }} new updates and {{total_comments}} new comments on the issue. </p> {% endif %} --> {% for update in data %} {% if update.changes.name %} <!-- Issue title updated -->
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The {{entity_type}} title has been updated to {{ issue.name}} </p>
<p style="font-size: 1rem; line-height: 28px; color: #1f2d5c"> The issue title has been updated to {{ issue.name}} </p>
{% endif %} <!-- Outer update Box start --> {% if data %}
<div style=" background-color: #f7f9ff; border-radius: 8px; border-style: solid; border-width: 1px; border-color: #c1d0ff; padding: 20px; margin-top: 15px; max-width: 100%; " >
<!-- Block Heading -->
@@ -224,7 +224,7 @@
{% endif %}
</div>
<a href="{{ issue_url }}" style="text-decoration: none;">
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View {{entity_type}} </div>
<div style=" max-width: min-content; white-space: nowrap; background-color: #3e63dd; padding: 10px 15px; border: 1px solid #2f4ba8; border-radius: 4px; margin-top: 15px; cursor: pointer; font-size: 0.8rem; color: white; " > View issue </div>
</a>
</div>
<!-- Footer -->
@@ -232,7 +232,7 @@
<tr>
<td>
<div style="font-size: 0.8rem; color: #1c2024">
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the {{entity_type}}</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
This email was sent to <a href="mailto:{{receiver.email}}" style="color: #3a5bc7; font-weight: 500; text-decoration: none" >{{ receiver.email }}.</a > If you'd rather not receive this kind of email, <a href="{{ issue_url }}" style="color: #3a5bc7; text-decoration: none" >you can unsubscribe to the issue</a > or <a href="{{ user_preference }}" style="color: #3a5bc7; text-decoration: none" >manage your email preferences</a >. <!-- Github | LinkedIn | Twitter -->
<div style="margin-top: 60px; float: right"> <a href="https://github.com/makeplane" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/github_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/linkedin_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> <a href="https://twitter.com/planepowers" target="_blank" style="margin-left: 10px; text-decoration: none" > <img src="https://creative-assets.mailinblue.com/editor/social-icons/rounded_colored/twitter_32px.png" width="25" height="25" border="0" style="display: inline-block" /> </a> </div>
</div>
</td>
+1 -26
View File
@@ -486,7 +486,7 @@ When you want to restore the previously backed-up data, follow the instructions
1. Download the restore script using the command below. We suggest downloading it in the same folder as `setup.sh`.
```bash
curl -fsSL -o restore.sh https://github.com/makeplane/plane/releases/latest/download/restore.sh
curl -fsSL -o restore.sh https://raw.githubusercontent.com/makeplane/plane/master/deploy/selfhost/restore.sh
chmod +x restore.sh
```
@@ -529,31 +529,6 @@ When you want to restore the previously backed-up data, follow the instructions
---
### Restore for Commercial Air-Gapped (Docker Compose)
When you want to restore the previously backed-up data on Plane Commercial Air-Gapped version, follow the instructions below.
1. Download the restore script using the command below
```bash
curl -fsSL -o restore-airgapped.sh https://github.com/makeplane/plane/releases/latest/download/restore-airgapped.sh
chmod +x restore-airgapped.sh
```
1. Copy the backup folder and the `restore-airgapped.sh` to `Commercial Airgapped Edition` server
1. Make sure that Plane Commercial (Airgapped) is extracted and ready to get started. In case it is running, you would need to stop that.
1. Execute the command below to restore your data.
```bash
./restore-airgapped.sh <path to backup folder containing *.tar.gz files>
```
1. After restoration, you are ready to start Plane Commercial (Airgapped) will all your previously saved data.
---
<details>
<summary><h2>Upgrading from v0.13.2 to v0.14.x</h2></summary>
-144
View File
@@ -1,144 +0,0 @@
#!/bin/bash
+set -euo pipefail
function print_header() {
clear
cat <<"EOF"
--------------------------------------------
____ _ /////////
| _ \| | __ _ _ __ ___ /////////
| |_) | |/ _` | '_ \ / _ \ ///// /////
| __/| | (_| | | | | __/ ///// /////
|_| |_|\__,_|_| |_|\___| ////
////
--------------------------------------------
Project management tool from the future
--------------------------------------------
EOF
}
function restoreData() {
echo ""
echo "****************************************************"
echo "We are about to restore your data from the backup files."
echo "****************************************************"
echo ""
# set the backup folder path
BACKUP_FOLDER=${1}
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
read -p "Enter the backup folder path [$BACKUP_FOLDER]: " BACKUP_FOLDER
if [ -z "$BACKUP_FOLDER" ]; then
BACKUP_FOLDER="$PWD/backup"
fi
fi
# check if the backup folder exists
if [ ! -d "$BACKUP_FOLDER" ]; then
echo "Error: Backup folder not found at $BACKUP_FOLDER"
exit 1
fi
# check if there are any .tar.gz files in the backup folder
if ! ls "$BACKUP_FOLDER"/*.tar.gz 1> /dev/null 2>&1; then
echo "Error: Backup folder does not contain .tar.gz files"
exit 1
fi
echo ""
echo "Using backup folder: $BACKUP_FOLDER"
echo ""
# ask for current install path
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
read -p "Enter the airgapped instance install path [$AIRGAPPED_INSTALL_PATH]: " AIRGAPPED_INSTALL_PATH
if [ -z "$AIRGAPPED_INSTALL_PATH" ]; then
AIRGAPPED_INSTALL_PATH="$HOME/planeairgapped"
fi
# check if the airgapped instance install path exists
if [ ! -d "$AIRGAPPED_INSTALL_PATH" ]; then
echo "Error: Airgapped instance install path not found at $AIRGAPPED_INSTALL_PATH"
exit 1
fi
echo ""
echo "Using airgapped instance install path: $AIRGAPPED_INSTALL_PATH"
echo ""
# check if the docker-compose.yaml exists
if [ ! -f "$AIRGAPPED_INSTALL_PATH/docker-compose.yml" ]; then
echo "Error: docker-compose.yml not found at $AIRGAPPED_INSTALL_PATH/docker-compose.yml"
exit 1
fi
local dockerServiceStatus
if command -v jq &> /dev/null; then
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped --format=json | jq -r .[0].Status)
else
dockerServiceStatus=$($COMPOSE_CMD ls --filter name=plane-airgapped | grep -o "running" | head -n 1)
fi
if [[ $dockerServiceStatus == "running" ]]; then
echo "Plane Airgapped is running. Please STOP the Plane Airgapped before restoring data."
exit 1
fi
CURRENT_USER_ID=$(id -u)
CURRENT_GROUP_ID=$(id -g)
# if the data folder not exists, create it
if [ ! -d "$AIRGAPPED_INSTALL_PATH/data" ]; then
mkdir -p "$AIRGAPPED_INSTALL_PATH/data"
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data"
fi
for BACKUP_FILE in "$BACKUP_FOLDER/*.tar.gz"; do
if [ -e "$BACKUP_FILE" ]; then
# get the basefilename without the extension
BASE_FILE_NAME=$(basename "$BACKUP_FILE" ".tar.gz")
# extract the restoreFile to the airgapped instance install path
echo "Restoring $BASE_FILE_NAME"
rm -rf "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME" || true
tar -xvzf "$BACKUP_FILE" -C "$AIRGAPPED_INSTALL_PATH/data/"
if [ $? -ne 0 ]; then
echo "Error: Failed to extract $BACKUP_FILE"
exit 1
fi
chown -R $CURRENT_USER_ID:$CURRENT_GROUP_ID "$AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
if [ $? -ne 0 ]; then
echo "Error: Failed to change ownership of $AIRGAPPED_INSTALL_PATH/data/$BASE_FILE_NAME"
exit 1
fi
else
echo "No .tar.gz files found in the current directory."
echo ""
echo "Please provide the path to the backup file."
echo ""
echo "Usage: $0 /path/to/backup"
exit 1
fi
done
echo ""
echo "Restore completed successfully."
echo ""
}
# if docker-compose is installed
if command -v docker-compose &> /dev/null
then
COMPOSE_CMD="docker-compose"
else
COMPOSE_CMD="docker compose"
fi
print_header
restoreData "$@"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"description": "A realtime collaborative server powers Plane's rich text editor",
"main": "./src/server.ts",
@@ -23,8 +23,8 @@
"@plane/constants": "*",
"@plane/editor": "*",
"@plane/types": "*",
"@tiptap/core": "^2.22.3",
"@tiptap/html": "^2.22.3",
"@tiptap/core": "2.10.4",
"@tiptap/html": "2.11.0",
"axios": "^1.8.3",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -58,6 +58,6 @@
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "5.3.3"
}
}
+3 -6
View File
@@ -2,7 +2,7 @@
"name": "plane",
"description": "Open-source project management that unlocks customer value",
"repository": "https://github.com/makeplane/plane.git",
"version": "0.26.1",
"version": "0.26.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -24,17 +24,14 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.5.4"
"turbo": "^2.5.3"
},
"resolutions": {
"brace-expansion": "2.0.2",
"nanoid": "3.3.8",
"esbuild": "0.25.0",
"@babel/helpers": "7.26.10",
"@babel/runtime": "7.26.10",
"chokidar": "3.6.0",
"tar-fs": "3.0.9",
"prosemirror-view": "1.40.0"
"chokidar": "3.6.0"
},
"packageManager": "yarn@1.22.22"
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.26.1",
"version": "0.26.0",
"private": true,
"main": "./src/index.ts",
"license": "AGPL-3.0"
@@ -0,0 +1,105 @@
import { TAnalyticsTabsV2Base } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
export const insightsFields: Record<TAnalyticsTabsV2Base, string[]> = {
overview: [
"total_users",
"total_admins",
"total_members",
"total_guests",
"total_projects",
"total_work_items",
"total_cycles",
"total_intake",
],
"work-items": [
"total_work_items",
"started_work_items",
"backlog_work_items",
"un_started_work_items",
"completed_work_items",
],
};
export const ANALYTICS_V2_DURATION_FILTER_OPTIONS = [
{
name: "Yesterday",
value: "yesterday",
},
{
name: "Last 7 days",
value: "last_7_days",
},
{
name: "Last 30 days",
value: "last_30_days",
},
{
name: "Last 3 months",
value: "last_3_months",
},
];
export const ANALYTICS_V2_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
{
value: ChartXAxisProperty.STATES,
label: "State name",
},
{
value: ChartXAxisProperty.STATE_GROUPS,
label: "State group",
},
{
value: ChartXAxisProperty.PRIORITY,
label: "Priority",
},
{
value: ChartXAxisProperty.LABELS,
label: "Label",
},
{
value: ChartXAxisProperty.ASSIGNEES,
label: "Assignee",
},
{
value: ChartXAxisProperty.ESTIMATE_POINTS,
label: "Estimate point",
},
{
value: ChartXAxisProperty.CYCLES,
label: "Cycle",
},
{
value: ChartXAxisProperty.MODULES,
label: "Module",
},
{
value: ChartXAxisProperty.COMPLETED_AT,
label: "Completed date",
},
{
value: ChartXAxisProperty.TARGET_DATE,
label: "Due date",
},
{
value: ChartXAxisProperty.START_DATE,
label: "Start date",
},
{
value: ChartXAxisProperty.CREATED_AT,
label: "Created date",
},
];
export const ANALYTICS_V2_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
{
value: ChartYAxisMetric.WORK_ITEM_COUNT,
label: "Work item",
},
{
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
+81
View File
@@ -0,0 +1,81 @@
// types
import { TXAxisValues, TYAxisValues } from "@plane/types";
export const ANALYTICS_TABS = [
{
key: "scope_and_demand",
i18n_title: "workspace_analytics.tabs.scope_and_demand",
},
{ key: "custom", i18n_title: "workspace_analytics.tabs.custom" },
];
export const ANALYTICS_X_AXIS_VALUES: { value: TXAxisValues; label: string }[] =
[
{
value: "state_id",
label: "State name",
},
{
value: "state__group",
label: "State group",
},
{
value: "priority",
label: "Priority",
},
{
value: "labels__id",
label: "Label",
},
{
value: "assignees__id",
label: "Assignee",
},
{
value: "estimate_point__value",
label: "Estimate point",
},
{
value: "issue_cycle__cycle_id",
label: "Cycle",
},
{
value: "issue_module__module_id",
label: "Module",
},
{
value: "completed_at",
label: "Completed date",
},
{
value: "target_date",
label: "Due date",
},
{
value: "start_date",
label: "Start date",
},
{
value: "created_at",
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: TYAxisValues; label: string }[] =
[
{
value: "issue_count",
label: "Work item Count",
},
{
value: "estimate",
label: "Estimate",
},
];
export const ANALYTICS_DATE_KEYS = [
"completed_at",
"target_date",
"start_date",
"created_at",
];
-178
View File
@@ -1,178 +0,0 @@
import { TAnalyticsTabsBase } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
export interface IInsightField {
key: string;
i18nKey: string;
i18nProps?: {
entity?: string;
entityPlural?: string;
[key: string]: any;
};
}
export const ANALYTICS_INSIGHTS_FIELDS: Record<TAnalyticsTabsBase, IInsightField[]> = {
overview: [
{
key: "total_users",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.users",
},
},
{
key: "total_admins",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.admins",
},
},
{
key: "total_members",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.members",
},
},
{
key: "total_guests",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.guests",
},
},
{
key: "total_projects",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.projects",
},
},
{
key: "total_work_items",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.work_items",
},
},
{
key: "total_cycles",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "common.cycles",
},
},
{
key: "total_intake",
i18nKey: "workspace_analytics.total",
i18nProps: {
entity: "sidebar.intake",
},
},
],
"work-items": [
{
key: "total_work_items",
i18nKey: "workspace_analytics.total",
},
{
key: "started_work_items",
i18nKey: "workspace_analytics.started_work_items",
},
{
key: "backlog_work_items",
i18nKey: "workspace_analytics.backlog_work_items",
},
{
key: "un_started_work_items",
i18nKey: "workspace_analytics.un_started_work_items",
},
{
key: "completed_work_items",
i18nKey: "workspace_analytics.completed_work_items",
},
],
};
export const ANALYTICS_DURATION_FILTER_OPTIONS = [
{
name: "Yesterday",
value: "yesterday",
},
{
name: "Last 7 days",
value: "last_7_days",
},
{
name: "Last 30 days",
value: "last_30_days",
},
{
name: "Last 3 months",
value: "last_3_months",
},
];
export const ANALYTICS_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
{
value: ChartXAxisProperty.STATES,
label: "State name",
},
{
value: ChartXAxisProperty.STATE_GROUPS,
label: "State group",
},
{
value: ChartXAxisProperty.PRIORITY,
label: "Priority",
},
{
value: ChartXAxisProperty.LABELS,
label: "Label",
},
{
value: ChartXAxisProperty.ASSIGNEES,
label: "Assignee",
},
{
value: ChartXAxisProperty.ESTIMATE_POINTS,
label: "Estimate point",
},
{
value: ChartXAxisProperty.CYCLES,
label: "Cycle",
},
{
value: ChartXAxisProperty.MODULES,
label: "Module",
},
{
value: ChartXAxisProperty.COMPLETED_AT,
label: "Completed date",
},
{
value: ChartXAxisProperty.TARGET_DATE,
label: "Due date",
},
{
value: ChartXAxisProperty.START_DATE,
label: "Start date",
},
{
value: ChartXAxisProperty.CREATED_AT,
label: "Created date",
},
];
export const ANALYTICS_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
{
value: ChartYAxisMetric.WORK_ITEM_COUNT,
label: "Work item",
},
{
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
+1 -8
View File
@@ -69,7 +69,7 @@ export enum EErrorAlertType {
export type TAuthErrorInfo = {
type: EErrorAlertType;
code: EAuthErrorCodes;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
@@ -87,13 +87,6 @@ export enum EAdminAuthErrorCodes {
ADMIN_USER_DEACTIVATED = "5190",
}
export type TAdminAuthErrorInfo = {
type: EErrorAlertType;
code: EAdminAuthErrorCodes;
title: string;
message: any;
};
export enum EAuthErrorCodes {
// Global
INSTANCE_NOT_CONFIGURED = "5000",
+9 -7
View File
@@ -1,26 +1,28 @@
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "";
export const API_BASE_PATH = process.env.NEXT_PUBLIC_API_BASE_PATH || "/";
export const API_URL = encodeURI(`${API_BASE_URL}${API_BASE_PATH}`);
// God Mode Admin App Base Url
export const ADMIN_BASE_URL = process.env.NEXT_PUBLIC_ADMIN_BASE_URL || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "";
export const ADMIN_BASE_PATH = process.env.NEXT_PUBLIC_ADMIN_BASE_PATH || "/";
export const GOD_MODE_URL = encodeURI(`${ADMIN_BASE_URL}${ADMIN_BASE_PATH}`);
// Publish App Base Url
export const SPACE_BASE_URL = process.env.NEXT_PUBLIC_SPACE_BASE_URL || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "";
export const SPACE_BASE_PATH = process.env.NEXT_PUBLIC_SPACE_BASE_PATH || "/";
export const SITES_URL = encodeURI(`${SPACE_BASE_URL}${SPACE_BASE_PATH}`);
// Live App Base Url
export const LIVE_BASE_URL = process.env.NEXT_PUBLIC_LIVE_BASE_URL || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "";
export const LIVE_BASE_PATH = process.env.NEXT_PUBLIC_LIVE_BASE_PATH || "/";
export const LIVE_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}`);
// Web App Base Url
export const WEB_BASE_URL = process.env.NEXT_PUBLIC_WEB_BASE_URL || "";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "";
export const WEB_BASE_PATH = process.env.NEXT_PUBLIC_WEB_BASE_PATH || "/";
export const WEB_URL = encodeURI(`${WEB_BASE_URL}${WEB_BASE_PATH}`);
// plane website url
export const WEBSITE_URL = process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
export const WEBSITE_URL =
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
// support email
export const SUPPORT_EMAIL = process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
export const SUPPORT_EMAIL =
process.env.NEXT_PUBLIC_SUPPORT_EMAIL || "support@plane.so";
// marketing links
export const MARKETING_PRICING_PAGE_LINK = "https://plane.so/pricing";
export const MARKETING_CONTACT_US_PAGE_LINK = "https://plane.so/contact";
@@ -95,32 +95,3 @@ export const INBOX_ISSUE_SORT_BY_OPTIONS = [
i18n_label: "common.sort.desc",
},
];
export enum EPastDurationFilters {
TODAY = "today",
YESTERDAY = "yesterday",
LAST_7_DAYS = "last_7_days",
LAST_30_DAYS = "last_30_days",
}
export const PAST_DURATION_FILTER_OPTIONS: {
name: string;
value: string;
}[] = [
{
name: "Today",
value: EPastDurationFilters.TODAY,
},
{
name: "Yesterday",
value: EPastDurationFilters.YESTERDAY,
},
{
name: "Last 7 days",
value: EPastDurationFilters.LAST_7_DAYS,
},
{
name: "Last 30 days",
value: EPastDurationFilters.LAST_30_DAYS,
},
];
+3 -3
View File
@@ -1,4 +1,5 @@
export * from "./ai";
export * from "./analytics";
export * from "./auth";
export * from "./chart";
export * from "./endpoints";
@@ -21,7 +22,7 @@ export * from "./module";
export * from "./project";
export * from "./views";
export * from "./themes";
export * from "./intake";
export * from "./inbox";
export * from "./profile";
export * from "./workspace-drafts";
export * from "./label";
@@ -33,5 +34,4 @@ export * from "./emoji";
export * from "./subscription";
export * from "./settings";
export * from "./icon";
export * from "./estimates";
export * from "./analytics";
export * from "./analytics-v2";
+39 -1
View File
@@ -136,7 +136,45 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state", "cycle", "module", "priority", "labels", "assignees", "created_by", null],
group_by: [
"state",
"cycle",
"module",
"state_detail.group",
"priority",
"labels",
"assignees",
"created_by",
null,
],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
access: true,
values: ["show_empty_groups"],
},
},
},
draft_issues: {
list: {
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels", null],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
extra_options: {
access: true,
values: ["show_empty_groups"],
},
},
kanban: {
filters: ["priority", "state_group", "cycle", "module", "labels", "start_date", "target_date", "issue_type"],
display_properties: ISSUE_DISPLAY_PROPERTIES_KEYS,
display_filters: {
group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels"],
order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"],
type: [null, "active", "backlog"],
},
+11 -18
View File
@@ -1,16 +1,9 @@
// types
import { TModuleLayoutOptions, TModuleOrderByOptions, TModuleStatus } from "@plane/types";
export const MODULE_STATUS_COLORS: {
[key in TModuleStatus]: string;
} = {
backlog: "#a3a3a2",
planned: "#3f76ff",
paused: "#525252",
completed: "#16a34a",
cancelled: "#ef4444",
"in-progress": "#f39e1f",
};
import {
TModuleLayoutOptions,
TModuleOrderByOptions,
TModuleStatus,
} from "@plane/types";
export const MODULE_STATUS: {
i18n_label: string;
@@ -22,42 +15,42 @@ export const MODULE_STATUS: {
{
i18n_label: "project_modules.status.backlog",
value: "backlog",
color: MODULE_STATUS_COLORS.backlog,
color: "#a3a3a2",
textColor: "text-custom-text-400",
bgColor: "bg-custom-background-80",
},
{
i18n_label: "project_modules.status.planned",
value: "planned",
color: MODULE_STATUS_COLORS.planned,
color: "#3f76ff",
textColor: "text-blue-500",
bgColor: "bg-indigo-50",
},
{
i18n_label: "project_modules.status.in_progress",
value: "in-progress",
color: MODULE_STATUS_COLORS["in-progress"],
color: "#f39e1f",
textColor: "text-amber-500",
bgColor: "bg-amber-50",
},
{
i18n_label: "project_modules.status.paused",
value: "paused",
color: MODULE_STATUS_COLORS.paused,
color: "#525252",
textColor: "text-custom-text-300",
bgColor: "bg-custom-background-90",
},
{
i18n_label: "project_modules.status.completed",
value: "completed",
color: MODULE_STATUS_COLORS.completed,
color: "#16a34a",
textColor: "text-green-600",
bgColor: "bg-green-100",
},
{
i18n_label: "project_modules.status.cancelled",
value: "cancelled",
color: MODULE_STATUS_COLORS.cancelled,
color: "#ef4444",
textColor: "text-red-500",
bgColor: "bg-red-50",
},
+7 -7
View File
@@ -72,23 +72,23 @@ export const PLANE_COMMUNITY_PRODUCTS: Record<string, IPaymentProduct> = {
prices: [
{
id: `price_yearly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 15600,
unit_amount: 0,
recurring: "year",
currency: "usd",
workspace_amount: 15600,
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
{
id: `price_monthly_${EProductSubscriptionEnum.BUSINESS}`,
unit_amount: 1500,
unit_amount: 0,
recurring: "month",
currency: "usd",
workspace_amount: 1500,
workspace_amount: 0,
product: EProductSubscriptionEnum.BUSINESS,
},
],
payment_quantity: 1,
is_active: true,
is_active: false,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
id: EProductSubscriptionEnum.ENTERPRISE,
@@ -141,8 +141,8 @@ export const SUBSCRIPTION_REDIRECTION_URLS: Record<EProductSubscriptionEnum, Rec
year: "https://app.plane.so/upgrade/pro/self-hosted?plan=year",
},
[EProductSubscriptionEnum.BUSINESS]: {
month: "https://app.plane.so/upgrade/business/self-hosted?plan=month",
year: "https://app.plane.so/upgrade/business/self-hosted?plan=year",
month: TALK_TO_SALES_URL,
year: TALK_TO_SALES_URL,
},
[EProductSubscriptionEnum.ENTERPRISE]: {
month: TALK_TO_SALES_URL,
-9
View File
@@ -149,12 +149,3 @@ 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",
}
-1
View File
@@ -1,5 +1,4 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
+1 -1
View File
@@ -28,7 +28,7 @@
"@types/reflect-metadata": "^0.1.0",
"@types/ws": "^8.5.10",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "^5.3.3"
},
"peerDependencies": {
"express": ">=4.21.2",
+22 -21
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.26.1",
"version": "0.26.0",
"description": "Core Editor that powers Plane",
"license": "AGPL-3.0",
"private": true,
@@ -39,31 +39,32 @@
"@plane/types": "*",
"@plane/ui": "*",
"@plane/utils": "*",
"@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",
"@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",
"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-safari-ime-span": "^1.0.2",
"prosemirror-utils": "^1.2.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
@@ -81,7 +82,7 @@
"@types/react-dom": "^18.2.18",
"postcss": "^8.4.38",
"tsup": "8.4.0",
"typescript": "5.8.3"
"typescript": "5.3.3"
},
"keywords": [
"editor",
@@ -0,0 +1,14 @@
import { Editor } from "@tiptap/core";
import { LinkViewContainer } from "@/components/editors/link-view-container";
export const LinkContainer = ({
editor,
containerRef,
}: {
editor: Editor;
containerRef: React.RefObject<HTMLDivElement>;
}) => (
<>
<LinkViewContainer editor={editor} containerRef={containerRef} />
</>
);
@@ -1,13 +1,13 @@
import type { Extensions } from "@tiptap/core";
import { Extensions } from "@tiptap/core";
// types
import type { IEditorProps } from "@/types";
import { TExtensions, TFileHandler } from "@/types";
export type TCoreAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export const CoreEditorAdditionalExtensions = (props: TCoreAdditionalExtensionsProps): Extensions => {
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -1,15 +1,12 @@
import type { Extensions } from "@tiptap/core";
import { Extensions } from "@tiptap/core";
// types
import type { IReadOnlyEditorProps } from "@/types";
import { TExtensions } from "@/types";
export type TCoreReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions"
>;
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreReadOnlyEditorAdditionalExtensions = (
props: TCoreReadOnlyEditorAdditionalExtensionsProps
): Extensions => {
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -1,39 +1,36 @@
import type { HocuspocusProvider } from "@hocuspocus/provider";
import type { AnyExtension } from "@tiptap/core";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { AnyExtension } from "@tiptap/core";
import { SlashCommands } from "@/extensions";
// plane editor types
import type { TEmbedConfig } from "@/plane-editor/types";
import { TIssueEmbedConfig } from "@/plane-editor/types";
// types
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
import { TExtensions, TUserDetails } from "@/types";
export type TDocumentEditorAdditionalExtensionsProps = Pick<
IEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
> & {
embedConfig: TEmbedConfig | undefined;
provider?: HocuspocusProvider;
type Props = {
disabledExtensions?: TExtensions[];
issueEmbedConfig: TIssueEmbedConfig | undefined;
provider: HocuspocusProvider;
userDetails: TUserDetails;
};
export type TDocumentEditorAdditionalExtensionsRegistry = {
isEnabled: (disabledExtensions: TExtensions[], flaggedExtensions: TExtensions[]) => boolean;
getExtension: (props: TDocumentEditorAdditionalExtensionsProps) => AnyExtension;
type ExtensionConfig = {
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
getExtension: (props: Props) => AnyExtension;
};
const extensionRegistry: TDocumentEditorAdditionalExtensionsRegistry[] = [
const extensionRegistry: ExtensionConfig[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({ disabledExtensions, flaggedExtensions }),
getExtension: () => SlashCommands({}),
},
];
export const DocumentEditorAdditionalExtensions = (props: TDocumentEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions = [] } = _props;
const documentExtensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props));
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(_props));
return documentExtensions;
};
@@ -1,42 +0,0 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// extensions
import { SlashCommands } from "@/extensions/slash-commands/root";
// types
import { IEditorProps, TExtensions } from "@/types";
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[], flaggedExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextEditorAdditionalExtensionsRegistry[] = [
{
isEnabled: (disabledExtensions) => !disabledExtensions.includes("slash-commands"),
getExtension: ({ disabledExtensions, flaggedExtensions }) =>
SlashCommands({
disabledExtensions,
flaggedExtensions,
}),
},
];
export const RichTextEditorAdditionalExtensions = (props: TRichTextEditorAdditionalExtensionsProps) => {
const { disabledExtensions, flaggedExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions, flaggedExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};
@@ -1,31 +0,0 @@
import { AnyExtension, Extensions } from "@tiptap/core";
// types
import { IReadOnlyEditorProps, TExtensions } from "@/types";
export type TRichTextReadOnlyEditorAdditionalExtensionsProps = Pick<
IReadOnlyEditorProps,
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
>;
/**
* Registry entry configuration for extensions
*/
export type TRichTextReadOnlyEditorAdditionalExtensionsRegistry = {
/** Determines if the extension should be enabled based on disabled extensions */
isEnabled: (disabledExtensions: TExtensions[]) => boolean;
/** Returns the extension instance(s) when enabled */
getExtension: (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => AnyExtension | undefined;
};
const extensionRegistry: TRichTextReadOnlyEditorAdditionalExtensionsRegistry[] = [];
export const RichTextReadOnlyEditorAdditionalExtensions = (props: TRichTextReadOnlyEditorAdditionalExtensionsProps) => {
const { disabledExtensions } = props;
const extensions: Extensions = extensionRegistry
.filter((config) => config.isEnabled(disabledExtensions))
.map((config) => config.getExtension(props))
.filter((extension): extension is AnyExtension => extension !== undefined);
return extensions;
};
@@ -1,9 +1,11 @@
// extensions
import type { TSlashCommandAdditionalOption } from "@/extensions";
import { TSlashCommandAdditionalOption } from "@/extensions";
// types
import type { IEditorProps } from "@/types";
import { TExtensions } from "@/types";
type Props = Pick<IEditorProps, "disabledExtensions" | "flaggedExtensions">;
type Props = {
disabledExtensions?: TExtensions[];
};
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
const {} = props;
+1 -1
View File
@@ -2,7 +2,7 @@
import { CORE_EXTENSIONS } from "@/constants/extension";
// extensions
import { type HeadingExtensionStorage } from "@/extensions";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image/types";
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
import { type CustomLinkStorage } from "@/extensions/custom-link";
import { type ImageExtensionStorage } from "@/extensions/image";
import { type MentionExtensionStorage } from "@/extensions/mentions";
@@ -13,11 +13,10 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useCollaborativeEditor } from "@/hooks/use-collaborative-editor";
// types
import { EditorRefApi, ICollaborativeDocumentEditorProps } from "@/types";
import { EditorRefApi, ICollaborativeDocumentEditor } from "@/types";
const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> = (props) => {
const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
const {
onChange,
onTransaction,
aiHandler,
bubbleMenuEnabled = true,
@@ -28,7 +27,6 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
@@ -58,12 +56,10 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
embedHandler,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onChange,
onTransaction,
placeholder,
realtimeConfig,
@@ -95,11 +91,12 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
editorContainerClassName={cn(editorContainerClassNames, "document-editor")}
id={id}
tabIndex={tabIndex}
disabledExtensions={disabledExtensions}
/>
);
};
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditorProps>(
const CollaborativeDocumentEditorWithRef = React.forwardRef<EditorRefApi, ICollaborativeDocumentEditor>(
(props, ref) => (
<CollaborativeDocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
)
@@ -3,9 +3,9 @@ import { Editor } from "@tiptap/react";
import { EditorContainer, EditorContentWrapper } from "@/components/editors";
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
// types
import { TAIHandler, TDisplayConfig } from "@/types";
import { TAIHandler, TDisplayConfig, TExtensions } from "@/types";
type Props = {
type IPageRenderer = {
aiHandler?: TAIHandler;
bubbleMenuEnabled: boolean;
displayConfig: TDisplayConfig;
@@ -13,10 +13,20 @@ type Props = {
editorContainerClassName: string;
id: string;
tabIndex?: number;
disabledExtensions: TExtensions[];
};
export const PageRenderer = (props: Props) => {
const { aiHandler, bubbleMenuEnabled, displayConfig, editor, editorContainerClassName, id, tabIndex } = props;
export const PageRenderer = (props: IPageRenderer) => {
const {
aiHandler,
bubbleMenuEnabled,
displayConfig,
editor,
editorContainerClassName,
id,
tabIndex,
disabledExtensions,
} = props;
return (
<div className="frame-renderer flex-grow w-full">
@@ -30,7 +40,7 @@ export const PageRenderer = (props: Props) => {
{editor.isEditable && (
<div>
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
<BlockMenu editor={editor} />
<BlockMenu editor={editor} disabledExtensions={disabledExtensions} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</div>
)}
@@ -1,5 +1,5 @@
import { Extensions } from "@tiptap/core";
import React, { forwardRef, MutableRefObject } from "react";
import { forwardRef, MutableRefObject } from "react";
// plane imports
import { cn } from "@plane/utils";
// components
@@ -13,9 +13,30 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps } from "@/types";
import {
EditorReadOnlyRefApi,
TDisplayConfig,
TExtensions,
TReadOnlyFileHandler,
TReadOnlyMentionHandler,
} from "@/types";
const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) => {
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 {
containerClassName,
disabledExtensions,
@@ -23,7 +44,6 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
editorClassName = "",
embedHandler,
fileHandler,
flaggedExtensions,
id,
forwardedRef,
handleEditorReady,
@@ -44,7 +64,6 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
handleEditorReady,
initialValue,
@@ -64,11 +83,12 @@ const DocumentReadOnlyEditor: React.FC<IDocumentReadOnlyEditorProps> = (props) =
editor={editor}
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
id={id}
disabledExtensions={disabledExtensions}
/>
);
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditorProps>((props, ref) => (
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -9,6 +9,7 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
import { TDisplayConfig } from "@/types";
// components
import { LinkViewContainer } from "./link-view-container";
import { LinkContainer } from "@/plane-editor/components/link-container";
interface EditorContainerProps {
children: ReactNode;
@@ -53,14 +54,17 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
const lastNodePos = editor.state.doc.resolve(Math.max(0, docSize - 2));
const lastNode = lastNodePos.node();
// 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();
}
// 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();
}
} catch (error) {
console.error("An error occurred while handling container click to insert new empty node at bottom:", error);
@@ -93,7 +97,7 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
)}
>
{children}
<LinkViewContainer editor={editor} containerRef={containerRef} />
<LinkContainer editor={editor} containerRef={containerRef} />
</div>
</>
);
@@ -26,7 +26,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
id,
initialValue,
fileHandler,
flaggedExtensions,
forwardedRef,
mentionHandler,
onChange,
@@ -45,7 +44,6 @@ export const EditorWrapper: React.FC<Props> = (props) => {
enableHistory: true,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -3,6 +3,7 @@ import { Editor, useEditorState } from "@tiptap/react";
import { FC, useCallback, useEffect, useState } from "react";
// components
import { LinkView, LinkViewProps } from "@/components/links";
import { getExtensionStorage } from "@/helpers/get-extension-storage";
interface LinkViewContainerProps {
editor: Editor;
@@ -17,7 +18,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
const editorState = useEditorState({
editor,
selector: ({ editor }: { editor: Editor }) => ({
linkExtensionStorage: editor.storage.link,
linkExtensionStorage: getExtensionStorage(editor, "link"),
}),
});
@@ -109,7 +110,7 @@ export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containe
// Close link view when bubble menu opens
useEffect(() => {
if (editorState.linkExtensionStorage.isBubbleMenuOpen && isOpen) {
if (editorState.linkExtensionStorage?.isBubbleMenuOpen && isOpen) {
setIsOpen(false);
}
}, [editorState.linkExtensionStorage, isOpen]);
@@ -4,25 +4,23 @@ import { EditorWrapper } from "@/components/editors/editor-wrapper";
// extensions
import { EnterKeyExtension } from "@/extensions";
// types
import { EditorRefApi, ILiteTextEditorProps } from "@/types";
import { EditorRefApi, ILiteTextEditor } from "@/types";
const LiteTextEditor: React.FC<ILiteTextEditorProps> = (props) => {
const LiteTextEditor = (props: ILiteTextEditor) => {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const extensions = useMemo(() => {
const resolvedExtensions = [...externalExtensions];
if (!disabledExtensions?.includes("enter-key")) {
resolvedExtensions.push(EnterKeyExtension(onEnterKeyPress));
}
return resolvedExtensions;
}, [externalExtensions, disabledExtensions, onEnterKeyPress]);
const extensions = useMemo(
() => [
...externalExtensions,
...(disabledExtensions?.includes("enter-key") ? [] : [EnterKeyExtension(onEnterKeyPress)]),
],
[externalExtensions, disabledExtensions, onEnterKeyPress]
);
return <EditorWrapper {...props} extensions={extensions} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditorProps>((props, ref) => (
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((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, ILiteTextReadOnlyEditorProps } from "@/types";
import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor } from "@/types";
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditorProps>((props, ref) => (
const LiteTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
@@ -15,9 +15,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
id,
initialValue,
@@ -27,9 +25,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
flaggedExtensions,
forwardedRef,
initialValue,
mentionHandler,
@@ -3,21 +3,12 @@ import { forwardRef, useCallback } from "react";
import { EditorWrapper } from "@/components/editors";
import { EditorBubbleMenu } from "@/components/menus";
// extensions
import { SideMenuExtension } from "@/extensions";
// plane editor imports
import { RichTextEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/extensions";
import { SideMenuExtension, SlashCommands } from "@/extensions";
// types
import { EditorRefApi, IRichTextEditorProps } from "@/types";
import { EditorRefApi, IRichTextEditor } from "@/types";
const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
const {
bubbleMenuEnabled = true,
disabledExtensions,
dragDropEnabled,
extensions: externalExtensions = [],
fileHandler,
flaggedExtensions,
} = props;
const RichTextEditor = (props: IRichTextEditor) => {
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
const getExtensions = useCallback(() => {
const extensions = [
@@ -26,15 +17,17 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
aiEnabled: false,
dragDropEnabled: !!dragDropEnabled,
}),
...RichTextEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
}),
];
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(
SlashCommands({
disabledExtensions,
})
);
}
return extensions;
}, [dragDropEnabled, disabledExtensions, externalExtensions, fileHandler, flaggedExtensions]);
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
@@ -43,7 +36,7 @@ const RichTextEditor: React.FC<IRichTextEditorProps> = (props) => {
);
};
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditorProps>((props, ref) => (
const RichTextEditorWithRef = forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
@@ -1,32 +1,11 @@
import { forwardRef, useCallback } from "react";
// plane editor extensions
import { RichTextReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions/rich-text/read-only-extensions";
import { forwardRef } from "react";
// types
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps } from "@/types";
// local imports
import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor } from "@/types";
import { ReadOnlyEditorWrapper } from "../read-only-editor-wrapper";
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditorProps>((props, ref) => {
const { disabledExtensions, fileHandler, flaggedExtensions } = props;
const getExtensions = useCallback(() => {
const extensions = RichTextReadOnlyEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
flaggedExtensions,
});
return extensions;
}, [disabledExtensions, fileHandler, flaggedExtensions]);
return (
<ReadOnlyEditorWrapper
{...props}
extensions={getExtensions()}
forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>}
/>
);
});
const RichTextReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => (
<ReadOnlyEditorWrapper {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
@@ -4,9 +4,11 @@ import { useCallback, useEffect, useRef } from "react";
import tippy, { Instance } from "tippy.js";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
import { TExtensions } from "@/types";
interface BlockMenuProps {
editor: Editor;
disabledExtensions?: TExtensions[];
}
export const BlockMenu = (props: BlockMenuProps) => {
@@ -18,7 +18,6 @@ import {
HeadingFiveItem,
HeadingSixItem,
EditorMenuItem,
ToggleListItem,
} from "@/components/menus";
// types
import { TEditorCommands } from "@/types";
@@ -43,7 +42,6 @@ export const BubbleMenuNodeSelector: FC<Props> = (props) => {
BulletListItem(editor),
NumberedListItem(editor),
TodoListItem(editor),
ToggleListItem(editor),
QuoteItem(editor),
CodeItem(editor),
];
@@ -10,7 +10,6 @@ import {
BubbleMenuLinkSelector,
BubbleMenuNodeSelector,
CodeItem,
EditorMenuItem,
ItalicItem,
StrikeThroughItem,
TextAlignItem,
@@ -24,7 +23,6 @@ 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">;
@@ -33,19 +31,19 @@ export interface EditorStateType {
bold: boolean;
italic: boolean;
underline: boolean;
strikethrough: boolean;
strike: 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 }) => {
@@ -60,10 +58,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: BoldItem(props.editor),
italic: ItalicItem(props.editor),
underline: UnderLineItem(props.editor),
strikethrough: StrikeThroughItem(props.editor),
"text-align": TextAlignItem(props.editor),
} satisfies {
[K in TEditorCommands]?: EditorMenuItem<K>;
strike: StrikeThroughItem(props.editor),
textAlign: TextAlignItem(props.editor),
};
const editorState: EditorStateType = useEditorState({
@@ -73,10 +69,10 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
bold: formattingItems.bold.isActive(),
italic: formattingItems.italic.isActive(),
underline: formattingItems.underline.isActive(),
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" }),
strike: formattingItems.strike.isActive(),
left: formattingItems.textAlign.isActive({ alignment: "left" }),
right: formattingItems.textAlign.isActive({ alignment: "right" }),
center: formattingItems.textAlign.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 })),
}),
@@ -84,7 +80,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
const basicFormattingOptions = editorState.code
? [formattingItems.code]
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strikethrough];
: [formattingItems.bold, formattingItems.italic, formattingItems.underline, formattingItems.strike];
const bubbleMenuProps: EditorBubbleMenuProps = {
...props,
@@ -22,7 +22,6 @@ import {
MinusSquare,
Palette,
AlignCenter,
ListCollapse,
} from "lucide-react";
// constants
import { CORE_EXTENSIONS } from "@/constants/extension";
@@ -36,14 +35,13 @@ import {
toggleBackgroundColor,
toggleBlockquote,
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatTaskList,
toggleFlatToggleList,
toggleHeading,
toggleItalic,
toggleOrderedList,
toggleStrike,
toggleTaskList,
toggleTextColor,
toggleUnderline,
} from "@/helpers/editor-commands";
@@ -138,35 +136,27 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("list", { kind: "bullet" }),
command: () => toggleFlatBulletList(editor),
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("list", { kind: "ordered" }),
command: () => toggleFlatOrderedList(editor),
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor?.isActive("list", { kind: "task" }),
command: () => toggleFlatTaskList(editor),
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
command: () => toggleTaskList(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",
@@ -258,7 +248,6 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
StrikeThroughItem(editor),
BulletListItem(editor),
TodoListItem(editor),
ToggleListItem(editor),
CodeItem(editor),
NumberedListItem(editor),
QuoteItem(editor),
@@ -41,5 +41,4 @@ 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";
export type CustomCalloutNodeViewProps = NodeViewProps & {
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<CustomCalloutNodeViewProps> = (props) => {
export const CustomCalloutBlock: React.FC<Props> = (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, CustomCalloutNodeViewProps } from "@/extensions/callout";
import { CustomCalloutBlock } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
@@ -63,8 +63,6 @@ export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
},
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -1,6 +1,6 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock, CustomCalloutNodeViewProps } from "@/extensions/callout";
import { CustomCalloutBlock } from "@/extensions";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
@@ -9,8 +9,6 @@ export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.exten
draggable: false,
addNodeView() {
return ReactNodeViewRenderer((props) => (
<CustomCalloutBlock {...props} node={props.node as CustomCalloutNodeViewProps["node"]} />
));
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -1,419 +0,0 @@
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;
}
@@ -1,89 +0,0 @@
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, TextSelection } from "@tiptap/pm/state";
import { Selection } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
@@ -14,169 +14,28 @@ 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, $to, empty } = selection;
const { $from, empty } = selection;
if ($from.parent.type !== this.type) {
if (!empty || $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");
// 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);
}
// 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;
}
},
"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,3 +1,4 @@
import { Selection } from "@tiptap/pm/state";
import ts from "highlight.js/lib/languages/typescript";
import { common, createLowlight } from "lowlight";
// components
@@ -6,7 +7,108 @@ import { CodeBlockLowlight } from "./code-block-lowlight";
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
export const CustomCodeBlockExtensionWithoutProps = CodeBlockLowlight.configure({
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({
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 { ImageExtensionConfig } from "./image";
import { ImageExtensionWithoutProps } from "./image";
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
@@ -72,8 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
},
}),
ImageExtensionConfig,
CustomImageExtensionConfig,
ImageExtensionWithoutProps.configure({
HTMLAttributes: {
class: "rounded-md",
},
}),
CustomImageComponentWithoutProps,
TiptapUnderline,
TextStyle,
TaskList.configure({
@@ -1,42 +1,68 @@
import { NodeSelection } from "@tiptap/pm/state";
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
import { Pixel, TCustomImageAttributes, TCustomImageSize } from "../types";
import { ensurePixelString } from "../utils";
import type { CustomImageNodeViewProps } from "./node-view";
import { ImageToolbarRoot } from "./toolbar";
// extensions
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { ImageUploadStatus } from "./upload-status";
const MIN_SIZE = 100;
type CustomImageBlockProps = CustomImageNodeViewProps & {
editorContainer: HTMLDivElement | null;
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;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
src: string | undefined;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
extension,
getPos,
imageFromFileSystem,
node,
selected,
setEditorContainer,
setFailedToLoadImage,
src: resolvedImageSrc,
updateAttributes,
setEditorContainer,
} = props;
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio, src: imgNodeSrc } = node.attrs;
// states
const [size, setSize] = useState<TCustomImageSize>({
const [size, setSize] = useState<Size>({
width: ensurePixelString(nodeWidth, "35%") ?? "35%",
height: ensurePixelString(nodeHeight, "auto") ?? "auto",
aspectRatio: nodeAspectRatio || null,
@@ -51,7 +77,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const [hasTriedRestoringImageOnce, setHasTriedRestoringImageOnce] = useState(false);
const updateAttributesSafely = useCallback(
(attributes: Partial<TCustomImageAttributes>, errorMessage: string) => {
(attributes: Partial<ImageAttributes>, errorMessage: string) => {
try {
updateAttributes(attributes);
} catch (error) {
@@ -88,7 +114,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatioCalculated;
const initialComputedSize: TCustomImageSize = {
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
@@ -113,7 +139,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributesSafely, editorContainer, nodeAspectRatio, setEditorContainer]);
}, [nodeWidth, updateAttributes, editorContainer, nodeAspectRatio]);
// for real time resizing
useLayoutEffect(() => {
@@ -142,7 +168,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, updateAttributesSafely]);
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
@@ -216,7 +242,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 (!extension.options.restoreImage || hasTriedRestoringImageOnce) {
if (!editor?.commands.restoreImage || hasTriedRestoringImageOnce) {
setFailedToLoadImage(true);
return;
}
@@ -227,7 +253,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
if (!imgNodeSrc) {
throw new Error("No source image to restore from");
}
await extension.options.restoreImage?.(imgNodeSrc);
await editor?.commands.restoreImage?.(imgNodeSrc);
if (!imageRef.current) {
throw new Error("Image reference not found");
}
@@ -263,10 +289,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={{
width: size.width,
height: size.height,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
src: resolvedImageSrc,
aspectRatio: size.aspectRatio === null ? 1 : size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
@@ -2,26 +2,25 @@ 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 CustomImageNodeViewProps = Omit<NodeViewProps, "extension" | "updateAttributes"> & {
extension: CustomImageExtension;
export type CustomBaseImageNodeViewProps = {
getPos: () => number;
editor: Editor;
node: NodeViewProps["node"] & {
attrs: TCustomImageAttributes;
attrs: ImageAttributes;
};
updateAttributes: (attrs: Partial<TCustomImageAttributes>) => void;
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
};
export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) => {
const { editor, extension, node } = props;
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
export const CustomImageNode = (props: CustomImageNodeProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { src: imgNodeSrc } = node.attrs;
const [isUploaded, setIsUploaded] = useState(false);
@@ -51,37 +50,41 @@ export const CustomImageNodeView: React.FC<CustomImageNodeViewProps> = (props) =
}, [resolvedSrc]);
useEffect(() => {
if (!imgNodeSrc) {
setResolvedSrc(undefined);
return;
}
const getImageSource = async () => {
const url = await extension.options.getImageSource?.(imgNodeSrc);
setResolvedSrc(url);
// @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);
};
getImageSource();
}, [imgNodeSrc, extension.options]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
editorContainer={editorContainer}
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
src={resolvedSrc}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
src={resolvedSrc}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
) : (
<CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
node={node}
setIsUploaded={setIsUploaded}
{...props}
selected={selected}
updateAttributes={updateAttributes}
/>
)}
</div>
@@ -1,30 +1,28 @@
import { ImageIcon } from "lucide-react";
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane imports
// plane utils
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 = CustomImageNodeViewProps & {
failedToLoadImage: boolean;
loadImageFromFileSystem: (file: string) => void;
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
maxFileSize: number;
loadImageFromFileSystem: (file: string) => void;
failedToLoadImage: boolean;
setIsUploaded: (isUploaded: boolean) => void;
};
export const CustomImageUploader = (props: CustomImageUploaderProps) => {
const {
editor,
extension,
failedToLoadImage,
getPos,
loadImageFromFileSystem,
@@ -73,13 +71,12 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
const uploadImageEditorCommand = useCallback(
async (file: File) => await extension.options.uploadImage?.(imageEntityId ?? "", file),
[extension.options, imageEntityId]
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
[editor, imageEntityId]
);
const handleProgressStatus = useCallback(
@@ -96,6 +93,7 @@ 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,
@@ -130,7 +128,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
imageComponentImageFileMap?.set(imageEntityId ?? "", { ...meta, hasOpenedFileInputOnce: true });
}
}
}, [meta, uploadFile, imageComponentImageFileMap, imageEntityId]);
}, [meta, uploadFile, imageComponentImageFileMap]);
const onFileChange = useCallback(
async (e: ChangeEvent<HTMLInputElement>) => {
@@ -165,7 +163,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
}
return "Add an image";
}, [draggedInside, failedToLoadImage, isImageBeingUploaded, editor.isEditable]);
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
@@ -0,0 +1,4 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -1,14 +1,14 @@
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
type Props = {
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
@@ -1,16 +1,16 @@
import { useState } from "react";
// plane imports
// plane utils
import { cn } from "@plane/utils";
// local imports
// components
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
width: string;
height: string;
aspectRatio: number;
src: string;
height: string;
width: string;
aspectRatio: number;
};
};
@@ -0,0 +1,180 @@
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);
},
});
};
@@ -1,47 +0,0 @@
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)];
},
});
@@ -1,123 +0,0 @@
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"]} />
));
},
});
};
@@ -0,0 +1,3 @@
export * from "./components";
export * from "./custom-image";
export * from "./read-only-custom-image";
@@ -0,0 +1,79 @@
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);
},
});
};
@@ -1,51 +0,0 @@
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>;
@@ -1,33 +0,0 @@
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;
};
@@ -79,6 +79,7 @@ declare module "@tiptap/core" {
export type CustomLinkStorage = {
isPreviewOpen: boolean;
posToInsert: { from: number; to: number };
isBubbleMenuOpen: boolean;
};
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
@@ -0,0 +1 @@
export * from "./list-keymap";
@@ -0,0 +1,377 @@
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;
};
@@ -0,0 +1,134 @@
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;
},
};
},
});

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