Compare commits

..

6 Commits

Author SHA1 Message Date
Palanikannan M 8303acb723 fix: loading of images 2024-09-23 19:01:15 +05:30
Palanikannan M c18c1a47dd fix: handling file being uploaded at 2 places 2024-09-23 18:58:26 +05:30
Palanikannan M f8aca6a574 fix: image block uploading with preview images 2024-09-23 18:46:14 +05:30
Palanikannan M 295fba6a0d fix: minor fixes 2024-09-19 14:22:38 +05:30
Palanikannan M d94046e69a fix: added preview image for upload 2024-09-18 20:55:48 +05:30
Aaryan Khandelwal 432be1a506 fix: image resize event listeners 2024-09-17 18:38:15 +05:30
351 changed files with 5550 additions and 7319 deletions
+59
View File
@@ -0,0 +1,59 @@
/**
* Adds three new lint plugins over the existing configuration:
* This is used to lint staged files only.
* We should remove this file once the entire codebase follows these rules.
*/
module.exports = {
root: true,
extends: [
"custom",
],
parser: "@typescript-eslint/parser",
settings: {
"import/resolver": {
typescript: {},
node: {
moduleDirectory: ["node_modules", "."],
},
},
},
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
};
+10
View File
@@ -0,0 +1,10 @@
module.exports = {
root: true,
// This tells ESLint to load the config from the package `eslint-config-custom`
extends: ["custom"],
settings: {
next: {
rootDir: ["web/", "space/", "admin/"],
},
},
};
View File
+3
View File
@@ -0,0 +1,3 @@
{
"*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"]
}
+35 -30
View File
@@ -1,39 +1,44 @@
# Security policy
This document outlines the security protocols and vulnerability reporting guidelines for the Plane project. Ensuring the security of our systems is a top priority, and while we work diligently to maintain robust protection, vulnerabilities may still occur. We highly value the communitys role in identifying and reporting security concerns to uphold the integrity of our systems and safeguard our users.
# Security Policy
## Reporting a vulnerability
If you have identified a security vulnerability, submit your findings to [security@plane.so](mailto:security@plane.so).
Ensure your report includes all relevant information needed for us to reproduce and assess the issue. Include the IP address or URL of the affected system.
This document outlines security procedures and vulnerabilities reporting for the Plane project.
To ensure a responsible and effective disclosure process, please adhere to the following:
At Plane, we safeguarding the security of our systems with top priority. Despite our efforts, vulnerabilities may still exist. We greatly appreciate your assistance in identifying and reporting any such vulnerabilities to help us maintain the integrity of our systems and protect our clients.
- Maintain confidentiality and refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address the issue.
- Refrain from running automated vulnerability scans on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
- Do not exploit any discovered vulnerabilities for malicious purposes, such as accessing or altering user data.
- Do not engage in physical security attacks, social engineering, distributed denial of service (DDoS) attacks, spam campaigns, or attacks on third-party applications as part of your vulnerability testing.
To report a security vulnerability, please email us directly at security@plane.so with a detailed description of the vulnerability and steps to reproduce it. Please refrain from disclosing the vulnerability publicly until we have had an opportunity to review and address it.
## Out of scope
While we appreciate all efforts to assist in improving our security, please note that the following types of vulnerabilities are considered out of scope:
## Out of Scope Vulnerabilities
- Vulnerabilities requiring man-in-the-middle (MITM) attacks or physical access to a users device.
- Content spoofing or text injection issues without a clear attack vector or the ability to modify HTML/CSS.
- Issues related to email spoofing.
- Missing DNSSEC, CAA, or CSP headers.
- Absence of secure or HTTP-only flags on non-sensitive cookies.
We appreciate your help in identifying vulnerabilities. However, please note that the following types of vulnerabilities are considered out of scope:
## Our commitment
- Attacks requiring MITM or physical access to a user's device.
- Content spoofing and text injection issues without demonstrating an attack vector or ability to modify HTML/CSS.
- Email spoofing.
- Missing DNSSEC, CAA, CSP headers.
- Lack of Secure or HTTP only flag on non-sensitive cookies.
At Plane, we are committed to maintaining transparent and collaborative communication throughout the vulnerability resolution process. Here's what you can expect from us:
## Reporting Process
- **Response Time** <br/>
We will acknowledge receipt of your vulnerability report within three business days and provide an estimated timeline for resolution.
- **Legal Protection** <br/>
We will not initiate legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality** <br/>
Your report will be treated with confidentiality. We will not disclose your personal information to third parties without your consent.
- **Recognition** <br/>
With your permission, we are happy to publicly acknowledge your contribution to improving our security once the issue is resolved.
- **Timely Resolution** <br/>
We are committed to working closely with you throughout the resolution process, providing timely updates as necessary. Our goal is to address all reported vulnerabilities swiftly, and we will actively engage with you to coordinate a responsible disclosure once the issue is fully resolved.
If you discover a vulnerability, please adhere to the following reporting process:
We appreciate your help in ensuring the security of our platform. Your contributions are crucial to protecting our users and maintaining a secure environment. Thank you for working with us to keep Plane safe.
1. Email your findings to security@plane.so.
2. Refrain from running automated scanners on our infrastructure or dashboard without prior consent. Contact us to set up a sandbox environment if necessary.
3. Do not exploit the vulnerability for malicious purposes, such as downloading excessive data or altering user data.
4. Maintain confidentiality and refrain from disclosing the vulnerability until it has been resolved.
5. Avoid using physical security attacks, social engineering, distributed denial of service, spam, or third-party applications.
When reporting a vulnerability, please provide sufficient information to allow us to reproduce and address the issue promptly. Include the IP address or URL of the affected system, along with a detailed description of the vulnerability.
## Our Commitment
We are committed to promptly addressing reported vulnerabilities and maintaining open communication throughout the resolution process. Here's what you can expect from us:
- **Response Time:** We will acknowledge receipt of your report within three business days and provide an expected resolution date.
- **Legal Protection:** We will not pursue legal action against you for reporting vulnerabilities, provided you adhere to the reporting guidelines.
- **Confidentiality:** Your report will be treated with strict confidentiality. We will not disclose your personal information to third parties without your consent.
- **Progress Updates:** We will keep you informed of our progress in resolving the reported vulnerability.
- **Recognition:** With your permission, we will publicly acknowledge you as the discoverer of the vulnerability.
- **Timely Resolution:** We strive to resolve all reported vulnerabilities promptly and will actively participate in the publication process once the issue is resolved.
We appreciate your cooperation in helping us maintain the security of our systems and protecting our clients. Thank you for your contributions to our security efforts.
reference: https://supabase.com/.well-known/security.txt
+48 -4
View File
@@ -1,8 +1,52 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/next.js"],
extends: ["custom"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
settings: {
"import/resolver": {
typescript: {},
node: {
moduleDirectory: ["node_modules", "."],
},
},
},
};
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling",],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
}
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
}
+3 -1
View File
@@ -9,7 +9,9 @@ import { HelpSection, SidebarMenu, SidebarDropdown } from "@/components/admin-si
// hooks
import { useTheme } from "@/hooks/store";
export const InstanceSidebar: FC = observer(() => {
export interface IInstanceSidebar {}
export const InstanceSidebar: FC<IInstanceSidebar> = observer(() => {
// store
const { isSidebarCollapsed, toggleSidebar } = useTheme();
@@ -7,7 +7,11 @@ import { Button } from "@plane/ui";
import InstanceFailureDarkImage from "@/public/instance/instance-failure-dark.svg";
import InstanceFailureImage from "@/public/instance/instance-failure.svg";
export const InstanceFailureView: FC = () => {
type InstanceFailureViewProps = {
// mutate: () => void;
};
export const InstanceFailureView: FC<InstanceFailureViewProps> = () => {
const { resolvedTheme } = useTheme();
const instanceImage = resolvedTheme === "dark" ? InstanceFailureDarkImage : InstanceFailureImage;
+1 -1
View File
@@ -1,5 +1,5 @@
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
import { API_BASE_URL } from "helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+2 -2
View File
@@ -1,7 +1,7 @@
// helpers
import { API_BASE_URL } from "helpers/common.helper";
// types
import type { IUser } from "@plane/types";
// helpers
import { API_BASE_URL } from "@/helpers/common.helper";
// services
import { APIService } from "@/services/api.service";
+5 -7
View File
@@ -8,8 +8,7 @@
"build": "next build",
"preview": "next build && next start",
"start": "next start",
"lint": "eslint . --ext .ts,.tsx",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
"lint": "next lint"
},
"dependencies": {
"@headlessui/react": "^1.7.19",
@@ -17,7 +16,6 @@
"@plane/helpers": "*",
"@plane/types": "*",
"@plane/ui": "*",
"@sentry/nextjs": "^8.32.0",
"@tailwindcss/typography": "^0.5.9",
"@types/lodash": "^4.17.0",
"autoprefixer": "10.4.14",
@@ -27,7 +25,7 @@
"lucide-react": "^0.356.0",
"mobx": "^6.12.0",
"mobx-react": "^9.1.1",
"next": "^14.2.12",
"next": "^14.2.3",
"next-themes": "^0.2.1",
"postcss": "^8.4.38",
"react": "^18.3.1",
@@ -39,15 +37,15 @@
"zxcvbn": "^4.4.2"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/js-cookie": "^3.0.6",
"@types/node": "18.16.1",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.8",
"@types/zxcvbn": "^4.4.4",
"eslint-config-custom": "*",
"tailwind-config-custom": "*",
"typescript": "5.3.3"
"tsconfig": "*",
"typescript": "5.4.5"
}
}
+12 -6
View File
@@ -1,15 +1,21 @@
{
"extends": "@plane/typescript-config/nextjs.json",
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"],
"compilerOptions": {
"plugins": [{ "name": "next" }],
"baseUrl": ".",
"jsx": "preserve",
"esModuleInterop": true,
"paths": {
"@/*": ["core/*"],
"@/helpers/*": ["helpers/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"]
}
},
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
},
"plugins": [
{
"name": "next"
}
]
}
}
+3 -8
View File
@@ -301,16 +301,11 @@ class ProjectAPIEndpoint(BaseAPIView):
if serializer.is_valid():
serializer.save()
if serializer.data["inbox_view"]:
inbox = Inbox.objects.filter(
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
).first()
if not inbox:
Inbox.objects.create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
)
# Create the triage state in Backlog group
State.objects.get_or_create(
+2 -8
View File
@@ -20,7 +20,6 @@ from plane.app.views import (
IssueViewSet,
LabelViewSet,
BulkArchiveIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
)
@@ -40,9 +39,9 @@ urlpatterns = [
),
name="project-issue",
),
# updated v2 paginated issues
# updated v1 paginated issues
path(
"workspaces/<str:slug>/v2/issues/",
"workspaces/<str:slug>/projects/<uuid:project_id>/v2/issues/",
IssuePaginatedViewSet.as_view({"get": "list"}),
name="project-issues-paginated",
),
@@ -312,9 +311,4 @@ urlpatterns = [
),
name="project-issue-draft",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/deleted-issues/",
DeletedIssuesListViewSet.as_view(),
name="deleted-issues",
),
]
-1
View File
@@ -114,7 +114,6 @@ from .issue.base import (
IssueViewSet,
IssueUserDisplayPropertyEndpoint,
BulkDeleteIssuesEndpoint,
DeletedIssuesListViewSet,
IssuePaginatedViewSet,
)
+5 -5
View File
@@ -167,10 +167,10 @@ class InboxIssueViewSet(BaseViewSet):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
inbox_id = Inbox.objects.filter(
inbox_id = Inbox.objects.get(
workspace__slug=slug, project_id=project_id
).first()
project = Project.objects.get(pk=project_id)
)
project = Project.objects.get(pk=project_id)
filters = issue_filters(request.GET, "GET", "issue__")
inbox_issue = (
InboxIssue.objects.filter(
@@ -527,9 +527,9 @@ class InboxIssueViewSet(BaseViewSet):
model=Issue,
)
def retrieve(self, request, slug, project_id, pk):
inbox_id = Inbox.objects.filter(
inbox_id = Inbox.objects.get(
workspace__slug=slug, project_id=project_id
).first()
)
project = Project.objects.get(pk=project_id)
inbox_issue = (
InboxIssue.objects.select_related("issue")
+14 -50
View File
@@ -234,17 +234,11 @@ class IssueViewSet(BaseViewSet):
@method_decorator(gzip_page)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
extra_filters = {}
if request.GET.get("updated_at__gt", None) is not None:
extra_filters = {
"updated_at__gt": request.GET.get("updated_at__gt")
}
project = Project.objects.get(pk=project_id, workspace__slug=slug)
filters = issue_filters(request.query_params, "GET")
order_by_param = request.GET.get("order_by", "-created_at")
issue_queryset = self.get_queryset().filter(**filters, **extra_filters)
issue_queryset = self.get_queryset().filter(**filters)
# Custom ordering for priority and state
# Issue queryset
@@ -719,43 +713,16 @@ class BulkDeleteIssuesEndpoint(BaseAPIView):
)
class DeletedIssuesListViewSet(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def get(self, request, slug, project_id):
filters = {}
if request.GET.get("updated_at__gt", None) is not None:
filters = {"updated_at__gt": request.GET.get("updated_at__gt")}
deleted_issues = (
Issue.all_objects.filter(
workspace__slug=slug,
project_id=project_id,
)
.filter(Q(archived_at__isnull=False) | Q(deleted_at__isnull=False))
.filter(**filters)
.values_list("id", flat=True)
)
return Response(deleted_issues, status=status.HTTP_200_OK)
class IssuePaginatedViewSet(BaseViewSet):
def get_queryset(self):
workspace_slug = self.kwargs.get("slug")
# getting the project_id from the request params
project_id = self.request.GET.get("project_id", None)
issue_queryset = Issue.issue_objects.filter(
workspace__slug=workspace_slug
)
if project_id:
issue_queryset = issue_queryset.filter(project_id=project_id)
project_id = self.kwargs.get("project_id")
return (
issue_queryset.select_related(
"workspace", "project", "state", "parent"
Issue.issue_objects.filter(
workspace__slug=workspace_slug, project_id=project_id
)
.select_related("workspace", "project", "state", "parent")
.prefetch_related("assignees", "labels", "issue_module__module")
.annotate(cycle_id=F("issue_cycle__cycle_id"))
.annotate(
@@ -793,18 +760,17 @@ class IssuePaginatedViewSet(BaseViewSet):
return paginated_data
def list(self, request, slug):
project_id = self.request.GET.get("project_id", None)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def list(self, request, slug, project_id):
cursor = request.GET.get("cursor", None)
is_description_required = request.GET.get("description", False)
updated_at = request.GET.get("updated_at__gt", None)
updated_at = request.GET.get("updated_at__gte", None)
# required fields
required_fields = [
"id",
"name",
"state_id",
"state__group",
"sort_order",
"completed_at",
"estimate_point",
@@ -821,6 +787,7 @@ class IssuePaginatedViewSet(BaseViewSet):
"updated_by",
"is_draft",
"archived_at",
"deleted_at",
"module_ids",
"label_ids",
"assignee_ids",
@@ -833,18 +800,15 @@ class IssuePaginatedViewSet(BaseViewSet):
required_fields.append("description_html")
# querying issues
base_queryset = Issue.issue_objects.filter(workspace__slug=slug)
if project_id:
base_queryset = base_queryset.filter(project_id=project_id)
base_queryset = base_queryset.order_by("updated_at")
base_queryset = Issue.issue_objects.filter(
workspace__slug=slug, project_id=project_id
).order_by("updated_at")
queryset = self.get_queryset().order_by("updated_at")
# filtering issues by greater then updated_at given by the user
if updated_at:
base_queryset = base_queryset.filter(updated_at__gt=updated_at)
queryset = queryset.filter(updated_at__gt=updated_at)
base_queryset = base_queryset.filter(updated_at__gte=updated_at)
queryset = queryset.filter(updated_at__gte=updated_at)
queryset = queryset.annotate(
label_ids=Coalesce(
+1 -1
View File
@@ -267,7 +267,7 @@ class IssueRelationViewSet(BaseViewSet):
IssueRelationSerializer(issue_relation).data,
cls=DjangoJSONEncoder,
)
issue_relation.delete(soft=False)
issue_relation.delete()
issue_activity.delay(
type="issue_relation.activity.deleted",
requested_data=json.dumps(request.data, cls=DjangoJSONEncoder),
@@ -41,7 +41,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST],
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER],
level="WORKSPACE",
)
def list(self, request, slug):
@@ -207,7 +207,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def mark_unread(self, request, slug, pk):
notification = Notification.objects.get(
@@ -219,7 +219,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def archive(self, request, slug, pk):
notification = Notification.objects.get(
@@ -231,7 +231,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def unarchive(self, request, slug, pk):
notification = Notification.objects.get(
@@ -286,7 +286,7 @@ class UnreadNotificationEndpoint(BaseAPIView):
class MarkAllReadNotificationViewSet(BaseViewSet):
@allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE"
)
def create(self, request, slug):
snoozed = request.data.get("snoozed", False)
+4 -9
View File
@@ -173,7 +173,7 @@ class ProjectViewSet(BaseViewSet):
member=request.user,
workspace__slug=slug,
is_active=True,
role=15,
role=10,
).exists():
projects = projects.filter(
Q(
@@ -438,16 +438,11 @@ class ProjectViewSet(BaseViewSet):
if serializer.is_valid():
serializer.save()
if serializer.data["inbox_view"]:
inbox = Inbox.objects.filter(
Inbox.objects.get_or_create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
).first()
if not inbox:
Inbox.objects.create(
name=f"{project.name} Inbox",
project=project,
is_default=True,
)
)
# Create the triage state in Backlog group
State.objects.get_or_create(
@@ -414,7 +414,6 @@ class UserProjectRolesEndpoint(BaseAPIView):
project_members = ProjectMember.objects.filter(
workspace__slug=slug,
member_id=request.user.id,
is_active=True,
).values("project_id", "role")
project_members = {
+1 -1
View File
@@ -90,7 +90,7 @@ class GlobalSearchEndpoint(BaseAPIView):
"project__identifier",
"project_id",
"workspace__slug",
)[:100]
)
def filter_cycles(self, query, slug, project_id, workspace_search):
fields = ["name"]
+1 -1
View File
@@ -97,6 +97,6 @@ class IssueSearchEndpoint(BaseAPIView):
"state__name",
"state__group",
"state__color",
)[:100],
),
status=status.HTTP_200_OK,
)
+20 -33
View File
@@ -347,7 +347,7 @@ def create_issues(workspace, project, user_id, issue_count):
)
)
text = fake.text(max_nb_chars=3000)
text = fake.text(max_nb_chars=60000)
issues.append(
Issue(
state_id=states[random.randint(0, len(states) - 1)],
@@ -490,23 +490,18 @@ def create_issue_assignees(workspace, project, user_id, issue_count):
def create_issue_labels(workspace, project, user_id, issue_count):
# labels
labels = Label.objects.filter(project=project).values_list("id", flat=True)
# issues = random.sample(
# list(
# Issue.objects.filter(project=project).values_list("id", flat=True)
# ),
# int(issue_count / 2),
# )
issues = list(
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
)
shuffled_labels = list(labels)
),
int(issue_count / 2),
)
# Bulk issue
bulk_issue_labels = []
for issue in issues:
random.shuffle(shuffled_labels)
for label in random.sample(
shuffled_labels, random.randint(0, 5)
list(labels), random.randint(0, len(labels) - 1)
):
bulk_issue_labels.append(
IssueLabel(
@@ -557,33 +552,25 @@ def create_module_issues(workspace, project, user_id, issue_count):
modules = Module.objects.filter(project=project).values_list(
"id", flat=True
)
# issues = random.sample(
# list(
# Issue.objects.filter(project=project).values_list("id", flat=True)
# ),
# int(issue_count / 2),
# )
issues = list(
issues = random.sample(
list(
Issue.objects.filter(project=project).values_list("id", flat=True)
)
shuffled_modules = list(modules)
),
int(issue_count / 2),
)
# Bulk issue
bulk_module_issues = []
for issue in issues:
random.shuffle(shuffled_modules)
for module in random.sample(
shuffled_modules, random.randint(0, 5)
):
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
)
module = modules[random.randint(0, len(modules) - 1)]
bulk_module_issues.append(
ModuleIssue(
module_id=module,
issue_id=issue,
project=project,
workspace=workspace,
)
)
# Issue assignees
ModuleIssue.objects.bulk_create(
bulk_module_issues, batch_size=1000, ignore_conflicts=True
@@ -73,7 +73,7 @@ class Command(BaseCommand):
from plane.bgtasks.dummy_data_task import create_dummy_data
create_dummy_data(
create_dummy_data.delay(
slug=workspace_slug,
email=creator,
members=members,
@@ -1,6 +1,3 @@
# python imports
from math import ceil
# constants
PAGINATOR_MAX_LIMIT = 1000
@@ -39,9 +36,6 @@ def paginate(base_queryset, queryset, cursor, on_result):
total_results = base_queryset.count()
page_size = min(cursor_object.current_page_size, PAGINATOR_MAX_LIMIT)
# getting the total pages available based on the page size
total_pages = ceil(total_results / page_size)
# Calculate the start and end index for the paginated data
start_index = 0
if cursor_object.current_page > 0:
@@ -78,7 +72,6 @@ def paginate(base_queryset, queryset, cursor, on_result):
"next_page_results": next_page_results,
"page_count": len(paginated_data),
"total_results": total_results,
"total_pages": total_pages,
"results": paginated_data,
}
+2 -2
View File
@@ -30,9 +30,9 @@ def order_issue_queryset(issue_queryset, order_by_param="-created_at"):
)
).order_by("priority_order")
order_by_param = (
"priority_order"
"-priority_order"
if order_by_param.startswith("-")
else "-priority_order"
else "priority_order"
)
# State Ordering
elif order_by_param in [
+5 -5
View File
@@ -82,7 +82,7 @@ class CursorResult(Sequence):
return f"<{type(self).__name__}: results={len(self.results)}>"
MAX_LIMIT = 1000
MAX_LIMIT = 100
class BadPaginationError(Exception):
@@ -118,7 +118,7 @@ class OffsetPaginator:
self.max_offset = max_offset
self.on_results = on_results
def get_result(self, limit=1000, cursor=None):
def get_result(self, limit=100, cursor=None):
# offset is page #
# value is page limit
if cursor is None:
@@ -727,7 +727,7 @@ class BasePaginator:
cursor_name = "cursor"
# get the per page parameter from request
def get_per_page(self, request, default_per_page=1000, max_per_page=1000):
def get_per_page(self, request, default_per_page=100, max_per_page=100):
try:
per_page = int(request.GET.get("per_page", default_per_page))
except ValueError:
@@ -747,8 +747,8 @@ class BasePaginator:
on_results=None,
paginator=None,
paginator_cls=OffsetPaginator,
default_per_page=1000,
max_per_page=1000,
default_per_page=100,
max_per_page=100,
cursor_cls=Cursor,
extra_stats=None,
controller=None,
+2 -2
View File
@@ -280,7 +280,7 @@ function download() {
function startServices() {
/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --pull if_not_present --quiet-pull"
local migrator_container_id=$(docker ps --format "{{.ID}} {{.Names}}" | grep -E "${SERVICE_FOLDER}(-migrator|_migrator)" | awk '{print $1}')
local migrator_container_id=$(docker container ls -aq -f "name=$SERVICE_FOLDER-migrator")
if [ -n "$migrator_container_id" ]; then
local idx=0
while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do
@@ -308,7 +308,7 @@ function startServices() {
fi
fi
local api_container_id=$(docker ps --format "{{.ID}} {{.Names}}" | grep -E "${SERVICE_FOLDER}(-api|_api)" | awk '{print $1}')
local api_container_id=$(docker container ls -q -f "name=$SERVICE_FOLDER-api")
local idx2=0
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
do
+2 -3
View File
@@ -1,8 +1,7 @@
API_BASE_URL="http://api:8000"
LIVE_BASE_PATH="/live"
REDIS_URL="redis://plane-redis:6379/"
REDIS_URL="redis://localhost:6379"
# If you prefer not to provide a Redis URL, you can set the REDIS_HOST and REDIS_PORT environment variables instead.
REDIS_PORT=6379
REDIS_HOST=plane-redis
REDIS_HOST=localhost
-4
View File
@@ -1,4 +0,0 @@
.turbo/*
out/*
dist/*
public/*
-8
View File
@@ -1,8 +0,0 @@
{
"root": true,
"extends": ["@plane/eslint-config/server.js"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": true
}
}
+3 -8
View File
@@ -7,10 +7,7 @@
"type": "module",
"scripts": {
"build": "babel src --out-dir dist --extensions \".ts,.js\"",
"start": "node dist/server.js",
"lint": "eslint . --ext .ts,.tsx",
"dev": "concurrently \"babel src --out-dir dist --extensions '.ts,.js' --watch\" \"nodemon dist/server.js\"",
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
"start": "node dist/server.js"
},
"keywords": [],
"author": "",
@@ -38,7 +35,6 @@
"morgan": "^1.10.0",
"pino-http": "^10.3.0",
"pino-pretty": "^11.2.2",
"uuid": "^10.0.0",
"y-prosemirror": "^1.2.9",
"y-protocols": "^1.0.6",
"yjs": "^13.6.14"
@@ -55,10 +51,9 @@
"@types/express-ws": "^3.0.4",
"@types/node": "^20.14.9",
"babel-plugin-module-resolver": "^5.0.2",
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"nodemon": "^3.1.0",
"ts-node": "^10.9.2",
"tsup": "^7.2.0",
"typescript": "5.3.3"
"typescript": "5.4.5"
}
}
+2 -1
View File
@@ -1 +1,2 @@
export type TAdditionalDocumentTypes = {};
// eslint-disable-next-line @typescript-eslint/ban-types
export type TAdditionalDocumentTypes = {}
+5 -8
View File
@@ -45,8 +45,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
const documentType = params.get("documentType")?.toString() as
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
try {
let fetchedData = null;
@@ -54,7 +53,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
fetchedData = await fetchPageDescriptionBinary(
params,
pageId,
cookie
cookie,
);
} else {
fetchedData = await fetchDocument({
@@ -84,8 +83,6 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
| TDocumentTypes
| undefined;
// TODO: Fix this lint error.
// eslint-disable-next-line no-async-promise-executor
return new Promise(async () => {
try {
if (documentType === "project_page") {
@@ -124,7 +121,7 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
}
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error
error,
);
reject(error);
});
@@ -138,12 +135,12 @@ export const getExtensions: () => Promise<Extension[]> = async () => {
} catch (error) {
manualLogger.warn(
`Redis Client wasn't able to connect, continuing without Redis (you won't be able to sync data between multiple plane live servers)`,
error
error,
);
}
} else {
manualLogger.warn(
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)"
"Redis URL is not set, continuing without Redis (you won't be able to sync data between multiple plane live servers)",
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { ErrorRequestHandler } from "express";
import { manualLogger } from "@/core/helpers/logger.js";
export const errorHandler: ErrorRequestHandler = (err, _req, res) => {
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
// Log the error
manualLogger.error(err);
+1 -6
View File
@@ -1,15 +1,11 @@
import { Server } from "@hocuspocus/server";
import { v4 as uuidv4 } from "uuid";
// lib
import { handleAuthentication } from "@/core/lib/authentication.js";
// extensions
import { getExtensions } from "@/core/extensions/index.js";
export const getHocusPocusServer = async () => {
const extensions = await getExtensions();
const serverName = process.env.HOSTNAME || uuidv4();
return Server.configure({
name: serverName,
onAuthenticate: async ({
requestHeaders,
requestParameters,
@@ -38,6 +34,5 @@ export const getHocusPocusServer = async () => {
}
},
extensions,
debounce: 10000
});
};
+24 -7
View File
@@ -1,26 +1,43 @@
{
"extends": "@plane/typescript-config/base.json",
"extends": "tsconfig/base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2015"],
"lib": [
"ES2015"
],
"outDir": "./dist",
"rootDir": ".",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/plane-live/*": ["./src/ce/*"]
"@/*": [
"./src/*"
],
"@/plane-live/*": [
"./src/ce/*"
]
},
"removeComments": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"inlineSources": true,
// Set `sourceRoot` to "/" to strip the build path prefix
// from generated source code references.
// This improves issue grouping in Sentry.
"sourceRoot": "/"
},
"include": ["src/**/*.ts", "tsup.config.ts"],
"exclude": ["./dist", "./build", "./node_modules"]
"include": [
"src/**/*.ts"
],
"exclude": [
"./dist",
"./build",
"./node_modules"
]
}
+1 -1
View File
@@ -57,7 +57,7 @@ http {
proxy_set_header Upgrade ${dollar}http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host ${dollar}http_host;
proxy_pass http://live:3000/live/;
proxy_pass http://live:3000/;
}
location /spaces/ {
+17 -3
View File
@@ -8,20 +8,34 @@
"space",
"admin",
"live",
"packages/*"
"packages/editor",
"packages/eslint-config-custom",
"packages/tailwind-config-custom",
"packages/tsconfig",
"packages/ui",
"packages/types",
"packages/constants",
"packages/helpers"
],
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev --concurrency=13",
"start": "turbo run start",
"lint": "turbo run lint",
"lint:errors": "turbo run lint:errors",
"clean": "turbo run clean",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"prepare": "husky"
},
"devDependencies": {
"autoprefixer": "^10.4.15",
"eslint-config-custom": "*",
"eslint-plugin-prettier": "^5.1.3",
"husky": "^9.0.11",
"lint-staged": "^15.2.2",
"postcss": "^8.4.29",
"prettier": "latest",
"prettier-plugin-tailwindcss": "^0.5.4",
"tailwindcss": "^3.3.3",
"turbo": "^2.1.1"
},
"resolutions": {
-13
View File
@@ -13,19 +13,6 @@ export enum EIssueGroupByToServerOptions {
"created_by" = "created_by",
}
export enum EIssueGroupBYServerToProperty {
"state_id" = "state_id",
"priority" = "priority",
"labels__id" = "label_ids",
"state__group" = "state__group",
"assignees__id" = "assignee_ids",
"cycle_id" = "cycle_id",
"issue_module__module_id" = "module_ids",
"target_date" = "target_date",
"project_id" = "project_id",
"created_by" = "created_by",
}
export enum EServerGroupByToFilterOptions {
"state_id" = "state",
"priority" = "priority",
+34 -5
View File
@@ -1,9 +1,38 @@
module.exports = {
root: true,
extends: ["@plane/eslint-config/library.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
extends: ["custom"],
rules: {
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
rules: {},
};
+3 -5
View File
@@ -26,7 +26,6 @@
"build": "tsup --minify",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"format": "prettier --write \"**/*.{ts,tsx,md}\""
},
"peerDependencies": {
@@ -66,22 +65,21 @@
"tailwind-merge": "^1.14.0",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8.9",
"uuid": "^10.0.0",
"y-indexeddb": "^9.0.12",
"y-prosemirror": "^1.2.5",
"y-protocols": "^1.0.6",
"yjs": "^13.6.15"
},
"devDependencies": {
"@plane/eslint-config": "*",
"@plane/typescript-config": "*",
"@types/node": "18.15.3",
"@types/react": "^18.2.42",
"@types/react-dom": "^18.2.17",
"eslint-config-custom": "*",
"postcss": "^8.4.38",
"tailwind-config-custom": "*",
"tsconfig": "*",
"tsup": "^7.2.0",
"typescript": "5.3.3"
"typescript": "5.4.5"
},
"keywords": [
"editor",
@@ -1,6 +1,6 @@
import React from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
@@ -42,7 +42,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
const { editor } = useCollaborativeEditor({
disabledExtensions,
editorClassName,
embedHandler,
@@ -67,8 +67,6 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
return (
<PageRenderer
displayConfig={displayConfig}
@@ -1,6 +1,6 @@
import { forwardRef, MutableRefObject } from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
import { PageRenderer } from "@/components/editors";
// constants
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
// extensions
@@ -35,7 +35,7 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
);
}
const { editor, hasServerConnectionFailed, hasServerSynced } = useReadOnlyCollaborativeEditor({
const { editor } = useReadOnlyCollaborativeEditor({
editorClassName,
extensions,
forwardedRef,
@@ -52,9 +52,6 @@ const CollaborativeDocumentReadOnlyEditor = (props: ICollaborativeDocumentReadOn
});
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
return (
<PageRenderer
displayConfig={displayConfig}
@@ -1,5 +1,4 @@
export * from "./collaborative-editor";
export * from "./collaborative-read-only-editor";
export * from "./loader";
export * from "./page-renderer";
export * from "./read-only-editor";
@@ -1,42 +0,0 @@
// ui
import { Loader } from "@plane/ui";
export const DocumentContentLoader = () => (
<div className="size-full px-5">
<Loader className="relative space-y-4">
<div className="space-y-2">
<div className="py-2">
<Loader.Item width="100%" height="36px" />
</div>
<Loader.Item width="80%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="60%" height="36px" />
</div>
<Loader.Item width="70%" height="22px" />
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<Loader.Item width="30px" height="30px" />
<Loader.Item width="30%" height="22px" />
</div>
<div className="py-2">
<Loader.Item width="50%" height="30px" />
</div>
<Loader.Item width="100%" height="22px" />
<div className="py-2">
<Loader.Item width="30%" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
<div className="relative flex items-center gap-2">
<div className="py-2">
<Loader.Item width="30px" height="30px" />
</div>
<Loader.Item width="30%" height="22px" />
</div>
</div>
</Loader>
</div>
);
@@ -18,8 +18,7 @@ interface EditorContainerProps {
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { children, displayConfig, editor, editorContainerClassName, id } = props;
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (event.target !== event.currentTarget) return;
const handleContainerClick = () => {
if (!editor) return;
if (!editor.isEditable) return;
try {
@@ -23,6 +23,7 @@ export const AIFeaturesMenu: React.FC<Props> = (props) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -34,6 +34,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
@@ -23,7 +23,6 @@ import {
} from "lucide-react";
// helpers
import {
insertImage,
insertTableCommand,
setText,
toggleBlockquote,
@@ -193,8 +192,9 @@ export const ImageItem = (editor: Editor) =>
({
key: "image",
name: "Image",
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
command: (savedSelection: Selection | null) => insertImage({ editor, event: "insert", pos: savedSelection?.from }),
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) =>
editor?.commands.setImageUpload({ event: "insert", pos: savedSelection?.from }),
icon: ImageIcon,
}) as const;
@@ -117,18 +117,14 @@ export function LowlightPlugin({
// Such transactions can happen during collab syncing via y-prosemirror, for example.
transaction.steps.some(
(step) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
step.from !== undefined &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
step.to !== undefined &&
oldNodes.some(
(node) =>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
node.pos >= step.from &&
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
node.pos + node.node.nodeSize <= step.to
)
@@ -0,0 +1,338 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { Editor } from "@tiptap/core";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { ImageIcon } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
// Hooks
import { useFileUpload, useDropZone } from "@/hooks/use-file-upload";
// Plugins
import { isFileValid } from "@/plugins/image";
import { NodeSelection } from "@tiptap/pm/state";
import { UploadEntity } from "../custom-image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
type Pixel = `${number}px`;
export type ImageAttributes = {
src: string | null;
width: Pixel | "35%";
height: Pixel | "auto";
aspectRatio: number | null;
id: string | null;
};
export type CustomImageBlockProps = {
editor: Editor;
getPos: () => number;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
fileInputRef: RefType;
droppedFileBlob?: string;
uploadFile: (file: File) => Promise<void>;
isFileUploading: boolean;
initialEditorContainerWidth: number;
uploadEntity?: UploadEntity;
};
const MIN_SIZE = 100;
type Size = Omit<ImageAttributes, "src" | "id">;
export const CustomImageBlockNew = (props: CustomImageBlockProps) => {
const {
editor,
getPos,
node,
isFileUploading,
selected,
fileInputRef,
droppedFileBlob,
uploadFile,
initialEditorContainerWidth,
updateAttributes,
uploadEntity,
} = props;
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
uploader: uploadFile,
});
const { width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio } = node.attrs;
// refs
const imageRef = useRef<HTMLImageElement>(null);
const localRef = useRef<HTMLInputElement | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
// states
const [displayedSrc, setDisplayedSrc] = useState<string | null>(null);
const [size, setSize] = useState<Size>({
width: nodeWidth || "35%",
height: nodeHeight || "auto",
aspectRatio: null,
});
const [isResizing, setIsResizing] = useState(false);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
// for realtime updates of the width and height and initializing size
useEffect(() => {
setSize({ width: nodeWidth, height: nodeHeight, aspectRatio: nodeAspectRatio });
}, [nodeWidth, nodeHeight, nodeAspectRatio]);
// for loading uploaded files via file picker
const onFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file && isFileValid(file)) {
const reader = new FileReader();
reader.onload = () => {
setDisplayedSrc(reader.result as string);
};
reader.readAsDataURL(file);
uploadFile(file);
}
},
[uploadFile, editor.storage.image]
);
// for loading dropped files
useEffect(() => {
if (droppedFileBlob) {
setDisplayedSrc(droppedFileBlob);
}
}, [droppedFileBlob]);
// for loading remote images
useEffect(() => {
if (node.attrs.src) {
setDisplayedSrc(node.attrs.src);
}
}, [node.attrs.src]);
// on first load, set the aspect ratio and height of the image based on
// conditions
const handleImageLoad = useCallback(() => {
setInitialResizeComplete(false);
const img = imageRef.current;
if (!img) {
console.error("Image reference is undefined");
return;
}
let aspectRatio = nodeAspectRatio;
if (!aspectRatio) {
aspectRatio = img.naturalWidth / img.naturalHeight;
}
if (nodeWidth === "35%") {
// the initial width hasn't been set and has to be set to 35% of the editor container
const closestEditorContainer = img.closest(".editor-container");
let editorWidth = initialEditorContainerWidth;
if (closestEditorContainer) {
editorWidth = closestEditorContainer.clientWidth;
}
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio;
const newSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
aspectRatio: aspectRatio,
};
setSize(newSize);
updateAttributes(newSize);
} else {
// if the width has been set, we need to update the aspect ratio and height
const newHeight = Number(nodeWidth?.replace("px", "")) / aspectRatio;
setSize((prevSize) => ({ ...prevSize, aspectRatio, height: `${newHeight}px` }));
if (newHeight !== Number(nodeHeight?.replace("px", ""))) {
updateAttributes({ height: `${newHeight}px` });
}
if (nodeAspectRatio !== aspectRatio) {
updateAttributes({ aspectRatio });
}
}
setInitialResizeComplete(true);
}, [nodeWidth, updateAttributes, nodeAspectRatio, nodeHeight]);
// resizing lifecycle
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
const handleResize = useCallback(
(e: MouseEvent | TouchEvent) => {
if (!containerRef.current || !containerRect.current || !size.aspectRatio) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const newWidth = Math.max(clientX - containerRect.current.left, MIN_SIZE);
const newHeight = newWidth / size.aspectRatio;
setSize((prevSize) => ({ ...prevSize, width: `${newWidth}px`, height: `${newHeight}px` }));
},
[size]
);
const handleResizeEnd = useCallback(() => {
setIsResizing(false);
updateAttributes({ width: size.width, height: size.height });
}, [size, updateAttributes]);
// handle resizing
useEffect(() => {
if (isResizing) {
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", handleResizeEnd);
window.addEventListener("mouseleave", handleResizeEnd);
return () => {
window.removeEventListener("mousemove", handleResize);
window.removeEventListener("mouseup", handleResizeEnd);
window.removeEventListener("mouseleave", handleResizeEnd);
};
}
}, [isResizing, handleResize, handleResizeEnd]);
const handleImageMouseDown = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const pos = getPos();
const nodeSelection = NodeSelection.create(editor.state.doc, pos);
editor.view.dispatch(editor.state.tr.setSelection(nodeSelection));
},
[editor, getPos]
);
const isRemoteImageBeingUploaded = node.attrs.width === "35%" && node.attrs.height === "auto" && !node.attrs.src;
console.log(
"uploadEntity",
!displayedSrc,
!isRemoteImageBeingUploaded,
uploadEntity?.event == "insert",
!displayedSrc || (!isRemoteImageBeingUploaded && uploadEntity?.event === "insert")
);
return (
<>
<div
ref={containerRef}
className="group/image-component relative inline-block max-w-full"
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
aspectRatio: size.aspectRatio ?? undefined,
}}
>
{/* if the image hasn't completed it's initial resize but has a src, show a loading placeholder */}
{!initialResizeComplete && displayedSrc && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{
width: size.width === "35%" ? `${0.35 * initialEditorContainerWidth}px` : size.width,
height: size.height === "auto" ? "100px" : size.height,
}}
/>
)}
{/* if the image has a src, load the image and hide it until the initial resize is complete (while showing the above loader) */}
{displayedSrc && (
<img
ref={imageRef}
src={displayedSrc || ""}
onLoad={handleImageLoad}
className={cn("w-full h-auto rounded-md", {
hidden: !initialResizeComplete,
"blur-sm opacity-80": isFileUploading,
})}
style={{
width: size.width,
aspectRatio: size.aspectRatio ?? undefined,
}}
/>
)}
{/* resize handles to be shown only when the image is selected and is not being uploaded a file */}
{editor.isEditable && selected && !isFileUploading && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{editor.isEditable && !isFileUploading && (
<>
<div
className={cn(
"absolute inset-0 border-2 border-custom-primary-100 pointer-events-none rounded-md transition-opacity duration-100 ease-in-out",
{
"opacity-100": isResizing,
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
}
)}
/>
<div
className={cn(
"absolute bottom-0 right-0 translate-y-1/2 translate-x-1/2 size-4 rounded-full bg-custom-primary-100 border-2 border-white cursor-nwse-resize transition-opacity duration-100 ease-in-out",
{
"opacity-100 pointer-events-auto": isResizing,
"opacity-0 group-hover/image-component:opacity-100": !isResizing,
}
)}
onMouseDown={handleResizeStart}
/>
</>
)}
</div>
{/* if there is no src (remote or local), show the upload button */}
{(!displayedSrc || (isRemoteImageBeingUploaded && uploadEntity?.event === "insert")) && (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">{draggedInside ? "Drop image here" : "Add an image"}</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
)}
</>
);
};
@@ -1,96 +1,58 @@
import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from "react";
import { NodeSelection } from "@tiptap/pm/state";
// extensions
import { CustomImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
import { CustomImageNodeViewProps } from "@/extensions/custom-image";
// helpers
import { cn } from "@/helpers/common";
const MIN_SIZE = 100;
type Pixel = `${number}px`;
type PixelAttribute<TDefault> = Pixel | TDefault;
export type ImageAttributes = {
src: string | null;
width: PixelAttribute<"35%" | number>;
height: PixelAttribute<"auto" | number>;
aspectRatio: number | null;
id: string | null;
};
type Size = {
width: PixelAttribute<"35%">;
height: PixelAttribute<"auto">;
aspectRatio: number | null;
};
const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefined | null, defaultValue?: TDefault) => {
if (!value || value === defaultValue) {
return defaultValue;
export const CustomImageBlock: React.FC<
CustomImageNodeViewProps & {
isImageLoaded: boolean;
displayedSrc: string | null;
isLoading: boolean;
}
> = (props) => {
const { node, updateAttributes, selected, getPos, editor, displayedSrc, isImageLoaded } = props;
const { src, width, height } = node.attrs;
if (typeof value === "number") {
return `${value}px` satisfies Pixel;
}
return value;
};
type CustomImageBlockProps = CustomImageNodeViewProps & {
imageFromFileSystem: string;
setFailedToLoadImage: (isError: boolean) => void;
editorContainer: HTMLDivElement | null;
setEditorContainer: (editorContainer: HTMLDivElement | null) => void;
};
export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
// props
const {
node,
updateAttributes,
setFailedToLoadImage,
imageFromFileSystem,
selected,
getPos,
editor,
editorContainer,
setEditorContainer,
} = props;
const { src: remoteImageSrc, width, height, aspectRatio } = node.attrs;
// states
const [size, setSize] = useState<Size>({
width: ensurePixelString(width, "35%"),
height: ensurePixelString(height, "auto"),
aspectRatio: aspectRatio || 1,
const [size, setSize] = useState<{
width: string;
height: string;
aspectRatio: number | null;
}>({
width: width || "35%",
height: height || "auto",
aspectRatio: null,
});
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [initialResizeComplete, setInitialResizeComplete] = useState(false);
// refs
const [editorContainer, setEditorContainer] = useState<HTMLElement | null>(null);
const [isResizing, setIsResizing] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const containerRect = useRef<DOMRect | null>(null);
const imageRef = useRef<HTMLImageElement>(null);
const isShimmerVisible = isLoading || !initialResizeComplete;
const handleImageLoad = useCallback(() => {
const img = imageRef.current;
if (!img) return;
let closestEditorContainer: HTMLDivElement | null = null;
if (editorContainer) {
closestEditorContainer = editorContainer;
} else {
closestEditorContainer = img.closest(".editor-container") as HTMLDivElement | null;
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
if (!img) {
console.error("Image reference is undefined");
return;
}
const closestEditorContainer = img.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
}
setEditorContainer(closestEditorContainer);
setEditorContainer(closestEditorContainer as HTMLElement);
const aspectRatio = img.naturalWidth / img.naturalHeight;
if (width === "35%") {
@@ -98,32 +60,23 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
const initialWidth = Math.max(editorWidth * 0.35, MIN_SIZE);
const initialHeight = initialWidth / aspectRatio;
const initialComputedSize = {
width: `${Math.round(initialWidth)}px` satisfies Pixel,
height: `${Math.round(initialHeight)}px` satisfies Pixel,
const newSize = {
width: `${Math.round(initialWidth)}px`,
height: `${Math.round(initialHeight)}px`,
aspectRatio: aspectRatio,
};
setSize(initialComputedSize);
updateAttributes(initialComputedSize);
setSize(newSize);
updateAttributes(newSize);
} else {
// as the aspect ratio in not stored for old images, we need to update the attrs
setSize((prevSize) => {
const newSize = { ...prevSize, aspectRatio };
updateAttributes(newSize);
return newSize;
});
setSize((prevSize) => ({ ...prevSize, aspectRatio }));
}
setInitialResizeComplete(true);
}, [width, updateAttributes, editorContainer]);
setIsLoading(false);
}, [width, updateAttributes]);
// for real time resizing
useLayoutEffect(() => {
setSize((prevSize) => ({
...prevSize,
width: ensurePixelString(width),
height: ensurePixelString(height),
}));
setSize((prevSize) => ({ ...prevSize, width, height }));
}, [width, height]);
const handleResize = useCallback(
@@ -145,15 +98,18 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
updateAttributes(size);
}, [size, updateAttributes]);
const handleResizeStart = useCallback((e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
const handleResizeStart = useCallback(
(e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
if (containerRef.current) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
}, []);
if (containerRef.current && editorContainer) {
containerRect.current = containerRef.current.getBoundingClientRect();
}
},
[editorContainer]
);
useEffect(() => {
if (isResizing) {
@@ -179,14 +135,6 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
[editor, getPos]
);
// show the image loader if the remote image's src or preview image from filesystem is not set yet (while loading the image post upload) (or)
// if the initial resize (from 35% width and "auto" height attrs to the actual size in px) is not complete
const showImageLoader = !(remoteImageSrc || imageFromFileSystem) || !initialResizeComplete;
// show the image utils only if the editor is editable, the remote image's (post upload) src is set and the initial resize is complete (but not while we're showing the preview imageFromFileSystem)
const showImageUtils = editor.isEditable && remoteImageSrc && initialResizeComplete;
// show the preview image from the file system if the remote image's src is not set
const displayedImageSrc = remoteImageSrc ?? imageFromFileSystem;
return (
<div
ref={containerRef}
@@ -194,10 +142,10 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
onMouseDown={handleImageMouseDown}
style={{
width: size.width,
aspectRatio: size.aspectRatio,
aspectRatio: size.aspectRatio ?? undefined,
}}
>
{showImageLoader && (
{isShimmerVisible && (
<div
className="animate-pulse bg-custom-background-80 rounded-md"
style={{ width: size.width, height: size.height }}
@@ -205,41 +153,24 @@ export const CustomImageBlock: React.FC<CustomImageBlockProps> = (props) => {
)}
<img
ref={imageRef}
src={displayedImageSrc}
onLoad={handleImageLoad}
onError={(e) => {
console.error("Error loading image", e);
setFailedToLoadImage(true);
}}
src={displayedSrc ?? src}
width={size.width}
className={cn("image-component block rounded-md", {
// hide the image while the background calculations of the image loader are in progress (to avoid flickering) and show the loader until then
hidden: showImageLoader,
height={size.height}
onLoad={handleImageLoad}
className={cn("block rounded-md", {
hidden: isShimmerVisible,
"read-only-image": !editor.isEditable,
"blur-sm opacity-80 loading-image": !remoteImageSrc,
"blur-sm opacity-80": isLoading || !isImageLoaded,
})}
style={{
width: size.width,
aspectRatio: size.aspectRatio,
aspectRatio: size.aspectRatio ?? undefined,
}}
/>
{showImageUtils && (
<ImageToolbarRoot
containerClassName={
"absolute top-1 right-1 z-20 bg-black/40 rounded opacity-0 pointer-events-none group-hover/image-component:opacity-100 group-hover/image-component:pointer-events-auto transition-opacity"
}
image={{
src: remoteImageSrc,
aspectRatio: size.aspectRatio,
height: size.height,
width: size.width,
}}
/>
)}
{selected && displayedImageSrc === remoteImageSrc && (
{editor.isEditable && selected && !isShimmerVisible && (
<div className="absolute inset-0 size-full bg-custom-primary-500/30" />
)}
{showImageUtils && (
{editor.isEditable && !isShimmerVisible && (
<>
<div
className={cn(
@@ -1,78 +1,102 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useMemo, useState } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { Editor, NodeViewWrapper } from "@tiptap/react";
// extensions
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
import { UploadImageExtensionStorage } from "@/extensions/custom-image";
import { CustomImageBlockNew, ImageAttributes } from "./image-block-new";
import { useUploader } from "@/hooks/use-file-upload";
import { useEditorContainerWidth } from "@/hooks/use-editor-container";
export type CustomImageNodeViewProps = {
getPos: () => number;
editor: Editor;
getPos: () => number;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: Record<string, any>) => void;
updateAttributes: (attrs: Partial<ImageAttributes>) => void;
selected: boolean;
};
export const CustomImageNode = (props: CustomImageNodeViewProps) => {
const { getPos, editor, node, updateAttributes, selected } = props;
const { id } = node.attrs;
const containerRef = useRef<HTMLDivElement>(null);
const [isUploaded, setIsUploaded] = useState(false);
const [imageFromFileSystem, setImageFromFileSystem] = useState<string | undefined>(undefined);
const [failedToLoadImage, setFailedToLoadImage] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const [editorContainer, setEditorContainer] = useState<HTMLDivElement | null>(null);
const imageComponentRef = useRef<HTMLDivElement>(null);
// if the image is dropped onto the editor, we need to store the blob
const [droppedFileBlob, setDroppedFileBlob] = useState<string | undefined>(undefined);
// the imageComponent's storage
const editorStorage = useMemo(
() => editor.storage.imageComponent as UploadImageExtensionStorage | undefined,
[editor.storage]
);
// the imageComponent's entity (it depicts how the image was added, either by
// dropping the image onto the editor or by inserting the image)
const uploadEntity = useMemo(() => {
if (id) {
return editorStorage?.fileMap.get(id);
}
}, [editorStorage, id]);
const onUpload = useCallback(
(url: string) => {
if (url) {
updateAttributes({ src: url });
// after uploading the image, we need to remove the entity from the storage
if (id) {
editorStorage?.fileMap.delete(id);
}
}
},
[editorStorage?.fileMap, id, updateAttributes]
);
const { loading: isFileUploading, uploadFile } = useUploader({ onUpload, editor });
useEffect(() => {
const closestEditorContainer = imageComponentRef.current?.closest(".editor-container");
if (!closestEditorContainer) {
console.error("Editor container not found");
return;
if (uploadEntity && !hasTriggeredFilePickerRef.current) {
if (uploadEntity.event === "drop" && "file" in uploadEntity) {
const file = uploadEntity.file;
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
setDroppedFileBlob(result);
};
reader.readAsDataURL(file);
uploadFile(file);
} else if (uploadEntity.event === "insert" && fileInputRef.current && id) {
const entity = editorStorage?.fileMap.get(id);
if (entity && entity.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
if (!entity) return;
editorStorage?.fileMap.set(id, { ...entity, hasOpenedFileInputOnce: true });
}
}
}, [uploadEntity, uploadFile, editorStorage?.fileMap, id]);
setEditorContainer(closestEditorContainer as HTMLDivElement);
}, []);
// the image is already uploaded if the image-component node has src attribute
// and we need to remove the blob from our file system
useEffect(() => {
const remoteImageSrc = node.attrs.src;
if (remoteImageSrc) {
setIsUploaded(true);
setImageFromFileSystem(undefined);
} else {
setIsUploaded(false);
}
}, [node.attrs.src]);
const initialEditorContainerWidth = useEditorContainerWidth(containerRef);
return (
<NodeViewWrapper>
<div className="p-0 mx-0 my-2" data-drag-handle ref={imageComponentRef}>
{(isUploaded || imageFromFileSystem) && !failedToLoadImage ? (
<CustomImageBlock
imageFromFileSystem={imageFromFileSystem}
editorContainer={editorContainer}
editor={editor}
getPos={getPos}
node={node}
setEditorContainer={setEditorContainer}
setFailedToLoadImage={setFailedToLoadImage}
selected={selected}
updateAttributes={updateAttributes}
/>
) : (
<CustomImageUploader
editor={editor}
failedToLoadImage={failedToLoadImage}
getPos={getPos}
loadImageFromFileSystem={setImageFromFileSystem}
node={node}
setIsUploaded={setIsUploaded}
selected={selected}
updateAttributes={updateAttributes}
/>
)}
<div className="p-0 mx-0 my-2" data-drag-handle ref={containerRef}>
<CustomImageBlockNew
fileInputRef={fileInputRef}
uploadFile={uploadFile}
editor={editor}
droppedFileBlob={droppedFileBlob}
isFileUploading={isFileUploading}
initialEditorContainerWidth={initialEditorContainerWidth}
getPos={getPos}
node={node}
updateAttributes={updateAttributes}
selected={selected}
uploadEntity={uploadEntity}
/>
</div>
</NodeViewWrapper>
);
@@ -1,173 +1,147 @@
import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
import { Node as ProsemirrorNode } from "@tiptap/pm/model";
import { ChangeEvent, useCallback, useEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
import { ImageIcon } from "lucide-react";
import { Spinner } from "@plane/ui";
// helpers
import { cn } from "@/helpers/common";
// hooks
import { useUploader, useDropZone } from "@/hooks/use-file-upload";
import { useUploader, useFileUpload, useDropZone } from "@/hooks/use-file-upload";
// plugins
import { isFileValid } from "@/plugins/image";
// extensions
import { getImageComponentImageFileMap, ImageAttributes } from "@/extensions/custom-image";
type RefType = React.RefObject<HTMLInputElement> | ((instance: HTMLInputElement | null) => void);
const assignRef = (ref: RefType, value: HTMLInputElement | null) => {
if (typeof ref === "function") {
ref(value);
} else if (ref && typeof ref === "object") {
(ref as React.MutableRefObject<HTMLInputElement | null>).current = value;
}
};
export const CustomImageUploader = (props: {
failedToLoadImage: boolean;
onUpload: (url: string) => void;
editor: Editor;
fileInputRef: RefType;
existingFile?: File;
selected: boolean;
loadImageFromFileSystem: (file: string) => void;
setIsUploaded: (isUploaded: boolean) => void;
node: ProsemirrorNode & {
attrs: ImageAttributes;
};
updateAttributes: (attrs: Record<string, any>) => void;
getPos: () => number;
setIsImageLoaded: (isImageLoaded: boolean) => void;
setDisplayedSrc: (displayedSrc: string | null) => void;
setIsLoading: (isLoading: boolean) => void;
}) => {
const {
selected,
failedToLoadImage,
editor,
loadImageFromFileSystem,
node,
setIsUploaded,
updateAttributes,
getPos,
} = props;
// ref
const fileInputRef = useRef<HTMLInputElement>(null);
const hasTriggeredFilePickerRef = useRef(false);
const imageEntityId = node.attrs.id;
const imageComponentImageFileMap = useMemo(() => getImageComponentImageFileMap(editor), [editor]);
const onUpload = useCallback(
(url: string) => {
if (url) {
setIsUploaded(true);
// Update the node view's src attribute post upload
updateAttributes({ src: url });
imageComponentImageFileMap?.delete(imageEntityId);
const pos = getPos();
// get current node
const getCurrentSelection = editor.state.selection;
const currentNode = editor.state.doc.nodeAt(getCurrentSelection.from);
// only if the cursor is at the current image component, manipulate
// the cursor position
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
if (nextNode && nextNode.type.name === "paragraph") {
// If there is a paragraph node after the image component, move the focus to the next node
editor.commands.setTextSelection(pos + 1);
} else {
// create a new paragraph after the image component post upload
editor.commands.createParagraphNear();
}
}
}
},
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({ onUpload, editor, loadImageFromFileSystem });
const { selected, onUpload, editor, fileInputRef, existingFile, setDisplayedSrc, setIsImageLoaded, setIsLoading } =
props;
const { loading, uploadFile } = useUploader({ onUpload, editor });
const { handleUploadClick, ref: internalRef } = useFileUpload();
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({ uploader: uploadFile });
const imageRef = useRef<HTMLImageElement>(null);
// the meta data of the image component
const meta = useMemo(
() => imageComponentImageFileMap?.get(imageEntityId),
[imageComponentImageFileMap, imageEntityId]
);
const localRef = useRef<HTMLInputElement | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [width, setWidth] = useState<number | null>(null);
// if the image component is dropped, we check if it has an existing file
const existingFile = useMemo(() => (meta && meta.event === "drop" ? meta.file : undefined), [meta]);
// after the image component is mounted we start the upload process based on
// it's uploaded
useEffect(() => {
if (meta) {
if (meta.event === "drop" && "file" in meta) {
uploadFile(meta.file);
} else if (meta.event === "insert" && fileInputRef.current && !hasTriggeredFilePickerRef.current) {
if (meta.hasOpenedFileInputOnce) return;
fileInputRef.current.click();
hasTriggeredFilePickerRef.current = true;
imageComponentImageFileMap?.set(imageEntityId, { ...meta, hasOpenedFileInputOnce: true });
setIsLoading(loading);
}, [loading]);
// state to track if the preview image is loaded
useEffect(() => {
if (previewUrl) {
const closestEditorContainer = imageRef.current?.closest(".editor-container");
if (closestEditorContainer) {
const editorWidth = closestEditorContainer?.clientWidth;
const initialWidth = Math.max(editorWidth * 0.35, 100);
setWidth(initialWidth);
}
}
}, [meta, uploadFile, imageComponentImageFileMap]);
}, [previewUrl, imageRef.current]);
// check if the image is dropped and set the local image as the existing file
useEffect(() => {
if (existingFile) {
uploadFile(existingFile);
}
}, [existingFile, uploadFile]);
// Function to preload images
const loadImage = useCallback((src: string) => {
setIsImageLoaded(false);
const img = new Image();
img.onload = () => {
setIsImageLoaded(true);
setDisplayedSrc(src);
};
img.src = src;
}, []);
const onFileChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (isFileValid(file)) {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
loadImage(result);
setPreviewUrl(reader.result as string);
};
reader.readAsDataURL(file);
uploadFile(file);
}
}
},
[uploadFile]
[uploadFile, editor.storage.image, loadImage]
);
const getDisplayMessage = useCallback(() => {
const isUploading = isImageBeingUploaded || existingFile;
if (failedToLoadImage) {
return "Error loading image";
useEffect(() => {
if (existingFile) {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
setPreviewUrl(result);
loadImage(result);
};
reader.readAsDataURL(existingFile);
uploadFile(existingFile);
}
if (isUploading) {
return "Uploading...";
}
if (draggedInside) {
return "Drop image here";
}
return "Add an image";
}, [draggedInside, failedToLoadImage, existingFile, isImageBeingUploaded]);
}, [existingFile, uploadFile, loadImage]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={() => {
if (!failedToLoadImage) {
fileInputRef.current?.click();
}
}}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">{getDisplayMessage()}</div>
<input
className="size-0 overflow-hidden"
ref={fileInputRef}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
<>
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 cursor-pointer transition-all duration-200 ease-in-out",
{
"bg-custom-background-80 text-custom-text-200": draggedInside,
},
{
"text-custom-primary-200 bg-custom-primary-100/10": selected,
}
)}
onDrop={onDrop}
onDragOver={onDragEnter}
onDragLeave={onDragLeave}
contentEditable={false}
onClick={handleUploadClick}
>
<ImageIcon className="size-4" />
<div className="text-base font-medium">
{loading
? "Uploading..."
: draggedInside
? "Drop image here"
: existingFile
? "Uploading..."
: "Add an image"}
</div>
<input
className="size-0 overflow-hidden"
ref={(element) => {
localRef.current = element;
assignRef(fileInputRef, element);
assignRef(internalRef as RefType, element);
}}
hidden
type="file"
accept=".jpg,.jpeg,.png,.webp"
onChange={onFileChange}
/>
</div>
</>
);
};
@@ -1,4 +1,3 @@
export * from "./toolbar";
export * from "./image-block";
export * from "./image-node";
export * from "./image-uploader";
@@ -1,159 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ExternalLink, Maximize, Minus, Plus, X } from "lucide-react";
// helpers
import { cn } from "@/helpers/common";
type Props = {
image: {
src: string;
height: string;
width: string;
aspectRatio: number;
};
isOpen: boolean;
toggleFullScreenMode: (val: boolean) => void;
};
const MAGNIFICATION_VALUES = [0.5, 0.75, 1, 1.5, 1.75, 2];
export const ImageFullScreenAction: React.FC<Props> = (props) => {
const { image, isOpen: isFullScreenEnabled, toggleFullScreenMode } = props;
const { src, width, aspectRatio } = image;
// states
const [magnification, setMagnification] = useState(1);
// refs
const modalRef = useRef<HTMLDivElement>(null);
// derived values
const widthInNumber = useMemo(() => Number(width?.replace("px", "")), [width]);
// close handler
const handleClose = useCallback(() => {
toggleFullScreenMode(false);
setTimeout(() => {
setMagnification(1);
}, 200);
}, [toggleFullScreenMode]);
// download handler
const handleOpenInNewTab = () => {
const link = document.createElement("a");
link.href = src;
link.target = "_blank";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// magnification decrease handler
const handleDecreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === 0) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex - 1]);
}, [magnification]);
// magnification increase handler
const handleIncreaseMagnification = useCallback(() => {
const currentIndex = MAGNIFICATION_VALUES.indexOf(magnification);
if (currentIndex === MAGNIFICATION_VALUES.length - 1) return;
setMagnification(MAGNIFICATION_VALUES[currentIndex + 1]);
}, [magnification]);
// keydown handler
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape" || e.key === "+" || e.key === "=" || e.key === "-") {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") handleClose();
if (e.key === "+" || e.key === "=") handleIncreaseMagnification();
if (e.key === "-") handleDecreaseMagnification();
}
},
[handleClose, handleDecreaseMagnification, handleIncreaseMagnification]
);
// click outside handler
const handleClickOutside = useCallback(
(e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
if (modalRef.current && e.target === modalRef.current) {
handleClose();
}
},
[handleClose]
);
// register keydown listener
useEffect(() => {
if (isFullScreenEnabled) {
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}
}, [handleKeyDown, isFullScreenEnabled]);
return (
<>
<div
className={cn(
"fixed inset-0 size-full z-20 bg-black/90 opacity-0 pointer-events-none cursor-default transition-opacity",
{
"opacity-100 pointer-events-auto": isFullScreenEnabled,
}
)}
>
<div ref={modalRef} onClick={handleClickOutside} className="relative size-full grid place-items-center">
<button
type="button"
onClick={handleClose}
className="absolute top-10 right-10 size-8 grid place-items-center"
>
<X className="size-8 text-white/60 hover:text-white transition-colors" />
</button>
<img
src={src}
className="read-only-image rounded-lg transition-all duration-200"
style={{
width: `${widthInNumber * magnification}px`,
aspectRatio,
}}
/>
</div>
<div className="fixed bottom-10 left-1/2 -translate-x-1/2 flex items-center justify-center gap-1 rounded-md border border-white/20 py-2 divide-x divide-white/20 bg-black">
<div className="flex items-center">
<button
type="button"
onClick={handleDecreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[0]}
>
<Minus className="size-4" />
</button>
<span className="text-sm w-12 text-center text-white">{(100 * magnification).toFixed(0)}%</span>
<button
type="button"
onClick={handleIncreaseMagnification}
className="size-6 grid place-items-center text-white/60 hover:text-white disabled:text-white/30 transition-colors duration-200"
disabled={magnification === MAGNIFICATION_VALUES[MAGNIFICATION_VALUES.length - 1]}
>
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={handleOpenInNewTab}
className="flex-shrink-0 size-8 grid place-items-center text-white/60 hover:text-white transition-colors duration-200"
>
<ExternalLink className="size-4" />
</button>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
toggleFullScreenMode(true);
}}
className="size-5 grid place-items-center hover:bg-black/40 text-white rounded transition-colors"
>
<Maximize className="size-3" />
</button>
</>
);
};
@@ -1 +0,0 @@
export * from "./root";
@@ -1,37 +0,0 @@
import { useState } from "react";
// helpers
import { cn } from "@/helpers/common";
// components
import { ImageFullScreenAction } from "./full-screen";
type Props = {
containerClassName?: string;
image: {
src: string;
height: string;
width: string;
aspectRatio: number;
};
};
export const ImageToolbarRoot: React.FC<Props> = (props) => {
const { containerClassName, image } = props;
// state
const [isFullScreenEnabled, setIsFullScreenEnabled] = useState(false);
return (
<>
<div
className={cn(containerClassName, {
"opacity-100 pointer-events-auto": isFullScreenEnabled,
})}
>
<ImageFullScreenAction
image={image}
isOpen={isFullScreenEnabled}
toggleFullScreenMode={(val) => setIsFullScreenEnabled(val)}
/>
</div>
</>
);
};
@@ -1,4 +1,4 @@
import { Editor, mergeAttributes } from "@tiptap/core";
import { mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
@@ -11,24 +11,15 @@ import { TFileHandler } from "@/types";
// helpers
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
export type InsertImageComponentProps = {
file?: File;
pos?: number;
event: "insert" | "drop";
};
declare module "@tiptap/core" {
interface Commands<ReturnType> {
imageComponent: {
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
setImageUpload: ({ file, pos, event }: { file?: File; pos?: number; event: "insert" | "drop" }) => ReturnType;
uploadImage: (file: File) => () => Promise<string> | undefined;
};
}
}
export const getImageComponentImageFileMap = (editor: Editor) =>
(editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap;
export interface UploadImageExtensionStorage {
fileMap: Map<string, UploadEntity>;
}
@@ -113,13 +104,12 @@ export const CustomImageExtension = (props: TFileHandler) => {
return {
fileMap: new Map(),
deletedImageSet: new Map<string, boolean>(),
uploadInProgress: false,
};
},
addCommands() {
return {
insertImageComponent:
setImageUpload:
(props: { file?: File; pos?: number; event: "insert" | "drop" }) =>
({ commands }) => {
// Early return if there's an invalid file being dropped
@@ -130,21 +120,15 @@ export const CustomImageExtension = (props: TFileHandler) => {
// generate a unique id for the image to keep track of dropped
// files' file data
const fileId = uuidv4();
const imageComponentImageFileMap = getImageComponentImageFileMap(this.editor);
if (imageComponentImageFileMap) {
if (props?.event === "drop" && props.file) {
imageComponentImageFileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
imageComponentImageFileMap.set(fileId, {
event: props.event,
hasOpenedFileInputOnce: false,
});
}
if (props?.event === "drop" && props.file) {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
file: props.file,
event: props.event,
});
} else if (props.event === "insert") {
(this.editor.storage.imageComponent as UploadImageExtensionStorage).fileMap.set(fileId, {
event: props.event,
});
}
const attributes = {
+20 -45
View File
@@ -1,6 +1,6 @@
import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "prosemirror-state";
import { EditorView } from "prosemirror-view";
export const DropHandlerExtension = () =>
Extension.create({
@@ -8,7 +8,6 @@ export const DropHandlerExtension = () =>
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
@@ -21,9 +20,15 @@ export const DropHandlerExtension = () =>
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
imageFiles.forEach((file, index) => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: pos + index, event: "drop" })
.run();
});
return true;
}
return true;
}
return false;
},
@@ -40,8 +45,15 @@ export const DropHandlerExtension = () =>
});
if (coordinates) {
const pos = coordinates.pos;
insertImages({ editor, files: imageFiles, initialPos: pos, event: "drop" });
imageFiles.forEach((file, index) => {
setTimeout(() => {
this.editor
.chain()
.focus()
.setImageUpload({ file, pos: coordinates.pos + index, event: "drop" })
.run();
}, index * 100); // Slight delay between insertions
});
}
return true;
}
@@ -53,40 +65,3 @@ export const DropHandlerExtension = () =>
];
},
});
const insertImages = async ({
editor,
files,
initialPos,
event,
}: {
editor: Editor;
files: File[];
initialPos: number;
event: "insert" | "drop";
}) => {
let pos = initialPos;
for (const file of files) {
// safe insertion
const docSize = editor.state.doc.content.size;
pos = Math.min(pos, docSize);
// Check if the position has a non-empty node
const nodeAtPos = editor.state.doc.nodeAt(pos);
if (nodeAtPos && nodeAtPos.content.size > 0) {
// Move to the end of the current node
pos += nodeAtPos.nodeSize;
}
try {
// Insert the image at the current position
editor.commands.insertImageComponent({ file, pos, event });
} catch (error) {
console.error(`Error while ${event}ing image:`, error);
}
// Move to the next position
pos += 1;
}
};
@@ -149,7 +149,7 @@ export const CoreEditorExtensions = ({
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
if (editor.storage.imageComponent.uploadInProgress) return "";
// if (editor.storage.image.uploadInProgress) return "";
const shouldHidePlaceholder =
editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image");
@@ -1,57 +0,0 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
export interface IMarking {
type: "heading";
level: number;
text: string;
sequence: number;
}
export const HeadingListExtension = Extension.create({
name: "headingList",
addStorage() {
return {
headings: [] as IMarking[],
};
},
addProseMirrorPlugins() {
const plugin = new Plugin({
key: new PluginKey("heading-list"),
appendTransaction: (_, __, newState) => {
const headings: IMarking[] = [];
let h1Sequence = 0;
let h2Sequence = 0;
let h3Sequence = 0;
newState.doc.descendants((node) => {
if (node.type.name === "heading") {
const level = node.attrs.level;
const text = node.textContent;
headings.push({
type: "heading",
level: level,
text: text,
sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
});
}
});
this.storage.headings = headings;
this.editor.emit("update", { editor: this.editor, transaction: newState.tr });
return null;
},
});
return [plugin];
},
getHeadings() {
return this.storage.headings;
},
});
@@ -1,4 +1,5 @@
import ImageExt from "@tiptap/extension-image";
// extensions
export const ImageExtensionWithoutProps = () =>
ImageExt.extend({
@@ -19,4 +19,3 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./slash-commands";
export * from "./headers";
@@ -1,7 +1,6 @@
// TODO: fix all warnings
/* eslint-disable react/display-name */
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { useEffect, useState } from "react";
import { NodeViewWrapper } from "@tiptap/react";
@@ -19,7 +19,6 @@ import {
TableRow,
Table,
CustomMention,
HeadingListExtension,
CustomReadOnlyImageExtension,
} from "@/extensions";
// helpers
@@ -109,5 +108,4 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
readonly: true,
}),
CharacterCount,
HeadingListExtension,
];
@@ -78,11 +78,10 @@ const SideMenu = (options: SideMenuPluginProps) => {
hideSideMenu();
view?.dom.parentElement?.appendChild(editorSideMenu);
// side menu elements' initialization
if (handlesConfig.ai && !editorSideMenu.querySelector("#ai-handle")) {
if (handlesConfig.ai) {
aiHandleView(view, editorSideMenu);
}
if (handlesConfig.dragDrop && !editorSideMenu.querySelector("#drag-handle")) {
if (handlesConfig.dragDrop) {
dragHandleView(view, editorSideMenu);
}
@@ -114,10 +113,6 @@ const SideMenu = (options: SideMenuPluginProps) => {
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
if (handlesConfig.ai) {
rect.left -= 20;
}
if (node.parentElement?.parentElement?.matches("td") || node.parentElement?.parentElement?.matches("th")) {
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= 5;
@@ -34,7 +34,6 @@ import {
toggleHeadingFour,
toggleHeadingFive,
toggleHeadingSix,
insertImage,
} from "@/helpers/editor-commands";
// types
import { CommandProps, ISlashCommandItem } from "@/types";
@@ -227,7 +226,9 @@ const getSuggestionItems =
icon: <ImageIcon className="size-3.5" />,
description: "Insert an image",
searchTerms: ["img", "photo", "picture", "media", "upload"],
command: ({ editor, range }: CommandProps) => insertImage({ editor, event: "insert", range }),
command: ({ editor, range }: CommandProps) => {
editor.chain().focus().deleteRange(range).setImageUpload({ event: "insert" }).run();
},
},
{
key: "divider",
@@ -198,7 +198,6 @@ function createToolbox({
onSelectColor: (color: { backgroundColor: string; textColor: string }) => void;
colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } };
}): Instance<Props> {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const toolbox = tippy(triggerButton, {
content: h(
@@ -204,8 +204,11 @@ export const Table = Node.create({
({ tr, dispatch }) => {
if (dispatch) {
const selection = CellSelection.create(tr.doc, position.anchorCell, position.headCell);
// @ts-ignore
tr.setSelection(selection);
}
return true;
},
};
@@ -244,7 +247,7 @@ export const Table = Node.create({
return ({ editor, getPos, node, decorations }) => {
const { cellMinWidth } = this.options;
return new TableView(node, cellMinWidth, decorations as any, editor, getPos as () => number);
return new TableView(node, cellMinWidth, decorations, editor, getPos as () => number);
};
},
@@ -264,6 +267,8 @@ export const Table = Node.create({
handleWidth: this.options.handleWidth,
cellMinWidth: this.options.cellMinWidth,
// View: TableView,
// @ts-ignore
lastColumnResizable: this.options.lastColumnResizable,
})
);
@@ -1,4 +1,4 @@
import { Fragment, Node as ProsemirrorNode, NodeType } from "@tiptap/pm/model";
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model";
export function createCell(
cellType: NodeType,
@@ -1,4 +1,4 @@
import { NodeType, Schema } from "@tiptap/pm/model";
import { NodeType, Schema } from "prosemirror-model";
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
if (schema.cached.tableNodeTypes) {
@@ -1,10 +1,13 @@
import { Editor, Range } from "@tiptap/core";
import { Selection } from "@tiptap/pm/state";
// extensions
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
// helpers
import { findTableAncestor } from "@/helpers/common";
// plugins
import { startImageUpload } from "@/plugins/image";
// types
import { InsertImageComponentProps } from "@/extensions";
import { UploadImage } from "@/types";
export const setText = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().run();
@@ -126,27 +129,6 @@ export const insertTableCommand = (editor: Editor, range?: Range) => {
else editor.chain().focus().clearNodes().insertTable({ rows: 3, cols: 3 }).run();
};
export const insertImage = ({
editor,
event,
pos,
file,
range,
}: {
editor: Editor;
event: "insert" | "drop";
pos?: number | null;
file?: File;
range?: Range;
}) => {
if (range) editor.chain().focus().deleteRange(range).run();
const imageOptions: InsertImageComponentProps = { event };
if (pos) imageOptions.pos = pos;
if (file) imageOptions.file = file;
return editor?.chain().focus().insertImageComponent(imageOptions).run();
};
export const unsetLinkEditor = (editor: Editor) => {
editor.chain().focus().unsetLink().run();
};
@@ -154,3 +136,23 @@ export const unsetLinkEditor = (editor: Editor) => {
export const setLinkEditor = (editor: Editor, url: string) => {
editor.chain().focus().setLink({ href: url }).run();
};
export const insertImageCommand = (
editor: Editor,
uploadFile: UploadImage,
savedSelection?: Selection | null,
range?: Range
) => {
if (range) editor.chain().focus().deleteRange(range).run();
const input = document.createElement("input");
input.type = "file";
input.accept = ".jpeg, .jpg, .png, .webp";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
startImageUpload(editor, file, editor.view, pos, uploadFile);
}
};
input.click();
};
-16
View File
@@ -1,16 +0,0 @@
import * as Y from "yjs";
/**
* @description apply updates to a doc and return the updated doc in base64(binary) format
* @param {Uint8Array} document
* @param {Uint8Array} updates
* @returns {string} base64(binary) form of the updated doc
*/
export const applyUpdates = (document: Uint8Array, updates: Uint8Array): Uint8Array => {
const yDoc = new Y.Doc();
Y.applyUpdate(yDoc, document);
Y.applyUpdate(yDoc, updates);
const encodedDoc = Y.encodeStateAsUpdate(yDoc);
return encodedDoc;
};
@@ -1,9 +1,9 @@
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useEffect, useLayoutEffect, useMemo } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
// extensions
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
import { SideMenuExtension } from "@/extensions";
// hooks
import { useEditor } from "@/hooks/use-editor";
// plane editor extensions
@@ -29,9 +29,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
tabIndex,
user,
} = props;
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
@@ -41,18 +38,11 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
// using user id as a token to verify the user on the server
token: user.id,
url: realtimeConfig.url,
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
},
onAuthenticationFailed: () => serverHandler?.onServerError?.(),
onConnect: () => serverHandler?.onConnect?.(),
onClose: (data) => {
if (data.event.code === 1006) {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
}
if (data.event.code === 1006) serverHandler?.onServerError?.();
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, serverHandler, user.id]
);
@@ -78,12 +68,15 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
editorProps,
editorClassName,
enableHistory: false,
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
extensions: [
SideMenuExtension({
aiEnabled: !disabledExtensions?.includes("ai"),
dragDropEnabled: true,
}),
HeadingListExtension,
Collaboration.configure({
document: provider.document,
}),
@@ -95,18 +88,9 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
userDetails: user,
}),
],
fileHandler,
handleEditorReady,
forwardedRef,
mentionHandler,
placeholder,
provider,
tabIndex,
});
return {
editor,
hasServerConnectionFailed,
hasServerSynced,
};
return { editor };
};
@@ -0,0 +1,37 @@
import { useState, useEffect, RefObject } from "react";
interface UseEditorContainerWidthOptions {
maxRetries?: number;
retryInterval?: number;
}
export const useEditorContainerWidth = (
containerRef: RefObject<HTMLElement>,
options: UseEditorContainerWidthOptions = {}
): number => {
const { maxRetries = 5, retryInterval = 100 } = options;
const [width, setWidth] = useState<number>(0);
useEffect(() => {
let retryCount = 0;
const checkEditorContainer = (): void => {
if (containerRef.current) {
const editorContainer = containerRef.current.closest(".editor-container") as HTMLElement | null;
if (editorContainer) {
setWidth(editorContainer.clientWidth);
return;
}
}
if (retryCount < maxRetries) {
retryCount++;
setTimeout(checkEditorContainer, retryInterval);
}
};
checkEditorContainer();
}, [containerRef, maxRetries, retryInterval]);
return width;
};
+12 -41
View File
@@ -1,10 +1,8 @@
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { DOMSerializer } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useTiptapEditor, Editor } from "@tiptap/react";
import * as Y from "yjs";
// components
import { getEditorMenuItems } from "@/components/menus";
// extensions
@@ -34,7 +32,6 @@ export interface CustomEditorProps {
};
onChange?: (json: object, html: string) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
provider?: HocuspocusProvider;
tabIndex?: number;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
@@ -55,12 +52,10 @@ export const useEditor = (props: CustomEditorProps) => {
mentionHandler,
onChange,
placeholder,
provider,
tabIndex,
value,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
@@ -83,7 +78,7 @@ export const useEditor = (props: CustomEditorProps) => {
},
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights,
mentionHighlights: mentionHandler.highlights ?? [],
},
placeholder,
tabIndex,
@@ -107,7 +102,7 @@ export const useEditor = (props: CustomEditorProps) => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value === null || value === undefined) return;
if (editor && !editor.isDestroyed && !editor.storage.imageComponent.uploadInProgress) {
if (editor && !editor.isDestroyed && !editor.storage.image.uploadInProgress) {
try {
editor.commands.setContent(value, false, { preserveWhitespace: "full" });
const currentSavedSelection = savedSelectionRef.current;
@@ -159,25 +154,11 @@ export const useEditor = (props: CustomEditorProps) => {
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
};
},
getHeadings: () => editorRef?.current?.storage.headingList.headings,
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editorRef.current?.on("transaction", () => {
callback();
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
@@ -189,22 +170,15 @@ export const useEditor = (props: CustomEditorProps) => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
return {
binary: documentBinary,
html: documentHTML,
json: documentJSON,
};
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
isEditorReadyToDiscard: () => editorRef.current?.storage.imageComponent.uploadInProgress === false,
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {
console.error("Editor reference is not available or has been destroyed.");
@@ -262,15 +236,12 @@ export const useEditor = (props: CustomEditorProps) => {
editorRef.current.chain().focus().deleteRange({ from, to }).insertContent(contentHTML).run();
}
},
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
setProviderDocument: (value) => {
const document = provider?.document;
if (!document) return;
Y.applyUpdate(document, value);
getDocumentInfo: () => {
return {
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
};
},
}),
[editorRef, savedSelection]
@@ -1,48 +1,17 @@
import { DragEvent, useCallback, useEffect, useState } from "react";
import { DragEvent, useCallback, useEffect, useRef, useState } from "react";
import { Editor } from "@tiptap/core";
import { isFileValid } from "@/plugins/image";
export const useUploader = ({
onUpload,
editor,
loadImageFromFileSystem,
}: {
onUpload: (url: string) => void;
editor: Editor;
loadImageFromFileSystem: (file: string) => void;
}) => {
const [uploading, setUploading] = useState(false);
export const useUploader = ({ onUpload, editor }: { onUpload: (url: string) => void; editor: Editor }) => {
const [loading, setLoading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
const setImageUploadInProgress = (isUploading: boolean) => {
editor.storage.imageComponent.uploadInProgress = isUploading;
};
setImageUploadInProgress(true);
setUploading(true);
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
const isValid = isFileValid(fileWithTrimmedName);
if (!isValid) {
setImageUploadInProgress(false);
return;
}
setLoading(true);
try {
const reader = new FileReader();
reader.onload = () => {
if (reader.result) {
loadImageFromFileSystem(reader.result as string);
} else {
console.error("Failed to read the file: reader.result is null");
}
};
reader.onerror = () => {
console.error("Error reading file");
};
reader.readAsDataURL(fileWithTrimmedName);
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(fileWithTrimmedName);
const url: string = await editor?.commands.uploadImage(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
@@ -52,17 +21,24 @@ export const useUploader = ({
console.log(errPayload);
const error = errPayload?.response?.data?.error || "Something went wrong";
console.error(error);
} finally {
setImageUploadInProgress(false);
setUploading(false);
}
setLoading(false);
},
[onUpload]
[onUpload, editor]
);
return { uploading, uploadFile };
return { loading, uploadFile };
};
export const useFileUpload = () => {
const fileInput = useRef<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
fileInput.current?.click();
}, []);
return { ref: fileInput, handleUploadClick };
};
export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) => {
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -114,9 +90,10 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
const file = filteredFiles.length > 0 ? filteredFiles[0] : undefined;
if (file) {
uploader(file);
} else {
console.error("No file found");
const isValid = isFileValid(file);
if (isValid) {
uploader(file);
}
}
},
[uploader]
@@ -132,14 +109,3 @@ export const useDropZone = ({ uploader }: { uploader: (file: File) => void }) =>
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
};
function trimFileName(fileName: string, maxLength = 100) {
if (fileName.length > maxLength) {
const extension = fileName.split(".").pop();
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
}
return fileName;
}
@@ -1,9 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useState } from "react";
import { useEffect, useLayoutEffect, useMemo } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import Collaboration from "@tiptap/extension-collaboration";
import { IndexeddbPersistence } from "y-indexeddb";
// extensions
import { HeadingListExtension } from "@/extensions";
// hooks
import { useReadOnlyEditor } from "@/hooks/use-read-only-editor";
// types
@@ -22,9 +20,6 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
serverHandler,
user,
} = props;
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
@@ -33,18 +28,10 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
name: id,
token: user.id,
parameters: realtimeConfig.queryParams,
onAuthenticationFailed: () => {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
},
onConnect: () => serverHandler?.onConnect?.(),
onClose: (data) => {
if (data.event.code === 1006) {
serverHandler?.onServerError?.();
setHasServerConnectionFailed(true);
}
if (data.event.code === 1006) serverHandler?.onServerError?.();
},
onSynced: () => setHasServerSynced(true),
}),
[id, realtimeConfig, user.id]
);
@@ -67,22 +54,16 @@ export const useReadOnlyCollaborativeEditor = (props: TReadOnlyCollaborativeEdit
const editor = useReadOnlyEditor({
editorProps,
editorClassName,
forwardedRef,
handleEditorReady,
mentionHandler,
extensions: [
...(extensions ?? []),
HeadingListExtension,
Collaboration.configure({
document: provider.document,
}),
],
forwardedRef,
handleEditorReady,
mentionHandler,
provider,
});
return {
editor,
hasServerConnectionFailed,
hasServerSynced,
};
return { editor, isIndexedDbSynced: true };
};
@@ -1,8 +1,6 @@
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { HocuspocusProvider } from "@hocuspocus/provider";
import { EditorProps } from "@tiptap/pm/view";
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import * as Y from "yjs";
// extensions
import { CoreReadOnlyEditorExtensions } from "@/extensions";
// helpers
@@ -23,21 +21,17 @@ interface CustomReadOnlyEditorProps {
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
provider?: HocuspocusProvider;
}
export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
const {
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorProps = {},
handleEditorReady,
mentionHandler,
provider,
} = props;
export const useReadOnlyEditor = ({
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorProps = {},
handleEditorReady,
mentionHandler,
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor({
editable: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
@@ -80,39 +74,21 @@ export const useReadOnlyEditor = (props: CustomReadOnlyEditorProps) => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
getDocument: () => {
const documentBinary = provider?.document ? Y.encodeStateAsUpdate(provider?.document) : null;
const documentHTML = editorRef.current?.getHTML() ?? "<p></p>";
const documentJSON = editorRef.current?.getJSON() ?? null;
return {
binary: documentBinary,
html: documentHTML,
json: documentJSON,
};
getHTML: (): string => {
const htmlOutput = editorRef.current?.getHTML() ?? "<p></p>";
return htmlOutput;
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
getDocumentInfo: () => ({
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
}),
onHeadingChange: (callback: (headings: IMarking[]) => void) => {
// Subscribe to update event emitted from headers extension
editorRef.current?.on("update", () => {
callback(editorRef.current?.storage.headingList.headings);
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("update");
getDocumentInfo: () => {
return {
characters: editorRef?.current?.storage?.characterCount?.characters?.() ?? 0,
paragraphs: getParagraphCount(editorRef?.current?.state),
words: editorRef?.current?.storage?.characterCount?.words?.() ?? 0,
};
},
getHeadings: () => editorRef?.current?.storage.headingList.headings,
}));
if (!editor) {
+35 -2
View File
@@ -2,12 +2,45 @@ import { NodeSelection } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
// extensions
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
// plugins
import { nodeDOMAtCoords } from "@/plugins/drag-handle";
const sparklesIcon =
'<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>';
const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
const elements = document.elementsFromPoint(coords.x, coords.y);
const generalSelectors = [
"li",
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
].join(", ");
for (const elem of elements) {
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
elem?.textContent?.trim() !== ""
) {
return elem; // Return only if p tag is not empty in td or th
}
// apply general selector
if (elem.matches(generalSelectors)) {
return elem;
}
}
return null;
};
const nodePosAtDOM = (node: Element, view: EditorView, options: SideMenuPluginProps) => {
const boundingRect = node.getBoundingClientRect();
@@ -37,19 +37,39 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
"p:not(:first-child)",
".code-block",
"blockquote",
"img",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
".image-component",
".image-upload-component",
].join(", ");
const hasNestedImg = (el: Element): boolean => {
if (el.tagName.toLowerCase() === "img") return true;
// @ts-expect-error todo
for (const child of el.children) {
if (hasNestedImg(child)) return true;
}
return false;
};
for (const elem of elements) {
const elemHasNestedImg = hasNestedImg(elem);
if (elem.matches("p:first-child") && elem.parentElement?.matches(".ProseMirror")) {
return elem;
}
// if the element is a <p> tag and has a nested img i.e. the new image
// component
if (elem.matches("p") && elemHasNestedImg) {
return null;
}
if (elem.matches("div") && elemHasNestedImg) {
return elem;
}
// if the element is a <p> tag that is the first child of a td or th
if (
(elem.matches("td > p:first-child") || elem.matches("th > p:first-child")) &&
@@ -0,0 +1,137 @@
import { Editor } from "@tiptap/core";
import { EditorView } from "@tiptap/pm/view";
import { v4 as uuidv4 } from "uuid";
// plugins
import { findPlaceholder, isFileValid, removePlaceholder, uploadKey } from "@/plugins/image";
// types
import { UploadImage } from "@/types";
export async function startImageUpload(
editor: Editor,
file: File,
view: EditorView,
pos: number | null,
uploadFile: UploadImage
) {
editor.storage.image.uploadInProgress = true;
if (!isFileValid(file)) {
editor.storage.image.uploadInProgress = false;
return;
}
const id = uuidv4();
const tr = view.state.tr;
if (!tr.selection.empty) tr.deleteSelection();
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
tr.setMeta(uploadKey, {
add: {
id,
pos,
src: reader.result,
},
});
view.dispatch(tr);
};
// Handle FileReader errors
reader.onerror = (error) => {
console.error("FileReader error: ", error);
removePlaceholder(editor, view, id);
return;
};
try {
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
const resolvedPos = view.state.doc.resolve(pos ?? 0);
const nodeBefore = resolvedPos.nodeBefore;
// if the image is at the start of the line i.e. when nodeBefore is null
if (nodeBefore === null) {
if (pos) {
// so that the image is not inserted at the next line, else incase the
// image is inserted at any line where there's some content, the
// position is kept as it is to be inserted at the next line
pos -= 1;
}
}
view.focus();
const src = await uploadAndValidateImage(fileWithTrimmedName, uploadFile);
if (src == null) {
throw new Error("Resolved image URL is undefined.");
}
const { schema } = view.state;
pos = findPlaceholder(view.state, id);
if (pos == null) {
editor.storage.image.uploadInProgress = false;
return;
}
const imageSrc = typeof src === "object" ? reader.result : src;
const node = schema.nodes.image.create({ src: imageSrc });
if (pos < 0 || pos > view.state.doc.content.size) {
throw new Error("Invalid position to insert the image node.");
}
// insert the image node at the position of the placeholder and remove the placeholder
const transaction = view.state.tr.insert(pos, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
editor.storage.image.uploadInProgress = false;
} catch (error) {
console.error("Error in uploading and inserting image: ", error);
removePlaceholder(editor, view, id);
}
}
async function uploadAndValidateImage(file: File, uploadFile: UploadImage): Promise<string | undefined> {
try {
const imageUrl = await uploadFile(file);
if (imageUrl == null) {
throw new Error("Image URL is undefined.");
}
await new Promise<void>((resolve, reject) => {
const image = new Image();
image.src = imageUrl;
image.onload = () => {
resolve();
};
image.onerror = (error) => {
console.error("Error in loading image: ", error);
reject(error);
};
});
return imageUrl;
} catch (error) {
console.error("Error in uploading image: ", error);
// throw error to remove the placeholder
throw error;
}
}
function trimFileName(fileName: string, maxLength = 100) {
if (fileName.length > maxLength) {
const extension = fileName.split(".").pop();
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
}
return fileName;
}
@@ -2,4 +2,6 @@ export * from "./types";
export * from "./utils";
export * from "./constants";
export * from "./delete-image";
export * from "./image-upload-handler";
export * from "./restore-image";
export * from "./upload-image";
@@ -0,0 +1,87 @@
import { Editor } from "@tiptap/core";
import { Plugin } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
// plugins
import { removePlaceholder, uploadKey } from "@/plugins/image";
export const UploadImagesPlugin = (editor: Editor, cancelUploadImage?: () => void) => {
let currentView: EditorView | null = null;
const createPlaceholder = (src: string): HTMLElement => {
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
image.src = src;
placeholder.appendChild(image);
return placeholder;
};
const createCancelButton = (id: string): HTMLButtonElement => {
const cancelButton = document.createElement("button");
cancelButton.type = "button";
cancelButton.style.position = "absolute";
cancelButton.style.right = "3px";
cancelButton.style.top = "3px";
cancelButton.setAttribute("class", "opacity-90 rounded-lg");
cancelButton.onclick = () => {
if (currentView) {
cancelUploadImage?.();
removePlaceholder(editor, currentView, id);
}
};
// Create an SVG element from the SVG string
const svgString = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-circle"><circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/></svg>`;
const parser = new DOMParser();
const svgElement = parser.parseFromString(svgString, "image/svg+xml").documentElement;
cancelButton.appendChild(svgElement);
return cancelButton;
};
return new Plugin({
key: uploadKey,
view(editorView) {
currentView = editorView;
return {
destroy() {
currentView = null;
},
};
},
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
set = set.map(tr.mapping, tr.doc);
const action = tr.getMeta(uploadKey);
if (action && action.add) {
const { id, pos, src } = action.add;
const placeholder = createPlaceholder(src);
const cancelButton = createCancelButton(id);
placeholder.appendChild(cancelButton);
const deco = Decoration.widget(pos, placeholder, {
id,
});
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(set.find(undefined, undefined, (spec) => spec.id == action.remove.id));
}
return set;
},
},
props: {
decorations(state) {
return this.getState(state);
},
},
});
};
@@ -1 +1,2 @@
export * from "./placeholder";
export * from "./validate-file";
@@ -0,0 +1,17 @@
import { Editor } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import { DecorationSet, EditorView } from "@tiptap/pm/view";
// plugins
import { uploadKey } from "@/plugins/image";
export function findPlaceholder(state: EditorState, id: string): number | null {
const decos = uploadKey.getState(state) as DecorationSet;
const found = decos.find(undefined, undefined, (spec: { id: string }) => spec.id === id);
return found.length ? found[0].from : null;
}
export function removePlaceholder(editor: Editor, view: EditorView, id: string) {
const removePlaceholderTr = view.state.tr.setMeta(uploadKey, { remove: { id } });
view.dispatch(removePlaceholderTr);
editor.storage.image.uploadInProgress = false;
}
+4 -12
View File
@@ -1,4 +1,3 @@
import { JSONContent } from "@tiptap/core";
// helpers
import { IMarking } from "@/helpers/scroll-to-node";
// types
@@ -17,11 +16,7 @@ import {
// editor refs
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
getDocument: () => {
binary: Uint8Array | null;
html: string;
json: JSONContent | null;
};
getHTML: () => string;
clearEditor: (emitUpdate?: boolean) => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
@@ -30,8 +25,6 @@ export type EditorReadOnlyRefApi = {
paragraphs: number;
words: number;
};
onHeadingChange: (callback: (headings: IMarking[]) => void) => () => void;
getHeadings: () => IMarking[];
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
@@ -43,7 +36,6 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
isEditorReadyToDiscard: () => boolean;
getSelectedText: () => string | null;
insertText: (contentHTML: string, insertOnNextLine?: boolean) => void;
setProviderDocument: (value: Uint8Array) => void;
}
// editor props
@@ -66,7 +58,7 @@ export interface IEditorProps {
value?: string | null;
}
export type ILiteTextEditor = IEditorProps;
export interface ILiteTextEditor extends IEditorProps {}
export interface IRichTextEditor extends IEditorProps {
dragDropEnabled?: boolean;
@@ -97,9 +89,9 @@ export interface IReadOnlyEditorProps {
};
}
export type ILiteTextReadOnlyEditor = IReadOnlyEditorProps;
export interface ILiteTextReadOnlyEditor extends IReadOnlyEditorProps {}
export type IRichTextReadOnlyEditor = IReadOnlyEditorProps;
export interface IRichTextReadOnlyEditor extends IReadOnlyEditorProps {}
export interface ICollaborativeDocumentReadOnlyEditor extends Omit<IReadOnlyEditorProps, "initialValue"> {
embedHandler: TEmbedConfig;
-1
View File
@@ -21,7 +21,6 @@ export { isCellSelection } from "@/extensions/table/table/utilities/is-cell-sele
// helpers
export * from "@/helpers/common";
export * from "@/helpers/editor-commands";
export * from "@/helpers/yjs";
export * from "@/extensions/table/table";
// components
+5 -1
View File
@@ -4,7 +4,11 @@
display: flex;
align-items: center;
opacity: 1;
transition: opacity 0.2s ease 0.2s;
transition:
opacity 0.2s ease 0.2s,
top 0.2s ease,
left 0.2s ease;
transform: translateX(-50%);
&.side-menu-hidden {
opacity: 0;
+4 -2
View File
@@ -125,7 +125,7 @@
margin-top: 0 !important;
margin-bottom: 0;
&:not(.read-only-image):not(.loading-image) {
&:not(.read-only-image) {
transition: filter 0.1s ease-in-out;
&:hover {
@@ -243,7 +243,8 @@ ul[data-type="taskList"] li > div > p {
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: rgb(var(--color-text-400));
transition: color 0.2s ease;
text-decoration: line-through;
text-decoration-thickness: 2px;
}
/* end to-do list */
@@ -263,6 +264,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
transition: opacity 0.2s ease-out;
}
@keyframes spinning {
to {
transform: rotate(360deg);
+5 -11
View File
@@ -1,19 +1,13 @@
{
"extends": "tsconfig/react-library.json",
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"],
"compilerOptions": {
"jsx": "react-jsx",
"lib": ["ES2015", "DOM"],
"module": "ESNext",
"moduleResolution": "Node",
"target": "ES6",
"sourceMap": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/core/*"],
"@/styles/*": ["src/styles/*"],
"@/plane-editor/*": ["src/ce/*"]
},
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*", "index.d.ts"],
"exclude": ["dist", "build", "node_modules"]
}
}
}
@@ -1,26 +1,20 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["prettier", "plugin:@typescript-eslint/recommended"],
extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"],
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2021, // Or the ECMAScript version you are using
sourceType: "module", // Or 'script' if you're using CommonJS or other modules
},
plugins: ["react", "@typescript-eslint", "import"],
globals: {
React: true,
JSX: true,
},
env: {
node: true,
browser: true,
},
settings: {
"import/resolver": {
typescript: {
project,
},
next: {
rootDir: ["."],
},
},
globals: {
React: "readonly",
JSX: "readonly",
},
rules: {
"no-useless-escape": "off",
"prefer-const": "error",
@@ -38,12 +32,18 @@ module.exports = {
"react/self-closing-comp": ["error", { component: true, html: true }],
"react/jsx-boolean-value": "error",
"react/jsx-no-duplicate-props": "error",
// "react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-expressions": "warn",
"@typescript-eslint/no-unused-vars": ["warn"],
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-useless-empty-export": "error",
"@typescript-eslint/prefer-ts-expect-error": "warn",
"@typescript-eslint/prefer-ts-expect-error": "error",
"@typescript-eslint/naming-convention": [
"error",
{
selector: ["function", "variable"],
format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"],
leadingUnderscore: "allow",
},
],
},
ignorePatterns: [".*.js", "node_modules/", "dist/"],
};
@@ -0,0 +1,18 @@
{
"name": "eslint-config-custom",
"private": true,
"version": "0.22.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "^7.1.1",
"@typescript-eslint/parser": "^7.1.1",
"eslint": "^8.57.0",
"eslint-config-next": "^14.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.12.4",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react": "^7.33.2",
"typescript": "5.4.5"
}
}
-92
View File
@@ -1,92 +0,0 @@
const { resolve } = require("node:path");
const project = resolve(process.cwd(), "tsconfig.json");
module.exports = {
extends: ["next", "prettier", "plugin:@typescript-eslint/recommended"],
globals: {
React: "readonly",
JSX: "readonly",
},
env: {
node: true,
browser: true,
},
plugins: ["react", "@typescript-eslint", "import"],
settings: {
"import/resolver": {
typescript: {
project,
},
},
},
ignorePatterns: [".*.js", "node_modules/"],
rules: {
"no-useless-escape": "off",
"prefer-const": "error",
"no-irregular-whitespace": "error",
"no-trailing-spaces": "error",
"no-duplicate-imports": "error",
"no-useless-catch": "warn",
"no-case-declarations": "error",
"no-undef": "error",
"no-unreachable": "error",
"arrow-body-style": ["error", "as-needed"],
"@next/next/no-html-link-for-pages": "off",
"@next/next/no-img-element": "off",
"react/jsx-key": "error",
"react/self-closing-comp": ["error", { component: true, html: true }],
"react/jsx-boolean-value": "error",
"react/jsx-no-duplicate-props": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/no-unused-expressions": "warn",
"@typescript-eslint/no-unused-vars": ["warn"],
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-useless-empty-export": "error",
"@typescript-eslint/prefer-ts-expect-error": "warn",
"@typescript-eslint/naming-convention": [
"warn",
{
selector: "variable",
format: ["camelCase", "snake_case", "UPPER_CASE", "PascalCase"],
leadingUnderscore: "allow",
},
],
"import/order": [
"error",
{
groups: ["builtin", "external", "internal", "parent", "sibling"],
pathGroups: [
{
pattern: "react",
group: "external",
position: "before",
},
{
pattern: "lucide-react",
group: "external",
position: "after",
},
{
pattern: "@headlessui/**",
group: "external",
position: "after",
},
{
pattern: "@plane/**",
group: "external",
position: "after",
},
{
pattern: "@/**",
group: "internal",
},
],
pathGroupsExcludedImportTypes: ["builtin", "internal", "react"],
alphabetize: {
order: "asc",
caseInsensitive: true,
},
},
],
},
};
-21
View File
@@ -1,21 +0,0 @@
{
"name": "@plane/eslint-config",
"private": true,
"version": "0.22.0",
"files": [
"library.js",
"next.js",
"server.js"
],
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.6.0",
"@typescript-eslint/parser": "^8.6.0",
"eslint": "8",
"eslint-config-next": "^14.1.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-turbo": "^1.12.4",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-react": "^7.33.2",
"typescript": "5.3.3"
}
}
-11
View File
@@ -1,11 +0,0 @@
module.exports = {
extends: ["eslint:recommended"],
env: {
node: true,
es6: true,
},
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
};
@@ -1,15 +1,15 @@
import React, { useEffect } from "react";
export const useOutsideClickDetector = (
ref: React.RefObject<HTMLElement> | any,
ref: React.RefObject<HTMLElement>,
callback: () => void,
useCapture = false
) => {
const handleClick = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as any)) {
if (ref.current && !ref.current.contains(event.target as Node)) {
// check for the closest element with attribute name data-prevent-outside-click
const preventOutsideClickElement = (
event.target as unknown as HTMLElement | undefined
event.target as HTMLElement | undefined
)?.closest("[data-prevent-outside-click]");
// if the closest element with attribute name data-prevent-outside-click is found, return
if (preventOutsideClickElement) {

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