Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 015f8cbfe5 | |||
| 42b38db131 | |||
| 0fe7da6265 | |||
| 8801ab0081 | |||
| c2464939fc | |||
| 34e231230f | |||
| eb5ac2fc2d | |||
| b6cf3a5a8b | |||
| bbc465a3b2 | |||
| 9a77e383cd | |||
| a2d9e70a83 | |||
| c7763dd431 | |||
| 599ff2eec4 | |||
| 568a1bb228 | |||
| 935e4b5c33 | |||
| 841388e437 | |||
| 9ecea15d74 | |||
| 4ad88c969c | |||
| 706085395e | |||
| a0f7acae42 | |||
| 6e5549c439 | |||
| cf8eeee03a | |||
| d3b26996dd | |||
| d0f26f8734 | |||
| e86b40ac82 | |||
| c209a713d8 | |||
| f10cd92610 |
@@ -97,3 +97,5 @@ dev-editor
|
||||
# Redis
|
||||
*.rdb
|
||||
*.rdb.gz
|
||||
|
||||
storybook-static
|
||||
|
||||
@@ -18,9 +18,6 @@ public-hoist-pattern[]=eslint
|
||||
public-hoist-pattern[]=prettier
|
||||
public-hoist-pattern[]=typescript
|
||||
|
||||
# Enforce Node version for consistent installs
|
||||
use-node-version=22.18.0
|
||||
|
||||
# Reproducible installs across CI and dev
|
||||
prefer-frozen-lockfile=true
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
"archive_in",
|
||||
"close_in",
|
||||
"timezone",
|
||||
"logo_props",
|
||||
"external_source",
|
||||
"external_id",
|
||||
"is_issue_type_enabled",
|
||||
]
|
||||
|
||||
read_only_fields = [
|
||||
|
||||
@@ -4,7 +4,7 @@ from plane.api.views import ProjectMemberAPIEndpoint, WorkspaceMemberAPIEndpoint
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<str:project_id>/members/",
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/members/",
|
||||
ProjectMemberAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="project-members",
|
||||
),
|
||||
|
||||
@@ -908,9 +908,14 @@ class IssueLiteSerializer(DynamicBaseSerializer):
|
||||
class IssueDetailSerializer(IssueSerializer):
|
||||
description_html = serializers.CharField()
|
||||
is_subscribed = serializers.BooleanField(read_only=True)
|
||||
is_intake = serializers.BooleanField(read_only=True)
|
||||
|
||||
class Meta(IssueSerializer.Meta):
|
||||
fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"]
|
||||
fields = IssueSerializer.Meta.fields + [
|
||||
"description_html",
|
||||
"is_subscribed",
|
||||
"is_intake",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ from plane.db.models import (
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
IssueLabel,
|
||||
IntakeIssue,
|
||||
)
|
||||
from plane.utils.grouper import (
|
||||
issue_group_values,
|
||||
@@ -1223,7 +1224,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
|
||||
# Fetch the issue
|
||||
issue = (
|
||||
Issue.issue_objects.filter(project_id=project.id)
|
||||
Issue.objects.filter(project_id=project.id)
|
||||
.filter(workspace__slug=slug)
|
||||
.select_related("workspace", "project", "state", "parent")
|
||||
.prefetch_related("assignees", "labels", "issue_module__module")
|
||||
@@ -1315,6 +1316,16 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
|
||||
)
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
is_intake=Exists(
|
||||
IntakeIssue.objects.filter(
|
||||
issue=OuterRef("id"),
|
||||
status__in=[-2, 0],
|
||||
workspace__slug=slug,
|
||||
project_id=project.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
# Check if the issue exists
|
||||
|
||||
@@ -59,9 +59,10 @@ class IssueSearchEndpoint(BaseAPIView):
|
||||
)
|
||||
|
||||
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
|
||||
related_issue_ids.append(issue_id)
|
||||
|
||||
if issue:
|
||||
issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids))
|
||||
issues = issues.exclude(pk__in=related_issue_ids)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
from plane.db.models import APIActivityLog
|
||||
from celery import shared_task
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
# Get the logs older than 30 days to delete
|
||||
logs_to_delete = APIActivityLog.objects.filter(
|
||||
created_at__lte=timezone.now() - timedelta(days=30)
|
||||
)
|
||||
|
||||
# Delete the logs
|
||||
logs_to_delete._raw_delete(logs_to_delete.db)
|
||||
@@ -0,0 +1,423 @@
|
||||
# Python imports
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import List, Dict, Any, Callable, Optional
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.db.models import F, Window, Subquery
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
from pymongo.errors import BulkWriteError
|
||||
from pymongo.collection import Collection
|
||||
from pymongo.operations import InsertOne
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
EmailNotificationLog,
|
||||
PageVersion,
|
||||
APIActivityLog,
|
||||
IssueDescriptionVersion,
|
||||
)
|
||||
from plane.settings.mongo import MongoConnection
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
|
||||
def get_mongo_collection(collection_name: str) -> Optional[Collection]:
|
||||
"""Get MongoDB collection if available, otherwise return None."""
|
||||
if not MongoConnection.is_configured():
|
||||
logger.info("MongoDB not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
mongo_collection = MongoConnection.get_collection(collection_name)
|
||||
logger.info(f"MongoDB collection '{collection_name}' connected successfully")
|
||||
return mongo_collection
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get MongoDB collection: {str(e)}")
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
|
||||
def flush_to_mongo_and_delete(
|
||||
mongo_collection: Optional[Collection],
|
||||
buffer: List[Dict[str, Any]],
|
||||
ids_to_delete: List[int],
|
||||
model,
|
||||
mongo_available: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Inserts a batch of records into MongoDB and deletes the corresponding rows from PostgreSQL.
|
||||
"""
|
||||
if not buffer:
|
||||
logger.debug("No records to flush - buffer is empty")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
f"Starting batch flush: {len(buffer)} records, {len(ids_to_delete)} IDs to delete"
|
||||
)
|
||||
|
||||
mongo_archival_failed = False
|
||||
|
||||
# Try to insert into MongoDB if available
|
||||
if mongo_collection and mongo_available:
|
||||
try:
|
||||
mongo_collection.bulk_write([InsertOne(doc) for doc in buffer])
|
||||
except BulkWriteError as bwe:
|
||||
logger.error(f"MongoDB bulk write error: {str(bwe)}")
|
||||
log_exception(bwe)
|
||||
mongo_archival_failed = True
|
||||
|
||||
# If MongoDB is available and archival failed, log the error and return
|
||||
if mongo_available and mongo_archival_failed:
|
||||
logger.error(f"MongoDB archival failed for {len(buffer)} records")
|
||||
return
|
||||
|
||||
# Delete from PostgreSQL - delete() returns (count, {model: count})
|
||||
delete_result = model.all_objects.filter(id__in=ids_to_delete).delete()
|
||||
deleted_count = (
|
||||
delete_result[0] if delete_result and isinstance(delete_result, tuple) else 0
|
||||
)
|
||||
logger.info(f"Batch flush completed: {deleted_count} records deleted")
|
||||
|
||||
|
||||
def process_cleanup_task(
|
||||
queryset_func: Callable,
|
||||
transform_func: Callable[[Dict], Dict],
|
||||
model,
|
||||
task_name: str,
|
||||
collection_name: str,
|
||||
):
|
||||
"""
|
||||
Generic function to process cleanup tasks.
|
||||
|
||||
Args:
|
||||
queryset_func: Function that returns the queryset to process
|
||||
transform_func: Function to transform each record for MongoDB
|
||||
model: Django model class
|
||||
task_name: Name of the task for logging
|
||||
collection_name: MongoDB collection name
|
||||
"""
|
||||
logger.info(f"Starting {task_name} cleanup task")
|
||||
|
||||
# Get MongoDB collection
|
||||
mongo_collection = get_mongo_collection(collection_name)
|
||||
mongo_available = mongo_collection is not None
|
||||
|
||||
# Get queryset
|
||||
queryset = queryset_func()
|
||||
|
||||
# Process records in batches
|
||||
buffer: List[Dict[str, Any]] = []
|
||||
ids_to_delete: List[int] = []
|
||||
total_processed = 0
|
||||
total_batches = 0
|
||||
|
||||
for record in queryset:
|
||||
# Transform record for MongoDB
|
||||
buffer.append(transform_func(record))
|
||||
ids_to_delete.append(record["id"])
|
||||
|
||||
# Flush batch when it reaches BATCH_SIZE
|
||||
if len(buffer) >= BATCH_SIZE:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
buffer.clear()
|
||||
ids_to_delete.clear()
|
||||
|
||||
# Process final batch if any records remain
|
||||
if buffer:
|
||||
total_batches += 1
|
||||
flush_to_mongo_and_delete(
|
||||
mongo_collection=mongo_collection,
|
||||
buffer=buffer,
|
||||
ids_to_delete=ids_to_delete,
|
||||
model=model,
|
||||
mongo_available=mongo_available,
|
||||
)
|
||||
total_processed += len(buffer)
|
||||
|
||||
logger.info(
|
||||
f"{task_name} cleanup task completed",
|
||||
extra={
|
||||
"total_records_processed": total_processed,
|
||||
"total_batches": total_batches,
|
||||
"mongo_available": mongo_available,
|
||||
"collection_name": collection_name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Transform functions for each model
|
||||
def transform_api_log(record: Dict) -> Dict:
|
||||
"""Transform API activity log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"token_identifier": record["token_identifier"],
|
||||
"path": record["path"],
|
||||
"method": record["method"],
|
||||
"query_params": record.get("query_params"),
|
||||
"headers": record.get("headers"),
|
||||
"body": record.get("body"),
|
||||
"response_code": record["response_code"],
|
||||
"response_body": record["response_body"],
|
||||
"ip_address": record["ip_address"],
|
||||
"user_agent": record["user_agent"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
}
|
||||
|
||||
|
||||
def transform_email_log(record: Dict) -> Dict:
|
||||
"""Transform email notification log record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"receiver_id": record["receiver_id"],
|
||||
"triggered_by_id": record["triggered_by_id"],
|
||||
"entity_identifier": record["entity_identifier"],
|
||||
"entity_name": record["entity_name"],
|
||||
"data": record["data"],
|
||||
"processed_at": (
|
||||
str(record["processed_at"]) if record.get("processed_at") else None
|
||||
),
|
||||
"sent_at": str(record["sent_at"]) if record.get("sent_at") else None,
|
||||
"entity": record["entity"],
|
||||
"old_value": record["old_value"],
|
||||
"new_value": record["new_value"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
}
|
||||
|
||||
|
||||
def transform_page_version(record: Dict) -> Dict:
|
||||
"""Transform page version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"page_id": record["page_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"description_html": record["description_html"],
|
||||
"description_binary": record["description_binary"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"sub_pages_data": record["sub_pages_data"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def transform_issue_description_version(record: Dict) -> Dict:
|
||||
"""Transform issue description version record."""
|
||||
return {
|
||||
"id": record["id"],
|
||||
"created_at": str(record["created_at"]) if record.get("created_at") else None,
|
||||
"issue_id": record["issue_id"],
|
||||
"workspace_id": record["workspace_id"],
|
||||
"project_id": record["project_id"],
|
||||
"created_by_id": record["created_by_id"],
|
||||
"updated_by_id": record["updated_by_id"],
|
||||
"owned_by_id": record["owned_by_id"],
|
||||
"last_saved_at": (
|
||||
str(record["last_saved_at"]) if record.get("last_saved_at") else None
|
||||
),
|
||||
"description_binary": record["description_binary"],
|
||||
"description_html": record["description_html"],
|
||||
"description_stripped": record["description_stripped"],
|
||||
"description_json": record["description_json"],
|
||||
"deleted_at": str(record["deleted_at"]) if record.get("deleted_at") else None,
|
||||
}
|
||||
|
||||
|
||||
# Queryset functions for each cleanup task
|
||||
def get_api_logs_queryset():
|
||||
"""Get API logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"API logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
APIActivityLog.all_objects.filter(created_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"token_identifier",
|
||||
"path",
|
||||
"method",
|
||||
"query_params",
|
||||
"headers",
|
||||
"body",
|
||||
"response_code",
|
||||
"response_body",
|
||||
"ip_address",
|
||||
"user_agent",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_email_logs_queryset():
|
||||
"""Get email logs older than cutoff days."""
|
||||
cutoff_days = int(os.environ.get("HARD_DELETE_AFTER_DAYS", 30))
|
||||
cutoff_time = timezone.now() - timedelta(days=cutoff_days)
|
||||
logger.info(f"Email logs cutoff time: {cutoff_time}")
|
||||
|
||||
return (
|
||||
EmailNotificationLog.all_objects.filter(sent_at__lte=cutoff_time)
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"receiver_id",
|
||||
"triggered_by_id",
|
||||
"entity_identifier",
|
||||
"entity_name",
|
||||
"data",
|
||||
"processed_at",
|
||||
"sent_at",
|
||||
"entity",
|
||||
"old_value",
|
||||
"new_value",
|
||||
"created_by_id",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_page_versions_queryset():
|
||||
"""Get page versions beyond the maximum allowed (20 per page)."""
|
||||
subq = (
|
||||
PageVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("page_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
PageVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"page_id",
|
||||
"workspace_id",
|
||||
"owned_by_id",
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"sub_pages_data",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"deleted_at",
|
||||
"last_saved_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
def get_issue_description_versions_queryset():
|
||||
"""Get issue description versions beyond the maximum allowed (20 per issue)."""
|
||||
subq = (
|
||||
IssueDescriptionVersion.all_objects.annotate(
|
||||
row_num=Window(
|
||||
expression=RowNumber(),
|
||||
partition_by=[F("issue_id")],
|
||||
order_by=F("created_at").desc(),
|
||||
)
|
||||
)
|
||||
.filter(row_num__gt=20)
|
||||
.values("id")
|
||||
)
|
||||
|
||||
return (
|
||||
IssueDescriptionVersion.all_objects.filter(id__in=Subquery(subq))
|
||||
.values(
|
||||
"id",
|
||||
"created_at",
|
||||
"issue_id",
|
||||
"workspace_id",
|
||||
"project_id",
|
||||
"created_by_id",
|
||||
"updated_by_id",
|
||||
"owned_by_id",
|
||||
"last_saved_at",
|
||||
"description_binary",
|
||||
"description_html",
|
||||
"description_stripped",
|
||||
"description_json",
|
||||
"deleted_at",
|
||||
)
|
||||
.iterator(chunk_size=BATCH_SIZE)
|
||||
)
|
||||
|
||||
|
||||
# Celery tasks - now much simpler!
|
||||
@shared_task
|
||||
def delete_api_logs():
|
||||
"""Delete old API activity logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_api_logs_queryset,
|
||||
transform_func=transform_api_log,
|
||||
model=APIActivityLog,
|
||||
task_name="API Activity Log",
|
||||
collection_name="api_activity_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_email_notification_logs():
|
||||
"""Delete old email notification logs."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_email_logs_queryset,
|
||||
transform_func=transform_email_log,
|
||||
model=EmailNotificationLog,
|
||||
task_name="Email Notification Log",
|
||||
collection_name="email_notification_logs",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_page_versions():
|
||||
"""Delete excess page versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_page_versions_queryset,
|
||||
transform_func=transform_page_version,
|
||||
model=PageVersion,
|
||||
task_name="Page Version",
|
||||
collection_name="page_versions",
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def delete_issue_description_versions():
|
||||
"""Delete excess issue description versions."""
|
||||
process_cleanup_task(
|
||||
queryset_func=get_issue_description_versions_queryset,
|
||||
transform_func=transform_issue_description_version,
|
||||
model=IssueDescriptionVersion,
|
||||
task_name="Issue Description Version",
|
||||
collection_name="issue_description_versions",
|
||||
)
|
||||
@@ -50,9 +50,21 @@ app.conf.beat_schedule = {
|
||||
"schedule": crontab(hour=2, minute=0), # UTC 02:00
|
||||
},
|
||||
"check-every-day-to-delete-api-logs": {
|
||||
"task": "plane.bgtasks.api_logs_task.delete_api_logs",
|
||||
"task": "plane.bgtasks.cleanup_task.delete_api_logs",
|
||||
"schedule": crontab(hour=2, minute=30), # UTC 02:30
|
||||
},
|
||||
"check-every-day-to-delete-email-notification-logs": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_email_notification_logs",
|
||||
"schedule": crontab(hour=3, minute=0), # UTC 03:00
|
||||
},
|
||||
"check-every-day-to-delete-page-versions": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_page_versions",
|
||||
"schedule": crontab(hour=3, minute=30), # UTC 03:30
|
||||
},
|
||||
"check-every-day-to-delete-issue-description-versions": {
|
||||
"task": "plane.bgtasks.cleanup_task.delete_issue_description_versions",
|
||||
"schedule": crontab(hour=4, minute=0), # UTC 04:00
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -284,7 +284,7 @@ CELERY_IMPORTS = (
|
||||
"plane.bgtasks.exporter_expired_task",
|
||||
"plane.bgtasks.file_asset_task",
|
||||
"plane.bgtasks.email_notification_task",
|
||||
"plane.bgtasks.api_logs_task",
|
||||
"plane.bgtasks.cleanup_task",
|
||||
"plane.license.bgtasks.tracer",
|
||||
# management tasks
|
||||
"plane.bgtasks.dummy_data_task",
|
||||
|
||||
@@ -73,5 +73,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.mongo": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
import logging
|
||||
|
||||
# Third party imports
|
||||
from pymongo import MongoClient
|
||||
from pymongo.database import Database
|
||||
from pymongo.collection import Collection
|
||||
from typing import Optional, TypeVar, Type
|
||||
|
||||
|
||||
T = TypeVar("T", bound="MongoConnection")
|
||||
|
||||
# Set up logger
|
||||
logger = logging.getLogger("plane.mongo")
|
||||
|
||||
|
||||
class MongoConnection:
|
||||
"""
|
||||
A singleton class that manages MongoDB connections.
|
||||
|
||||
This class ensures only one MongoDB connection is maintained throughout the application.
|
||||
It provides methods to access the MongoDB client, database, and collections.
|
||||
|
||||
Attributes:
|
||||
_instance (Optional[MongoConnection]): The singleton instance of this class
|
||||
_client (Optional[MongoClient]): The MongoDB client instance
|
||||
_db (Optional[Database]): The MongoDB database instance
|
||||
"""
|
||||
|
||||
_instance: Optional["MongoConnection"] = None
|
||||
_client: Optional[MongoClient] = None
|
||||
_db: Optional[Database] = None
|
||||
|
||||
def __new__(cls: Type[T]) -> T:
|
||||
"""
|
||||
Creates a new instance of MongoConnection if one doesn't exist.
|
||||
|
||||
Returns:
|
||||
MongoConnection: The singleton instance
|
||||
"""
|
||||
if cls._instance is None:
|
||||
cls._instance = super(MongoConnection, cls).__new__(cls)
|
||||
try:
|
||||
mongo_url = getattr(settings, "MONGO_DB_URL", None)
|
||||
mongo_db_database = getattr(settings, "MONGO_DB_DATABASE", None)
|
||||
|
||||
if not mongo_url or not mongo_db_database:
|
||||
logger.warning(
|
||||
"MongoDB connection parameters not configured. MongoDB functionality will be disabled."
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
cls._client = MongoClient(mongo_url)
|
||||
cls._db = cls._client[mongo_db_database]
|
||||
|
||||
# Test the connection
|
||||
cls._client.server_info()
|
||||
logger.info("MongoDB connection established successfully")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize MongoDB connection: {str(e)}. MongoDB functionality will be disabled."
|
||||
)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_client(cls) -> Optional[MongoClient]:
|
||||
"""
|
||||
Returns the MongoDB client instance.
|
||||
|
||||
Returns:
|
||||
Optional[MongoClient]: The MongoDB client instance or None if not configured
|
||||
"""
|
||||
if cls._client is None:
|
||||
cls._instance = cls()
|
||||
return cls._client
|
||||
|
||||
@classmethod
|
||||
def get_db(cls) -> Optional[Database]:
|
||||
"""
|
||||
Returns the MongoDB database instance.
|
||||
|
||||
Returns:
|
||||
Optional[Database]: The MongoDB database instance or None if not configured
|
||||
"""
|
||||
if cls._db is None:
|
||||
cls._instance = cls()
|
||||
return cls._db
|
||||
|
||||
@classmethod
|
||||
def get_collection(cls, collection_name: str) -> Optional[Collection]:
|
||||
"""
|
||||
Returns a MongoDB collection by name.
|
||||
|
||||
Args:
|
||||
collection_name (str): The name of the collection to retrieve
|
||||
|
||||
Returns:
|
||||
Optional[Collection]: The MongoDB collection instance or None if not configured
|
||||
"""
|
||||
try:
|
||||
db = cls.get_db()
|
||||
if db is None:
|
||||
logger.warning(
|
||||
f"Cannot access collection '{collection_name}': MongoDB not configured"
|
||||
)
|
||||
return None
|
||||
return db[collection_name]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to access collection '{collection_name}': {str(e)}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def is_configured(cls) -> bool:
|
||||
"""
|
||||
Check if MongoDB is properly configured and connected.
|
||||
|
||||
Returns:
|
||||
bool: True if MongoDB is configured and connected, False otherwise
|
||||
"""
|
||||
return cls._client is not None and cls._db is not None
|
||||
@@ -83,5 +83,10 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.mongo": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ psycopg==3.1.18
|
||||
psycopg-binary==3.1.18
|
||||
psycopg-c==3.1.18
|
||||
dj-database-url==2.1.0
|
||||
# mongo
|
||||
pymongo==4.6.3
|
||||
# redis
|
||||
redis==5.0.4
|
||||
django-redis==5.4.0
|
||||
@@ -66,4 +68,4 @@ opentelemetry-sdk==1.28.1
|
||||
opentelemetry-instrumentation-django==0.49b1
|
||||
opentelemetry-exporter-otlp==1.28.1
|
||||
# OpenAPI Specification
|
||||
drf-spectacular==0.28.0
|
||||
drf-spectacular==0.28.0
|
||||
|
||||
@@ -14,6 +14,7 @@ export abstract class APIService {
|
||||
this.axiosInstance = axios.create({
|
||||
baseURL,
|
||||
withCredentials: true,
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export const isCommentEmpty = (comment: string | undefined): boolean => {
|
||||
return (
|
||||
comment?.trim() === "" ||
|
||||
comment === "<p></p>" ||
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component"])
|
||||
isEmptyHtmlString(comment ?? "", ["img", "mention-component", "image-component", "embed-component"])
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ const IssueDetailsPage = observer(() => {
|
||||
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
|
||||
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.is_intake) {
|
||||
router.push(`/${workspaceSlug}/projects/${data.project_id}/intake/?currentTab=open&inboxIssueId=${data?.id}`);
|
||||
}
|
||||
}, [workspaceSlug, data]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
// components
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake";
|
||||
import { ProjectInboxHeader } from "@/plane-web/components/projects/settings/intake/header";
|
||||
|
||||
export default function ProjectInboxIssuesLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { redirect, useParams } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTheme } from "next-themes";
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
@@ -34,7 +34,7 @@ const IssueDetailsPage = observer(() => {
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
redirect(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
|
||||
router.push(`/${workspaceSlug}/browse/${data.project_identifier}-${data.sequence_id}`);
|
||||
}
|
||||
}, [workspaceSlug, data]);
|
||||
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import { SettingsHeading } from "@/components/settings/heading";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web imports
|
||||
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces";
|
||||
import { ProjectTeamspaceList } from "@/plane-web/components/projects/teamspaces/teamspace-list";
|
||||
import { getProjectSettingsPageLabelI18nKey } from "@/plane-web/helpers/project-settings";
|
||||
|
||||
const MembersSettingsPage = observer(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { TNavigationItem } from "@/components/workspace/sidebar/project-nav
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
// local imports
|
||||
import { getProjectFeatureNavigation } from "../projects/navigation";
|
||||
import { getProjectFeatureNavigation } from "../projects/navigation/helper";
|
||||
|
||||
type TProjectFeatureBreadcrumbProps = {
|
||||
workspaceSlug: string;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./subscription";
|
||||
export * from "./extended-app-header";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./subscription-pill";
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from "./de-dupe-button";
|
||||
export * from "./duplicate-modal";
|
||||
export * from "./duplicate-popover";
|
||||
export * from "./issue-block";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./button-label";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./epic-modal";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./helper";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./header";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./teamspace-list";
|
||||
@@ -16,10 +16,11 @@ type Props = {
|
||||
params: IAnalyticsParams;
|
||||
workspaceSlug: string;
|
||||
classNames?: string;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const AnalyticsSelectParams: React.FC<Props> = observer((props) => {
|
||||
const { control, params, classNames } = props;
|
||||
const { control, params, classNames, isEpic } = props;
|
||||
const xAxisOptions = useMemo(
|
||||
() => ANALYTICS_X_AXIS_VALUES.filter((option) => option.value !== params.group_by),
|
||||
[params.group_by]
|
||||
@@ -42,7 +43,10 @@ export const AnalyticsSelectParams: React.FC<Props> = observer((props) => {
|
||||
onChange(val);
|
||||
}}
|
||||
options={ANALYTICS_Y_AXIS_VALUES}
|
||||
hiddenOptions={[ChartYAxisMetric.ESTIMATE_POINT_COUNT]}
|
||||
hiddenOptions={[
|
||||
ChartYAxisMetric.ESTIMATE_POINT_COUNT,
|
||||
isEpic ? ChartYAxisMetric.WORK_ITEM_COUNT : ChartYAxisMetric.EPIC_WORK_ITEM_COUNT,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -10,17 +10,13 @@ import AnalyticsSectionWrapper from "../analytics-section-wrapper";
|
||||
import { AnalyticsSelectParams } from "../select/analytics-params";
|
||||
import PriorityChart from "./priority-chart";
|
||||
|
||||
const defaultValues: IAnalyticsParams = {
|
||||
x_axis: ChartXAxisProperty.PRIORITY,
|
||||
y_axis: ChartYAxisMetric.WORK_ITEM_COUNT,
|
||||
};
|
||||
|
||||
const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
|
||||
const CustomizedInsights = observer(({ peekView, isEpic }: { peekView?: boolean; isEpic?: boolean }) => {
|
||||
const { t } = useTranslation();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { control, watch, setValue } = useForm<IAnalyticsParams>({
|
||||
defaultValues: {
|
||||
...defaultValues,
|
||||
x_axis: ChartXAxisProperty.PRIORITY,
|
||||
y_axis: isEpic ? ChartYAxisMetric.EPIC_WORK_ITEM_COUNT : ChartYAxisMetric.WORK_ITEM_COUNT,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +37,7 @@ const CustomizedInsights = observer(({ peekView }: { peekView?: boolean }) => {
|
||||
setValue={setValue}
|
||||
params={params}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -17,10 +17,11 @@ type Props = {
|
||||
projectDetails: IProject | undefined;
|
||||
cycleDetails: ICycle | undefined;
|
||||
moduleDetails: IModule | undefined;
|
||||
isEpic?: boolean;
|
||||
};
|
||||
|
||||
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
|
||||
const { projectDetails, cycleDetails, moduleDetails, fullScreen } = props;
|
||||
const { projectDetails, cycleDetails, moduleDetails, fullScreen, isEpic } = props;
|
||||
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalytics();
|
||||
const [isModalConfigured, setIsModalConfigured] = useState(false);
|
||||
|
||||
@@ -72,7 +73,7 @@ export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
|
||||
<div className="flex flex-col gap-14 overflow-y-auto p-6">
|
||||
<TotalInsights analyticsType="work-items" peekView={!fullScreen} />
|
||||
<CreatedVsResolved />
|
||||
<CustomizedInsights peekView={!fullScreen} />
|
||||
<CustomizedInsights peekView={!fullScreen} isEpic={isEpic} />
|
||||
<WorkItemsInsightTable />
|
||||
</div>
|
||||
</Tab.Group>
|
||||
|
||||
@@ -20,7 +20,7 @@ type Props = {
|
||||
|
||||
export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails, isEpic } = props;
|
||||
const { updateIsEpic } = useAnalytics();
|
||||
const { updateIsEpic, isPeekView } = useAnalytics();
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -29,18 +29,18 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateIsEpic(isEpic ?? false);
|
||||
}, [isEpic, updateIsEpic]);
|
||||
updateIsEpic(isPeekView ? (isEpic ?? false) : false);
|
||||
}, [isEpic, updateIsEpic, isPeekView]);
|
||||
|
||||
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const content = (
|
||||
<div className={cn("inset-0 z-[25] h-full w-full overflow-y-auto", fullScreen ? "absolute" : "fixed")}>
|
||||
<div className={cn("z-[25] h-full w-full overflow-y-auto absolute")}>
|
||||
<div
|
||||
className={`right-0 top-0 z-20 h-full bg-custom-background-100 shadow-custom-shadow-md ${
|
||||
fullScreen ? "w-full p-2 absolute" : "w-full sm:w-full md:w-1/2 fixed"
|
||||
className={`top-0 right-0 z-[25] h-full bg-custom-background-100 shadow-custom-shadow-md absolute ${
|
||||
fullScreen ? "w-full p-2" : "w-1/2"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -61,11 +61,12 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
projectDetails={projectDetails}
|
||||
cycleDetails={cycleDetails}
|
||||
moduleDetails={moduleDetails}
|
||||
isEpic={isEpic}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return fullScreen && portalContainer ? createPortal(content, portalContainer) : content;
|
||||
return portalContainer ? createPortal(content, portalContainer) : content;
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Row } from "@plane/ui";
|
||||
// components
|
||||
import { ExtendedAppHeader } from "@/plane-web/components/common";
|
||||
import { ExtendedAppHeader } from "@/plane-web/components/common/extended-app-header";
|
||||
|
||||
export interface AppHeaderProps {
|
||||
header: ReactNode;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { filter } from "lodash";
|
||||
import { Rocket, Search, X } from "lucide-react";
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
// i18n
|
||||
@@ -20,7 +21,6 @@ import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/iss
|
||||
import { ProjectService } from "@/services/project";
|
||||
// components
|
||||
import { IssueSearchModalEmptyState } from "./issue-search-modal-empty-state";
|
||||
import { filter } from "lodash";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string | undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { FC, MouseEvent, useRef } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Check } from "lucide-react";
|
||||
@@ -53,11 +53,6 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
const isCompleted = cycleStatus === "completed";
|
||||
const isActive = cycleStatus === "current";
|
||||
|
||||
const completionPercentage =
|
||||
((cycleDetails.completed_issues + cycleDetails.cancelled_issues) / cycleDetails.total_issues) * 100;
|
||||
|
||||
const progress = isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
|
||||
|
||||
// handlers
|
||||
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -78,6 +73,21 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
|
||||
const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined;
|
||||
|
||||
const getCycleProgress = () => {
|
||||
let completionPercentage =
|
||||
((cycleDetails.completed_issues + cycleDetails.cancelled_issues) / cycleDetails.total_issues) * 100;
|
||||
|
||||
if (isCompleted && !isEmpty(cycleDetails.progress_snapshot)) {
|
||||
completionPercentage =
|
||||
((cycleDetails.progress_snapshot.completed_issues + cycleDetails.progress_snapshot.cancelled_issues) /
|
||||
cycleDetails.progress_snapshot.total_issues) *
|
||||
100;
|
||||
}
|
||||
return isNaN(completionPercentage) ? 0 : Math.floor(completionPercentage);
|
||||
};
|
||||
|
||||
const progress = getCycleProgress();
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
title={cycleDetails?.name ?? ""}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
// plane imports
|
||||
// plane constants
|
||||
import { EIssueCommentAccessSpecifier } from "@plane/constants";
|
||||
// plane imports
|
||||
import { type EditorRefApi, type ILiteTextEditorProps, LiteTextEditorWithRef, type TFileHandler } from "@plane/editor";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { MakeOptional } from "@plane/types";
|
||||
@@ -87,7 +88,6 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
|
||||
// derived values
|
||||
const isEmpty = isCommentEmpty(props.initialValue);
|
||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -59,7 +59,6 @@ export const StickyEditor = React.forwardRef<EditorRefApi, StickyEditorWrapperPr
|
||||
}
|
||||
// derived values
|
||||
const editorRef = isMutableRefObject<EditorRefApi>(ref) ? ref.current : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative border border-custom-border-200 rounded", parentClassName)}
|
||||
|
||||
@@ -104,8 +104,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
|
||||
const currentInboxIssueId = inboxIssue?.issue?.id;
|
||||
|
||||
const intakeIssueLink = `${workspaceSlug}/projects/${issue?.project_id}/intake/?currentTab=${currentTab}&inboxIssueId=${currentInboxIssueId}`;
|
||||
|
||||
const redirectIssue = (): string | undefined => {
|
||||
let nextOrPreviousIssueId: string | undefined = undefined;
|
||||
const currentIssueIndex = filteredInboxIssueIds.findIndex((id) => id === currentInboxIssueId);
|
||||
@@ -413,7 +411,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(intakeIssueLink)}>
|
||||
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(workItemLink)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Copy size={14} strokeWidth={2} />
|
||||
{t("inbox_issue.actions.copy")}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useProjectInbox } from "@/hooks/store/use-project-inbox";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// store types
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
// services
|
||||
import { IntakeWorkItemVersionService } from "@/services/inbox";
|
||||
|
||||
@@ -19,7 +19,8 @@ import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// plane web imports
|
||||
import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe";
|
||||
import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button";
|
||||
import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
// services
|
||||
import { FileService } from "@/services/file.service";
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CreateUpdateIssueModal } from "@/components/issues/issue-modal/modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
|
||||
// Plane-web
|
||||
import { CreateUpdateEpicModal } from "@/plane-web/components/epics";
|
||||
import { CreateUpdateEpicModal } from "@/plane-web/components/epics/epic-modal";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
import { TIssueRelationTypes } from "@/plane-web/types";
|
||||
// helper
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
import useSize from "@/hooks/use-window-size";
|
||||
// plane web components
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
// services
|
||||
|
||||
@@ -71,7 +71,8 @@ export const isWorkspaceLevel = (type: EIssuesStoreType) =>
|
||||
EIssuesStoreType.GLOBAL,
|
||||
EIssuesStoreType.TEAM,
|
||||
EIssuesStoreType.TEAM_VIEW,
|
||||
EIssuesStoreType.PROJECT_VIEW,
|
||||
EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS,
|
||||
EIssuesStoreType.WORKSPACE_DRAFT,
|
||||
].includes(type)
|
||||
? true
|
||||
: false;
|
||||
|
||||
@@ -40,7 +40,8 @@ import { useWorkspaceDraftIssues } from "@/hooks/store/workspace-draft";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
import { useProjectIssueProperties } from "@/hooks/use-project-issue-properties";
|
||||
// plane web imports
|
||||
import { DeDupeButtonRoot, DuplicateModalRoot } from "@/plane-web/components/de-dupe";
|
||||
import { DeDupeButtonRoot } from "@/plane-web/components/de-dupe/de-dupe-button";
|
||||
import { DuplicateModalRoot } from "@/plane-web/components/de-dupe/duplicate-modal";
|
||||
import { IssueTypeSelect, WorkItemTemplateSelect } from "@/plane-web/components/issues/issue-modal";
|
||||
import { WorkItemModalAdditionalProperties } from "@/plane-web/components/issues/issue-modal/modal-additional-properties";
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useProject } from "@/hooks/store/use-project";
|
||||
import { useUser } from "@/hooks/store/user";
|
||||
import useReloadConfirmations from "@/hooks/use-reload-confirmation";
|
||||
// plane web components
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe";
|
||||
import { DeDupeIssuePopoverRoot } from "@/plane-web/components/de-dupe/duplicate-popover";
|
||||
import { IssueTypeSwitcher } from "@/plane-web/components/issues/issue-details/issue-type-switcher";
|
||||
// plane web hooks
|
||||
import { useDebouncedDuplicateIssues } from "@/plane-web/hooks/use-debounced-duplicate-issues";
|
||||
|
||||
@@ -110,16 +110,16 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
|
||||
const peekOverviewIssueClassName = cn(
|
||||
!embedIssue
|
||||
? "fixed z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300"
|
||||
? "absolute z-[25] flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300"
|
||||
: `w-full h-full`,
|
||||
!embedIssue && {
|
||||
"top-2 bottom-2 right-2 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek",
|
||||
"top-0 bottom-0 right-0 w-full md:w-[50%] border-0 border-l": peekMode === "side-peek",
|
||||
"size-5/6 top-[8.33%] left-[8.33%]": peekMode === "modal",
|
||||
"inset-0 m-4 absolute": peekMode === "full-screen",
|
||||
}
|
||||
);
|
||||
|
||||
const shouldUsePortal = !embedIssue && peekMode === "full-screen";
|
||||
const shouldUsePortal = !embedIssue;
|
||||
|
||||
const portalContainer = document.getElementById("full-screen-portal") as HTMLElement;
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { WorkspaceLogo } from "@/components/workspace/logo";
|
||||
// hooks
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
// plane web imports
|
||||
import { SubscriptionPill } from "@/plane-web/components/common";
|
||||
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
|
||||
|
||||
export const SettingsSidebarHeader = observer((props: { customHeader?: React.ReactNode }) => {
|
||||
const { customHeader } = props;
|
||||
|
||||
@@ -3,15 +3,14 @@ import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Check, Settings, UserPlus } from "lucide-react";
|
||||
// plane imports
|
||||
import { Menu } from "@headlessui/react";
|
||||
// plane imports
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IWorkspace } from "@plane/types";
|
||||
import { cn, getFileURL, getUserRole } from "@plane/utils";
|
||||
// helpers
|
||||
// plane web imports
|
||||
import { SubscriptionPill } from "@/plane-web/components/common/subscription";
|
||||
import { SubscriptionPill } from "@/plane-web/components/common/subscription/subscription-pill";
|
||||
|
||||
type TProps = {
|
||||
workspace: IWorkspace;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "ce/components/de-dupe";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "ce/components/epics";
|
||||
@@ -1 +0,0 @@
|
||||
export * from "ce/components/projects/settings/intake";
|
||||
@@ -12,7 +12,7 @@ const nextConfig = {
|
||||
return [
|
||||
{
|
||||
source: "/(.*)?",
|
||||
headers: [{ key: "X-Frame-Options", value: "SAMEORIGIN" }],
|
||||
headers: [{ key: "X-Frame-Options", value: "DENY" }],
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
+5
-1
@@ -15,6 +15,7 @@
|
||||
"start": "turbo run start",
|
||||
"clean": "turbo run clean && rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist",
|
||||
"fix": "turbo run fix",
|
||||
"fix:format": "turbo run fix:format",
|
||||
"check": "turbo run check",
|
||||
"check:lint": "turbo run check:lint",
|
||||
"check:format": "turbo run check:format"
|
||||
@@ -39,5 +40,8 @@
|
||||
"sharp": "0.33.5"
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1"
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"engines": {
|
||||
"node": ">=22.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ export const ANALYTICS_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }
|
||||
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
|
||||
label: "Estimate",
|
||||
},
|
||||
{
|
||||
value: ChartYAxisMetric.EPIC_WORK_ITEM_COUNT,
|
||||
label: "Epic",
|
||||
},
|
||||
];
|
||||
|
||||
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const DRAG_HANDLE_ADDITIONAL_SELECTORS = [];
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
import type { TEmbedConfig } from "@/plane-editor/types";
|
||||
// types
|
||||
import type { IEditorProps, TExtensions, TUserDetails } from "@/types";
|
||||
|
||||
@@ -10,7 +8,6 @@ export type TDocumentEditorAdditionalExtensionsProps = Pick<
|
||||
IEditorProps,
|
||||
"disabledExtensions" | "flaggedExtensions" | "fileHandler"
|
||||
> & {
|
||||
embedConfig: TEmbedConfig | undefined;
|
||||
isEditable: boolean;
|
||||
provider?: HocuspocusProvider;
|
||||
userDetails: TUserDetails;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// helpers
|
||||
import type { TEditorRefHelperArgs } from "@/helpers/editor-ref";
|
||||
// local imports
|
||||
import type { TAdditionalEditorRefApiMethods } from "../types/editor";
|
||||
|
||||
export const getAdditionalEditorRefHelpers = (_args: TEditorRefHelperArgs): TAdditionalEditorRefApiMethods => ({});
|
||||
@@ -0,0 +1,7 @@
|
||||
export type TAdditionalEditorCommands = never;
|
||||
|
||||
export type TAdditionalCommandExtraProps = {};
|
||||
|
||||
export type TAdditionalEditorRefApiMethods = {};
|
||||
|
||||
export type IEditorPropsExtended = {};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./issue-embed";
|
||||
@@ -1,17 +0,0 @@
|
||||
export type TEmbedConfig = {
|
||||
issue?: TIssueEmbedConfig;
|
||||
};
|
||||
|
||||
export type TReadOnlyEmbedConfig = TEmbedConfig;
|
||||
|
||||
export type TIssueEmbedConfig = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
@@ -6,8 +6,6 @@ import { cn } from "@plane/utils";
|
||||
import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { WorkItemEmbedExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -21,13 +19,12 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
bubbleMenuEnabled = true,
|
||||
containerClassName,
|
||||
documentLoaderClassName,
|
||||
extensions: externalExtensions = [],
|
||||
extensions,
|
||||
disabledExtensions,
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
editorProps,
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
@@ -47,27 +44,12 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
user,
|
||||
} = props;
|
||||
|
||||
const extensions: Extensions = useMemo(() => {
|
||||
const allExtensions = [...externalExtensions];
|
||||
|
||||
if (embedHandler?.issue) {
|
||||
allExtensions.push(
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return allExtensions;
|
||||
}, [externalExtensions, embedHandler.issue]);
|
||||
|
||||
// use document editor
|
||||
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
|
||||
disabledExtensions,
|
||||
editable,
|
||||
editorClassName,
|
||||
editorProps,
|
||||
embedHandler,
|
||||
extensions,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
@@ -108,6 +90,8 @@ const CollaborativeDocumentEditor: React.FC<ICollaborativeDocumentEditorProps> =
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
isLoading={!hasServerSynced && !hasServerConnectionFailed}
|
||||
tabIndex={tabIndex}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { HeadingListExtension, WorkItemEmbedExtension, SideMenuExtension } from "@/extensions";
|
||||
import { HeadingListExtension, SideMenuExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -25,7 +25,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
displayConfig = DEFAULT_DISPLAY_CONFIG,
|
||||
editable,
|
||||
editorClassName = "",
|
||||
embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
forwardedRef,
|
||||
@@ -39,13 +38,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
} = props;
|
||||
const extensions: Extensions = useMemo(() => {
|
||||
const additionalExtensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
additionalExtensions.push(
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
}
|
||||
additionalExtensions.push(
|
||||
SideMenuExtension({
|
||||
aiEnabled: !disabledExtensions?.includes("ai"),
|
||||
@@ -54,7 +46,6 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
HeadingListExtension,
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
embedConfig: embedHandler,
|
||||
flaggedExtensions,
|
||||
isEditable: editable,
|
||||
fileHandler,
|
||||
@@ -98,6 +89,8 @@ const DocumentEditor = (props: IDocumentEditorProps) => {
|
||||
editorContainerClassName={cn(editorContainerClassName, "document-editor")}
|
||||
id={id}
|
||||
isTouchDevice={!!isTouchDevice}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@plane/utils";
|
||||
import { DocumentContentLoader, EditorContainer, EditorContentWrapper } from "@/components/editors";
|
||||
import { AIFeaturesMenu, BlockMenu, EditorBubbleMenu } from "@/components/menus";
|
||||
// types
|
||||
import { TAIHandler, TDisplayConfig } from "@/types";
|
||||
import { IEditorProps, TAIHandler, TDisplayConfig } from "@/types";
|
||||
|
||||
type Props = {
|
||||
aiHandler?: TAIHandler;
|
||||
@@ -18,6 +18,8 @@ type Props = {
|
||||
isLoading?: boolean;
|
||||
isTouchDevice: boolean;
|
||||
tabIndex?: number;
|
||||
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
||||
disabledExtensions?: IEditorProps["disabledExtensions"];
|
||||
};
|
||||
|
||||
export const PageRenderer = (props: Props) => {
|
||||
@@ -32,6 +34,8 @@ export const PageRenderer = (props: Props) => {
|
||||
isLoading,
|
||||
isTouchDevice,
|
||||
tabIndex,
|
||||
flaggedExtensions,
|
||||
disabledExtensions,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -54,7 +58,11 @@ export const PageRenderer = (props: Props) => {
|
||||
{editor.isEditable && !isTouchDevice && (
|
||||
<div>
|
||||
{bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}
|
||||
<BlockMenu editor={editor} />
|
||||
<BlockMenu
|
||||
editor={editor}
|
||||
flaggedExtensions={flaggedExtensions}
|
||||
disabledExtensions={disabledExtensions}
|
||||
/>
|
||||
<AIFeaturesMenu menu={aiHandler?.menu} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { autoUpdate, flip, hide, shift, useDismiss, useFloating, useInteractions } from "@floating-ui/react";
|
||||
import { Editor, useEditorState } from "@tiptap/react";
|
||||
import { FC, useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
// components
|
||||
import { LinkView, LinkViewProps } from "@/components/links";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
@@ -18,7 +22,7 @@ export const LinkViewContainer: FC<Props> = ({ editor, containerRef }) => {
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }: { editor: Editor }) => ({
|
||||
linkExtensionStorage: editor.storage.link,
|
||||
linkExtensionStorage: getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_LINK),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import { useCallback, useEffect, useRef } from "react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { IEditorProps } from "@/types";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
flaggedExtensions?: IEditorProps["flaggedExtensions"];
|
||||
disabledExtensions?: IEditorProps["disabledExtensions"];
|
||||
};
|
||||
|
||||
export const BlockMenu = (props: Props) => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export enum CORE_EDITOR_META {
|
||||
SKIP_FILE_DELETION = "skipFileDeletion",
|
||||
INTENTIONAL_DELETION = "intentionalDeletion",
|
||||
ADD_TO_HISTORY = "addToHistory",
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
@@ -101,5 +100,3 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensionsWithoutProps,
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];
|
||||
|
||||
@@ -10,11 +10,12 @@ import { IMAGE_ALIGNMENT_OPTIONS } from "../../utils";
|
||||
type Props = {
|
||||
activeAlignment: TCustomImageAlignment;
|
||||
handleChange: (alignment: TCustomImageAlignment) => void;
|
||||
isTouchDevice: boolean;
|
||||
toggleToolbarViewStatus: (val: boolean) => void;
|
||||
};
|
||||
|
||||
export const ImageAlignmentAction: React.FC<Props> = (props) => {
|
||||
const { activeAlignment, handleChange, toggleToolbarViewStatus } = props;
|
||||
const { activeAlignment, handleChange, isTouchDevice, toggleToolbarViewStatus } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// refs
|
||||
@@ -30,7 +31,7 @@ export const ImageAlignmentAction: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="h-full relative">
|
||||
<Tooltip tooltipContent="Align">
|
||||
<Tooltip disabled={isTouchDevice} tooltipContent="Align">
|
||||
<button
|
||||
type="button"
|
||||
className="h-full flex items-center gap-1 text-white/60 hover:text-white transition-colors"
|
||||
@@ -43,7 +44,7 @@ export const ImageAlignmentAction: React.FC<Props> = (props) => {
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-0.5 h-7 bg-black/80 flex items-center gap-2 px-2 rounded">
|
||||
{IMAGE_ALIGNMENT_OPTIONS.map((option) => (
|
||||
<Tooltip key={option.value} tooltipContent={option.label}>
|
||||
<Tooltip disabled={isTouchDevice} key={option.value} tooltipContent={option.label}>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 h-full grid place-items-center text-white/60 hover:text-white transition-colors"
|
||||
|
||||
@@ -42,6 +42,7 @@ export const ImageToolbarRoot: React.FC<Props> = (props) => {
|
||||
<ImageAlignmentAction
|
||||
activeAlignment={alignment}
|
||||
handleChange={handleAlignmentChange}
|
||||
isTouchDevice={isTouchDevice}
|
||||
toggleToolbarViewStatus={setShouldShowToolbar}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -81,6 +81,7 @@ declare module "@tiptap/core" {
|
||||
export type CustomLinkStorage = {
|
||||
isPreviewOpen: boolean;
|
||||
posToInsert: { from: number; to: number };
|
||||
isBubbleMenuOpen: boolean;
|
||||
};
|
||||
|
||||
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
|
||||
|
||||
@@ -8,7 +8,6 @@ export * from "./mentions";
|
||||
export * from "./slash-commands";
|
||||
export * from "./table";
|
||||
export * from "./typography";
|
||||
export * from "./work-item-embed";
|
||||
export * from "./core-without-props";
|
||||
export * from "./custom-color";
|
||||
export * from "./enter-key";
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Fragment, type Node, type Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import type { Transaction } from "@tiptap/pm/state";
|
||||
import { type CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||
// extensions
|
||||
import { TableNodeLocation } from "@/extensions/table/table/utilities/helpers";
|
||||
|
||||
type TableRow = (ProseMirrorNode | null)[];
|
||||
type TableRows = TableRow[];
|
||||
|
||||
/**
|
||||
* Move the selected columns to the specified index.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {CellSelection} selection - The cell selection.
|
||||
* @param {number} to - The index to move the columns to.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
* @returns {Transaction} The updated transaction.
|
||||
*/
|
||||
export const moveSelectedColumns = (
|
||||
editor: Editor,
|
||||
table: TableNodeLocation,
|
||||
selection: CellSelection,
|
||||
to: number,
|
||||
tr: Transaction
|
||||
): Transaction => {
|
||||
const tableMap = TableMap.get(table.node);
|
||||
|
||||
let columnStart = -1;
|
||||
let columnEnd = -1;
|
||||
|
||||
selection.forEachCell((_node, pos) => {
|
||||
const cell = tableMap.findCell(pos - table.pos - 1);
|
||||
for (let i = cell.left; i < cell.right; i++) {
|
||||
columnStart = columnStart >= 0 ? Math.min(cell.left, columnStart) : cell.left;
|
||||
columnEnd = columnEnd >= 0 ? Math.max(cell.right, columnEnd) : cell.right;
|
||||
}
|
||||
});
|
||||
|
||||
if (columnStart === -1 || columnEnd === -1) {
|
||||
console.warn("Invalid column selection");
|
||||
return tr;
|
||||
}
|
||||
|
||||
if (to < 0 || to > tableMap.width || (to >= columnStart && to < columnEnd)) return tr;
|
||||
|
||||
const rows = tableToCells(table);
|
||||
for (const row of rows) {
|
||||
const range = row.splice(columnStart, columnEnd - columnStart);
|
||||
const offset = to > columnStart ? to - (columnEnd - columnStart - 1) : to;
|
||||
row.splice(offset, 0, ...range);
|
||||
}
|
||||
|
||||
tableFromCells(editor, table, rows, tr);
|
||||
return tr;
|
||||
};
|
||||
|
||||
/**
|
||||
* Move the selected rows to the specified index.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {CellSelection} selection - The cell selection.
|
||||
* @param {number} to - The index to move the rows to.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
* @returns {Transaction} The updated transaction.
|
||||
*/
|
||||
export const moveSelectedRows = (
|
||||
editor: Editor,
|
||||
table: TableNodeLocation,
|
||||
selection: CellSelection,
|
||||
to: number,
|
||||
tr: Transaction
|
||||
): Transaction => {
|
||||
const tableMap = TableMap.get(table.node);
|
||||
|
||||
let rowStart = -1;
|
||||
let rowEnd = -1;
|
||||
|
||||
selection.forEachCell((_node, pos) => {
|
||||
const cell = tableMap.findCell(pos - table.pos - 1);
|
||||
for (let i = cell.top; i < cell.bottom; i++) {
|
||||
rowStart = rowStart >= 0 ? Math.min(cell.top, rowStart) : cell.top;
|
||||
rowEnd = rowEnd >= 0 ? Math.max(cell.bottom, rowEnd) : cell.bottom;
|
||||
}
|
||||
});
|
||||
|
||||
if (rowStart === -1 || rowEnd === -1) {
|
||||
console.warn("Invalid row selection");
|
||||
return tr;
|
||||
}
|
||||
|
||||
if (to < 0 || to > tableMap.height || (to >= rowStart && to < rowEnd)) return tr;
|
||||
|
||||
const rows = tableToCells(table);
|
||||
const range = rows.splice(rowStart, rowEnd - rowStart);
|
||||
const offset = to > rowStart ? to - (rowEnd - rowStart - 1) : to;
|
||||
rows.splice(offset, 0, ...range);
|
||||
|
||||
tableFromCells(editor, table, rows, tr);
|
||||
return tr;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Duplicate the selected rows.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {number[]} rowIndices - The indices of the rows to duplicate.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
* @returns {Transaction} The updated transaction.
|
||||
*/
|
||||
export const duplicateRows = (table: TableNodeLocation, rowIndices: number[], tr: Transaction): Transaction => {
|
||||
const rows = tableToCells(table);
|
||||
|
||||
const { map, width } = TableMap.get(table.node);
|
||||
|
||||
// Validate row indices
|
||||
const maxRow = rows.length - 1;
|
||||
if (rowIndices.some((idx) => idx < 0 || idx > maxRow)) {
|
||||
console.warn("Invalid row indices for duplication");
|
||||
return tr;
|
||||
}
|
||||
|
||||
const mapStart = tr.mapping.maps.length;
|
||||
|
||||
const lastRowPos = map[rowIndices[rowIndices.length - 1] * width + width - 1];
|
||||
const nextRowStart = lastRowPos + (table.node.nodeAt(lastRowPos)?.nodeSize ?? 0) + 1;
|
||||
const insertPos = tr.mapping.slice(mapStart).map(table.start + nextRowStart);
|
||||
|
||||
for (let i = rowIndices.length - 1; i >= 0; i--) {
|
||||
tr.insert(
|
||||
insertPos,
|
||||
rows[rowIndices[i]].filter((r) => r !== null)
|
||||
);
|
||||
}
|
||||
|
||||
return tr;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Duplicate the selected columns.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {number[]} columnIndices - The indices of the columns to duplicate.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
* @returns {Transaction} The updated transaction.
|
||||
*/
|
||||
export const duplicateColumns = (table: TableNodeLocation, columnIndices: number[], tr: Transaction): Transaction => {
|
||||
const rows = tableToCells(table);
|
||||
|
||||
const { map, width, height } = TableMap.get(table.node);
|
||||
|
||||
// Validate column indices
|
||||
if (columnIndices.some((idx) => idx < 0 || idx >= width)) {
|
||||
console.warn("Invalid column indices for duplication");
|
||||
return tr;
|
||||
}
|
||||
|
||||
const mapStart = tr.mapping.maps.length;
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
const lastColumnPos = map[row * width + columnIndices[columnIndices.length - 1]];
|
||||
const nextColumnStart = lastColumnPos + (table.node.nodeAt(lastColumnPos)?.nodeSize ?? 0);
|
||||
const insertPos = tr.mapping.slice(mapStart).map(table.start + nextColumnStart);
|
||||
|
||||
for (let i = columnIndices.length - 1; i >= 0; i--) {
|
||||
const copiedNode = rows[row][columnIndices[i]];
|
||||
if (copiedNode !== null) {
|
||||
tr.insert(insertPos, copiedNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tr;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Convert the table to cells.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @returns {TableRows} The table rows.
|
||||
*/
|
||||
const tableToCells = (table: TableNodeLocation): TableRows => {
|
||||
const { map, width, height } = TableMap.get(table.node);
|
||||
|
||||
const visitedCells = new Set<number>();
|
||||
const rows: TableRows = [];
|
||||
for (let row = 0; row < height; row++) {
|
||||
const cells: (ProseMirrorNode | null)[] = [];
|
||||
for (let col = 0; col < width; col++) {
|
||||
const pos = map[row * width + col];
|
||||
cells.push(!visitedCells.has(pos) ? table.node.nodeAt(pos) : null);
|
||||
visitedCells.add(pos);
|
||||
}
|
||||
rows.push(cells);
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Convert the cells to a table.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {TableRows} rows - The table rows.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
*/
|
||||
const tableFromCells = (editor: Editor, table: TableNodeLocation, rows: TableRows, tr: Transaction): void => {
|
||||
const schema = editor.schema.nodes;
|
||||
const newRowNodes = rows.map((row) =>
|
||||
schema.tableRow.create(null, row.filter((cell) => cell !== null) as readonly Node[])
|
||||
);
|
||||
const newTableNode = table.node.copy(Fragment.from(newRowNodes));
|
||||
tr.replaceWith(table.pos, table.pos + table.node.nodeSize, newTableNode);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Disclosure } from "@headlessui/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Ban, ChevronRight, Palette } from "lucide-react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
// TODO: implement text color selector
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
onSelect: (color: string | null) => void;
|
||||
};
|
||||
|
||||
const handleBackgroundColorChange = (editor: Editor, color: string | null) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: color,
|
||||
})
|
||||
.run();
|
||||
};
|
||||
|
||||
// const handleTextColorChange = (editor: Editor, color: string | null) => {
|
||||
// editor
|
||||
// .chain()
|
||||
// .focus()
|
||||
// .updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
// textColor: color,
|
||||
// })
|
||||
// .run();
|
||||
// };
|
||||
|
||||
export const TableDragHandleDropdownColorSelector: React.FC<Props> = (props) => {
|
||||
const { editor, onSelect } = props;
|
||||
|
||||
return (
|
||||
<Disclosure defaultOpen>
|
||||
<Disclosure.Button
|
||||
as="button"
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
|
||||
>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<span className="flex items-center gap-2">
|
||||
<Palette className="shrink-0 size-3" />
|
||||
Color
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn("shrink-0 size-3 transition-transform duration-200", {
|
||||
"rotate-90": open,
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
<Disclosure.Panel className="p-1 space-y-2 mb-1.5">
|
||||
{/* <div className="space-y-1.5">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Text colors</p>
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.textColor,
|
||||
}}
|
||||
onClick={() => handleTextColorChange(editor, color.textColor)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => handleTextColorChange(editor, null)}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div> */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-custom-text-300 font-semibold">Background colors</p>
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
{COLORS_LIST.map((color) => (
|
||||
<button
|
||||
key={color.key}
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 rounded border-[0.5px] border-custom-border-400 hover:opacity-60 transition-opacity"
|
||||
style={{
|
||||
backgroundColor: color.backgroundColor,
|
||||
}}
|
||||
onClick={() => {
|
||||
handleBackgroundColorChange(editor, color.backgroundColor);
|
||||
onSelect(color.backgroundColor);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded text-custom-text-300 border-[0.5px] border-custom-border-400 hover:bg-custom-background-80 transition-colors"
|
||||
onClick={() => {
|
||||
handleBackgroundColorChange(editor, null);
|
||||
onSelect(null);
|
||||
}}
|
||||
>
|
||||
<Ban className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Disclosure>
|
||||
);
|
||||
};
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
shift,
|
||||
flip,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
autoUpdate,
|
||||
useClick,
|
||||
useRole,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
} from "@floating-ui/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectColumn,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedColumns } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getColDragMarker,
|
||||
getDropMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateColDragMarker,
|
||||
updateColDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { ColumnOptionsDropdown } from "./dropdown";
|
||||
import { calculateColumnDropIndex, constructColumnDragPreview, getTableColumnNodesInfo } from "./utils";
|
||||
|
||||
export type ColumnDragHandleProps = {
|
||||
col: number;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const ColumnDragHandle: React.FC<ColumnDragHandleProps> = (props) => {
|
||||
const { col, editor } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// floating ui
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
open: isDropdownOpen,
|
||||
onOpenChange: setIsDropdownOpen,
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectColumn(table, col, editor.state.tr));
|
||||
|
||||
// drag column
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
const columns = getTableColumnNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = col;
|
||||
const startLeft = columns[col].left ?? 0;
|
||||
const startX = e.clientX;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getColDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = () => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
if (col !== dropIndex) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedColumns(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
};
|
||||
|
||||
let pseudoColumn: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent) => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
const currentLeft = startLeft + moveEvent.clientX - startX;
|
||||
dropIndex = calculateColumnDropIndex(col, columns, currentLeft);
|
||||
|
||||
if (!pseudoColumn) {
|
||||
pseudoColumn = constructColumnDragPreview(editor, editor.state.selection, table);
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
if (pseudoColumn) {
|
||||
pseudoColumn.style.height = `${tableHeightPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerWidthPx = columns[col].width;
|
||||
const dragMarkerLeftPx = Math.max(0, Math.min(currentLeft, tableWidthPx - dragMarkerWidthPx));
|
||||
const dropMarkerLeftPx =
|
||||
dropIndex <= col ? columns[dropIndex].left : columns[dropIndex].left + columns[dropIndex].width;
|
||||
|
||||
updateColDropMarker({
|
||||
element: dropMarker,
|
||||
left: dropMarkerLeftPx - Math.floor(DROP_MARKER_THICKNESS / 2) - 1,
|
||||
width: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateColDragMarker({
|
||||
element: dragMarker,
|
||||
left: dragMarkerLeftPx,
|
||||
width: dragMarkerWidthPx,
|
||||
pseudoColumn,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in ColumnDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
},
|
||||
[col, editor]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-col-handle-container absolute z-20 top-0 left-0 flex justify-center items-center w-full -translate-y-1/2">
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
"px-1 bg-custom-background-90 border border-custom-border-400 rounded outline-none transition-all duration-200",
|
||||
{
|
||||
"!opacity-100 bg-custom-primary-100 border-custom-primary-100": isDropdownOpen,
|
||||
"hover:bg-custom-background-80": !isDropdownOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="size-4 text-custom-text-100" />
|
||||
</button>
|
||||
</div>
|
||||
{isDropdownOpen && (
|
||||
<FloatingPortal>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
|
||||
<div
|
||||
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps()}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<ColumnOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { ArrowLeft, ArrowRight, Copy, ToggleRight, Trash2, X, type LucideIcon } from "lucide-react";
|
||||
// extensions
|
||||
import { findTable, getSelectedColumns } from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { duplicateColumns } from "../actions";
|
||||
import { TableDragHandleDropdownColorSelector } from "../color-selector";
|
||||
|
||||
const DROPDOWN_ITEMS: {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
action: (editor: Editor) => void;
|
||||
}[] = [
|
||||
{
|
||||
key: "insert-left",
|
||||
label: "Insert left",
|
||||
icon: ArrowLeft,
|
||||
action: (editor) => editor.chain().focus().addColumnBefore().run(),
|
||||
},
|
||||
{
|
||||
key: "insert-right",
|
||||
label: "Insert right",
|
||||
icon: ArrowRight,
|
||||
action: (editor) => editor.chain().focus().addColumnAfter().run(),
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
icon: Copy,
|
||||
action: (editor) => {
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
let tr = editor.state.tr;
|
||||
const selectedColumns = getSelectedColumns(editor.state.selection, tableMap);
|
||||
tr = duplicateColumns(table, selectedColumns, tr);
|
||||
editor.view.dispatch(tr);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "clear-contents",
|
||||
label: "Clear contents",
|
||||
icon: X,
|
||||
action: (editor) => editor.chain().focus().clearSelectedCells().run(),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
action: (editor) => editor.chain().focus().deleteColumn().run(),
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ColumnOptionsDropdown: React.FC<Props> = (props) => {
|
||||
const { editor, onClose } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
editor.chain().focus().toggleHeaderColumn().run();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex-grow truncate">Header column</div>
|
||||
<ToggleRight className="shrink-0 size-3" />
|
||||
</button>
|
||||
<hr className="my-2 border-custom-border-200" />
|
||||
<TableDragHandleDropdownColorSelector editor={editor} onSelect={onClose} />
|
||||
{DROPDOWN_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action(editor);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<item.icon className="shrink-0 size-3" />
|
||||
<div className="flex-grow truncate">{item.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableCellWidgetDecorationPos,
|
||||
haveTableRelatedChanges,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { ColumnDragHandle, ColumnDragHandleProps } from "./drag-handle";
|
||||
|
||||
type TableColumnDragHandlePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
// track table structure to detect changes
|
||||
tableWidth?: number;
|
||||
tableNodePos?: number;
|
||||
};
|
||||
|
||||
const TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableColumnHandlerDecorationPlugin");
|
||||
|
||||
export const TableColumnDragHandlePlugin = (editor: Editor): Plugin<TableColumnDragHandlePluginState> =>
|
||||
new Plugin<TableColumnDragHandlePluginState>({
|
||||
key: TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY,
|
||||
state: {
|
||||
init: () => ({}),
|
||||
apply(tr, prev, oldState, newState) {
|
||||
const table = findTable(newState.selection);
|
||||
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
|
||||
return table !== undefined ? prev : {};
|
||||
}
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
|
||||
// Check if table structure changed (width or position)
|
||||
const tableStructureChanged = prev.tableWidth !== tableMap.width || prev.tableNodePos !== table.pos;
|
||||
|
||||
let isStale = tableStructureChanged;
|
||||
|
||||
// Only do position-based stale check if structure hasn't changed
|
||||
if (!isStale) {
|
||||
const mapped = prev.decorations?.map(tr.mapping, tr.doc);
|
||||
for (let col = 0; col < tableMap.width; col++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, col);
|
||||
if (mapped?.find(pos, pos + 1)?.length !== 1) {
|
||||
isStale = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStale) {
|
||||
const mapped = prev.decorations?.map(tr.mapping, tr.doc);
|
||||
return {
|
||||
decorations: mapped,
|
||||
tableWidth: tableMap.width,
|
||||
tableNodePos: table.pos,
|
||||
};
|
||||
}
|
||||
|
||||
// recreate all decorations
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
for (let col = 0; col < tableMap.width; col++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, col);
|
||||
|
||||
const dragHandleComponent = new ReactRenderer(ColumnDragHandle, {
|
||||
props: {
|
||||
col,
|
||||
editor,
|
||||
} satisfies ColumnDragHandleProps,
|
||||
editor,
|
||||
});
|
||||
|
||||
decorations.push(Decoration.widget(pos, () => dragHandleComponent.element));
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
tableWidth: tableMap.width,
|
||||
tableNodePos: table.pos,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return TABLE_COLUMN_DRAG_HANDLE_PLUGIN_KEY.getState(state).decorations;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
// extensions
|
||||
import { getSelectedRect, isCellSelection, type TableNodeLocation } from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { cloneTableCell, constructDragPreviewTable, updateCellContentVisibility } from "../utils";
|
||||
|
||||
type TableColumn = {
|
||||
left: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Calculate the index where the dragged column should be dropped.
|
||||
* @param {number} col - The column index.
|
||||
* @param {TableColumn[]} columns - The columns.
|
||||
* @param {number} left - The left position of the dragged column.
|
||||
* @returns {number} The index where the dragged column should be dropped.
|
||||
*/
|
||||
export const calculateColumnDropIndex = (col: number, columns: TableColumn[], left: number): number => {
|
||||
const currentColumnLeft = columns[col].left;
|
||||
const currentColumnRight = currentColumnLeft + columns[col].width;
|
||||
|
||||
const draggedColumnLeft = left;
|
||||
const draggedColumnRight = draggedColumnLeft + columns[col].width;
|
||||
|
||||
const isDraggingToLeft = draggedColumnLeft < currentColumnLeft;
|
||||
const isDraggingToRight = draggedColumnRight > currentColumnRight;
|
||||
|
||||
const isFirstColumn = col === 0;
|
||||
const isLastColumn = col === columns.length - 1;
|
||||
|
||||
if ((isFirstColumn && isDraggingToLeft) || (isLastColumn && isDraggingToRight)) {
|
||||
return col;
|
||||
}
|
||||
|
||||
const firstColumn = columns[0];
|
||||
if (isDraggingToLeft && draggedColumnLeft <= firstColumn.left) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastColumn = columns[columns.length - 1];
|
||||
if (isDraggingToRight && draggedColumnRight >= lastColumn.left + lastColumn.width) {
|
||||
return columns.length - 1;
|
||||
}
|
||||
|
||||
let dropColumnIndex = col;
|
||||
if (isDraggingToRight) {
|
||||
const findHoveredColumn = columns.find((p, index) => {
|
||||
if (index === col) return false;
|
||||
const currentColumnCenter = p.left + p.width / 2;
|
||||
const currentColumnEdge = p.left + p.width;
|
||||
const nextColumn = columns[index + 1] as TableColumn | undefined;
|
||||
const nextColumnCenter = nextColumn ? nextColumn.width / 2 : 0;
|
||||
|
||||
return draggedColumnRight >= currentColumnCenter && draggedColumnRight < currentColumnEdge + nextColumnCenter;
|
||||
});
|
||||
if (findHoveredColumn) {
|
||||
dropColumnIndex = columns.indexOf(findHoveredColumn);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDraggingToLeft) {
|
||||
const findHoveredColumn = columns.find((p, index) => {
|
||||
if (index === col) return false;
|
||||
const currentColumnCenter = p.left + p.width / 2;
|
||||
const prevColumn = columns[index - 1] as TableColumn | undefined;
|
||||
const prevColumnLeft = prevColumn ? prevColumn.left : 0;
|
||||
const prevColumnCenter = prevColumn ? prevColumn.width / 2 : 0;
|
||||
|
||||
return draggedColumnLeft <= currentColumnCenter && draggedColumnLeft > prevColumnLeft + prevColumnCenter;
|
||||
});
|
||||
if (findHoveredColumn) {
|
||||
dropColumnIndex = columns.indexOf(findHoveredColumn);
|
||||
}
|
||||
}
|
||||
|
||||
return dropColumnIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Get the node information of the columns in the table- their offset left and width.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @returns {TableColumn[]} The information of the columns in the table.
|
||||
*/
|
||||
export const getTableColumnNodesInfo = (table: TableNodeLocation, editor: Editor): TableColumn[] => {
|
||||
const result: TableColumn[] = [];
|
||||
let leftPx = 0;
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
if (!tableMap || tableMap.height === 0 || tableMap.width === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (let col = 0; col < tableMap.width; col++) {
|
||||
const cellPos = tableMap.map[col];
|
||||
if (cellPos === undefined) continue;
|
||||
|
||||
const dom = editor.view.domAtPos(table.start + cellPos + 1);
|
||||
if (dom.node instanceof HTMLElement) {
|
||||
if (col === 0) {
|
||||
leftPx = dom.node.offsetLeft;
|
||||
}
|
||||
result.push({
|
||||
left: dom.node.offsetLeft - leftPx,
|
||||
width: dom.node.offsetWidth,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Construct a pseudo column from the selected cells for drag preview.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @param {Selection} selection - The selection.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @returns {HTMLElement | undefined} The pseudo column.
|
||||
*/
|
||||
export const constructColumnDragPreview = (
|
||||
editor: Editor,
|
||||
selection: Selection,
|
||||
table: TableNodeLocation
|
||||
): HTMLElement | undefined => {
|
||||
if (!isCellSelection(selection)) return;
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
const selectedColRect = getSelectedRect(selection, tableMap);
|
||||
const activeColCells = tableMap.cellsInRect(selectedColRect);
|
||||
|
||||
const { tableElement, tableBodyElement } = constructDragPreviewTable();
|
||||
|
||||
activeColCells.forEach((cellPos) => {
|
||||
const resolvedCellPos = table.start + cellPos + 1;
|
||||
const cellElement = editor.view.domAtPos(resolvedCellPos).node;
|
||||
if (cellElement instanceof HTMLElement) {
|
||||
const { clonedCellElement } = cloneTableCell(cellElement);
|
||||
clonedCellElement.style.height = cellElement.getBoundingClientRect().height + "px";
|
||||
const tableRowElement = document.createElement("tr");
|
||||
tableRowElement.appendChild(clonedCellElement);
|
||||
tableBodyElement.appendChild(tableRowElement);
|
||||
}
|
||||
});
|
||||
|
||||
updateCellContentVisibility(editor, true);
|
||||
|
||||
return tableElement;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
export const DROP_MARKER_CLASS = "table-drop-marker";
|
||||
export const COL_DRAG_MARKER_CLASS = "table-col-drag-marker";
|
||||
export const ROW_DRAG_MARKER_CLASS = "table-row-drag-marker";
|
||||
|
||||
export const DROP_MARKER_THICKNESS = 2;
|
||||
|
||||
export const getDropMarker = (tableElement: HTMLElement): HTMLElement | null =>
|
||||
tableElement.querySelector(`.${DROP_MARKER_CLASS}`);
|
||||
|
||||
export const hideDropMarker = (element: HTMLElement): void => {
|
||||
if (!element.classList.contains("hidden")) {
|
||||
element.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateColDropMarker = ({
|
||||
element,
|
||||
left,
|
||||
width,
|
||||
}: {
|
||||
element: HTMLElement;
|
||||
left: number;
|
||||
width: number;
|
||||
}) => {
|
||||
element.style.height = "100%";
|
||||
element.style.width = `${width}px`;
|
||||
element.style.top = "0";
|
||||
element.style.left = `${left}px`;
|
||||
element.classList.remove("hidden");
|
||||
};
|
||||
|
||||
export const updateRowDropMarker = ({
|
||||
element,
|
||||
top,
|
||||
height,
|
||||
}: {
|
||||
element: HTMLElement;
|
||||
top: number;
|
||||
height: number;
|
||||
}) => {
|
||||
element.style.width = "100%";
|
||||
element.style.height = `${height}px`;
|
||||
element.style.left = "0";
|
||||
element.style.top = `${top}px`;
|
||||
element.classList.remove("hidden");
|
||||
};
|
||||
|
||||
export const getColDragMarker = (tableElement: HTMLElement): HTMLElement | null =>
|
||||
tableElement.querySelector(`.${COL_DRAG_MARKER_CLASS}`);
|
||||
|
||||
export const getRowDragMarker = (tableElement: HTMLElement): HTMLElement | null =>
|
||||
tableElement.querySelector(`.${ROW_DRAG_MARKER_CLASS}`);
|
||||
|
||||
export const hideDragMarker = (element: HTMLElement): void => {
|
||||
if (!element.classList.contains("hidden")) {
|
||||
element.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
export const updateColDragMarker = ({
|
||||
element,
|
||||
left,
|
||||
width,
|
||||
pseudoColumn,
|
||||
}: {
|
||||
element: HTMLElement;
|
||||
left: number;
|
||||
width: number;
|
||||
pseudoColumn: HTMLElement | undefined;
|
||||
}) => {
|
||||
element.style.left = `${left}px`;
|
||||
element.style.width = `${width}px`;
|
||||
element.classList.remove("hidden");
|
||||
if (pseudoColumn) {
|
||||
/// clear existing content
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
// clone and append the pseudo column
|
||||
element.appendChild(pseudoColumn.cloneNode(true));
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRowDragMarker = ({
|
||||
element,
|
||||
top,
|
||||
height,
|
||||
pseudoRow,
|
||||
}: {
|
||||
element: HTMLElement;
|
||||
top: number;
|
||||
height: number;
|
||||
pseudoRow: HTMLElement | undefined;
|
||||
}) => {
|
||||
element.style.top = `${top}px`;
|
||||
element.style.height = `${height}px`;
|
||||
element.classList.remove("hidden");
|
||||
if (pseudoRow) {
|
||||
/// clear existing content
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
// clone and append the pseudo row
|
||||
element.appendChild(pseudoRow.cloneNode(true));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
FloatingOverlay,
|
||||
FloatingPortal,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
useFloating,
|
||||
useInteractions,
|
||||
useRole,
|
||||
} from "@floating-ui/react";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableHeightPx,
|
||||
getTableWidthPx,
|
||||
isCellSelection,
|
||||
selectRow,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { moveSelectedRows } from "../actions";
|
||||
import {
|
||||
DROP_MARKER_THICKNESS,
|
||||
getDropMarker,
|
||||
getRowDragMarker,
|
||||
hideDragMarker,
|
||||
hideDropMarker,
|
||||
updateRowDragMarker,
|
||||
updateRowDropMarker,
|
||||
} from "../marker-utils";
|
||||
import { updateCellContentVisibility } from "../utils";
|
||||
import { RowOptionsDropdown } from "./dropdown";
|
||||
import { calculateRowDropIndex, constructRowDragPreview, getTableRowNodesInfo } from "./utils";
|
||||
|
||||
export type RowDragHandleProps = {
|
||||
editor: Editor;
|
||||
row: number;
|
||||
};
|
||||
|
||||
export const RowDragHandle: React.FC<RowDragHandleProps> = (props) => {
|
||||
const { editor, row } = props;
|
||||
// states
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
// floating ui
|
||||
const { refs, floatingStyles, context } = useFloating({
|
||||
placement: "bottom-start",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-start", "top-end", "bottom-end"],
|
||||
}),
|
||||
shift({
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
open: isDropdownOpen,
|
||||
onOpenChange: setIsDropdownOpen,
|
||||
whileElementsMounted: autoUpdate,
|
||||
});
|
||||
const click = useClick(context);
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context);
|
||||
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click, role]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
editor.view.dispatch(selectRow(table, row, editor.state.tr));
|
||||
|
||||
// drag row
|
||||
const tableHeightPx = getTableHeightPx(table, editor);
|
||||
const rows = getTableRowNodesInfo(table, editor);
|
||||
|
||||
let dropIndex = row;
|
||||
const startTop = rows[row].top ?? 0;
|
||||
const startY = e.clientY;
|
||||
const tableElement = editor.view.nodeDOM(table.pos);
|
||||
|
||||
const dropMarker = tableElement instanceof HTMLElement ? getDropMarker(tableElement) : null;
|
||||
const dragMarker = tableElement instanceof HTMLElement ? getRowDragMarker(tableElement) : null;
|
||||
|
||||
const handleFinish = (): void => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
hideDropMarker(dropMarker);
|
||||
hideDragMarker(dragMarker);
|
||||
|
||||
if (isCellSelection(editor.state.selection)) {
|
||||
updateCellContentVisibility(editor, false);
|
||||
}
|
||||
|
||||
if (row !== dropIndex) {
|
||||
let tr = editor.state.tr;
|
||||
const selection = editor.state.selection;
|
||||
if (isCellSelection(selection)) {
|
||||
const table = findTable(selection);
|
||||
if (table) {
|
||||
tr = moveSelectedRows(editor, table, selection, dropIndex, tr);
|
||||
}
|
||||
}
|
||||
editor.view.dispatch(tr);
|
||||
}
|
||||
window.removeEventListener("mouseup", handleFinish);
|
||||
window.removeEventListener("mousemove", handleMove);
|
||||
};
|
||||
|
||||
let pseudoRow: HTMLElement | undefined;
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent): void => {
|
||||
if (!dropMarker || !dragMarker) return;
|
||||
const cursorTop = startTop + moveEvent.clientY - startY;
|
||||
dropIndex = calculateRowDropIndex(row, rows, cursorTop);
|
||||
|
||||
if (!pseudoRow) {
|
||||
pseudoRow = constructRowDragPreview(editor, editor.state.selection, table);
|
||||
const tableWidthPx = getTableWidthPx(table, editor);
|
||||
if (pseudoRow) {
|
||||
pseudoRow.style.width = `${tableWidthPx}px`;
|
||||
}
|
||||
}
|
||||
|
||||
const dragMarkerHeightPx = rows[row].height;
|
||||
const dragMarkerTopPx = Math.max(0, Math.min(cursorTop, tableHeightPx - dragMarkerHeightPx));
|
||||
const dropMarkerTopPx = dropIndex <= row ? rows[dropIndex].top : rows[dropIndex].top + rows[dropIndex].height;
|
||||
|
||||
updateRowDropMarker({
|
||||
element: dropMarker,
|
||||
top: dropMarkerTopPx - DROP_MARKER_THICKNESS / 2,
|
||||
height: DROP_MARKER_THICKNESS,
|
||||
});
|
||||
updateRowDragMarker({
|
||||
element: dragMarker,
|
||||
top: dragMarkerTopPx,
|
||||
height: dragMarkerHeightPx,
|
||||
pseudoRow,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
window.addEventListener("mouseup", handleFinish);
|
||||
window.addEventListener("mousemove", handleMove);
|
||||
} catch (error) {
|
||||
console.error("Error in RowDragHandle:", error);
|
||||
handleFinish();
|
||||
}
|
||||
},
|
||||
[editor, row]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="table-row-handle-container absolute z-20 top-0 left-0 flex justify-center items-center h-full -translate-x-1/2">
|
||||
<button
|
||||
ref={refs.setReference}
|
||||
{...getReferenceProps()}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
className={cn(
|
||||
"py-1 bg-custom-background-90 border border-custom-border-400 rounded outline-none transition-all duration-200",
|
||||
{
|
||||
"!opacity-100 bg-custom-primary-100 border-custom-primary-100": isDropdownOpen,
|
||||
"hover:bg-custom-background-80": !isDropdownOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="size-4 text-custom-text-100 rotate-90" />
|
||||
</button>
|
||||
</div>
|
||||
{isDropdownOpen && (
|
||||
<FloatingPortal>
|
||||
{/* Backdrop */}
|
||||
<FloatingOverlay
|
||||
style={{
|
||||
zIndex: 99,
|
||||
}}
|
||||
lockScroll
|
||||
/>
|
||||
|
||||
<div
|
||||
className="max-h-[90vh] w-[12rem] overflow-y-auto rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps()}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<RowOptionsDropdown editor={editor} onClose={() => setIsDropdownOpen(false)} />
|
||||
</div>
|
||||
</FloatingPortal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { ArrowDown, ArrowUp, Copy, ToggleRight, Trash2, X, type LucideIcon } from "lucide-react";
|
||||
// extensions
|
||||
import { findTable, getSelectedRows } from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { duplicateRows } from "../actions";
|
||||
import { TableDragHandleDropdownColorSelector } from "../color-selector";
|
||||
|
||||
const DROPDOWN_ITEMS: {
|
||||
key: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
action: (editor: Editor) => void;
|
||||
}[] = [
|
||||
{
|
||||
key: "insert-above",
|
||||
label: "Insert above",
|
||||
icon: ArrowUp,
|
||||
action: (editor) => editor.chain().focus().addRowBefore().run(),
|
||||
},
|
||||
{
|
||||
key: "insert-below",
|
||||
label: "Insert below",
|
||||
icon: ArrowDown,
|
||||
action: (editor) => editor.chain().focus().addRowAfter().run(),
|
||||
},
|
||||
{
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
icon: Copy,
|
||||
action: (editor) => {
|
||||
const table = findTable(editor.state.selection);
|
||||
if (!table) return;
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
let tr = editor.state.tr;
|
||||
const selectedRows = getSelectedRows(editor.state.selection, tableMap);
|
||||
tr = duplicateRows(table, selectedRows, tr);
|
||||
editor.view.dispatch(tr);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "clear-contents",
|
||||
label: "Clear contents",
|
||||
icon: X,
|
||||
action: (editor) => editor.chain().focus().clearSelectedCells().run(),
|
||||
},
|
||||
{
|
||||
key: "delete",
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
action: (editor) => editor.chain().focus().deleteRow().run(),
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const RowOptionsDropdown: React.FC<Props> = (props) => {
|
||||
const { editor, onClose } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
editor.chain().focus().toggleHeaderRow().run();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div className="flex-grow truncate">Header row</div>
|
||||
<ToggleRight className="shrink-0 size-3" />
|
||||
</button>
|
||||
<hr className="my-2 border-custom-border-200" />
|
||||
<TableDragHandleDropdownColorSelector editor={editor} onSelect={onClose} />
|
||||
{DROPDOWN_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="flex items-center gap-2 w-full rounded px-1 py-1.5 text-xs text-left truncate text-custom-text-200 hover:bg-custom-background-80"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
item.action(editor);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<item.icon className="shrink-0 size-3" />
|
||||
<div className="flex-grow truncate">{item.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { type Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import {
|
||||
findTable,
|
||||
getTableCellWidgetDecorationPos,
|
||||
haveTableRelatedChanges,
|
||||
} from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { RowDragHandle, RowDragHandleProps } from "./drag-handle";
|
||||
|
||||
type TableRowDragHandlePluginState = {
|
||||
decorations?: DecorationSet;
|
||||
// track table structure to detect changes
|
||||
tableHeight?: number;
|
||||
tableNodePos?: number;
|
||||
};
|
||||
|
||||
const TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY = new PluginKey("tableRowDragHandlePlugin");
|
||||
|
||||
export const TableRowDragHandlePlugin = (editor: Editor): Plugin<TableRowDragHandlePluginState> =>
|
||||
new Plugin<TableRowDragHandlePluginState>({
|
||||
key: TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY,
|
||||
state: {
|
||||
init: () => ({}),
|
||||
apply(tr, prev, oldState, newState) {
|
||||
const table = findTable(newState.selection);
|
||||
if (!haveTableRelatedChanges(editor, table, oldState, newState, tr)) {
|
||||
return table !== undefined ? prev : {};
|
||||
}
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
|
||||
// Check if table structure changed (height or position)
|
||||
const tableStructureChanged = prev.tableHeight !== tableMap.height || prev.tableNodePos !== table.pos;
|
||||
|
||||
let isStale = tableStructureChanged;
|
||||
|
||||
// Only do position-based stale check if structure hasn't changed
|
||||
if (!isStale) {
|
||||
const mapped = prev.decorations?.map(tr.mapping, tr.doc);
|
||||
for (let row = 0; row < tableMap.height; row++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width);
|
||||
if (mapped?.find(pos, pos + 1)?.length !== 1) {
|
||||
isStale = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStale) {
|
||||
const mapped = prev.decorations?.map(tr.mapping, tr.doc);
|
||||
return {
|
||||
decorations: mapped,
|
||||
tableHeight: tableMap.height,
|
||||
tableNodePos: table.pos,
|
||||
};
|
||||
}
|
||||
|
||||
// recreate all decorations
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
for (let row = 0; row < tableMap.height; row++) {
|
||||
const pos = getTableCellWidgetDecorationPos(table, tableMap, row * tableMap.width);
|
||||
|
||||
const dragHandleComponent = new ReactRenderer(RowDragHandle, {
|
||||
props: {
|
||||
editor,
|
||||
row,
|
||||
} satisfies RowDragHandleProps,
|
||||
editor,
|
||||
});
|
||||
|
||||
decorations.push(Decoration.widget(pos, () => dragHandleComponent.element));
|
||||
}
|
||||
|
||||
return {
|
||||
decorations: DecorationSet.create(newState.doc, decorations),
|
||||
tableHeight: tableMap.height,
|
||||
tableNodePos: table.pos,
|
||||
};
|
||||
},
|
||||
},
|
||||
props: {
|
||||
decorations(state) {
|
||||
return TABLE_ROW_DRAG_HANDLE_PLUGIN_KEY.getState(state).decorations;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import { TableMap } from "@tiptap/pm/tables";
|
||||
// extensions
|
||||
import { getSelectedRect, isCellSelection, type TableNodeLocation } from "@/extensions/table/table/utilities/helpers";
|
||||
// local imports
|
||||
import { cloneTableCell, constructDragPreviewTable, updateCellContentVisibility } from "../utils";
|
||||
|
||||
type TableRow = {
|
||||
top: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Calculate the index where the dragged row should be dropped.
|
||||
* @param {number} row - The row index.
|
||||
* @param {TableRow[]} rows - The rows.
|
||||
* @param {number} top - The top position of the dragged row.
|
||||
* @returns {number} The index where the dragged row should be dropped.
|
||||
*/
|
||||
export const calculateRowDropIndex = (row: number, rows: TableRow[], top: number): number => {
|
||||
const currentRowTop = rows[row].top;
|
||||
const currentRowBottom = currentRowTop + rows[row].height;
|
||||
|
||||
const draggedRowTop = top;
|
||||
const draggedRowBottom = draggedRowTop + rows[row].height;
|
||||
|
||||
const isDraggingUp = draggedRowTop < currentRowTop;
|
||||
const isDraggingDown = draggedRowBottom > currentRowBottom;
|
||||
|
||||
const isFirstRow = row === 0;
|
||||
const isLastRow = row === rows.length - 1;
|
||||
|
||||
if ((isFirstRow && isDraggingUp) || (isLastRow && isDraggingDown)) {
|
||||
return row;
|
||||
}
|
||||
|
||||
const firstRow = rows[0];
|
||||
if (isDraggingUp && draggedRowTop <= firstRow.top) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
if (isDraggingDown && draggedRowBottom >= lastRow.top + lastRow.height) {
|
||||
return rows.length - 1;
|
||||
}
|
||||
|
||||
let dropRowIndex = row;
|
||||
if (isDraggingDown) {
|
||||
const findHoveredRow = rows.find((p, index) => {
|
||||
if (index === row) return false;
|
||||
const currentRowCenter = p.top + p.height / 2;
|
||||
const currentRowEdge = p.top + p.height;
|
||||
const nextRow = rows[index + 1] as TableRow | undefined;
|
||||
const nextRowCenter = nextRow ? nextRow.height / 2 : 0;
|
||||
|
||||
return draggedRowBottom >= currentRowCenter && draggedRowBottom < currentRowEdge + nextRowCenter;
|
||||
});
|
||||
if (findHoveredRow) {
|
||||
dropRowIndex = rows.indexOf(findHoveredRow);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDraggingUp) {
|
||||
const findHoveredRow = rows.find((p, index) => {
|
||||
if (index === row) return false;
|
||||
const currentRowCenter = p.top + p.height / 2;
|
||||
const prevRow = rows[index - 1] as TableRow | undefined;
|
||||
const prevRowTop = prevRow ? prevRow.top : 0;
|
||||
const prevRowCenter = prevRow ? prevRow.height / 2 : 0;
|
||||
|
||||
return draggedRowTop <= currentRowCenter && draggedRowTop > prevRowTop + prevRowCenter;
|
||||
});
|
||||
if (findHoveredRow) {
|
||||
dropRowIndex = rows.indexOf(findHoveredRow);
|
||||
}
|
||||
}
|
||||
|
||||
return dropRowIndex;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Get the node information of the rows in the table- their offset top and height.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @returns {TableRow[]} The information of the rows in the table.
|
||||
*/
|
||||
export const getTableRowNodesInfo = (table: TableNodeLocation, editor: Editor): TableRow[] => {
|
||||
const result: TableRow[] = [];
|
||||
let topPx = 0;
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
if (!tableMap || tableMap.height === 0 || tableMap.width === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (let row = 0; row < tableMap.height; row++) {
|
||||
const cellPos = tableMap.map[row * tableMap.width];
|
||||
if (cellPos === undefined) continue;
|
||||
const dom = editor.view.domAtPos(table.start + cellPos);
|
||||
if (dom.node instanceof HTMLElement) {
|
||||
const heightPx = dom.node.offsetHeight;
|
||||
result.push({
|
||||
top: topPx,
|
||||
height: heightPx,
|
||||
});
|
||||
topPx += heightPx;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Construct a pseudo column from the selected cells for drag preview.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @param {Selection} selection - The selection.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @returns {HTMLElement | undefined} The pseudo column.
|
||||
*/
|
||||
export const constructRowDragPreview = (
|
||||
editor: Editor,
|
||||
selection: Selection,
|
||||
table: TableNodeLocation
|
||||
): HTMLElement | undefined => {
|
||||
if (!isCellSelection(selection)) return;
|
||||
|
||||
const tableMap = TableMap.get(table.node);
|
||||
const selectedRowRect = getSelectedRect(selection, tableMap);
|
||||
const activeRowCells = tableMap.cellsInRect(selectedRowRect);
|
||||
|
||||
const { tableElement, tableBodyElement } = constructDragPreviewTable();
|
||||
|
||||
const tableRowElement = document.createElement("tr");
|
||||
tableBodyElement.appendChild(tableRowElement);
|
||||
|
||||
activeRowCells.forEach((cellPos) => {
|
||||
const resolvedCellPos = table.start + cellPos + 1;
|
||||
const cellElement = editor.view.domAtPos(resolvedCellPos).node;
|
||||
if (cellElement instanceof HTMLElement) {
|
||||
const { clonedCellElement } = cloneTableCell(cellElement);
|
||||
clonedCellElement.style.width = cellElement.getBoundingClientRect().width + "px";
|
||||
tableRowElement.appendChild(clonedCellElement);
|
||||
}
|
||||
});
|
||||
|
||||
updateCellContentVisibility(editor, true);
|
||||
|
||||
return tableElement;
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
|
||||
/**
|
||||
* @description Construct a pseudo table element which will act as a parent for column and row drag previews.
|
||||
* @returns {HTMLTableElement} The pseudo table.
|
||||
*/
|
||||
export const constructDragPreviewTable = (): {
|
||||
tableElement: HTMLTableElement;
|
||||
tableBodyElement: HTMLTableSectionElement;
|
||||
} => {
|
||||
const tableElement = document.createElement("table");
|
||||
tableElement.classList.add("table-drag-preview");
|
||||
tableElement.classList.add("bg-custom-background-100");
|
||||
tableElement.style.opacity = "0.9";
|
||||
const tableBodyElement = document.createElement("tbody");
|
||||
tableElement.appendChild(tableBodyElement);
|
||||
|
||||
return { tableElement, tableBodyElement };
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Clone a table cell element.
|
||||
* @param {HTMLElement} cellElement - The cell element to clone.
|
||||
* @returns {HTMLElement} The cloned cell element.
|
||||
*/
|
||||
export const cloneTableCell = (
|
||||
cellElement: HTMLElement
|
||||
): {
|
||||
clonedCellElement: HTMLElement;
|
||||
} => {
|
||||
const clonedCellElement = cellElement.cloneNode(true) as HTMLElement;
|
||||
clonedCellElement.style.setProperty("visibility", "visible", "important");
|
||||
|
||||
const widgetElement = clonedCellElement.querySelectorAll(".ProseMirror-widget");
|
||||
widgetElement.forEach((widget) => widget.remove());
|
||||
|
||||
return { clonedCellElement };
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This function updates the `hideContent` attribute of the table cells and headers.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @param {boolean} hideContent - Whether to hide the content.
|
||||
* @returns {boolean} Whether the content visibility was updated.
|
||||
*/
|
||||
export const updateCellContentVisibility = (editor: Editor, hideContent: boolean): boolean =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setMeta(CORE_EDITOR_META.ADD_TO_HISTORY, false)
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
hideContent,
|
||||
})
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_HEADER, {
|
||||
hideContent,
|
||||
})
|
||||
.run();
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Editor } from "@tiptap/core";
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// local imports
|
||||
import { COL_DRAG_MARKER_CLASS, DROP_MARKER_CLASS, ROW_DRAG_MARKER_CLASS } from "../drag-handles/marker-utils";
|
||||
import { createColumnInsertButton, createRowInsertButton, findAllTables, TableInfo } from "./utils";
|
||||
|
||||
const TABLE_INSERT_PLUGIN_KEY = new PluginKey("table-insert");
|
||||
@@ -25,6 +26,13 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
tableInfo.rowButtonElement = rowButton;
|
||||
}
|
||||
|
||||
// Create and add drag marker if it doesn't exist
|
||||
if (!tableInfo.dragMarkerContainerElement) {
|
||||
const dragMarker = createMarkerContainer();
|
||||
tableElement.appendChild(dragMarker);
|
||||
tableInfo.dragMarkerContainerElement = dragMarker;
|
||||
}
|
||||
|
||||
tableMap.set(tableElement, tableInfo);
|
||||
};
|
||||
|
||||
@@ -32,6 +40,7 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
const tableInfo = tableMap.get(tableElement);
|
||||
tableInfo?.columnButtonElement?.remove();
|
||||
tableInfo?.rowButtonElement?.remove();
|
||||
tableInfo?.dragMarkerContainerElement?.remove();
|
||||
tableMap.delete(tableElement);
|
||||
};
|
||||
|
||||
@@ -64,6 +73,7 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
|
||||
return new Plugin({
|
||||
key: TABLE_INSERT_PLUGIN_KEY,
|
||||
|
||||
view() {
|
||||
setTimeout(updateAllTables, 0);
|
||||
|
||||
@@ -85,3 +95,33 @@ export const TableInsertPlugin = (editor: Editor): Plugin => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createMarkerContainer = (): HTMLElement => {
|
||||
const el = document.createElement("div");
|
||||
el.className = "table-drag-marker-container";
|
||||
el.contentEditable = "false";
|
||||
el.appendChild(createDropMarker());
|
||||
el.appendChild(createColDragMarker());
|
||||
el.appendChild(createRowDragMarker());
|
||||
return el;
|
||||
};
|
||||
|
||||
const createDropMarker = (): HTMLElement => {
|
||||
const el = document.createElement("div");
|
||||
el.className = DROP_MARKER_CLASS;
|
||||
return el;
|
||||
};
|
||||
|
||||
const createColDragMarker = (): HTMLElement => {
|
||||
const el = document.createElement("div");
|
||||
el.className = `${COL_DRAG_MARKER_CLASS} hidden`;
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
const createRowDragMarker = (): HTMLElement => {
|
||||
const el = document.createElement("div");
|
||||
el.className = `${ROW_DRAG_MARKER_CLASS} hidden`;
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { addColumn, removeColumn, addRow, removeRow, TableMap } from "@tiptap/pm/tables";
|
||||
import { addColumn, removeColumn, addRow, removeRow, TableMap, type TableRect } from "@tiptap/pm/tables";
|
||||
// local imports
|
||||
import { isCellEmpty } from "../../table/utilities/helpers";
|
||||
|
||||
@@ -17,6 +17,7 @@ export type TableInfo = {
|
||||
tablePos: number;
|
||||
columnButtonElement?: HTMLElement;
|
||||
rowButtonElement?: HTMLElement;
|
||||
dragMarkerContainerElement?: HTMLElement;
|
||||
};
|
||||
|
||||
export const createColumnInsertButton = (editor: Editor, tableInfo: TableInfo): HTMLElement => {
|
||||
@@ -274,7 +275,7 @@ const insertColumnAfterLast = (editor: Editor, tableInfo: TableInfo) => {
|
||||
const lastColumnIndex = tableMapData.width;
|
||||
|
||||
const tr = editor.state.tr;
|
||||
const rect = {
|
||||
const rect: TableRect = {
|
||||
map: tableMapData,
|
||||
tableStart: tablePos,
|
||||
table: tableNode,
|
||||
@@ -346,7 +347,7 @@ const insertRowAfterLast = (editor: Editor, tableInfo: TableInfo) => {
|
||||
const lastRowIndex = tableMapData.height;
|
||||
|
||||
const tr = editor.state.tr;
|
||||
const rect = {
|
||||
const rect: TableRect = {
|
||||
map: tableMapData,
|
||||
tableStart: tablePos,
|
||||
table: tableNode,
|
||||
|
||||
@@ -47,6 +47,9 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
textColor: {
|
||||
default: null,
|
||||
},
|
||||
hideContent: {
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -107,7 +110,8 @@ export const TableCell = Node.create<TableCellOptions>({
|
||||
return [
|
||||
"td",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`,
|
||||
class: node.attrs.hideContent ? "content-hidden" : "",
|
||||
style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor};`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
|
||||
@@ -17,7 +17,7 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
};
|
||||
},
|
||||
|
||||
content: "paragraph+",
|
||||
content: "block+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
@@ -39,6 +39,9 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
background: {
|
||||
default: "none",
|
||||
},
|
||||
hideContent: {
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -54,7 +57,8 @@ export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
return [
|
||||
"th",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}`,
|
||||
class: node.attrs.hideContent ? "content-hidden" : "",
|
||||
style: `background-color: ${node.attrs.background};`,
|
||||
}),
|
||||
0,
|
||||
];
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { findParentNode } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection, Transaction } from "@tiptap/pm/state";
|
||||
import { DecorationSet, Decoration } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
const key = new PluginKey("tableControls");
|
||||
|
||||
export function tableControls() {
|
||||
return new Plugin({
|
||||
key,
|
||||
state: {
|
||||
init() {
|
||||
return new TableControlsState();
|
||||
},
|
||||
apply(tr, prev) {
|
||||
return prev.apply(tr);
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleTripleClickOn(view, pos, node, nodePos, event) {
|
||||
if (node.type.name === CORE_EXTENSIONS.TABLE_CELL) {
|
||||
event.preventDefault();
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const line = $pos.parent;
|
||||
const linePos = $pos.start();
|
||||
const start = linePos;
|
||||
const end = linePos + line.nodeSize - 1;
|
||||
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, start, end));
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
const pluginState = key.getState(view.state);
|
||||
|
||||
if (!(event.target as HTMLElement).closest(".table-wrapper") && pluginState.values.hoveredTable) {
|
||||
return view.dispatch(
|
||||
view.state.tr.setMeta(key, {
|
||||
setHoveredTable: null,
|
||||
setHoveredCell: null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const pos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return;
|
||||
|
||||
const table = findParentNode((node) => node.type.name === CORE_EXTENSIONS.TABLE)(
|
||||
TextSelection.create(view.state.doc, pos.pos)
|
||||
);
|
||||
const cell = findParentNode((node) =>
|
||||
[CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)
|
||||
)(TextSelection.create(view.state.doc, pos.pos));
|
||||
|
||||
if (!table || !cell) return;
|
||||
|
||||
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
|
||||
return view.dispatch(
|
||||
view.state.tr.setMeta(key, {
|
||||
setHoveredTable: table,
|
||||
setHoveredCell: cell,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
decorations: (state) => {
|
||||
const pluginState = key.getState(state);
|
||||
if (!pluginState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { hoveredTable, hoveredCell } = pluginState.values;
|
||||
const docSize = state.doc.content.size;
|
||||
if (hoveredTable && hoveredCell && hoveredTable.pos < docSize && hoveredCell.pos < docSize) {
|
||||
const decorations = [
|
||||
Decoration.node(
|
||||
hoveredTable.pos,
|
||||
hoveredTable.pos + hoveredTable.node.nodeSize,
|
||||
{},
|
||||
{
|
||||
hoveredTable,
|
||||
hoveredCell,
|
||||
}
|
||||
),
|
||||
];
|
||||
|
||||
return DecorationSet.create(state.doc, decorations);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
class TableControlsState {
|
||||
values;
|
||||
|
||||
constructor(props = {}) {
|
||||
this.values = {
|
||||
hoveredTable: null,
|
||||
hoveredCell: null,
|
||||
...props,
|
||||
};
|
||||
}
|
||||
|
||||
apply(tr: Transaction) {
|
||||
const actions = tr.getMeta(key);
|
||||
|
||||
if (actions?.setHoveredTable !== undefined) {
|
||||
this.values.hoveredTable = actions.setHoveredTable;
|
||||
}
|
||||
|
||||
if (actions?.setHoveredCell !== undefined) {
|
||||
this.values.hoveredCell = actions.setHoveredCell;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
addRowBefore,
|
||||
CellSelection,
|
||||
columnResizing,
|
||||
deleteCellSelection,
|
||||
deleteTable,
|
||||
fixTables,
|
||||
goToNextCell,
|
||||
@@ -17,12 +18,13 @@ import {
|
||||
toggleHeader,
|
||||
toggleHeaderCell,
|
||||
} from "@tiptap/pm/tables";
|
||||
import { Decoration } from "@tiptap/pm/view";
|
||||
import type { Decoration } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { TableColumnDragHandlePlugin } from "../plugins/drag-handles/column/plugin";
|
||||
import { TableRowDragHandlePlugin } from "../plugins/drag-handles/row/plugin";
|
||||
import { TableInsertPlugin } from "../plugins/insert-handlers/plugin";
|
||||
import { tableControls } from "./table-controls";
|
||||
import { TableView } from "./table-view";
|
||||
import { createTable } from "./utilities/create-table";
|
||||
import { deleteColumnOrTable } from "./utilities/delete-column";
|
||||
@@ -57,6 +59,7 @@ declare module "@tiptap/core" {
|
||||
toggleHeaderColumn: () => ReturnType;
|
||||
toggleHeaderRow: () => ReturnType;
|
||||
toggleHeaderCell: () => ReturnType;
|
||||
clearSelectedCells: () => ReturnType;
|
||||
mergeOrSplit: () => ReturnType;
|
||||
setCellAttribute: (name: string, value: any) => ReturnType;
|
||||
goToNextCell: () => ReturnType;
|
||||
@@ -174,6 +177,10 @@ export const Table = Node.create<TableOptions>({
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
toggleHeaderCell(state, dispatch),
|
||||
clearSelectedCells:
|
||||
() =>
|
||||
({ state, dispatch }) =>
|
||||
deleteCellSelection(state, dispatch),
|
||||
mergeOrSplit:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
@@ -254,10 +261,10 @@ export const Table = Node.create<TableOptions>({
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ editor, getPos, node, decorations }) => {
|
||||
return ({ editor, node, decorations, getPos }) => {
|
||||
const { cellMinWidth } = this.options;
|
||||
|
||||
return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos as () => number);
|
||||
return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos);
|
||||
};
|
||||
},
|
||||
|
||||
@@ -268,8 +275,9 @@ export const Table = Node.create<TableOptions>({
|
||||
tableEditing({
|
||||
allowTableNodeSelection: this.options.allowTableNodeSelection,
|
||||
}),
|
||||
tableControls(),
|
||||
TableInsertPlugin(this.editor),
|
||||
TableColumnDragHandlePlugin(this.editor),
|
||||
TableRowDragHandlePlugin(this.editor),
|
||||
];
|
||||
|
||||
if (isResizable) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Editor, findParentNode } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import { CellSelection } from "@tiptap/pm/tables";
|
||||
import type { EditorState, Selection, Transaction } from "@tiptap/pm/state";
|
||||
import { CellSelection, type Rect, TableMap } from "@tiptap/pm/tables";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
@@ -35,3 +36,184 @@ export const isCellEmpty = (cell: ProseMirrorNode | null): boolean => {
|
||||
|
||||
return !hasContent;
|
||||
};
|
||||
|
||||
export type TableNodeLocation = {
|
||||
pos: number;
|
||||
start: number;
|
||||
node: ProseMirrorNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Find the table node location from the selection.
|
||||
* @param {Selection} selection - The selection.
|
||||
* @returns {TableNodeLocation | undefined} The table node location.
|
||||
*/
|
||||
export const findTable = (selection: Selection): TableNodeLocation | undefined =>
|
||||
findParentNode((node) => node.type.spec.tableRole === "table")(selection);
|
||||
|
||||
/**
|
||||
* @description Check if the selection has table related changes.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @param {TableNodeLocation | undefined} table - The table node location.
|
||||
* @param {EditorState} oldState - The old editor state.
|
||||
* @param {EditorState} newState - The new editor state.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
* @returns {boolean} True if the selection has table related changes, false otherwise.
|
||||
*/
|
||||
export const haveTableRelatedChanges = (
|
||||
editor: Editor,
|
||||
table: TableNodeLocation | undefined,
|
||||
oldState: EditorState,
|
||||
newState: EditorState,
|
||||
tr: Transaction
|
||||
): table is TableNodeLocation =>
|
||||
editor.isEditable && table !== undefined && (tr.docChanged || !newState.selection.eq(oldState.selection));
|
||||
|
||||
/**
|
||||
* @description Get the selected rect from the cell selection.
|
||||
* @param {CellSelection} selection - The cell selection.
|
||||
* @param {TableMap} map - The table map.
|
||||
* @returns {Rect} The selected rect.
|
||||
*/
|
||||
export const getSelectedRect = (selection: CellSelection, map: TableMap): Rect => {
|
||||
const start = selection.$anchorCell.start(-1);
|
||||
return map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Get the selected columns from the cell selection.
|
||||
* @param {Selection} selection - The selection.
|
||||
* @param {TableMap} map - The table map.
|
||||
* @returns {number[]} The selected columns.
|
||||
*/
|
||||
export const getSelectedColumns = (selection: Selection, map: TableMap): number[] => {
|
||||
if (isCellSelection(selection) && selection.isColSelection()) {
|
||||
const selectedRect = getSelectedRect(selection, map);
|
||||
return [...Array(selectedRect.right - selectedRect.left).keys()].map((idx) => idx + selectedRect.left);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Get the selected rows from the cell selection.
|
||||
* @param {Selection} selection - The selection.
|
||||
* @param {TableMap} map - The table map.
|
||||
* @returns {number[]} The selected rows.
|
||||
*/
|
||||
export const getSelectedRows = (selection: Selection, map: TableMap): number[] => {
|
||||
if (isCellSelection(selection) && selection.isRowSelection()) {
|
||||
const selectedRect = getSelectedRect(selection, map);
|
||||
return [...Array(selectedRect.bottom - selectedRect.top).keys()].map((idx) => idx + selectedRect.top);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Check if the rect is selected.
|
||||
* @param {Rect} rect - The rect.
|
||||
* @param {CellSelection} selection - The cell selection.
|
||||
* @returns {boolean} True if the rect is selected, false otherwise.
|
||||
*/
|
||||
export const isRectSelected = (rect: Rect, selection: CellSelection): boolean => {
|
||||
const map = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const cells = map.cellsInRect(rect);
|
||||
const selectedCells = map.cellsInRect(getSelectedRect(selection, map));
|
||||
|
||||
return cells.every((cell) => selectedCells.includes(cell));
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Check if the column is selected.
|
||||
* @param {number} columnIndex - The column index.
|
||||
* @param {Selection} selection - The selection.
|
||||
* @returns {boolean} True if the column is selected, false otherwise.
|
||||
*/
|
||||
export const isColumnSelected = (columnIndex: number, selection: Selection): boolean => {
|
||||
if (!isCellSelection(selection)) return false;
|
||||
|
||||
const { height } = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const rect = { left: columnIndex, right: columnIndex + 1, top: 0, bottom: height };
|
||||
return isRectSelected(rect, selection);
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Check if the row is selected.
|
||||
* @param {number} rowIndex - The row index.
|
||||
* @param {Selection} selection - The selection.
|
||||
* @returns {boolean} True if the row is selected, false otherwise.
|
||||
*/
|
||||
export const isRowSelected = (rowIndex: number, selection: Selection): boolean => {
|
||||
if (isCellSelection(selection)) {
|
||||
const { width } = TableMap.get(selection.$anchorCell.node(-1));
|
||||
const rect = { left: 0, right: width, top: rowIndex, bottom: rowIndex + 1 };
|
||||
return isRectSelected(rect, selection);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Select the column.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {number} index - The column index.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
* @returns {Transaction} The updated transaction.
|
||||
*/
|
||||
export const selectColumn = (table: TableNodeLocation, index: number, tr: Transaction): Transaction => {
|
||||
const { map } = TableMap.get(table.node);
|
||||
|
||||
const anchorCell = table.start + map[index];
|
||||
const $anchor = tr.doc.resolve(anchorCell);
|
||||
|
||||
return tr.setSelection(CellSelection.colSelection($anchor));
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Select the row.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {number} index - The row index.
|
||||
* @param {Transaction} tr - The transaction.
|
||||
* @returns {Transaction} The updated transaction.
|
||||
*/
|
||||
export const selectRow = (table: TableNodeLocation, index: number, tr: Transaction): Transaction => {
|
||||
const { map, width } = TableMap.get(table.node);
|
||||
|
||||
const anchorCell = table.start + map[index * width];
|
||||
const $anchor = tr.doc.resolve(anchorCell);
|
||||
|
||||
return tr.setSelection(CellSelection.rowSelection($anchor));
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Get the position of the cell widget decoration.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {TableMap} map - The table map.
|
||||
* @param {number} index - The index.
|
||||
* @returns {number} The position of the cell widget decoration.
|
||||
*/
|
||||
export const getTableCellWidgetDecorationPos = (table: TableNodeLocation, map: TableMap, index: number): number =>
|
||||
table.start + map.map[index] + 1;
|
||||
|
||||
/**
|
||||
* @description Get the height of the table in pixels.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @returns {number} The height of the table in pixels.
|
||||
*/
|
||||
export const getTableHeightPx = (table: TableNodeLocation, editor: Editor): number => {
|
||||
const dom = editor.view.domAtPos(table.start);
|
||||
return dom.node.parentElement?.offsetHeight ?? 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Get the width of the table in pixels.
|
||||
* @param {TableNodeLocation} table - The table node location.
|
||||
* @param {Editor} editor - The editor instance.
|
||||
* @returns {number} The width of the table in pixels.
|
||||
*/
|
||||
export const getTableWidthPx = (table: TableNodeLocation, editor: Editor): number => {
|
||||
const dom = editor.view.domAtPos(table.start);
|
||||
return dom.node.parentElement?.offsetWidth ?? 0;
|
||||
};
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export const WorkItemEmbedExtensionConfig = Node.create({
|
||||
name: CORE_EXTENSIONS.WORK_ITEM_EMBED,
|
||||
group: "block",
|
||||
atom: true,
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entity_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
project_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
workspace_identifier: {
|
||||
default: undefined,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
},
|
||||
entity_name: {
|
||||
default: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper, type NodeViewProps } from "@tiptap/react";
|
||||
// local imports
|
||||
import { WorkItemEmbedExtensionConfig } from "./extension-config";
|
||||
|
||||
type Props = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const WorkItemEmbedExtension = (props: Props) =>
|
||||
WorkItemEmbedExtensionConfig.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((issueProps: NodeViewProps) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./extension";
|
||||
@@ -9,18 +9,20 @@ import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import { CORE_EDITOR_META } from "@/constants/meta";
|
||||
// types
|
||||
import type { EditorRefApi, TEditorCommands } from "@/types";
|
||||
// plane editor imports
|
||||
import { getAdditionalEditorRefHelpers } from "@/plane-editor/helpers/editor-ref";
|
||||
// local imports
|
||||
import { getParagraphCount } from "./common";
|
||||
import { getExtensionStorage } from "./get-extension-storage";
|
||||
import { insertContentAtSavedSelection } from "./insert-content-at-cursor-position";
|
||||
import { scrollSummary, scrollToNodeViaDOMCoordinates } from "./scroll-to-node";
|
||||
|
||||
type TArgs = {
|
||||
export type TEditorRefHelperArgs = {
|
||||
editor: Editor | null;
|
||||
provider: HocuspocusProvider | undefined;
|
||||
};
|
||||
|
||||
export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
export const getEditorRefHelpers = (args: TEditorRefHelperArgs): EditorRefApi => {
|
||||
const { editor, provider } = args;
|
||||
|
||||
return {
|
||||
@@ -238,5 +240,6 @@ export const getEditorRefHelpers = (args: TArgs): EditorRefApi => {
|
||||
Y.applyUpdate(document, value);
|
||||
},
|
||||
undo: () => editor?.commands.undo(),
|
||||
...getAdditionalEditorRefHelpers(args),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,14 +4,11 @@ import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-pros
|
||||
import * as Y from "yjs";
|
||||
// extensions
|
||||
import { TDocumentPayload } from "@plane/types";
|
||||
import {
|
||||
CoreEditorExtensionsWithoutProps,
|
||||
DocumentEditorExtensionsWithoutProps,
|
||||
} from "@/extensions/core-without-props";
|
||||
import { CoreEditorExtensionsWithoutProps } from "@/extensions/core-without-props";
|
||||
|
||||
// editor extension configs
|
||||
const RICH_TEXT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = [...CoreEditorExtensionsWithoutProps, ...DocumentEditorExtensionsWithoutProps];
|
||||
const DOCUMENT_EDITOR_EXTENSIONS = CoreEditorExtensionsWithoutProps;
|
||||
// editor schemas
|
||||
const richTextEditorSchema = getSchema(RICH_TEXT_EDITOR_EXTENSIONS);
|
||||
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
|
||||
|
||||
@@ -20,7 +20,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
editable,
|
||||
editorClassName = "",
|
||||
editorProps = {},
|
||||
embedHandler,
|
||||
extensions = [],
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
@@ -98,7 +97,6 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorHookProps) =>
|
||||
...extensions,
|
||||
...DocumentEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
embedConfig: embedHandler,
|
||||
fileHandler,
|
||||
flaggedExtensions,
|
||||
isEditable: editable,
|
||||
|
||||
@@ -4,7 +4,9 @@ import { EditorView } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
|
||||
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions/side-menu";
|
||||
// plane editor imports
|
||||
import { DRAG_HANDLE_ADDITIONAL_SELECTORS } from "@/plane-editor/constants/common";
|
||||
|
||||
const verticalEllipsisIcon =
|
||||
'<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-ellipsis-vertical"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>';
|
||||
@@ -16,11 +18,11 @@ const generalSelectors = [
|
||||
"blockquote",
|
||||
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
|
||||
"[data-type=horizontalRule]",
|
||||
"table",
|
||||
".issue-embed",
|
||||
"table:not(.table-drag-preview)",
|
||||
".image-component",
|
||||
".image-upload-component",
|
||||
".editor-callout-component",
|
||||
...DRAG_HANDLE_ADDITIONAL_SELECTORS,
|
||||
].join(", ");
|
||||
|
||||
const maxScrollSpeed = 20;
|
||||
@@ -65,9 +67,7 @@ const isScrollable = (node: HTMLElement | SVGElement) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getScrollParent = (node: HTMLElement | SVGElement | null): Element | null => {
|
||||
if (!node) return null;
|
||||
|
||||
export const getScrollParent = (node: HTMLElement | SVGElement) => {
|
||||
if (scrollParentCache.has(node)) {
|
||||
return scrollParentCache.get(node);
|
||||
}
|
||||
@@ -92,7 +92,7 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
|
||||
for (const elem of elements) {
|
||||
// Check for table wrapper first
|
||||
if (elem.matches("table")) {
|
||||
if (elem.matches("table:not(.table-drag-preview)")) {
|
||||
return elem;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ export const nodeDOMAtCoords = (coords: { x: number; y: number }) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip elements inside .editor-embed-component
|
||||
if (elem.closest(".editor-embed-component") && !elem.matches(".editor-embed-component")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// apply general selector
|
||||
if (elem.matches(generalSelectors)) {
|
||||
return elem;
|
||||
@@ -173,7 +178,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
scrollableParent.scrollBy({ top: currentScrollSpeed });
|
||||
}
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll) as unknown as null;
|
||||
}
|
||||
|
||||
const handleClick = (event: MouseEvent, view: EditorView) => {
|
||||
@@ -381,8 +386,9 @@ const handleNodeSelection = (
|
||||
let draggedNodePos = nodePosAtDOM(node, view, options);
|
||||
if (draggedNodePos == null || draggedNodePos < 0) return;
|
||||
|
||||
// Handle blockquote separately
|
||||
if (node.matches("blockquote")) {
|
||||
if (node.matches("table")) {
|
||||
draggedNodePos = draggedNodePos - 2;
|
||||
} else if (node.matches("blockquote")) {
|
||||
draggedNodePos = nodePosAtDOMForBlockQuotes(node, view);
|
||||
if (draggedNodePos === null || draggedNodePos === undefined) return;
|
||||
} else {
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { MarkType, NodeType } from "@tiptap/pm/model";
|
||||
import type { Selection } from "@tiptap/pm/state";
|
||||
import type { EditorProps, EditorView } from "@tiptap/pm/view";
|
||||
// extension types
|
||||
import type { TTextAlign } from "@/extensions";
|
||||
import type { TTextAlign } from "@/extensions/text-align";
|
||||
// plane editor imports
|
||||
import type {
|
||||
IEditorPropsExtended,
|
||||
TAdditionalCommandExtraProps,
|
||||
TAdditionalEditorCommands,
|
||||
TAdditionalEditorRefApiMethods,
|
||||
} from "@/plane-editor/types/editor";
|
||||
// types
|
||||
import type {
|
||||
IMarking,
|
||||
@@ -12,7 +19,6 @@ import type {
|
||||
TDocumentEventEmitter,
|
||||
TDocumentEventsServer,
|
||||
TEditorAsset,
|
||||
TEmbedConfig,
|
||||
TExtensions,
|
||||
TFileHandler,
|
||||
TMentionHandler,
|
||||
@@ -42,21 +48,17 @@ export type TEditorCommands =
|
||||
| "image"
|
||||
| "divider"
|
||||
| "link"
|
||||
| "issue-embed"
|
||||
| "text-color"
|
||||
| "background-color"
|
||||
| "text-align"
|
||||
| "callout"
|
||||
| "attachment"
|
||||
| "emoji";
|
||||
| "emoji"
|
||||
| TAdditionalEditorCommands;
|
||||
|
||||
export type TCommandExtraProps = {
|
||||
image: {
|
||||
savedSelection: Selection | null;
|
||||
};
|
||||
attachment: {
|
||||
savedSelection: Selection | null;
|
||||
};
|
||||
"text-color": {
|
||||
color: string | undefined;
|
||||
};
|
||||
@@ -70,7 +72,7 @@ export type TCommandExtraProps = {
|
||||
"text-align": {
|
||||
alignment: TTextAlign;
|
||||
};
|
||||
};
|
||||
} & TAdditionalCommandExtraProps;
|
||||
|
||||
// Create a utility type that maps a command to its extra props or an empty object if none are defined
|
||||
export type TCommandWithProps<T extends TEditorCommands> = T extends keyof TCommandExtraProps
|
||||
@@ -125,7 +127,7 @@ export type EditorRefApi = {
|
||||
setFocusAtPosition: (position: number) => void;
|
||||
setProviderDocument: (value: Uint8Array) => void;
|
||||
undo: () => void;
|
||||
};
|
||||
} & TAdditionalEditorRefApiMethods;
|
||||
|
||||
// editor props
|
||||
export type IEditorProps = {
|
||||
@@ -147,14 +149,14 @@ export type IEditorProps = {
|
||||
isTouchDevice?: boolean;
|
||||
mentionHandler: TMentionHandler;
|
||||
onAssetChange?: (assets: TEditorAsset[]) => void;
|
||||
onEditorFocus?: () => void;
|
||||
onChange?: (json: object, html: string) => void;
|
||||
onEditorFocus?: () => void;
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
onTransaction?: () => void;
|
||||
placeholder?: string | ((isFocused: boolean, value: string) => string);
|
||||
tabIndex?: number;
|
||||
value?: string | null;
|
||||
};
|
||||
} & IEditorPropsExtended;
|
||||
|
||||
export type ILiteTextEditorProps = IEditorProps;
|
||||
|
||||
@@ -167,7 +169,6 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
|
||||
documentLoaderClassName?: string;
|
||||
dragDropEnabled?: boolean;
|
||||
editable: boolean;
|
||||
embedHandler: TEmbedConfig;
|
||||
realtimeConfig: TRealtimeConfig;
|
||||
serverHandler?: TServerHandler;
|
||||
user: TUserDetails;
|
||||
@@ -175,7 +176,6 @@ export type ICollaborativeDocumentEditorProps = Omit<IEditorProps, "initialValue
|
||||
|
||||
export type IDocumentEditorProps = Omit<IEditorProps, "initialValue" | "onEnterKeyPress" | "value"> & {
|
||||
aiHandler?: TAIHandler;
|
||||
embedHandler: TEmbedConfig;
|
||||
user?: TUserDetails;
|
||||
value: Content;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user