Compare commits

...
59 Commits
Author SHA1 Message Date
Palanikannan M a5752adb42 chore: remove dev editor 2024-12-03 15:04:31 +05:30
Palanikannan M 7231b943be Merge branch 'preview' into feat/flat-list 2024-12-02 20:58:04 +05:30
Anmol Singh BhatiaandGitHub fe5999ceff fix: intake issue permission (#6136) 2024-12-02 19:49:09 +05:30
rahulrameshaandGitHub da0071256f fix half block dragging (#6135) 2024-12-02 19:30:58 +05:30
M. PalanikannanandGitHub 3c6006d04a [PE-31] feat: Add lock unlock archive restore realtime sync (#5629)
* fix: add lock unlock archive restore realtime sync

* fix: show only after editor loads

* fix: added strong types

* fix: live events fixed

* fix: remove unused vars and logs

* fix: converted objects to enum

* fix: error handling and removing the events in read only mode

* fix: added check to only update if the image aspect ratio is not present already

* fix: imports

* fix: props order

* revert: no need of these changes anymore

* fix: updated type names

* fix: order of things

* fix: fixed types and renamed variables

* fix: better typing for the real time updates

* fix: trying multiplexing our socket connection

* fix: multiplexing socket connection in read only editor as well

* fix: remove single socket logic

* fix: fixing the cleanup deps for the provider and localprovider

* fix: add a better data structure for managing events

* chore: refactored realtime events into hooks

* feat: fetch page meta while focusing tabs

* fix: cycling through items on slash command item in down arrow

* fix: better naming convention for realtime events

* fix: simplified localprovider initialization and cleaning

* fix: types from ui

* fix: abstracted away from exposing the provider directly

* fix: coderabbit suggestions

* regression: pass user in dependency array

* fix: removed page action api calls by the other users the document is synced with

* chore: removed unused imports
2024-12-02 14:26:36 +05:30
Aaryan KhandelwalandGitHub 8c04aa6f51 dev: revamp pages authorization (#6094) 2024-12-02 13:59:01 +05:30
Aaryan KhandelwalandGitHub 9f14167ef5 refactor: editor code splitting (#6102)
* fix: merge conflicts resolved from preview

* fix: space app build errors

* fix: product updates modal

* fix: build errors

* fix: lite text read only editor

* refactor: additional options push logic
2024-12-02 13:51:27 +05:30
Aaryan KhandelwalandGitHub 11bfbe560a fix: checked colored todo list item (#6113) 2024-12-02 13:47:50 +05:30
Aaryan KhandelwalandGitHub fc52936024 fix: escape markdown content for images (#6096) 2024-12-02 13:36:12 +05:30
Vamsi KrishnaandGitHub 5150c661ab reduced the components moved (#6110) 2024-12-02 13:35:40 +05:30
Vamsi KrishnaandGitHub 63bc01f385 [WEB-2774]fix:reordering favorites and favorite folders (#6119)
* fixed re order for favorites

* fixed lint errors

* added reorder

* fixed reorder inside folder

* fixed lint issues

* memoized reorder

* removed unnecessary comments

* seprated duplicate logic to a common file

* removed code comments

* fixed favorite remove while reorder inside folder

* fixed folder remove while reorder inside folder

* fixed-reorder issue

* added last child to drop handled

* fixed orderby function

* removed unncessasary comments
2024-12-02 13:35:09 +05:30
Anmol Singh BhatiaandGitHub 1953d6fe3a [WEB-2762] chore: loader code refactor (#5992)
* chore: loader code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2024-12-02 13:24:01 +05:30
Anmol Singh BhatiaandGitHub 1b9033993d [WEB-2799] chore: global component and code refactor (#6131)
* chore: local storage helper hook added to package

* chore: tabs global component added

* chore: collapsible button improvement

* chore: linear progress indicator improvement

* chore: fill icon set added to package
2024-12-02 13:22:08 +05:30
sriram veeraghanta 75ada1bfac fix: constants package updates 2024-12-01 21:26:35 +05:30
Prateek ShouryaandGitHub d0f9a4d245 chore: add redirection to plane logo in invitations page (#6125) 2024-11-29 20:20:49 +05:30
sriram veeraghantaandGitHub 05894c5b9c Merge pull request #6121 from makeplane/preview
release: v0.24.0
2024-11-29 19:36:12 +05:30
Prateek ShouryaandGitHub 5926c9e8e9 fix: comment images in profile activity page (#6123) 2024-11-29 19:20:31 +05:30
Prateek ShouryaandGitHub 5aeedd1e5a [WEB-2610] fix: workspace redirection from admin app (#6122) 2024-11-29 19:02:13 +05:30
sriram veeraghanta 7725b200f7 fix: changelog redirection 2024-11-29 18:13:29 +05:30
sriram veeraghanta 2c69538617 fix: hypermode text typo changes 2024-11-29 17:47:46 +05:30
pablohashescobar 41bd98dd63 fix: instance collect 2024-11-29 17:41:06 +05:30
sriram veeraghanta bf1c326b44 Merge branch 'preview' of github.com:makeplane/plane into preview 2024-11-29 17:36:00 +05:30
sriram veeraghanta 3d1485461d fix: lockfile udpated 2024-11-29 17:35:47 +05:30
rahulrameshaandGitHub 4251b114c3 chore: enable no load by default (#5968)
* enable no load by default

* remove help section brackets

* fallback to server with mentions
2024-11-29 14:55:39 +05:30
Prateek ShouryaandGitHub 712339a638 minor improvements for workspace management (#6099)
* minor improvements for workspace management

* typo fix
2024-11-29 14:53:30 +05:30
sriram veeraghanta 1c9162e1f1 chore: turbo version upgrade 2024-11-29 14:40:14 +05:30
sriram veeraghanta f1e6f59716 chore: package version updated 2024-11-29 14:37:53 +05:30
sriram veeraghanta 69f235ed24 fix: merge conflicts 2024-11-29 14:35:43 +05:30
4aa01ffebe [WEB-2795]chore:removed header links for project bread crumb inside project detail and list (#6116)
* removed header links for project bread crumb inside project detail

* Add total issue count while syncing project to telemetry

---------

Co-authored-by: Satish Gandham <satish.iitg@gmail.com>
2024-11-29 11:39:44 +05:30
Bavisetti NarayanandGitHub 41c0ba502c fix: intake toggle (#6111) 2024-11-28 16:58:21 +05:30
Bavisetti NarayanandGitHub 378e896bf0 fix: notification count (#6109) 2024-11-28 12:58:09 +05:30
Prateek ShouryaandGitHub e3799c8a40 fix: add back issue identifier for relation activity. (#6106) 2024-11-28 12:50:56 +05:30
sriram veeraghanta 0d70397639 chore: issue version migrations updates 2024-11-28 12:42:30 +05:30
Palanikannan M 17b53c888f fix: removed input rules from lists 2024-11-28 08:42:18 +05:30
sriram veeraghanta d2758fe5e6 Revert "fix: refactor editor extensions code spliting"
This reverts commit 234513278f.
2024-11-27 18:20:41 +05:30
Palanikannan M f4061c4ad6 Merge branch 'preview' into feat/flat-list 2024-11-27 16:38:55 +05:30
Bavisetti NarayanandGitHub 1420b7e7d3 chore: restrict email notifications for removed users (#6100) 2024-11-27 15:06:55 +05:30
Palanikannan M 3b7003815b Merge branch 'preview' into feat/flat-list 2024-11-26 19:58:04 +05:30
Palanikannan M 2c499cf436 fix: parse html 2024-11-13 20:36:38 +05:30
Palanikannan M 24f46bd098 fix: flat-list 2024-11-13 18:01:07 +05:30
Palanikannan M 5e6e12d5e2 feat: trying out heading lists, nested styles and diving deeper 2024-11-13 09:34:55 +05:30
Palanikannan M 2d9a2ba9c7 Merge branch 'preview' into chore/editor-updates 2024-11-09 15:25:49 +05:30
Palanikannan M c81f02d879 revert: old fetch logic 2024-10-29 13:11:55 +05:30
Palanikannan M 9773a1feed fix: exiting code blocks inside lists 2024-10-28 16:56:57 +05:30
Palanikannan M 88b8e764e3 fix: insert table command for nesting it in lists 2024-10-28 16:56:35 +05:30
Palanikannan M 523b0a3eaf fix: image attr type 2024-10-28 16:56:18 +05:30
Palanikannan M daf14f17c7 fix: table insertion 2024-10-28 16:56:02 +05:30
Palanikannan M 6ce6533de5 fix: styles of ordered lists 2024-10-25 15:39:10 +05:30
Palanikannan M 95a063d564 Merge branch 'preview' into feat/flat-list 2024-10-25 05:41:36 +05:30
Palanikannan M 2a1c08f203 fix: migration script added 2024-10-25 04:58:26 +05:30
Palanikannan M cefc1e99b1 fix: flat lists 2024-10-25 01:49:51 +05:30
Palanikannan M 15668299b4 feat: migration (but with duplication of content and persistence of old lists due to indexed db) 2024-10-22 17:30:49 +05:30
Palanikannan M 594ae81c31 feat: old and new lists coexist 2024-10-22 15:45:06 +05:30
Palanikannan M 8a41b523d6 chore: renamed manual logger and logger middleware across the live server 2024-10-22 15:44:36 +05:30
Palanikannan M fb6cdb7f86 Merge branch 'preview' into feat/flat-list 2024-10-22 13:06:39 +05:30
Palanikannan M ce99563a0d fix: types to insert list 2024-10-22 13:06:37 +05:30
Palanikannan M 1b9b59799e fix: able to render prosemirror flat lists 2024-10-21 20:52:20 +05:30
Palanikannan M a83903280c wip: added flat list 2024-10-21 15:32:34 +05:30
M. PalanikannanandGitHub c68658d877 [PE-56] fix: image aspect ratio (#5794)
* regression: image aspect ratio fix

* fix: name of variables changed for clarity
2024-10-10 20:53:20 +05:30
278 changed files with 11422 additions and 1965 deletions
+2
View File
@@ -85,3 +85,5 @@ deploy/selfhost/plane-app/
## Storybook
*storybook.log
output.css
dev-server
+3 -1
View File
@@ -38,6 +38,8 @@ 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
@@ -124,7 +126,7 @@ export const WorkspaceCreateForm = () => {
<div className="flex flex-col gap-1">
<h4 className="text-sm text-custom-text-300">Set your workspace&apos;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">{WEB_BASE_URL}/</span>
<span className="whitespace-nowrap text-sm text-custom-text-200">{workspaceBaseURL}</span>
<Controller
control={control}
name="slug"
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import Link from "next/link";
import { ExternalLink } from "lucide-react";
// helpers
import { Tooltip } from "@plane/ui";
@@ -20,9 +19,9 @@ export const WorkspaceListItem = observer(({ workspaceId }: TWorkspaceListItemPr
if (!workspace) return null;
return (
<Link
<a
key={workspaceId}
href={encodeURI(WEB_BASE_URL + "/" + workspace.slug)}
href={`${WEB_BASE_URL}/${encodeURIComponent(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"
>
@@ -77,6 +76,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>
</Link>
</a>
);
});
+2 -1
View File
@@ -30,7 +30,8 @@ export class WorkspaceService extends APIService {
* @returns Promise<any>
*/
async workspaceSlugCheck(slug: string): Promise<any> {
return this.get(`/api/instances/workspace-slug-check/?slug=${slug}`)
const params = new URLSearchParams({ slug });
return this.get(`/api/instances/workspace-slug-check/?${params.toString()}`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
+3 -3
View File
@@ -14,7 +14,7 @@ export interface IWorkspaceStore {
// computed
workspaceIds: string[];
// helper actions
hydrate: (data: any) => void;
hydrate: (data: Record<string, IWorkspace>) => 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 - any
* @param data - Record<string, IWorkspace>
*/
hydrate = (data: any) => {
hydrate = (data: Record<string, IWorkspace>) => {
if (data) this.workspaces = data;
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "admin",
"version": "0.23.1",
"version": "0.24.0",
"private": true,
"scripts": {
"dev": "turbo run develop",
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
{
"name": "plane-api",
"version": "0.23.1"
"version": "0.24.0"
}
+1 -3
View File
@@ -258,9 +258,7 @@ class ProjectAPIEndpoint(BaseAPIView):
ProjectSerializer(project).data, cls=DjangoJSONEncoder
)
intake_view = request.data.get(
"inbox_view", request.data.get("intake_view", False)
)
intake_view = request.data.get("inbox_view", project.intake_view)
if project.archived_at:
return Response(
@@ -13,7 +13,6 @@ from .user import (
from .workspace import (
WorkSpaceSerializer,
WorkSpaceMemberSerializer,
TeamSerializer,
WorkSpaceMemberInviteSerializer,
WorkspaceLiteSerializer,
WorkspaceThemeSerializer,
@@ -6,11 +6,8 @@ from .base import BaseSerializer, DynamicBaseSerializer
from .user import UserLiteSerializer, UserAdminLiteSerializer
from plane.db.models import (
User,
Workspace,
WorkspaceMember,
Team,
TeamMember,
WorkspaceMemberInvite,
WorkspaceTheme,
WorkspaceUserProperties,
@@ -97,52 +94,6 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
]
class TeamSerializer(BaseSerializer):
members_detail = UserLiteSerializer(read_only=True, source="members", many=True)
members = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()),
write_only=True,
required=False,
)
class Meta:
model = Team
fields = "__all__"
read_only_fields = [
"workspace",
"created_by",
"updated_by",
"created_at",
"updated_at",
]
def create(self, validated_data, **kwargs):
if "members" in validated_data:
members = validated_data.pop("members")
workspace = self.context["workspace"]
team = Team.objects.create(**validated_data, workspace=workspace)
team_members = [
TeamMember(member=member, team=team, workspace=workspace)
for member in members
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return team
team = Team.objects.create(**validated_data)
return team
def update(self, instance, validated_data):
if "members" in validated_data:
members = validated_data.pop("members")
TeamMember.objects.filter(team=instance).delete()
team_members = [
TeamMember(member=member, team=instance, workspace=instance.workspace)
for member in members
]
TeamMember.objects.bulk_create(team_members, batch_size=10)
return super().update(instance, validated_data)
return super().update(instance, validated_data)
class WorkspaceThemeSerializer(BaseSerializer):
class Meta:
model = WorkspaceTheme
-6
View File
@@ -7,7 +7,6 @@ from plane.app.views import (
ProjectMemberViewSet,
ProjectMemberUserEndpoint,
ProjectJoinEndpoint,
AddTeamToProjectEndpoint,
ProjectUserViewsEndpoint,
ProjectIdentifierEndpoint,
ProjectFavoritesViewSet,
@@ -83,11 +82,6 @@ urlpatterns = [
ProjectMemberViewSet.as_view({"post": "leave"}),
name="project-member",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/team-invite/",
AddTeamToProjectEndpoint.as_view(),
name="projects",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-views/",
ProjectUserViewsEndpoint.as_view(),
+3 -19
View File
@@ -10,7 +10,6 @@ from plane.app.views import (
WorkspaceMemberUserEndpoint,
WorkspaceMemberUserViewsEndpoint,
WorkSpaceAvailabilityCheckEndpoint,
TeamMemberViewSet,
UserLastProjectWithWorkspaceEndpoint,
WorkspaceThemeViewSet,
WorkspaceUserProfileStatsEndpoint,
@@ -69,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(
@@ -100,23 +101,6 @@ urlpatterns = [
WorkSpaceMemberViewSet.as_view({"post": "leave"}),
name="leave-workspace-members",
),
path(
"workspaces/<str:slug>/teams/",
TeamMemberViewSet.as_view({"get": "list", "post": "create"}),
name="workspace-team-members",
),
path(
"workspaces/<str:slug>/teams/<uuid:pk>/",
TeamMemberViewSet.as_view(
{
"put": "update",
"patch": "partial_update",
"delete": "destroy",
"get": "retrieve",
}
),
name="workspace-team-members",
),
path(
"users/last-visited-workspace/",
UserLastProjectWithWorkspaceEndpoint.as_view(),
-5
View File
@@ -16,7 +16,6 @@ from .project.invite import (
from .project.member import (
ProjectMemberViewSet,
AddTeamToProjectEndpoint,
ProjectMemberUserEndpoint,
UserProjectRolesEndpoint,
)
@@ -49,7 +48,6 @@ from .workspace.favorite import (
from .workspace.member import (
WorkSpaceMemberViewSet,
TeamMemberViewSet,
WorkspaceMemberUserEndpoint,
WorkspaceProjectMemberEndpoint,
WorkspaceMemberUserViewsEndpoint,
@@ -88,8 +86,6 @@ from .cycle.base import (
CycleFavoriteViewSet,
TransferCycleIssueEndpoint,
CycleUserPropertiesEndpoint,
CycleViewSet,
TransferCycleIssueEndpoint,
CycleAnalyticsEndpoint,
CycleProgressEndpoint,
)
@@ -206,6 +202,5 @@ from .dashboard.base import DashboardEndpoint, WidgetsEndpoint
from .error_404 import custom_404_view
from .exporter.base import ExportIssuesEndpoint
from .notification.base import MarkAllReadNotificationViewSet
from .user.base import AccountEndpoint, ProfileEndpoint, UserSessionEndpoint
+8 -8
View File
@@ -114,7 +114,7 @@ class PageViewSet(BaseViewSet):
.distinct()
)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
@@ -134,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, ROLE.GUEST])
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def partial_update(self, request, slug, project_id, pk):
try:
page = Page.objects.get(
@@ -234,7 +234,7 @@ class PageViewSet(BaseViewSet):
)
return Response(data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -244,7 +244,7 @@ class PageViewSet(BaseViewSet):
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=pk, workspace__slug=slug, projects__id=project_id
@@ -255,7 +255,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def access(self, request, slug, project_id, pk):
access = request.data.get("access", 0)
page = Page.objects.filter(
@@ -296,7 +296,7 @@ class PageViewSet(BaseViewSet):
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -323,7 +323,7 @@ class PageViewSet(BaseViewSet):
return Response({"archived_at": str(datetime.now())}, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
@@ -348,7 +348,7 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN], creator=True, model=Page)
@allow_permission([ROLE.ADMIN], model=Page, creator=True)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(pk=pk, workspace__slug=slug, projects__id=project_id)
+1 -3
View File
@@ -384,11 +384,9 @@ 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
)
+25 -58
View File
@@ -11,7 +11,6 @@ from plane.app.serializers import (
)
from plane.app.permissions import (
ProjectBasePermission,
ProjectMemberPermission,
ProjectLitePermission,
WorkspaceUserPermission,
@@ -20,8 +19,6 @@ from plane.app.permissions import (
from plane.db.models import (
Project,
ProjectMember,
Workspace,
TeamMember,
IssueUserProperty,
WorkspaceMember,
)
@@ -86,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"
@@ -94,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"
@@ -132,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(
@@ -141,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
@@ -232,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,
)
@@ -272,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,
)
@@ -293,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
):
@@ -309,53 +322,6 @@ class ProjectMemberViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
class AddTeamToProjectEndpoint(BaseAPIView):
permission_classes = [ProjectBasePermission]
def post(self, request, slug, project_id):
team_members = TeamMember.objects.filter(
workspace__slug=slug, team__in=request.data.get("teams", [])
).values_list("member", flat=True)
if len(team_members) == 0:
return Response(
{"error": "No such team exists"}, status=status.HTTP_400_BAD_REQUEST
)
workspace = Workspace.objects.get(slug=slug)
project_members = []
issue_props = []
for member in team_members:
project_members.append(
ProjectMember(
project_id=project_id,
member_id=member,
workspace=workspace,
created_by=request.user,
)
)
issue_props.append(
IssueUserProperty(
project_id=project_id,
user_id=member,
workspace=workspace,
created_by=request.user,
)
)
ProjectMember.objects.bulk_create(
project_members, batch_size=10, ignore_conflicts=True
)
_ = IssueUserProperty.objects.bulk_create(
issue_props, batch_size=10, ignore_conflicts=True
)
serializer = ProjectMemberSerializer(project_members, many=True)
return Response(serializer.data, status=status.HTTP_201_CREATED)
class ProjectMemberUserEndpoint(BaseAPIView):
def get(self, request, slug, project_id):
project_member = ProjectMember.objects.get(
@@ -378,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)
@@ -353,6 +353,7 @@ class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
workspace__slug=slug,
created_at__date=request.data.get("date"),
project__project_projectmember__member=request.user,
project__project_projectmember__is_active=True,
actor_id=user_id,
).select_related("actor", "workspace", "issue", "project")[:10000]
+22 -63
View File
@@ -1,14 +1,18 @@
# Django imports
from django.db.models import CharField, Count, Q, OuterRef, Subquery, IntegerField
from django.db.models import (
Count,
Q,
OuterRef,
Subquery,
IntegerField,
)
from django.db.models.functions import Coalesce
from django.db.models.functions import Cast
# Third party modules
from rest_framework import status
from rest_framework.response import Response
from plane.app.permissions import (
WorkSpaceAdminPermission,
WorkspaceEntityPermission,
allow_permission,
ROLE,
@@ -17,8 +21,6 @@ from plane.app.permissions import (
# Module imports
from plane.app.serializers import (
ProjectMemberRoleSerializer,
TeamSerializer,
UserLiteSerializer,
WorkspaceMemberAdminSerializer,
WorkspaceMemberMeSerializer,
WorkSpaceMemberSerializer,
@@ -27,9 +29,6 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import (
Project,
ProjectMember,
Team,
User,
Workspace,
WorkspaceMember,
DraftIssue,
)
@@ -120,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,
)
@@ -147,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
@@ -161,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"
)
@@ -208,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
@@ -272,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()
@@ -284,53 +293,3 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView):
project_members_dict[str(project_id)].append(project_member)
return Response(project_members_dict, status=status.HTTP_200_OK)
class TeamMemberViewSet(BaseViewSet):
serializer_class = TeamSerializer
model = Team
permission_classes = [WorkSpaceAdminPermission]
search_fields = ["member__display_name", "member__first_name"]
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(workspace__slug=self.kwargs.get("slug"))
.select_related("workspace", "workspace__owner")
.prefetch_related("members")
)
def create(self, request, slug):
members = list(
WorkspaceMember.objects.filter(
workspace__slug=slug,
member__id__in=request.data.get("members", []),
is_active=True,
)
.annotate(member_str_id=Cast("member", output_field=CharField()))
.distinct()
.values_list("member_str_id", flat=True)
)
if len(members) != len(request.data.get("members", [])):
users = list(set(request.data.get("members", [])).difference(members))
users = User.objects.filter(pk__in=users)
serializer = UserLiteSerializer(users, many=True)
return Response(
{
"error": f"{len(users)} of the member(s) are not a part of the workspace",
"members": serializer.data,
},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = Workspace.objects.get(slug=slug)
serializer = TeamSerializer(data=request.data, context={"workspace": workspace})
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+28 -8
View File
@@ -1,6 +1,8 @@
# Python imports
import json
import uuid
from uuid import UUID
# Module imports
from plane.db.models import (
@@ -16,8 +18,9 @@ from plane.db.models import (
IssueComment,
IssueActivity,
UserNotificationPreference,
ProjectMember
ProjectMember,
)
from django.db.models import Subquery
# Third Party imports
from celery import shared_task
@@ -95,7 +98,8 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
).exists()
and not Issue.objects.filter(
project_id=project_id, pk=issue_id, created_by_id=mention_id
).exists() and ProjectMember.objects.filter(
).exists()
and ProjectMember.objects.filter(
project_id=project_id, member_id=mention_id, is_active=True
).exists()
):
@@ -242,14 +246,19 @@ def notifications(
2. From the latest set of mentions, extract the users which are not a subscribers & make them subscribers
"""
# get the list of active project members
project_members = ProjectMember.objects.filter(
project_id=project_id, is_active=True
).values_list("member_id", flat=True)
# Get new mentions from the newer instance
new_mentions = get_new_mentions(
requested_instance=requested_data, current_instance=current_instance
)
new_mentions = list(ProjectMember.objects.filter(
project_id=project_id, member_id__in=new_mentions, is_active=True
).values_list("member_id", flat=True))
new_mentions = [str(member_id) for member_id in new_mentions]
new_mentions = [
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
)
@@ -280,6 +289,11 @@ def notifications(
new_value=issue_comment_new_value,
)
comment_mentions = comment_mentions + new_comment_mentions
comment_mentions = [
mention
for mention in comment_mentions
if UUID(mention) in set(project_members)
]
comment_mention_subscribers = extract_mentions_as_subscribers(
project_id=project_id, issue_id=issue_id, mentions=all_comment_mentions
@@ -293,7 +307,11 @@ def notifications(
# ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- #
issue_subscribers = list(
IssueSubscriber.objects.filter(project_id=project_id, issue_id=issue_id, project__project_projectmember__is_active=True,)
IssueSubscriber.objects.filter(
project_id=project_id,
issue_id=issue_id,
subscriber__in=Subquery(project_members),
)
.exclude(
subscriber_id__in=list(new_mentions + comment_mentions + [actor_id])
)
@@ -314,7 +332,9 @@ def notifications(
project = Project.objects.get(pk=project_id)
issue_assignees = IssueAssignee.objects.filter(
issue_id=issue_id, project_id=project_id
issue_id=issue_id,
project_id=project_id,
assignee__in=Subquery(project_members),
).values_list("assignee", flat=True)
issue_subscribers = list(set(issue_subscribers) - {uuid.UUID(actor_id)})
@@ -0,0 +1,242 @@
# Generated by Django 4.2.15 on 2024-11-27 09:07
from django.conf import settings
import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
import plane.db.models.webhook
import uuid
class Migration(migrations.Migration):
dependencies = [
("db", "0085_intake_intakeissue_remove_inboxissue_created_by_and_more"),
]
operations = [
migrations.CreateModel(
name="IssueVersion",
fields=[
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
),
(
"updated_at",
models.DateTimeField(
auto_now=True, verbose_name="Last Modified At"
),
),
(
"deleted_at",
models.DateTimeField(
blank=True, null=True, verbose_name="Deleted At"
),
),
(
"id",
models.UUIDField(
db_index=True,
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
unique=True,
),
),
("parent", models.UUIDField(blank=True, null=True)),
("state", models.UUIDField(blank=True, null=True)),
("estimate_point", models.UUIDField(blank=True, null=True)),
("name", models.CharField(max_length=255, verbose_name="Issue Name")),
("description", models.JSONField(blank=True, default=dict)),
("description_html", models.TextField(blank=True, default="<p></p>")),
("description_stripped", models.TextField(blank=True, null=True)),
("description_binary", models.BinaryField(null=True)),
(
"priority",
models.CharField(
choices=[
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
],
default="none",
max_length=30,
verbose_name="Issue Priority",
),
),
("start_date", models.DateField(blank=True, null=True)),
("target_date", models.DateField(blank=True, null=True)),
(
"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)),
("is_draft", models.BooleanField(default=False)),
(
"external_source",
models.CharField(blank=True, max_length=255, null=True),
),
(
"external_id",
models.CharField(blank=True, max_length=255, null=True),
),
("type", models.UUIDField(blank=True, null=True)),
(
"last_saved_at",
models.DateTimeField(default=django.utils.timezone.now),
),
("owned_by", models.UUIDField()),
(
"assignees",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
(
"labels",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
("cycle", models.UUIDField(blank=True, null=True)),
(
"modules",
django.contrib.postgres.fields.ArrayField(
base_field=models.UUIDField(),
blank=True,
default=list,
size=None,
),
),
("properties", models.JSONField(default=dict)),
("meta", models.JSONField(default=dict)),
(
"created_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_created_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Created By",
),
),
(
"issue",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="db.issue",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="project_%(class)s",
to="db.project",
),
),
(
"updated_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="%(class)s_updated_by",
to=settings.AUTH_USER_MODEL,
verbose_name="Last Modified By",
),
),
(
"workspace",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="workspace_%(class)s",
to="db.workspace",
),
),
],
options={
"verbose_name": "Issue Version",
"verbose_name_plural": "Issue Versions",
"db_table": "issue_versions",
"ordering": ("-created_at",),
},
),
migrations.AlterUniqueTogether(
name="teampage",
unique_together=None,
),
migrations.RemoveField(
model_name="teampage",
name="created_by",
),
migrations.RemoveField(
model_name="teampage",
name="page",
),
migrations.RemoveField(
model_name="teampage",
name="team",
),
migrations.RemoveField(
model_name="teampage",
name="updated_by",
),
migrations.RemoveField(
model_name="teampage",
name="workspace",
),
migrations.RemoveField(
model_name="page",
name="teams",
),
migrations.RemoveField(
model_name="team",
name="members",
),
migrations.AddField(
model_name="fileasset",
name="entity_identifier",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="webhook",
name="is_internal",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="fileasset",
name="entity_type",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AlterField(
model_name="webhook",
name="url",
field=models.URLField(
max_length=1024,
validators=[
plane.db.models.webhook.validate_schema,
plane.db.models.webhook.validate_domain,
],
),
),
migrations.DeleteModel(
name="TeamMember",
),
migrations.DeleteModel(
name="TeamPage",
),
]
-2
View File
@@ -61,8 +61,6 @@ from .user import Account, Profile, User
from .view import IssueView
from .webhook import Webhook, WebhookLog
from .workspace import (
Team,
TeamMember,
Workspace,
WorkspaceBaseModel,
WorkspaceMember,
+24 -5
View File
@@ -44,25 +44,44 @@ 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, choices=EntityTypeContext.choices, null=True, blank=True
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)
+125 -1
View File
@@ -9,11 +9,12 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.utils import timezone
from django.db.models import Q
from django import apps
# Module imports
from plane.utils.html_processor import strip_tags
from plane.db.mixins import SoftDeletionManager
from plane.utils.exception_logger import log_exception
from .project import ProjectBaseModel
@@ -656,3 +657,126 @@ class IssueVote(ProjectBaseModel):
def __str__(self):
return f"{self.issue.name} {self.actor.email}"
class IssueVersion(ProjectBaseModel):
issue = models.ForeignKey(
"db.Issue",
on_delete=models.CASCADE,
related_name="versions",
)
PRIORITY_CHOICES = (
("urgent", "Urgent"),
("high", "High"),
("medium", "Medium"),
("low", "Low"),
("none", "None"),
)
parent = models.UUIDField(blank=True, null=True)
state = models.UUIDField(blank=True, null=True)
estimate_point = models.UUIDField(blank=True, null=True)
name = models.CharField(max_length=255, verbose_name="Issue Name")
description = models.JSONField(blank=True, default=dict)
description_html = models.TextField(blank=True, default="<p></p>")
description_stripped = models.TextField(blank=True, null=True)
description_binary = models.BinaryField(null=True)
priority = models.CharField(
max_length=30,
choices=PRIORITY_CHOICES,
verbose_name="Issue Priority",
default="none",
)
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"
)
sort_order = models.FloatField(default=65535)
completed_at = models.DateTimeField(null=True)
archived_at = models.DateField(null=True)
is_draft = models.BooleanField(default=False)
external_source = models.CharField(max_length=255, null=True, blank=True)
external_id = models.CharField(max_length=255, blank=True, null=True)
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,
)
properties = models.JSONField(default=dict)
meta = models.JSONField(default=dict)
class Meta:
verbose_name = "Issue Version"
verbose_name_plural = "Issue Versions"
db_table = "issue_versions"
ordering = ("-created_at",)
def __str__(self):
return f"{self.name} <{self.project.name}>"
@classmethod
def log_issue_version(cls, issue, user):
try:
"""
Log the issue version
"""
Module = apps.get_model("db.Module")
CycleIssue = apps.get_model("db.CycleIssue")
cycle_issue = CycleIssue.objects.filter(
issue=issue,
).first()
cls.objects.create(
issue=issue,
parent=issue.parent,
state=issue.state,
point=issue.point,
estimate_point=issue.estimate_point,
name=issue.name,
description=issue.description,
description_html=issue.description_html,
description_stripped=issue.description_stripped,
description_binary=issue.description_binary,
priority=issue.priority,
start_date=issue.start_date,
target_date=issue.target_date,
sequence_id=issue.sequence_id,
sort_order=issue.sort_order,
completed_at=issue.completed_at,
archived_at=issue.archived_at,
is_draft=issue.is_draft,
external_source=issue.external_source,
external_id=issue.external_id,
type=issue.type,
last_saved_at=issue.last_saved_at,
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
),
owned_by=user,
)
return True
except Exception as e:
log_exception(e)
return False
-29
View File
@@ -50,9 +50,6 @@ class Page(BaseModel):
projects = models.ManyToManyField(
"db.Project", related_name="pages", through="db.ProjectPage"
)
teams = models.ManyToManyField(
"db.Team", related_name="pages", through="db.TeamPage"
)
class Meta:
verbose_name = "Page"
@@ -160,32 +157,6 @@ class ProjectPage(BaseModel):
return f"{self.project.name} {self.page.name}"
class TeamPage(BaseModel):
team = models.ForeignKey(
"db.Team", on_delete=models.CASCADE, related_name="team_pages"
)
page = models.ForeignKey(
"db.Page", on_delete=models.CASCADE, related_name="team_pages"
)
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="team_pages"
)
class Meta:
unique_together = ["team", "page", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "page"],
condition=models.Q(deleted_at__isnull=True),
name="team_page_unique_team_page_when_deleted_at_null",
)
]
verbose_name = "Team Page"
verbose_name_plural = "Team Pages"
db_table = "team_pages"
ordering = ("-created_at",)
class PageVersion(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="page_versions"
+7 -2
View File
@@ -29,9 +29,13 @@ 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
)
url = models.URLField(validators=[validate_schema, validate_domain])
is_active = models.BooleanField(default=True)
secret_key = models.CharField(max_length=255, default=generate_token)
project = models.BooleanField(default=False)
@@ -39,6 +43,7 @@ class Webhook(BaseModel):
module = models.BooleanField(default=False)
cycle = models.BooleanField(default=False)
issue_comment = models.BooleanField(default=False)
is_internal = models.BooleanField(default=False)
def __str__(self):
return f"{self.workspace.slug} {self.url}"
+25 -41
View File
@@ -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)
@@ -239,13 +253,6 @@ class WorkspaceMemberInvite(BaseModel):
class Team(BaseModel):
name = models.CharField(max_length=255, verbose_name="Team Name")
description = models.TextField(verbose_name="Team Description", blank=True)
members = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="members",
through="TeamMember",
through_fields=("team", "member"),
)
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="workspace_team"
)
@@ -270,40 +277,15 @@ class Team(BaseModel):
ordering = ("-created_at",)
class TeamMember(BaseModel):
workspace = models.ForeignKey(
Workspace, on_delete=models.CASCADE, related_name="team_member"
)
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="team_member")
member = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="team_member"
)
def __str__(self):
return self.team.name
class Meta:
unique_together = ["team", "member", "deleted_at"]
constraints = [
models.UniqueConstraint(
fields=["team", "member"],
condition=models.Q(deleted_at__isnull=True),
name="team_member_unique_team_member_when_deleted_at_null",
)
]
verbose_name = "Team Member"
verbose_name_plural = "Team Members"
db_table = "team_members"
ordering = ("-created_at",)
class WorkspaceTheme(BaseModel):
workspace = models.ForeignKey(
"db.Workspace", on_delete=models.CASCADE, related_name="themes"
)
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)
@@ -338,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"]
@@ -18,6 +18,9 @@ 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:
@@ -25,7 +25,7 @@ class InstanceWorkSpaceAvailabilityCheckEndpoint(BaseAPIView):
)
workspace = (
Workspace.objects.filter(slug=slug).exists()
Workspace.objects.filter(slug__iexact=slug).exists()
or slug in RESTRICTED_WORKSPACE_SLUGS
)
return Response({"status": not workspace}, status=status.HTTP_200_OK)
+25 -7
View File
@@ -1,23 +1,33 @@
# 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
import os
# Global variable to track initialization
_TRACER_PROVIDER = None
def init_tracer():
"""Initialize OpenTelemetry with proper shutdown handling"""
global _TRACER_PROVIDER
# Check if already initialized to prevent double initialization
if trace.get_tracer_provider().__class__.__name__ == "TracerProvider":
return
# If already initialized, return existing provider
if _TRACER_PROVIDER is not None:
return _TRACER_PROVIDER
# 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
@@ -29,12 +39,20 @@ 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"""
provider = trace.get_tracer_provider()
global _TRACER_PROVIDER
if hasattr(provider, "shutdown"):
provider.shutdown()
if _TRACER_PROVIDER is not None:
if hasattr(_TRACER_PROVIDER, "shutdown"):
_TRACER_PROVIDER.shutdown()
_TRACER_PROVIDER = None
+10 -10
View File
@@ -1,6 +1,6 @@
{
"name": "live",
"version": "0.23.1",
"version": "0.24.0",
"description": "",
"main": "./src/server.ts",
"private": true,
@@ -16,16 +16,16 @@
"author": "",
"license": "ISC",
"dependencies": {
"@hocuspocus/extension-database": "^2.11.3",
"@hocuspocus/extension-logger": "^2.11.3",
"@hocuspocus/extension-redis": "^2.13.5",
"@hocuspocus/server": "^2.11.3",
"@hocuspocus/extension-database": "^2.13.7",
"@hocuspocus/extension-logger": "^2.13.7",
"@hocuspocus/extension-redis": "^2.13.7",
"@hocuspocus/server": "^2.13.7",
"@plane/editor": "*",
"@plane/types": "*",
"@sentry/node": "^8.28.0",
"@sentry/profiling-node": "^8.28.0",
"@tiptap/core": "^2.4.0",
"@tiptap/html": "^2.3.0",
"@tiptap/core": "^2.9.1",
"@tiptap/html": "^2.9.1",
"axios": "^1.7.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -39,14 +39,14 @@
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-prosemirror": "^1.2.12",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
"yjs": "^13.6.20"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-env": "^7.25.9",
"@babel/preset-typescript": "^7.24.7",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
+18 -10
View File
@@ -5,8 +5,9 @@ import { Database } from "@hocuspocus/extension-database";
import { Extension } from "@hocuspocus/server";
import { Logger } from "@hocuspocus/extension-logger";
import { Redis as HocusPocusRedis } from "@hocuspocus/extension-redis";
// core helpers and utilities
import { manualLogger } from "@/core/helpers/logger.js";
// Core helpers and utilities
import { logger } from "@/core/helpers/logger.js";
import { getRedisUrl } from "@/core/lib/utils/redis-url.js";
// core libraries
import {
@@ -27,7 +28,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
new Logger({
onChange: false,
log: (message) => {
manualLogger.info(message);
logger.info(message);
},
}),
new Database({
@@ -40,7 +41,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
return new Promise(async (resolve, reject) => {
try {
let fetchedData = null;
if (documentType === "project_page") {
@@ -57,9 +58,16 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
params,
});
}
if (!fetchedData) {
reject("Data is null");
return;
}
resolve(fetchedData);
} catch (error) {
manualLogger.error("Error in fetching document", error);
logger.error("Error in fetching document", error);
reject("Error in fetching document" + JSON.stringify(error));
}
});
},
@@ -92,7 +100,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
});
}
} catch (error) {
manualLogger.error("Error in updating document:", error);
logger.error("Error in updating document:", error);
}
});
},
@@ -114,7 +122,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
) {
redisClient.disconnect();
}
manualLogger.warn(
logger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
@@ -123,18 +131,18 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
redisClient.on("ready", () => {
extensions.push(new HocusPocusRedis({ redis: redisClient }));
manualLogger.info("Redis Client connected ✅");
logger.info("Redis Client connected ✅");
resolve();
});
});
} catch (error) {
manualLogger.warn(
logger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error,
);
}
} else {
manualLogger.warn(
logger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
+2 -2
View File
@@ -1,9 +1,9 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
import { logger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
// Log the error
manualLogger.error(err);
logger.error(err);
// Set the response status
res.status(err.status || 500);
+2 -2
View File
@@ -18,7 +18,7 @@ const hooks = {
},
};
export const logger = pinoHttp({
export const coreLogger = pinoHttp({
level: "info",
transport: transport,
hooks: hooks,
@@ -35,4 +35,4 @@ export const logger = pinoHttp({
},
});
export const manualLogger = logger.logger;
export const logger = coreLogger.logger;
+25 -14
View File
@@ -1,17 +1,25 @@
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,
...DocumentEditorExtensionsWithoutProps,
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array,
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
@@ -24,7 +32,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
documentEditorSchema,
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -34,26 +42,29 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
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,
};
};
+12
View File
@@ -4,6 +4,10 @@ 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
@@ -55,6 +59,14 @@ 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,
});
+2 -2
View File
@@ -1,7 +1,7 @@
// services
import { UserService } from "@/core/services/user.service.js";
// core helpers
import { manualLogger } from "@/core/helpers/logger.js";
import { logger } from "@/core/helpers/logger.js";
const userService = new UserService();
@@ -17,7 +17,7 @@ export const handleAuthentication = async (props: Props) => {
try {
response = await userService.currentUser(cookie);
} catch (error) {
manualLogger.error("Failed to fetch current user:", error);
logger.error("Failed to fetch current user:", error);
throw error;
}
if (response.id !== userId) {
+4 -4
View File
@@ -5,7 +5,7 @@ import {
} from "@/core/helpers/page.js";
// services
import { PageService } from "@/core/services/page.service.js";
import { manualLogger } from "../helpers/logger.js";
import { logger } from "../helpers/logger.js";
const pageService = new PageService();
export const updatePageDescription = async (
@@ -41,7 +41,7 @@ export const updatePageDescription = async (
cookie,
);
} catch (error) {
manualLogger.error("Update error:", error);
logger.error("Update error:", error);
throw error;
}
};
@@ -66,7 +66,7 @@ const fetchDescriptionHTMLAndTransform = async (
);
return contentBinary;
} catch (error) {
manualLogger.error(
logger.error(
"Error while transforming from HTML to Uint8Array",
error,
);
@@ -106,7 +106,7 @@ export const fetchPageDescriptionBinary = async (
return binaryData;
} catch (error) {
manualLogger.error("Fetch error:", error);
logger.error("Fetch error:", error);
throw error;
}
};
+17 -16
View File
@@ -13,7 +13,10 @@ import cors from "cors";
import { getHocusPocusServer } from "@/core/hocuspocus-server.js";
// helpers
import { logger, manualLogger } from "@/core/helpers/logger.js";
import {
coreLogger as LoggerMiddleware,
logger,
} from "@/core/helpers/logger.js";
import { errorHandler } from "@/core/helpers/error-handler.js";
const app = express();
@@ -33,7 +36,7 @@ app.use(
);
// Logging middleware
app.use(logger);
app.use(LoggerMiddleware);
// Body parsing middleware
app.use(express.json());
@@ -45,7 +48,7 @@ app.use(cors());
const router = express.Router();
const HocusPocusServer = await getHocusPocusServer().catch((err) => {
manualLogger.error("Failed to initialize HocusPocusServer:", err);
logger.error("Failed to initialize HocusPocusServer:", err);
process.exit(1);
});
@@ -57,7 +60,7 @@ router.ws("/collaboration", (ws, req) => {
try {
HocusPocusServer.handleConnection(ws, req);
} catch (err) {
manualLogger.error("WebSocket connection error:", err);
logger.error("WebSocket connection error:", err);
ws.close();
}
});
@@ -73,46 +76,44 @@ Sentry.setupExpressErrorHandler(app);
app.use(errorHandler);
const liveServer = app.listen(app.get("port"), () => {
manualLogger.info(`Plane Live server has started at port ${app.get("port")}`);
logger.info(`Plane Live server has started at port ${app.get("port")}`);
});
const gracefulShutdown = async () => {
manualLogger.info("Starting graceful shutdown...");
logger.info("Starting graceful shutdown...");
try {
// Close the HocusPocus server WebSocket connections
await HocusPocusServer.destroy();
manualLogger.info(
"HocusPocus server WebSocket connections closed gracefully.",
);
logger.info("HocusPocus server WebSocket connections closed gracefully.");
// Close the Express server
liveServer.close(() => {
manualLogger.info("Express server closed gracefully.");
logger.info("Express server closed gracefully.");
process.exit(1);
});
} catch (err) {
manualLogger.error("Error during shutdown:", err);
logger.error("Error during shutdown:", err);
process.exit(1);
}
// Forcefully shut down after 10 seconds if not closed
setTimeout(() => {
manualLogger.error("Forcing shutdown...");
logger.error("Forcing shutdown...");
process.exit(1);
}, 10000);
};
// Graceful shutdown on unhandled rejection
process.on("unhandledRejection", (err: any) => {
manualLogger.info("Unhandled Rejection: ", err);
manualLogger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
logger.info("Unhandled Rejection: ", err);
logger.info(`UNHANDLED REJECTION! 💥 Shutting down...`);
gracefulShutdown();
});
// Graceful shutdown on uncaught exception
process.on("uncaughtException", (err: any) => {
manualLogger.info("Uncaught Exception: ", err);
manualLogger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
logger.info("Uncaught Exception: ", err);
logger.info(`UNCAUGHT EXCEPTION! 💥 Shutting down...`);
gracefulShutdown();
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/makeplane/plane.git",
"version": "0.23.1",
"version": "0.24.0",
"license": "AGPL-3.0",
"private": true,
"workspaces": [
@@ -22,7 +22,7 @@
"devDependencies": {
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"turbo": "^2.3.2"
"turbo": "^2.3.3"
},
"packageManager": "yarn@1.22.22",
"name": "plane"
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/constants",
"version": "0.23.1",
"version": "0.24.0",
"private": true,
"main": "./index.ts"
"main": "./src/index.ts"
}
+15
View File
@@ -0,0 +1,15 @@
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}/`);
@@ -1,3 +1,4 @@
export * from "./auth";
export * from "./endpoints";
export * from "./issue";
export * from "./workspace";
+76
View File
@@ -0,0 +1,76 @@
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",
];
-23
View File
@@ -1,23 +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",
"error",
"god-mode",
"installations",
"invitations",
"onboarding",
"profile",
"spaces",
"workspace-invitations",
];
+22 -21
View File
@@ -1,6 +1,6 @@
{
"name": "@plane/editor",
"version": "0.23.1",
"version": "0.24.0",
"description": "Core Editor that powers Plane",
"private": true,
"main": "./dist/index.mjs",
@@ -38,23 +38,23 @@
"@hocuspocus/provider": "^2.13.5",
"@plane/helpers": "*",
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-character-count": "^2.6.5",
"@tiptap/extension-collaboration": "^2.3.2",
"@tiptap/extension-image": "^2.1.13",
"@tiptap/extension-list-item": "^2.1.13",
"@tiptap/extension-mention": "^2.1.13",
"@tiptap/extension-placeholder": "^2.3.0",
"@tiptap/extension-task-item": "^2.1.13",
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-align": "^2.8.0",
"@tiptap/extension-text-style": "^2.7.1",
"@tiptap/extension-underline": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",
"@tiptap/suggestion": "^2.0.13",
"@tiptap/core": "^2.9.1",
"@tiptap/extension-blockquote": "^2.9.1",
"@tiptap/extension-character-count": "^2.9.1",
"@tiptap/extension-collaboration": "^2.9.1",
"@tiptap/extension-image": "^2.9.1",
"@tiptap/extension-list-item": "^2.9.1",
"@tiptap/extension-mention": "^2.9.1",
"@tiptap/extension-placeholder": "^2.9.1",
"@tiptap/extension-task-item": "^2.9.1",
"@tiptap/extension-task-list": "^2.9.1",
"@tiptap/extension-text-style": "^2.9.1",
"@tiptap/extension-underline": "^2.9.1",
"@tiptap/extension-text-align": "^2.9.1",
"@tiptap/pm": "^2.9.1",
"@tiptap/react": "^2.9.1",
"@tiptap/starter-kit": "^2.9.1",
"@tiptap/suggestion": "^2.9.1",
"class-variance-authority": "^0.7.0",
"clsx": "^1.2.1",
"highlight.js": "^11.8.0",
@@ -63,16 +63,17 @@
"lowlight": "^3.0.0",
"lucide-react": "^0.378.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-flat-list": "^0.5.4",
"prosemirror-utils": "^1.2.2",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"tiptap-markdown": "^0.8.10",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-prosemirror": "^1.2.12",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
"yjs": "^13.6.20"
},
"devDependencies": {
"@plane/eslint-config": "*",
@@ -1,3 +1,12 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
export const CoreEditorAdditionalExtensions = (): Extensions => [];
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -1,3 +1,12 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
export const CoreReadOnlyEditorAdditionalExtensions = (): Extensions => [];
type Props = {
disabledExtensions: TExtensions[];
};
export const CoreReadOnlyEditorAdditionalExtensions = (props: Props): Extensions => {
const {} = props;
return [];
};
@@ -15,7 +15,13 @@ type Props = {
export const DocumentEditorAdditionalExtensions = (_props: Props) => {
const { disabledExtensions } = _props;
const extensions: Extensions = disabledExtensions?.includes("slash-commands") ? [] : [SlashCommands()];
const extensions: Extensions = disabledExtensions?.includes("slash-commands")
? []
: [
SlashCommands({
disabledExtensions,
}),
];
return extensions;
};
@@ -1,2 +1,3 @@
export * from "./core";
export * from "./document-extensions";
export * from "./slash-commands";
@@ -0,0 +1,14 @@
// 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
View File
@@ -1 +0,0 @@
export type TEditorAdditionalCommands = never;
-1
View File
@@ -1,2 +1 @@
export * from "./editor";
export * from "./issue-embed";
@@ -15,6 +15,7 @@ import { EditorReadOnlyRefApi, ICollaborativeDocumentReadOnlyEditor } from "@/ty
const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOnlyEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
@@ -37,6 +38,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
}
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
@@ -10,9 +10,10 @@ import { getEditorClassNames } from "@/helpers/common";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TFileHandler } from "@/types";
import { EditorReadOnlyRefApi, IMentionHighlight, TDisplayConfig, TExtensions, TFileHandler } from "@/types";
interface IDocumentReadOnlyEditor {
disabledExtensions: TExtensions[];
id: string;
initialValue: string;
containerClassName: string;
@@ -31,6 +32,7 @@ interface IDocumentReadOnlyEditor {
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
embedHandler,
@@ -51,6 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
}
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
extensions,
fileHandler,
@@ -19,6 +19,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
const {
children,
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
extensions,
@@ -37,6 +38,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
} = props;
const editor = useEditor({
disabledExtensions,
editorClassName,
enableHistory: true,
extensions,
@@ -12,6 +12,7 @@ import { IReadOnlyEditorProps } from "@/types";
export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
const {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editorClassName = "",
fileHandler,
@@ -22,6 +23,7 @@ export const ReadOnlyEditorWrapper = (props: IReadOnlyEditorProps) => {
} = props;
const editor = useReadOnlyEditor({
disabledExtensions,
editorClassName,
fileHandler,
forwardedRef,
@@ -8,12 +8,7 @@ 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 = [] } = props;
const getExtensions = useCallback(() => {
const extensions = [
@@ -24,7 +19,11 @@ const RichTextEditor = (props: IRichTextEditor) => {
}),
];
if (!disabledExtensions?.includes("slash-commands")) {
extensions.push(SlashCommands());
extensions.push(
SlashCommands({
disabledExtensions,
})
);
}
return extensions;
@@ -35,6 +35,10 @@ import {
toggleBold,
toggleBulletList,
toggleCodeBlock,
toggleFlatBulletList,
toggleFlatOrderedList,
toggleFlatTaskList,
toggleFlatToggleList,
toggleHeadingFive,
toggleHeadingFour,
toggleHeadingOne,
@@ -153,27 +157,35 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
key: "bulleted-list",
name: "Bulleted list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
isActive: () => editor?.isActive("list", { type: "bullet" }),
command: () => toggleFlatBulletList(editor),
icon: ListIcon,
});
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
key: "numbered-list",
name: "Numbered list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
isActive: () => editor?.isActive("list", { type: "ordered" }),
command: () => toggleFlatOrderedList(editor),
icon: ListIcon,
});
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
key: "to-do-list",
name: "To-do list",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
isActive: () => editor?.isActive("list", { type: "task" }),
command: () => toggleFlatTaskList(editor),
icon: CheckSquare,
});
export const ToggleListItem = (editor: Editor): EditorMenuItem<"toggle-list"> => ({
key: "toggle-list",
name: "Toggle list",
isActive: () => editor?.isActive("list", { type: "toggle" }),
command: () => toggleFlatToggleList(editor),
icon: ListIcon,
});
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
key: "quote",
name: "Quote",
@@ -265,6 +277,7 @@ export const getEditorMenuItems = (editor: Editor | null): EditorMenuItem<TEdito
HorizontalRuleItem(editor),
TextColorItem(editor),
BackgroundColorItem(editor),
ToggleListItem(editor),
TextAlignItem(editor),
];
};
@@ -0,0 +1,6 @@
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;
@@ -0,0 +1,56 @@
import React, { useState } from "react";
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
// constants
import { COLORS_LIST } from "@/constants/common";
// local components
import { CalloutBlockColorSelector } from "./color-selector";
import { CalloutBlockLogoSelector } from "./logo-selector";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { updateStoredBackgroundColor } from "./utils";
type Props = NodeViewProps & {
node: NodeViewProps["node"] & {
attrs: TCalloutBlockAttributes;
};
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CustomCalloutBlock: React.FC<Props> = (props) => {
const { editor, node, updateAttributes } = props;
// states
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = useState(false);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
// derived values
const activeBackgroundColor = COLORS_LIST.find((c) => node.attrs["data-background"] === c.key)?.backgroundColor;
return (
<NodeViewWrapper
className="editor-callout-component group/callout-node relative bg-custom-background-90 rounded-lg text-custom-text-100 p-4 my-2 flex items-start gap-4 transition-colors duration-500 break-words"
style={{
backgroundColor: activeBackgroundColor,
}}
>
<CalloutBlockLogoSelector
blockAttributes={node.attrs}
disabled={!editor.isEditable}
isOpen={isEmojiPickerOpen}
handleOpen={(val) => setIsEmojiPickerOpen(val)}
updateAttributes={updateAttributes}
/>
<CalloutBlockColorSelector
disabled={!editor.isEditable}
isOpen={isColorPickerOpen}
toggleDropdown={() => setIsColorPickerOpen((prev) => !prev)}
onSelect={(val) => {
updateAttributes({
[EAttributeNames.BACKGROUND]: val,
});
updateStoredBackgroundColor(val);
}}
/>
<NodeViewContent as="div" className="w-full break-words" />
</NodeViewWrapper>
);
};
@@ -0,0 +1,75 @@
import { Ban, ChevronDown } from "lucide-react";
// constants
import { COLORS_LIST } from "@/constants/common";
// helpers
import { cn } from "@/helpers/common";
type Props = {
disabled: boolean;
isOpen: boolean;
onSelect: (color: string | null) => void;
toggleDropdown: () => void;
};
export const CalloutBlockColorSelector: React.FC<Props> = (props) => {
const { disabled, isOpen, onSelect, toggleDropdown } = props;
const handleColorSelect = (val: string | null) => {
onSelect(val);
toggleDropdown();
};
return (
<div
className={cn("opacity-0 pointer-events-none absolute top-2 right-2 z-10 transition-opacity", {
"group-hover/callout-node:opacity-100 group-hover/callout-node:pointer-events-auto": !disabled,
"opacity-100 pointer-events-auto": isOpen,
})}
contentEditable={false}
>
<div className="relative">
<button
type="button"
onClick={(e) => {
toggleDropdown();
e.stopPropagation();
}}
className={cn(
"flex items-center gap-1 h-full whitespace-nowrap py-1 px-2.5 text-sm font-medium text-custom-text-300 hover:bg-white/10 active:bg-custom-background-80 rounded transition-colors",
{
"bg-white/10": isOpen,
}
)}
disabled={disabled}
>
<span>Color</span>
<ChevronDown className="flex-shrink-0 size-3" />
</button>
{isOpen && (
<section className="absolute top-full right-0 z-10 mt-1 rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 p-2 shadow-custom-shadow-rg animate-in fade-in slide-in-from-top-1">
<div className="flex items-center gap-2">
{COLORS_LIST.map((color) => (
<button
key={color.key}
type="button"
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
style={{
backgroundColor: color.backgroundColor,
}}
onClick={() => handleColorSelect(color.key)}
/>
))}
<button
type="button"
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
onClick={() => handleColorSelect(null)}
>
<Ban className="size-4" />
</button>
</div>
</section>
)}
</div>
</div>
);
};
@@ -0,0 +1,72 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { Node as NodeType } from "@tiptap/pm/model";
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
// types
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
// Extend Tiptap's Commands interface
declare module "@tiptap/core" {
interface Commands<ReturnType> {
calloutComponent: {
insertCallout: () => ReturnType;
};
}
}
export const CustomCalloutExtensionConfig = Node.create({
name: "calloutComponent",
group: "block",
content: "block+",
addAttributes() {
const attributes = {
// Reduce instead of map to accumulate the attributes directly into an object
...Object.values(EAttributeNames).reduce((acc, value) => {
acc[value] = {
default: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[value],
};
return acc;
}, {}),
};
return attributes;
},
addStorage() {
return {
markdown: {
serialize(state: MarkdownSerializerState, node: NodeType) {
const attrs = node.attrs as TCalloutBlockAttributes;
const logoInUse = attrs["data-logo-in-use"];
// add callout logo
if (logoInUse === "emoji") {
state.write(
`> <img src="${attrs["data-emoji-url"]}" alt="${attrs["data-emoji-unicode"]}" width="30px" />\n`
);
} else {
state.write(`> <icon>${attrs["data-icon-name"]} icon</icon>\n`);
}
// add an empty line after the logo
state.write("> \n");
// add '> ' before each line of the callout content
state.wrapBlock("> ", null, node, () => state.renderContent(node));
state.closeBlock(node);
},
},
};
},
parseHTML() {
return [
{
tag: `div[${EAttributeNames.BLOCK_TYPE}="${DEFAULT_CALLOUT_BLOCK_ATTRIBUTES[EAttributeNames.BLOCK_TYPE]}"]`,
},
];
},
// Render HTML for the callout node
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes(HTMLAttributes), 0];
},
});
@@ -0,0 +1,68 @@
import { findParentNodeClosestToPos, Predicate, ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
// utils
import { getStoredBackgroundColor, getStoredLogo } from "./utils";
export const CustomCalloutExtension = CustomCalloutExtensionConfig.extend({
selectable: true,
draggable: true,
addCommands() {
return {
insertCallout:
() =>
({ commands }) => {
// get stored logo values and background color from the local storage
const storedLogoValues = getStoredLogo();
const storedBackgroundValue = getStoredBackgroundColor();
return commands.insertContent({
type: this.name,
content: [
{
type: "paragraph",
},
],
attrs: {
...storedLogoValues,
"data-background": storedBackgroundValue,
},
});
},
};
},
addKeyboardShortcuts() {
return {
Backspace: ({ editor }) => {
const { $from, empty } = editor.state.selection;
try {
const isParentNodeCallout: Predicate = (node) => node.type === this.type;
const parentNodeDetails = findParentNodeClosestToPos($from, isParentNodeCallout);
// Check if selection is empty and at the beginning of the callout
if (empty && parentNodeDetails) {
const isCursorAtCalloutBeginning = $from.pos === parentNodeDetails.start + 1;
if (parentNodeDetails.node.content.size > 2 && isCursorAtCalloutBeginning) {
editor.commands.setTextSelection(parentNodeDetails.pos - 1);
return true;
}
}
} catch (error) {
console.error("Error in performing backspace action on callout", error);
}
return false; // Allow the default behavior if conditions are not met
},
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
ArrowUp: insertEmptyParagraphAtNodeBoundaries("up", this.name),
};
},
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -0,0 +1,3 @@
export * from "./block";
export * from "./extension";
export * from "./read-only-extension";
@@ -0,0 +1,97 @@
// plane helpers
import { convertHexEmojiToDecimal } from "@plane/helpers";
// plane ui
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common";
// types
import { TCalloutBlockAttributes } from "./types";
// utils
import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES, updateStoredLogo } from "./utils";
type Props = {
blockAttributes: TCalloutBlockAttributes;
disabled: boolean;
handleOpen: (val: boolean) => void;
isOpen: boolean;
updateAttributes: (attrs: Partial<TCalloutBlockAttributes>) => void;
};
export const CalloutBlockLogoSelector: React.FC<Props> = (props) => {
const { blockAttributes, disabled, handleOpen, isOpen, updateAttributes } = props;
const logoValue: TEmojiLogoProps = {
in_use: blockAttributes["data-logo-in-use"],
icon: {
color: blockAttributes["data-icon-color"],
name: blockAttributes["data-icon-name"],
},
emoji: {
value: blockAttributes["data-emoji-unicode"]?.toString(),
url: blockAttributes["data-emoji-url"],
},
};
return (
<div contentEditable={false}>
<EmojiIconPicker
closeOnSelect={false}
isOpen={isOpen}
handleToggle={handleOpen}
className="flex-shrink-0 grid place-items-center"
buttonClassName={cn("flex-shrink-0 size-8 grid place-items-center rounded-lg", {
"hover:bg-white/10": !disabled,
})}
label={<Logo logo={logoValue} size={18} type="lucide" />}
onChange={(val) => {
// construct the new logo value based on the type of value
let newLogoValue: Partial<TCalloutBlockAttributes> = {};
let newLogoValueToStoreInLocalStorage: TEmojiLogoProps = {
in_use: "emoji",
emoji: {
value: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
url: DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
},
};
if (val.type === "emoji") {
newLogoValue = {
"data-emoji-unicode": convertHexEmojiToDecimal(val.value.unified),
"data-emoji-url": val.value.imageUrl,
};
newLogoValueToStoreInLocalStorage = {
in_use: "emoji",
emoji: {
value: convertHexEmojiToDecimal(val.value.unified),
url: val.value.imageUrl,
},
};
} else if (val.type === "icon") {
newLogoValue = {
"data-icon-name": val.value.name,
"data-icon-color": val.value.color,
};
newLogoValueToStoreInLocalStorage = {
in_use: "icon",
icon: {
name: val.value.name,
color: val.value.color,
},
};
}
// update node attributes
updateAttributes({
"data-logo-in-use": val.type,
...newLogoValue,
});
// update stored logo in local storage
updateStoredLogo(newLogoValueToStoreInLocalStorage);
handleOpen(false);
}}
defaultIconColor={logoValue?.in_use && logoValue.in_use === "icon" ? logoValue?.icon?.color : undefined}
defaultOpen={logoValue.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
disabled={disabled}
searchDisabled
/>
</div>
);
};
@@ -0,0 +1,14 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
// extensions
import { CustomCalloutBlock } from "@/extensions";
// config
import { CustomCalloutExtensionConfig } from "./extension-config";
export const CustomCalloutReadOnlyExtension = CustomCalloutExtensionConfig.extend({
selectable: false,
draggable: false,
addNodeView() {
return ReactNodeViewRenderer(CustomCalloutBlock);
},
});
@@ -0,0 +1,26 @@
export enum EAttributeNames {
ICON_COLOR = "data-icon-color",
ICON_NAME = "data-icon-name",
EMOJI_UNICODE = "data-emoji-unicode",
EMOJI_URL = "data-emoji-url",
LOGO_IN_USE = "data-logo-in-use",
BACKGROUND = "data-background",
BLOCK_TYPE = "data-block-type",
}
export type TCalloutBlockIconAttributes = {
[EAttributeNames.ICON_COLOR]: string | undefined;
[EAttributeNames.ICON_NAME]: string | undefined;
};
export type TCalloutBlockEmojiAttributes = {
[EAttributeNames.EMOJI_UNICODE]: string | undefined;
[EAttributeNames.EMOJI_URL]: string | undefined;
};
export type TCalloutBlockAttributes = {
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
[EAttributeNames.BACKGROUND]: string;
[EAttributeNames.BLOCK_TYPE]: "callout-component";
} & TCalloutBlockIconAttributes &
TCalloutBlockEmojiAttributes;
@@ -0,0 +1,85 @@
// plane helpers
import { sanitizeHTML } from "@plane/helpers";
// plane ui
import { TEmojiLogoProps } from "@plane/ui";
// types
import {
EAttributeNames,
TCalloutBlockAttributes,
TCalloutBlockEmojiAttributes,
TCalloutBlockIconAttributes,
} from "./types";
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
"data-logo-in-use": "emoji",
"data-icon-color": null,
"data-icon-name": null,
"data-emoji-unicode": "128161",
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
"data-background": null,
"data-block-type": "callout-component",
};
type TStoredLogoValue = Pick<TCalloutBlockAttributes, EAttributeNames.LOGO_IN_USE> &
(TCalloutBlockEmojiAttributes | TCalloutBlockIconAttributes);
// function to get the stored logo from local storage
export const getStoredLogo = (): TStoredLogoValue => {
const fallBackValues: TStoredLogoValue = {
"data-logo-in-use": "emoji",
"data-emoji-unicode": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
if (typeof window !== "undefined") {
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
if (storedData) {
let parsedData: TEmojiLogoProps;
try {
parsedData = JSON.parse(storedData);
} catch (error) {
console.error(`Error parsing stored callout logo, stored value- ${storedData}`, error);
localStorage.removeItem("editor-calloutComponent-logo");
return fallBackValues;
}
if (parsedData.in_use === "emoji" && parsedData.emoji?.value) {
return {
"data-logo-in-use": "emoji",
"data-emoji-unicode": parsedData.emoji.value || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-unicode"],
"data-emoji-url": parsedData.emoji.url || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-emoji-url"],
};
}
if (parsedData.in_use === "icon" && parsedData.icon?.name) {
return {
"data-logo-in-use": "icon",
"data-icon-name": parsedData.icon.name || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-name"],
"data-icon-color": parsedData.icon.color || DEFAULT_CALLOUT_BLOCK_ATTRIBUTES["data-icon-color"],
};
}
}
}
// fallback values
return fallBackValues;
};
// function to update the stored logo on local storage
export const updateStoredLogo = (value: TEmojiLogoProps): void => {
if (typeof window === "undefined") return;
localStorage.setItem("editor-calloutComponent-logo", JSON.stringify(value));
};
// function to get the stored background color from local storage
export const getStoredBackgroundColor = (): string | null => {
if (typeof window !== "undefined") {
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
}
return null;
};
// function to update the stored background color on local storage
export const updateStoredBackgroundColor = (value: string | null): void => {
if (typeof window === "undefined") return;
if (value === null) {
localStorage.removeItem("editor-calloutComponent-background");
return;
} else {
localStorage.setItem("editor-calloutComponent-background", value);
}
};
@@ -1,5 +1,5 @@
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Plugin, PluginKey, Selection } from "@tiptap/pm/state";
export interface CodeBlockOptions {
/**
@@ -150,6 +150,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
// exit node on triple enter
Enter: ({ editor }) => {
try {
console.log("ran in code block");
if (!this.options.exitOnTripleEnter) {
return false;
}
@@ -183,8 +184,6 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
try {
if (!this.options.exitOnArrowDown) {
@@ -205,7 +204,12 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
return false;
}
const after = $from.after();
// if the code block is directly on the root level, then just
// find the next node at same depth (basically the root and code block are at the same level)
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
const after = $from.after(parentDepth);
if (after === undefined) {
return false;
@@ -214,12 +218,15 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return false;
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
} catch (error) {
console.error("Error handling ArrowDown in code block:", error);
console.error("Error handling ArrowDown in CustomCodeBlockExtension:", error);
return false;
}
},
@@ -91,7 +91,12 @@ export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
return false;
}
const after = $from.after();
// if the code block is directly on the root level, then just
// find the next node at same depth (basically the root and code block are at the same level)
// else it's always to be found at $from.depth - 1 to set the cursor at the next node
const parentDepth = $from.depth === 1 ? $from.depth : $from.depth - 1;
const after = $from.after(parentDepth);
if (after === undefined) {
return false;
@@ -1,4 +1,3 @@
import { Extensions } from "@tiptap/core";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
@@ -18,10 +17,13 @@ import { CustomMentionWithoutProps } from "./mentions/mentions-without-props";
import { CustomQuoteExtension } from "./quote";
import { TableHeader, TableCell, TableRow, Table } from "./table";
import { CustomTextAlignExtension } from "./text-align";
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
import { CustomColorExtension } from "./custom-color";
import { FlatListExtension } from "./flat-list/flat-list";
// plane editor extensions
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
export const CoreEditorExtensionsWithoutProps: Extensions = [
export const CoreEditorExtensionsWithoutProps = [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
@@ -88,7 +90,9 @@ export const CoreEditorExtensionsWithoutProps: Extensions = [
TableRow,
CustomMentionWithoutProps(),
CustomTextAlignExtension,
CustomCalloutExtensionConfig,
CustomColorExtension,
FlatListExtension,
...CoreEditorAdditionalExtensionsWithoutProps,
];
@@ -118,7 +118,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatioCalculated,
};
setSize(initialComputedSize);
updateAttributesSafely(
initialComputedSize,
@@ -29,12 +29,9 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
if (closestEditorContainer) {
setEditorContainer(closestEditorContainer as HTMLDivElement);
}
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
@@ -55,7 +52,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
setResolvedSrc(url as string);
};
getImageSource();
}, [imageFromFileSystem, node.attrs.src]);
}, [imgNodeSrc]);
return (
<NodeViewWrapper>
@@ -1,11 +1,9 @@
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, ImageAttributes } from "@/extensions/custom-image";
import { CustomImageNode } from "@/extensions/custom-image";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
// types
@@ -126,14 +124,9 @@ export const CustomImageExtension = (props: TFileHandler) => {
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
maxFileSize,
// escape markdown for images
markdown: {
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);
},
serialize() {},
},
};
},
@@ -1,10 +1,8 @@
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, ImageAttributes, UploadImageExtensionStorage } from "@/extensions/custom-image";
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
// types
import { TFileHandler } from "@/types";
@@ -54,14 +52,9 @@ export const CustomReadOnlyImageExtension = (props: Pick<TFileHandler, "getAsset
addStorage() {
return {
fileMap: new Map(),
// escape markdown for images
markdown: {
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);
},
serialize() {},
},
};
},
@@ -88,41 +88,41 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
return handled;
},
Backspace: ({ editor }) => {
try {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
} catch (e) {
console.log("Error in handling Backspace:", e);
return false;
}
},
"Mod-Backspace": ({ editor }) => {
let handled = false;
this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
if (editor.state.schema.nodes[itemName] === undefined) {
return;
}
if (handleBackspace(editor, itemName, wrapperNames)) {
handled = true;
}
});
return handled;
},
// Backspace: ({ editor }) => {
// try {
// let handled = false;
//
// this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
// if (editor.state.schema.nodes[itemName] === undefined) {
// return;
// }
//
// if (handleBackspace(editor, itemName, wrapperNames)) {
// handled = true;
// }
// });
//
// return handled;
// } catch (e) {
// console.log("Error in handling Backspace:", e);
// return false;
// }
// },
// "Mod-Backspace": ({ editor }) => {
// let handled = false;
//
// this.options.listTypes.forEach(({ itemName, wrapperNames }) => {
// if (editor.state.schema.nodes[itemName] === undefined) {
// return;
// }
//
// if (handleBackspace(editor, itemName, wrapperNames)) {
// handled = true;
// }
// });
//
// return handled;
// },
};
},
});
@@ -0,0 +1,234 @@
import { Plugin, EditorState } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
import { dropPoint } from "prosemirror-transform";
import { Extension } from "@tiptap/core";
export function dropCursor(options: DropCursorOptions = {}): Plugin {
return new Plugin({
view(editorView) {
return new DropCursorView(editorView, {
...options,
// Add custom behavior for list nodes
disableDropCursor: (view: EditorView, pos: { pos: number; inside: number }, event: DragEvent) => {
console.log("adf");
if (!pos) return true;
const $pos = view.state.doc.resolve(pos.pos);
const parentNode = $pos.parent;
// If we're between two list items, only show cursor at the list boundary
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
const nodeBefore = $pos.nodeBefore;
const nodeAfter = $pos.nodeAfter;
// Only show cursor at list boundaries
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
// Only allow cursor at the exact position between lists
return $pos.pos !== pos.pos;
}
}
return false;
},
});
},
});
}
interface DropCursorOptions {
/// The color of the cursor. Defaults to `black`. Use `false` to apply no color and rely only on class.
color?: string | false;
/// The precise width of the cursor in pixels. Defaults to 1.
width?: number;
/// A CSS class name to add to the cursor element.
class?: string;
}
/// Create a plugin that, when added to a ProseMirror instance,
/// causes a decoration to show up at the drop position when something
/// is dragged over the editor.
///
/// Nodes may add a `disableDropCursor` property to their spec to
/// control the showing of a drop cursor inside them. This may be a
/// boolean or a function, which will be called with a view and a
/// position, and should return a boolean.
// export function dropCursor(options: DropCursorOptions = {}): Plugin {
// return new Plugin({
// view(editorView) {
// return new DropCursorView(editorView, options);
// },
// });
// }
class DropCursorView {
width: number;
color: string | undefined;
class: string | undefined;
cursorPos: number | null = null;
element: HTMLElement | null = null;
timeout: number = -1;
handlers: { name: string; handler: (event: Event) => void }[];
constructor(
readonly editorView: EditorView,
options: DropCursorOptions
) {
this.width = options.width ?? 1;
this.color = options.color === false ? undefined : options.color || "black";
this.class = options.class;
this.handlers = ["dragover", "dragend", "drop", "dragleave"].map((name) => {
let handler = (e: Event) => {
(this as any)[name](e);
};
editorView.dom.addEventListener(name, handler);
return { name, handler };
});
}
destroy() {
this.handlers.forEach(({ name, handler }) => this.editorView.dom.removeEventListener(name, handler));
}
update(editorView: EditorView, prevState: EditorState) {
if (this.cursorPos != null && prevState.doc != editorView.state.doc) {
if (this.cursorPos > editorView.state.doc.content.size) this.setCursor(null);
else this.updateOverlay();
}
}
setCursor(pos: number | null) {
if (pos == this.cursorPos) return;
this.cursorPos = pos;
if (pos == null) {
this.element!.parentNode!.removeChild(this.element!);
this.element = null;
} else {
this.updateOverlay();
}
}
updateOverlay() {
let $pos = this.editorView.state.doc.resolve(this.cursorPos!);
let isBlock = !$pos.parent.inlineContent,
rect;
let editorDOM = this.editorView.dom,
editorRect = editorDOM.getBoundingClientRect();
let scaleX = editorRect.width / editorDOM.offsetWidth,
scaleY = editorRect.height / editorDOM.offsetHeight;
if (isBlock) {
let before = $pos.nodeBefore,
after = $pos.nodeAfter;
if (before || after) {
let node = this.editorView.nodeDOM(this.cursorPos! - (before ? before.nodeSize : 0));
if (node) {
let nodeRect = (node as HTMLElement).getBoundingClientRect();
let top = before ? nodeRect.bottom : nodeRect.top;
if (before && after)
top = (top + (this.editorView.nodeDOM(this.cursorPos!) as HTMLElement).getBoundingClientRect().top) / 2;
let halfWidth = (this.width / 2) * scaleY;
rect = { left: nodeRect.left, right: nodeRect.right, top: top - halfWidth, bottom: top + halfWidth };
}
}
}
if (!rect) {
let coords = this.editorView.coordsAtPos(this.cursorPos!);
let halfWidth = (this.width / 2) * scaleX;
rect = { left: coords.left - halfWidth, right: coords.left + halfWidth, top: coords.top, bottom: coords.bottom };
}
let parent = this.editorView.dom.offsetParent as HTMLElement;
if (!this.element) {
this.element = parent.appendChild(document.createElement("div"));
if (this.class) this.element.className = this.class;
this.element.style.cssText = "position: absolute; z-index: 50; pointer-events: none;";
if (this.color) {
this.element.style.backgroundColor = this.color;
}
}
this.element.classList.toggle("prosemirror-dropcursor-block", isBlock);
this.element.classList.toggle("prosemirror-dropcursor-inline", !isBlock);
let parentLeft, parentTop;
if (!parent || (parent == document.body && getComputedStyle(parent).position == "static")) {
parentLeft = -pageXOffset;
parentTop = -pageYOffset;
} else {
let rect = parent.getBoundingClientRect();
let parentScaleX = rect.width / parent.offsetWidth,
parentScaleY = rect.height / parent.offsetHeight;
parentLeft = rect.left - parent.scrollLeft * parentScaleX;
parentTop = rect.top - parent.scrollTop * parentScaleY;
}
this.element.style.left = (rect.left - parentLeft) / scaleX + "px";
this.element.style.top = (rect.top - parentTop) / scaleY + "px";
this.element.style.width = (rect.right - rect.left) / scaleX + "px";
this.element.style.height = (rect.bottom - rect.top) / scaleY + "px";
}
scheduleRemoval(timeout: number) {
clearTimeout(this.timeout);
this.timeout = setTimeout(() => this.setCursor(null), timeout);
}
dragover(event: DragEvent) {
if (!this.editorView.editable) return;
let pos = this.editorView.posAtCoords({ left: event.clientX, top: event.clientY });
let node = pos && pos.inside >= 0 && this.editorView.state.doc.nodeAt(pos.inside);
let disableDropCursor = node && node.type.spec.disableDropCursor;
let disabled =
typeof disableDropCursor == "function" ? disableDropCursor(this.editorView, pos, event) : disableDropCursor;
if (!pos) return true;
const $pos = this.editorView.state.doc.resolve(pos.pos);
const parentNode = $pos.parent;
// If we're between two list items, only show cursor at the list boundary
if (parentNode.type.name === "list" || parentNode.type.name.includes("list")) {
const nodeBefore = $pos.nodeBefore;
const nodeAfter = $pos.nodeAfter;
console.log(nodeBefore.type.name, nodeAfter.type.name);
// Only show cursor at list boundaries
if (nodeBefore?.type.name.includes("list") && nodeAfter?.type.name.includes("list")) {
// Only allow cursor at the exact position between lists
return $pos.pos !== pos.pos;
}
}
// return false;
if (pos && !disabled) {
let target: number | null = pos.pos;
if (this.editorView.dragging && this.editorView.dragging.slice) {
let point = dropPoint(this.editorView.state.doc, target, this.editorView.dragging.slice);
if (point != null) target = point;
}
this.setCursor(target);
this.scheduleRemoval(5000);
}
}
dragend() {
this.scheduleRemoval(20);
}
drop() {
this.scheduleRemoval(20);
}
dragleave(event: DragEvent) {
if (event.target == this.editorView.dom || !this.editorView.dom.contains((event as any).relatedTarget))
this.setCursor(null);
}
}
export const dropCursorExtension = (options: DropCursorOptions) =>
Extension.create({
addProseMirrorPlugins() {
return [dropCursor(options)];
},
});
@@ -1,14 +1,15 @@
import { Extensions } from "@tiptap/core";
import CharacterCount from "@tiptap/extension-character-count";
import Placeholder from "@tiptap/extension-placeholder";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
import TiptapUnderline from "@tiptap/extension-underline";
import StarterKit from "@tiptap/starter-kit";
import BulletList from "@tiptap/extension-bullet-list";
import OrderedList from "@tiptap/extension-ordered-list";
import { Markdown } from "tiptap-markdown";
// extensions
import {
CustomCalloutExtension,
CustomCodeBlockExtension,
CustomCodeInlineExtension,
CustomCodeMarkPlugin,
@@ -23,7 +24,6 @@ import {
CustomTypographyExtension,
DropHandlerExtension,
ImageExtension,
ListKeymap,
Table,
TableCell,
TableHeader,
@@ -32,11 +32,18 @@ import {
// helpers
import { isValidHttpUrl } from "@/helpers/common";
// types
import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
import { IMentionHighlight, IMentionSuggestion, TExtensions, TFileHandler } from "@/types";
import { FlatListExtension } from "./flat-list/flat-list";
import { multipleSelectionExtension } from "./selections/multipleSelections";
// plane editor extensions
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import ListItem from "@tiptap/extension-list-item";
type TArguments = {
disabledExtensions: TExtensions[];
enableHistory: boolean;
fileHandler: TFileHandler;
mentionConfig: {
@@ -48,34 +55,52 @@ type TArguments = {
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
return [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
},
listItem: {
HTMLAttributes: {
class: "not-prose space-y-2",
},
},
bulletList: false,
orderedList: false,
listItem: false,
code: false,
codeBlock: false,
horizontalRule: false,
blockquote: false,
// dropcursor: false,
dropcursor: {
class: "text-custom-text-300",
},
...(enableHistory ? {} : { history: false }),
}),
BulletList.extend({
addInputRules() {
return [];
},
addKeyboardShortcuts() {
return {};
},
}).configure({
HTMLAttributes: {
class: "list-disc pl-7 space-y-2",
},
}),
OrderedList.extend({
addInputRules() {
return [];
},
addKeyboardShortcuts() {
return {};
},
}).configure({
HTMLAttributes: {
class: "list-decimal pl-7 space-y-2",
},
}),
ListItem.configure({
HTMLAttributes: {
class: "not-prose space-y-2",
},
}),
CustomQuoteExtension,
DropHandlerExtension(),
CustomHorizontalRule.configure({
@@ -84,7 +109,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
},
}),
CustomKeymap,
ListKeymap({ tabIndex }),
CustomLinkExtension.configure({
openOnClick: true,
autolink: true,
@@ -162,7 +186,13 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
}),
CharacterCount,
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
...CoreEditorAdditionalExtensions(),
FlatListExtension,
multipleSelectionExtension,
// FlatHeadingListExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
}),
];
};
@@ -0,0 +1,575 @@
import { type TaggedProsemirrorNode } from 'jest-remirror'
import { type Node as ProsemirrorNode } from 'prosemirror-model'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createDedentListCommand } from './dedent-list'
describe('dedentList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
it('can dedent a list node to outer list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B<cursor>1
`,
markdown`
- A1
- B<cursor>1
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- - <cursor>B1
- A1
`,
markdown`
- B1
- A1
`,
)
})
it('can dedent a paragraph node to outer list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1a
B1b<cursor>
`,
markdown`
- A1
- B1a
B1b<cursor>
`,
)
})
it('can unwrap a list node', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
paragraph
`,
markdown`
A1<cursor>
paragraph
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
- A2
`,
markdown`
A1<cursor>
- A2
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
A2
`,
)
})
it('can unwrap multiple list nodes', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<start>
- A2<end>
`,
markdown`
A1
A2
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- A2<start>
- A3<end>
- A4
`,
markdown`
- A1
A2
A3
- A4
`,
)
})
it('can keep siblings after the lifted items at the same position', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<start>
- B3
- C1<end>
B3
- B4
`,
markdown`
- A1
- B1
- B2<start>
- B3
- C1<end>
B3
- B4
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<cursor>
A1
`,
markdown`
- A1
- B1
- B2<cursor>
A1
`,
)
})
it('can only dedent selected part when the selection across multiple depth of a nested lists', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
`,
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
- C2
`,
markdown`
- A1
- B1
- B2
- C1<start>
- B3<end>
- - C2
`,
)
})
it('can wrap unselected paragraphs with a list node if necessary', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2<start>
- B3<end>
B3
B3
- B4
`,
markdown`
- A1
- B1
- B2<start>
- B3<end>
- B3
B3
- B4
`,
)
})
it('can keep the indentation of sub list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1<cursor>
- C1
`,
markdown`
- A1
- B1<cursor>
- - C1
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1<cursor>
- B1
`,
markdown`
A1<cursor>
- - B1
`,
)
})
it('do nothing when not inside a list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
Hello<cursor>
paragraph
`,
markdown`
Hello<cursor>
paragraph
`,
)
})
it('can dedent a nested list item', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- - B1<cursor>
B1
A1
`,
markdown`
- B1
- B1
A1
`,
)
})
it('can dedent a blockquote inside a list', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- > A1
>
> A2<cursor>
`,
markdown`
- > A1
A2<cursor>
`,
)
})
it('can accept custom positions', () => {
t.applyCommand(
createDedentListCommand({ from: 13, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
//
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2')),
t.p('A3'),
),
)
t.applyCommand(
createDedentListCommand({ from: 10, to: 14 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
//
t.bulletList(t.p('A1')),
t.p('A2'),
t.p('A3'),
),
)
})
it('can handle some complex nested lists', () => {
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
markdown`
- A1
- B1
- <start>B2
A2
- B3
- C1<end>
- - D1
- B4
`,
)
t.applyCommand(
createDedentListCommand(),
markdown`
- A1
- B1
- B2
- C1
- D1
D1<start>
- A2
- B3
- C2
C2
- D2<end>
C2
- C3
`,
markdown`
- A1
- B1
- B2
- C1
- D1
D1<start>
A2
- B3
- C2
C2
- D2<end>
C2
- C3
`,
)
})
it('only needs one step for some of the most comment indent action', () => {
const countSteps = (
doc: TaggedProsemirrorNode,
expected: TaggedProsemirrorNode,
) => {
t.add(doc)
const state = t.view.state
const command = createDedentListCommand()
let count = -1
let actual: ProsemirrorNode | null = null
command(state, (tr) => {
count = tr.steps.length
actual = tr.doc
})
expect(actual).not.equal(null)
expect(actual).toEqualRemirrorDocument(expected)
return count
}
expect(
countSteps(
markdown`
- A1
- B1<cursor>
`,
markdown`
- A1
- B1<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
A2<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
# heading
- A1<cursor>
`,
markdown`
# heading
A1<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
# heading
- - A1<cursor>
`,
markdown`
# heading
- A1<cursor>
`,
),
).toBe(1)
})
})
@@ -0,0 +1,223 @@
import { Fragment, NodeRange, Slice } from "prosemirror-model";
import { type Command, type Transaction } from "prosemirror-state";
import { ReplaceAroundStep } from "prosemirror-transform";
import { withVisibleSelection } from "./set-safe-selection";
import { findListsRange, isListNode, isListsRange, getListType } from "prosemirror-flat-list";
import { atStartBlockBoundary, atEndBlockBoundary } from "../utils/block-boundary";
import { mapPos } from "../utils/map-pos";
import { safeLift } from "../utils/safe-lift";
import { zoomInRange } from "../utils/zoom-in-range";
/**
* @public
*/
export interface DedentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number;
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number;
}
/**
* Returns a command function that decreases the indentation of selected list nodes.
*
* @public @group Commands
*/
export function createDedentListCommand(options?: DedentListOptions): Command {
const dedentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr;
const $from = options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from);
const $to = options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to);
const range = findListsRange($from, $to);
if (!range) return false;
if (dedentRange(range, tr)) {
dispatch?.(tr);
return true;
}
return false;
};
return withVisibleSelection(dedentListCommand);
}
function dedentRange(range: NodeRange, tr: Transaction, startBoundary?: boolean, endBoundary?: boolean): boolean {
const { depth, $from, $to } = range;
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1);
if (!startBoundary) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? dedentRange(contentRange, tr) : false;
} else {
return splitAndDedentRange(range, tr, startIndex + 1);
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1);
if (!endBoundary) {
fixEndBoundary(range, tr);
const endOfParent = $to.end(depth);
range = new NodeRange(tr.doc.resolve($from.pos), tr.doc.resolve(endOfParent), depth);
return dedentRange(range, tr, undefined, true);
}
if (range.startIndex === 0 && range.endIndex === range.parent.childCount && isListNode(range.parent)) {
return dedentNodeRange(new NodeRange($from, $to, depth - 1), tr);
}
return dedentNodeRange(range, tr);
}
/**
* Split a range into two parts, and dedent them separately.
*/
function splitAndDedentRange(range: NodeRange, tr: Transaction, splitIndex: number): boolean {
const { $from, $to, depth } = range;
const splitPos = $from.posAtIndex(splitIndex, depth);
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1));
if (!range1) return false;
const getRange2From = mapPos(tr, splitPos + 1);
const getRange2To = mapPos(tr, $to.pos);
dedentRange(range1, tr, undefined, true);
let range2 = tr.doc.resolve(getRange2From()).blockRange(tr.doc.resolve(getRange2To()));
if (range2 && range2.depth >= depth) {
range2 = new NodeRange(range2.$from, range2.$to, depth);
dedentRange(range2, tr, true, undefined);
}
return true;
}
export function dedentNodeRange(range: NodeRange, tr: Transaction) {
if (isListNode(range.parent)) {
return safeLiftRange(tr, range);
} else if (isListsRange(range)) {
return dedentOutOfList(tr, range);
} else {
return safeLiftRange(tr, range);
}
}
function safeLiftRange(tr: Transaction, range: NodeRange): boolean {
if (moveRangeSiblings(tr, range)) {
const $from = tr.doc.resolve(range.$from.pos);
const $to = tr.doc.resolve(range.$to.pos);
range = new NodeRange($from, $to, range.depth);
}
return safeLift(tr, range);
}
function moveRangeSiblings(tr: Transaction, range: NodeRange): boolean {
const listType = getListType(tr.doc.type.schema);
const { $to, depth, end, parent, endIndex } = range;
const endOfParent = $to.end(depth);
if (end < endOfParent) {
// There are siblings after the lifted items, which must become
// children of the last item
const lastChild = parent.maybeChild(endIndex - 1);
if (!lastChild) return false;
const canAppend =
endIndex < parent.childCount &&
lastChild.canReplace(lastChild.childCount, lastChild.childCount, parent.content, endIndex, parent.childCount);
if (canAppend) {
tr.step(
new ReplaceAroundStep(
end - 1,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 1, 0),
0,
true
)
);
return true;
} else {
tr.step(
new ReplaceAroundStep(
end,
endOfParent,
end,
endOfParent,
new Slice(Fragment.from(listType.create(null)), 0, 0),
1,
true
)
);
return true;
}
}
return false;
}
function fixEndBoundary(range: NodeRange, tr: Transaction): void {
if (range.endIndex - range.startIndex >= 2) {
range = new NodeRange(
range.$to.doc.resolve(range.$to.posAtIndex(range.endIndex - 1, range.depth)),
range.$to,
range.depth
);
}
const contentRange = zoomInRange(range);
if (contentRange) {
fixEndBoundary(contentRange, tr);
range = new NodeRange(tr.doc.resolve(range.$from.pos), tr.doc.resolve(range.$to.pos), range.depth);
}
moveRangeSiblings(tr, range);
}
export function dedentOutOfList(tr: Transaction, range: NodeRange): boolean {
const { startIndex, endIndex, parent } = range;
const getRangeStart = mapPos(tr, range.start);
const getRangeEnd = mapPos(tr, range.end);
// Merge the list nodes into a single big list node
for (let end = getRangeEnd(), i = endIndex - 1; i > startIndex; i--) {
end -= parent.child(i).nodeSize;
tr.delete(end - 1, end + 1);
}
const $start = tr.doc.resolve(getRangeStart());
const listNode = $start.nodeAfter;
if (!listNode) return false;
const start = range.start;
const end = start + listNode.nodeSize;
if (getRangeEnd() !== end) return false;
if (!$start.parent.canReplace(startIndex, startIndex + 1, Fragment.from(listNode))) {
return false;
}
tr.step(new ReplaceAroundStep(start, end, start + 1, end - 1, new Slice(Fragment.empty, 0, 0), 0, true));
return true;
}
@@ -0,0 +1,19 @@
import {
chainCommands,
createParagraphNear,
newlineInCode,
splitBlock,
} from 'prosemirror-commands'
import { type Command } from 'prosemirror-state'
/**
* This command has the same behavior as the `Enter` keybinding from
* `prosemirror-commands`, but without the `liftEmptyBlock` command.
*
* @internal
*/
export const enterWithoutLift: Command = chainCommands(
newlineInCode,
createParagraphNear,
splitBlock,
)
@@ -0,0 +1,787 @@
import { type TaggedProsemirrorNode } from 'jest-remirror'
import { type Node as ProsemirrorNode } from 'prosemirror-model'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createIndentListCommand } from './indent-list'
describe('indentList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
const indentList = createIndentListCommand()
it('can indent a list node and append it to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A<cursor>2
`,
markdown`
- A1
- A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- ## A<cursor>2
`,
markdown`
- A1
- ## A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- > ## A<cursor>2
`,
markdown`
- A1
- > ## A<cursor>2
`,
)
})
it('can indent multiple list nodes and append them to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2<start>
- A3<end>
`,
markdown`
- A1
- A2<start>
- A3<end>
`,
)
})
it('should not wrap a paragraph with a new list node when it will bring a new visual bullet', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2a
A2b<cursor>
`,
markdown`
- A1
- A2a
A2b<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2a
A2b<cursor>
A2c
`,
markdown`
- A1
- A2a
A2b<cursor>
A2c
`,
)
})
it('can indent a paragraph and append it to the previous sibling list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2a
- B1
A2b<cursor>
`,
markdown`
- A1
- A2a
- B1
A2b<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2a
- B1
A2b<cursor>
- B2
`,
markdown`
- A1
- A2a
- B1
A2b<cursor>
- B2
`,
)
})
it('can only indent selected part when the selection across multiple depth of a nested lists', () => {
t.applyCommand(
indentList,
markdown`
- A1a
- B1a
- C1
B1b<start>
A1b<end>
`,
markdown`
- A1a
- B1a
- C1
B1b<start>
A1b<end>
`,
)
t.applyCommand(
indentList,
markdown`
- A1a
- B1a
- C1
B1b<start>
- B2
- B3
- B4
A1b<end>
`,
markdown`
- A1a
- B1a
- C1
B1b<start>
- B2
- B3
- B4
A1b<end>
`,
)
})
it('can indent multiple list nodes and append them to the previous list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A<start>2
- A<end>3
`,
markdown`
- A1
- A<start>2
- A<end>3
`,
)
})
it('can add ambitious indentations', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B<cursor>2
`,
markdown`
- A1
- - B<cursor>2
`,
)
})
it('can split the list when necessary', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B<cursor>2a
B2b
B2c
`,
markdown`
- A1
- - B<cursor>2a
- B2b
B2c
`,
)
})
it('can keep attributes', () => {
t.applyCommand(
indentList,
markdown`
- [ ] A1
- [x] A<cursor>2
`,
markdown`
- [ ] A1
- [x] A<cursor>2
`,
)
t.applyCommand(
indentList,
markdown`
1. A1
2. A<cursor>2
- B1
`,
markdown`
1. A1
1. A<cursor>2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- [x] A1
- B1
- [x] A<cursor>2
1. B2
`,
markdown`
- [x] A1
- B1
- [x] A<cursor>2
1. B2
`,
)
})
it('can keep the indentation of sub list nodes', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2
- A3<cursor>
- B1
- B2
- B3
`,
markdown`
- A1
- A2
- A3<cursor>
- B1
- B2
- B3
`,
)
})
it('can move all collapsed content', () => {
t.applyCommand(
indentList,
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2')),
t.collapsedToggleList(
t.p('A3<cursor>'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
),
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(
t.p('A2'),
t.collapsedToggleList(
t.p('A3<cursor>'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
),
),
)
})
it('can expand a collapsed list node if something is indent into it', () => {
t.applyCommand(
indentList,
t.doc(
t.collapsedToggleList(
t.p('A1'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
),
t.p('<cursor>'),
),
t.doc(
t.expandedToggleList(
t.p('A1'),
t.bulletList(t.p('B1')),
t.bulletList(t.p('B2')),
t.bulletList(t.p('B3')),
t.p('<cursor>'),
),
),
)
})
it('can keep the indentation of sub list nodes when moving multiple list', () => {
t.applyCommand(
indentList,
markdown`
- A1
- <start>A2
- A3<end>
- B1
- B2
- B3
`,
markdown`
- A1
- <start>A2
- A3<end>
- B1
- B2
- B3
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- B1
- A2<start>
- B2<end>
- C1
`,
markdown`
- A1
- B1
- A2<start>
- B2<end>
- C1
`,
)
})
it('can keep the indentation of siblings around the indented item', () => {
t.applyCommand(
indentList,
markdown`
- A1
- A2<cursor>
A2
`,
markdown`
- A1
- A2<cursor>
A2
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2<cursor>
A2
- B1
`,
markdown`
- A1
- A2<cursor>
A2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- A1
- A2
- B1
A2<cursor>
A2
- B1
`,
markdown`
- A1
- A2
- B1
A2<cursor>
A2
- B1
`,
)
t.applyCommand(
indentList,
markdown`
- A1
A1
- <cursor>A2
`,
markdown`
- A1
A1
- <cursor>A2
`,
)
})
it('can indent a paragraph that not inside a list node', () => {
t.applyCommand(
indentList,
markdown`
- A1
P1<cursor>
`,
markdown`
- A1
P1<cursor>
`,
)
t.applyCommand(
indentList,
markdown`
- A1
P1<start>
P2<end>
P3
`,
markdown`
- A1
P1<start>
P2<end>
P3
`,
)
})
it('can accept custom positions', () => {
t.applyCommand(
createIndentListCommand({ from: 13, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
t.bulletList(t.p('A1')),
t.bulletList(t.p('A2'), t.bulletList(t.p('A3'))),
),
)
t.applyCommand(
createIndentListCommand({ from: 10, to: 17 }),
t.doc(
/*0*/
t.bulletList(/*1*/ t.p('A1') /*5*/),
/*6*/
t.bulletList(/*7*/ t.p('A2<cursor>') /*11*/),
/*12*/
t.bulletList(/*13*/ t.p('A3') /*17*/),
/*18*/
),
t.doc(
t.bulletList(
t.p('A1'),
t.bulletList(t.p('A2')),
t.bulletList(t.p('A3')),
),
),
)
})
it('can handle some complex nested lists', () => {
t.applyCommand(
indentList,
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
markdown`
- A1
- B1
- <start>B2
- A2
- B3
- C1<end>
- D1
- B4
`,
)
})
it('only needs one step for some of the most comment indent action', () => {
const countSteps = (
doc: TaggedProsemirrorNode,
expected: TaggedProsemirrorNode,
) => {
t.add(doc)
const state = t.view.state
const command = createIndentListCommand()
let count = -1
let actual: ProsemirrorNode | null = null
command(state, (tr) => {
count = tr.steps.length
actual = tr.doc
})
expect(actual).not.equal(null)
expect(actual).toEqualRemirrorDocument(expected)
return count
}
expect(
countSteps(
markdown`
- A1
- A2<cursor>
`,
markdown`
- A1
- A2<cursor>
`,
),
).toBe(1)
expect(
countSteps(
markdown`
- A1
- [ ] A2<cursor>
- [x] A3
`,
markdown`
- A1
- [ ] A2<cursor>
- [x] A3
`,
),
).toBe(1)
expect(
countSteps(
markdown`
1. A1
2. <start>A2
3. A3<end>
4. A4
`,
markdown`
1. A1
1. <start>A2
2. A3<end>
2. A4
`,
),
).toBe(1)
expect(
countSteps(
markdown`
1. A1
- B1
- <start>B2
- B3
- B4<end>
`,
markdown`
1. A1
- B1
- <start>B2
- B3
- B4<end>
`,
),
).toBe(1)
// For more complex (and less common) cases, more steps is acceptable
expect(
countSteps(
markdown`
- A1
- B1
- C1
- D1
D1b
- <start>D2
C1b
C1c
- B2
- C2
- A2
- A3
- B3
B3b
- B4<end>
A3b
`,
markdown`
- A1
- B1
- C1
- D1
D1b
- <start>D2
C1b
C1c
- B2
- C2
- A2
- A3
- B3
B3b
- B4<end>
A3b
`,
),
).toBeGreaterThan(1)
})
})
@@ -0,0 +1,153 @@
import { Fragment, type NodeRange, Slice } from "prosemirror-model";
import { type Command, type Transaction } from "prosemirror-state";
import { ReplaceAroundStep } from "prosemirror-transform";
import { withAutoFixList } from "../utils/auto-fix-list";
import { atEndBlockBoundary, atStartBlockBoundary } from "../utils/block-boundary";
import { getListType } from "../utils/get-list-type";
import { inCollapsedList } from "../utils/in-collapsed-list";
import { isListNode } from "../utils/is-list-node";
import { findListsRange } from "../utils/list-range";
import { mapPos } from "../utils/map-pos";
import { zoomInRange } from "../utils/zoom-in-range";
import { withVisibleSelection } from "./set-safe-selection";
import { ListAttributes } from "prosemirror-flat-list";
/**
* @public
*/
export interface IndentListOptions {
/**
* A optional from position to indent.
*
* @defaultValue `state.selection.from`
*/
from?: number;
/**
* A optional to position to indent.
*
* @defaultValue `state.selection.to`
*/
to?: number;
}
/**
* Returns a command function that increases the indentation of selected list
* nodes.
*
* @public @group Commands
*/
export function createIndentListCommand(options?: IndentListOptions): Command {
const indentListCommand: Command = (state, dispatch): boolean => {
const tr = state.tr;
const $from = options?.from == null ? tr.selection.$from : tr.doc.resolve(options.from);
const $to = options?.to == null ? tr.selection.$to : tr.doc.resolve(options.to);
const range = findListsRange($from, $to) || $from.blockRange($to);
if (!range) return false;
if (indentRange(range, tr)) {
dispatch?.(tr);
return true;
}
return false;
};
return withVisibleSelection(withAutoFixList(indentListCommand));
}
function indentRange(range: NodeRange, tr: Transaction, startBoundary?: boolean, endBoundary?: boolean): boolean {
const { depth, $from, $to } = range;
startBoundary = startBoundary || atStartBlockBoundary($from, depth + 1);
if (!startBoundary) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? indentRange(contentRange, tr) : false;
} else {
return splitAndIndentRange(range, tr, startIndex + 1);
}
}
endBoundary = endBoundary || atEndBlockBoundary($to, depth + 1);
if (!endBoundary && !inCollapsedList($to)) {
const { startIndex, endIndex } = range;
if (endIndex - startIndex === 1) {
const contentRange = zoomInRange(range);
return contentRange ? indentRange(contentRange, tr) : false;
} else {
return splitAndIndentRange(range, tr, endIndex - 1);
}
}
return indentNodeRange(range, tr);
}
/**
* Split a range into two parts, and indent them separately.
*/
function splitAndIndentRange(range: NodeRange, tr: Transaction, splitIndex: number): boolean {
const { $from, $to, depth } = range;
const splitPos = $from.posAtIndex(splitIndex, depth);
const range1 = $from.blockRange(tr.doc.resolve(splitPos - 1));
if (!range1) return false;
const getRange2From = mapPos(tr, splitPos + 1);
const getRange2To = mapPos(tr, $to.pos);
indentRange(range1, tr, undefined, true);
const range2 = tr.doc.resolve(getRange2From()).blockRange(tr.doc.resolve(getRange2To()));
if (range2) {
indentRange(range2, tr, true, undefined);
}
return true;
}
/**
* Increase the indentation of a block range.
*/
function indentNodeRange(range: NodeRange, tr: Transaction): boolean {
const listType = getListType(tr.doc.type.schema);
const { parent, startIndex } = range;
const prevChild = startIndex >= 1 && parent.child(startIndex - 1);
// If the previous node before the range is a list node, move the range into
// the previous list node as its children
if (prevChild && isListNode(prevChild)) {
const { start, end } = range;
tr.step(
new ReplaceAroundStep(start - 1, end, start, end, new Slice(Fragment.from(listType.create(null)), 1, 0), 0, true)
);
return true;
}
// If we can avoid to add a new bullet visually, we can wrap the range with a
// new list node.
const isParentListNode = isListNode(parent);
const isFirstChildListNode = isListNode(parent.maybeChild(startIndex));
if ((startIndex === 0 && isParentListNode) || isFirstChildListNode) {
const { start, end } = range;
const listAttrs: ListAttributes | null = isFirstChildListNode
? parent.child(startIndex).attrs
: isParentListNode
? parent.attrs
: null;
tr.step(
new ReplaceAroundStep(start, end, start, end, new Slice(Fragment.from(listType.create(listAttrs)), 0, 0), 1, true)
);
return true;
}
// Otherwise we cannot indent
return false;
}
@@ -0,0 +1,56 @@
import { type ResolvedPos } from "prosemirror-model";
import { type Command, TextSelection } from "prosemirror-state";
import { atTextblockStart } from "../utils/at-textblock-start";
import { isListNode } from "../utils/is-list-node";
import { joinTextblocksAround } from "./join-textblocks-around";
import { ListAttributes } from "prosemirror-flat-list";
/**
* If the selection is empty and at the start of a block, and there is a
* collapsed list node right before the cursor, move current block and append it
* to the first child of the collapsed list node (i.e. skip the hidden content).
*
* @public @group Commands
*/
export const joinCollapsedListBackward: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view);
if (!$cursor) return false;
const $cut = findCutBefore($cursor);
if (!$cut) return false;
const { nodeBefore, nodeAfter } = $cut;
if (
nodeBefore &&
nodeAfter &&
isListNode(nodeBefore) &&
(nodeBefore.attrs as ListAttributes).collapsed &&
nodeAfter.isBlock
) {
const tr = state.tr;
const listPos = $cut.pos - nodeBefore.nodeSize;
tr.delete($cut.pos, $cut.pos + nodeAfter.nodeSize);
const insert = listPos + 1 + nodeBefore.child(0).nodeSize;
tr.insert(insert, nodeAfter);
const $insert = tr.doc.resolve(insert);
tr.setSelection(TextSelection.near($insert));
if (joinTextblocksAround(tr, $insert, dispatch)) {
return true;
}
}
return false;
};
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L150
function findCutBefore($pos: ResolvedPos): ResolvedPos | null {
if (!$pos.parent.type.spec.isolating)
for (let i = $pos.depth - 1; i >= 0; i--) {
if ($pos.index(i) > 0) return $pos.doc.resolve($pos.before(i + 1));
if ($pos.node(i).type.spec.isolating) break;
}
return null;
}
@@ -0,0 +1,76 @@
import { NodeRange, type ResolvedPos } from 'prosemirror-model'
import {
type Command,
type EditorState,
type Transaction,
} from 'prosemirror-state'
import { atTextblockStart } from '../utils/at-textblock-start'
import { isListNode } from '../utils/is-list-node'
import { safeLift } from '../utils/safe-lift'
/**
* If the text cursor is at the start of the first child of a list node, lift
* all content inside the list. If the text cursor is at the start of the last
* child of a list node, lift this child.
*
* @public @group Commands
*/
export const joinListUp: Command = (state, dispatch, view) => {
const $cursor = atTextblockStart(state, view)
if (!$cursor) return false
const { depth } = $cursor
if (depth < 2) return false
const listDepth = depth - 1
const listNode = $cursor.node(listDepth)
if (!isListNode(listNode)) return false
const indexInList = $cursor.index(listDepth)
if (indexInList === 0) {
if (dispatch) {
liftListContent(state, dispatch, $cursor)
}
return true
}
if (indexInList === listNode.childCount - 1) {
if (dispatch) {
liftParent(state, dispatch, $cursor)
}
return true
}
return false
}
function liftListContent(
state: EditorState,
dispatch: (tr: Transaction) => void,
$cursor: ResolvedPos,
) {
const tr = state.tr
const listDepth = $cursor.depth - 1
const range = new NodeRange(
$cursor,
tr.doc.resolve($cursor.end(listDepth)),
listDepth,
)
if (safeLift(tr, range)) {
dispatch(tr)
}
}
function liftParent(
state: EditorState,
dispatch: (tr: Transaction) => void,
$cursor: ResolvedPos,
) {
const tr = state.tr
const range = $cursor.blockRange()
if (range && safeLift(tr, range)) {
dispatch(tr)
}
}
@@ -0,0 +1,36 @@
/* eslint-disable prefer-const */
import { type ResolvedPos, Slice } from 'prosemirror-model'
import { TextSelection, type Transaction } from 'prosemirror-state'
import { replaceStep, ReplaceStep } from 'prosemirror-transform'
// prettier-ignore
// https://github.com/prosemirror/prosemirror-commands/blob/e607d5abda0fcc399462e6452a82450f4118702d/src/commands.ts#L94
function joinTextblocksAround(tr: Transaction, $cut: ResolvedPos, dispatch?: (tr: Transaction) => void) {
let before = $cut.nodeBefore!, beforeText = before, beforePos = $cut.pos - 1
for (; !beforeText.isTextblock; beforePos--) {
if (beforeText.type.spec.isolating) return false
let child = beforeText.lastChild
if (!child) return false
beforeText = child
}
let after = $cut.nodeAfter!, afterText = after, afterPos = $cut.pos + 1
for (; !afterText.isTextblock; afterPos++) {
if (afterText.type.spec.isolating) return false
let child = afterText.firstChild
if (!child) return false
afterText = child
}
let step = replaceStep(tr.doc, beforePos, afterPos, Slice.empty) as ReplaceStep | null
if (!step || step.from != beforePos ||
step instanceof ReplaceStep && step.slice.size >= afterPos - beforePos) return false
if (dispatch) {
tr.step(step)
tr.setSelection(TextSelection.create(tr.doc, beforePos))
dispatch(tr.scrollIntoView())
}
return true
}
export { joinTextblocksAround }
@@ -0,0 +1,208 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { backspaceCommand } from './keymap'
describe('Keymap', () => {
const t = setupTestingEditor()
const markdown = t.markdown
describe('Backspace', () => {
it('should delete the empty paragraph between two list nodes', () => {
t.applyCommand(
backspaceCommand,
t.doc(
t.bulletList(t.p('A1')),
t.p('<cursor>'),
t.bulletList(t.p('A2')),
),
t.doc(t.bulletList(t.p('A1')), t.bulletList(t.p('A2'))),
)
})
it('can handle nested list', () => {
const doc1 = markdown`
- A1
- B1
- <cursor>B2
`
const doc2 = markdown`
- A1
- B1
<cursor>B2
`
const doc3 = markdown`
- A1
- B1
<cursor>B2
`
const doc4 = markdown`
- A1
- B1<cursor>B2
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc4)
})
it('can handle nested list with multiple children', () => {
const doc1 = markdown`
- A1
- B1
- <cursor>B2a
B2b
B2c
`
const doc2 = markdown`
- A1
- B1
<cursor>B2a
B2b
B2c
`
const doc3 = markdown`
- A1
- B1<cursor>B2a
B2b
B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
})
it('can handle cursor in the middle child', () => {
const doc1 = markdown`
- A1
- B1
- B2a
<cursor>B2b
B2c
`
const doc2 = markdown`
- A1
- B1
- B2a<cursor>B2b
B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
})
it('can handle cursor in the last child', () => {
const doc1 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
const doc2 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
const doc3 = markdown`
- A1
- B1
- B2a
B2b
<cursor>B2c
`
t.add(doc1)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc2)
t.editor.press('Backspace')
expect(t.editor.state).toEqualRemirrorState(doc3)
})
it('can skip collapsed content', () => {
t.applyCommand(
backspaceCommand,
t.doc(
t.collapsedToggleList(
//
t.p('A1'),
t.bulletList(t.p('B1')),
),
t.p('<cursor>A2'),
),
t.doc(
t.collapsedToggleList(
//
t.p('A1<cursor>A2'),
t.bulletList(t.p('B1')),
),
),
)
t.applyCommand(
backspaceCommand,
t.doc(
t.collapsedToggleList(
//
t.p('A1'),
t.bulletList(t.p('B1')),
),
t.blockquote(t.p('<cursor>A2')),
),
t.doc(
t.collapsedToggleList(
//
t.p('A1<cursor>A2'),
t.bulletList(t.p('B1')),
),
),
)
})
})
})
@@ -0,0 +1,91 @@
import {
chainCommands,
deleteSelection,
joinTextblockBackward,
joinTextblockForward,
selectNodeBackward,
selectNodeForward,
} from 'prosemirror-commands'
import { createDedentListCommand } from './dedent-list'
import { createIndentListCommand } from './indent-list'
import { joinCollapsedListBackward } from './join-collapsed-backward'
import { joinListUp } from './join-list-up'
import { protectCollapsed } from './protect-collapsed'
import { createSplitListCommand } from './split-list'
/**
* Keybinding for `Enter`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - {@link createSplitListCommand}
*
* @public @group Commands
*/
export const enterCommand = chainCommands(
protectCollapsed,
createSplitListCommand(),
)
/**
* Keybinding for `Backspace`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - {@link joinListUp}
* - {@link joinCollapsedListBackward}
* - [joinTextblockBackward](https://prosemirror.net/docs/ref/#commands.joinTextblockBackward)
* - [selectNodeBackward](https://prosemirror.net/docs/ref/#commands.selectNodeBackward)
*
* @public @group Commands
*
*/
export const backspaceCommand = chainCommands(
protectCollapsed,
deleteSelection,
joinListUp,
joinCollapsedListBackward,
joinTextblockBackward,
selectNodeBackward,
)
/**
* Keybinding for `Delete`. It's chained with following commands:
*
* - {@link protectCollapsed}
* - [deleteSelection](https://prosemirror.net/docs/ref/#commands.deleteSelection)
* - [joinTextblockForward](https://prosemirror.net/docs/ref/#commands.joinTextblockForward)
* - [selectNodeForward](https://prosemirror.net/docs/ref/#commands.selectNodeForward)
*
* @public @group Commands
*
*/
export const deleteCommand = chainCommands(
protectCollapsed,
deleteSelection,
joinTextblockForward,
selectNodeForward,
)
/**
* Returns an object containing the keymap for the list commands.
*
* - `Enter`: See {@link enterCommand}.
* - `Backspace`: See {@link backspaceCommand}.
* - `Delete`: See {@link deleteCommand}.
* - `Mod-[`: Decrease indentation. See {@link createDedentListCommand}.
* - `Mod-]`: Increase indentation. See {@link createIndentListCommand}.
*
* @public @group Commands
*/
export const listKeymap = {
Enter: enterCommand,
Backspace: backspaceCommand,
Delete: deleteCommand,
'Mod-[': createDedentListCommand(),
'Mod-]': createIndentListCommand(),
}
@@ -0,0 +1,84 @@
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createMoveListCommand } from './move-list'
describe('moveList', () => {
const t = setupTestingEditor()
const markdown = t.markdown
const moveUp = createMoveListCommand('up')
const moveDown = createMoveListCommand('down')
it('can move up list nodes', () => {
t.applyCommand(
moveUp,
markdown`
- A1
- A2<start>
- A3<end>
`,
markdown`
- A2<start>
- A3<end>
- A1
`,
)
})
it('can move up and dedent list nodes to parent list', () => {
t.applyCommand(
moveUp,
markdown`
- A1
- A2
- B1<start>
- B2<end>
- B3
`,
markdown`
- A1
- B1<start>
- B2<end>
- A2
- B3
`,
)
})
it('can move down list nodes', () => {
t.applyCommand(
moveDown,
markdown`
- A1<start>
- A2<end>
- A3
`,
markdown`
- A3
- A1<start>
- A2<end>
`,
)
})
it('can move down and dedent list nodes to parent list', () => {
t.applyCommand(
moveDown,
markdown`
- A1
- A2
- B1<start>
- B2<end>
- A3
`,
markdown`
- A1
- A2
- A3
- B1<start>
- B2<end>
`,
)
})
})
@@ -0,0 +1,86 @@
import { type Command, type Transaction } from 'prosemirror-state'
import { withAutoFixList } from '../utils/auto-fix-list'
import { cutByIndex } from '../utils/cut-by-index'
import { isListNode } from '../utils/is-list-node'
import { findListsRange } from '../utils/list-range'
import { safeLift } from '../utils/safe-lift'
/**
* Returns a command function that moves up or down selected list nodes.
*
* @public @group Commands
*
*/
export function createMoveListCommand(direction: 'up' | 'down'): Command {
const moveList: Command = (state, dispatch): boolean => {
const tr = state.tr
if (doMoveList(tr, direction, true, !!dispatch)) {
dispatch?.(tr)
return true
}
return false
}
return withAutoFixList(moveList)
}
/** @internal */
export function doMoveList(
tr: Transaction,
direction: 'up' | 'down',
canDedent: boolean,
dispatch: boolean,
): boolean {
const { $from, $to } = tr.selection
const range = findListsRange($from, $to)
if (!range) return false
const { parent, depth, startIndex, endIndex } = range
if (direction === 'up') {
if (startIndex >= 2 || (startIndex === 1 && isListNode(parent.child(0)))) {
const before = cutByIndex(parent.content, startIndex - 1, startIndex)
const selected = cutByIndex(parent.content, startIndex, endIndex)
if (
parent.canReplace(startIndex - 1, endIndex, selected.append(before))
) {
if (dispatch) {
tr.insert($from.posAtIndex(endIndex, depth), before)
tr.delete(
$from.posAtIndex(startIndex - 1, depth),
$from.posAtIndex(startIndex, depth),
)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
} else {
if (endIndex < parent.childCount) {
const selected = cutByIndex(parent.content, startIndex, endIndex)
const after = cutByIndex(parent.content, endIndex, endIndex + 1)
if (parent.canReplace(startIndex, endIndex + 1, after.append(selected))) {
if (dispatch) {
tr.delete(
$from.posAtIndex(endIndex, depth),
$from.posAtIndex(endIndex + 1, depth),
)
tr.insert($from.posAtIndex(startIndex, depth), after)
}
return true
} else {
return false
}
} else if (canDedent && isListNode(parent)) {
return safeLift(tr, range) && doMoveList(tr, direction, false, dispatch)
} else {
return false
}
}
}
@@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
describe('protectCollapsed', () => {
const { add, doc, p, editor, collapsedToggleList, expandedToggleList } =
setupTestingEditor()
it('can skip collapsed content', () => {
// Cursor in the last paragraph of the item
add(
doc(
collapsedToggleList(
//
p('1<start>23'),
p('456'),
),
collapsedToggleList(
//
p('123'),
p('4<end>56'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
expandedToggleList(
//
p('1<start>23'),
p('456'),
),
expandedToggleList(
//
p('123'),
p('4<end>56'),
),
),
)
})
})
@@ -0,0 +1,43 @@
import type { Command } from 'prosemirror-state'
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
/**
* This command will protect the collapsed items from being deleted.
*
* If current selection contains a collapsed item, we don't want the user to
* delete this selection by pressing Backspace or Delete, because this could
* be unintentional.
*
* In such case, we will stop the delete action and expand the collapsed items
* instead. Therefore the user can clearly know what content he is trying to
* delete.
*
* @public @group Commands
*
*/
export const protectCollapsed: Command = (state, dispatch): boolean => {
const tr = state.tr
let found = false
const { from, to } = state.selection
state.doc.nodesBetween(from, to, (node, pos, parent, index) => {
if (found && !dispatch) {
return false
}
if (parent && isCollapsedListNode(parent) && index >= 1) {
found = true
if (!dispatch) {
return false
}
const $pos = state.doc.resolve(pos)
tr.setNodeAttribute($pos.before($pos.depth), 'collapsed', false)
}
})
if (found) {
dispatch?.(tr)
}
return found
}
@@ -0,0 +1,154 @@
import { type Command } from 'prosemirror-state'
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { setSafeSelection } from './set-safe-selection'
describe('setSafeSelection', () => {
const {
doc,
p,
collapsedToggleList,
expandedToggleList,
bulletList,
applyCommand,
} = setupTestingEditor()
const command: Command = (state, dispatch) => {
dispatch?.(setSafeSelection(state.tr))
return true
}
it('can move cursor outside of collapsed content', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('123'),
p('45<cursor>6'),
),
),
doc(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
)
})
it('can move cursor outside of collapsed and deep sub list', () => {
applyCommand(
command,
doc(
bulletList(
bulletList(
bulletList(
collapsedToggleList(
//
p('123'),
p('45<cursor>6'),
),
),
),
),
),
doc(
bulletList(
bulletList(
bulletList(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
),
),
),
)
})
it('does not change if the cursor is visible ', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('12<cursor>3'),
p('456'),
),
),
doc(
collapsedToggleList(
//
p('12<cursor>3'),
p('456'),
),
),
)
})
it('can handle from position', () => {
applyCommand(
command,
doc(
collapsedToggleList(
//
p('123'),
p('45<start>6'),
),
expandedToggleList(
//
p('12<end>3'),
p('456'),
),
),
doc(
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
expandedToggleList(
//
p('123'),
p('456'),
),
),
)
})
it('can handle to position', () => {
applyCommand(
command,
doc(
expandedToggleList(
//
p('1<start>23'),
p('456'),
),
collapsedToggleList(
//
p('123'),
p('4<end>56'),
),
),
doc(
expandedToggleList(
//
p('123'),
p('456'),
),
collapsedToggleList(
//
p('123<cursor>'),
p('456'),
),
),
)
})
})
@@ -0,0 +1,70 @@
import { type ResolvedPos } from 'prosemirror-model'
import {
type Selection,
TextSelection,
type Transaction,
} from 'prosemirror-state'
import { isCollapsedListNode } from '../utils/is-collapsed-list-node'
import { patchCommand } from '../utils/patch-command'
import { setListAttributes } from '../utils/set-list-attributes'
function moveOutOfCollapsed(
$pos: ResolvedPos,
minDepth: number,
): Selection | null {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
const before = $pos.posAtIndex(1, depth)
const $before = $pos.doc.resolve(before)
return TextSelection.near($before, -1)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, move the selection outside of it
*
* @internal
*/
export function setSafeSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const selection =
moveOutOfCollapsed($from, 0) ||
moveOutOfCollapsed($to, $from.sharedDepth(to))
if (selection) {
tr.setSelection(selection)
}
return tr
}
export const withSafeSelection = patchCommand(setSafeSelection)
function getCollapsedPosition($pos: ResolvedPos, minDepth: number) {
for (let depth = minDepth; depth <= $pos.depth; depth++) {
if (isCollapsedListNode($pos.node(depth)) && $pos.index(depth) >= 1) {
return $pos.before(depth)
}
}
return null
}
/**
* If one of the selection's end points is inside a collapsed node, expand it
*
* @internal
*/
export function setVisibleSelection(tr: Transaction): Transaction {
const { $from, $to, to } = tr.selection
const pos =
getCollapsedPosition($from, 0) ??
getCollapsedPosition($to, $from.sharedDepth(to))
if (pos != null) {
tr.doc.resolve(pos)
setListAttributes(tr, pos, { collapsed: false })
}
return tr
}
export const withVisibleSelection = patchCommand(setVisibleSelection)
@@ -0,0 +1,516 @@
import { NodeSelection } from 'prosemirror-state'
import { describe, expect, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { enterCommand } from './keymap'
describe('splitList', () => {
const {
add,
doc,
p,
bulletList,
blockquote,
editor,
markdown,
applyCommand,
collapsedToggleList,
expandedToggleList,
checkedTaskList,
uncheckedTaskList,
} = setupTestingEditor()
it('can split non-empty item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- 234<cursor>
paragraph
`,
markdown`
- 123
- 234
- <cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- 123
- 23<cursor>4
`,
markdown`
- 123
- 23
- <cursor>4
`,
)
applyCommand(
enterCommand,
markdown`
- 1<cursor>23
- 234
`,
markdown`
- 1
- <cursor>23
- 234
`,
)
})
it('can split non-empty sub item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- 456<cursor>
paragraph
`,
markdown`
- 123
- 456
- <cursor>
paragraph
`,
)
})
it('can delete empty item', () => {
applyCommand(
enterCommand,
markdown`
- 123
- <cursor>
paragraph
`,
markdown`
- 123
<cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- 123
- <cursor>
- 456
`,
markdown`
- 123
<cursor>
- 456
`,
)
applyCommand(
enterCommand,
markdown`
- <cursor>
- 123
`,
markdown`
<cursor>
- 123
`,
)
})
it('can dedent the last empty sub item', () => {
applyCommand(
enterCommand,
markdown`
- A1
- <cursor>
paragraph
`,
markdown`
- A1
- <cursor>
paragraph
`,
)
applyCommand(
enterCommand,
markdown`
- A1
- B1
- <cursor>
paragraph
`,
markdown`
- A1
- B1
- <cursor>
paragraph
`,
)
})
it('can delete selected text', () => {
applyCommand(
enterCommand,
markdown`
- <start>123<end>
- 456
`,
markdown`
-
- <cusror>
- 456
`,
)
})
it('can set attributes correctly', () => {
applyCommand(
enterCommand,
doc(
checkedTaskList(p('<cursor>A1')),
uncheckedTaskList(p('A2')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('')),
checkedTaskList(p('<cursor>A1')),
uncheckedTaskList(p('A2')),
uncheckedTaskList(p('A3')),
),
)
applyCommand(
enterCommand,
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A2<cursor>')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A2')),
uncheckedTaskList(p('<cursor>')),
uncheckedTaskList(p('A3')),
),
)
applyCommand(
enterCommand,
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A<cursor>2')),
uncheckedTaskList(p('A3')),
),
doc(
uncheckedTaskList(p('A1')),
checkedTaskList(p('A')),
uncheckedTaskList(p('<cursor>2')),
uncheckedTaskList(p('A3')),
),
)
})
it('escapes the item when the cursor is in the first paragraph of the item', () => {
applyCommand(
enterCommand,
markdown`
- 123<cursor>
456
789
`,
markdown`
- 123
- <cursor>
456
789
`,
)
// Nested list item
applyCommand(
enterCommand,
markdown`
- Parent
- 123<cursor>
456
789
`,
markdown`
- Parent
- 123
- <cursor>
456
789
`,
)
})
it('can create new paragraph when the caret is not inside the first child of the list', () => {
// Cursor in the last paragraph of the item
add(
doc(
bulletList(
//
p('123'),
p('456<cursor>'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p('456'),
p('<cursor>'),
),
),
)
// Cursor in the middle paragraph of the item
add(
doc(
bulletList(
//
p('123'),
p('456<cursor>'),
p('789'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p('456'),
p('<cursor>'),
p('789'),
),
),
)
// Cursor in the last paragraph of the item (nested list item)
add(
doc(
bulletList(
p('parent'),
bulletList(
//
p('123'),
p('<cursor>456'),
),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
p('parent'),
bulletList(
//
p('123'),
p(''),
p('<cursor>456'),
),
),
),
)
add(
doc(
bulletList(
//
p('123'),
p('<cursor>'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p(''),
p('<cursor>'),
),
),
)
add(
doc(
bulletList(
//
p('123'),
p('<cursor>'),
p('456'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
bulletList(
//
p('123'),
p(''),
p('<cursor>'),
p('456'),
),
),
)
})
it('can skip collapsed content', () => {
// Cursor in the last paragraph of the item
add(
doc(
collapsedToggleList(
//
p('1<start>23<end>'),
p('456'),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
collapsedToggleList(
//
p('1'),
p('456'),
),
expandedToggleList(
//
p('<cursor>'),
),
),
)
})
it("won't effect non-list document", () => {
applyCommand(
enterCommand,
markdown`
# h1
1<cursor>23
`,
null,
)
applyCommand(
enterCommand,
markdown`
# h1
123
> 4<cursor>56
`,
null,
)
add(
doc(
blockquote(
p('123'),
blockquote(
//
p('4<cursor>56'),
),
),
),
)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(
doc(
blockquote(
p('123'),
blockquote(
//
p('4'),
p('<cursor>56'),
),
),
),
)
})
it('can split list node for a block node selection', () => {
add(markdown`
# h1
1. ***
`)
let hrPos = -1
editor.doc.descendants((node, pos) => {
if (node.type.name === 'horizontalRule') {
hrPos = pos
}
})
expect(hrPos > -1).toBe(true)
const nodeSelection = NodeSelection.create(editor.state.doc, hrPos)
editor.view.dispatch(editor.view.state.tr.setSelection(nodeSelection))
expect(editor.view.state.selection.toJSON()).toMatchInlineSnapshot(`
{
"anchor": 5,
"type": "node",
}
`)
editor.press('Enter')
expect(editor.state).toEqualRemirrorState(markdown`
# h1
1. ***
2. <cursor>\n
`)
})
})
@@ -0,0 +1,179 @@
import { chainCommands } from "@tiptap/pm/commands";
import { isTextSelection } from "@tiptap/core";
import { canSplit } from "@tiptap/pm/transform";
import {
type NodeSelection,
type Command,
type EditorState,
Selection,
TextSelection,
type Transaction,
} from "@tiptap/pm/state";
import { NodeType, Attrs, Mark, Fragment, type Node as ProsemirrorNode, Slice } from "@tiptap/pm/model";
import { ListAttributes, isListNode } from "prosemirror-flat-list";
/**
* Returns a command that split the current list node.
*
* @public @group Commands
*
*/
export function createSplitListCommand(): Command {
return chainCommands(splitBlockNodeSelectionInListCommand, splitListCommand);
}
function deriveListAttributes(listNode: ProsemirrorNode): ListAttributes {
// For the new list node, we don't want to inherit any list attribute (For example: `checked`) other than `kind`
return { kind: (listNode.attrs as ListAttributes).kind };
}
const splitBlockNodeSelectionInListCommand: Command = (state, dispatch) => {
if (!isBlockNodeSelection(state.selection)) {
return false;
}
const selection = state.selection;
const { $to, node } = selection;
const parent = $to.parent;
// We only cover the case that
// 1. the list node only contains one child node
// 2. this child node is not a list node
if (isListNode(node) || !isListNode(parent) || parent.childCount !== 1 || parent.firstChild !== node) {
return false;
}
const listType = parent.type;
const nextList = listType.createAndFill(deriveListAttributes(parent));
if (!nextList) {
return false;
}
if (dispatch) {
const tr = state.tr;
const cutPoint = $to.pos;
tr.replace(cutPoint, cutPoint, new Slice(Fragment.fromArray([listType.create(), nextList]), 1, 1));
const newSelection = TextSelection.near(tr.doc.resolve(cutPoint));
if (isTextSelection(newSelection)) {
tr.setSelection(newSelection);
dispatch(tr);
}
}
return true;
};
const splitListCommand: Command = (state, dispatch): boolean => {
if (isBlockNodeSelection(state.selection)) {
return false;
}
console.log("aaya 2");
const { $from, $to } = state.selection;
if (!$from.sameParent($to)) {
return false;
}
if ($from.depth < 2) {
return false;
}
const listDepth = $from.depth - 1;
const listNode = $from.node(listDepth);
if (!isListNode(listNode)) {
return false;
}
return doSplitList(state, listNode, dispatch);
};
/**
* @internal
*/
export function doSplitList(
state: EditorState,
listNode: ProsemirrorNode,
dispatch?: (tr: Transaction) => void
): boolean {
const tr = state.tr;
const listType = listNode.type;
const attrs: ListAttributes = listNode.attrs;
const newAttrs: ListAttributes = deriveListAttributes(listNode);
tr.delete(tr.selection.from, tr.selection.to);
const { $from, $to } = tr.selection;
const { parentOffset } = $to;
const atStart = parentOffset == 0 && $from.index($from.depth - 1) === 0;
const atEnd = parentOffset == $to.parent.content.size;
const currentNode = $from.node($from.depth);
// // __AUTO_GENERATED_PRINT_VAR_START__
// console.log("doSplitList currentNode: %s", currentNode.ty); // __AUTO_GENERATED_PRINT_VAR_END__
if (currentNode.type.name !== "paragraph") {
console.log("ran fasle");
return false;
}
// is at start and not the second child of a list
if (atStart) {
if (dispatch) {
const pos = $from.before(-1);
tr.insert(pos, createAndFill(listType, newAttrs));
dispatch(tr.scrollIntoView());
}
return true;
}
if (atEnd && attrs.collapsed) {
if (dispatch) {
const pos = $from.after(-1);
tr.insert(pos, createAndFill(listType, newAttrs));
tr.setSelection(Selection.near(tr.doc.resolve(pos)));
dispatch(tr.scrollIntoView());
}
return true;
}
// If split the list at the start or at the middle, we want to inherit the
// current parent type (e.g. heading); otherwise, we want to create a new
// default block type (typically paragraph)
const nextType = atEnd ? listNode.contentMatchAt(0).defaultType : undefined;
const typesAfter = [{ type: listType, attrs: newAttrs }, nextType ? { type: nextType } : null];
if (!canSplit(tr.doc, $from.pos, 2, typesAfter)) {
return false;
}
dispatch?.(tr.split($from.pos, 2, typesAfter).scrollIntoView());
return true;
}
export function createAndFill(
type: NodeType,
attrs?: Attrs | null,
content?: Fragment | ProsemirrorNode | readonly ProsemirrorNode[] | null,
marks?: readonly Mark[]
) {
const node = type.createAndFill(attrs, content, marks);
if (!node) {
throw new RangeError(`Failed to create '${type.name}' node`);
}
node.check();
return node;
}
export function isBlockNodeSelection(selection: Selection): selection is NodeSelection {
const isNodeSelectionBool = isNodeSelection(selection) && selection.node.type.isBlock;
return isNodeSelectionBool;
}
export function isNodeSelection(selection: Selection): selection is NodeSelection {
return Boolean((selection as NodeSelection).node);
}
@@ -0,0 +1,63 @@
import { describe, it } from 'vitest'
import { setupTestingEditor } from '../../test/setup-editor'
import { createToggleCollapsedCommand } from './toggle-collapsed'
describe('toggleCollapsed', () => {
const t = setupTestingEditor()
it('can toggle collapsed attribute', () => {
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
})
it('can set collapsed value', () => {
t.applyCommand(
createToggleCollapsedCommand({ collapsed: true }),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand({ collapsed: true }),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand({ collapsed: false }),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
t.applyCommand(
createToggleCollapsedCommand({ collapsed: false }),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'), t.p('A1'))),
)
})
it('can skip non-collapsed node', () => {
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.expandedToggleList(t.p('A1<cursor>'))),
t.doc(t.expandedToggleList(t.p('A1<cursor>'))),
)
t.applyCommand(
createToggleCollapsedCommand(),
t.doc(t.expandedToggleList(t.p('A1'), t.orderedList(t.p('B1<cursor>')))),
t.doc(t.collapsedToggleList(t.p('A1<cursor>'), t.orderedList(t.p('B1')))),
)
})
})

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