Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4835fc478 | ||
|
|
2d34ef201c | ||
|
|
3122bd2e89 | ||
|
|
4d92d6b919 | ||
|
|
f35083bc4f | ||
|
|
557020cd47 | ||
|
|
5770cdeb5e | ||
|
|
f6f48881b0 | ||
|
|
d79883333c | ||
|
|
e206224746 | ||
|
|
85e87ce595 | ||
|
|
854eacdaa8 |
@@ -1,20 +0,0 @@
|
||||
### Description
|
||||
<!-- Provide a detailed description of the changes in this PR -->
|
||||
|
||||
### Type of Change
|
||||
<!-- Put an 'x' in the boxes that apply -->
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Feature (non-breaking change which adds functionality)
|
||||
- [ ] Improvement (change that would cause existing functionality to not work as expected)
|
||||
- [ ] Code refactoring
|
||||
- [ ] Performance improvements
|
||||
- [ ] Documentation update
|
||||
|
||||
### Screenshots and Media (if applicable)
|
||||
<!-- Add screenshots to help explain your changes, ideally showcasing before and after -->
|
||||
|
||||
### Test Scenarios
|
||||
<!-- Please describe the tests that you ran to verify your changes -->
|
||||
|
||||
### References
|
||||
<!-- Link related issues if there are any -->
|
||||
@@ -314,8 +314,8 @@ jobs:
|
||||
buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }}
|
||||
|
||||
attach_assets_to_build:
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Release' }}
|
||||
name: Attach Assets to Release
|
||||
if: ${{ needs.branch_build_setup.outputs.build_type == 'Build' }}
|
||||
name: Attach Assets to Build
|
||||
runs-on: ubuntu-20.04
|
||||
needs: [branch_build_setup]
|
||||
steps:
|
||||
|
||||
@@ -38,8 +38,6 @@ export const WorkspaceCreateForm = () => {
|
||||
getValues,
|
||||
formState: { errors, isSubmitting, isValid },
|
||||
} = useForm<IWorkspace>({ defaultValues, mode: "onChange" });
|
||||
// derived values
|
||||
const workspaceBaseURL = encodeURI(WEB_BASE_URL || window.location.origin + "/");
|
||||
|
||||
const handleCreateWorkspace = async (formData: IWorkspace) => {
|
||||
await workspaceService
|
||||
@@ -126,7 +124,7 @@ export const WorkspaceCreateForm = () => {
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm text-custom-text-300">Set your workspace's URL</h4>
|
||||
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-custom-border-200 px-3">
|
||||
<span className="whitespace-nowrap text-sm text-custom-text-200">{workspaceBaseURL}</span>
|
||||
<span className="whitespace-nowrap text-sm text-custom-text-200">{WEB_BASE_URL}/</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane helpers
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
import { useOutsideClickDetector } from "@plane/helpers";
|
||||
// components
|
||||
import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-sidebar";
|
||||
// hooks
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
// helpers
|
||||
import { Tooltip } from "@plane/ui";
|
||||
@@ -19,9 +20,9 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
|
||||
|
||||
if (!workspace) return null;
|
||||
return (
|
||||
<a
|
||||
<Link
|
||||
key={workspaceId}
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
href={encodeURI(WEB_BASE_URL + "/" + workspace.slug)}
|
||||
target="_blank"
|
||||
className="group flex items-center justify-between p-4 gap-2.5 truncate border border-custom-border-200/70 hover:border-custom-border-200 hover:bg-custom-background-90 rounded-md"
|
||||
>
|
||||
@@ -76,6 +77,6 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
|
||||
<div className="flex-shrink-0">
|
||||
<ExternalLink size={14} className="text-custom-text-400 group-hover:text-custom-text-200" />
|
||||
</div>
|
||||
</a>
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,8 +30,7 @@ export class WorkspaceService extends APIService {
|
||||
* @returns Promise<any>
|
||||
*/
|
||||
async workspaceSlugCheck(slug: string): Promise<any> {
|
||||
const params = new URLSearchParams({ slug });
|
||||
return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
|
||||
return this.get(`/api/instances/workspace-slug-check/?slug=${slug}`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface IWorkspaceStore {
|
||||
// computed
|
||||
workspaceIds: string[];
|
||||
// helper actions
|
||||
hydrate: (data: Record<string, IWorkspace>) => void;
|
||||
hydrate: (data: any) => void;
|
||||
getWorkspaceById: (workspaceId: string) => IWorkspace | undefined;
|
||||
// fetch actions
|
||||
fetchWorkspaces: () => Promise<IWorkspace[]>;
|
||||
@@ -59,9 +59,9 @@ export class WorkspaceStore implements IWorkspaceStore {
|
||||
// helper actions
|
||||
/**
|
||||
* @description Hydrates the workspaces
|
||||
* @param data - Record<string, IWorkspace>
|
||||
* @param data - any
|
||||
*/
|
||||
hydrate = (data: Record<string, IWorkspace>) => {
|
||||
hydrate = (data: any) => {
|
||||
if (data) this.workspaces = data;
|
||||
};
|
||||
|
||||
|
||||
+3
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "0.24.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "turbo run develop",
|
||||
@@ -14,10 +14,9 @@
|
||||
"dependencies": {
|
||||
"@headlessui/react": "^1.7.19",
|
||||
"@plane/constants": "*",
|
||||
"@plane/hooks": "*",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@sentry/nextjs": "^8.32.0",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/lodash": "^4.17.0",
|
||||
@@ -27,7 +26,7 @@
|
||||
"lucide-react": "^0.356.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.20",
|
||||
"next": "^14.2.12",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"name": "plane-api",
|
||||
"version": "0.24.0"
|
||||
"version": "0.23.1"
|
||||
}
|
||||
|
||||
@@ -258,7 +258,9 @@ class ProjectAPIEndpoint(BaseAPIView):
|
||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
intake_view = request.data.get("inbox_view", project.intake_view)
|
||||
intake_view = request.data.get(
|
||||
"inbox_view", request.data.get("intake_view", False)
|
||||
)
|
||||
|
||||
if project.archived_at:
|
||||
return Response(
|
||||
|
||||
@@ -68,7 +68,9 @@ urlpatterns = [
|
||||
# user workspace invitations
|
||||
path(
|
||||
"users/me/workspaces/invitations/",
|
||||
UserWorkspaceInvitationsViewSet.as_view({"get": "list", "post": "create"}),
|
||||
UserWorkspaceInvitationsViewSet.as_view(
|
||||
{"get": "list", "post": "create"}
|
||||
),
|
||||
name="user-workspace-invitations",
|
||||
),
|
||||
path(
|
||||
|
||||
@@ -15,6 +15,8 @@ from django.db.models import (
|
||||
UUIDField,
|
||||
Value,
|
||||
Subquery,
|
||||
Case,
|
||||
When,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.utils import timezone
|
||||
@@ -443,10 +445,12 @@ class IssueViewSet(BaseViewSet):
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
.annotate(
|
||||
cycle_id=Subquery(
|
||||
CycleIssue.objects.filter(issue=OuterRef("id")).values("cycle_id")[
|
||||
:1
|
||||
]
|
||||
cycle_id=Case(
|
||||
When(
|
||||
issue_cycle__cycle__deleted_at__isnull=True,
|
||||
then=F("issue_cycle__cycle_id"),
|
||||
),
|
||||
default=None,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
|
||||
@@ -39,7 +39,6 @@ from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
|
||||
|
||||
def unarchive_archive_page_and_descendants(page_id, archived_at):
|
||||
@@ -115,7 +114,7 @@ class PageViewSet(BaseViewSet):
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def create(self, request, slug, project_id):
|
||||
serializer = PageSerializer(
|
||||
data=request.data,
|
||||
@@ -135,7 +134,7 @@ class PageViewSet(BaseViewSet):
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def partial_update(self, request, slug, project_id, pk):
|
||||
try:
|
||||
page = Page.objects.get(
|
||||
@@ -235,7 +234,7 @@ class PageViewSet(BaseViewSet):
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def lock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
@@ -245,7 +244,7 @@ class PageViewSet(BaseViewSet):
|
||||
page.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def unlock(self, request, slug, project_id, pk):
|
||||
page = Page.objects.filter(
|
||||
pk=pk, workspace__slug=slug, projects__id=project_id
|
||||
@@ -256,7 +255,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def access(self, request, slug, project_id, pk):
|
||||
access = request.data.get("access", 0)
|
||||
page = Page.objects.filter(
|
||||
@@ -297,7 +296,7 @@ class PageViewSet(BaseViewSet):
|
||||
pages = PageSerializer(queryset, many=True).data
|
||||
return Response(pages, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def archive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
@@ -324,7 +323,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def unarchive(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
@@ -349,7 +348,7 @@ class PageViewSet(BaseViewSet):
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
|
||||
def destroy(self, request, slug, project_id, pk):
|
||||
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
|
||||
|
||||
@@ -471,8 +470,6 @@ class SubPagesEndpoint(BaseAPIView):
|
||||
|
||||
|
||||
class PagesDescriptionViewSet(BaseViewSet):
|
||||
parser_classes = [MultiPartParser]
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
page = (
|
||||
@@ -529,33 +526,30 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
existing_instance = json.dumps(
|
||||
{"description_html": page.description_html}, cls=DjangoJSONEncoder
|
||||
)
|
||||
print("before the variables")
|
||||
|
||||
# capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_value=request.data, old_value=existing_instance, page_id=pk
|
||||
# Get the base64 data from the request
|
||||
base64_data = request.data.get("description_binary")
|
||||
|
||||
# If base64 data is provided
|
||||
if base64_data:
|
||||
# Decode the base64 data to bytes
|
||||
new_binary_data = base64.b64decode(base64_data)
|
||||
# capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_value=request.data, old_value=existing_instance, page_id=pk
|
||||
)
|
||||
# Store the updated binary data
|
||||
page.description_binary = new_binary_data
|
||||
page.description_html = request.data.get("description_html")
|
||||
page.description = request.data.get("description")
|
||||
page.save()
|
||||
# Return a success response
|
||||
page_version.delay(
|
||||
page_id=page.id,
|
||||
existing_instance=existing_instance,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
if not request.data.get("description_binary"):
|
||||
return Response({"message": "Updated successfully"})
|
||||
else:
|
||||
return Response({"error": "No binary data provided"})
|
||||
|
||||
# Store the updated binary data
|
||||
page.description_html = request.data.get(
|
||||
"description_html", page.description_html
|
||||
)
|
||||
page.description = request.data.get("description", page.description)
|
||||
page.description_binary = request.POST.get("description_binary")
|
||||
|
||||
page.description_binary = base64.b64decode(
|
||||
request.POST.get("description_binary")
|
||||
)
|
||||
print("before the ssssave")
|
||||
page.save()
|
||||
# Return a success response
|
||||
page_version.delay(
|
||||
page_id=page.id,
|
||||
existing_instance=existing_instance,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
return Response({"message": "Updated successfully"})
|
||||
|
||||
@@ -384,9 +384,11 @@ class ProjectViewSet(BaseViewSet):
|
||||
)
|
||||
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
intake_view = request.data.get(
|
||||
"inbox_view", request.data.get("intake_view", False)
|
||||
)
|
||||
|
||||
project = Project.objects.get(pk=pk)
|
||||
intake_view = request.data.get("inbox_view", project.intake_view)
|
||||
current_instance = json.dumps(
|
||||
ProjectSerializer(project).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
@@ -16,7 +16,12 @@ from plane.app.permissions import (
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
|
||||
from plane.db.models import Project, ProjectMember, IssueUserProperty, WorkspaceMember
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
IssueUserProperty,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.bgtasks.project_add_user_email_task import project_add_user_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.app.permissions.base import allow_permission, ROLE
|
||||
@@ -78,7 +83,10 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
workspace_member_role = WorkspaceMember.objects.get(
|
||||
workspace__slug=slug, member=member, is_active=True
|
||||
).role
|
||||
if workspace_member_role in [20] and member_roles.get(member) in [5, 15]:
|
||||
if workspace_member_role in [20] and member_roles.get(member) in [
|
||||
5,
|
||||
15,
|
||||
]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role lower than the workspace role"
|
||||
@@ -86,7 +94,10 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if workspace_member_role in [5] and member_roles.get(member) in [15, 20]:
|
||||
if workspace_member_role in [5] and member_roles.get(member) in [
|
||||
15,
|
||||
20,
|
||||
]:
|
||||
return Response(
|
||||
{
|
||||
"error": "You cannot add a user with role higher than the workspace role"
|
||||
@@ -124,7 +135,8 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
sort_order = [
|
||||
project_member.get("sort_order")
|
||||
for project_member in project_members
|
||||
if str(project_member.get("member_id")) == str(member.get("member_id"))
|
||||
if str(project_member.get("member_id"))
|
||||
== str(member.get("member_id"))
|
||||
]
|
||||
# Create a new project member
|
||||
bulk_project_members.append(
|
||||
@@ -133,7 +145,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
role=member.get("role", 5),
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
sort_order=(sort_order[0] - 10000 if len(sort_order) else 65535),
|
||||
sort_order=(
|
||||
sort_order[0] - 10000 if len(sort_order) else 65535
|
||||
),
|
||||
)
|
||||
)
|
||||
# Create a new issue property
|
||||
@@ -224,7 +238,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
> requested_project_member.role
|
||||
):
|
||||
return Response(
|
||||
{"error": "You cannot update a role that is higher than your own role"},
|
||||
{
|
||||
"error": "You cannot update a role that is higher than your own role"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -264,7 +280,9 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
# User cannot deactivate higher role
|
||||
if requesting_project_member.role < project_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -285,7 +303,10 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
if (
|
||||
project_member.role == 20
|
||||
and not ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, role=20, is_active=True
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
role=20,
|
||||
is_active=True,
|
||||
).count()
|
||||
> 1
|
||||
):
|
||||
@@ -323,6 +344,7 @@ class UserProjectRolesEndpoint(BaseAPIView):
|
||||
).values("project_id", "role")
|
||||
|
||||
project_members = {
|
||||
str(member["project_id"]): member["role"] for member in project_members
|
||||
str(member["project_id"]): member["role"]
|
||||
for member in project_members
|
||||
}
|
||||
return Response(project_members, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -41,7 +41,6 @@ from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
model = Workspace
|
||||
serializer_class = WorkSpaceSerializer
|
||||
@@ -82,12 +81,12 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
|
||||
def create(self, request):
|
||||
try:
|
||||
(DISABLE_WORKSPACE_CREATION,) = get_configuration_value(
|
||||
DISABLE_WORKSPACE_CREATION, = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "DISABLE_WORKSPACE_CREATION",
|
||||
"default": os.environ.get("DISABLE_WORKSPACE_CREATION", "0"),
|
||||
}
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
# Django imports
|
||||
from django.db.models import Count, Q, OuterRef, Subquery, IntegerField
|
||||
from django.db.models import (
|
||||
Count,
|
||||
Q,
|
||||
OuterRef,
|
||||
Subquery,
|
||||
IntegerField,
|
||||
)
|
||||
from django.db.models.functions import Coalesce
|
||||
|
||||
# Third party modules
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import WorkspaceEntityPermission, allow_permission, ROLE
|
||||
from plane.app.permissions import (
|
||||
WorkspaceEntityPermission,
|
||||
allow_permission,
|
||||
ROLE,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.app.serializers import (
|
||||
@@ -16,7 +26,12 @@ from plane.app.serializers import (
|
||||
WorkSpaceMemberSerializer,
|
||||
)
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue
|
||||
from plane.db.models import (
|
||||
Project,
|
||||
ProjectMember,
|
||||
WorkspaceMember,
|
||||
DraftIssue,
|
||||
)
|
||||
from plane.utils.cache import invalidate_cache
|
||||
|
||||
from .. import BaseViewSet
|
||||
@@ -104,7 +119,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
if requesting_workspace_member.role < workspace_member.role:
|
||||
return Response(
|
||||
{"error": "You cannot remove a user having role higher than you"},
|
||||
{
|
||||
"error": "You cannot remove a user having role higher than you"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -131,7 +148,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).update(is_active=False)
|
||||
|
||||
workspace_member.is_active = False
|
||||
@@ -145,7 +164,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
multiple=True,
|
||||
)
|
||||
@invalidate_cache(path="/api/users/me/settings/")
|
||||
@invalidate_cache(path="api/users/me/workspaces/", user=False, multiple=True)
|
||||
@invalidate_cache(
|
||||
path="api/users/me/workspaces/", user=False, multiple=True
|
||||
)
|
||||
@allow_permission(
|
||||
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
|
||||
)
|
||||
@@ -192,7 +213,9 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
|
||||
# # Deactivate the users from the projects where the user is part of
|
||||
_ = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
|
||||
workspace__slug=slug,
|
||||
member_id=workspace_member.member_id,
|
||||
is_active=True,
|
||||
).update(is_active=False)
|
||||
|
||||
# # Deactivate the user
|
||||
@@ -256,7 +279,9 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
|
||||
project_members = ProjectMember.objects.filter(
|
||||
workspace__slug=slug, project_id__in=project_ids, is_active=True
|
||||
).select_related("project", "member", "workspace")
|
||||
project_members = ProjectMemberRoleSerializer(project_members, many=True).data
|
||||
project_members = ProjectMemberRoleSerializer(
|
||||
project_members, many=True
|
||||
).data
|
||||
|
||||
project_members_dict = dict()
|
||||
|
||||
|
||||
@@ -60,9 +60,6 @@ class EmailCheckEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Lower the email
|
||||
email = str(email).lower().strip()
|
||||
|
||||
# Validate email
|
||||
try:
|
||||
validate_email(email)
|
||||
|
||||
@@ -60,7 +60,6 @@ class EmailCheckSpaceEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
email = str(email).lower().strip()
|
||||
# Validate email
|
||||
try:
|
||||
validate_email(email)
|
||||
|
||||
@@ -3,8 +3,7 @@ from django.utils import timezone
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models.fields.related import OneToOneRel
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -12,98 +11,31 @@ from celery import shared_task
|
||||
|
||||
@shared_task
|
||||
def soft_delete_related_objects(app_label, model_name, instance_pk, using=None):
|
||||
"""
|
||||
Soft delete related objects for a given model instance
|
||||
"""
|
||||
# Get the model class using app registry
|
||||
model_class = apps.get_model(app_label, model_name)
|
||||
|
||||
# Get the instance using all_objects to ensure we can get even if it's already soft deleted
|
||||
try:
|
||||
instance = model_class.all_objects.get(pk=instance_pk)
|
||||
except model_class.DoesNotExist:
|
||||
return
|
||||
|
||||
# Get all related fields that are reverse relationships
|
||||
all_related = [
|
||||
f
|
||||
for f in instance._meta.get_fields()
|
||||
if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete
|
||||
]
|
||||
|
||||
# Handle each related field
|
||||
for relation in all_related:
|
||||
related_name = relation.get_accessor_name()
|
||||
|
||||
# Skip if the relation doesn't exist
|
||||
if not hasattr(instance, related_name):
|
||||
continue
|
||||
|
||||
# Get the on_delete behavior name
|
||||
on_delete_name = (
|
||||
relation.on_delete.__name__
|
||||
if hasattr(relation.on_delete, "__name__")
|
||||
else ""
|
||||
)
|
||||
|
||||
if on_delete_name == "DO_NOTHING":
|
||||
continue
|
||||
|
||||
elif on_delete_name == "SET_NULL":
|
||||
# Handle SET_NULL relationships
|
||||
if isinstance(relation, OneToOneRel):
|
||||
# For OneToOne relationships
|
||||
related_obj = getattr(instance, related_name, None)
|
||||
if related_obj and isinstance(related_obj, models.Model):
|
||||
setattr(related_obj, relation.remote_field.name, None)
|
||||
related_obj.save(update_fields=[relation.remote_field.name])
|
||||
else:
|
||||
# For other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
related_queryset.update(**{relation.remote_field.name: None})
|
||||
|
||||
else:
|
||||
# Handle CASCADE and other delete behaviors
|
||||
instance = model_class.all_objects.get(pk=instance_pk)
|
||||
related_fields = instance._meta.get_fields()
|
||||
for field in related_fields:
|
||||
if field.one_to_many or field.one_to_one:
|
||||
try:
|
||||
if relation.one_to_one:
|
||||
# Handle OneToOne relationships
|
||||
related_obj = getattr(instance, related_name, None)
|
||||
if related_obj:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
related_obj.deleted_at = timezone.now()
|
||||
related_obj.save()
|
||||
# Recursively handle related objects
|
||||
soft_delete_related_objects(
|
||||
related_obj._meta.app_label,
|
||||
related_obj._meta.model_name,
|
||||
related_obj.pk,
|
||||
using,
|
||||
)
|
||||
else:
|
||||
# Handle other relationships
|
||||
related_queryset = getattr(instance, related_name).all()
|
||||
for related_obj in related_queryset:
|
||||
if hasattr(related_obj, "deleted_at"):
|
||||
if not related_obj.deleted_at:
|
||||
related_obj.deleted_at = timezone.now()
|
||||
related_obj.save()
|
||||
# Recursively handle related objects
|
||||
soft_delete_related_objects(
|
||||
related_obj._meta.app_label,
|
||||
related_obj._meta.model_name,
|
||||
related_obj.pk,
|
||||
using,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log the error or handle as needed
|
||||
print(f"Error handling relation {related_name}: {str(e)}")
|
||||
continue
|
||||
# Check if the field has CASCADE on delete
|
||||
if (
|
||||
not hasattr(field.remote_field, "on_delete")
|
||||
or field.remote_field.on_delete == models.CASCADE
|
||||
):
|
||||
if field.one_to_many:
|
||||
related_objects = getattr(instance, field.name).all()
|
||||
elif field.one_to_one:
|
||||
related_object = getattr(instance, field.name)
|
||||
related_objects = (
|
||||
[related_object] if related_object is not None else []
|
||||
)
|
||||
|
||||
# Finally, soft delete the instance itself if it hasn't been deleted yet
|
||||
if hasattr(instance, "deleted_at") and not instance.deleted_at:
|
||||
instance.deleted_at = timezone.now()
|
||||
instance.save()
|
||||
for obj in related_objects:
|
||||
if obj:
|
||||
obj.deleted_at = timezone.now()
|
||||
obj.save(using=using)
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
# @shared_task
|
||||
|
||||
@@ -162,7 +162,8 @@ def generate_table_row(issue):
|
||||
issue["priority"],
|
||||
(
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
if issue["created_by__first_name"]
|
||||
and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
(
|
||||
@@ -196,7 +197,8 @@ def generate_json_row(issue):
|
||||
"Priority": issue["priority"],
|
||||
"Created By": (
|
||||
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
|
||||
if issue["created_by__first_name"] and issue["created_by__last_name"]
|
||||
if issue["created_by__first_name"]
|
||||
and issue["created_by__last_name"]
|
||||
else ""
|
||||
),
|
||||
"Assignee": (
|
||||
@@ -206,11 +208,17 @@ def generate_json_row(issue):
|
||||
),
|
||||
"Labels": issue["labels__name"] if issue["labels__name"] else "",
|
||||
"Cycle Name": issue["issue_cycle__cycle__name"],
|
||||
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
|
||||
"Cycle Start Date": dateConverter(
|
||||
issue["issue_cycle__cycle__start_date"]
|
||||
),
|
||||
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
|
||||
"Module Name": issue["issue_module__module__name"],
|
||||
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
|
||||
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
|
||||
"Module Start Date": dateConverter(
|
||||
issue["issue_module__module__start_date"]
|
||||
),
|
||||
"Module Target Date": dateConverter(
|
||||
issue["issue_module__module__target_date"]
|
||||
),
|
||||
"Created At": dateTimeConverter(issue["created_at"]),
|
||||
"Updated At": dateTimeConverter(issue["updated_at"]),
|
||||
"Completed At": dateTimeConverter(issue["completed_at"]),
|
||||
|
||||
@@ -257,9 +257,7 @@ def notifications(
|
||||
)
|
||||
|
||||
new_mentions = [
|
||||
str(mention)
|
||||
for mention in new_mentions
|
||||
if mention in set(project_members)
|
||||
str(mention) for mention in new_mentions if mention in set(project_members)
|
||||
]
|
||||
removed_mention = get_removed_mentions(
|
||||
requested_instance=requested_data, current_instance=current_instance
|
||||
|
||||
@@ -13,14 +13,28 @@ from plane.db.models import (
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
|
||||
help = "Add a member to a project. If present in the workspace"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
# Positional argument
|
||||
parser.add_argument("--project_id", type=str, nargs="?", help="Project ID")
|
||||
parser.add_argument("--user_email", type=str, nargs="?", help="User Email")
|
||||
parser.add_argument(
|
||||
"--role", type=int, nargs="?", help="Role of the user in the project"
|
||||
"--project_id",
|
||||
type=str,
|
||||
nargs="?",
|
||||
help="Project ID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--user_email",
|
||||
type=str,
|
||||
nargs="?",
|
||||
help="User Email",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--role",
|
||||
type=int,
|
||||
nargs="?",
|
||||
help="Role of the user in the project",
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: Any):
|
||||
@@ -53,7 +67,9 @@ class Command(BaseCommand):
|
||||
|
||||
# Get the smallest sort order
|
||||
smallest_sort_order = (
|
||||
ProjectMember.objects.filter(workspace_id=project.workspace_id)
|
||||
ProjectMember.objects.filter(
|
||||
workspace_id=project.workspace_id,
|
||||
)
|
||||
.order_by("sort_order")
|
||||
.first()
|
||||
)
|
||||
@@ -63,15 +79,22 @@ class Command(BaseCommand):
|
||||
else:
|
||||
sort_order = 65535
|
||||
|
||||
if ProjectMember.objects.filter(project=project, member=user).exists():
|
||||
if ProjectMember.objects.filter(
|
||||
project=project,
|
||||
member=user,
|
||||
).exists():
|
||||
# Update the project member
|
||||
ProjectMember.objects.filter(project=project, member=user).update(
|
||||
is_active=True, sort_order=sort_order, role=role
|
||||
)
|
||||
ProjectMember.objects.filter(
|
||||
project=project,
|
||||
member=user,
|
||||
).update(is_active=True, sort_order=sort_order, role=role)
|
||||
else:
|
||||
# Create the project member
|
||||
ProjectMember.objects.create(
|
||||
project=project, member=user, role=role, sort_order=sort_order
|
||||
project=project,
|
||||
member=user,
|
||||
role=role,
|
||||
sort_order=sort_order,
|
||||
)
|
||||
|
||||
# Issue Property
|
||||
@@ -79,7 +102,9 @@ class Command(BaseCommand):
|
||||
|
||||
# Success message
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"User {user_email} added to project {project_id}")
|
||||
self.style.SUCCESS(
|
||||
f"User {user_email} added to project {project_id}"
|
||||
)
|
||||
)
|
||||
return
|
||||
except CommandError as e:
|
||||
|
||||
@@ -53,6 +53,7 @@ from .project import (
|
||||
ProjectMemberInvite,
|
||||
ProjectPublicMember,
|
||||
)
|
||||
from .deploy_board import DeployBoard
|
||||
from .session import Session
|
||||
from .social_connection import SocialLoginConnection
|
||||
from .state import State
|
||||
@@ -68,14 +69,23 @@ from .workspace import (
|
||||
WorkspaceUserProperties,
|
||||
)
|
||||
|
||||
from .importer import Importer
|
||||
|
||||
from .page import Page, PageLog, PageLabel
|
||||
|
||||
from .estimate import Estimate, EstimatePoint
|
||||
|
||||
from .intake import Intake, IntakeIssue
|
||||
|
||||
from .analytic import AnalyticView
|
||||
|
||||
from .notification import Notification, UserNotificationPreference, EmailNotificationLog
|
||||
|
||||
from .exporter import ExporterHistory
|
||||
|
||||
from .webhook import Webhook, WebhookLog
|
||||
|
||||
from .dashboard import Dashboard, DashboardWidget, Widget
|
||||
|
||||
from .favorite import UserFavorite
|
||||
|
||||
|
||||
@@ -44,25 +44,45 @@ class FileAsset(BaseModel):
|
||||
"db.User", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
draft_issue = models.ForeignKey(
|
||||
"db.DraftIssue", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.DraftIssue",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.Project",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
comment = models.ForeignKey(
|
||||
"db.IssueComment", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
"db.IssueComment",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
related_name="assets",
|
||||
)
|
||||
page = models.ForeignKey(
|
||||
"db.Page", on_delete=models.CASCADE, null=True, related_name="assets"
|
||||
)
|
||||
entity_type = models.CharField(max_length=255, null=True, blank=True)
|
||||
entity_identifier = models.CharField(max_length=255, null=True, blank=True)
|
||||
entity_type = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
entity_identifier = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
is_deleted = models.BooleanField(default=False)
|
||||
is_archived = models.BooleanField(default=False)
|
||||
external_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
@@ -661,7 +661,9 @@ class IssueVote(ProjectBaseModel):
|
||||
|
||||
class IssueVersion(ProjectBaseModel):
|
||||
issue = models.ForeignKey(
|
||||
"db.Issue", on_delete=models.CASCADE, related_name="versions"
|
||||
"db.Issue",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="versions",
|
||||
)
|
||||
PRIORITY_CHOICES = (
|
||||
("urgent", "Urgent"),
|
||||
@@ -686,7 +688,9 @@ class IssueVersion(ProjectBaseModel):
|
||||
)
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
target_date = models.DateField(null=True, blank=True)
|
||||
sequence_id = models.IntegerField(default=1, verbose_name="Issue Sequence ID")
|
||||
sequence_id = models.IntegerField(
|
||||
default=1, verbose_name="Issue Sequence ID"
|
||||
)
|
||||
sort_order = models.FloatField(default=65535)
|
||||
completed_at = models.DateTimeField(null=True)
|
||||
archived_at = models.DateField(null=True)
|
||||
@@ -696,10 +700,25 @@ class IssueVersion(ProjectBaseModel):
|
||||
type = models.UUIDField(blank=True, null=True)
|
||||
last_saved_at = models.DateTimeField(default=timezone.now)
|
||||
owned_by = models.UUIDField()
|
||||
assignees = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
labels = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
cycle = models.UUIDField(null=True, blank=True)
|
||||
modules = ArrayField(models.UUIDField(), blank=True, default=list)
|
||||
assignees = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
labels = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
cycle = models.UUIDField(
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
modules = ArrayField(
|
||||
models.UUIDField(),
|
||||
blank=True,
|
||||
default=list,
|
||||
)
|
||||
properties = models.JSONField(default=dict)
|
||||
meta = models.JSONField(default=dict)
|
||||
|
||||
@@ -722,7 +741,9 @@ class IssueVersion(ProjectBaseModel):
|
||||
Module = apps.get_model("db.Module")
|
||||
CycleIssue = apps.get_model("db.CycleIssue")
|
||||
|
||||
cycle_issue = CycleIssue.objects.filter(issue=issue).first()
|
||||
cycle_issue = CycleIssue.objects.filter(
|
||||
issue=issue,
|
||||
).first()
|
||||
|
||||
cls.objects.create(
|
||||
issue=issue,
|
||||
@@ -750,7 +771,9 @@ class IssueVersion(ProjectBaseModel):
|
||||
assignees=issue.assignees,
|
||||
labels=issue.labels,
|
||||
cycle=cycle_issue.cycle if cycle_issue else None,
|
||||
modules=Module.objects.filter(issue=issue).values_list("id", flat=True),
|
||||
modules=Module.objects.filter(issue=issue).values_list(
|
||||
"id", flat=True
|
||||
),
|
||||
owned_by=user,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -29,7 +29,9 @@ def validate_domain(value):
|
||||
|
||||
class Webhook(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_webhooks"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_webhooks",
|
||||
)
|
||||
url = models.URLField(
|
||||
validators=[validate_schema, validate_domain], max_length=1024
|
||||
|
||||
@@ -102,7 +102,12 @@ def get_default_display_properties():
|
||||
|
||||
|
||||
def get_issue_props():
|
||||
return {"subscribed": True, "assigned": True, "created": True, "all_issues": True}
|
||||
return {
|
||||
"subscribed": True,
|
||||
"assigned": True,
|
||||
"created": True,
|
||||
"all_issues": True,
|
||||
}
|
||||
|
||||
|
||||
def slug_validator(value):
|
||||
@@ -131,7 +136,9 @@ class Workspace(BaseModel):
|
||||
max_length=48, db_index=True, unique=True, validators=[slug_validator]
|
||||
)
|
||||
organization_size = models.CharField(max_length=20, blank=True, null=True)
|
||||
timezone = models.CharField(max_length=255, default="UTC", choices=TIMEZONE_CHOICES)
|
||||
timezone = models.CharField(
|
||||
max_length=255, default="UTC", choices=TIMEZONE_CHOICES
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
"""Return name of the Workspace"""
|
||||
@@ -160,7 +167,10 @@ class WorkspaceBaseModel(BaseModel):
|
||||
"db.Workspace", models.CASCADE, related_name="workspace_%(class)s"
|
||||
)
|
||||
project = models.ForeignKey(
|
||||
"db.Project", models.CASCADE, related_name="project_%(class)s", null=True
|
||||
"db.Project",
|
||||
models.CASCADE,
|
||||
related_name="project_%(class)s",
|
||||
null=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -174,7 +184,9 @@ class WorkspaceBaseModel(BaseModel):
|
||||
|
||||
class WorkspaceMember(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_member",
|
||||
)
|
||||
member = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
@@ -209,7 +221,9 @@ class WorkspaceMember(BaseModel):
|
||||
|
||||
class WorkspaceMemberInvite(BaseModel):
|
||||
workspace = models.ForeignKey(
|
||||
"db.Workspace", on_delete=models.CASCADE, related_name="workspace_member_invite"
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="workspace_member_invite",
|
||||
)
|
||||
email = models.CharField(max_length=255)
|
||||
accepted = models.BooleanField(default=False)
|
||||
@@ -269,7 +283,9 @@ class WorkspaceTheme(BaseModel):
|
||||
)
|
||||
name = models.CharField(max_length=300)
|
||||
actor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="themes"
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="themes",
|
||||
)
|
||||
colors = models.JSONField(default=dict)
|
||||
|
||||
@@ -304,7 +320,9 @@ class WorkspaceUserProperties(BaseModel):
|
||||
)
|
||||
filters = models.JSONField(default=get_default_filters)
|
||||
display_filters = models.JSONField(default=get_default_display_filters)
|
||||
display_properties = models.JSONField(default=get_default_display_properties)
|
||||
display_properties = models.JSONField(
|
||||
default=get_default_display_properties
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["workspace", "user", "deleted_at"]
|
||||
|
||||
@@ -2,4 +2,4 @@ from .instance import InstanceSerializer
|
||||
|
||||
from .configuration import InstanceConfigurationSerializer
|
||||
from .admin import InstanceAdminSerializer, InstanceAdminMeSerializer
|
||||
from .workspace import WorkspaceSerializer
|
||||
from .workspace import WorkspaceSerializer
|
||||
@@ -1,8 +1,6 @@
|
||||
from .base import BaseSerializer
|
||||
from plane.db.models import User
|
||||
|
||||
|
||||
class UserLiteSerializer(BaseSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "first_name", "last_name"]
|
||||
fields = ["id", "email", "first_name", "last_name",]
|
||||
|
||||
@@ -18,9 +18,6 @@ class WorkspaceSerializer(BaseSerializer):
|
||||
# Check if the slug is restricted
|
||||
if value in RESTRICTED_WORKSPACE_SLUGS:
|
||||
raise serializers.ValidationError("Slug is not valid")
|
||||
# Check uniqueness case-insensitively
|
||||
if Workspace.objects.filter(slug__iexact=value).exists():
|
||||
raise serializers.ValidationError("Slug is already in use")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -13,8 +13,6 @@ from .admin import (
|
||||
InstanceAdminUserSessionEndpoint,
|
||||
)
|
||||
|
||||
from .changelog import ChangeLogEndpoint
|
||||
|
||||
from .workspace import (
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
)
|
||||
from .workspace import InstanceWorkSpaceAvailabilityCheckEndpoint, InstanceWorkSpaceEndpoint
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Python imports
|
||||
import requests
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
# plane imports
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class ChangeLogEndpoint(BaseAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def fetch_change_logs(self):
|
||||
response = requests.get(settings.INSTANCE_CHANGELOG_URL)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def get(self, request):
|
||||
# Fetch the changelog
|
||||
if settings.INSTANCE_CHANGELOG_URL:
|
||||
data = self.fetch_change_logs()
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
else:
|
||||
return Response(
|
||||
{"error": "could not fetch changelog please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -25,7 +25,7 @@ class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
workspace = (
|
||||
Workspace.objects.filter(slug__iexact=slug).exists()
|
||||
Workspace.objects.filter(slug=slug).exists()
|
||||
or slug in RESTRICTED_WORKSPACE_SLUGS
|
||||
)
|
||||
return Response({"status": not workspace}, status=status.HTTP_200_OK)
|
||||
@@ -43,19 +43,19 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
|
||||
member_count = (
|
||||
WorkspaceMember.objects.filter(
|
||||
workspace=OuterRef("id"), member__is_bot=False, is_active=True
|
||||
)
|
||||
.select_related("owner")
|
||||
).select_related("owner")
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
|
||||
workspaces = Workspace.objects.annotate(
|
||||
total_projects=project_count, total_members=member_count
|
||||
total_projects=project_count,
|
||||
total_members=member_count,
|
||||
)
|
||||
|
||||
# Add search functionality
|
||||
@@ -66,14 +66,16 @@ class InstanceWorkSpaceEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=workspaces,
|
||||
on_results=lambda results: WorkspaceSerializer(results, many=True).data,
|
||||
on_results=lambda results: WorkspaceSerializer(
|
||||
results, many=True,
|
||||
).data,
|
||||
max_per_page=10,
|
||||
default_per_page=10,
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
try:
|
||||
serializer = WorkspaceSerializer(data=request.data)
|
||||
serializer = WorkspaceSerializer (data=request.data)
|
||||
|
||||
slug = request.data.get("slug", False)
|
||||
name = request.data.get("name", False)
|
||||
|
||||
@@ -11,12 +11,14 @@ from plane.license.api.views import (
|
||||
InstanceAdminUserMeEndpoint,
|
||||
InstanceAdminSignOutEndpoint,
|
||||
InstanceAdminUserSessionEndpoint,
|
||||
ChangeLogEndpoint,
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint,
|
||||
InstanceWorkSpaceEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path("", InstanceEndpoint.as_view(), name="instance"),
|
||||
path("changelog/", ChangeLogEndpoint.as_view(), name="instance-changelog"),
|
||||
path("admins/", InstanceAdminEndpoint.as_view(), name="instance-admins"),
|
||||
path("admins/me/", InstanceAdminUserMeEndpoint.as_view(), name="instance-admins"),
|
||||
path(
|
||||
@@ -60,5 +62,9 @@ urlpatterns = [
|
||||
InstanceWorkSpaceAvailabilityCheckEndpoint.as_view(),
|
||||
name="instance-workspace-availability",
|
||||
),
|
||||
path("workspaces/", InstanceWorkSpaceEndpoint.as_view(), name="instance-workspace"),
|
||||
path(
|
||||
"workspaces/",
|
||||
InstanceWorkSpaceEndpoint.as_view(),
|
||||
name="instance-workspace",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -10,15 +10,9 @@ from plane.space.views import (
|
||||
ProjectStatesEndpoint,
|
||||
ProjectLabelsEndpoint,
|
||||
ProjectMembersEndpoint,
|
||||
ProjectMetaDataEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"anchor/<str:anchor>/meta/",
|
||||
ProjectMetaDataEndpoint.as_view(),
|
||||
name="project-meta",
|
||||
),
|
||||
path(
|
||||
"anchor/<str:anchor>/settings/",
|
||||
ProjectDeployBoardPublicSettingsEndpoint.as_view(),
|
||||
|
||||
@@ -25,5 +25,3 @@ from .state import ProjectStatesEndpoint
|
||||
from .label import ProjectLabelsEndpoint
|
||||
|
||||
from .asset import EntityAssetEndpoint, AssetRestoreEndpoint, EntityBulkAssetEndpoint
|
||||
|
||||
from .meta import ProjectMetaDataEndpoint
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# third party
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.db.models import DeployBoard, Project
|
||||
|
||||
from .base import BaseAPIView
|
||||
from plane.space.serializer.project import ProjectLiteSerializer
|
||||
|
||||
|
||||
class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.filter(
|
||||
anchor=anchor, entity_name="project"
|
||||
).first()
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
try:
|
||||
project_id = deploy_board.entity_identifier
|
||||
project = Project.objects.get(id=project_id)
|
||||
except Project.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
serializer = ProjectLiteSerializer(project)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
@@ -1,33 +1,23 @@
|
||||
# Python imports
|
||||
import os
|
||||
import atexit
|
||||
|
||||
# Third party imports
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.instrumentation.django import DjangoInstrumentor
|
||||
|
||||
# Global variable to track initialization
|
||||
_TRACER_PROVIDER = None
|
||||
import os
|
||||
|
||||
|
||||
def init_tracer():
|
||||
"""Initialize OpenTelemetry with proper shutdown handling"""
|
||||
global _TRACER_PROVIDER
|
||||
|
||||
# If already initialized, return existing provider
|
||||
if _TRACER_PROVIDER is not None:
|
||||
return _TRACER_PROVIDER
|
||||
# Check if already initialized to prevent double initialization
|
||||
if trace.get_tracer_provider().__class__.__name__ == "TracerProvider":
|
||||
return
|
||||
|
||||
# Configure the tracer provider
|
||||
service_name = os.environ.get("SERVICE_NAME", "plane-ce-api")
|
||||
resource = Resource.create({"service.name": service_name})
|
||||
tracer_provider = TracerProvider(resource=resource)
|
||||
|
||||
# Set as global tracer provider
|
||||
trace.set_tracer_provider(tracer_provider)
|
||||
|
||||
# Configure the OTLP exporter
|
||||
@@ -39,20 +29,12 @@ def init_tracer():
|
||||
# Initialize Django instrumentation
|
||||
DjangoInstrumentor().instrument()
|
||||
|
||||
# Store provider globally
|
||||
_TRACER_PROVIDER = tracer_provider
|
||||
|
||||
# Register shutdown handler
|
||||
atexit.register(shutdown_tracer)
|
||||
|
||||
return tracer_provider
|
||||
|
||||
|
||||
def shutdown_tracer():
|
||||
"""Shutdown OpenTelemetry tracers and processors"""
|
||||
global _TRACER_PROVIDER
|
||||
provider = trace.get_tracer_provider()
|
||||
|
||||
if _TRACER_PROVIDER is not None:
|
||||
if hasattr(_TRACER_PROVIDER, "shutdown"):
|
||||
_TRACER_PROVIDER.shutdown()
|
||||
_TRACER_PROVIDER = None
|
||||
if hasattr(provider, "shutdown"):
|
||||
provider.shutdown()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.17
|
||||
Django==4.2.16
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
+5
-5
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "live",
|
||||
"version": "0.24.0",
|
||||
"version": "0.23.1",
|
||||
"description": "",
|
||||
"main": "./src/server.ts",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
|
||||
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
|
||||
"start": "node dist/server.js",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet"
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
|
||||
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
@@ -30,7 +30,7 @@
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"express": "^4.20.0",
|
||||
"express-ws": "^5.0.2",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.4.1",
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
||||
import {
|
||||
prosemirrorJSONToYDoc,
|
||||
yXmlFragmentToProseMirrorRootNode,
|
||||
} from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
|
||||
import * as Y from "yjs"
|
||||
// plane editor
|
||||
import {
|
||||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@plane/editor/lib";
|
||||
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
|
||||
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [
|
||||
...CoreEditorExtensionsWithoutProps,
|
||||
@@ -17,9 +11,7 @@ const DOCUMENT_EDITOR_EXTENSIONS = [
|
||||
];
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
export const getAllDocumentFormatsFromBinaryData = (
|
||||
description: Uint8Array,
|
||||
): {
|
||||
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
|
||||
contentBinaryEncoded: string;
|
||||
contentJSON: object;
|
||||
contentHTML: string;
|
||||
@@ -32,7 +24,7 @@ export const getAllDocumentFormatsFromBinaryData = (
|
||||
const type = yDoc.getXmlFragment("default");
|
||||
const contentJSON = yXmlFragmentToProseMirrorRootNode(
|
||||
type,
|
||||
documentEditorSchema,
|
||||
documentEditorSchema
|
||||
).toJSON();
|
||||
// convert to HTML
|
||||
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
|
||||
@@ -42,29 +34,26 @@ export const getAllDocumentFormatsFromBinaryData = (
|
||||
contentJSON,
|
||||
contentHTML,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const getBinaryDataFromHTMLString = (
|
||||
descriptionHTML: string,
|
||||
): {
|
||||
contentBinary: Uint8Array;
|
||||
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
|
||||
contentBinary: Uint8Array
|
||||
} => {
|
||||
// convert HTML to JSON
|
||||
const contentJSON = generateJSON(
|
||||
descriptionHTML ?? "<p></p>",
|
||||
DOCUMENT_EDITOR_EXTENSIONS,
|
||||
DOCUMENT_EDITOR_EXTENSIONS
|
||||
);
|
||||
// convert JSON to Y.Doc format
|
||||
const transformedData = prosemirrorJSONToYDoc(
|
||||
documentEditorSchema,
|
||||
contentJSON,
|
||||
"default",
|
||||
"default"
|
||||
);
|
||||
// convert Y.Doc to Uint8Array format
|
||||
const encodedData = Y.encodeStateAsUpdate(transformedData);
|
||||
|
||||
return {
|
||||
contentBinary: encodedData,
|
||||
};
|
||||
};
|
||||
|
||||
contentBinary: encodedData
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { handleAuthentication } from "@/core/lib/authentication.js";
|
||||
// extensions
|
||||
import { getExtensions } from "@/core/extensions/index.js";
|
||||
import {
|
||||
DocumentCollaborativeEvents,
|
||||
TDocumentEventsServer,
|
||||
} from "@plane/editor/lib";
|
||||
// editor types
|
||||
import { TUserDetails } from "@plane/editor";
|
||||
// types
|
||||
@@ -59,14 +55,6 @@ export const getHocusPocusServer = async () => {
|
||||
throw Error("Authentication unsuccessful!");
|
||||
}
|
||||
},
|
||||
async onStateless({ payload, document }) {
|
||||
// broadcast the client event (derived from the server event) to all the clients so that they can update their state
|
||||
const response =
|
||||
DocumentCollaborativeEvents[payload as TDocumentEventsServer].client;
|
||||
if (response) {
|
||||
document.broadcastStateless(response);
|
||||
}
|
||||
},
|
||||
extensions,
|
||||
debounce: 10000,
|
||||
});
|
||||
|
||||
@@ -26,41 +26,18 @@ export const updatePageDescription = async (
|
||||
|
||||
const { contentBinaryEncoded, contentHTML, contentJSON } =
|
||||
getAllDocumentFormatsFromBinaryData(updatedDescription);
|
||||
|
||||
try {
|
||||
// Generate a unique boundary
|
||||
const boundary = `----FormBoundary${Date.now().toString()}`;
|
||||
|
||||
// Construct the multipart form data manually
|
||||
let formData = "";
|
||||
|
||||
// Add binary content
|
||||
formData += `--${boundary}\r\n`;
|
||||
formData += 'Content-Disposition: form-data; name="description_binary"\r\n';
|
||||
formData += "Content-Type: application/octet-stream\r\n\r\n";
|
||||
formData += updatedDescription + "\r\n";
|
||||
|
||||
// Add HTML content
|
||||
formData += `--${boundary}\r\n`;
|
||||
formData += 'Content-Disposition: form-data; name="description_html"\r\n';
|
||||
formData += "Content-Type: text/html\r\n\r\n";
|
||||
formData += contentHTML + "\r\n";
|
||||
|
||||
// Add JSON content
|
||||
formData += `--${boundary}\r\n`;
|
||||
formData += 'Content-Disposition: form-data; name="description"\r\n';
|
||||
formData += "Content-Type: application/json\r\n\r\n";
|
||||
formData += JSON.stringify(contentJSON) + "\r\n";
|
||||
|
||||
// End boundary
|
||||
formData += `--${boundary}--\r\n`;
|
||||
const payload = {
|
||||
description_binary: contentBinaryEncoded,
|
||||
description_html: contentHTML,
|
||||
description: contentJSON,
|
||||
};
|
||||
|
||||
await pageService.updateDescription(
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
pageId,
|
||||
formData,
|
||||
boundary,
|
||||
payload,
|
||||
cookie,
|
||||
);
|
||||
} catch (error) {
|
||||
@@ -69,8 +46,6 @@ export const updatePageDescription = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Update the service method
|
||||
|
||||
const fetchDescriptionHTMLAndTransform = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
@@ -115,9 +90,7 @@ export const fetchPageDescriptionBinary = async (
|
||||
pageId,
|
||||
cookie,
|
||||
);
|
||||
console.log("response", response);
|
||||
const binaryData = new Uint8Array(response);
|
||||
console.log("binaryData", binaryData);
|
||||
|
||||
if (binaryData.byteLength === 0) {
|
||||
const binary = await fetchDescriptionHTMLAndTransform(
|
||||
|
||||
@@ -12,7 +12,7 @@ export class PageService extends APIService {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string,
|
||||
cookie: string
|
||||
): Promise<TPage> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
|
||||
@@ -20,7 +20,7 @@ export class PageService extends APIService {
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@@ -32,7 +32,7 @@ export class PageService extends APIService {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
cookie: string,
|
||||
cookie: string
|
||||
): Promise<any> {
|
||||
return this.get(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
@@ -42,7 +42,7 @@ export class PageService extends APIService {
|
||||
Cookie: cookie,
|
||||
},
|
||||
responseType: "arraybuffer",
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
@@ -54,19 +54,21 @@ export class PageService extends APIService {
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
pageId: string,
|
||||
formData: string,
|
||||
boundary: string,
|
||||
cookie: string,
|
||||
data: {
|
||||
description_binary: string;
|
||||
description_html: string;
|
||||
description: object;
|
||||
},
|
||||
cookie: string
|
||||
): Promise<any> {
|
||||
return this.patch(
|
||||
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
|
||||
formData,
|
||||
data,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
Cookie: cookie,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.24.0",
|
||||
"version": "0.23.1",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
@@ -22,7 +22,7 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.3.3"
|
||||
"turbo": "^2.3.2"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22",
|
||||
"name": "plane"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from "./auth";
|
||||
export * from "./endpoints";
|
||||
export * from "./issue";
|
||||
export * from "./workspace";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/constants",
|
||||
"version": "0.24.0",
|
||||
"version": "0.23.1",
|
||||
"private": true,
|
||||
"main": "./src/index.ts"
|
||||
"main": "./index.ts"
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "";
|
||||
// PI Base Url
|
||||
export const PI_BASE_URL = process.env.NEXT_PUBLIC_PI_BASE_URL || "";
|
||||
// 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 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 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_URL = encodeURI(`${LIVE_BASE_URL}${LIVE_BASE_PATH}/`);
|
||||
// plane website url
|
||||
export const WEBSITE_URL =
|
||||
process.env.NEXT_PUBLIC_WEBSITE_URL || "https://plane.so";
|
||||
@@ -1,76 +0,0 @@
|
||||
export const ORGANIZATION_SIZE = [
|
||||
"Just myself",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-500",
|
||||
"500+",
|
||||
];
|
||||
|
||||
export const RESTRICTED_URLS = [
|
||||
"404",
|
||||
"accounts",
|
||||
"api",
|
||||
"create-workspace",
|
||||
"god-mode",
|
||||
"installations",
|
||||
"invitations",
|
||||
"onboarding",
|
||||
"profile",
|
||||
"spaces",
|
||||
"workspace-invitations",
|
||||
"password",
|
||||
"flags",
|
||||
"monitor",
|
||||
"monitoring",
|
||||
"ingest",
|
||||
"plane-pro",
|
||||
"plane-ultimate",
|
||||
"enterprise",
|
||||
"plane-enterprise",
|
||||
"disco",
|
||||
"silo",
|
||||
"chat",
|
||||
"calendar",
|
||||
"drive",
|
||||
"channels",
|
||||
"upgrade",
|
||||
"billing",
|
||||
"sign-in",
|
||||
"sign-up",
|
||||
"signin",
|
||||
"signup",
|
||||
"config",
|
||||
"live",
|
||||
"admin",
|
||||
"m",
|
||||
"import",
|
||||
"importers",
|
||||
"integrations",
|
||||
"integration",
|
||||
"configuration",
|
||||
"initiatives",
|
||||
"initiative",
|
||||
"config",
|
||||
"workflow",
|
||||
"workflows",
|
||||
"epics",
|
||||
"epic",
|
||||
"story",
|
||||
"mobile",
|
||||
"dashboard",
|
||||
"desktop",
|
||||
"onload",
|
||||
"real-time",
|
||||
"one",
|
||||
"pages",
|
||||
"mobile",
|
||||
"business",
|
||||
"pro",
|
||||
"settings",
|
||||
"monitor",
|
||||
"license",
|
||||
"licenses",
|
||||
"instances",
|
||||
"instance",
|
||||
];
|
||||
@@ -0,0 +1,23 @@
|
||||
export const ORGANIZATION_SIZE = [
|
||||
"Just myself",
|
||||
"2-10",
|
||||
"11-50",
|
||||
"51-200",
|
||||
"201-500",
|
||||
"500+",
|
||||
];
|
||||
|
||||
export const RESTRICTED_URLS = [
|
||||
"404",
|
||||
"accounts",
|
||||
"api",
|
||||
"create-workspace",
|
||||
"error",
|
||||
"god-mode",
|
||||
"installations",
|
||||
"invitations",
|
||||
"onboarding",
|
||||
"profile",
|
||||
"spaces",
|
||||
"workspace-invitations",
|
||||
];
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@plane/editor",
|
||||
"version": "0.24.0",
|
||||
"version": "0.23.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"private": true,
|
||||
"main": "./dist/index.mjs",
|
||||
@@ -27,7 +27,6 @@
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.tsx",
|
||||
"lint:errors": "eslint src --ext .ts,.tsx --quiet",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\""
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -37,8 +36,8 @@
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "^0.26.4",
|
||||
"@hocuspocus/provider": "^2.13.5",
|
||||
"@plane/helpers": "*",
|
||||
"@plane/ui": "*",
|
||||
"@plane/utils": "*",
|
||||
"@tiptap/core": "^2.1.13",
|
||||
"@tiptap/extension-blockquote": "^2.1.13",
|
||||
"@tiptap/extension-character-count": "^2.6.5",
|
||||
@@ -63,7 +62,6 @@
|
||||
"linkifyjs": "^4.1.3",
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.378.0",
|
||||
"prosemirror-codemark": "^0.4.2",
|
||||
"prosemirror-utils": "^1.2.2",
|
||||
"react-moveable": "^0.54.2",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./extensions";
|
||||
export * from "./read-only-extensions";
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
|
||||
const {} = props;
|
||||
return [];
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
|
||||
export const CoreEditorAdditionalExtensionsWithoutProps: Extensions = [];
|
||||
@@ -15,13 +15,7 @@ type Props = {
|
||||
|
||||
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
|
||||
const { disabledExtensions } = _props;
|
||||
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
|
||||
? []
|
||||
: [
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
}),
|
||||
];
|
||||
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
export * from "./core";
|
||||
export * from "./document-extensions";
|
||||
export * from "./slash-commands";
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
// extensions
|
||||
import { TSlashCommandAdditionalOption } from "@/extensions";
|
||||
// types
|
||||
import { TExtensions } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const coreEditorAdditionalSlashCommandOptions = (props: Props): TSlashCommandAdditionalOption[] => {
|
||||
const {} = props;
|
||||
const options: TSlashCommandAdditionalOption[] = [];
|
||||
return options;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React from "react";
|
||||
// components
|
||||
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
@@ -19,7 +19,6 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
@@ -32,6 +31,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
has_enabled_smooth_cursor = true,
|
||||
} = props;
|
||||
|
||||
const extensions = [];
|
||||
@@ -44,25 +44,24 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
}
|
||||
|
||||
// use document editor
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced, localProvider, hasIndexedDbSynced } =
|
||||
useCollaborativeEditor({
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
});
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
onTransaction,
|
||||
has_enabled_smooth_cursor,
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
id,
|
||||
mentionHandler,
|
||||
placeholder,
|
||||
realtimeConfig,
|
||||
serverHandler,
|
||||
tabIndex,
|
||||
user,
|
||||
});
|
||||
|
||||
const editorContainerClassNames = getEditorClassNames({
|
||||
noBorder: true,
|
||||
@@ -70,30 +69,9 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
containerClassName,
|
||||
});
|
||||
|
||||
const [hasIndexedDbEntry, setHasIndexedDbEntry] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function documentIndexedDbEntry(dbName: string) {
|
||||
try {
|
||||
const databases = await indexedDB.databases();
|
||||
const hasEntry = databases.some((db) => db.name === dbName);
|
||||
setHasIndexedDbEntry(hasEntry);
|
||||
} catch (error) {
|
||||
console.error("Error checking database existence:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
documentIndexedDbEntry(id);
|
||||
}, [id, localProvider]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
// Wait until we know about IndexedDB status
|
||||
if (hasIndexedDbEntry === null) return null;
|
||||
|
||||
if (hasServerConnectionFailed || (!hasIndexedDbEntry && !hasServerSynced) || !hasIndexedDbSynced) {
|
||||
return <DocumentContentLoader />;
|
||||
}
|
||||
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
|
||||
|
||||
return (
|
||||
<PageRenderer
|
||||
|
||||
-2
@@ -15,7 +15,6 @@ import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/ty
|
||||
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
@@ -38,7 +37,6 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
|
||||
}
|
||||
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
|
||||
@@ -129,7 +129,6 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
[editor, cleanup]
|
||||
);
|
||||
|
||||
console.log("rendered");
|
||||
return (
|
||||
<>
|
||||
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
|
||||
@@ -140,12 +139,12 @@ export const PageRenderer = (props: IPageRenderer) => {
|
||||
id={id}
|
||||
>
|
||||
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
|
||||
{/* {editor.isEditable && ( */}
|
||||
{/* <> */}
|
||||
{/* <BlockMenu editor={editor} /> */}
|
||||
{/* <AIFeaturesMenu menu={aiHandler?.menu} /> */}
|
||||
{/* </> */}
|
||||
{/* )} */}
|
||||
{editor.isEditable && (
|
||||
<>
|
||||
<BlockMenu editor={editor} />
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</>
|
||||
)}
|
||||
</EditorContainer>
|
||||
</div>
|
||||
{isOpen && linkViewProps && coordinates && (
|
||||
|
||||
@@ -10,10 +10,9 @@ import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
|
||||
// types
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
|
||||
|
||||
interface IDocumentReadOnlyEditor {
|
||||
disabledExtensions: TExtensions[];
|
||||
id: string;
|
||||
initialValue: string;
|
||||
containerClassName: string;
|
||||
@@ -32,7 +31,6 @@ interface IDocumentReadOnlyEditor {
|
||||
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
@@ -53,7 +51,6 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
}
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
extensions,
|
||||
fileHandler,
|
||||
|
||||
@@ -13,25 +13,25 @@ import { EditorContentWrapper } from "./editor-content";
|
||||
type Props = IEditorProps & {
|
||||
children?: (editor: Editor) => React.ReactNode;
|
||||
extensions: Extension<any, any>[];
|
||||
has_enabled_smooth_cursor: boolean;
|
||||
};
|
||||
|
||||
export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
const {
|
||||
children,
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
extensions,
|
||||
id,
|
||||
initialValue,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
has_enabled_smooth_cursor,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
@@ -39,19 +39,18 @@ export const EditorWrapper: React.FC<Props> = (props) => {
|
||||
} = props;
|
||||
|
||||
const editor = useEditor({
|
||||
editable,
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
enableHistory: true,
|
||||
extensions,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
has_enabled_smooth_cursor,
|
||||
id,
|
||||
initialValue,
|
||||
mentionHandler,
|
||||
onChange,
|
||||
onTransaction,
|
||||
handleEditorReady,
|
||||
autofocus,
|
||||
placeholder,
|
||||
tabIndex,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { EnterKeyExtension } from "@/extensions";
|
||||
import { EditorRefApi, ILiteTextEditor } from "@/types";
|
||||
|
||||
const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
|
||||
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [], has_enabled_smooth_cursor } = props;
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
@@ -17,7 +17,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
|
||||
[externalExtensions, disabledExtensions, onEnterKeyPress]
|
||||
);
|
||||
|
||||
return <EditorWrapper {...props} extensions={extensions} />;
|
||||
return <EditorWrapper {...props} extensions={extensions} has_enabled_smooth_cursor={has_enabled_smooth_cursor} />;
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
|
||||
|
||||
@@ -12,7 +12,6 @@ import { IReadOnlyEditorProps } from "@/types";
|
||||
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
const {
|
||||
containerClassName,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editorClassName = "",
|
||||
fileHandler,
|
||||
@@ -23,7 +22,6 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
|
||||
} = props;
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
|
||||
@@ -8,7 +8,13 @@ import { SideMenuExtension, SlashCommands } from "@/extensions";
|
||||
import { EditorRefApi, IRichTextEditor } from "@/types";
|
||||
|
||||
const RichTextEditor = (props: IRichTextEditor) => {
|
||||
const { disabledExtensions, dragDropEnabled, bubbleMenuEnabled = true, extensions: externalExtensions = [] } = props;
|
||||
const {
|
||||
disabledExtensions,
|
||||
dragDropEnabled,
|
||||
bubbleMenuEnabled = true,
|
||||
extensions: externalExtensions = [],
|
||||
has_enabled_smooth_cursor,
|
||||
} = props;
|
||||
|
||||
const getExtensions = useCallback(() => {
|
||||
const extensions = [
|
||||
@@ -19,18 +25,14 @@ const RichTextEditor = (props: IRichTextEditor) => {
|
||||
}),
|
||||
];
|
||||
if (!disabledExtensions?.includes("slash-commands")) {
|
||||
extensions.push(
|
||||
SlashCommands({
|
||||
disabledExtensions,
|
||||
})
|
||||
);
|
||||
extensions.push(SlashCommands());
|
||||
}
|
||||
|
||||
return extensions;
|
||||
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
|
||||
|
||||
return (
|
||||
<EditorWrapper {...props} extensions={getExtensions()}>
|
||||
<EditorWrapper {...props} extensions={getExtensions()} has_enabled_smooth_cursor={has_enabled_smooth_cursor}>
|
||||
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>}
|
||||
</EditorWrapper>
|
||||
);
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const DocumentCollaborativeEvents = {
|
||||
lock: { client: "locked", server: "lock" },
|
||||
unlock: { client: "unlocked", server: "unlock" },
|
||||
archive: { client: "archived", server: "archive" },
|
||||
unarchive: { client: "unarchived", server: "unarchive" },
|
||||
} as const;
|
||||
@@ -1,5 +1,5 @@
|
||||
// plane helpers
|
||||
import { convertHexEmojiToDecimal } from "@plane/utils";
|
||||
import { convertHexEmojiToDecimal } from "@plane/helpers";
|
||||
// plane ui
|
||||
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
|
||||
// helpers
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// plane helpers
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
import { sanitizeHTML } from "@plane/helpers";
|
||||
// plane ui
|
||||
import { TEmojiLogoProps } from "@plane/ui";
|
||||
// types
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { MarkType } from "@tiptap/pm/model";
|
||||
import { type EditorState, type Plugin, type Transaction, TextSelection } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import type { CodemarkState, CursorMetaTr } from "./types";
|
||||
import { MAX_MATCH, safeResolve } from "./utils";
|
||||
|
||||
export function stepOutsideNextTrAndPass(view: EditorView, plugin: Plugin, action: "click" | "next" = "next"): boolean {
|
||||
const meta: CursorMetaTr = { action };
|
||||
view.dispatch(view.state.tr.setMeta(plugin, meta));
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onBacktick(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (view.state.selection.empty) return false;
|
||||
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
// Create a code mark!
|
||||
const { from, to } = view.state.selection;
|
||||
if (to - from >= MAX_MATCH || view.state.doc.rangeHasMark(from, to, markType)) return false;
|
||||
const tr = view.state.tr.addMark(from, to, markType.create());
|
||||
const selected = tr.setSelection(TextSelection.create(tr.doc, to)).removeStoredMark(markType);
|
||||
view.dispatch(selected);
|
||||
return true;
|
||||
}
|
||||
|
||||
function onArrowRightInside(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey) return stepOutsideNextTrAndPass(view, plugin);
|
||||
if (event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
if (!selection.empty) return false;
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
const pos = selection.$from;
|
||||
const inCode = !!markType.isInSet(pos.marks());
|
||||
const nextCode = !!markType.isInSet(pos.marksAcross(safeResolve(doc, selection.from + 1)) ?? []);
|
||||
|
||||
if (pos.pos === view.state.doc.nodeSize - 3 && pos.parentOffset === pos.parent.nodeSize - 2 && pluginState?.active) {
|
||||
// Behaviour stops: `code`| at the end of the document
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode === nextCode && pos.parentOffset !== 0) return false;
|
||||
if (inCode && (!pluginState?.active || pluginState.side === -1) && pos.parentOffset !== 0) {
|
||||
// `code|` --> `code`|
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (nextCode && pluginState?.side === -1) {
|
||||
// |`code` --> `|code`
|
||||
view.dispatch(view.state.tr.addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onArrowRight(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
const handled = onArrowRightInside(view, plugin, event, markType);
|
||||
if (handled) return true;
|
||||
const { selection } = view.state;
|
||||
const pos = selection.$from;
|
||||
if (selection.empty && pos.parentOffset === pos.parent.nodeSize - 2) {
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onArrowLeftInside(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey) return stepOutsideNextTrAndPass(view, plugin);
|
||||
if (event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
const inCode = !!markType.isInSet(selection.$from.marks());
|
||||
const nextCode = !!markType.isInSet(
|
||||
safeResolve(doc, selection.empty ? selection.from - 1 : selection.from + 1).marks() ?? []
|
||||
);
|
||||
if (inCode && pluginState?.side === -1 && selection.$from.parentOffset === 0) {
|
||||
// New line!
|
||||
// ^|`code` --> |^`code`
|
||||
return false;
|
||||
}
|
||||
if (pluginState?.side === 0 && selection.$from.parentOffset === 0) {
|
||||
// New line!
|
||||
// ^`|code` --> ^|`code`
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode && nextCode && pluginState?.side === 0) {
|
||||
// `code`| --> `code|`
|
||||
view.dispatch(view.state.tr.addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
if (inCode && !nextCode && pluginState?.active && selection.$from.parentOffset === 0) {
|
||||
// ^`|code` --> ^|`code`
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (!inCode && pluginState?.active && pluginState?.side === 0) {
|
||||
// `|code` --> |`code`
|
||||
view.dispatch(view.state.tr.removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode === nextCode) return false;
|
||||
if (nextCode || (!selection.empty && inCode)) {
|
||||
// `code`_|_ --> `code`| nextCode
|
||||
// `code`███ --> `code`| !selection.empty && inCode
|
||||
// `██de`___ --> `|code` !selection.empty && nextCode
|
||||
const from = selection.empty ? selection.from - 1 : selection.from;
|
||||
const selected = view.state.tr.setSelection(TextSelection.create(doc, from));
|
||||
if (!selection.empty && nextCode) {
|
||||
view.dispatch(selected.addStoredMark(markType.create()));
|
||||
} else {
|
||||
view.dispatch(selected.removeStoredMark(markType));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if ((nextCode || (!selection.empty && inCode)) && !pluginState?.active) {
|
||||
// `code`_|_ --> `code`|
|
||||
// `code`███ --> `code`|
|
||||
const from = selection.empty ? selection.from - 1 : selection.from;
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, from)).removeStoredMark(markType));
|
||||
return true;
|
||||
}
|
||||
if (inCode && !pluginState?.active && selection.$from.parentOffset > 0) {
|
||||
// `c|ode` --> `|code`
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(TextSelection.create(doc, selection.from - 1)).addStoredMark(markType.create())
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (inCode && !nextCode && pluginState?.active && pluginState.side !== -1) {
|
||||
// `x`| --> `x|` - Single character
|
||||
view.dispatch(view.state.tr.addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
if (inCode && !nextCode && pluginState?.active) {
|
||||
// `x|` --> `|x` - Single character inside
|
||||
const pos = selection.from - 1;
|
||||
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)).addStoredMark(markType.create()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onArrowLeft(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
const handled = onArrowLeftInside(view, plugin, event, markType);
|
||||
if (handled) return true;
|
||||
const { selection } = view.state;
|
||||
const pos = selection.$from;
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
if (pos.pos === 1 && pos.parentOffset === 0 && pluginState?.side === -1) {
|
||||
return true;
|
||||
}
|
||||
if (selection.empty && pos.parentOffset === 0) {
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onBackspace(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
const from = safeResolve(doc, selection.from - 1);
|
||||
const fromCode = !!markType.isInSet(from.marks());
|
||||
const startOfLine = from.parentOffset === 0;
|
||||
const toCode = !!markType.isInSet(safeResolve(doc, selection.to + 1).marks());
|
||||
if ((!fromCode || startOfLine) && !toCode) {
|
||||
// `x|` → |
|
||||
// `|████` → |
|
||||
// `|███`█ → |
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
// Firefox has difficulty with the decorations on -1.
|
||||
const pluginState = plugin.getState(view.state) as CodemarkState;
|
||||
if (selection.empty && pluginState?.side === -1) {
|
||||
const tr = view.state.tr.delete(selection.from - 1, selection.from);
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function onDelete(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
|
||||
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
|
||||
const { selection, doc } = view.state;
|
||||
const fromCode = !!markType.isInSet(selection.$from.marks());
|
||||
const startOfLine = selection.$from.parentOffset === 0;
|
||||
const toCode = !!markType.isInSet(safeResolve(doc, selection.to + 2).marks());
|
||||
if ((!fromCode || startOfLine) && !toCode) {
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stepOutside(state: EditorState, markType: MarkType): Transaction | null {
|
||||
if (!state) return null;
|
||||
const { selection, doc } = state;
|
||||
if (!selection.empty) return null;
|
||||
const stored = !!markType.isInSet(state.storedMarks ?? []);
|
||||
const inCode = !!markType.isInSet(selection.$from.marks());
|
||||
const nextCode = !!markType.isInSet(safeResolve(doc, selection.from + 1).marks() ?? []);
|
||||
const startOfLine = selection.$from.parentOffset === 0;
|
||||
// `code|` --> `code`|
|
||||
// `|code` --> |`code`
|
||||
// ^`|code` --> ^|`code`
|
||||
if (inCode !== nextCode || (!inCode && stored !== inCode) || (inCode && startOfLine))
|
||||
return state.tr.removeStoredMark(markType);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// taken from https://github.com/curvenote/editor/tree/main/packages/prosemirror-codemark
|
||||
import { type PluginSpec, Plugin } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import type { CodemarkState, CursorMetaTr, Options } from "./types";
|
||||
import { getMarkType, codeMarkPluginKey, safeResolve } from "./utils";
|
||||
import { createInputRule } from "./input-rules";
|
||||
import {
|
||||
onArrowLeft,
|
||||
onArrowRight,
|
||||
onBackspace,
|
||||
onBacktick,
|
||||
onDelete,
|
||||
stepOutside,
|
||||
stepOutsideNextTrAndPass,
|
||||
} from "./actions";
|
||||
|
||||
function toDom(): Node {
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("fake-cursor");
|
||||
return span;
|
||||
}
|
||||
|
||||
export function getDecorationPlugin(opts?: Options) {
|
||||
const plugin: Plugin<CodemarkState> = new Plugin({
|
||||
key: codeMarkPluginKey,
|
||||
appendTransaction: (trs, oldState, newState) => {
|
||||
const prev = plugin.getState(oldState) as CodemarkState;
|
||||
const meta = trs[0]?.getMeta(plugin) as CursorMetaTr | null;
|
||||
if (prev?.next || meta?.action === "click") {
|
||||
return stepOutside(newState, getMarkType(newState, opts));
|
||||
}
|
||||
return null;
|
||||
},
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, value, oldState, state): CodemarkState | null {
|
||||
const meta = tr.getMeta(plugin) as CursorMetaTr | null;
|
||||
if (meta?.action === "next") return { next: true };
|
||||
|
||||
const markType = getMarkType(state, opts);
|
||||
const nextMark = markType.isInSet(state.storedMarks ?? state.doc.resolve(tr.selection.from).marks());
|
||||
const inCode = markType.isInSet(state.doc.resolve(tr.selection.from).marks());
|
||||
const nextCode = markType.isInSet(safeResolve(state.doc, tr.selection.from + 1).marks());
|
||||
const startOfLine = tr.selection.$from.parentOffset === 0;
|
||||
if (!tr.selection.empty) return null;
|
||||
if (!nextMark && nextCode && (!inCode || startOfLine)) {
|
||||
// |`code`
|
||||
return { active: true, side: -1 };
|
||||
}
|
||||
if (nextMark && (!inCode || startOfLine)) {
|
||||
// `|code`
|
||||
return { active: true, side: 0 };
|
||||
}
|
||||
if (!nextMark && inCode && !nextCode) {
|
||||
// `code`|
|
||||
return { active: true, side: 0 };
|
||||
}
|
||||
if (nextMark && inCode && !nextCode) {
|
||||
// `code|`
|
||||
return { active: true, side: -1 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
attributes: (state) => {
|
||||
const { active = false } = plugin.getState(state) ?? {};
|
||||
return {
|
||||
...(active ? { class: "no-cursor" } : {}),
|
||||
};
|
||||
},
|
||||
decorations: (state) => {
|
||||
const { active, side } = plugin.getState(state) ?? {};
|
||||
if (!active) return DecorationSet.empty;
|
||||
const deco = Decoration.widget(state.selection.from, toDom, { side });
|
||||
return DecorationSet.create(state.doc, [deco]);
|
||||
},
|
||||
handleKeyDown(view, event) {
|
||||
switch (event.key) {
|
||||
case "`":
|
||||
return onBacktick(view, plugin, event, getMarkType(view, opts));
|
||||
case "ArrowRight":
|
||||
return onArrowRight(view, plugin, event, getMarkType(view, opts));
|
||||
case "ArrowLeft":
|
||||
return onArrowLeft(view, plugin, event, getMarkType(view, opts));
|
||||
case "Backspace":
|
||||
return onBackspace(view, plugin, event, getMarkType(view, opts));
|
||||
case "Delete":
|
||||
return onDelete(view, plugin, event, getMarkType(view, opts));
|
||||
case "ArrowUp":
|
||||
case "ArrowDown":
|
||||
case "Home":
|
||||
case "End":
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
case "e":
|
||||
case "a":
|
||||
if (!event.ctrlKey) return false;
|
||||
return stepOutsideNextTrAndPass(view, plugin);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
},
|
||||
handleClick(view) {
|
||||
return stepOutsideNextTrAndPass(view, plugin, "click");
|
||||
},
|
||||
},
|
||||
} as PluginSpec<CodemarkState>);
|
||||
return plugin;
|
||||
}
|
||||
|
||||
export function codemark(opts?: Options) {
|
||||
const cursorPlugin = getDecorationPlugin(opts);
|
||||
const inputRule = createInputRule(cursorPlugin, opts);
|
||||
const rules: Plugin[] = [cursorPlugin, inputRule];
|
||||
return rules;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { MarkType } from "@tiptap/pm/model";
|
||||
import { type PluginSpec, type Transaction, Plugin, TextSelection } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import type { Options } from "./types";
|
||||
import { getMarkType, MAX_MATCH } from "./utils";
|
||||
|
||||
type InputRuleState = {
|
||||
transform: Transaction;
|
||||
from: number;
|
||||
to: number;
|
||||
text: string;
|
||||
} | null;
|
||||
|
||||
type Plugins = { input: Plugin; cursor: Plugin };
|
||||
|
||||
type Handler = (
|
||||
markType: MarkType,
|
||||
state: EditorView,
|
||||
text: string,
|
||||
match: RegExpExecArray,
|
||||
from: number,
|
||||
to: number,
|
||||
plugin: Plugins
|
||||
) => boolean;
|
||||
|
||||
type Rule = {
|
||||
match: RegExp;
|
||||
handler: Handler;
|
||||
};
|
||||
|
||||
function stopMatch(markType: MarkType, view: EditorView, from: number, to: number): boolean {
|
||||
const stored = markType.isInSet(view.state.storedMarks ?? view.state.doc.resolve(from).marks());
|
||||
const range = view.state.doc.rangeHasMark(from, to, markType);
|
||||
// Don't create it if there is code in between!
|
||||
if (stored || range) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const markBefore: Rule = {
|
||||
match: /`((?:[^`\w]|[\w])+)`$/,
|
||||
handler: (markType, view, text, match, from, to, plugins) => {
|
||||
if (stopMatch(markType, view, from, to)) return false;
|
||||
const code = match[1];
|
||||
const mark = markType.create();
|
||||
const pos = from + code.length;
|
||||
const tr = view.state.tr.delete(from, to).insertText(code).addMark(from, pos, mark);
|
||||
const selected = tr.setSelection(TextSelection.create(tr.doc, pos)).removeStoredMark(markType);
|
||||
const withMeta = selected.setMeta(plugins.input, {
|
||||
transform: selected,
|
||||
from,
|
||||
to,
|
||||
text: `\`${code}${text}`,
|
||||
});
|
||||
view.dispatch(withMeta);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
const markAfter: Rule = {
|
||||
match: /^`((?:[^`\w]|[\w])+)`/,
|
||||
handler: (markType, view, text, match, from, to, plugins) => {
|
||||
if (stopMatch(markType, view, from, to)) return false;
|
||||
const mark = markType.create();
|
||||
const code = match[1];
|
||||
const pos = from;
|
||||
const tr = view.state.tr
|
||||
.delete(from, to)
|
||||
.insertText(code)
|
||||
.addMark(from, from + code.length, mark);
|
||||
const selected = tr.setSelection(TextSelection.create(tr.doc, pos)).addStoredMark(markType.create());
|
||||
const withMeta = selected.setMeta(plugins.input, {
|
||||
transform: selected,
|
||||
from,
|
||||
to,
|
||||
text: `\`${code}${text}`,
|
||||
});
|
||||
view.dispatch(withMeta);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function run(markType: MarkType, view: EditorView, from: number, to: number, text: string, plugins: Plugins) {
|
||||
if (view.composing) return false;
|
||||
const { state } = view;
|
||||
const $from = state.doc.resolve(from);
|
||||
if ($from.parent.type.spec.code) return false;
|
||||
|
||||
const leafText = "\ufffc";
|
||||
const textBefore =
|
||||
$from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset, undefined, leafText) +
|
||||
text;
|
||||
const textAfter =
|
||||
text +
|
||||
$from.parent.textBetween(
|
||||
$from.parentOffset,
|
||||
Math.min($from.parent.nodeSize - 2, $from.parentOffset + MAX_MATCH),
|
||||
undefined,
|
||||
leafText
|
||||
);
|
||||
const matchB = markBefore.match.exec(textBefore);
|
||||
const matchA = markAfter.match.exec(textAfter);
|
||||
if (matchB) {
|
||||
const handled = markBefore.handler(
|
||||
markType,
|
||||
view,
|
||||
text,
|
||||
matchB,
|
||||
from - matchB[0].length + text.length,
|
||||
to,
|
||||
plugins
|
||||
);
|
||||
if (handled) return handled;
|
||||
}
|
||||
if (matchA)
|
||||
return markAfter.handler(markType, view, text, matchA, from, to + matchA[0].length - text.length, plugins);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createInputRule(cursorPlugin: Plugin, opts?: Options) {
|
||||
const plugin: Plugin<InputRuleState> = new Plugin({
|
||||
isInputRules: true,
|
||||
state: {
|
||||
init: () => null,
|
||||
apply(tr, prev) {
|
||||
const meta = tr.getMeta(plugin);
|
||||
if (meta) return meta;
|
||||
return tr.selectionSet || tr.docChanged ? null : prev;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleTextInput(view, from, to, text) {
|
||||
const markType = getMarkType(view, opts);
|
||||
return run(markType, view, from, to, text, { input: plugin, cursor: cursorPlugin });
|
||||
},
|
||||
},
|
||||
} as PluginSpec<InputRuleState>);
|
||||
return plugin;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MarkType } from "prosemirror-model";
|
||||
|
||||
export type Options = {
|
||||
markType?: MarkType;
|
||||
};
|
||||
|
||||
export type CodemarkState = {
|
||||
active?: boolean;
|
||||
side?: -1 | 0;
|
||||
next?: true; // Move outside of code after next transaction
|
||||
click?: true; // When the editor is clicked on
|
||||
} | null;
|
||||
|
||||
export type CursorMetaTr = { action: "next" } | { action: "click" };
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { MarkType, Node } from "@tiptap/pm/model";
|
||||
import { type EditorState, PluginKey } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
import type { Options } from "./types";
|
||||
|
||||
export const MAX_MATCH = 100;
|
||||
export const codeMarkPluginKey = new PluginKey("codemark");
|
||||
|
||||
export function getMarkType(view: EditorView | EditorState, opts?: Options): MarkType {
|
||||
if ("schema" in view) return opts?.markType ?? view.schema.marks.code;
|
||||
return opts?.markType ?? view.state.schema.marks.code;
|
||||
}
|
||||
|
||||
export function safeResolve(doc: Node, pos: number) {
|
||||
return doc.resolve(Math.min(Math.max(1, pos), doc.nodeSize - 2));
|
||||
}
|
||||
@@ -19,8 +19,6 @@ import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
@@ -43,16 +41,6 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-paragraph-block",
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-heading-block",
|
||||
},
|
||||
},
|
||||
dropcursor: false,
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
@@ -101,7 +89,6 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtensionConfig,
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensionsWithoutProps,
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import codemark from "prosemirror-codemark";
|
||||
import { codemark } from "@/extensions/code-mark";
|
||||
|
||||
export const CustomCodeMarkPlugin = Extension.create({
|
||||
name: "codemarkPlugin",
|
||||
|
||||
@@ -118,6 +118,7 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
|
||||
height: `${Math.round(initialHeight)}px` satisfies Pixel,
|
||||
aspectRatio: aspectRatioCalculated,
|
||||
};
|
||||
|
||||
setSize(initialComputedSize);
|
||||
updateAttributesSafely(
|
||||
initialComputedSize,
|
||||
|
||||
@@ -29,9 +29,12 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
|
||||
useEffect(() => {
|
||||
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
|
||||
if (closestEditorContainer) {
|
||||
setEditorContainer(closestEditorContainer as HTMLDivElement);
|
||||
if (!closestEditorContainer) {
|
||||
console.error("Editor container not found");
|
||||
return;
|
||||
}
|
||||
|
||||
setEditorContainer(closestEditorContainer as HTMLDivElement);
|
||||
}, []);
|
||||
|
||||
// the image is already uploaded if the image-component node has src attribute
|
||||
@@ -52,7 +55,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
setResolvedSrc(url as string);
|
||||
};
|
||||
getImageSource();
|
||||
}, [imgNodeSrc]);
|
||||
}, [imageFromFileSystem, node.attrs.src]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
|
||||
@@ -127,30 +127,24 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
return "Uploading...";
|
||||
}
|
||||
|
||||
if (draggedInside && editor.isEditable) {
|
||||
if (draggedInside) {
|
||||
return "Drop image here";
|
||||
}
|
||||
|
||||
if (!editor.isEditable) {
|
||||
return "Viewing Mode: Image Upload Disabled";
|
||||
} else {
|
||||
return "Add an image";
|
||||
}
|
||||
return "Add an image";
|
||||
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 bg-custom-background-90 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
|
||||
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
|
||||
{
|
||||
"hover:text-custom-text-200 hover:bg-custom-background-80 cursor-pointer": editor.isEditable,
|
||||
"bg-custom-background-80 text-custom-text-200": draggedInside && editor.isEditable,
|
||||
"text-custom-primary-200 bg-custom-primary-100/10 border-custom-primary-200/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200":
|
||||
selected && editor.isEditable,
|
||||
"text-red-500 cursor-default": failedToLoadImage,
|
||||
"hover:text-red-500": failedToLoadImage && editor.isEditable,
|
||||
"bg-red-500/10": failedToLoadImage && selected,
|
||||
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable,
|
||||
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
|
||||
"bg-custom-background-80 text-custom-text-200": draggedInside,
|
||||
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
|
||||
selected,
|
||||
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
|
||||
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
|
||||
}
|
||||
)}
|
||||
onDrop={onDrop}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
import { CustomImageNode, ImageAttributes } from "@/extensions/custom-image";
|
||||
// plugins
|
||||
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
|
||||
// types
|
||||
@@ -124,9 +126,14 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
serialize(state: MarkdownSerializerState, node: Node) {
|
||||
const attrs = node.attrs as ImageAttributes;
|
||||
const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src);
|
||||
const imageWidth = state.esc(attrs.width?.toString());
|
||||
state.write(`<img src="${state.esc(imageSource)}" width="${imageWidth}" />`);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { CustomImageNode, ImageAttributes, UploadImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
@@ -52,9 +54,14 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
serialize(state: MarkdownSerializerState, node: Node) {
|
||||
const attrs = node.attrs as ImageAttributes;
|
||||
const imageSource = state.esc(this?.editor?.commands?.getImageSource?.(attrs.src) || attrs.src);
|
||||
const imageWidth = state.esc(attrs.width?.toString());
|
||||
state.write(`<img src="${state.esc(imageSource)}" width="${imageWidth}" />`);
|
||||
state.closeBlock(node);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2,67 +2,58 @@ import { Extension, Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
|
||||
export const DropHandlerExtension = Extension.create({
|
||||
name: "dropHandler",
|
||||
priority: 1000,
|
||||
export const DropHandlerExtension = () =>
|
||||
Extension.create({
|
||||
name: "dropHandler",
|
||||
priority: 1000,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view: EditorView, event: ClipboardEvent) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
event.clipboardData &&
|
||||
event.clipboardData.files &&
|
||||
event.clipboardData.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view: EditorView, event: ClipboardEvent) => {
|
||||
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const pos = view.state.selection.from;
|
||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
!moved &&
|
||||
event.dataTransfer &&
|
||||
event.dataTransfer.files &&
|
||||
event.dataTransfer.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (coordinates) {
|
||||
const pos = coordinates.pos;
|
||||
if (imageFiles.length > 0) {
|
||||
const pos = view.state.selection.from;
|
||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
|
||||
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const imageFiles = files.filter((file) => file.type.startsWith("image"));
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (coordinates) {
|
||||
const pos = coordinates.pos;
|
||||
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const insertImagesSafely = async ({
|
||||
editor,
|
||||
files,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
@@ -25,6 +24,7 @@ import {
|
||||
DropHandlerExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
SmoothCursorExtension,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
@@ -33,13 +33,11 @@ import {
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
|
||||
|
||||
type TArguments = {
|
||||
disabledExtensions: TExtensions[];
|
||||
enableHistory: boolean;
|
||||
has_enabled_smooth_cursor: boolean;
|
||||
fileHandler: TFileHandler;
|
||||
mentionConfig: {
|
||||
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
|
||||
@@ -47,11 +45,10 @@ type TArguments = {
|
||||
};
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
|
||||
export const CoreEditorExtensions = (args: TArguments) => {
|
||||
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, has_enabled_smooth_cursor } = args;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
@@ -74,23 +71,13 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-paragraph-block",
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-heading-block",
|
||||
},
|
||||
},
|
||||
dropcursor: {
|
||||
class: "text-custom-text-300",
|
||||
},
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension,
|
||||
DropHandlerExtension(),
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: "py-4 border-custom-border-400",
|
||||
@@ -146,9 +133,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
TableCell,
|
||||
TableRow,
|
||||
CustomMention({
|
||||
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
|
||||
mentionSuggestions: mentionConfig.mentionSuggestions,
|
||||
mentionHighlights: mentionConfig.mentionHighlights,
|
||||
readonly: !editable,
|
||||
readonly: false,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ editor, node }) => {
|
||||
@@ -177,8 +164,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtension,
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
has_enabled_smooth_cursor ? SmoothCursorExtension : null,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -23,3 +23,5 @@ export * from "./quote";
|
||||
export * from "./read-only-extensions";
|
||||
export * from "./side-menu";
|
||||
export * from "./text-align";
|
||||
export * from "./smooth-cursor";
|
||||
export * from "./code-mark";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import CharacterCount from "@tiptap/extension-character-count";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
@@ -29,20 +28,17 @@ import {
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { IMentionHighlight, TExtensions, TFileHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreReadOnlyEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
import { IMentionHighlight, TFileHandler } from "@/types";
|
||||
|
||||
type Props = {
|
||||
disabledExtensions: TExtensions[];
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
mentionConfig: {
|
||||
mentionHighlights?: () => Promise<IMentionHighlight[]>;
|
||||
};
|
||||
};
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
const { disabledExtensions, fileHandler, mentionConfig } = props;
|
||||
export const CoreReadOnlyEditorExtensions = (props: Props) => {
|
||||
const { fileHandler, mentionConfig } = props;
|
||||
|
||||
return [
|
||||
StarterKit.configure({
|
||||
@@ -65,16 +61,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
blockquote: false,
|
||||
paragraph: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-paragraph-block",
|
||||
},
|
||||
},
|
||||
heading: {
|
||||
HTMLAttributes: {
|
||||
class: "editor-heading-block",
|
||||
},
|
||||
},
|
||||
dropcursor: false,
|
||||
gapcursor: false,
|
||||
}),
|
||||
@@ -142,8 +128,5 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
HeadingListExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
...CoreReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -39,27 +39,17 @@ import {
|
||||
setText,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||
// plane editor extensions
|
||||
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
|
||||
// local types
|
||||
import { TSlashCommandAdditionalOption } from "./root";
|
||||
import { CommandProps, ISlashCommandItem } from "@/types";
|
||||
|
||||
export type TSlashCommandSection = {
|
||||
key: TSlashCommandSectionKeys;
|
||||
key: string;
|
||||
title?: string;
|
||||
items: ISlashCommandItem[];
|
||||
};
|
||||
|
||||
type TArgs = {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const getSlashCommandFilteredSections =
|
||||
(args: TArgs) =>
|
||||
(additionalOptions?: ISlashCommandItem[]) =>
|
||||
({ query }: { query: string }): TSlashCommandSection[] => {
|
||||
const { additionalOptions, disabledExtensions } = args;
|
||||
const SLASH_COMMAND_SECTIONS: TSlashCommandSection[] = [
|
||||
{
|
||||
key: "general",
|
||||
@@ -211,7 +201,7 @@ export const getSlashCommandFilteredSections =
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "text-colors",
|
||||
key: "text-color",
|
||||
title: "Colors",
|
||||
items: [
|
||||
{
|
||||
@@ -252,7 +242,7 @@ export const getSlashCommandFilteredSections =
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "background-colors",
|
||||
key: "background-color",
|
||||
title: "Background colors",
|
||||
items: [
|
||||
{
|
||||
@@ -289,19 +279,8 @@ export const getSlashCommandFilteredSections =
|
||||
},
|
||||
];
|
||||
|
||||
[
|
||||
...(additionalOptions ?? []),
|
||||
...coreEditorAdditionalSlashCommandOptions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
]?.forEach((item) => {
|
||||
const sectionToPushTo = SLASH_COMMAND_SECTIONS.find((s) => s.key === item.section) ?? SLASH_COMMAND_SECTIONS[0];
|
||||
const itemIndexToPushAfter = sectionToPushTo.items.findIndex((i) => i.commandKey === item.pushAfter);
|
||||
if (itemIndexToPushAfter !== -1) {
|
||||
sectionToPushTo.items.splice(itemIndexToPushAfter + 1, 0, item);
|
||||
} else {
|
||||
sectionToPushTo.items.push(item);
|
||||
}
|
||||
additionalOptions?.map((item) => {
|
||||
SLASH_COMMAND_SECTIONS?.[0]?.items.push(item);
|
||||
});
|
||||
|
||||
const filteredSlashSections = SLASH_COMMAND_SECTIONS.map((section) => ({
|
||||
|
||||
@@ -41,7 +41,7 @@ export const SlashCommandsMenu = (props: SlashCommandsMenuProps) => {
|
||||
if (nextItem < 0) {
|
||||
nextSection = currentSection - 1;
|
||||
if (nextSection < 0) nextSection = sections.length - 1;
|
||||
nextItem = sections[nextSection]?.items.length - 1;
|
||||
nextItem = sections[nextSection].items.length - 1;
|
||||
}
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
// types
|
||||
import { ISlashCommandItem, TEditorCommands, TExtensions, TSlashCommandSectionKeys } from "@/types";
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
// components
|
||||
import { getSlashCommandFilteredSections } from "./command-items-list";
|
||||
import { SlashCommandsMenu, SlashCommandsMenuProps } from "./command-menu";
|
||||
@@ -12,11 +12,6 @@ export type SlashCommandOptions = {
|
||||
suggestion: Omit<SuggestionOptions, "editor">;
|
||||
};
|
||||
|
||||
export type TSlashCommandAdditionalOption = ISlashCommandItem & {
|
||||
section: TSlashCommandSectionKeys;
|
||||
pushAfter: TEditorCommands;
|
||||
};
|
||||
|
||||
const Command = Extension.create<SlashCommandOptions>({
|
||||
name: "slash-command",
|
||||
addOptions() {
|
||||
@@ -107,15 +102,10 @@ const renderItems = () => {
|
||||
};
|
||||
};
|
||||
|
||||
type TExtensionProps = {
|
||||
additionalOptions?: TSlashCommandAdditionalOption[];
|
||||
disabledExtensions: TExtensions[];
|
||||
};
|
||||
|
||||
export const SlashCommands = (props: TExtensionProps) =>
|
||||
export const SlashCommands = (additionalOptions?: ISlashCommandItem[]) =>
|
||||
Command.configure({
|
||||
suggestion: {
|
||||
items: getSlashCommandFilteredSections(props),
|
||||
items: getSlashCommandFilteredSections(additionalOptions),
|
||||
render: renderItems,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { smoothCursorPlugin } from "./plugin";
|
||||
|
||||
export const SmoothCursorExtension = Extension.create({
|
||||
name: "smoothCursorExtension",
|
||||
addProseMirrorPlugins() {
|
||||
return [smoothCursorPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { type Selection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
import { type EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { codeMarkPluginKey } from "@/extensions/code-mark/utils";
|
||||
|
||||
export const PROSEMIRROR_SMOOTH_CURSOR_CLASS = "prosemirror-smooth-cursor";
|
||||
const BLINK_DELAY = 750;
|
||||
|
||||
export function smoothCursorPlugin(): Plugin {
|
||||
let smoothCursor: HTMLElement | null = typeof document === "undefined" ? null : document.createElement("div");
|
||||
let rafId: number | undefined;
|
||||
let blinkTimeoutId: number | undefined;
|
||||
let isEditorFocused = false;
|
||||
let lastCursorPosition = { x: 0, y: 0 };
|
||||
|
||||
function isCodemarkCursorActive(view: EditorView) {
|
||||
const codemarkState = codeMarkPluginKey.getState(view.state);
|
||||
return codemarkState?.active === true;
|
||||
}
|
||||
|
||||
function updateCursor(view?: EditorView, cursor?: HTMLElement) {
|
||||
if (!view || !view.dom || view.isDestroyed || !cursor) return;
|
||||
|
||||
if (!isEditorFocused || isCodemarkCursorActive(view)) {
|
||||
cursor.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
cursor.style.display = "block";
|
||||
|
||||
const { state, dom } = view;
|
||||
const { selection } = state;
|
||||
if (!isTextSelection(selection)) return;
|
||||
|
||||
const cursorRect = getCursorRect(view, selection.$head === selection.$from);
|
||||
|
||||
if (!cursorRect) return cursor;
|
||||
|
||||
const editorRect = dom.getBoundingClientRect();
|
||||
|
||||
const className = PROSEMIRROR_SMOOTH_CURSOR_CLASS;
|
||||
|
||||
// Calculate the exact position
|
||||
const x = cursorRect.left - editorRect.left;
|
||||
const y = cursorRect.top - editorRect.top;
|
||||
|
||||
// Check if cursor position has changed
|
||||
if (x !== lastCursorPosition.x || y !== lastCursorPosition.y) {
|
||||
lastCursorPosition = { x, y };
|
||||
cursor.classList.remove(`${className}--blinking`);
|
||||
|
||||
// Clear existing timeout
|
||||
if (blinkTimeoutId) {
|
||||
window.clearTimeout(blinkTimeoutId);
|
||||
}
|
||||
|
||||
// Set new timeout for blinking
|
||||
blinkTimeoutId = window.setTimeout(() => {
|
||||
if (cursor && isEditorFocused) {
|
||||
cursor.classList.add(`${className}--blinking`);
|
||||
}
|
||||
}, BLINK_DELAY);
|
||||
}
|
||||
|
||||
cursor.className = className;
|
||||
cursor.style.height = `${cursorRect.bottom - cursorRect.top}px`;
|
||||
|
||||
rafId = requestAnimationFrame(() => {
|
||||
cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
||||
});
|
||||
}
|
||||
|
||||
return new Plugin({
|
||||
key,
|
||||
view: (view) => {
|
||||
const doc = view.dom.ownerDocument;
|
||||
smoothCursor = smoothCursor || document.createElement("div");
|
||||
const cursor = smoothCursor;
|
||||
|
||||
const update = () => {
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
updateCursor(view, cursor);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
isEditorFocused = true;
|
||||
update();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isEditorFocused = false;
|
||||
if (blinkTimeoutId) {
|
||||
window.clearTimeout(blinkTimeoutId);
|
||||
}
|
||||
cursor.classList.remove(`${PROSEMIRROR_SMOOTH_CURSOR_CLASS}--blinking`);
|
||||
update();
|
||||
};
|
||||
|
||||
let observer: ResizeObserver | undefined;
|
||||
if (window.ResizeObserver) {
|
||||
observer = new window.ResizeObserver(update);
|
||||
observer?.observe(view.dom);
|
||||
}
|
||||
|
||||
doc.addEventListener("selectionchange", update);
|
||||
view.dom.addEventListener("focus", handleFocus);
|
||||
view.dom.addEventListener("blur", handleBlur);
|
||||
|
||||
return {
|
||||
update,
|
||||
destroy: () => {
|
||||
doc.removeEventListener("selectionchange", update);
|
||||
view.dom.removeEventListener("focus", handleFocus);
|
||||
view.dom.removeEventListener("blur", handleBlur);
|
||||
observer?.unobserve(view.dom);
|
||||
if (rafId !== undefined) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (blinkTimeoutId) {
|
||||
window.clearTimeout(blinkTimeoutId);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
decorations: (state) => {
|
||||
if (!smoothCursor || !isTextSelection(state.selection) || !state.selection.empty) return;
|
||||
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.widget(0, smoothCursor, {
|
||||
key: PROSEMIRROR_SMOOTH_CURSOR_CLASS,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
attributes: () => ({
|
||||
class: isEditorFocused ? "smooth-cursor-enabled" : "",
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const key = new PluginKey(PROSEMIRROR_SMOOTH_CURSOR_CLASS);
|
||||
|
||||
function getCursorRect(
|
||||
view: EditorView,
|
||||
toStart: boolean
|
||||
): { left: number; right: number; top: number; bottom: number } | null {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || !selection.rangeCount) return null;
|
||||
|
||||
const range = selection?.getRangeAt(0)?.cloneRange();
|
||||
if (!range) return null;
|
||||
|
||||
range.collapse(toStart);
|
||||
const rects = range.getClientRects();
|
||||
const rect = rects?.length ? rects[rects.length - 1] : null;
|
||||
if (rect?.height) return rect;
|
||||
|
||||
return view.coordsAtPos(view.state.selection.head);
|
||||
}
|
||||
|
||||
function isTextSelection(selection: Selection): selection is TextSelection {
|
||||
return selection && typeof selection === "object" && "$cursor" in selection;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { DocumentCollaborativeEvents } from "@/constants/document-collaborative-events";
|
||||
import { TDocumentEventKey, TDocumentEventsClient, TDocumentEventsServer } from "@/types/document-collaborative-events";
|
||||
|
||||
export const getServerEventName = (clientEvent: TDocumentEventsClient): TDocumentEventsServer | undefined => {
|
||||
for (const key in DocumentCollaborativeEvents) {
|
||||
if (DocumentCollaborativeEvents[key as TDocumentEventKey].client === clientEvent) {
|
||||
return DocumentCollaborativeEvents[key as TDocumentEventKey].server;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
@@ -14,8 +14,8 @@ import { TCollaborativeEditorProps } from "@/types";
|
||||
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
const {
|
||||
onTransaction,
|
||||
has_enabled_smooth_cursor,
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
@@ -34,7 +34,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
// states
|
||||
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
|
||||
const [hasServerSynced, setHasServerSynced] = useState(false);
|
||||
const [hasIndexedDbSynced, setHasIndexedDbSynced] = useState(false);
|
||||
// initialize Hocuspocus provider
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
@@ -55,36 +54,30 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
setHasServerConnectionFailed(true);
|
||||
}
|
||||
},
|
||||
onSynced: () => {
|
||||
serverHandler?.onServerSync?.();
|
||||
setHasServerSynced(true);
|
||||
},
|
||||
onSynced: () => setHasServerSynced(true),
|
||||
}),
|
||||
[id, realtimeConfig, serverHandler, user]
|
||||
);
|
||||
|
||||
const localProvider = useMemo(
|
||||
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
|
||||
[id, provider]
|
||||
);
|
||||
|
||||
localProvider?.on("synced", () => {
|
||||
setHasIndexedDbSynced(true);
|
||||
});
|
||||
|
||||
// destroy and disconnect all providers connection on unmount
|
||||
// destroy and disconnect connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider?.destroy();
|
||||
localProvider?.destroy();
|
||||
provider.destroy();
|
||||
provider.disconnect();
|
||||
},
|
||||
[provider, localProvider]
|
||||
[provider]
|
||||
);
|
||||
// indexed db integration for offline support
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useEditor({
|
||||
disabledExtensions,
|
||||
id,
|
||||
editable,
|
||||
onTransaction,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
enableHistory: false,
|
||||
@@ -106,10 +99,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
}),
|
||||
],
|
||||
fileHandler,
|
||||
forwardedRef,
|
||||
handleEditorReady,
|
||||
has_enabled_smooth_cursor,
|
||||
forwardedRef,
|
||||
mentionHandler,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
@@ -119,7 +112,5 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
|
||||
editor,
|
||||
hasServerConnectionFailed,
|
||||
hasServerSynced,
|
||||
hasIndexedDbSynced,
|
||||
localProvider,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -16,26 +16,17 @@ import { IMarking, scrollSummary, scrollToNodeViaDOMCoordinates } from "@/helper
|
||||
// props
|
||||
import { CoreEditorProps } from "@/props";
|
||||
// types
|
||||
import type {
|
||||
TDocumentEventsServer,
|
||||
EditorRefApi,
|
||||
IMentionHighlight,
|
||||
IMentionSuggestion,
|
||||
TEditorCommands,
|
||||
TFileHandler,
|
||||
TExtensions,
|
||||
} from "@/types";
|
||||
import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TEditorCommands, TFileHandler } from "@/types";
|
||||
|
||||
export interface CustomEditorProps {
|
||||
editable: boolean;
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
enableHistory: boolean;
|
||||
disabledExtensions: TExtensions[];
|
||||
extensions?: any;
|
||||
fileHandler: TFileHandler;
|
||||
forwardedRef?: MutableRefObject<EditorRefApi | null>;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
has_enabled_smooth_cursor?: boolean;
|
||||
id?: string;
|
||||
initialValue?: string;
|
||||
mentionHandler: {
|
||||
@@ -55,8 +46,6 @@ export interface CustomEditorProps {
|
||||
|
||||
export const useEditor = (props: CustomEditorProps) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
enableHistory,
|
||||
@@ -70,52 +59,49 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
onChange,
|
||||
onTransaction,
|
||||
placeholder,
|
||||
provider,
|
||||
tabIndex,
|
||||
value,
|
||||
provider,
|
||||
autofocus = false,
|
||||
has_enabled_smooth_cursor = true,
|
||||
} = props;
|
||||
// states
|
||||
|
||||
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
|
||||
// refs
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
const savedSelectionRef = useRef(savedSelection);
|
||||
const editor = useTiptapEditor(
|
||||
{
|
||||
editable,
|
||||
autofocus,
|
||||
editorProps: {
|
||||
...CoreEditorProps({
|
||||
editorClassName,
|
||||
}),
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [
|
||||
...CoreEditorExtensions({
|
||||
editable,
|
||||
disabledExtensions,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: () => handleEditorReady?.(true),
|
||||
onTransaction: ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
onTransaction?.();
|
||||
},
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
onDestroy: () => handleEditorReady?.(false),
|
||||
const editor = useTiptapEditor({
|
||||
autofocus,
|
||||
editorProps: {
|
||||
...CoreEditorProps({
|
||||
editorClassName,
|
||||
}),
|
||||
...editorProps,
|
||||
},
|
||||
[editable]
|
||||
);
|
||||
extensions: [
|
||||
...CoreEditorExtensions({
|
||||
has_enabled_smooth_cursor,
|
||||
enableHistory,
|
||||
fileHandler,
|
||||
mentionConfig: {
|
||||
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
placeholder,
|
||||
tabIndex,
|
||||
}),
|
||||
...extensions,
|
||||
],
|
||||
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
|
||||
onCreate: () => handleEditorReady?.(true),
|
||||
onTransaction: ({ editor }) => {
|
||||
setSavedSelection(editor.state.selection);
|
||||
onTransaction?.();
|
||||
},
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
|
||||
onDestroy: () => handleEditorReady?.(false),
|
||||
});
|
||||
|
||||
// Update the ref whenever savedSelection changes
|
||||
useEffect(() => {
|
||||
@@ -264,7 +250,7 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (empty) return null;
|
||||
|
||||
const nodesArray: string[] = [];
|
||||
state.doc.nodesBetween(from, to, (node, _pos, parent) => {
|
||||
state.doc.nodesBetween(from, to, (node, pos, parent) => {
|
||||
if (parent === state.doc && editorRef.current) {
|
||||
const serializer = DOMSerializer.fromSchema(editorRef.current?.schema);
|
||||
const dom = serializer.serializeNode(node);
|
||||
@@ -305,8 +291,6 @@ export const useEditor = (props: CustomEditorProps) => {
|
||||
if (!document) return;
|
||||
Y.applyUpdate(document, value);
|
||||
},
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
}),
|
||||
[editorRef, savedSelection]
|
||||
);
|
||||
|
||||
@@ -105,7 +105,7 @@ export const useDropZone = (args: TDropzoneArgs) => {
|
||||
async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setDraggedInside(false);
|
||||
if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
|
||||
if (e.dataTransfer.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
const filesList = e.dataTransfer.files;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import Collaboration from "@tiptap/extension-collaboration";
|
||||
import { IndexeddbPersistence } from "y-indexeddb";
|
||||
@@ -11,7 +11,6 @@ import { TReadOnlyCollaborativeEditorProps } from "@/types";
|
||||
|
||||
export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEditorProps) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
editorClassName,
|
||||
editorProps = {},
|
||||
extensions,
|
||||
@@ -31,8 +30,8 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
||||
const provider = useMemo(
|
||||
() =>
|
||||
new HocuspocusProvider({
|
||||
name: id,
|
||||
url: realtimeConfig.url,
|
||||
name: id,
|
||||
token: JSON.stringify(user),
|
||||
parameters: realtimeConfig.queryParams,
|
||||
onAuthenticationFailed: () => {
|
||||
@@ -48,26 +47,25 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
|
||||
},
|
||||
onSynced: () => setHasServerSynced(true),
|
||||
}),
|
||||
[id, realtimeConfig, serverHandler, user]
|
||||
[id, realtimeConfig, user]
|
||||
);
|
||||
|
||||
// indexed db integration for offline support
|
||||
const localProvider = useMemo(
|
||||
() => (id ? new IndexeddbPersistence(id, provider.document) : undefined),
|
||||
[id, provider]
|
||||
);
|
||||
|
||||
// destroy and disconnect connection on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
provider.destroy();
|
||||
localProvider?.destroy();
|
||||
provider.disconnect();
|
||||
},
|
||||
[provider, localProvider]
|
||||
[provider]
|
||||
);
|
||||
// indexed db integration for offline support
|
||||
useLayoutEffect(() => {
|
||||
const localProvider = new IndexeddbPersistence(id, provider.document);
|
||||
return () => {
|
||||
localProvider?.destroy();
|
||||
};
|
||||
}, [provider, id]);
|
||||
|
||||
const editor = useReadOnlyEditor({
|
||||
disabledExtensions,
|
||||
editorProps,
|
||||
editorClassName,
|
||||
extensions: [
|
||||
|
||||
@@ -11,21 +11,14 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
|
||||
// props
|
||||
import { CoreReadOnlyEditorProps } from "@/props";
|
||||
// types
|
||||
import type {
|
||||
EditorReadOnlyRefApi,
|
||||
IMentionHighlight,
|
||||
TExtensions,
|
||||
TDocumentEventsServer,
|
||||
TFileHandler,
|
||||
} from "@/types";
|
||||
import { EditorReadOnlyRefApi, IMentionHighlight, TFileHandler } from "@/types";
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
disabledExtensions: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: any;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
initialValue?: string;
|
||||
editorClassName: string;
|
||||
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
fileHandler: Pick<TFileHandler, "getAssetSrc">;
|
||||
handleEditorReady?: (value: boolean) => void;
|
||||
mentionHandler: {
|
||||
@@ -36,7 +29,6 @@ interface CustomReadOnlyEditorProps {
|
||||
|
||||
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
const {
|
||||
disabledExtensions,
|
||||
initialValue,
|
||||
editorClassName,
|
||||
forwardedRef,
|
||||
@@ -62,7 +54,6 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
},
|
||||
extensions: [
|
||||
...CoreReadOnlyEditorExtensions({
|
||||
disabledExtensions,
|
||||
mentionConfig: {
|
||||
mentionHighlights: mentionHandler.highlights,
|
||||
},
|
||||
@@ -126,8 +117,6 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
|
||||
editorRef.current?.off("update");
|
||||
};
|
||||
},
|
||||
emitRealTimeUpdate: (message: TDocumentEventsServer) => provider?.sendStateless(message),
|
||||
listenToRealTimeUpdate: () => provider && { on: provider.on.bind(provider), off: provider.off.bind(provider) },
|
||||
getHeadings: () => editorRef?.current?.storage.headingList.headings,
|
||||
}));
|
||||
|
||||
|
||||
@@ -17,12 +17,10 @@ import {
|
||||
export type TServerHandler = {
|
||||
onConnect?: () => void;
|
||||
onServerError?: () => void;
|
||||
onServerSync?: () => void;
|
||||
};
|
||||
|
||||
type TCollaborativeEditorHookProps = {
|
||||
disabledExtensions: TExtensions[];
|
||||
editable?: boolean;
|
||||
disabledExtensions?: TExtensions[];
|
||||
editorClassName: string;
|
||||
editorProps?: EditorProps;
|
||||
extensions?: Extensions;
|
||||
@@ -38,6 +36,7 @@ type TCollaborativeEditorHookProps = {
|
||||
};
|
||||
|
||||
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
|
||||
has_enabled_smooth_cursor: boolean;
|
||||
onTransaction?: () => void;
|
||||
embedHandler?: TEmbedConfig;
|
||||
fileHandler: TFileHandler;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user