Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8303acb723 | |||
| c18c1a47dd | |||
| f8aca6a574 | |||
| 295fba6a0d | |||
| d94046e69a | |||
| 432be1a506 |
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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/"],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"*.{ts,tsx,js,jsx}": ["eslint -c ./.eslintrc-staged.js", "prettier --check"]
|
||||
}
|
||||
+35
-30
@@ -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 community’s 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 user’s 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
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -114,7 +114,6 @@ from .issue.base import (
|
||||
IssueViewSet,
|
||||
IssueUserDisplayPropertyEndpoint,
|
||||
BulkDeleteIssuesEndpoint,
|
||||
DeletedIssuesListViewSet,
|
||||
IssuePaginatedViewSet,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -97,6 +97,6 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
"state__name",
|
||||
"state__group",
|
||||
"state__color",
|
||||
)[:100],
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
.turbo/*
|
||||
out/*
|
||||
dist/*
|
||||
public/*
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["@plane/eslint-config/server.js"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": true
|
||||
}
|
||||
}
|
||||
+3
-8
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -1 +1,2 @@
|
||||
export type TAdditionalDocumentTypes = {};
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export type TAdditionalDocumentTypes = {}
|
||||
@@ -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,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,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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
+2
-5
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user