Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 397cecc3a1 | |||
| e70105235b | |||
| 7766e8b5cf | |||
| 16d63abcdc | |||
| 0568b8d583 | |||
| 64da29b0d9 | |||
| 7c336a65c4 | |||
| 2242a85e5c | |||
| 323920a358 | |||
| 151fc8389e | |||
| 0f828fd5e0 | |||
| 67cbe94d4a | |||
| 322af8c436 | |||
| 41c2aefad4 | |||
| 445c819fbd | |||
| 046a8a1bcf | |||
| 099a1cc12b | |||
| a0a697401b | |||
| cb92108bf4 | |||
| 01b685ea57 | |||
| b16a585102 | |||
| 4a97d7c28c | |||
| 141cb17e8a | |||
| 26b62c4a70 | |||
| e388a9a279 | |||
| a3a580923c | |||
| b4bc49971c |
+2
-1
@@ -2,6 +2,7 @@
|
||||
*.pyc
|
||||
.env
|
||||
venv
|
||||
.venv
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
npm-debug.log
|
||||
@@ -14,4 +15,4 @@ build/
|
||||
out/
|
||||
**/out/
|
||||
dist/
|
||||
**/dist/
|
||||
**/dist/
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
name: Test Pull Request
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: ["opened", "synchronize", "ready_for_review"]
|
||||
paths:
|
||||
- 'apiserver/**'
|
||||
- '.github/workflows/test-pull-request.yml'
|
||||
|
||||
jobs:
|
||||
test-apiserver:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:14
|
||||
env:
|
||||
POSTGRES_PASSWORD: plane
|
||||
POSTGRES_USER: plane
|
||||
POSTGRES_DB: plane
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache Python dependencies
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements/test.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
cd apiserver
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements/test.txt
|
||||
|
||||
- name: Set up test environment
|
||||
run: |
|
||||
cd apiserver
|
||||
cat > .env << EOF
|
||||
# Basic Django settings
|
||||
DEBUG=1
|
||||
SECRET_KEY=test-secret-key-for-ci-only-do-not-use-in-production
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=postgres://plane:plane@localhost:5432/plane
|
||||
|
||||
# Redis Configuration
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email Backend for Testing
|
||||
EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend
|
||||
|
||||
# CORS Settings for Testing
|
||||
CORS_ALLOW_ALL_ORIGINS=True
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:3001,http://localhost:3002
|
||||
|
||||
# Disable SSL and security features for testing
|
||||
SECURE_SSL_REDIRECT=False
|
||||
SECURE_HSTS_SECONDS=0
|
||||
SESSION_COOKIE_SECURE=False
|
||||
CSRF_COOKIE_SECURE=False
|
||||
|
||||
# Instance settings
|
||||
INSTANCE_KEY=test-instance-key-for-ci
|
||||
SKIP_ENV_VAR=1
|
||||
|
||||
# File upload settings for testing
|
||||
FILE_SIZE_LIMIT=5242880
|
||||
USE_MINIO=0
|
||||
|
||||
# Base URLs for testing
|
||||
WEB_URL=http://localhost:8000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
ADMIN_BASE_URL=http://localhost:3001
|
||||
SPACE_BASE_URL=http://localhost:3002
|
||||
LIVE_BASE_URL=http://localhost:3100
|
||||
|
||||
# Session settings
|
||||
SESSION_COOKIE_AGE=604800
|
||||
SESSION_COOKIE_NAME=session-id
|
||||
ADMIN_SESSION_COOKIE_AGE=3600
|
||||
|
||||
# API settings
|
||||
API_KEY_RATE_LIMIT=60/minute
|
||||
|
||||
# Disable external services for testing
|
||||
ENABLE_SIGNUP=1
|
||||
POSTHOG_API_KEY=
|
||||
ANALYTICS_SECRET_KEY=
|
||||
GITHUB_ACCESS_TOKEN=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# RabbitMQ/Celery settings (will be mocked in tests)
|
||||
RABBITMQ_HOST=localhost
|
||||
RABBITMQ_PORT=5672
|
||||
RABBITMQ_USER=guest
|
||||
RABBITMQ_PASSWORD=guest
|
||||
RABBITMQ_VHOST=/
|
||||
|
||||
# AWS/Storage settings (will be mocked in tests)
|
||||
AWS_ACCESS_KEY_ID=test-access-key
|
||||
AWS_SECRET_ACCESS_KEY=test-secret-key
|
||||
AWS_S3_BUCKET_NAME=test-uploads
|
||||
AWS_REGION=us-east-1
|
||||
EOF
|
||||
|
||||
- name: Run unit tests
|
||||
run: |
|
||||
cd apiserver
|
||||
python run_tests.py -u -v --coverage
|
||||
|
||||
- name: Run contract tests
|
||||
run: |
|
||||
cd apiserver
|
||||
python run_tests.py -c -v
|
||||
|
||||
- name: Show coverage summary
|
||||
if: always()
|
||||
run: |
|
||||
cd apiserver
|
||||
python -m coverage report --show-missing
|
||||
|
||||
- name: Upload coverage reports
|
||||
uses: codecov/codecov-action@v4
|
||||
if: always() && env.CODECOV_TOKEN != ''
|
||||
with:
|
||||
file: ./apiserver/coverage.xml
|
||||
flags: apiserver
|
||||
name: apiserver-coverage
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
- name: Generate coverage badge
|
||||
if: always()
|
||||
run: |
|
||||
cd apiserver
|
||||
coverage-badge -o coverage.svg
|
||||
continue-on-error: true
|
||||
|
||||
test-summary:
|
||||
if: always() && github.event.pull_request.draft == false
|
||||
needs: [test-apiserver]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Test Results Summary
|
||||
run: |
|
||||
echo "# Test Results Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [[ "${{ needs.test-apiserver.result }}" == "success" ]]; then
|
||||
echo "✅ **API Server Tests**: PASSED" >> $GITHUB_STEP_SUMMARY
|
||||
echo "All tests completed successfully with coverage reporting." >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "❌ **API Server Tests**: FAILED" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Tests failed. Please check the logs for details." >> $GITHUB_STEP_SUMMARY
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## Test Categories Executed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Unit Tests**: Fast, isolated component tests" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Contract Tests**: API endpoint verification" >> $GITHUB_STEP_SUMMARY
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
"lucide-react": "^0.469.0",
|
||||
"mobx": "^6.12.0",
|
||||
"mobx-react": "^9.1.1",
|
||||
"next": "^14.2.28",
|
||||
"next": "^14.2.29",
|
||||
"next-themes": "^0.2.1",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -148,10 +148,13 @@ class ProjectMemberAdminSerializer(BaseSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
class ProjectMemberRoleSerializer(DynamicBaseSerializer):
|
||||
original_role = serializers.IntegerField(source='role', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = ProjectMember
|
||||
fields = ("id", "role", "member", "project")
|
||||
fields = ("id", "role", "member", "project", "original_role", "created_at")
|
||||
read_only_fields = ["original_role", "created_at"]
|
||||
|
||||
|
||||
class ProjectMemberInviteSerializer(BaseSerializer):
|
||||
|
||||
@@ -3,11 +3,22 @@ from rest_framework import serializers
|
||||
|
||||
# Module import
|
||||
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class UserSerializer(BaseSerializer):
|
||||
def validate_first_name(self, value):
|
||||
if contains_url(value):
|
||||
raise serializers.ValidationError("First name cannot contain a URL.")
|
||||
return value
|
||||
|
||||
def validate_last_name(self, value):
|
||||
if contains_url(value):
|
||||
raise serializers.ValidationError("Last name cannot contain a URL.")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
# Exclude password field from the serializer
|
||||
@@ -99,11 +110,16 @@ class UserMeSettingsSerializer(BaseSerializer):
|
||||
workspace_member__member=obj.id,
|
||||
workspace_member__is_active=True,
|
||||
).first()
|
||||
logo_asset_url = workspace.logo_asset.asset_url if workspace.logo_asset is not None else ""
|
||||
return {
|
||||
"last_workspace_id": profile.last_workspace_id,
|
||||
"last_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
),
|
||||
"last_workspace_name": (
|
||||
workspace.name if workspace is not None else ""
|
||||
),
|
||||
"last_workspace_logo": (logo_asset_url),
|
||||
"fallback_workspace_id": profile.last_workspace_id,
|
||||
"fallback_workspace_slug": (
|
||||
workspace.slug if workspace is not None else ""
|
||||
|
||||
@@ -25,10 +25,12 @@ from plane.db.models import (
|
||||
WorkspaceUserPreference,
|
||||
)
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
import re
|
||||
|
||||
|
||||
class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
@@ -36,10 +38,21 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
|
||||
logo_url = serializers.CharField(read_only=True)
|
||||
role = serializers.IntegerField(read_only=True)
|
||||
|
||||
def validate_name(self, value):
|
||||
# Check if the name contains a URL
|
||||
if contains_url(value):
|
||||
raise serializers.ValidationError("Name must not contain URLs")
|
||||
return value
|
||||
|
||||
def validate_slug(self, value):
|
||||
# Check if the slug is restricted
|
||||
if value in RESTRICTED_WORKSPACE_SLUGS:
|
||||
raise serializers.ValidationError("Slug is not valid")
|
||||
# Slug should only contain alphanumeric characters, hyphens, and underscores
|
||||
if not re.match(r"^[a-zA-Z0-9_-]+$", value):
|
||||
raise serializers.ValidationError(
|
||||
"Slug can only contain letters, numbers, hyphens (-), and underscores (_)"
|
||||
)
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -12,6 +12,7 @@ from plane.app.views import (
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,5 +82,11 @@ urlpatterns = [
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/projects/<uuid:project_id>/<uuid:entity_id>/bulk/",
|
||||
ProjectBulkAssetEndpoint.as_view(),
|
||||
name="bulk-asset-update",
|
||||
),
|
||||
path(
|
||||
"assets/v2/workspaces/<str:slug>/check/<uuid:asset_id>/",
|
||||
AssetCheckEndpoint.as_view(),
|
||||
name="asset-check",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -106,6 +106,7 @@ from .asset.v2 import (
|
||||
AssetRestoreEndpoint,
|
||||
ProjectAssetEndpoint,
|
||||
ProjectBulkAssetEndpoint,
|
||||
AssetCheckEndpoint,
|
||||
)
|
||||
from .issue.base import (
|
||||
IssueListEndpoint,
|
||||
|
||||
@@ -707,3 +707,14 @@ class ProjectBulkAssetEndpoint(BaseAPIView):
|
||||
pass
|
||||
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class AssetCheckEndpoint(BaseAPIView):
|
||||
"""Endpoint to check if an asset exists."""
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||
def get(self, request, slug, asset_id):
|
||||
asset = FileAsset.all_objects.filter(
|
||||
id=asset_id, workspace__slug=slug, deleted_at__isnull=True
|
||||
).exists()
|
||||
return Response({"exists": asset}, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -15,6 +15,7 @@ from plane.app.serializers import IssueLinkSerializer
|
||||
from plane.app.permissions import ProjectEntityPermission
|
||||
from plane.db.models import IssueLink
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.bgtasks.work_item_link_task import crawl_work_item_link_title
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
@@ -44,6 +45,9 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
serializer = IssueLinkSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||
crawl_work_item_link_title(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
issue_activity.delay(
|
||||
type="link.activity.created",
|
||||
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
|
||||
@@ -55,6 +59,10 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
|
||||
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
|
||||
serializer = IssueLinkSerializer(issue_link)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -66,9 +74,14 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
current_instance = json.dumps(
|
||||
IssueLinkSerializer(issue_link).data, cls=DjangoJSONEncoder
|
||||
)
|
||||
|
||||
serializer = IssueLinkSerializer(issue_link, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
crawl_work_item_link_title(
|
||||
serializer.data.get("id"), serializer.data.get("url")
|
||||
)
|
||||
|
||||
issue_activity.delay(
|
||||
type="link.activity.updated",
|
||||
requested_data=requested_data,
|
||||
@@ -80,6 +93,9 @@ class IssueLinkViewSet(BaseViewSet):
|
||||
notification=True,
|
||||
origin=base_host(request=request, is_app=True),
|
||||
)
|
||||
issue_link = self.get_queryset().get(id=serializer.data.get("id"))
|
||||
serializer = IssueLinkSerializer(issue_link)
|
||||
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import csv
|
||||
import io
|
||||
import os
|
||||
from datetime import date
|
||||
import uuid
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.db import IntegrityError
|
||||
@@ -35,6 +36,7 @@ from plane.db.models import (
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
WorkspaceTheme,
|
||||
Profile,
|
||||
)
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from django.utils.decorators import method_decorator
|
||||
@@ -43,6 +45,7 @@ from django.views.decorators.vary import vary_on_cookie
|
||||
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.bgtasks.workspace_seed_task import workspace_seed
|
||||
from plane.utils.url import contains_url
|
||||
|
||||
|
||||
class WorkSpaceViewSet(BaseViewSet):
|
||||
@@ -109,6 +112,12 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if contains_url(name):
|
||||
return Response(
|
||||
{"error": "Name cannot contain a URL"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if serializer.is_valid(raise_exception=True):
|
||||
serializer.save(owner=request.user)
|
||||
# Create Workspace member
|
||||
@@ -150,8 +159,18 @@ class WorkSpaceViewSet(BaseViewSet):
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
return super().partial_update(request, *args, **kwargs)
|
||||
|
||||
def remove_last_workspace_ids_from_user_settings(self, id: uuid.UUID) -> None:
|
||||
"""
|
||||
Remove the last workspace id from the user settings
|
||||
"""
|
||||
Profile.objects.filter(last_workspace_id=id).update(last_workspace_id=None)
|
||||
return
|
||||
|
||||
@allow_permission([ROLE.ADMIN], level="WORKSPACE")
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
# Get the workspace
|
||||
workspace = self.get_object()
|
||||
self.remove_last_workspace_ids_from_user_settings(workspace.id)
|
||||
return super().destroy(request, *args, **kwargs)
|
||||
|
||||
|
||||
@@ -159,8 +178,6 @@ class UserWorkSpacesEndpoint(BaseAPIView):
|
||||
search_fields = ["name"]
|
||||
filterset_fields = ["owner"]
|
||||
|
||||
@method_decorator(cache_control(private=True, max_age=12))
|
||||
@method_decorator(vary_on_cookie)
|
||||
def get(self, request):
|
||||
fields = [field for field in request.GET.get("fields", "").split(",") if field]
|
||||
member_count = (
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Python imports
|
||||
import logging
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import base64
|
||||
import ipaddress
|
||||
from typing import Dict, Any
|
||||
from typing import Optional
|
||||
from plane.db.models import IssueLink
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
logger = logging.getLogger("plane.worker")
|
||||
|
||||
|
||||
DEFAULT_FAVICON = "PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWxpbmstaWNvbiBsdWNpZGUtbGluayI+PHBhdGggZD0iTTEwIDEzYTUgNSAwIDAgMCA3LjU0LjU0bDMtM2E1IDUgMCAwIDAtNy4wNy03LjA3bC0xLjcyIDEuNzEiLz48cGF0aCBkPSJNMTQgMTFhNSA1IDAgMCAwLTcuNTQtLjU0bC0zIDNhNSA1IDAgMCAwIDcuMDcgNy4wN2wxLjcxLTEuNzEiLz48L3N2Zz4=" # noqa: E501
|
||||
|
||||
|
||||
@shared_task
|
||||
def crawl_work_item_link_title(id: str, url: str) -> None:
|
||||
meta_data = crawl_work_item_link_title_and_favicon(url)
|
||||
issue_link = IssueLink.objects.get(id=id)
|
||||
|
||||
issue_link.metadata = meta_data
|
||||
|
||||
issue_link.save()
|
||||
|
||||
|
||||
def crawl_work_item_link_title_and_favicon(url: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Crawls a URL to extract the title and favicon.
|
||||
|
||||
Args:
|
||||
url (str): The URL to crawl
|
||||
|
||||
Returns:
|
||||
str: JSON string containing title and base64-encoded favicon
|
||||
"""
|
||||
try:
|
||||
# Prevent access to private IP ranges
|
||||
parsed = urlparse(url)
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(parsed.hostname)
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved:
|
||||
raise ValueError("Access to private/internal networks is not allowed")
|
||||
except ValueError:
|
||||
# Not an IP address, continue with domain validation
|
||||
pass
|
||||
|
||||
# Set up headers to mimic a real browser
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" # noqa: E501
|
||||
}
|
||||
|
||||
# Fetch the main page
|
||||
response = requests.get(url, headers=headers, timeout=2)
|
||||
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse HTML
|
||||
soup = BeautifulSoup(response.content, "html.parser")
|
||||
|
||||
# Extract title
|
||||
title_tag = soup.find("title")
|
||||
title = title_tag.get_text().strip() if title_tag else None
|
||||
|
||||
# Fetch and encode favicon
|
||||
favicon_base64 = fetch_and_encode_favicon(headers, soup, url)
|
||||
|
||||
# Prepare result
|
||||
result = {
|
||||
"title": title,
|
||||
"favicon": favicon_base64["favicon_base64"],
|
||||
"url": url,
|
||||
"favicon_url": favicon_base64["favicon_url"],
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"error": f"Request failed: {str(e)}",
|
||||
"title": None,
|
||||
"favicon": None,
|
||||
"url": url,
|
||||
}
|
||||
except Exception as e:
|
||||
log_exception(e)
|
||||
return {
|
||||
"error": f"Unexpected error: {str(e)}",
|
||||
"title": None,
|
||||
"favicon": None,
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
def find_favicon_url(soup: BeautifulSoup, base_url: str) -> Optional[str]:
|
||||
"""
|
||||
Find the favicon URL from HTML soup.
|
||||
|
||||
Args:
|
||||
soup: BeautifulSoup object
|
||||
base_url: Base URL for resolving relative paths
|
||||
|
||||
Returns:
|
||||
str: Absolute URL to favicon or None
|
||||
"""
|
||||
# Look for various favicon link tags
|
||||
favicon_selectors = [
|
||||
'link[rel="icon"]',
|
||||
'link[rel="shortcut icon"]',
|
||||
'link[rel="apple-touch-icon"]',
|
||||
'link[rel="apple-touch-icon-precomposed"]',
|
||||
]
|
||||
|
||||
for selector in favicon_selectors:
|
||||
favicon_tag = soup.select_one(selector)
|
||||
if favicon_tag and favicon_tag.get("href"):
|
||||
return urljoin(base_url, favicon_tag["href"])
|
||||
|
||||
# Fallback to /favicon.ico
|
||||
parsed_url = urlparse(base_url)
|
||||
fallback_url = f"{parsed_url.scheme}://{parsed_url.netloc}/favicon.ico"
|
||||
|
||||
# Check if fallback exists
|
||||
try:
|
||||
response = requests.head(fallback_url, timeout=2)
|
||||
response.raise_for_status()
|
||||
if response.status_code == 200:
|
||||
return fallback_url
|
||||
except requests.RequestException as e:
|
||||
log_exception(e)
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def fetch_and_encode_favicon(
|
||||
headers: Dict[str, str], soup: BeautifulSoup, url: str
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
Fetch favicon and encode it as base64.
|
||||
|
||||
Args:
|
||||
favicon_url: URL to the favicon
|
||||
headers: Request headers
|
||||
|
||||
Returns:
|
||||
str: Base64 encoded favicon with data URI prefix or None
|
||||
"""
|
||||
try:
|
||||
favicon_url = find_favicon_url(soup, url)
|
||||
if favicon_url is None:
|
||||
return {
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
|
||||
response = requests.get(favicon_url, headers=headers, timeout=2)
|
||||
response.raise_for_status()
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get("content-type", "image/x-icon")
|
||||
|
||||
# Convert to base64
|
||||
favicon_base64 = base64.b64encode(response.content).decode("utf-8")
|
||||
|
||||
# Return as data URI
|
||||
return {
|
||||
"favicon_url": favicon_url,
|
||||
"favicon_base64": f"data:{content_type};base64,{favicon_base64}",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch favicon: {e}")
|
||||
return {
|
||||
"favicon_url": None,
|
||||
"favicon_base64": f"data:image/svg+xml;base64,{DEFAULT_FAVICON}",
|
||||
}
|
||||
@@ -12,6 +12,29 @@ Tests are organized into the following categories:
|
||||
- **App tests**: Test the web application API endpoints (under `/api/`).
|
||||
- **Smoke tests**: Basic tests to verify that the application runs correctly.
|
||||
|
||||
## Continuous Integration (CI)
|
||||
|
||||
Tests run automatically on pull requests via GitHub Actions:
|
||||
|
||||
### Automated Testing Workflow
|
||||
|
||||
When a pull request is created or updated with changes to `apiserver/**` files, the `test-pull-request.yml` workflow automatically:
|
||||
|
||||
1. **Sets up test environment**: PostgreSQL 14, Redis 7, Python 3.11
|
||||
2. **Runs unit tests**: Fast, isolated component tests with coverage
|
||||
3. **Runs contract tests**: API endpoint verification
|
||||
4. **Generates coverage reports**: Enforces 90% threshold with HTML, terminal, and XML formats
|
||||
5. **Uploads to Codecov**: If token is configured
|
||||
|
||||
### CI Environment Variables
|
||||
|
||||
The CI automatically configures comprehensive environment variables including:
|
||||
- Database and Redis connections
|
||||
- Security settings (disabled for testing)
|
||||
- Base URLs for all components
|
||||
- File upload and storage settings
|
||||
- External service configurations (mocked)
|
||||
|
||||
## API vs App Endpoints
|
||||
|
||||
Plane has two types of API endpoints:
|
||||
@@ -32,6 +55,8 @@ Plane has two types of API endpoints:
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Local Testing
|
||||
|
||||
To run all tests:
|
||||
|
||||
```bash
|
||||
@@ -54,20 +79,19 @@ python -m pytest plane/tests/contract/app/
|
||||
python -m pytest plane/tests/smoke/
|
||||
```
|
||||
|
||||
For convenience, we also provide a helper script:
|
||||
### Using the Test Runner
|
||||
|
||||
For convenience, we provide helper scripts:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
./run_tests.py
|
||||
# Using Python script directly
|
||||
python run_tests.py --coverage --verbose # Full test suite with coverage
|
||||
python run_tests.py -u -v # Unit tests only
|
||||
python run_tests.py -c -v # Contract tests only
|
||||
python run_tests.py -p -v # Parallel execution
|
||||
|
||||
# Run only unit tests
|
||||
./run_tests.py -u
|
||||
|
||||
# Run contract tests with coverage report
|
||||
./run_tests.py -c -o
|
||||
|
||||
# Run tests in parallel
|
||||
./run_tests.py -p
|
||||
# Using shell wrapper
|
||||
./run_tests.sh --coverage --verbose # Full test suite with coverage
|
||||
```
|
||||
|
||||
## Fixtures
|
||||
@@ -134,9 +158,30 @@ Generate a coverage report with:
|
||||
|
||||
```bash
|
||||
python -m pytest --cov=plane --cov-report=term --cov-report=html
|
||||
# Or using the test runner
|
||||
python run_tests.py --coverage
|
||||
```
|
||||
|
||||
This creates an HTML report in the `htmlcov/` directory.
|
||||
This creates an HTML report in the `htmlcov/` directory and enforces the 90% coverage threshold.
|
||||
|
||||
## CI Troubleshooting
|
||||
|
||||
### Common CI Issues
|
||||
|
||||
1. **Test failures**: Check the GitHub Actions logs for specific error messages
|
||||
2. **Coverage below threshold**: Add tests for uncovered code
|
||||
3. **Database connection issues**: Ensure PostgreSQL service is healthy in CI
|
||||
4. **Redis connection issues**: Ensure Redis service is healthy in CI
|
||||
|
||||
### Local Setup for CI Testing
|
||||
|
||||
Make sure you have the test dependencies installed:
|
||||
|
||||
```bash
|
||||
pip install -r requirements/test.txt
|
||||
```
|
||||
|
||||
Set up your local environment with PostgreSQL and Redis, or use the provided Docker setup.
|
||||
|
||||
## Migration from Old Tests
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ def user_data():
|
||||
"email": "test@plane.so",
|
||||
"password": "test-password",
|
||||
"first_name": "Test",
|
||||
"last_name": "User"
|
||||
"last_name": "User",
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def create_user(db, user_data):
|
||||
user = User.objects.create(
|
||||
email=user_data["email"],
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"]
|
||||
last_name=user_data["last_name"],
|
||||
)
|
||||
user.set_password(user_data["password"])
|
||||
user.save()
|
||||
@@ -75,4 +75,4 @@ def plane_server(live_server):
|
||||
Renamed version of live_server fixture to avoid name clashes.
|
||||
Returns a live Django server for testing HTTP requests.
|
||||
"""
|
||||
return live_server
|
||||
return live_server
|
||||
|
||||
@@ -4,6 +4,14 @@ from typing import Optional
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def contains_url(value: str) -> bool:
|
||||
"""
|
||||
Check if the value contains a URL.
|
||||
"""
|
||||
url_pattern = re.compile(r"https?://|www\\.")
|
||||
return bool(url_pattern.search(value))
|
||||
|
||||
|
||||
def is_valid_url(url: str) -> bool:
|
||||
"""
|
||||
Validates whether the given string is a well-formed URL.
|
||||
|
||||
+18
-27
@@ -6,36 +6,20 @@ import sys
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run Plane tests")
|
||||
parser.add_argument("-u", "--unit", action="store_true", help="Run unit tests only")
|
||||
parser.add_argument(
|
||||
"-u", "--unit",
|
||||
action="store_true",
|
||||
help="Run unit tests only"
|
||||
"-c", "--contract", action="store_true", help="Run contract tests only"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--contract",
|
||||
action="store_true",
|
||||
help="Run contract tests only"
|
||||
"-s", "--smoke", action="store_true", help="Run smoke tests only"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s", "--smoke",
|
||||
action="store_true",
|
||||
help="Run smoke tests only"
|
||||
"-o", "--coverage", action="store_true", help="Generate coverage report"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--coverage",
|
||||
action="store_true",
|
||||
help="Generate coverage report"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p", "--parallel",
|
||||
action="store_true",
|
||||
help="Run tests in parallel"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v", "--verbose",
|
||||
action="store_true",
|
||||
help="Verbose output"
|
||||
"-p", "--parallel", action="store_true", help="Run tests in parallel"
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Build command
|
||||
@@ -56,7 +40,14 @@ def main():
|
||||
|
||||
# Add coverage
|
||||
if args.coverage:
|
||||
cmd.extend(["--cov=plane", "--cov-report=term", "--cov-report=html"])
|
||||
cmd.extend(
|
||||
[
|
||||
"--cov=plane",
|
||||
"--cov-report=term",
|
||||
"--cov-report=html",
|
||||
"--cov-report=xml",
|
||||
]
|
||||
)
|
||||
|
||||
# Add parallel
|
||||
if args.parallel:
|
||||
@@ -71,10 +62,10 @@ def main():
|
||||
|
||||
# Print command
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
|
||||
|
||||
# Execute command
|
||||
result = subprocess.run(cmd)
|
||||
|
||||
|
||||
# Check coverage thresholds if coverage is enabled
|
||||
if args.coverage:
|
||||
print("Checking coverage thresholds...")
|
||||
@@ -83,9 +74,9 @@ def main():
|
||||
if coverage_result.returncode != 0:
|
||||
print("Coverage below threshold (90%)")
|
||||
sys.exit(coverage_result.returncode)
|
||||
|
||||
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@
|
||||
"concurrently": "^9.0.1",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsup": "^8.4.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@
|
||||
"nanoid": "3.3.8",
|
||||
"esbuild": "0.25.0",
|
||||
"@babel/helpers": "7.26.10",
|
||||
"@babel/runtime": "7.26.10"
|
||||
"@babel/runtime": "7.26.10",
|
||||
"chokidar": "3.6.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22"
|
||||
}
|
||||
|
||||
@@ -32,5 +32,6 @@ export * from "./dashboard";
|
||||
export * from "./page";
|
||||
export * from "./emoji";
|
||||
export * from "./subscription";
|
||||
export * from "./settings";
|
||||
export * from "./icon";
|
||||
export * from "./analytics-v2";
|
||||
|
||||
@@ -1,39 +1,53 @@
|
||||
export const PROFILE_SETTINGS = {
|
||||
profile: {
|
||||
key: "profile",
|
||||
i18n_label: "profile.actions.profile",
|
||||
href: `/settings/account`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/",
|
||||
},
|
||||
security: {
|
||||
key: "security",
|
||||
i18n_label: "profile.actions.security",
|
||||
href: `/settings/account/security`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/security/",
|
||||
},
|
||||
activity: {
|
||||
key: "activity",
|
||||
i18n_label: "profile.actions.activity",
|
||||
href: `/settings/account/activity`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/activity/",
|
||||
},
|
||||
preferences: {
|
||||
key: "preferences",
|
||||
i18n_label: "profile.actions.preferences",
|
||||
href: `/settings/account/preferences`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/preferences",
|
||||
},
|
||||
notifications: {
|
||||
key: "notifications",
|
||||
i18n_label: "profile.actions.notifications",
|
||||
href: `/settings/account/notifications`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/notifications/",
|
||||
},
|
||||
"api-tokens": {
|
||||
key: "api-tokens",
|
||||
i18n_label: "profile.actions.api-tokens",
|
||||
href: `/settings/account/api-tokens`,
|
||||
highlight: (pathname: string) => pathname === "/settings/account/api-tokens/",
|
||||
},
|
||||
};
|
||||
export const PROFILE_ACTION_LINKS: {
|
||||
key: string;
|
||||
i18n_label: string;
|
||||
href: string;
|
||||
highlight: (pathname: string) => boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: "profile",
|
||||
i18n_label: "profile.actions.profile",
|
||||
href: `/profile`,
|
||||
highlight: (pathname: string) => pathname === "/profile/",
|
||||
},
|
||||
{
|
||||
key: "security",
|
||||
i18n_label: "profile.actions.security",
|
||||
href: `/profile/security`,
|
||||
highlight: (pathname: string) => pathname === "/profile/security/",
|
||||
},
|
||||
{
|
||||
key: "activity",
|
||||
i18n_label: "profile.actions.activity",
|
||||
href: `/profile/activity`,
|
||||
highlight: (pathname: string) => pathname === "/profile/activity/",
|
||||
},
|
||||
{
|
||||
key: "appearance",
|
||||
i18n_label: "profile.actions.appearance",
|
||||
href: `/profile/appearance`,
|
||||
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
|
||||
},
|
||||
{
|
||||
key: "notifications",
|
||||
i18n_label: "profile.actions.notifications",
|
||||
href: `/profile/notifications`,
|
||||
highlight: (pathname: string) => pathname === "/profile/notifications/",
|
||||
},
|
||||
PROFILE_SETTINGS["profile"],
|
||||
PROFILE_SETTINGS["security"],
|
||||
PROFILE_SETTINGS["activity"],
|
||||
PROFILE_SETTINGS["preferences"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
PROFILE_SETTINGS["api-tokens"],
|
||||
];
|
||||
|
||||
export const PROFILE_VIEWER_TAB = [
|
||||
@@ -72,6 +86,23 @@ export const PROFILE_ADMINS_TAB = [
|
||||
},
|
||||
];
|
||||
|
||||
export const PREFERENCE_OPTIONS: {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
id: "theme",
|
||||
title: "theme",
|
||||
description: "select_or_customize_your_interface_color_scheme",
|
||||
},
|
||||
{
|
||||
id: "start_of_week",
|
||||
title: "First day of the week",
|
||||
description: "This will change how all calendars in your app look.",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @description The start of the week for the user
|
||||
* @enum {number}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { PROFILE_SETTINGS } from ".";
|
||||
import { WORKSPACE_SETTINGS } from "./workspace";
|
||||
|
||||
export enum WORKSPACE_SETTINGS_CATEGORY {
|
||||
ADMINISTRATION = "administration",
|
||||
FEATURES = "features",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export enum PROFILE_SETTINGS_CATEGORY {
|
||||
YOUR_PROFILE = "your profile",
|
||||
DEVELOPER = "developer",
|
||||
}
|
||||
|
||||
export enum PROJECT_SETTINGS_CATEGORY {
|
||||
PROJECTS = "projects",
|
||||
}
|
||||
|
||||
export const WORKSPACE_SETTINGS_CATEGORIES = [
|
||||
WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION,
|
||||
WORKSPACE_SETTINGS_CATEGORY.FEATURES,
|
||||
WORKSPACE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
|
||||
export const PROFILE_SETTINGS_CATEGORIES = [
|
||||
PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE,
|
||||
PROFILE_SETTINGS_CATEGORY.DEVELOPER,
|
||||
];
|
||||
|
||||
export const PROJECT_SETTINGS_CATEGORIES = [PROJECT_SETTINGS_CATEGORY.PROJECTS];
|
||||
|
||||
export const GROUPED_WORKSPACE_SETTINGS = {
|
||||
[WORKSPACE_SETTINGS_CATEGORY.ADMINISTRATION]: [
|
||||
WORKSPACE_SETTINGS["general"],
|
||||
WORKSPACE_SETTINGS["members"],
|
||||
WORKSPACE_SETTINGS["billing-and-plans"],
|
||||
WORKSPACE_SETTINGS["export"],
|
||||
],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.FEATURES]: [],
|
||||
[WORKSPACE_SETTINGS_CATEGORY.DEVELOPER]: [WORKSPACE_SETTINGS["webhooks"]],
|
||||
};
|
||||
|
||||
export const GROUPED_PROFILE_SETTINGS = {
|
||||
[PROFILE_SETTINGS_CATEGORY.YOUR_PROFILE]: [
|
||||
PROFILE_SETTINGS["profile"],
|
||||
PROFILE_SETTINGS["preferences"],
|
||||
PROFILE_SETTINGS["notifications"],
|
||||
PROFILE_SETTINGS["security"],
|
||||
PROFILE_SETTINGS["activity"],
|
||||
],
|
||||
[PROFILE_SETTINGS_CATEGORY.DEVELOPER]: [PROFILE_SETTINGS["api-tokens"]],
|
||||
};
|
||||
@@ -114,13 +114,6 @@ export const WORKSPACE_SETTINGS = {
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/webhooks/`,
|
||||
},
|
||||
"api-tokens": {
|
||||
key: "api-tokens",
|
||||
i18n_label: "workspace_settings.settings.api_tokens.title",
|
||||
href: `/settings/api-tokens`,
|
||||
access: [EUserWorkspaceRoles.ADMIN],
|
||||
highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/api-tokens/`,
|
||||
},
|
||||
};
|
||||
|
||||
export const WORKSPACE_SETTINGS_ACCESS = Object.fromEntries(
|
||||
@@ -139,7 +132,6 @@ export const WORKSPACE_SETTINGS_LINKS: {
|
||||
WORKSPACE_SETTINGS["billing-and-plans"],
|
||||
WORKSPACE_SETTINGS["export"],
|
||||
WORKSPACE_SETTINGS["webhooks"],
|
||||
WORKSPACE_SETTINGS["api-tokens"],
|
||||
];
|
||||
|
||||
export const ROLE = {
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"postcss": "^8.4.38",
|
||||
"tsup": "^8.4.0",
|
||||
"tsup": "8.4.0",
|
||||
"typescript": "5.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ExtensionFileSetStorageKey } from "@/plane-editor/types/storage";
|
||||
|
||||
export const NODE_FILE_MAP: {
|
||||
[key: string]: {
|
||||
fileSetName: ExtensionFileSetStorageKey;
|
||||
};
|
||||
} = {
|
||||
image: {
|
||||
fileSetName: "deletedImageSet",
|
||||
},
|
||||
imageComponent: {
|
||||
fileSetName: "deletedImageSet",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||
import { Extensions } from "@tiptap/core";
|
||||
import { AnyExtension } from "@tiptap/core";
|
||||
import { SlashCommands } from "@/extensions";
|
||||
// plane editor types
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { HeadingExtensionStorage } from "@/extensions";
|
||||
import { CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { MentionExtensionStorage } from "@/extensions/mentions";
|
||||
import { ImageExtensionStorage } from "@/plugins/image";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { type HeadingExtensionStorage } from "@/extensions";
|
||||
import { type CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { type CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { type ImageExtensionStorage } from "@/extensions/image";
|
||||
import { type MentionExtensionStorage } from "@/extensions/mentions";
|
||||
import { type UtilityExtensionStorage } from "@/extensions/utility";
|
||||
|
||||
export type ExtensionStorageMap = {
|
||||
imageComponent: CustomImageExtensionStorage;
|
||||
image: ImageExtensionStorage;
|
||||
link: CustomLinkStorage;
|
||||
headingList: HeadingExtensionStorage;
|
||||
mention: MentionExtensionStorage;
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: CustomImageExtensionStorage;
|
||||
[CORE_EXTENSIONS.IMAGE]: ImageExtensionStorage;
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: CustomLinkStorage;
|
||||
[CORE_EXTENSIONS.HEADINGS_LIST]: HeadingExtensionStorage;
|
||||
[CORE_EXTENSIONS.MENTION]: MentionExtensionStorage;
|
||||
[CORE_EXTENSIONS.UTILITY]: UtilityExtensionStorage;
|
||||
};
|
||||
|
||||
export type ExtensionFileSetStorageKey = Extract<keyof ImageExtensionStorage, "deletedImageSet">;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { DocumentContentLoader, PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
import { WorkItemEmbedExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -39,9 +39,10 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
|
||||
} = props;
|
||||
|
||||
const extensions: Extensions = [];
|
||||
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { PageRenderer } from "@/components/editors";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
// extensions
|
||||
import { IssueWidget } from "@/extensions";
|
||||
import { WorkItemEmbedExtension } from "@/extensions";
|
||||
// helpers
|
||||
import { getEditorClassNames } from "@/helpers/common";
|
||||
// hooks
|
||||
@@ -53,7 +53,7 @@ const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
|
||||
const extensions: Extensions = [];
|
||||
if (embedHandler?.issue) {
|
||||
extensions.push(
|
||||
IssueWidget({
|
||||
WorkItemEmbedExtension({
|
||||
widgetCallback: embedHandler.issue.widgetCallback,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { FC, ReactNode, useRef } from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { DEFAULT_DISPLAY_CONFIG } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// types
|
||||
import { TDisplayConfig } from "@/types";
|
||||
// components
|
||||
@@ -36,12 +37,12 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
if (
|
||||
currentNode.content.size === 0 && // Check if the current node is empty
|
||||
!(
|
||||
editor.isActive("orderedList") ||
|
||||
editor.isActive("bulletList") ||
|
||||
editor.isActive("taskItem") ||
|
||||
editor.isActive("table") ||
|
||||
editor.isActive("blockquote") ||
|
||||
editor.isActive("codeBlock")
|
||||
editor.isActive(CORE_EXTENSIONS.ORDERED_LIST) ||
|
||||
editor.isActive(CORE_EXTENSIONS.BULLET_LIST) ||
|
||||
editor.isActive(CORE_EXTENSIONS.TASK_ITEM) ||
|
||||
editor.isActive(CORE_EXTENSIONS.TABLE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.BLOCKQUOTE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CODE_BLOCK)
|
||||
) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block
|
||||
) {
|
||||
return;
|
||||
@@ -53,10 +54,10 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
|
||||
const lastNode = lastNodePos.node();
|
||||
|
||||
// Check if the last node is a not paragraph
|
||||
if (lastNode && lastNode.type.name !== "paragraph") {
|
||||
if (lastNode && lastNode.type.name !== CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If last node is not a paragraph, insert a new paragraph at the end
|
||||
const endPosition = editor?.state.doc.content.size;
|
||||
editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run();
|
||||
editor?.chain().insertContentAt(endPosition, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
|
||||
// Focus the newly added paragraph for immediate editing
|
||||
editor
|
||||
|
||||
@@ -12,7 +12,7 @@ interface LinkViewContainerProps {
|
||||
export const LinkViewContainer: FC<LinkViewContainerProps> = ({ editor, containerRef }) => {
|
||||
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [virtualElement, setVirtualElement] = useState<any>(null);
|
||||
const [virtualElement, setVirtualElement] = useState<Element | null>(null);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
|
||||
@@ -51,7 +51,9 @@ export const LinkEditView = ({ viewProps }: LinkEditViewProps) => {
|
||||
if (!hasSubmitted.current && !linkRemoved && initialUrl === "") {
|
||||
try {
|
||||
removeLink();
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
console.error("Error removing link", e);
|
||||
}
|
||||
}
|
||||
},
|
||||
[linkRemoved, initialUrl]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
interface BlockMenuProps {
|
||||
editor: Editor;
|
||||
@@ -102,7 +104,8 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||
key: "duplicate",
|
||||
label: "Duplicate",
|
||||
isDisabled:
|
||||
editor.state.selection.content().content.firstChild?.type.name === "image" || editor.isActive("imageComponent"),
|
||||
editor.state.selection.content().content.firstChild?.type.name === CORE_EXTENSIONS.IMAGE ||
|
||||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Check, Link, Trash2 } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction, useCallback, useRef, useState } from "react";
|
||||
// plane utils
|
||||
// plane imports
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { setLinkEditor, unsetLinkEditor } from "@/helpers/editor-commands";
|
||||
@@ -43,7 +45,7 @@ export const BubbleMenuLinkSelector: FC<Props> = (props) => {
|
||||
"h-full flex items-center gap-1 px-3 text-sm font-medium text-custom-text-300 hover:bg-custom-background-80 active:bg-custom-background-80 rounded transition-colors",
|
||||
{
|
||||
"bg-custom-background-80": isOpen,
|
||||
"text-custom-text-100": editor.isActive("link"),
|
||||
"text-custom-text-100": editor.isActive(CORE_EXTENSIONS.CUSTOM_LINK),
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { Dispatch, FC, SetStateAction } from "react";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@/components/menus";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
// local components
|
||||
@@ -90,8 +91,8 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: { editor: Edi
|
||||
if (
|
||||
empty ||
|
||||
!editor.isEditable ||
|
||||
editor.isActive("image") ||
|
||||
editor.isActive("imageComponent") ||
|
||||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE) ||
|
||||
isNodeSelection(selection) ||
|
||||
isCellSelection(selection) ||
|
||||
isSelecting
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
Palette,
|
||||
AlignCenter,
|
||||
} from "lucide-react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import {
|
||||
insertHorizontalRule,
|
||||
@@ -35,12 +37,7 @@ import {
|
||||
toggleBold,
|
||||
toggleBulletList,
|
||||
toggleCodeBlock,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingSix,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingTwo,
|
||||
toggleHeading,
|
||||
toggleItalic,
|
||||
toggleOrderedList,
|
||||
toggleStrike,
|
||||
@@ -65,63 +62,49 @@ export type EditorMenuItem<T extends TEditorCommands> = {
|
||||
export const TextItem = (editor: Editor): EditorMenuItem<"text"> => ({
|
||||
key: "text",
|
||||
name: "Text",
|
||||
isActive: () => editor.isActive("paragraph"),
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.PARAGRAPH),
|
||||
command: () => setText(editor),
|
||||
icon: CaseSensitive,
|
||||
});
|
||||
|
||||
export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> => ({
|
||||
key: "h1",
|
||||
name: "Heading 1",
|
||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||
command: () => toggleHeadingOne(editor),
|
||||
icon: Heading1,
|
||||
type SupportedHeadingLevels = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
||||
|
||||
const HeadingItem = <T extends SupportedHeadingLevels>(
|
||||
editor: Editor,
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6,
|
||||
key: T,
|
||||
name: string,
|
||||
icon: LucideIcon
|
||||
): EditorMenuItem<T> => ({
|
||||
key,
|
||||
name,
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.HEADING, { level }),
|
||||
command: () => toggleHeading(editor, level),
|
||||
icon,
|
||||
});
|
||||
|
||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> => ({
|
||||
key: "h2",
|
||||
name: "Heading 2",
|
||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||
command: () => toggleHeadingTwo(editor),
|
||||
icon: Heading2,
|
||||
});
|
||||
export const HeadingOneItem = (editor: Editor): EditorMenuItem<"h1"> =>
|
||||
HeadingItem(editor, 1, "h1", "Heading 1", Heading1);
|
||||
|
||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> => ({
|
||||
key: "h3",
|
||||
name: "Heading 3",
|
||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||
command: () => toggleHeadingThree(editor),
|
||||
icon: Heading3,
|
||||
});
|
||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem<"h2"> =>
|
||||
HeadingItem(editor, 2, "h2", "Heading 2", Heading2);
|
||||
|
||||
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> => ({
|
||||
key: "h4",
|
||||
name: "Heading 4",
|
||||
isActive: () => editor.isActive("heading", { level: 4 }),
|
||||
command: () => toggleHeadingFour(editor),
|
||||
icon: Heading4,
|
||||
});
|
||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem<"h3"> =>
|
||||
HeadingItem(editor, 3, "h3", "Heading 3", Heading3);
|
||||
|
||||
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> => ({
|
||||
key: "h5",
|
||||
name: "Heading 5",
|
||||
isActive: () => editor.isActive("heading", { level: 5 }),
|
||||
command: () => toggleHeadingFive(editor),
|
||||
icon: Heading5,
|
||||
});
|
||||
export const HeadingFourItem = (editor: Editor): EditorMenuItem<"h4"> =>
|
||||
HeadingItem(editor, 4, "h4", "Heading 4", Heading4);
|
||||
|
||||
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> => ({
|
||||
key: "h6",
|
||||
name: "Heading 6",
|
||||
isActive: () => editor.isActive("heading", { level: 6 }),
|
||||
command: () => toggleHeadingSix(editor),
|
||||
icon: Heading6,
|
||||
});
|
||||
export const HeadingFiveItem = (editor: Editor): EditorMenuItem<"h5"> =>
|
||||
HeadingItem(editor, 5, "h5", "Heading 5", Heading5);
|
||||
|
||||
export const HeadingSixItem = (editor: Editor): EditorMenuItem<"h6"> =>
|
||||
HeadingItem(editor, 6, "h6", "Heading 6", Heading6);
|
||||
|
||||
export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
|
||||
key: "bold",
|
||||
name: "Bold",
|
||||
isActive: () => editor?.isActive("bold"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BOLD),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldIcon,
|
||||
});
|
||||
@@ -129,7 +112,7 @@ export const BoldItem = (editor: Editor): EditorMenuItem<"bold"> => ({
|
||||
export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
|
||||
key: "italic",
|
||||
name: "Italic",
|
||||
isActive: () => editor?.isActive("italic"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.ITALIC),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicIcon,
|
||||
});
|
||||
@@ -137,7 +120,7 @@ export const ItalicItem = (editor: Editor): EditorMenuItem<"italic"> => ({
|
||||
export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
|
||||
key: "underline",
|
||||
name: "Underline",
|
||||
isActive: () => editor?.isActive("underline"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.UNDERLINE),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineIcon,
|
||||
});
|
||||
@@ -145,7 +128,7 @@ export const UnderLineItem = (editor: Editor): EditorMenuItem<"underline"> => ({
|
||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough"> => ({
|
||||
key: "strikethrough",
|
||||
name: "Strikethrough",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.STRIKETHROUGH),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughIcon,
|
||||
});
|
||||
@@ -153,7 +136,7 @@ export const StrikeThroughItem = (editor: Editor): EditorMenuItem<"strikethrough
|
||||
export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list"> => ({
|
||||
key: "bulleted-list",
|
||||
name: "Bulleted list",
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BULLET_LIST),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
});
|
||||
@@ -161,7 +144,7 @@ export const BulletListItem = (editor: Editor): EditorMenuItem<"bulleted-list">
|
||||
export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"> => ({
|
||||
key: "numbered-list",
|
||||
name: "Numbered list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.ORDERED_LIST),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon,
|
||||
});
|
||||
@@ -169,7 +152,7 @@ export const NumberedListItem = (editor: Editor): EditorMenuItem<"numbered-list"
|
||||
export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
|
||||
key: "to-do-list",
|
||||
name: "To-do list",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
isActive: () => editor.isActive(CORE_EXTENSIONS.TASK_ITEM),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
});
|
||||
@@ -177,7 +160,7 @@ export const TodoListItem = (editor: Editor): EditorMenuItem<"to-do-list"> => ({
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
|
||||
key: "quote",
|
||||
name: "Quote",
|
||||
isActive: () => editor?.isActive("blockquote"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.BLOCKQUOTE),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: TextQuote,
|
||||
});
|
||||
@@ -185,7 +168,7 @@ export const QuoteItem = (editor: Editor): EditorMenuItem<"quote"> => ({
|
||||
export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({
|
||||
key: "code",
|
||||
name: "Code",
|
||||
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.CODE_INLINE) || editor?.isActive(CORE_EXTENSIONS.CODE_BLOCK),
|
||||
command: () => toggleCodeBlock(editor),
|
||||
icon: CodeIcon,
|
||||
});
|
||||
@@ -193,7 +176,7 @@ export const CodeItem = (editor: Editor): EditorMenuItem<"code"> => ({
|
||||
export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
|
||||
key: "table",
|
||||
name: "Table",
|
||||
isActive: () => editor?.isActive("table"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.TABLE),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableIcon,
|
||||
});
|
||||
@@ -201,7 +184,7 @@ export const TableItem = (editor: Editor): EditorMenuItem<"table"> => ({
|
||||
export const ImageItem = (editor: Editor): EditorMenuItem<"image"> => ({
|
||||
key: "image",
|
||||
name: "Image",
|
||||
isActive: () => editor?.isActive("image") || editor?.isActive("imageComponent"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.IMAGE) || editor?.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE),
|
||||
command: () => insertImage({ editor, event: "insert", pos: editor.state.selection.from }),
|
||||
icon: ImageIcon,
|
||||
});
|
||||
@@ -210,7 +193,7 @@ export const HorizontalRuleItem = (editor: Editor) =>
|
||||
({
|
||||
key: "divider",
|
||||
name: "Divider",
|
||||
isActive: () => editor?.isActive("horizontalRule"),
|
||||
isActive: () => editor?.isActive(CORE_EXTENSIONS.HORIZONTAL_RULE),
|
||||
command: () => insertHorizontalRule(editor),
|
||||
icon: MinusSquare,
|
||||
}) as const;
|
||||
@@ -218,7 +201,7 @@ export const HorizontalRuleItem = (editor: Editor) =>
|
||||
export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => ({
|
||||
key: "text-color",
|
||||
name: "Color",
|
||||
isActive: (props) => editor.isActive("customColor", { color: props?.color }),
|
||||
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { color: props?.color }),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
toggleTextColor(props.color, editor);
|
||||
@@ -229,7 +212,7 @@ export const TextColorItem = (editor: Editor): EditorMenuItem<"text-color"> => (
|
||||
export const BackgroundColorItem = (editor: Editor): EditorMenuItem<"background-color"> => ({
|
||||
key: "background-color",
|
||||
name: "Background color",
|
||||
isActive: (props) => editor.isActive("customColor", { backgroundColor: props?.color }),
|
||||
isActive: (props) => editor.isActive(CORE_EXTENSIONS.CUSTOM_COLOR, { backgroundColor: props?.color }),
|
||||
command: (props) => {
|
||||
if (!props) return;
|
||||
toggleBackgroundColor(props.color, editor);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
export enum CORE_EXTENSIONS {
|
||||
BLOCKQUOTE = "blockquote",
|
||||
BOLD = "bold",
|
||||
BULLET_LIST = "bulletList",
|
||||
CALLOUT = "calloutComponent",
|
||||
CHARACTER_COUNT = "characterCount",
|
||||
CODE_BLOCK = "codeBlock",
|
||||
CODE_INLINE = "code",
|
||||
CUSTOM_COLOR = "customColor",
|
||||
CUSTOM_IMAGE = "imageComponent",
|
||||
CUSTOM_LINK = "link",
|
||||
DOCUMENT = "doc",
|
||||
DROP_CURSOR = "dropCursor",
|
||||
ENTER_KEY = "enterKey",
|
||||
GAP_CURSOR = "gapCursor",
|
||||
HARD_BREAK = "hardBreak",
|
||||
HEADING = "heading",
|
||||
HEADINGS_LIST = "headingsList",
|
||||
HISTORY = "history",
|
||||
HORIZONTAL_RULE = "horizontalRule",
|
||||
IMAGE = "image",
|
||||
ITALIC = "italic",
|
||||
LIST_ITEM = "listItem",
|
||||
MARKDOWN_CLIPBOARD = "markdownClipboard",
|
||||
MENTION = "mention",
|
||||
ORDERED_LIST = "orderedList",
|
||||
PARAGRAPH = "paragraph",
|
||||
PLACEHOLDER = "placeholder",
|
||||
SIDE_MENU = "editorSideMenu",
|
||||
SLASH_COMMANDS = "slash-command",
|
||||
STRIKETHROUGH = "strike",
|
||||
TABLE = "table",
|
||||
TABLE_CELL = "tableCell",
|
||||
TABLE_HEADER = "tableHeader",
|
||||
TABLE_ROW = "tableRow",
|
||||
TASK_ITEM = "taskItem",
|
||||
TASK_LIST = "taskList",
|
||||
TEXT_ALIGN = "textAlign",
|
||||
TEXT_STYLE = "textStyle",
|
||||
TYPOGRAPHY = "typography",
|
||||
UNDERLINE = "underline",
|
||||
UTILITY = "utility",
|
||||
WORK_ITEM_EMBED = "issue-embed-component",
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum CORE_EDITOR_META {
|
||||
SKIP_FILE_DELETION = "skipFileDeletion",
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import React, { useState } from "react";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
// local components
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { Node as NodeType } from "@tiptap/pm/model";
|
||||
import { MarkdownSerializerState } from "@tiptap/pm/markdown";
|
||||
import { Node as NodeType } from "@tiptap/pm/model";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// types
|
||||
import { EAttributeNames, TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
@@ -9,14 +11,14 @@ import { DEFAULT_CALLOUT_BLOCK_ATTRIBUTES } from "./utils";
|
||||
// Extend Tiptap's Commands interface
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
calloutComponent: {
|
||||
[CORE_EXTENSIONS.CALLOUT]: {
|
||||
insertCallout: () => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const CustomCalloutExtensionConfig = Node.create({
|
||||
name: "calloutComponent",
|
||||
name: CORE_EXTENSIONS.CALLOUT,
|
||||
group: "block",
|
||||
content: "block+",
|
||||
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
// plane helpers
|
||||
import { convertHexEmojiToDecimal } from "@plane/utils";
|
||||
// plane ui
|
||||
// plane imports
|
||||
import { EmojiIconPicker, EmojiIconPickerTypes, Logo, TEmojiLogoProps } from "@plane/ui";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
import { cn, convertHexEmojiToDecimal } from "@plane/utils";
|
||||
// types
|
||||
import { TCalloutBlockAttributes } from "./types";
|
||||
// utils
|
||||
|
||||
@@ -20,7 +20,7 @@ export type TCalloutBlockEmojiAttributes = {
|
||||
|
||||
export type TCalloutBlockAttributes = {
|
||||
[EAttributeNames.LOGO_IN_USE]: "emoji" | "icon";
|
||||
[EAttributeNames.BACKGROUND]: string;
|
||||
[EAttributeNames.BACKGROUND]: string | undefined;
|
||||
[EAttributeNames.BLOCK_TYPE]: "callout-component";
|
||||
} & TCalloutBlockIconAttributes &
|
||||
TCalloutBlockEmojiAttributes;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// plane helpers
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
// plane ui
|
||||
// plane imports
|
||||
import { TEmojiLogoProps } from "@plane/ui";
|
||||
import { sanitizeHTML } from "@plane/utils";
|
||||
// types
|
||||
import {
|
||||
EAttributeNames,
|
||||
@@ -12,11 +11,11 @@ import {
|
||||
|
||||
export const DEFAULT_CALLOUT_BLOCK_ATTRIBUTES: TCalloutBlockAttributes = {
|
||||
"data-logo-in-use": "emoji",
|
||||
"data-icon-color": null,
|
||||
"data-icon-name": null,
|
||||
"data-icon-color": undefined,
|
||||
"data-icon-name": undefined,
|
||||
"data-emoji-unicode": "128161",
|
||||
"data-emoji-url": "https://cdn.jsdelivr.net/npm/emoji-datasource-apple/img/apple/64/1f4a1.png",
|
||||
"data-background": null,
|
||||
"data-background": undefined,
|
||||
"data-block-type": "callout-component",
|
||||
};
|
||||
|
||||
@@ -32,7 +31,7 @@ export const getStoredLogo = (): TStoredLogoValue => {
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo"));
|
||||
const storedData = sanitizeHTML(localStorage.getItem("editor-calloutComponent-logo") ?? "");
|
||||
if (storedData) {
|
||||
let parsedData: TEmojiLogoProps;
|
||||
try {
|
||||
@@ -69,7 +68,7 @@ export const updateStoredLogo = (value: TEmojiLogoProps): void => {
|
||||
// function to get the stored background color from local storage
|
||||
export const getStoredBackgroundColor = (): string | null => {
|
||||
if (typeof window !== "undefined") {
|
||||
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background"));
|
||||
return sanitizeHTML(localStorage.getItem("editor-calloutComponent-background") ?? "");
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Fragment, Node } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
export const MarkdownClipboard = Extension.create({
|
||||
name: "markdownClipboard",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("markdownClipboard"),
|
||||
props: {
|
||||
clipboardTextSerializer: (slice) => {
|
||||
const markdownSerializer = this.editor.storage.markdown.serializer;
|
||||
const isTableRow = slice.content.firstChild?.type?.name === "tableRow";
|
||||
const nodeSelect = slice.openStart === 0 && slice.openEnd === 0;
|
||||
|
||||
if (nodeSelect) {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
}
|
||||
|
||||
const processTableContent = (tableNode: Node | Fragment) => {
|
||||
let result = "";
|
||||
tableNode.content?.forEach?.((tableRowNode: Node | Fragment) => {
|
||||
tableRowNode.content?.forEach?.((cell: Node) => {
|
||||
const cellContent = cell.content ? markdownSerializer.serialize(cell.content) : "";
|
||||
result += cellContent + "\n";
|
||||
});
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
if (isTableRow) {
|
||||
const rowsCount = slice.content?.childCount || 0;
|
||||
const cellsCount = slice.content?.firstChild?.content?.childCount || 0;
|
||||
if (rowsCount === 1 || cellsCount === 1) {
|
||||
return processTableContent(slice.content);
|
||||
} else {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
}
|
||||
}
|
||||
|
||||
const traverseToParentOfLeaf = (
|
||||
node: Node | null,
|
||||
parent: Fragment | Node,
|
||||
depth: number
|
||||
): Node | Fragment => {
|
||||
let currentNode = node;
|
||||
let currentParent = parent;
|
||||
let currentDepth = depth;
|
||||
|
||||
while (currentNode && currentDepth > 1 && currentNode.content?.firstChild) {
|
||||
if (currentNode.content?.childCount > 1) {
|
||||
if (currentNode.content.firstChild?.type?.name === "listItem") {
|
||||
return currentParent;
|
||||
} else {
|
||||
return currentNode.content;
|
||||
}
|
||||
}
|
||||
|
||||
currentParent = currentNode;
|
||||
currentNode = currentNode.content?.firstChild || null;
|
||||
currentDepth--;
|
||||
}
|
||||
|
||||
return currentParent;
|
||||
};
|
||||
|
||||
if (slice.content.childCount > 1) {
|
||||
return markdownSerializer.serialize(slice.content);
|
||||
} else {
|
||||
const targetNode = traverseToParentOfLeaf(slice.content.firstChild, slice.content, slice.openStart);
|
||||
|
||||
let currentNode = targetNode;
|
||||
while (currentNode && currentNode.content && currentNode.childCount === 1 && currentNode.firstChild) {
|
||||
currentNode = currentNode.firstChild;
|
||||
}
|
||||
if (currentNode instanceof Node && currentNode.isText) {
|
||||
return currentNode.text;
|
||||
}
|
||||
|
||||
return markdownSerializer.serialize(targetNode);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Mark, markInputRule, markPasteRule, mergeAttributes } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface CodeOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -6,7 +8,7 @@ export interface CodeOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
code: {
|
||||
[CORE_EXTENSIONS.CODE_INLINE]: {
|
||||
/**
|
||||
* Set a code mark
|
||||
*/
|
||||
@@ -27,7 +29,7 @@ export const inputRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))$/;
|
||||
const pasteRegex = /(?:^|\s)((?:`)((?:[^`]+))(?:`))/g;
|
||||
|
||||
export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
|
||||
name: "code",
|
||||
name: CORE_EXTENSIONS.CODE_INLINE,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import { common, createLowlight } from "lowlight";
|
||||
import { CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// plane utils
|
||||
@@ -27,7 +27,7 @@ export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node })
|
||||
await navigator.clipboard.writeText(node.textContent);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { mergeAttributes, Node, textblockTypeInputRule } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface CodeBlockOptions {
|
||||
/**
|
||||
@@ -25,7 +27,7 @@ export interface CodeBlockOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
codeBlock: {
|
||||
[CORE_EXTENSIONS.CODE_BLOCK]: {
|
||||
/**
|
||||
* Set a code block
|
||||
*/
|
||||
@@ -42,7 +44,7 @@ export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/;
|
||||
export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/;
|
||||
|
||||
export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
name: "codeBlock",
|
||||
name: CORE_EXTENSIONS.CODE_BLOCK,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -118,7 +120,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
toggleCodeBlock:
|
||||
(attributes) =>
|
||||
({ commands }) =>
|
||||
commands.toggleNode(this.name, "paragraph", attributes),
|
||||
commands.toggleNode(this.name, CORE_EXTENSIONS.PARAGRAPH, attributes),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -126,7 +128,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return {
|
||||
"Mod-Alt-c": () => this.editor.commands.toggleCodeBlock(),
|
||||
|
||||
// remove code block when at start of document or code block is empty
|
||||
// remove codeBlock when at start of document or codeBlock is empty
|
||||
Backspace: () => {
|
||||
try {
|
||||
const { empty, $anchor } = this.editor.state.selection;
|
||||
@@ -259,7 +261,7 @@ export const CodeBlock = Node.create<CodeBlockOptions>({
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.editor.isActive("code")) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.CODE_INLINE)) {
|
||||
// Check if it's an inline code block
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData("text/plain");
|
||||
|
||||
@@ -88,7 +88,7 @@ export function LowlightPlugin({
|
||||
throw Error("You should provide an instance of lowlight to use the code-block-lowlight extension");
|
||||
}
|
||||
|
||||
const lowlightPlugin: Plugin<any> = new Plugin({
|
||||
const lowlightPlugin: Plugin = new Plugin({
|
||||
key: new PluginKey("lowlight"),
|
||||
|
||||
state: {
|
||||
|
||||
@@ -3,24 +3,24 @@ import TaskList from "@tiptap/extension-task-list";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
// extensions
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// plane editor imports
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
// extensions
|
||||
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomCodeBlockExtensionWithoutProps } from "./code/without-props";
|
||||
import { CustomCodeInlineExtension } from "./code-inline";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
import { CustomLinkExtension } from "./custom-link";
|
||||
import { CustomHorizontalRule } from "./horizontal-rule";
|
||||
import { ImageExtensionWithoutProps } from "./image";
|
||||
import { CustomImageComponentWithoutProps } from "./image/image-component-without-props";
|
||||
import { IssueWidgetWithoutProps } from "./issue-embed/issue-embed-without-props";
|
||||
import { CustomMentionExtensionConfig } from "./mentions/extension-config";
|
||||
import { CustomQuoteExtension } from "./quote";
|
||||
import { TableHeader, TableCell, TableRow, Table } from "./table";
|
||||
import { CustomTextAlignExtension } from "./text-align";
|
||||
import { CustomCalloutExtensionConfig } from "./callout/extension-config";
|
||||
import { CustomColorExtension } from "./custom-color";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensionsWithoutProps } from "@/plane-editor/extensions/core/without-props";
|
||||
import { WorkItemEmbedExtensionConfig } from "./work-item-embed/extension-config";
|
||||
|
||||
export const CoreEditorExtensionsWithoutProps = [
|
||||
StarterKit.configure({
|
||||
@@ -72,12 +72,12 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtensionWithoutProps().configure({
|
||||
ImageExtensionWithoutProps.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-md",
|
||||
},
|
||||
}),
|
||||
CustomImageComponentWithoutProps(),
|
||||
CustomImageComponentWithoutProps,
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
TaskList.configure({
|
||||
@@ -104,4 +104,4 @@ export const CoreEditorExtensionsWithoutProps = [
|
||||
...CoreEditorAdditionalExtensionsWithoutProps,
|
||||
];
|
||||
|
||||
export const DocumentEditorExtensionsWithoutProps = [IssueWidgetWithoutProps()];
|
||||
export const DocumentEditorExtensionsWithoutProps = [WorkItemEmbedExtensionConfig];
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import codemark from "prosemirror-codemark";
|
||||
|
||||
export const CustomCodeMarkPlugin = Extension.create({
|
||||
name: "codemarkPlugin",
|
||||
addProseMirrorPlugins() {
|
||||
return codemark({ markType: this.editor.schema.marks.code });
|
||||
},
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Mark, mergeAttributes } from "@tiptap/core";
|
||||
// constants
|
||||
import { COLORS_LIST } from "@/constants/common";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
color: {
|
||||
[CORE_EXTENSIONS.CUSTOM_COLOR]: {
|
||||
/**
|
||||
* Set the text color
|
||||
* @param {string} color The color to set
|
||||
@@ -34,7 +35,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
|
||||
export const CustomColorExtension = Mark.create({
|
||||
name: "customColor",
|
||||
name: CORE_EXTENSIONS.CUSTOM_COLOR,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useRef, useState, useCallback, useLayoutEffect, useEffect } from
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
// extensions
|
||||
import { CustoBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
import { CustomBaseImageNodeViewProps, ImageToolbarRoot } from "@/extensions/custom-image";
|
||||
import { ImageUploadStatus } from "./upload-status";
|
||||
|
||||
const MIN_SIZE = 100;
|
||||
@@ -38,7 +38,7 @@ const ensurePixelString = <TDefault,>(value: Pixel | TDefault | number | undefin
|
||||
return value;
|
||||
};
|
||||
|
||||
type CustomImageBlockProps = CustoBaseImageNodeViewProps & {
|
||||
type CustomImageBlockProps = CustomBaseImageNodeViewProps & {
|
||||
imageFromFileSystem: string | undefined;
|
||||
setFailedToLoadImage: (isError: boolean) => void;
|
||||
editorContainer: HTMLDivElement | null;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Editor, NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageBlock, CustomImageUploader, ImageAttributes } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
export type CustoBaseImageNodeViewProps = {
|
||||
export type CustomBaseImageNodeViewProps = {
|
||||
getPos: () => number;
|
||||
editor: Editor;
|
||||
node: NodeViewProps["node"] & {
|
||||
@@ -14,7 +17,7 @@ export type CustoBaseImageNodeViewProps = {
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
export type CustomImageNodeProps = NodeViewProps & CustoBaseImageNodeViewProps;
|
||||
export type CustomImageNodeProps = NodeViewProps & CustomBaseImageNodeViewProps;
|
||||
|
||||
export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
const { getPos, editor, node, updateAttributes, selected } = props;
|
||||
@@ -77,7 +80,7 @@ export const CustomImageNode = (props: CustomImageNodeProps) => {
|
||||
failedToLoadImage={failedToLoadImage}
|
||||
getPos={getPos}
|
||||
loadImageFromFileSystem={setImageFromFileSystem}
|
||||
maxFileSize={getExtensionStorage(editor, "imageComponent").maxFileSize}
|
||||
maxFileSize={getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE).maxFileSize}
|
||||
node={node}
|
||||
setIsUploaded={setIsUploaded}
|
||||
selected={selected}
|
||||
|
||||
@@ -4,12 +4,16 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { cn } from "@plane/utils";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
import { CustomBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { EFileError } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// hooks
|
||||
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
|
||||
|
||||
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
|
||||
type CustomImageUploaderProps = CustomBaseImageNodeViewProps & {
|
||||
maxFileSize: number;
|
||||
loadImageFromFileSystem: (file: string) => void;
|
||||
failedToLoadImage: boolean;
|
||||
@@ -57,7 +61,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
// control cursor position after upload
|
||||
const nextNode = editor.state.doc.nodeAt(pos + 1);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If there is a paragraph node after the image component, move the focus to the next node
|
||||
editor.commands.setTextSelection(pos + 1);
|
||||
} else {
|
||||
@@ -69,22 +73,37 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
},
|
||||
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
|
||||
);
|
||||
|
||||
const uploadImageEditorCommand = useCallback(
|
||||
async (file: File) => await editor?.commands.uploadImage(imageEntityId ?? "", file),
|
||||
[editor, imageEntityId]
|
||||
);
|
||||
|
||||
const handleProgressStatus = useCallback(
|
||||
(isUploading: boolean) => {
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY).uploadInProgress = isUploading;
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const handleInvalidFile = useCallback((_error: EFileError, _file: File, message: string) => {
|
||||
alert(message);
|
||||
}, []);
|
||||
|
||||
// hooks
|
||||
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
|
||||
editorCommand: async (file) => await editor?.commands.uploadImage(imageEntityId, file),
|
||||
handleProgressStatus: (isUploading) => {
|
||||
editor.storage.imageComponent.uploadInProgress = isUploading;
|
||||
},
|
||||
editorCommand: uploadImageEditorCommand,
|
||||
handleProgressStatus,
|
||||
loadFileFromFileSystem: loadImageFromFileSystem,
|
||||
maxFileSize,
|
||||
onInvalidFile: handleInvalidFile,
|
||||
onUpload,
|
||||
});
|
||||
|
||||
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
editor,
|
||||
maxFileSize,
|
||||
pos: getPos(),
|
||||
type: "image",
|
||||
uploader: uploadFile,
|
||||
@@ -119,10 +138,8 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
|
||||
return;
|
||||
}
|
||||
await uploadFirstFileAndInsertRemaining({
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
editor,
|
||||
filesList,
|
||||
maxFileSize,
|
||||
pos: getPos(),
|
||||
type: "image",
|
||||
uploader: uploadFile,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useEditorState } from "@tiptap/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
@@ -16,7 +20,7 @@ export const ImageUploadStatus: React.FC<Props> = (props) => {
|
||||
// subscribe to image upload status
|
||||
const uploadStatus: number | undefined = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }) => editor.storage.imageComponent?.assetsUploadStatus[nodeId],
|
||||
selector: ({ editor }) => getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.assetsUploadStatus?.[nodeId],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Editor, mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// constants
|
||||
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
@@ -23,23 +22,21 @@ export type InsertImageComponentProps = {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
imageComponent: {
|
||||
[CORE_EXTENSIONS.CUSTOM_IMAGE]: {
|
||||
insertImageComponent: ({ file, pos, event }: InsertImageComponentProps) => ReturnType;
|
||||
uploadImage: (blockId: string, file: File) => () => Promise<string> | undefined;
|
||||
updateAssetsUploadStatus?: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
|
||||
getImageSource?: (path: string) => () => Promise<string>;
|
||||
restoreImage: (src: string) => () => Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) => getExtensionStorage(editor, "imageComponent")?.fileMap;
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
getExtensionStorage(editor, CORE_EXTENSIONS.CUSTOM_IMAGE)?.fileMap;
|
||||
|
||||
export interface CustomImageExtensionStorage {
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
uploadInProgress: boolean;
|
||||
maxFileSize: number;
|
||||
}
|
||||
|
||||
@@ -47,16 +44,14 @@ export type UploadEntity = ({ event: "insert" } | { event: "drop"; file: File })
|
||||
|
||||
export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const {
|
||||
assetsUploadStatus,
|
||||
getAssetSrc,
|
||||
upload,
|
||||
delete: deleteImageFn,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
@@ -102,41 +97,15 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
await restoreImageFn(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
assetsUploadStatus,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -152,6 +121,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
|
||||
file: props.file,
|
||||
maxFileSize,
|
||||
onError: (_error, message) => alert(message),
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
@@ -196,9 +166,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
const fileUrl = await upload(blockId, file);
|
||||
return fileUrl;
|
||||
},
|
||||
updateAssetsUploadStatus: (updatedStatus) => () => {
|
||||
this.storage.assetsUploadStatus = updatedStatus;
|
||||
},
|
||||
getImageSource: (path) => async () => await getAssetSrc(path),
|
||||
restoreImage: (src) => async () => {
|
||||
await restoreImageFn(src);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// components
|
||||
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
@@ -9,8 +11,8 @@ import { TReadOnlyFileHandler } from "@/types";
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
return BaseImageExtension.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.CUSTOM_IMAGE,
|
||||
selectable: false,
|
||||
group: "block",
|
||||
atom: true,
|
||||
@@ -53,13 +55,11 @@ export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize: 0,
|
||||
// escape markdown for images
|
||||
markdown: {
|
||||
serialize() {},
|
||||
},
|
||||
assetsUploadStatus: {},
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core";
|
||||
import { Plugin } from "@tiptap/pm/state";
|
||||
import { find, registerCustomProtocol, reset } from "linkifyjs";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { autolink } from "./helpers/autolink";
|
||||
import { clickHandler } from "./helpers/clickHandler";
|
||||
import { pasteHandler } from "./helpers/pasteHandler";
|
||||
@@ -46,7 +49,7 @@ export interface LinkOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
link: {
|
||||
[CORE_EXTENSIONS.CUSTOM_LINK]: {
|
||||
/**
|
||||
* Set a link mark
|
||||
*/
|
||||
@@ -79,7 +82,7 @@ export type CustomLinkStorage = {
|
||||
};
|
||||
|
||||
export const CustomLinkExtension = Mark.create<LinkOptions, CustomLinkStorage>({
|
||||
name: "link",
|
||||
name: CORE_EXTENSIONS.CUSTOM_LINK,
|
||||
|
||||
priority: 1000,
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ export function clickHandler(options: ClickHandlerOptions): Plugin {
|
||||
}
|
||||
|
||||
let a = event.target as HTMLElement;
|
||||
const els = [];
|
||||
const els: HTMLElement[] = [];
|
||||
|
||||
while (a?.nodeName !== "DIV") {
|
||||
els.push(a);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
|
||||
import { Node, NodeType } from "@tiptap/pm/model";
|
||||
import { EditorState } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
|
||||
const { $from } = state.selection;
|
||||
const nodeType = getNodeType(typeOrName, state.schema);
|
||||
|
||||
let currentNode = null;
|
||||
let currentNode: Node | null = null;
|
||||
let currentDepth = $from.depth;
|
||||
let currentPos = $from.pos;
|
||||
let targetDepth: number | null = null;
|
||||
@@ -72,7 +74,11 @@ const getPrevListDepth = (typeOrName: string, state: EditorState) => {
|
||||
// Traverse up the document structure from the adjusted position
|
||||
for (let d = resolvedPos.depth; d > 0; d--) {
|
||||
const node = resolvedPos.node(d);
|
||||
if (node.type.name === "bulletList" || node.type.name === "orderedList" || node.type.name === "taskList") {
|
||||
if (
|
||||
[CORE_EXTENSIONS.BULLET_LIST, CORE_EXTENSIONS.ORDERED_LIST, CORE_EXTENSIONS.TASK_LIST].includes(
|
||||
node.type.name as CORE_EXTENSIONS
|
||||
)
|
||||
) {
|
||||
// Increment depth for each list ancestor found
|
||||
depth++;
|
||||
}
|
||||
@@ -309,12 +315,12 @@ const isCurrentParagraphASibling = (state: EditorState): boolean => {
|
||||
|
||||
// Ensure we're in a paragraph and the parent is a list item.
|
||||
if (
|
||||
currentParagraphNode.type.name === "paragraph" &&
|
||||
(listItemNode.type.name === "listItem" || listItemNode.type.name === "taskItem")
|
||||
currentParagraphNode.type.name === CORE_EXTENSIONS.PARAGRAPH &&
|
||||
[CORE_EXTENSIONS.LIST_ITEM, CORE_EXTENSIONS.TASK_ITEM].includes(listItemNode.type.name as CORE_EXTENSIONS)
|
||||
) {
|
||||
let paragraphNodesCount = 0;
|
||||
listItemNode.forEach((child) => {
|
||||
if (child.type.name === "paragraph") {
|
||||
if (child.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
paragraphNodesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { handleBackspace, handleDelete } from "@/extensions/custom-list-keymap/list-helpers";
|
||||
|
||||
@@ -31,10 +33,10 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
|
||||
if (this.editor.commands.sinkListItem("listItem")) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.sinkListItem("taskItem")) {
|
||||
} else if (this.editor.commands.sinkListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
@@ -46,9 +48,9 @@ export const ListKeymap = ({ tabIndex }: { tabIndex?: number }) =>
|
||||
return true;
|
||||
},
|
||||
"Shift-Tab": () => {
|
||||
if (this.editor.commands.liftListItem("listItem")) {
|
||||
if (this.editor.commands.liftListItem(CORE_EXTENSIONS.LIST_ITEM)) {
|
||||
return true;
|
||||
} else if (this.editor.commands.liftListItem("taskItem")) {
|
||||
} else if (this.editor.commands.liftListItem(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return true;
|
||||
}
|
||||
// if tabIndex is set, we don't want to handle Tab key
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Extension, Editor } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
// types
|
||||
import { TEditorCommands } from "@/types";
|
||||
|
||||
export const DropHandlerExtension = Extension.create({
|
||||
name: "dropHandler",
|
||||
priority: 1000,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const editor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("drop-handler-plugin"),
|
||||
props: {
|
||||
handlePaste: (view, event) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
event.clipboardData &&
|
||||
event.clipboardData.files &&
|
||||
event.clipboardData.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
const acceptedFiles = files.filter(
|
||||
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||
);
|
||||
|
||||
if (acceptedFiles.length) {
|
||||
const pos = view.state.selection.from;
|
||||
insertFilesSafely({
|
||||
editor,
|
||||
files: acceptedFiles,
|
||||
initialPos: pos,
|
||||
event: "drop",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop: (view, event, _slice, moved) => {
|
||||
if (
|
||||
editor.isEditable &&
|
||||
!moved &&
|
||||
event.dataTransfer &&
|
||||
event.dataTransfer.files &&
|
||||
event.dataTransfer.files.length > 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
const acceptedFiles = files.filter(
|
||||
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
|
||||
);
|
||||
|
||||
if (acceptedFiles.length) {
|
||||
const coordinates = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (coordinates) {
|
||||
const pos = coordinates.pos;
|
||||
insertFilesSafely({
|
||||
editor,
|
||||
files: acceptedFiles,
|
||||
initialPos: pos,
|
||||
event: "drop",
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
type InsertFilesSafelyArgs = {
|
||||
editor: Editor;
|
||||
event: "insert" | "drop";
|
||||
files: File[];
|
||||
initialPos: number;
|
||||
type?: Extract<TEditorCommands, "attachment" | "image">;
|
||||
};
|
||||
|
||||
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
|
||||
const { editor, event, files, initialPos, type } = args;
|
||||
let pos = initialPos;
|
||||
|
||||
for (const file of files) {
|
||||
// safe insertion
|
||||
const docSize = editor.state.doc.content.size;
|
||||
pos = Math.min(pos, docSize);
|
||||
|
||||
let fileType: "image" | "attachment" | null = null;
|
||||
|
||||
try {
|
||||
if (type) {
|
||||
if (["image", "attachment"].includes(type)) fileType = type;
|
||||
else throw new Error("Wrong file type passed");
|
||||
} else {
|
||||
if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image";
|
||||
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
|
||||
}
|
||||
// insert file depending on the type at the current position
|
||||
if (fileType === "image") {
|
||||
editor.commands.insertImageComponent({
|
||||
file,
|
||||
pos,
|
||||
event,
|
||||
});
|
||||
} else if (fileType === "attachment") {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error while ${event}ing file:`, error);
|
||||
}
|
||||
|
||||
// Move to the next position
|
||||
pos += 1;
|
||||
}
|
||||
};
|
||||
+10
-7
@@ -1,16 +1,19 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
|
||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
Extension.create({
|
||||
name: "enterKey",
|
||||
name: CORE_EXTENSIONS.ENTER_KEY,
|
||||
|
||||
addKeyboardShortcuts(this) {
|
||||
return {
|
||||
Enter: () => {
|
||||
if (!this.editor.storage.mentionsOpen) {
|
||||
if (onEnterKeyPress) {
|
||||
onEnterKeyPress();
|
||||
}
|
||||
const isMentionOpen = getExtensionStorage(this.editor, CORE_EXTENSIONS.MENTION)?.mentionsOpen;
|
||||
if (!isMentionOpen) {
|
||||
onEnterKeyPress?.();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -18,8 +21,8 @@ export const EnterKeyExtension = (onEnterKeyPress?: () => void) =>
|
||||
"Shift-Enter": ({ editor }) =>
|
||||
editor.commands.first(({ commands }) => [
|
||||
() => commands.newlineInCode(),
|
||||
() => commands.splitListItem("listItem"),
|
||||
() => commands.splitListItem("taskItem"),
|
||||
() => commands.splitListItem(CORE_EXTENSIONS.LIST_ITEM),
|
||||
() => commands.splitListItem(CORE_EXTENSIONS.TASK_ITEM),
|
||||
() => commands.createParagraphNear(),
|
||||
() => commands.liftEmptyBlock(),
|
||||
() => commands.splitBlock(),
|
||||
+17
-13
@@ -7,12 +7,13 @@ import TextStyle from "@tiptap/extension-text-style";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import {
|
||||
CustomCalloutExtension,
|
||||
CustomCodeBlockExtension,
|
||||
CustomCodeInlineExtension,
|
||||
CustomCodeMarkPlugin,
|
||||
CustomColorExtension,
|
||||
CustomHorizontalRule,
|
||||
CustomImageExtension,
|
||||
@@ -22,17 +23,17 @@ import {
|
||||
CustomQuoteExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomTypographyExtension,
|
||||
DropHandlerExtension,
|
||||
ImageExtension,
|
||||
ListKeymap,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
MarkdownClipboard,
|
||||
UtilityExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
@@ -49,7 +50,7 @@ type TArguments = {
|
||||
};
|
||||
|
||||
export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex } = args;
|
||||
const { disabledExtensions, enableHistory, fileHandler, mentionHandler, placeholder, tabIndex, editable } = args;
|
||||
|
||||
const extensions = [
|
||||
StarterKit.configure({
|
||||
@@ -89,7 +90,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
...(enableHistory ? {} : { history: false }),
|
||||
}),
|
||||
CustomQuoteExtension,
|
||||
DropHandlerExtension,
|
||||
CustomHorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: "py-4 border-custom-border-400",
|
||||
@@ -127,7 +127,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
class: "",
|
||||
},
|
||||
}),
|
||||
CustomCodeMarkPlugin,
|
||||
CustomCodeInlineExtension,
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
@@ -135,7 +134,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
transformPastedText: true,
|
||||
breaks: true,
|
||||
}),
|
||||
MarkdownClipboard,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
@@ -145,15 +143,17 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
placeholder: ({ editor, node }) => {
|
||||
if (!editor.isEditable) return "";
|
||||
|
||||
if (node.type.name === "heading") return `Heading ${node.attrs.level}`;
|
||||
if (node.type.name === CORE_EXTENSIONS.HEADING) return `Heading ${node.attrs.level}`;
|
||||
|
||||
if (editor.storage.imageComponent?.uploadInProgress) return "";
|
||||
const isUploadInProgress = getExtensionStorage(editor, CORE_EXTENSIONS.UTILITY)?.uploadInProgress;
|
||||
|
||||
if (isUploadInProgress) return "";
|
||||
|
||||
const shouldHidePlaceholder =
|
||||
editor.isActive("table") ||
|
||||
editor.isActive("codeBlock") ||
|
||||
editor.isActive("image") ||
|
||||
editor.isActive("imageComponent");
|
||||
editor.isActive(CORE_EXTENSIONS.TABLE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CODE_BLOCK) ||
|
||||
editor.isActive(CORE_EXTENSIONS.IMAGE) ||
|
||||
editor.isActive(CORE_EXTENSIONS.CUSTOM_IMAGE);
|
||||
|
||||
if (shouldHidePlaceholder) return "";
|
||||
|
||||
@@ -169,6 +169,10 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
CharacterCount,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutExtension,
|
||||
UtilityExtension({
|
||||
isEditable: editable,
|
||||
fileHandler,
|
||||
}),
|
||||
CustomColorExtension,
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
+4
-2
@@ -1,5 +1,7 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface IMarking {
|
||||
type: "heading";
|
||||
@@ -12,8 +14,8 @@ export type HeadingExtensionStorage = {
|
||||
headings: IMarking[];
|
||||
};
|
||||
|
||||
export const HeadingListExtension = Extension.create<any, HeadingExtensionStorage>({
|
||||
name: "headingList",
|
||||
export const HeadingListExtension = Extension.create<unknown, HeadingExtensionStorage>({
|
||||
name: CORE_EXTENSIONS.HEADINGS_LIST,
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core";
|
||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface HorizontalRuleOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
@@ -7,7 +9,7 @@ export interface HorizontalRuleOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
horizontalRule: {
|
||||
[CORE_EXTENSIONS.HORIZONTAL_RULE]: {
|
||||
/**
|
||||
* Add a horizontal rule
|
||||
*/
|
||||
@@ -17,7 +19,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
|
||||
export const CustomHorizontalRule = Node.create<HorizontalRuleOptions>({
|
||||
name: "horizontalRule",
|
||||
name: CORE_EXTENSIONS.HORIZONTAL_RULE,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
// helpers
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import { ImageExtensionStorage, TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
// types
|
||||
import { TFileHandler } from "@/types";
|
||||
|
||||
export type ImageExtensionStorage = {
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
};
|
||||
|
||||
export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
const {
|
||||
getAssetSrc,
|
||||
delete: deleteImageFn,
|
||||
restore: restoreImageFn,
|
||||
validation: { maxFileSize },
|
||||
} = fileHandler;
|
||||
|
||||
return ImageExt.extend<any, ImageExtensionStorage>({
|
||||
return BaseImageExtension.extend<unknown, ImageExtensionStorage>({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
ArrowDown: insertEmptyParagraphAtNodeBoundaries("down", this.name),
|
||||
@@ -25,36 +25,10 @@ export const ImageExtension = (fileHandler: TFileHandler) => {
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
TrackImageDeletionPlugin(this.editor, deleteImageFn, this.name),
|
||||
TrackImageRestorationPlugin(this.editor, restoreImageFn, this.name),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate(this) {
|
||||
const imageSources = new Set<string>();
|
||||
this.editor.state.doc.descendants((node) => {
|
||||
if (node.type.name === this.name) {
|
||||
if (!node.attrs.src?.startsWith("http")) return;
|
||||
|
||||
imageSources.add(node.attrs.src);
|
||||
}
|
||||
});
|
||||
imageSources.forEach(async (src) => {
|
||||
try {
|
||||
await restoreImageFn(src);
|
||||
} catch (error) {
|
||||
console.error("Error restoring image: ", error);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// storage to keep track of image states Map<src, isDeleted>
|
||||
addStorage() {
|
||||
return {
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,58 +1,56 @@
|
||||
import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
// extensions
|
||||
import { ImageExtensionStorage } from "@/plugins/image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
// local imports
|
||||
import { ImageExtensionStorage } from "./extension";
|
||||
|
||||
export const CustomImageComponentWithoutProps = () =>
|
||||
Image.extend<Record<string, unknown>, ImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
export const CustomImageComponentWithoutProps = BaseImageExtension.extend<
|
||||
Record<string, unknown>,
|
||||
ImageExtensionStorage
|
||||
>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: "auto",
|
||||
},
|
||||
["id"]: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "image-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["image-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
uploadInProgress: false,
|
||||
maxFileSize: 0,
|
||||
assetsUploadStatus: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default CustomImageComponentWithoutProps;
|
||||
addStorage() {
|
||||
return {
|
||||
fileMap: new Map(),
|
||||
deletedImageSet: new Map<string, boolean>(),
|
||||
maxFileSize: 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import ImageExt from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
|
||||
export const ImageExtensionWithoutProps = () =>
|
||||
ImageExt.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
export const ImageExtensionWithoutProps = BaseImageExtension.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
aspectRatio: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Image as BaseImageExtension } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// extensions
|
||||
import { CustomImageNode } from "@/extensions";
|
||||
@@ -8,7 +8,7 @@ import { TReadOnlyFileHandler } from "@/types";
|
||||
export const ReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc } = props;
|
||||
|
||||
return Image.extend({
|
||||
return BaseImageExtension.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
|
||||
@@ -5,22 +5,20 @@ export * from "./custom-image";
|
||||
export * from "./custom-link";
|
||||
export * from "./custom-list-keymap";
|
||||
export * from "./image";
|
||||
export * from "./issue-embed";
|
||||
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-code-inline";
|
||||
export * from "./custom-color";
|
||||
export * from "./drop";
|
||||
export * from "./enter-key-extension";
|
||||
export * from "./enter-key";
|
||||
export * from "./extensions";
|
||||
export * from "./headers";
|
||||
export * from "./headings-list";
|
||||
export * from "./horizontal-rule";
|
||||
export * from "./keymap";
|
||||
export * from "./quote";
|
||||
export * from "./read-only-extensions";
|
||||
export * from "./side-menu";
|
||||
export * from "./text-align";
|
||||
export * from "./clipboard";
|
||||
export * from "./utility";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./widget-node";
|
||||
export * from "./issue-embed-without-props";
|
||||
@@ -1,41 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
export const IssueWidgetWithoutProps = () =>
|
||||
Node.create({
|
||||
name: "issue-embed-component",
|
||||
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,66 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
|
||||
type Props = {
|
||||
widgetCallback: ({
|
||||
issueId,
|
||||
projectId,
|
||||
workspaceSlug,
|
||||
}: {
|
||||
issueId: string;
|
||||
projectId: string | undefined;
|
||||
workspaceSlug: string | undefined;
|
||||
}) => React.ReactNode;
|
||||
};
|
||||
|
||||
export const IssueWidget = (props: Props) =>
|
||||
Node.create({
|
||||
name: "issue-embed-component",
|
||||
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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer((issueProps: any) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "issue-embed-component",
|
||||
},
|
||||
];
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["issue-embed-component", mergeAttributes(HTMLAttributes)];
|
||||
},
|
||||
});
|
||||
+7
-5
@@ -2,11 +2,13 @@ import { Extension } from "@tiptap/core";
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
|
||||
import { canJoin } from "@tiptap/pm/transform";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
interface Commands<ReturnType> {
|
||||
customkeymap: {
|
||||
customKeymap: {
|
||||
/**
|
||||
* Select text between node boundaries
|
||||
*/
|
||||
@@ -59,7 +61,7 @@ function autoJoin(tr: Transaction, newTr: Transaction, nodeTypes: NodeType[]) {
|
||||
}
|
||||
|
||||
export const CustomKeymap = Extension.create({
|
||||
name: "CustomKeymap",
|
||||
name: "customKeymap",
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
@@ -87,9 +89,9 @@ export const CustomKeymap = Extension.create({
|
||||
const newTr = newState.tr;
|
||||
|
||||
const joinableNodes = [
|
||||
newState.schema.nodes["orderedList"],
|
||||
newState.schema.nodes["taskList"],
|
||||
newState.schema.nodes["bulletList"],
|
||||
newState.schema.nodes[CORE_EXTENSIONS.ORDERED_LIST],
|
||||
newState.schema.nodes[CORE_EXTENSIONS.TASK_LIST],
|
||||
newState.schema.nodes[CORE_EXTENSIONS.BULLET_LIST],
|
||||
];
|
||||
|
||||
let joined = false;
|
||||
@@ -18,7 +18,7 @@ export const MentionNodeView = (props: Props) => {
|
||||
return (
|
||||
<NodeViewWrapper className="mention-component inline w-fit">
|
||||
{(extension.options as TMentionExtensionOptions).renderComponent({
|
||||
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER],
|
||||
entity_identifier: attrs[EMentionComponentAttributeNames.ENTITY_IDENTIFIER] ?? "",
|
||||
entity_name: attrs[EMentionComponentAttributeNames.ENTITY_NAME] ?? "user_mention",
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// plane utils
|
||||
import { cn } from "@plane/utils";
|
||||
@@ -61,7 +61,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
|
||||
sections,
|
||||
selectedIndex,
|
||||
});
|
||||
setSelectedIndex(newIndex);
|
||||
if (newIndex) {
|
||||
setSelectedIndex(newIndex);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -79,7 +81,9 @@ export const MentionsListDropdown = forwardRef((props: MentionsListDropdownProps
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const sectionsResponse = await searchCallback?.(query);
|
||||
setSections(sectionsResponse);
|
||||
if (sectionsResponse) {
|
||||
setSections(sectionsResponse);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch suggestions:", error);
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import tippy from "tippy.js";
|
||||
import { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
@@ -15,7 +15,7 @@ export const renderMentionsDropdown =
|
||||
() => {
|
||||
const { searchCallback } = props;
|
||||
let component: ReactRenderer<CommandListInstance, MentionsListDropdownProps> | null = null;
|
||||
let popup: any | null = null;
|
||||
let popup: Instance | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export const CustomQuoteExtension = Blockquote.extend({
|
||||
addKeyboardShortcuts() {
|
||||
@@ -10,7 +12,7 @@ export const CustomQuoteExtension = Blockquote.extend({
|
||||
|
||||
if (!parent) return false;
|
||||
|
||||
if (parent.type.name !== "blockquote") {
|
||||
if (parent.type.name !== CORE_EXTENSIONS.BLOCKQUOTE) {
|
||||
return false;
|
||||
}
|
||||
if ($from.pos !== $to.pos) return false;
|
||||
+5
-2
@@ -24,7 +24,7 @@ import {
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
CustomColorExtension,
|
||||
MarkdownClipboard,
|
||||
UtilityExtension,
|
||||
} from "@/extensions";
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
@@ -117,7 +117,6 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
html: true,
|
||||
transformCopiedText: false,
|
||||
}),
|
||||
MarkdownClipboard,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
@@ -127,6 +126,10 @@ export const CoreReadOnlyEditorExtensions = (props: Props): Extensions => {
|
||||
CustomColorExtension,
|
||||
CustomTextAlignExtension,
|
||||
CustomCalloutReadOnlyExtension,
|
||||
UtilityExtension({
|
||||
isEditable: false,
|
||||
fileHandler,
|
||||
}),
|
||||
...CoreReadOnlyEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorView } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// plugins
|
||||
import { AIHandlePlugin } from "@/plugins/ai-handle";
|
||||
import { DragHandlePlugin, nodeDOMAtCoords } from "@/plugins/drag-handle";
|
||||
@@ -33,7 +35,7 @@ export const SideMenuExtension = (props: Props) => {
|
||||
const { aiEnabled, dragDropEnabled } = props;
|
||||
|
||||
return Extension.create({
|
||||
name: "editorSideMenu",
|
||||
name: CORE_EXTENSIONS.SIDE_MENU,
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
SideMenu({
|
||||
@@ -26,22 +26,17 @@ import {
|
||||
toggleBulletList,
|
||||
toggleOrderedList,
|
||||
toggleTaskList,
|
||||
toggleHeadingOne,
|
||||
toggleHeadingTwo,
|
||||
toggleHeadingThree,
|
||||
toggleHeadingFour,
|
||||
toggleHeadingFive,
|
||||
toggleHeadingSix,
|
||||
toggleHeading,
|
||||
toggleTextColor,
|
||||
toggleBackgroundColor,
|
||||
insertImage,
|
||||
insertCallout,
|
||||
setText,
|
||||
} from "@/helpers/editor-commands";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
|
||||
// plane editor extensions
|
||||
import { coreEditorAdditionalSlashCommandOptions } from "@/plane-editor/extensions";
|
||||
// types
|
||||
import { CommandProps, ISlashCommandItem, TSlashCommandSectionKeys } from "@/types";
|
||||
// local types
|
||||
import { TExtensionProps, TSlashCommandAdditionalOption } from "./root";
|
||||
|
||||
@@ -75,7 +70,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Big section heading.",
|
||||
searchTerms: ["title", "big", "large"],
|
||||
icon: <Heading1 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingOne(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 1, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h2",
|
||||
@@ -84,7 +79,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Medium section heading.",
|
||||
searchTerms: ["subtitle", "medium"],
|
||||
icon: <Heading2 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingTwo(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 2, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h3",
|
||||
@@ -93,7 +88,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading3 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingThree(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 3, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h4",
|
||||
@@ -102,7 +97,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading4 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFour(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 4, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h5",
|
||||
@@ -111,7 +106,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading5 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingFive(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 5, range),
|
||||
},
|
||||
{
|
||||
commandKey: "h6",
|
||||
@@ -120,7 +115,7 @@ export const getSlashCommandFilteredSections =
|
||||
description: "Small section heading.",
|
||||
searchTerms: ["subtitle", "small"],
|
||||
icon: <Heading6 className="size-3.5" />,
|
||||
command: ({ editor, range }) => toggleHeadingSix(editor, range),
|
||||
command: ({ editor, range }) => toggleHeading(editor, 6, range),
|
||||
},
|
||||
{
|
||||
commandKey: "to-do-list",
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
|
||||
// helpers
|
||||
import { DROPDOWN_NAVIGATION_KEYS, getNextValidIndex } from "@/helpers/tippy";
|
||||
// components
|
||||
import { ISlashCommandItem } from "@/types";
|
||||
import { TSlashCommandSection } from "./command-items-list";
|
||||
import { CommandMenuItem } from "./command-menu-item";
|
||||
|
||||
export type SlashCommandsMenuProps = {
|
||||
editor: Editor;
|
||||
items: TSlashCommandSection[];
|
||||
command: any;
|
||||
command: (item: ISlashCommandItem) => void;
|
||||
};
|
||||
|
||||
export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref) => {
|
||||
@@ -103,7 +104,9 @@ export const SlashCommandsMenu = forwardRef((props: SlashCommandsMenuProps, ref)
|
||||
sections,
|
||||
selectedIndex,
|
||||
});
|
||||
setSelectedIndex(newIndex);
|
||||
if (newIndex) {
|
||||
setSelectedIndex(newIndex);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Editor, Range, Extension } from "@tiptap/core";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion, { SuggestionOptions } from "@tiptap/suggestion";
|
||||
import tippy from "tippy.js";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { CommandListInstance } from "@/helpers/tippy";
|
||||
// types
|
||||
@@ -20,7 +22,7 @@ export type TSlashCommandAdditionalOption = ISlashCommandItem & {
|
||||
};
|
||||
|
||||
const Command = Extension.create<SlashCommandOptions>({
|
||||
name: "slash-command",
|
||||
name: CORE_EXTENSIONS.SLASH_COMMANDS,
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
@@ -34,11 +36,11 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
const parentNode = selection.$from.node(selection.$from.depth);
|
||||
const blockType = parentNode.type.name;
|
||||
|
||||
if (blockType === "codeBlock") {
|
||||
if (blockType === CORE_EXTENSIONS.CODE_BLOCK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor.isActive("table")) {
|
||||
if (editor.isActive(CORE_EXTENSIONS.TABLE)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -59,7 +61,7 @@ const Command = Extension.create<SlashCommandOptions>({
|
||||
|
||||
const renderItems = () => {
|
||||
let component: ReactRenderer<CommandListInstance, SlashCommandsMenuProps> | null = null;
|
||||
let popup: any | null = null;
|
||||
let popup: Instance | null = null;
|
||||
return {
|
||||
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
|
||||
component = new ReactRenderer<CommandListInstance, SlashCommandsMenuProps>(SlashCommandsMenu, {
|
||||
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableCell = Node.create<TableCellOptions>({
|
||||
name: "tableCell",
|
||||
name: CORE_EXTENSIONS.TABLE_CELL,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -1 +0,0 @@
|
||||
export { TableCell } from "./table-cell";
|
||||
+3
-2
@@ -1,11 +1,12 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
export interface TableHeaderOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableHeader = Node.create<TableHeaderOptions>({
|
||||
name: "tableHeader",
|
||||
name: CORE_EXTENSIONS.TABLE_HEADER,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -1 +0,0 @@
|
||||
export { TableHeader } from "./table-header";
|
||||
+3
-1
@@ -1,11 +1,13 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
export interface TableRowOptions {
|
||||
HTMLAttributes: Record<string, any>;
|
||||
}
|
||||
|
||||
export const TableRow = Node.create<TableRowOptions>({
|
||||
name: "tableRow",
|
||||
name: CORE_EXTENSIONS.TABLE_ROW,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -1 +0,0 @@
|
||||
export { TableRow } from "./table-row";
|
||||
@@ -1,6 +1,8 @@
|
||||
import { findParentNode } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||
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");
|
||||
|
||||
@@ -17,16 +19,14 @@ export function tableControls() {
|
||||
},
|
||||
props: {
|
||||
handleTripleClickOn(view, pos, node, nodePos, event, direct) {
|
||||
if (node.type.name === 'tableCell') {
|
||||
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)
|
||||
);
|
||||
const tr = view.state.tr.setSelection(TextSelection.create(view.state.doc, start, end));
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
@@ -52,12 +52,12 @@ export function tableControls() {
|
||||
|
||||
if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return;
|
||||
|
||||
const table = findParentNode((node) => node.type.name === "table")(
|
||||
TextSelection.create(view.state.doc, pos.pos)
|
||||
);
|
||||
const cell = findParentNode((node) => node.type.name === "tableCell" || node.type.name === "tableHeader")(
|
||||
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;
|
||||
|
||||
@@ -112,7 +112,7 @@ class TableControlsState {
|
||||
};
|
||||
}
|
||||
|
||||
apply(tr: any) {
|
||||
apply(tr: Transaction) {
|
||||
const actions = tr.getMeta(key);
|
||||
|
||||
if (actions?.setHoveredTable !== undefined) {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { h } from "jsx-dom-cjs";
|
||||
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
||||
import { Decoration, NodeView } from "@tiptap/pm/view";
|
||||
import tippy, { Instance, Props } from "tippy.js";
|
||||
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Node as ProseMirrorNode, ResolvedPos } from "@tiptap/pm/model";
|
||||
import { CellSelection, TableMap, updateColumnsOnResize } from "@tiptap/pm/tables";
|
||||
|
||||
import { Decoration, NodeView } from "@tiptap/pm/view";
|
||||
import { h } from "jsx-dom-cjs";
|
||||
import { icons } from "src/core/extensions/table/table/icons";
|
||||
import tippy, { Instance, Props } from "tippy.js";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
|
||||
type ToolboxItem = {
|
||||
label: string;
|
||||
@@ -30,10 +30,10 @@ export function updateColumns(
|
||||
if (!row) return;
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
const { colspan, colwidth } = row.child(i).attrs;
|
||||
const { colspan, colWidth } = row.child(i).attrs;
|
||||
|
||||
for (let j = 0; j < colspan; j += 1, col += 1) {
|
||||
const hasWidth = overrideCol === col ? overrideValue : colwidth && colwidth[j];
|
||||
const hasWidth = overrideCol === col ? overrideValue : colWidth && colWidth[j];
|
||||
const cssWidth = hasWidth ? `${hasWidth}px` : "";
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth;
|
||||
@@ -85,7 +85,7 @@ function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: strin
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
.updateAttributes(CORE_EXTENSIONS.TABLE_CELL, {
|
||||
background: color.backgroundColor,
|
||||
textColor: color.textColor,
|
||||
})
|
||||
@@ -104,12 +104,12 @@ function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: st
|
||||
|
||||
// Find the depth of the table row node
|
||||
let rowDepth = hoveredCell.depth;
|
||||
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) {
|
||||
rowDepth--;
|
||||
}
|
||||
|
||||
// If we couldn't find a tableRow node, we can't set the background color
|
||||
if (hoveredCell.node(rowDepth).type.name !== "tableRow") {
|
||||
if (hoveredCell.node(rowDepth).type.name !== CORE_EXTENSIONS.TABLE_ROW) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,11 +19,14 @@ import {
|
||||
toggleHeader,
|
||||
toggleHeaderCell,
|
||||
} from "@tiptap/pm/tables";
|
||||
|
||||
import { tableControls } from "@/extensions/table/table/table-controls";
|
||||
import { TableView } from "@/extensions/table/table/table-view";
|
||||
import { createTable } from "@/extensions/table/table/utilities/create-table";
|
||||
import { deleteTableWhenAllCellsSelected } from "@/extensions/table/table/utilities/delete-table-when-all-cells-selected";
|
||||
import { Decoration } from "@tiptap/pm/view";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// local imports
|
||||
import { tableControls } from "./table-controls";
|
||||
import { TableView } from "./table-view";
|
||||
import { createTable } from "./utilities/create-table";
|
||||
import { deleteTableWhenAllCellsSelected } from "./utilities/delete-table-when-all-cells-selected";
|
||||
import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action";
|
||||
import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action";
|
||||
|
||||
@@ -38,7 +41,7 @@ export interface TableOptions {
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
table: {
|
||||
[CORE_EXTENSIONS.TABLE]: {
|
||||
insertTable: (options?: {
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
@@ -79,7 +82,7 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
|
||||
export const Table = Node.create({
|
||||
name: "table",
|
||||
name: CORE_EXTENSIONS.TABLE,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
@@ -219,8 +222,8 @@ export const Table = Node.create({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.isActive("table")) {
|
||||
if (this.editor.isActive("listItem") || this.editor.isActive("taskItem")) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.TABLE)) {
|
||||
if (this.editor.isActive(CORE_EXTENSIONS.LIST_ITEM) || this.editor.isActive(CORE_EXTENSIONS.TASK_ITEM)) {
|
||||
return false;
|
||||
}
|
||||
if (this.editor.commands.goToNextCell()) {
|
||||
@@ -249,7 +252,7 @@ export const Table = Node.create({
|
||||
return ({ editor, getPos, node, decorations }) => {
|
||||
const { cellMinWidth } = this.options;
|
||||
|
||||
return new TableView(node, cellMinWidth, decorations as any, editor, getPos as () => number);
|
||||
return new TableView(node, cellMinWidth, decorations as Decoration[], editor, getPos as () => number);
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
+8
-3
@@ -1,4 +1,6 @@
|
||||
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// extensions
|
||||
import { isCellSelection } from "@/extensions/table/table/utilities/is-cell-selection";
|
||||
|
||||
@@ -10,14 +12,17 @@ export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({ edito
|
||||
}
|
||||
|
||||
let cellCount = 0;
|
||||
const table = findParentNodeClosestToPos(selection.ranges[0].$from, (node) => node.type.name === "table");
|
||||
const table = findParentNodeClosestToPos(
|
||||
selection.ranges[0].$from,
|
||||
(node) => node.type.name === CORE_EXTENSIONS.TABLE
|
||||
);
|
||||
|
||||
table?.node.descendants((node) => {
|
||||
if (node.type.name === "table") {
|
||||
if (node.type.name === CORE_EXTENSIONS.TABLE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (["tableCell", "tableHeader"].includes(node.type.name)) {
|
||||
if ([CORE_EXTENSIONS.TABLE_CELL, CORE_EXTENSIONS.TABLE_HEADER].includes(node.type.name as CORE_EXTENSIONS)) {
|
||||
cellCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
+5
-3
@@ -1,17 +1,19 @@
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { findParentNodeOfType } from "@/helpers/common";
|
||||
|
||||
export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE);
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
@@ -39,7 +41,7 @@ export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
|
||||
const prevNode = editor.state.doc.nodeAt(prevNodePos - 1);
|
||||
|
||||
if (prevNode && prevNode.type.name === "paragraph") {
|
||||
if (prevNode && prevNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If there's a paragraph before the table, move the cursor to the end of that paragraph
|
||||
const endOfParagraphPos = tablePos - prevNode.nodeSize;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
|
||||
+6
-4
@@ -1,17 +1,19 @@
|
||||
import { KeyboardShortcutCommand } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
// helpers
|
||||
import { findParentNodeOfType } from "@/helpers/common";
|
||||
|
||||
export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => {
|
||||
// Check if the current selection or the closest node is a table
|
||||
if (!editor.isActive("table")) return false;
|
||||
if (!editor.isActive(CORE_EXTENSIONS.TABLE)) return false;
|
||||
|
||||
try {
|
||||
// Get the current selection
|
||||
const { selection } = editor.state;
|
||||
|
||||
// Find the table node and its position
|
||||
const tableNode = findParentNodeOfType(selection, "table");
|
||||
const tableNode = findParentNodeOfType(selection, CORE_EXTENSIONS.TABLE);
|
||||
if (!tableNode) return false;
|
||||
|
||||
const tablePos = tableNode.pos;
|
||||
@@ -31,13 +33,13 @@ export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor })
|
||||
// Check for an existing node immediately after the table
|
||||
const nextNode = editor.state.doc.nodeAt(nextNodePos);
|
||||
|
||||
if (nextNode && nextNode.type.name === "paragraph") {
|
||||
if (nextNode && nextNode.type.name === CORE_EXTENSIONS.PARAGRAPH) {
|
||||
// If the next node is an paragraph, move the cursor there
|
||||
const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1;
|
||||
editor.chain().setTextSelection(endOfParagraphPos).run();
|
||||
} else if (!nextNode) {
|
||||
// If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there
|
||||
editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run();
|
||||
editor.chain().insertContentAt(nextNodePos, { type: CORE_EXTENSIONS.PARAGRAPH }).run();
|
||||
editor
|
||||
.chain()
|
||||
.setTextSelection(nextNodePos + 1)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Extension, InputRule } from "@tiptap/core";
|
||||
// constants
|
||||
import { CORE_EXTENSIONS } from "@/constants/extension";
|
||||
import {
|
||||
TypographyOptions,
|
||||
emDash,
|
||||
@@ -23,7 +25,7 @@ import {
|
||||
} from "./inputRules";
|
||||
|
||||
export const CustomTypographyExtension = Extension.create<TypographyOptions>({
|
||||
name: "typography",
|
||||
name: CORE_EXTENSIONS.TYPOGRAPHY,
|
||||
|
||||
addInputRules() {
|
||||
const rules: InputRule[] = [];
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
// prosemirror plugins
|
||||
import codemark from "prosemirror-codemark";
|
||||
// helpers
|
||||
import { restorePublicImages } from "@/helpers/image-helpers";
|
||||
// plugins
|
||||
import { DropHandlerPlugin } from "@/plugins/drop";
|
||||
import { FilePlugins } from "@/plugins/file/root";
|
||||
import { MarkdownClipboardPlugin } from "@/plugins/markdown-clipboard";
|
||||
// types
|
||||
import { TFileHandler, TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands {
|
||||
utility: {
|
||||
updateAssetsUploadStatus: (updatedStatus: TFileHandler["assetsUploadStatus"]) => () => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface UtilityExtensionStorage {
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
uploadInProgress: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
fileHandler: TFileHandler | TReadOnlyFileHandler;
|
||||
isEditable: boolean;
|
||||
};
|
||||
|
||||
export const UtilityExtension = (props: Props) => {
|
||||
const { fileHandler, isEditable } = props;
|
||||
const { restore: restoreImageFn } = fileHandler;
|
||||
|
||||
return Extension.create<Record<string, unknown>, UtilityExtensionStorage>({
|
||||
name: "utility",
|
||||
priority: 1000,
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
...FilePlugins({
|
||||
editor: this.editor,
|
||||
isEditable,
|
||||
fileHandler,
|
||||
}),
|
||||
...codemark({ markType: this.editor.schema.marks.code }),
|
||||
MarkdownClipboardPlugin(this.editor),
|
||||
DropHandlerPlugin(this.editor),
|
||||
];
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
restorePublicImages(this.editor, restoreImageFn);
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
assetsUploadStatus: isEditable && "assetsUploadStatus" in fileHandler ? fileHandler.assetsUploadStatus : {},
|
||||
uploadInProgress: false,
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
updateAssetsUploadStatus: (updatedStatus) => () => {
|
||||
this.storage.assetsUploadStatus = updatedStatus;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
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)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } 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: any) => (
|
||||
<NodeViewWrapper>
|
||||
{props.widgetCallback({
|
||||
issueId: issueProps.node.attrs.entity_identifier,
|
||||
projectId: issueProps.node.attrs.project_identifier,
|
||||
workspaceSlug: issueProps.node.attrs.workspace_identifier,
|
||||
})}
|
||||
</NodeViewWrapper>
|
||||
));
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user