Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f751619759 | ||
|
|
adf49782ba | ||
|
|
8d4ac9b430 | ||
|
|
7c125075b1 | ||
|
|
26bb51e686 | ||
|
|
fbf88c3196 | ||
|
|
dc2b4de95e | ||
|
|
62ba8d5e9f | ||
|
|
698b42768e | ||
|
|
ab028a317b | ||
|
|
2bc05cc7b7 | ||
|
|
14fe545709 | ||
|
|
bc99ec0f1d | ||
|
|
cc63f67654 | ||
|
|
b8dd9ca729 | ||
|
|
73b360c2fd | ||
|
|
80bcca71ff | ||
|
|
3db0ec819a | ||
|
|
414ea7371d | ||
|
|
2b84b7c18d | ||
|
|
674347c99e | ||
|
|
c1102180e6 | ||
|
|
650c0c3b78 | ||
|
|
6f397710ce | ||
|
|
e6bd6b6a8c | ||
|
|
9d3952006b | ||
|
|
b75473a684 | ||
|
|
2f5bd58c61 | ||
|
|
e833fccf61 | ||
|
|
62ba9abdb4 | ||
|
|
46b138eb0b | ||
|
|
59bdf222f5 | ||
|
|
eb50ade5e3 | ||
|
|
85a08e4abd | ||
|
|
aa2e1697b0 | ||
|
|
3beab9de6f | ||
|
|
13d21e752d | ||
|
|
9ff8994c0e | ||
|
|
3488001197 | ||
|
|
2ced7e4911 | ||
|
|
e5a3bec28c | ||
|
|
e930f8cc7b |
+4
-2
@@ -36,13 +36,15 @@ jobs:
|
||||
- name: Build Plane's Main App
|
||||
if: steps.changed-files.outputs.web_any_changed == 'true'
|
||||
run: |
|
||||
cd web
|
||||
yarn
|
||||
yarn build --filter=web
|
||||
yarn build
|
||||
|
||||
- name: Build Plane's Deploy App
|
||||
if: steps.changed-files.outputs.deploy_any_changed == 'true'
|
||||
run: |
|
||||
cd space
|
||||
yarn
|
||||
yarn build --filter=space
|
||||
yarn build
|
||||
|
||||
|
||||
+3
-3
@@ -39,10 +39,10 @@ jobs:
|
||||
type=ref,event=tag
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker (Docker Hub) from Github Release
|
||||
id: metaSpace
|
||||
id: metaDeploy
|
||||
uses: docker/metadata-action@v4.3.0
|
||||
with:
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-space
|
||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/plane-deploy
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
file: ./space/Dockerfile.space
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.metaSpace.outputs.tags }}
|
||||
tags: ${{ steps.metaDeploy.outputs.tags }}
|
||||
env:
|
||||
DOCKER_BUILDKIT: 1
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Create PR in Plane EE Repository to sync the changes
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- closed
|
||||
|
||||
jobs:
|
||||
create_pr:
|
||||
# Only run the job when a PR is merged
|
||||
if: github.event.pull_request.merged == true
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check SOURCE_REPO
|
||||
id: check_repo
|
||||
env:
|
||||
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
|
||||
run: |
|
||||
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
|
||||
|
||||
- name: Checkout Code
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Branch Name
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
run: |
|
||||
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup GH CLI
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
|
||||
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_BODY="${{ github.event.pull_request.body }}"
|
||||
|
||||
# Remove double quotes
|
||||
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
|
||||
PR_BODY_CLEANED="${PR_BODY//\"/}"
|
||||
|
||||
# Construct PR_BODY_CONTENT using a here-document
|
||||
PR_BODY_CONTENT=$(cat <<EOF
|
||||
$PR_BODY_CLEANED
|
||||
EOF
|
||||
)
|
||||
|
||||
gh pr create \
|
||||
--base $TARGET_BRANCH \
|
||||
--head $SOURCE_BRANCH \
|
||||
--title "[SYNC] $PR_TITLE_CLEANED" \
|
||||
--body "$PR_BODY_CONTENT" \
|
||||
--repo $TARGET_REPO
|
||||
@@ -73,7 +73,3 @@ pnpm-lock.yaml
|
||||
pnpm-workspace.yaml
|
||||
|
||||
.npmrc
|
||||
|
||||
|
||||
## packages
|
||||
dist
|
||||
|
||||
@@ -54,7 +54,7 @@ chmod +x setup.sh
|
||||
- Run setup.sh
|
||||
|
||||
```bash
|
||||
./setup.sh
|
||||
./setup.sh http://localhost
|
||||
```
|
||||
|
||||
> If running in a cloud env replace localhost with public facing IP address of the VM
|
||||
|
||||
+1
-12
@@ -1,7 +1,7 @@
|
||||
# Backend
|
||||
# Debug value for api server use it as 0 for production use
|
||||
DEBUG=0
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.production"
|
||||
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted"
|
||||
|
||||
# Error logs
|
||||
SENTRY_DSN=""
|
||||
@@ -59,14 +59,3 @@ DEFAULT_PASSWORD="password123"
|
||||
|
||||
# SignUps
|
||||
ENABLE_SIGNUP="1"
|
||||
|
||||
|
||||
# Enable Email/Password Signup
|
||||
ENABLE_EMAIL_PASSWORD="1"
|
||||
|
||||
# Enable Magic link Login
|
||||
ENABLE_MAGIC_LINK_LOGIN="0"
|
||||
|
||||
# Email redirections and minio domain settings
|
||||
WEB_URL="http://localhost"
|
||||
|
||||
|
||||
@@ -70,7 +70,6 @@ from plane.api.views import (
|
||||
ProjectIdentifierEndpoint,
|
||||
ProjectFavoritesViewSet,
|
||||
LeaveProjectEndpoint,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
## End Projects
|
||||
# Issues
|
||||
IssueViewSet,
|
||||
@@ -151,11 +150,12 @@ from plane.api.views import (
|
||||
GlobalSearchEndpoint,
|
||||
IssueSearchEndpoint,
|
||||
## End Search
|
||||
# External
|
||||
# Gpt
|
||||
GPTIntegrationEndpoint,
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
ReleaseNotesEndpoint,
|
||||
UnsplashEndpoint,
|
||||
## End External
|
||||
## End Release Notes
|
||||
# Inbox
|
||||
InboxViewSet,
|
||||
InboxIssueViewSet,
|
||||
@@ -186,9 +186,6 @@ from plane.api.views import (
|
||||
## Exporter
|
||||
ExportIssuesEndpoint,
|
||||
## End Exporter
|
||||
# Configuration
|
||||
ConfigurationEndpoint,
|
||||
## End Configuration
|
||||
)
|
||||
|
||||
|
||||
@@ -576,11 +573,6 @@ urlpatterns = [
|
||||
LeaveProjectEndpoint.as_view(),
|
||||
name="project",
|
||||
),
|
||||
path(
|
||||
"project-covers/",
|
||||
ProjectPublicCoverImagesEndpoint.as_view(),
|
||||
name="project-covers",
|
||||
),
|
||||
# End Projects
|
||||
# States
|
||||
path(
|
||||
@@ -1454,23 +1446,20 @@ urlpatterns = [
|
||||
name="project-issue-search",
|
||||
),
|
||||
## End Search
|
||||
# External
|
||||
# Gpt
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/ai-assistant/",
|
||||
GPTIntegrationEndpoint.as_view(),
|
||||
name="importer",
|
||||
),
|
||||
## End Gpt
|
||||
# Release Notes
|
||||
path(
|
||||
"release-notes/",
|
||||
ReleaseNotesEndpoint.as_view(),
|
||||
name="release-notes",
|
||||
),
|
||||
path(
|
||||
"unsplash/",
|
||||
UnsplashEndpoint.as_view(),
|
||||
name="release-notes",
|
||||
),
|
||||
## End External
|
||||
## End Release Notes
|
||||
# Inbox
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/inboxes/",
|
||||
@@ -1739,11 +1728,4 @@ urlpatterns = [
|
||||
name="workspace-project-boards",
|
||||
),
|
||||
## End Public Boards
|
||||
# Configuration
|
||||
path(
|
||||
"configs/",
|
||||
ConfigurationEndpoint.as_view(),
|
||||
name="configuration",
|
||||
),
|
||||
## End Configuration
|
||||
]
|
||||
|
||||
@@ -17,7 +17,6 @@ from .project import (
|
||||
ProjectMemberEndpoint,
|
||||
WorkspaceProjectDeployBoardEndpoint,
|
||||
LeaveProjectEndpoint,
|
||||
ProjectPublicCoverImagesEndpoint,
|
||||
)
|
||||
from .user import (
|
||||
UserEndpoint,
|
||||
@@ -148,13 +147,16 @@ from .page import (
|
||||
from .search import GlobalSearchEndpoint, IssueSearchEndpoint
|
||||
|
||||
|
||||
from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint
|
||||
from .gpt import GPTIntegrationEndpoint
|
||||
|
||||
from .estimate import (
|
||||
ProjectEstimatePointEndpoint,
|
||||
BulkEstimatePointEndpoint,
|
||||
)
|
||||
|
||||
|
||||
from .release import ReleaseNotesEndpoint
|
||||
|
||||
from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet
|
||||
|
||||
from .analytic import (
|
||||
@@ -167,6 +169,4 @@ from .analytic import (
|
||||
|
||||
from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet
|
||||
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
|
||||
from .config import ConfigurationEndpoint
|
||||
from .exporter import ExportIssuesEndpoint
|
||||
@@ -1,40 +0,0 @@
|
||||
# Python imports
|
||||
import os
|
||||
|
||||
# Django imports
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class ConfigurationEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
data = {}
|
||||
data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None)
|
||||
data["github"] = os.environ.get("GITHUB_CLIENT_ID", None)
|
||||
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None)
|
||||
data["magic_login"] = (
|
||||
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD)
|
||||
) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1"
|
||||
data["email_password_login"] = (
|
||||
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
|
||||
)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -2,10 +2,9 @@
|
||||
import requests
|
||||
|
||||
# Third party imports
|
||||
import openai
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.permissions import AllowAny
|
||||
import openai
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Django imports
|
||||
@@ -16,7 +15,6 @@ from .base import BaseAPIView
|
||||
from plane.api.permissions import ProjectEntityPermission
|
||||
from plane.db.models import Workspace, Project
|
||||
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||
from plane.utils.integrations.github import get_release_notes
|
||||
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
@@ -75,44 +73,3 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseNotesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
release_notes = get_release_notes()
|
||||
return Response(release_notes, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class UnsplashEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
query = request.GET.get("query", False)
|
||||
page = request.GET.get("page", 1)
|
||||
per_page = request.GET.get("per_page", 20)
|
||||
|
||||
url = (
|
||||
f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}"
|
||||
if query
|
||||
else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}"
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
resp = requests.get(url=url, headers=headers)
|
||||
return Response(resp.json(), status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -1,6 +1,5 @@
|
||||
# Python imports
|
||||
import jwt
|
||||
import boto3
|
||||
from datetime import datetime
|
||||
|
||||
# Django imports
|
||||
@@ -496,7 +495,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
serializer_class = ProjectMemberAdminSerializer
|
||||
model = ProjectMember
|
||||
permission_classes = [
|
||||
ProjectMemberPermission,
|
||||
ProjectBasePermission,
|
||||
]
|
||||
|
||||
search_fields = [
|
||||
@@ -618,8 +617,7 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
except ProjectMember.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project Member does not exist"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
{"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
@@ -1211,38 +1209,3 @@ class LeaveProjectEndpoint(BaseAPIView):
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
AllowAny,
|
||||
]
|
||||
|
||||
def get(self, request):
|
||||
try:
|
||||
files = []
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
params = {
|
||||
"Bucket": settings.AWS_S3_BUCKET_NAME,
|
||||
"Prefix": "static/project-cover/",
|
||||
}
|
||||
|
||||
response = s3.list_objects_v2(**params)
|
||||
# Extracting file keys from the response
|
||||
if "Contents" in response:
|
||||
for content in response["Contents"]:
|
||||
if not content["Key"].endswith(
|
||||
"/"
|
||||
): # This line ensures we're only getting files, not "sub-folders"
|
||||
files.append(
|
||||
f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||
)
|
||||
|
||||
return Response(files, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response([], status=status.HTTP_200_OK)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Third party imports
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
# Module imports
|
||||
from .base import BaseAPIView
|
||||
from plane.utils.integrations.github import get_release_notes
|
||||
|
||||
|
||||
class ReleaseNotesEndpoint(BaseAPIView):
|
||||
def get(self, request):
|
||||
try:
|
||||
release_notes = get_release_notes()
|
||||
return Response(release_notes, status=status.HTTP_200_OK)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
return Response(
|
||||
{"error": "Something went wrong please try again later"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
@@ -1197,7 +1197,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
||||
projects = request.query_params.getlist("project", [])
|
||||
|
||||
queryset = IssueActivity.objects.filter(
|
||||
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||
~Q(field__in=["comment", "vote", "reaction"]),
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
actor=user_id,
|
||||
|
||||
@@ -114,6 +114,3 @@ CELERY_BROKER_URL = os.environ.get("REDIS_URL")
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
# Unsplash Access key
|
||||
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||
|
||||
@@ -7,7 +7,6 @@ import dj_database_url
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.redis import RedisIntegration
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
@@ -90,112 +89,90 @@ if bool(os.environ.get("SENTRY_DSN", False)):
|
||||
profiles_sample_rate=1.0,
|
||||
)
|
||||
|
||||
if DOCKERIZED and USE_MINIO:
|
||||
INSTALLED_APPS += ("storages",)
|
||||
STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"}
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||
# The name of the bucket to store files in.
|
||||
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get(
|
||||
"AWS_S3_ENDPOINT_URL", "http://plane-minio:9000"
|
||||
)
|
||||
# Default permissions
|
||||
AWS_DEFAULT_ACL = "public-read"
|
||||
AWS_QUERYSTRING_AUTH = False
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
|
||||
# Custom Domain settings
|
||||
parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost"))
|
||||
AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}"
|
||||
AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
|
||||
else:
|
||||
# The AWS region to connect to.
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||
|
||||
# The AWS access key to use.
|
||||
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "")
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||
|
||||
# The AWS secret access key to use.
|
||||
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "")
|
||||
# The optional AWS session token to use.
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
|
||||
# The optional AWS session token to use.
|
||||
# AWS_SESSION_TOKEN = ""
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
|
||||
# The name of the bucket to store files in.
|
||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
|
||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
||||
|
||||
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "")
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
|
||||
# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator.
|
||||
AWS_S3_KEY_PREFIX = ""
|
||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||
# and their permissions will be set to "public-read".
|
||||
AWS_S3_BUCKET_AUTH = False
|
||||
|
||||
# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication
|
||||
# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token,
|
||||
# and their permissions will be set to "public-read".
|
||||
AWS_S3_BUCKET_AUTH = False
|
||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||
# is True. It also affects the "Cache-Control" header of the files.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||
|
||||
# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH`
|
||||
# is True. It also affects the "Cache-Control" header of the files.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours.
|
||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||
AWS_S3_PUBLIC_URL = ""
|
||||
|
||||
# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting
|
||||
# cannot be used with `AWS_S3_BUCKET_AUTH`.
|
||||
AWS_S3_PUBLIC_URL = ""
|
||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||
# understand the consequences before enabling.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_REDUCED_REDUNDANCY = False
|
||||
|
||||
# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you
|
||||
# understand the consequences before enabling.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_REDUCED_REDUNDANCY = False
|
||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_DISPOSITION = ""
|
||||
|
||||
# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_DISPOSITION = ""
|
||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_LANGUAGE = ""
|
||||
|
||||
# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_CONTENT_LANGUAGE = ""
|
||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_METADATA = {}
|
||||
|
||||
# A mapping of custom metadata for each file. Each value can be a string, or a function taking a
|
||||
# single `name` argument.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_METADATA = {}
|
||||
# If True, then files will be stored using AES256 server-side encryption.
|
||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||
# Otherwise, server-side encryption is not be enabled.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_ENCRYPT_KEY = False
|
||||
|
||||
# If True, then files will be stored using AES256 server-side encryption.
|
||||
# If this is a string value (e.g., "aws:kms"), that encryption type will be used.
|
||||
# Otherwise, server-side encryption is not be enabled.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_ENCRYPT_KEY = False
|
||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||
|
||||
# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present.
|
||||
# This is only relevant if AWS S3 KMS server-side encryption is enabled (above).
|
||||
# AWS_S3_KMS_ENCRYPTION_KEY_ID = ""
|
||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||
# compressed size is smaller than their uncompressed size.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_GZIP = True
|
||||
|
||||
# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their
|
||||
# compressed size is smaller than their uncompressed size.
|
||||
# Important: Changing this setting will not affect existing files.
|
||||
AWS_S3_GZIP = True
|
||||
# The signature version to use for S3 requests.
|
||||
AWS_S3_SIGNATURE_VERSION = None
|
||||
|
||||
# The signature version to use for S3 requests.
|
||||
AWS_S3_SIGNATURE_VERSION = None
|
||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
|
||||
# If True, then files with the same name will overwrite each other. By default it's set to False to have
|
||||
# extra characters appended.
|
||||
AWS_S3_FILE_OVERWRITE = False
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "django_s3_storage.storage.S3Storage",
|
||||
}
|
||||
|
||||
STORAGES["default"] = {
|
||||
"BACKEND": "django_s3_storage.storage.S3Storage",
|
||||
}
|
||||
# AWS Settings End
|
||||
|
||||
# Enable Connection Pooling (if desired)
|
||||
@@ -216,27 +193,16 @@ CSRF_COOKIE_SECURE = True
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL")
|
||||
|
||||
if DOCKERIZED:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
}
|
||||
}
|
||||
else:
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||
},
|
||||
}
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": REDIS_URL,
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so")
|
||||
@@ -259,12 +225,8 @@ broker_url = (
|
||||
f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}"
|
||||
)
|
||||
|
||||
if DOCKERIZED:
|
||||
CELERY_BROKER_URL = REDIS_URL
|
||||
CELERY_RESULT_BACKEND = REDIS_URL
|
||||
else:
|
||||
CELERY_BROKER_URL = broker_url
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_RESULT_BACKEND = broker_url
|
||||
CELERY_BROKER_URL = broker_url
|
||||
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
@@ -276,6 +238,3 @@ SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False)
|
||||
SCOUT_KEY = os.environ.get("SCOUT_KEY", "")
|
||||
SCOUT_NAME = "Plane"
|
||||
|
||||
# Unsplash Access key
|
||||
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||
|
||||
|
||||
@@ -126,4 +126,3 @@ ANALYTICS_BASE_API = False
|
||||
OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1")
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False)
|
||||
GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")
|
||||
|
||||
|
||||
@@ -218,7 +218,3 @@ CELERY_BROKER_URL = broker_url
|
||||
GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False)
|
||||
|
||||
ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1"
|
||||
|
||||
|
||||
# Unsplash Access key
|
||||
UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY")
|
||||
|
||||
+1
-9
@@ -1,16 +1,11 @@
|
||||
{
|
||||
"repository": "https://github.com/makeplane/plane.git",
|
||||
"version": "0.13.2",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"web",
|
||||
"space",
|
||||
"packages/editor/*",
|
||||
"packages/eslint-config-custom",
|
||||
"packages/tailwind-config-custom",
|
||||
"packages/tsconfig",
|
||||
"packages/ui"
|
||||
"packages/*"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
@@ -29,8 +24,5 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"turbo": "latest"
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/react": "18.2.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.19"
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# @plane/editor-core
|
||||
|
||||
## Description
|
||||
|
||||
The `@plane/editor-core` package serves as the foundation for our editor system. It provides the base functionality for our other editor packages, but it will not be used directly in any of the projects but only for extending other editors.
|
||||
|
||||
## Utilities
|
||||
|
||||
We provide a wide range of utilities for extending the core itself.
|
||||
|
||||
1. Merging classes and custom styling
|
||||
2. Adding new extensions
|
||||
3. Adding custom props
|
||||
4. Base menu items, and their commands
|
||||
|
||||
This allows for extensive customization and flexibility in the Editors created using our `editor-core` package.
|
||||
|
||||
### Here's a detailed overview of what's exported
|
||||
|
||||
1. useEditor - A hook that you can use to extend the Plane editor.
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
|
||||
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
|
||||
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
|
||||
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
|
||||
| `value` | `html string` | The initial content of the editor. |
|
||||
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
|
||||
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
|
||||
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
|
||||
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert in case of content not being "saved". |
|
||||
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
|
||||
|
||||
2. useReadOnlyEditor - A hook that can be used to extend a Read Only instance of the core editor.
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `value` | `string` | The initial content of the editor. |
|
||||
| `forwardedRef` | `any` | Pass this in whenever you want to control the editor's state from an external component |
|
||||
| `extensions` | `Extension[]` | An array of custom extensions you want to add into the editor to extend it's core features |
|
||||
| `editorProps` | `EditorProps` | Extend the editor props by passing in a custom props object |
|
||||
|
||||
3. Items and Commands - H1, H2, H3, task list, quote, code block, etc's methods.
|
||||
|
||||
4. UI Wrappers
|
||||
|
||||
- `EditorContainer` - Wrap your Editor Container with this to apply base classes and styles.
|
||||
- `EditorContentWrapper` - Use this to get Editor's Content and base menus.
|
||||
|
||||
5. Extending with Custom Styles
|
||||
|
||||
```ts
|
||||
const customEditorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||
```
|
||||
|
||||
## Core features
|
||||
|
||||
- **Content Trimming**: The Editor’s content is now automatically trimmed of empty line breaks from the start and end before submitting it to the backend. This ensures cleaner, more consistent data.
|
||||
- **Value Cleaning**: The Editor’s value is cleaned at the editor core level, eliminating the need for additional validation before sending from our app. This results in cleaner code and less potential for errors.
|
||||
- **Turbo Pipeline**: Added a turbo pipeline for both dev and build tasks for projects depending on the editor package.
|
||||
|
||||
```json
|
||||
"web#develop": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build"
|
||||
]
|
||||
},
|
||||
"space#develop": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build"
|
||||
]
|
||||
},
|
||||
"web#build": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build"
|
||||
]
|
||||
},
|
||||
"space#build": {
|
||||
"cache": true,
|
||||
"dependsOn": [
|
||||
"@plane/lite-text-editor#build",
|
||||
"@plane/rich-text-editor#build"
|
||||
]
|
||||
},
|
||||
|
||||
```
|
||||
|
||||
## Base extensions included
|
||||
|
||||
- BulletList
|
||||
- OrderedList
|
||||
- Blockquote
|
||||
- Code
|
||||
- Gapcursor
|
||||
- Link
|
||||
- Image
|
||||
- Basic Marks
|
||||
- Underline
|
||||
- TextStyle
|
||||
- Color
|
||||
- TaskList
|
||||
- Markdown
|
||||
- Table
|
||||
@@ -1,74 +0,0 @@
|
||||
{
|
||||
"name": "@plane/editor-core",
|
||||
"version": "0.0.1",
|
||||
"description": "Core Editor that powers Plane",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"next": "12.3.2",
|
||||
"next-themes": "^0.2.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@blueprintjs/popover2": "^2.0.10",
|
||||
"@tiptap/core": "^2.1.7",
|
||||
"@tiptap/extension-color": "^2.1.11",
|
||||
"@tiptap/extension-image": "^2.1.7",
|
||||
"@tiptap/extension-link": "^2.1.7",
|
||||
"@tiptap/extension-task-item": "^2.1.7",
|
||||
"@tiptap/extension-task-list": "^2.1.7",
|
||||
"@tiptap/extension-text-style": "^2.1.11",
|
||||
"@tiptap/extension-underline": "^2.1.7",
|
||||
"@tiptap/pm": "^2.1.7",
|
||||
"@tiptap/react": "^2.1.7",
|
||||
"@tiptap/starter-kit": "^2.1.10",
|
||||
"@types/react": "^18.2.5",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/node": "18.15.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"lucide-react": "^0.244.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"use-debounce": "^9.0.4",
|
||||
"@tiptap/prosemirror-tables": "^1.1.4",
|
||||
"jsx-dom-cjs": "^8.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"keywords": [
|
||||
"editor",
|
||||
"rich-text",
|
||||
"markdown",
|
||||
"nextjs",
|
||||
"react"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// If you want to use other PostCSS plugins, see the following:
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
// styles
|
||||
// import "./styles/tailwind.css";
|
||||
// import "./styles/editor.css";
|
||||
|
||||
export * from "./ui/extensions/table/table";
|
||||
|
||||
// utils
|
||||
export * from "./lib/utils";
|
||||
export { startImageUpload } from "./ui/plugins/upload-image";
|
||||
|
||||
// components
|
||||
export { EditorContainer } from "./ui/components/editor-container";
|
||||
export { EditorContentWrapper } from "./ui/components/editor-content";
|
||||
|
||||
// hooks
|
||||
export { useEditor } from "./ui/hooks/useEditor";
|
||||
export { useReadOnlyEditor } from "./ui/hooks/useReadOnlyEditor";
|
||||
|
||||
// helper items
|
||||
export * from "./ui/menus/menu-items";
|
||||
export * from "./lib/editor-commands";
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Editor, Range } from "@tiptap/core";
|
||||
import { UploadImage } from "../types/upload-image";
|
||||
import { startImageUpload } from "../ui/plugins/upload-image";
|
||||
|
||||
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 1 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
};
|
||||
|
||||
export const toggleHeadingTwo = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 2 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
};
|
||||
|
||||
export const toggleHeadingThree = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("heading", { level: 3 }).run();
|
||||
else editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
};
|
||||
|
||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
else editor.chain().focus().toggleBold().run();
|
||||
};
|
||||
|
||||
export const toggleItalic = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||
else editor.chain().focus().toggleItalic().run();
|
||||
};
|
||||
|
||||
export const toggleUnderline = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleUnderline().run();
|
||||
else editor.chain().focus().toggleUnderline().run();
|
||||
};
|
||||
|
||||
export const toggleCode = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleCode().run();
|
||||
else editor.chain().focus().toggleCode().run();
|
||||
};
|
||||
export const toggleOrderedList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||
else editor.chain().focus().toggleOrderedList().run();
|
||||
};
|
||||
|
||||
export const toggleBulletList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||
else editor.chain().focus().toggleBulletList().run();
|
||||
};
|
||||
|
||||
export const toggleTaskList = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||
else editor.chain().focus().toggleTaskList().run()
|
||||
};
|
||||
|
||||
export const toggleStrike = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleStrike().run();
|
||||
else editor.chain().focus().toggleStrike().run();
|
||||
};
|
||||
|
||||
export const toggleBlockquote = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleNode("paragraph", "paragraph").toggleBlockquote().run();
|
||||
else editor.chain().focus().toggleNode("paragraph", "paragraph").toggleBlockquote().run();
|
||||
};
|
||||
|
||||
export const insertTableCommand = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
};
|
||||
|
||||
export const unsetLinkEditor = (editor: Editor) => {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
};
|
||||
|
||||
export const setLinkEditor = (editor: Editor, url: string) => {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
};
|
||||
|
||||
export const insertImageCommand = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).run();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "image/*";
|
||||
input.onchange = async () => {
|
||||
if (input.files?.length) {
|
||||
const file = input.files[0];
|
||||
const pos = editor.view.state.selection.from;
|
||||
startImageUpload(file, editor.view, pos, uploadFile, setIsSubmitting);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
interface EditorClassNames {
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
}
|
||||
|
||||
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) => cn(
|
||||
'relative w-full max-w-full sm:rounded-lg mt-2 p-3 relative focus:outline-none rounded-md',
|
||||
noBorder ? '' : 'border border-custom-border-200',
|
||||
borderOnFocus ? 'focus:border border-custom-border-300' : 'focus:border-0',
|
||||
customClassName
|
||||
);
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export const findTableAncestor = (
|
||||
node: Node | null
|
||||
): HTMLTableElement | null => {
|
||||
while (node !== null && node.nodeName !== "TABLE") {
|
||||
node = node.parentNode;
|
||||
}
|
||||
return node as HTMLTableElement;
|
||||
};
|
||||
|
||||
export const getTrimmedHTML = (html: string) => {
|
||||
html = html.replace(/^(<p><\/p>)+/, '');
|
||||
html = html.replace(/(<p><\/p>)+$/, '');
|
||||
return html;
|
||||
}
|
||||
|
||||
export const isValidHttpUrl = (string: string): boolean => {
|
||||
let url: URL;
|
||||
|
||||
try {
|
||||
url = new URL(string);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return url.protocol === "http:" || url.protocol === "https:";
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.ProseMirror .is-empty::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: rgb(var(--color-text-400));
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* Custom image styles */
|
||||
|
||||
.ProseMirror img {
|
||||
transition: filter 0.1s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
|
||||
&.ProseMirror-selectednode {
|
||||
outline: 3px solid #5abbf7;
|
||||
filter: brightness(90%);
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-gapcursor:after {
|
||||
border-top: 1px solid rgb(var(--color-text-100)) !important;
|
||||
}
|
||||
|
||||
/* Custom TODO list checkboxes – shoutout to this awesome tutorial: https://moderncss.dev/pure-css-custom-checkbox-style/ */
|
||||
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.2rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
ul[data-type="taskList"] li > label {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background-color: rgb(var(--color-background-100));
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
position: relative;
|
||||
border: 2px solid rgb(var(--color-text-100));
|
||||
margin-right: 0.3rem;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(var(--color-background-80));
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: rgb(var(--color-background-90));
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
width: 0.65em;
|
||||
height: 0.65em;
|
||||
transform: scale(0);
|
||||
transition: 120ms transform ease-in-out;
|
||||
box-shadow: inset 1em 1em;
|
||||
transform-origin: center;
|
||||
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||
}
|
||||
|
||||
&:checked::before {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||
color: rgb(var(--color-text-200));
|
||||
text-decoration: line-through;
|
||||
text-decoration-thickness: 2px;
|
||||
}
|
||||
|
||||
/* Overwrite tippy-box original max-width */
|
||||
|
||||
.tippy-box {
|
||||
max-width: 400px !important;
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
outline: none;
|
||||
cursor: text;
|
||||
line-height: 1.2;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
appearance: textfield;
|
||||
-webkit-appearance: textfield;
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.fadeIn {
|
||||
opacity: 1;
|
||||
transition: opacity 0.3s ease-in;
|
||||
}
|
||||
|
||||
.fadeOut {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
.img-placeholder {
|
||||
position: relative;
|
||||
width: 35%;
|
||||
|
||||
&:before {
|
||||
content: "";
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 45%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(var(--color-text-200));
|
||||
border-top-color: rgba(var(--color-text-800));
|
||||
animation: spinning 0.6s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinning {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#editor-container {
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
margin: 0;
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
width: 100%;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 1px solid rgb(var(--color-border-200));
|
||||
padding: 10px 15px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: rgb(var(--color-primary-100));
|
||||
}
|
||||
|
||||
td:hover {
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: "";
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(var(--color-primary-300), 0.1);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 2px;
|
||||
background-color: rgb(var(--color-primary-400));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* .tableWrapper { */
|
||||
/* overflow-x: auto; */
|
||||
/* } */
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.ProseMirror table * p {
|
||||
padding: 0px 1px;
|
||||
margin: 6px 2px;
|
||||
}
|
||||
|
||||
.ProseMirror table * .is-empty::before {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@@ -1 +0,0 @@
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
@@ -1 +0,0 @@
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface EditorContainerProps {
|
||||
editor: Editor | null;
|
||||
editorClassNames: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const EditorContainer = ({ editor, editorClassNames, children }: EditorContainerProps) => (
|
||||
<div
|
||||
id="editor-container"
|
||||
onClick={() => {
|
||||
editor?.chain().focus().run();
|
||||
}}
|
||||
className={`cursor-text editorContainer ${editorClassNames}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
import { Editor, EditorContent } from "@tiptap/react";
|
||||
import { ReactNode } from "react";
|
||||
import { ImageResizer } from "../extensions/image/image-resize";
|
||||
|
||||
interface EditorContentProps {
|
||||
editor: Editor | null;
|
||||
editorContentCustomClassNames: string | undefined;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = '', children }: EditorContentProps) => (
|
||||
<div className={`contentEditor ${editorContentCustomClassNames}`}>
|
||||
<EditorContent editor={editor} />
|
||||
{editor?.isActive("image") && <ImageResizer editor={editor} />}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import TrackImageDeletionPlugin from "../../plugins/delete-image";
|
||||
import UploadImagesPlugin from "../../plugins/upload-image";
|
||||
import { DeleteImage } from "../../../types/delete-image";
|
||||
|
||||
const ImageExtension = (deleteImage: DeleteImage) => Image.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [UploadImagesPlugin(), TrackImageDeletionPlugin(deleteImage)];
|
||||
},
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default ImageExtension;
|
||||
@@ -1,17 +0,0 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
|
||||
const ReadOnlyImageExtension = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
width: {
|
||||
default: "35%",
|
||||
},
|
||||
height: {
|
||||
default: null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export default ReadOnlyImageExtension;
|
||||
@@ -1,95 +0,0 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||
|
||||
import ImageExtension from "./image";
|
||||
|
||||
import { DeleteImage } from "../../types/delete-image";
|
||||
import { isValidHttpUrl } from "../../lib/utils";
|
||||
import TableHeader from "./table/table-header/table-header";
|
||||
import Table from "./table/table";
|
||||
import TableCell from "./table/table-cell/table-cell";
|
||||
import TableRow from "./table/table-row/table-row";
|
||||
|
||||
export const CoreEditorExtensions = (
|
||||
deleteFile: DeleteImage,
|
||||
) => [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc list-outside leading-3 -mt-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal list-outside leading-3 -mt-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "leading-normal -mb-2",
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: "border-l-4 border-custom-border-300",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
},
|
||||
gapcursor: false,
|
||||
}),
|
||||
Gapcursor,
|
||||
TiptapLink.configure({
|
||||
protocols: ["http", "https"],
|
||||
validate: (url) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ImageExtension(deleteFile).configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "flex items-start my-4",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
Table,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TableRow,
|
||||
];
|
||||
@@ -1 +0,0 @@
|
||||
export { default as default } from "./table-cell"
|
||||
@@ -1,58 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
|
||||
export interface TableCellOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
export default Node.create<TableCellOptions>({
|
||||
name: "tableCell",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
|
||||
content: "paragraph+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1
|
||||
},
|
||||
rowspan: {
|
||||
default: 1
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth")
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null
|
||||
|
||||
return value
|
||||
}
|
||||
},
|
||||
background: {
|
||||
default: "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
tableRole: "cell",
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "td" }]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"td",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}`
|
||||
}),
|
||||
0
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export { default as default } from "./table-header"
|
||||
@@ -1,57 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
|
||||
export interface TableHeaderOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
export default Node.create<TableHeaderOptions>({
|
||||
name: "tableHeader",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
|
||||
content: "paragraph+",
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
colspan: {
|
||||
default: 1
|
||||
},
|
||||
rowspan: {
|
||||
default: 1
|
||||
},
|
||||
colwidth: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const colwidth = element.getAttribute("colwidth")
|
||||
const value = colwidth ? [parseInt(colwidth, 10)] : null
|
||||
|
||||
return value
|
||||
}
|
||||
},
|
||||
background: {
|
||||
default: "rgb(var(--color-primary-100))"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
tableRole: "header_cell",
|
||||
|
||||
isolating: true,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "th" }]
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"th",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
|
||||
style: `background-color: ${node.attrs.background}`
|
||||
}),
|
||||
0
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -1 +0,0 @@
|
||||
export { default as default } from "./table-row"
|
||||
@@ -1,31 +0,0 @@
|
||||
import { mergeAttributes, Node } from "@tiptap/core"
|
||||
|
||||
export interface TableRowOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
}
|
||||
|
||||
export default Node.create<TableRowOptions>({
|
||||
name: "tableRow",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {}
|
||||
}
|
||||
},
|
||||
|
||||
content: "(tableCell | tableHeader)*",
|
||||
|
||||
tableRole: "row",
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "tr" }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"tr",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
0
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -1,11 +0,0 @@
|
||||
const icons = {
|
||||
insertColumnLeft: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1h-6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2h-4v14h4V5zM6 7c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2H5v1.999L3 11v2l2-.001V15h2v-2.001L9 13v-2l-2-.001V9z"/></svg>`,
|
||||
insertColumnRight: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M10 3c.552 0 1 .448 1 1v16c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zM9 5H5v14h4V5zm9 2c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L15 11v2l2-.001V15h2v-2.001L21 13v-2l-2-.001V9z"/></svg>`,
|
||||
insertRowTop: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 13c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1v-6c0-.552.448-1 1-1h16zm-1 2H5v4h14v-4zM12 1c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L9 5v2l2-.001V9h2V6.999L15 7V5l-2-.001V3z"/></svg>`,
|
||||
insertRowBottom: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M12 13c2.761 0 5 2.239 5 5s-2.239 5-5 5-5-2.239-5-5 2.239-5 5-5zm1 2h-2v1.999L9 17v2l2-.001V21h2v-2.001L15 19v-2l-2-.001V15zm7-12c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1H4c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h16zM5 5v4h14V5H5z"/></svg>`,
|
||||
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="fill: inherit;transform: ;msFilter:;"><path d="M20 14c-.092.064-2 2.083-2 3.5 0 1.494.949 2.448 2 2.5.906.044 2-.891 2-2.5 0-1.5-1.908-3.436-2-3.5zM9.586 20c.378.378.88.586 1.414.586s1.036-.208 1.414-.586l7-7-.707-.707L11 4.586 8.707 2.293 7.293 3.707 9.586 6 4 11.586c-.378.378-.586.88-.586 1.414s.208 1.036.586 1.414L9.586 20zM11 7.414 16.586 13H5.414L11 7.414z"></path></svg>`,
|
||||
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M12 3c.552 0 1 .448 1 1v8c.835-.628 1.874-1 3-1 2.761 0 5 2.239 5 5s-2.239 5-5 5c-1.032 0-1.99-.313-2.787-.848L13 20c0 .552-.448 1-1 1H6c-.552 0-1-.448-1-1V4c0-.552.448-1 1-1h6zm-1 2H7v14h4V5zm8 10h-6v2h6v-2z"/></svg>`,
|
||||
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0H24V24H0z"/><path d="M20 5c.552 0 1 .448 1 1v6c0 .552-.448 1-1 1 .628.835 1 1.874 1 3 0 2.761-2.239 5-5 5s-5-2.239-5-5c0-1.126.372-2.165 1-3H4c-.552 0-1-.448-1-1V6c0-.552.448-1 1-1h16zm-7 10v2h6v-2h-6zm6-8H5v4h14V7z"/></svg>`
|
||||
}
|
||||
|
||||
export default icons
|
||||
@@ -1 +0,0 @@
|
||||
export { default as default } from "./table"
|
||||
@@ -1,118 +0,0 @@
|
||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state"
|
||||
import { findParentNode } from "@tiptap/core"
|
||||
import { DecorationSet, Decoration } from "@tiptap/pm/view"
|
||||
|
||||
const key = new PluginKey("tableControls")
|
||||
|
||||
export function tableControls() {
|
||||
return new Plugin({
|
||||
key,
|
||||
state: {
|
||||
init() {
|
||||
return new TableControlsState()
|
||||
},
|
||||
apply(tr, prev) {
|
||||
return prev.apply(tr)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousemove: (view, event) => {
|
||||
const pluginState = key.getState(view.state)
|
||||
|
||||
if (
|
||||
!(event.target as HTMLElement).closest(
|
||||
".tableWrapper"
|
||||
) &&
|
||||
pluginState.values.hoveredTable
|
||||
) {
|
||||
return view.dispatch(
|
||||
view.state.tr.setMeta(key, {
|
||||
setHoveredTable: null,
|
||||
setHoveredCell: null
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const pos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY
|
||||
})
|
||||
|
||||
if (!pos) 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"
|
||||
)(TextSelection.create(view.state.doc, pos.pos))
|
||||
|
||||
if (!table || !cell) return
|
||||
|
||||
if (pluginState.values.hoveredCell?.pos !== cell.pos) {
|
||||
return view.dispatch(
|
||||
view.state.tr.setMeta(key, {
|
||||
setHoveredTable: table,
|
||||
setHoveredCell: cell
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
decorations: (state) => {
|
||||
const pluginState = key.getState(state)
|
||||
if (!pluginState) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { hoveredTable, hoveredCell } = pluginState.values
|
||||
if (hoveredTable) {
|
||||
const decorations = [
|
||||
Decoration.node(
|
||||
hoveredTable.pos,
|
||||
hoveredTable.pos + hoveredTable.node.nodeSize,
|
||||
{},
|
||||
{
|
||||
hoveredTable,
|
||||
hoveredCell
|
||||
}
|
||||
)
|
||||
]
|
||||
|
||||
return DecorationSet.create(state.doc, decorations)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
class TableControlsState {
|
||||
values
|
||||
|
||||
constructor(props = {}) {
|
||||
this.values = {
|
||||
hoveredTable: null,
|
||||
hoveredCell: null,
|
||||
...props
|
||||
}
|
||||
}
|
||||
|
||||
apply(tr: any) {
|
||||
const actions = tr.getMeta(key)
|
||||
|
||||
if (actions?.setHoveredTable !== undefined) {
|
||||
this.values.hoveredTable = actions.setHoveredTable
|
||||
}
|
||||
|
||||
if (actions?.setHoveredCell !== undefined) {
|
||||
this.values.hoveredCell = actions.setHoveredCell
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -1,500 +0,0 @@
|
||||
import { h } from "jsx-dom-cjs"
|
||||
import { Node as ProseMirrorNode } from "@tiptap/pm/model"
|
||||
import { Decoration, NodeView } from "@tiptap/pm/view"
|
||||
import tippy, { Instance, Props } from "tippy.js"
|
||||
|
||||
import { Editor } from "@tiptap/core"
|
||||
import {
|
||||
CellSelection,
|
||||
TableMap,
|
||||
updateColumnsOnResize
|
||||
} from "@tiptap/prosemirror-tables"
|
||||
|
||||
import icons from "./icons"
|
||||
|
||||
export function updateColumns(
|
||||
node: ProseMirrorNode,
|
||||
colgroup: HTMLElement,
|
||||
table: HTMLElement,
|
||||
cellMinWidth: number,
|
||||
overrideCol?: number,
|
||||
overrideValue?: any
|
||||
) {
|
||||
let totalWidth = 0
|
||||
let fixedWidth = true
|
||||
let nextDOM = colgroup.firstChild as HTMLElement
|
||||
const row = node.firstChild
|
||||
|
||||
if (!row) return
|
||||
|
||||
for (let i = 0, col = 0; i < row.childCount; i += 1) {
|
||||
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 cssWidth = hasWidth ? `${hasWidth}px` : ""
|
||||
|
||||
totalWidth += hasWidth || cellMinWidth
|
||||
|
||||
if (!hasWidth) {
|
||||
fixedWidth = false
|
||||
}
|
||||
|
||||
if (!nextDOM) {
|
||||
colgroup.appendChild(
|
||||
document.createElement("col")
|
||||
).style.width = cssWidth
|
||||
} else {
|
||||
if (nextDOM.style.width !== cssWidth) {
|
||||
nextDOM.style.width = cssWidth
|
||||
}
|
||||
|
||||
nextDOM = nextDOM.nextSibling as HTMLElement
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (nextDOM) {
|
||||
const after = nextDOM.nextSibling
|
||||
|
||||
nextDOM.parentNode?.removeChild(nextDOM)
|
||||
nextDOM = after as HTMLElement
|
||||
}
|
||||
|
||||
if (fixedWidth) {
|
||||
table.style.width = `${totalWidth}px`
|
||||
table.style.minWidth = ""
|
||||
} else {
|
||||
table.style.width = ""
|
||||
table.style.minWidth = `${totalWidth}px`
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTippyOptions: Partial<Props> = {
|
||||
allowHTML: true,
|
||||
arrow: false,
|
||||
trigger: "click",
|
||||
animation: "scale-subtle",
|
||||
theme: "light-border no-padding",
|
||||
interactive: true,
|
||||
hideOnClick: true,
|
||||
placement: "right"
|
||||
}
|
||||
|
||||
function setCellsBackgroundColor(editor: Editor, backgroundColor) {
|
||||
return editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes("tableCell", {
|
||||
background: backgroundColor
|
||||
})
|
||||
.updateAttributes("tableHeader", {
|
||||
background: backgroundColor
|
||||
})
|
||||
.run()
|
||||
}
|
||||
|
||||
const columnsToolboxItems = [
|
||||
{
|
||||
label: "Add Column Before",
|
||||
icon: icons.insertColumnLeft,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run()
|
||||
},
|
||||
{
|
||||
label: "Add Column After",
|
||||
icon: icons.insertColumnRight,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run()
|
||||
},
|
||||
{
|
||||
label: "Pick Column Color",
|
||||
icon: icons.colorPicker,
|
||||
action: ({ editor, triggerButton, controlsContainer }: { editor: Editor, triggerButton: HTMLElement, controlsContainer }) => {
|
||||
createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions: {
|
||||
appendTo: controlsContainer
|
||||
},
|
||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Delete Column",
|
||||
icon: icons.deleteColumn,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run()
|
||||
}
|
||||
]
|
||||
|
||||
const rowsToolboxItems = [
|
||||
{
|
||||
label: "Add Row Above",
|
||||
icon: icons.insertRowTop,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run()
|
||||
},
|
||||
{
|
||||
label: "Add Row Below",
|
||||
icon: icons.insertRowBottom,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run()
|
||||
},
|
||||
{
|
||||
label: "Pick a Color",
|
||||
icon: icons.colorPicker,
|
||||
action: ({ editor, triggerButton, controlsContainer }: { editor: Editor, triggerButton: HTMLButtonElement, controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined }) => {
|
||||
createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions: {
|
||||
appendTo: controlsContainer
|
||||
},
|
||||
onSelectColor: (color) => setCellsBackgroundColor(editor, color)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
label: "Delete Row",
|
||||
icon: icons.deleteRow,
|
||||
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run()
|
||||
}
|
||||
]
|
||||
|
||||
function createToolbox({
|
||||
triggerButton,
|
||||
items,
|
||||
tippyOptions,
|
||||
onClickItem
|
||||
}: { triggerButton: HTMLElement, items: { icon: string, label: string }[], tippyOptions: any, onClickItem: any }): Instance<Props> {
|
||||
const toolbox = tippy(triggerButton, {
|
||||
content: h(
|
||||
"div",
|
||||
{ className: "tableToolbox" },
|
||||
items.map((item) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
onClick() {
|
||||
onClickItem(item)
|
||||
}
|
||||
},
|
||||
[
|
||||
h("div", {
|
||||
className: "iconContainer",
|
||||
innerHTML: item.icon
|
||||
}),
|
||||
h("div", { className: "label" }, item.label)
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
...tippyOptions
|
||||
})
|
||||
|
||||
return Array.isArray(toolbox) ? toolbox[0] : toolbox
|
||||
}
|
||||
|
||||
function createColorPickerToolbox({
|
||||
triggerButton,
|
||||
tippyOptions,
|
||||
onSelectColor = () => { }
|
||||
}: {
|
||||
triggerButton: HTMLElement
|
||||
tippyOptions: Partial<Props>
|
||||
onSelectColor?: (color: string) => void
|
||||
}) {
|
||||
const items = {
|
||||
"Default": "rgb(var(--color-primary-100))",
|
||||
"Light gray": "#e7f3f8",
|
||||
"Dark gray": "#c7d2d7",
|
||||
"Light blue": "#e7f3f8",
|
||||
"Light red": "#ffc4c7",
|
||||
"Light yellow": "#fbf3db"
|
||||
}
|
||||
|
||||
const colorPicker = tippy(triggerButton, {
|
||||
...defaultTippyOptions,
|
||||
content: h(
|
||||
"div",
|
||||
{ className: "tableColorPickerToolbox" },
|
||||
Object.entries(items).map(([key, value]) =>
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "toolboxItem",
|
||||
onClick: () => {
|
||||
onSelectColor(value)
|
||||
colorPicker.hide()
|
||||
}
|
||||
},
|
||||
[
|
||||
h("div", {
|
||||
className: "colorContainer",
|
||||
style: {
|
||||
backgroundColor: value
|
||||
}
|
||||
}),
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
className: "label"
|
||||
},
|
||||
key
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
),
|
||||
onHidden: (instance) => {
|
||||
instance.destroy()
|
||||
},
|
||||
showOnCreate: true,
|
||||
...tippyOptions
|
||||
})
|
||||
|
||||
return colorPicker
|
||||
}
|
||||
|
||||
export class TableView implements NodeView {
|
||||
node: ProseMirrorNode
|
||||
cellMinWidth: number
|
||||
decorations: Decoration[]
|
||||
editor: Editor
|
||||
getPos: () => number
|
||||
hoveredCell
|
||||
map: TableMap
|
||||
root: HTMLElement
|
||||
table: HTMLElement
|
||||
colgroup: HTMLElement
|
||||
tbody: HTMLElement
|
||||
rowsControl?: HTMLElement
|
||||
columnsControl?: HTMLElement
|
||||
columnsToolbox?: Instance<Props>
|
||||
rowsToolbox?: Instance<Props>
|
||||
controls?: HTMLElement
|
||||
|
||||
get dom() {
|
||||
return this.root
|
||||
}
|
||||
|
||||
get contentDOM() {
|
||||
return this.tbody
|
||||
}
|
||||
|
||||
constructor(
|
||||
node: ProseMirrorNode,
|
||||
cellMinWidth: number,
|
||||
decorations: Decoration[],
|
||||
editor: Editor,
|
||||
getPos: () => number
|
||||
) {
|
||||
this.node = node
|
||||
this.cellMinWidth = cellMinWidth
|
||||
this.decorations = decorations
|
||||
this.editor = editor
|
||||
this.getPos = getPos
|
||||
this.hoveredCell = null
|
||||
this.map = TableMap.get(node)
|
||||
|
||||
/**
|
||||
* DOM
|
||||
*/
|
||||
|
||||
// Controllers
|
||||
if (editor.isEditable) {
|
||||
this.rowsControl = h(
|
||||
"div",
|
||||
{ className: "rowsControl" },
|
||||
h("button", {
|
||||
onClick: () => this.selectRow()
|
||||
})
|
||||
)
|
||||
|
||||
this.columnsControl = h(
|
||||
"div",
|
||||
{ className: "columnsControl" },
|
||||
h("button", {
|
||||
onClick: () => this.selectColumn()
|
||||
})
|
||||
)
|
||||
|
||||
this.controls = h(
|
||||
"div",
|
||||
{ className: "tableControls", contentEditable: "false" },
|
||||
this.rowsControl,
|
||||
this.columnsControl
|
||||
)
|
||||
|
||||
this.columnsToolbox = createToolbox({
|
||||
triggerButton: this.columnsControl.querySelector("button"),
|
||||
items: columnsToolboxItems,
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls
|
||||
},
|
||||
onClickItem: (item) => {
|
||||
item.action({
|
||||
editor: this.editor,
|
||||
triggerButton: this.columnsControl?.firstElementChild,
|
||||
controlsContainer: this.controls
|
||||
})
|
||||
this.columnsToolbox?.hide()
|
||||
}
|
||||
})
|
||||
|
||||
this.rowsToolbox = createToolbox({
|
||||
triggerButton: this.rowsControl.firstElementChild,
|
||||
items: rowsToolboxItems,
|
||||
tippyOptions: {
|
||||
...defaultTippyOptions,
|
||||
appendTo: this.controls
|
||||
},
|
||||
onClickItem: (item) => {
|
||||
item.action({
|
||||
editor: this.editor,
|
||||
triggerButton: this.rowsControl?.firstElementChild,
|
||||
controlsContainer: this.controls
|
||||
})
|
||||
this.rowsToolbox?.hide()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Table
|
||||
|
||||
this.colgroup = h(
|
||||
"colgroup",
|
||||
null,
|
||||
Array.from({ length: this.map.width }, () => 1).map(() => h("col"))
|
||||
)
|
||||
this.tbody = h("tbody")
|
||||
this.table = h("table", null, this.colgroup, this.tbody)
|
||||
|
||||
this.root = h(
|
||||
"div",
|
||||
{
|
||||
className: "tableWrapper controls--disabled"
|
||||
},
|
||||
this.controls,
|
||||
this.table
|
||||
)
|
||||
|
||||
this.render()
|
||||
}
|
||||
|
||||
update(node: ProseMirrorNode, decorations) {
|
||||
if (node.type !== this.node.type) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.node = node
|
||||
this.decorations = decorations
|
||||
this.map = TableMap.get(this.node)
|
||||
|
||||
if (this.editor.isEditable) {
|
||||
this.updateControls()
|
||||
}
|
||||
|
||||
this.render()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.colgroup.children.length !== this.map.width) {
|
||||
const cols = Array.from({ length: this.map.width }, () => 1).map(
|
||||
() => h("col")
|
||||
)
|
||||
this.colgroup.replaceChildren(...cols)
|
||||
}
|
||||
|
||||
updateColumnsOnResize(
|
||||
this.node,
|
||||
this.colgroup,
|
||||
this.table,
|
||||
this.cellMinWidth
|
||||
)
|
||||
}
|
||||
|
||||
ignoreMutation() {
|
||||
return true
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
const { hoveredTable: table, hoveredCell: cell } = Object.values(
|
||||
this.decorations
|
||||
).reduce((acc, curr) => {
|
||||
if (curr.spec.hoveredCell !== undefined) {
|
||||
acc["hoveredCell"] = curr.spec.hoveredCell
|
||||
}
|
||||
|
||||
if (curr.spec.hoveredTable !== undefined) {
|
||||
acc["hoveredTable"] = curr.spec.hoveredTable
|
||||
}
|
||||
return acc
|
||||
}, {}) as any
|
||||
|
||||
if (table === undefined || cell === undefined) {
|
||||
return this.root.classList.add("controls--disabled")
|
||||
}
|
||||
|
||||
this.root.classList.remove("controls--disabled")
|
||||
this.hoveredCell = cell
|
||||
|
||||
const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement
|
||||
|
||||
const tableRect = this.table.getBoundingClientRect()
|
||||
const cellRect = cellDom.getBoundingClientRect()
|
||||
|
||||
this.columnsControl.style.left = `${cellRect.left -
|
||||
tableRect.left -
|
||||
this.table.parentElement!.scrollLeft
|
||||
}px`
|
||||
this.columnsControl.style.width = `${cellRect.width}px`
|
||||
|
||||
this.rowsControl.style.top = `${cellRect.top - tableRect.top}px`
|
||||
this.rowsControl.style.height = `${cellRect.height}px`
|
||||
}
|
||||
|
||||
selectColumn() {
|
||||
if (!this.hoveredCell) return
|
||||
|
||||
const colIndex = this.map.colCount(
|
||||
this.hoveredCell.pos - (this.getPos() + 1)
|
||||
)
|
||||
const anchorCellPos = this.hoveredCell.pos
|
||||
const headCellPos =
|
||||
this.map.map[colIndex + this.map.width * (this.map.height - 1)] +
|
||||
(this.getPos() + 1)
|
||||
|
||||
const cellSelection = CellSelection.create(
|
||||
this.editor.view.state.doc,
|
||||
anchorCellPos,
|
||||
headCellPos
|
||||
)
|
||||
this.editor.view.dispatch(
|
||||
// @ts-ignore
|
||||
this.editor.state.tr.setSelection(cellSelection)
|
||||
)
|
||||
}
|
||||
|
||||
selectRow() {
|
||||
if (!this.hoveredCell) return
|
||||
|
||||
const anchorCellPos = this.hoveredCell.pos
|
||||
const anchorCellIndex = this.map.map.indexOf(
|
||||
anchorCellPos - (this.getPos() + 1)
|
||||
)
|
||||
const headCellPos =
|
||||
this.map.map[anchorCellIndex + (this.map.width - 1)] +
|
||||
(this.getPos() + 1)
|
||||
|
||||
const cellSelection = CellSelection.create(
|
||||
this.editor.state.doc,
|
||||
anchorCellPos,
|
||||
headCellPos
|
||||
)
|
||||
this.editor.view.dispatch(
|
||||
// @ts-ignore
|
||||
this.editor.view.state.tr.setSelection(cellSelection)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
import { TextSelection } from "@tiptap/pm/state"
|
||||
|
||||
import { callOrReturn, getExtensionField, mergeAttributes, Node, ParentConfig } from "@tiptap/core"
|
||||
import {
|
||||
addColumnAfter,
|
||||
addColumnBefore,
|
||||
addRowAfter,
|
||||
addRowBefore,
|
||||
CellSelection,
|
||||
columnResizing,
|
||||
deleteColumn,
|
||||
deleteRow,
|
||||
deleteTable,
|
||||
fixTables,
|
||||
goToNextCell,
|
||||
mergeCells,
|
||||
setCellAttr,
|
||||
splitCell,
|
||||
tableEditing,
|
||||
toggleHeader,
|
||||
toggleHeaderCell
|
||||
} from "@tiptap/prosemirror-tables"
|
||||
|
||||
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"
|
||||
|
||||
export interface TableOptions {
|
||||
HTMLAttributes: Record<string, any>
|
||||
resizable: boolean
|
||||
handleWidth: number
|
||||
cellMinWidth: number
|
||||
lastColumnResizable: boolean
|
||||
allowTableNodeSelection: boolean
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
table: {
|
||||
insertTable: (options?: {
|
||||
rows?: number
|
||||
cols?: number
|
||||
withHeaderRow?: boolean
|
||||
}) => ReturnType
|
||||
addColumnBefore: () => ReturnType
|
||||
addColumnAfter: () => ReturnType
|
||||
deleteColumn: () => ReturnType
|
||||
addRowBefore: () => ReturnType
|
||||
addRowAfter: () => ReturnType
|
||||
deleteRow: () => ReturnType
|
||||
deleteTable: () => ReturnType
|
||||
mergeCells: () => ReturnType
|
||||
splitCell: () => ReturnType
|
||||
toggleHeaderColumn: () => ReturnType
|
||||
toggleHeaderRow: () => ReturnType
|
||||
toggleHeaderCell: () => ReturnType
|
||||
mergeOrSplit: () => ReturnType
|
||||
setCellAttribute: (name: string, value: any) => ReturnType
|
||||
goToNextCell: () => ReturnType
|
||||
goToPreviousCell: () => ReturnType
|
||||
fixTables: () => ReturnType
|
||||
setCellSelection: (position: {
|
||||
anchorCell: number
|
||||
headCell?: number
|
||||
}) => ReturnType
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeConfig<Options, Storage> {
|
||||
tableRole?:
|
||||
| string
|
||||
| ((this: {
|
||||
name: string
|
||||
options: Options
|
||||
storage: Storage
|
||||
parent: ParentConfig<NodeConfig<Options>>["tableRole"]
|
||||
}) => string)
|
||||
}
|
||||
}
|
||||
|
||||
export default Node.create({
|
||||
name: "table",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
resizable: true,
|
||||
handleWidth: 5,
|
||||
cellMinWidth: 100,
|
||||
lastColumnResizable: true,
|
||||
allowTableNodeSelection: true
|
||||
}
|
||||
},
|
||||
|
||||
content: "tableRow+",
|
||||
|
||||
tableRole: "table",
|
||||
|
||||
isolating: true,
|
||||
|
||||
group: "block",
|
||||
|
||||
allowGapCursor: false,
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: "table" }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"table",
|
||||
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes),
|
||||
["tbody", 0]
|
||||
]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertTable:
|
||||
({ rows = 3, cols = 3, withHeaderRow = true} = {}) =>
|
||||
({ tr, dispatch, editor }) => {
|
||||
const node = createTable(
|
||||
editor.schema,
|
||||
rows,
|
||||
cols,
|
||||
withHeaderRow
|
||||
)
|
||||
|
||||
if (dispatch) {
|
||||
const offset = tr.selection.anchor + 1
|
||||
|
||||
tr.replaceSelectionWith(node)
|
||||
.scrollIntoView()
|
||||
.setSelection(
|
||||
TextSelection.near(tr.doc.resolve(offset))
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
addColumnBefore:
|
||||
() =>
|
||||
({ state, dispatch }) => addColumnBefore(state, dispatch),
|
||||
addColumnAfter:
|
||||
() =>
|
||||
({ state, dispatch }) => addColumnAfter(state, dispatch),
|
||||
deleteColumn:
|
||||
() =>
|
||||
({ state, dispatch }) => deleteColumn(state, dispatch),
|
||||
addRowBefore:
|
||||
() =>
|
||||
({ state, dispatch }) => addRowBefore(state, dispatch),
|
||||
addRowAfter:
|
||||
() =>
|
||||
({ state, dispatch }) => addRowAfter(state, dispatch),
|
||||
deleteRow:
|
||||
() =>
|
||||
({ state, dispatch }) => deleteRow(state, dispatch),
|
||||
deleteTable:
|
||||
() =>
|
||||
({ state, dispatch }) => deleteTable(state, dispatch),
|
||||
mergeCells:
|
||||
() =>
|
||||
({ state, dispatch }) => mergeCells(state, dispatch),
|
||||
splitCell:
|
||||
() =>
|
||||
({ state, dispatch }) => splitCell(state, dispatch),
|
||||
toggleHeaderColumn:
|
||||
() =>
|
||||
({ state, dispatch }) => toggleHeader("column")(state, dispatch),
|
||||
toggleHeaderRow:
|
||||
() =>
|
||||
({ state, dispatch }) => toggleHeader("row")(state, dispatch),
|
||||
toggleHeaderCell:
|
||||
() =>
|
||||
({ state, dispatch }) => toggleHeaderCell(state, dispatch),
|
||||
mergeOrSplit:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (mergeCells(state, dispatch)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return splitCell(state, dispatch)
|
||||
},
|
||||
setCellAttribute:
|
||||
(name, value) =>
|
||||
({ state, dispatch }) => setCellAttr(name, value)(state, dispatch),
|
||||
goToNextCell:
|
||||
() =>
|
||||
({ state, dispatch }) => goToNextCell(1)(state, dispatch),
|
||||
goToPreviousCell:
|
||||
() =>
|
||||
({ state, dispatch }) => goToNextCell(-1)(state, dispatch),
|
||||
fixTables:
|
||||
() =>
|
||||
({ state, dispatch }) => {
|
||||
if (dispatch) {
|
||||
fixTables(state)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
setCellSelection:
|
||||
(position) =>
|
||||
({ tr, dispatch }) => {
|
||||
if (dispatch) {
|
||||
const selection = CellSelection.create(
|
||||
tr.doc,
|
||||
position.anchorCell,
|
||||
position.headCell
|
||||
)
|
||||
|
||||
// @ts-ignore
|
||||
tr.setSelection(selection)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Tab: () => {
|
||||
if (this.editor.commands.goToNextCell()) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!this.editor.can().addRowAfter()) {
|
||||
return false
|
||||
}
|
||||
|
||||
return this.editor.chain().addRowAfter().goToNextCell().run()
|
||||
},
|
||||
"Shift-Tab": () => this.editor.commands.goToPreviousCell(),
|
||||
Backspace: deleteTableWhenAllCellsSelected,
|
||||
"Mod-Backspace": deleteTableWhenAllCellsSelected,
|
||||
Delete: deleteTableWhenAllCellsSelected,
|
||||
"Mod-Delete": deleteTableWhenAllCellsSelected
|
||||
}
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ editor, getPos, node, decorations }) => {
|
||||
const { cellMinWidth } = this.options
|
||||
|
||||
return new TableView(
|
||||
node,
|
||||
cellMinWidth,
|
||||
decorations,
|
||||
editor,
|
||||
getPos as () => number
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const isResizable = this.options.resizable && this.editor.isEditable
|
||||
|
||||
const plugins = [
|
||||
tableEditing({
|
||||
allowTableNodeSelection: this.options.allowTableNodeSelection
|
||||
}),
|
||||
tableControls()
|
||||
]
|
||||
|
||||
if (isResizable) {
|
||||
plugins.unshift(
|
||||
columnResizing({
|
||||
handleWidth: this.options.handleWidth,
|
||||
cellMinWidth: this.options.cellMinWidth,
|
||||
// View: TableView,
|
||||
|
||||
// @ts-ignore
|
||||
lastColumnResizable: this.options.lastColumnResizable
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return plugins
|
||||
},
|
||||
|
||||
extendNodeSchema(extension) {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage
|
||||
}
|
||||
|
||||
return {
|
||||
tableRole: callOrReturn(
|
||||
getExtensionField(extension, "tableRole", context)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Fragment, Node as ProsemirrorNode, NodeType } from "prosemirror-model"
|
||||
|
||||
export function createCell(
|
||||
cellType: NodeType,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||
): ProsemirrorNode | null | undefined {
|
||||
if (cellContent) {
|
||||
return cellType.createChecked(null, cellContent)
|
||||
}
|
||||
|
||||
return cellType.createAndFill()
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { Fragment, Node as ProsemirrorNode, Schema } from "@tiptap/pm/model"
|
||||
|
||||
import { createCell } from "./create-cell"
|
||||
import { getTableNodeTypes } from "./get-table-node-types"
|
||||
|
||||
export function createTable(
|
||||
schema: Schema,
|
||||
rowsCount: number,
|
||||
colsCount: number,
|
||||
withHeaderRow: boolean,
|
||||
cellContent?: Fragment | ProsemirrorNode | Array<ProsemirrorNode>
|
||||
): ProsemirrorNode {
|
||||
const types = getTableNodeTypes(schema)
|
||||
const headerCells: ProsemirrorNode[] = []
|
||||
const cells: ProsemirrorNode[] = []
|
||||
|
||||
for (let index = 0; index < colsCount; index += 1) {
|
||||
const cell = createCell(types.cell, cellContent)
|
||||
|
||||
if (cell) {
|
||||
cells.push(cell)
|
||||
}
|
||||
|
||||
if (withHeaderRow) {
|
||||
const headerCell = createCell(types.header_cell, cellContent)
|
||||
|
||||
if (headerCell) {
|
||||
headerCells.push(headerCell)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows: ProsemirrorNode[] = []
|
||||
|
||||
for (let index = 0; index < rowsCount; index += 1) {
|
||||
rows.push(
|
||||
types.row.createChecked(
|
||||
null,
|
||||
withHeaderRow && index === 0 ? headerCells : cells
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return types.table.createChecked(null, rows)
|
||||
}
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
import { findParentNodeClosestToPos, KeyboardShortcutCommand } from "@tiptap/core"
|
||||
|
||||
import { isCellSelection } from "./is-cell-selection"
|
||||
|
||||
export const deleteTableWhenAllCellsSelected: KeyboardShortcutCommand = ({
|
||||
editor
|
||||
}) => {
|
||||
const { selection } = editor.state
|
||||
|
||||
if (!isCellSelection(selection)) {
|
||||
return false
|
||||
}
|
||||
|
||||
let cellCount = 0
|
||||
const table = findParentNodeClosestToPos(
|
||||
selection.ranges[0].$from,
|
||||
(node) => node.type.name === "table"
|
||||
)
|
||||
|
||||
table?.node.descendants((node) => {
|
||||
if (node.type.name === "table") {
|
||||
return false
|
||||
}
|
||||
|
||||
if (["tableCell", "tableHeader"].includes(node.type.name)) {
|
||||
cellCount += 1
|
||||
}
|
||||
})
|
||||
|
||||
const allCellsSelected = cellCount === selection.ranges.length
|
||||
|
||||
if (!allCellsSelected) {
|
||||
return false
|
||||
}
|
||||
|
||||
editor.commands.deleteTable()
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NodeType, Schema } from "prosemirror-model"
|
||||
|
||||
export function getTableNodeTypes(schema: Schema): { [key: string]: NodeType } {
|
||||
if (schema.cached.tableNodeTypes) {
|
||||
return schema.cached.tableNodeTypes
|
||||
}
|
||||
|
||||
const roles: { [key: string]: NodeType } = {}
|
||||
|
||||
Object.keys(schema.nodes).forEach((type) => {
|
||||
const nodeType = schema.nodes[type]
|
||||
|
||||
if (nodeType.spec.tableRole) {
|
||||
roles[nodeType.spec.tableRole] = nodeType
|
||||
}
|
||||
})
|
||||
|
||||
schema.cached.tableNodeTypes = roles
|
||||
|
||||
return roles
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { CellSelection } from "@tiptap/prosemirror-tables"
|
||||
|
||||
export function isCellSelection(value: unknown): value is CellSelection {
|
||||
return value instanceof CellSelection
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import { DeleteImage } from '../../types/delete-image';
|
||||
import { CoreEditorProps } from "../props";
|
||||
import { CoreEditorExtensions } from "../extensions";
|
||||
import { EditorProps } from '@tiptap/pm/view';
|
||||
import { getTrimmedHTML } from "../../lib/utils";
|
||||
import { UploadImage } from "../../types/upload-image";
|
||||
|
||||
const DEBOUNCE_DELAY = 1500;
|
||||
|
||||
interface CustomEditorProps {
|
||||
uploadFile: UploadImage;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
value: string;
|
||||
deleteFile: DeleteImage;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
forwardedRef?: any;
|
||||
}
|
||||
|
||||
export const useEditor = ({ uploadFile, deleteFile, editorProps = {}, value, extensions = [], onChange, setIsSubmitting, debouncedUpdatesEnabled, forwardedRef, setShouldShowAlert, }: CustomEditorProps) => {
|
||||
const editor = useCustomEditor({
|
||||
editorProps: {
|
||||
...CoreEditorProps(uploadFile, setIsSubmitting),
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [...CoreEditorExtensions(deleteFile), ...extensions],
|
||||
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
||||
onUpdate: async ({ editor }) => {
|
||||
// for instant feedback loop
|
||||
setIsSubmitting?.("submitting");
|
||||
setShouldShowAlert?.(true);
|
||||
if (debouncedUpdatesEnabled) {
|
||||
debouncedUpdates({ onChange: onChange, editor });
|
||||
} else {
|
||||
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: () => {
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
},
|
||||
}));
|
||||
|
||||
const debouncedUpdates = useDebouncedCallback(async ({ onChange, editor }) => {
|
||||
if (onChange) {
|
||||
onChange(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
|
||||
}
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return editor;
|
||||
};
|
||||
@@ -1,43 +0,0 @@
|
||||
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
|
||||
import { useImperativeHandle, useRef, MutableRefObject } from "react";
|
||||
import { CoreReadOnlyEditorExtensions } from "../../ui/read-only/extensions";
|
||||
import { CoreReadOnlyEditorProps } from "../../ui/read-only/props";
|
||||
import { EditorProps } from '@tiptap/pm/view';
|
||||
|
||||
interface CustomReadOnlyEditorProps {
|
||||
value: string;
|
||||
forwardedRef?: any;
|
||||
extensions?: any;
|
||||
editorProps?: EditorProps;
|
||||
}
|
||||
|
||||
export const useReadOnlyEditor = ({ value, forwardedRef, extensions = [], editorProps = {} }: CustomReadOnlyEditorProps) => {
|
||||
const editor = useCustomEditor({
|
||||
editable: false,
|
||||
content: (typeof value === "string" && value.trim() !== "") ? value : "<p></p>",
|
||||
editorProps: {
|
||||
...CoreReadOnlyEditorProps,
|
||||
...editorProps,
|
||||
},
|
||||
extensions: [...CoreReadOnlyEditorExtensions, ...extensions],
|
||||
});
|
||||
|
||||
const editorRef: MutableRefObject<Editor | null> = useRef(null);
|
||||
editorRef.current = editor;
|
||||
|
||||
useImperativeHandle(forwardedRef, () => ({
|
||||
clearEditor: () => {
|
||||
editorRef.current?.commands.clearContent();
|
||||
},
|
||||
setEditorValue: (content: string) => {
|
||||
editorRef.current?.commands.setContent(content);
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return editor;
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client"
|
||||
import * as React from 'react';
|
||||
import { Extension } from "@tiptap/react";
|
||||
import { UploadImage } from '../types/upload-image';
|
||||
import { DeleteImage } from '../types/delete-image';
|
||||
import { getEditorClassNames } from '../lib/utils';
|
||||
import { EditorProps } from '@tiptap/pm/view';
|
||||
import { useEditor } from './hooks/useEditor';
|
||||
import { EditorContainer } from '../ui/components/editor-container';
|
||||
import { EditorContentWrapper } from '../ui/components/editor-content';
|
||||
|
||||
interface ICoreEditor {
|
||||
value: string;
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
editable?: boolean;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
accessValue: string;
|
||||
onAccessChange: (accessKey: string) => void;
|
||||
commentAccess: {
|
||||
icon: string;
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
}[];
|
||||
extensions?: Extension[];
|
||||
editorProps?: EditorProps;
|
||||
}
|
||||
|
||||
interface EditorCoreProps extends ICoreEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const CoreEditor = ({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
editable,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
forwardedRef,
|
||||
}: EditorCoreProps) => {
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
editable,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
forwardedRef,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
</div>
|
||||
</EditorContainer >
|
||||
);
|
||||
};
|
||||
|
||||
const CoreEditorWithRef = React.forwardRef<EditorHandle, ICoreEditor>((props, ref) => (
|
||||
<CoreEditor {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
CoreEditorWithRef.displayName = "CoreEditorWithRef";
|
||||
|
||||
export { CoreEditor, CoreEditorWithRef };
|
||||
@@ -1,109 +0,0 @@
|
||||
import { BoldIcon, Heading1, CheckSquare, Heading2, Heading3, QuoteIcon, ImageIcon, TableIcon, ListIcon, ListOrderedIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { UploadImage } from "../../../types/upload-image";
|
||||
import { insertImageCommand, insertTableCommand, toggleBlockquote, toggleBold, toggleBulletList, toggleCode, toggleHeadingOne, toggleHeadingThree, toggleHeadingTwo, toggleItalic, toggleOrderedList, toggleStrike, toggleTaskList, toggleUnderline, } from "../../../lib/editor-commands";
|
||||
|
||||
export interface EditorMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof BoldIcon;
|
||||
}
|
||||
|
||||
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "H1",
|
||||
isActive: () => editor.isActive("heading", { level: 1 }),
|
||||
command: () => toggleHeadingOne(editor),
|
||||
icon: Heading1,
|
||||
})
|
||||
|
||||
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "H2",
|
||||
isActive: () => editor.isActive("heading", { level: 2 }),
|
||||
command: () => toggleHeadingTwo(editor),
|
||||
icon: Heading2,
|
||||
})
|
||||
|
||||
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "H3",
|
||||
isActive: () => editor.isActive("heading", { level: 3 }),
|
||||
command: () => toggleHeadingThree(editor),
|
||||
icon: Heading3,
|
||||
})
|
||||
|
||||
export const BoldItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "bold",
|
||||
isActive: () => editor?.isActive("bold"),
|
||||
command: () => toggleBold(editor),
|
||||
icon: BoldIcon,
|
||||
})
|
||||
|
||||
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "italic",
|
||||
isActive: () => editor?.isActive("italic"),
|
||||
command: () => toggleItalic(editor),
|
||||
icon: ItalicIcon,
|
||||
})
|
||||
|
||||
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "underline",
|
||||
isActive: () => editor?.isActive("underline"),
|
||||
command: () => toggleUnderline(editor),
|
||||
icon: UnderlineIcon,
|
||||
})
|
||||
|
||||
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "strike",
|
||||
isActive: () => editor?.isActive("strike"),
|
||||
command: () => toggleStrike(editor),
|
||||
icon: StrikethroughIcon,
|
||||
})
|
||||
|
||||
export const CodeItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "code",
|
||||
isActive: () => editor?.isActive("code"),
|
||||
command: () => toggleCode(editor),
|
||||
icon: CodeIcon,
|
||||
})
|
||||
|
||||
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "bullet-list",
|
||||
isActive: () => editor?.isActive("bulletList"),
|
||||
command: () => toggleBulletList(editor),
|
||||
icon: ListIcon,
|
||||
})
|
||||
|
||||
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "To-do List",
|
||||
isActive: () => editor.isActive("taskItem"),
|
||||
command: () => toggleTaskList(editor),
|
||||
icon: CheckSquare,
|
||||
})
|
||||
|
||||
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "ordered-list",
|
||||
isActive: () => editor?.isActive("orderedList"),
|
||||
command: () => toggleOrderedList(editor),
|
||||
icon: ListOrderedIcon
|
||||
})
|
||||
|
||||
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "quote",
|
||||
isActive: () => editor?.isActive("quote"),
|
||||
command: () => toggleBlockquote(editor),
|
||||
icon: QuoteIcon
|
||||
})
|
||||
|
||||
export const TableItem = (editor: Editor): EditorMenuItem => ({
|
||||
name: "quote",
|
||||
isActive: () => editor?.isActive("table"),
|
||||
command: () => insertTableCommand(editor),
|
||||
icon: TableIcon
|
||||
})
|
||||
|
||||
export const ImageItem = (editor: Editor, uploadFile: UploadImage, setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void): EditorMenuItem => ({
|
||||
name: "image",
|
||||
isActive: () => editor?.isActive("image"),
|
||||
command: () => insertImageCommand(editor, uploadFile, setIsSubmitting),
|
||||
icon: ImageIcon,
|
||||
})
|
||||
@@ -1,92 +0,0 @@
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import TiptapLink from "@tiptap/extension-link";
|
||||
import TiptapUnderline from "@tiptap/extension-underline";
|
||||
import TextStyle from "@tiptap/extension-text-style";
|
||||
import { Color } from "@tiptap/extension-color";
|
||||
import TaskItem from "@tiptap/extension-task-item";
|
||||
import TaskList from "@tiptap/extension-task-list";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import Gapcursor from "@tiptap/extension-gapcursor";
|
||||
|
||||
// import { CustomTableCell } from "../extensions/table/table-cell";
|
||||
// import { Table } from "../extensions/table";
|
||||
// import { TableHeader } from "../extensions/table/table-header";
|
||||
// import { TableRow } from "@tiptap/extension-table-row";
|
||||
|
||||
import ReadOnlyImageExtension from "../extensions/image/read-only-image";
|
||||
import { isValidHttpUrl } from "../../lib/utils";
|
||||
|
||||
export const CoreReadOnlyEditorExtensions = [
|
||||
StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-disc list-outside leading-3 -mt-2",
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: "list-decimal list-outside leading-3 -mt-2",
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: "leading-normal -mb-2",
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: "border-l-4 border-custom-border-300",
|
||||
},
|
||||
},
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000",
|
||||
spellcheck: "false",
|
||||
},
|
||||
},
|
||||
codeBlock: false,
|
||||
horizontalRule: false,
|
||||
dropcursor: {
|
||||
color: "rgba(var(--color-text-100))",
|
||||
width: 2,
|
||||
},
|
||||
gapcursor: false,
|
||||
}),
|
||||
Gapcursor,
|
||||
TiptapLink.configure({
|
||||
protocols: ["http", "https"],
|
||||
validate: (url) => isValidHttpUrl(url),
|
||||
HTMLAttributes: {
|
||||
class:
|
||||
"text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer",
|
||||
},
|
||||
}),
|
||||
ReadOnlyImageExtension.configure({
|
||||
HTMLAttributes: {
|
||||
class: "rounded-lg border border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
TiptapUnderline,
|
||||
TextStyle,
|
||||
Color,
|
||||
TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: "not-prose pl-2",
|
||||
},
|
||||
}),
|
||||
TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: "flex items-start my-4",
|
||||
},
|
||||
nested: true,
|
||||
}),
|
||||
Markdown.configure({
|
||||
html: true,
|
||||
transformCopiedText: true,
|
||||
}),
|
||||
// Table,
|
||||
// TableHeader,
|
||||
// CustomTableCell,
|
||||
// TableRow,
|
||||
];
|
||||
@@ -1,8 +0,0 @@
|
||||
import { EditorProps } from "@tiptap/pm/view";
|
||||
|
||||
export const CoreReadOnlyEditorProps: EditorProps =
|
||||
{
|
||||
attributes: {
|
||||
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
|
||||
},
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
// prefix ui lib classes to avoid conflicting with the app
|
||||
...sharedConfig,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "tsconfig/react.json",
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"index.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"build",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
@@ -1,97 +0,0 @@
|
||||
# @plane/lite-text-editor
|
||||
|
||||
## Description
|
||||
|
||||
The `@plane/lite-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Custom control over Enter key, etc.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Exported Components**: There are two components exported from the Lite text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code.
|
||||
|
||||
`LiteTextEditor` & `LiteTextEditorWithRef`
|
||||
|
||||
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Lite editor types (with and without Ref)
|
||||
`LiteReadOnlyEditor` &`LiteReadOnlyEditorWithRef`
|
||||
|
||||
## LiteTextEditor
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
|
||||
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
|
||||
| `value` | `html string` | The initial content of the editor. |
|
||||
| `onEnterKeyPress` | `(e) => void` | The event that happens on Enter key press |
|
||||
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
|
||||
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
|
||||
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
|
||||
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
|
||||
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
|
||||
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
|
||||
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
|
||||
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
|
||||
|
||||
### Usage
|
||||
|
||||
1. Here is an example of how to use the `RichTextEditor` component
|
||||
|
||||
```tsx
|
||||
<LiteTextEditor
|
||||
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
value={value}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||
onChange={(comment_json: Object, comment_html: string) => {
|
||||
onChange(comment_html);
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
2. Example of how to use the `LiteTextEditorWithRef` component
|
||||
|
||||
```tsx
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
// can use it to set the editor's value
|
||||
editorRef.current?.setEditorValue(`${watch("description_html")}`);
|
||||
|
||||
// can use it to clear the editor
|
||||
editorRef?.current?.clearEditor();
|
||||
|
||||
return (
|
||||
<LiteTextEditorWithRef
|
||||
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
debouncedUpdatesEnabled={false}
|
||||
customClassName="min-h-[50px] p-3 shadow-sm"
|
||||
onChange={(comment_json: Object, comment_html: string) => {
|
||||
onChange(comment_html);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
```
|
||||
|
||||
## LiteReadOnlyEditor
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `value` | `html string` | The initial content of the editor. |
|
||||
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
|
||||
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
|
||||
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
|
||||
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
|
||||
|
||||
### Usage
|
||||
|
||||
Here is an example of how to use the `RichReadOnlyEditor` component
|
||||
|
||||
```tsx
|
||||
<LiteReadOnlyEditor
|
||||
value={comment.comment_html}
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
/>
|
||||
```
|
||||
@@ -1,63 +0,0 @@
|
||||
{
|
||||
"name": "@plane/lite-text-editor",
|
||||
"version": "0.0.1",
|
||||
"description": "Package that powers Plane's Comment Editor",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "12.3.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/editor-core": "*",
|
||||
"@tiptap/extension-list-item": "^2.1.11",
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.5",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"eslint": "8.36.0",
|
||||
"eslint-config-next": "13.2.4",
|
||||
"eventsource-parser": "^0.1.0",
|
||||
"lowlight": "^2.9.0",
|
||||
"lucide-react": "^0.244.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8.2",
|
||||
"use-debounce": "^9.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"keywords": [
|
||||
"editor",
|
||||
"rich-text",
|
||||
"markdown",
|
||||
"nextjs",
|
||||
"react"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// If you want to use other PostCSS plugins, see the following:
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export { LiteTextEditor, LiteTextEditorWithRef } from "./ui";
|
||||
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "./ui/read-only";
|
||||
@@ -1,9 +0,0 @@
|
||||
import ListItem from '@tiptap/extension-list-item'
|
||||
|
||||
export const CustomListItem = ListItem.extend({
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Shift-Enter': () => this.editor.chain().focus().splitListItem('listItem').run(),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
|
||||
export const EnterKeyExtension = (onEnterKeyPress?: () => void) => Extension.create({
|
||||
name: 'enterKey',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
'Enter': () => {
|
||||
if (onEnterKeyPress) {
|
||||
onEnterKeyPress();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
import { CustomListItem } from "./custom-list-extension";
|
||||
import { EnterKeyExtension } from "./enter-key-extension";
|
||||
|
||||
export const LiteTextEditorExtensions = (onEnterKeyPress?: () => void) => [
|
||||
CustomListItem,
|
||||
EnterKeyExtension(onEnterKeyPress),
|
||||
];
|
||||
@@ -1,95 +0,0 @@
|
||||
"use client"
|
||||
import * as React from 'react';
|
||||
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||
import { FixedMenu } from './menus/fixed-menu';
|
||||
import { LiteTextEditorExtensions } from './extensions';
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
|
||||
interface ILiteTextEditor {
|
||||
value: string;
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
commentAccessSpecifier?: {
|
||||
accessValue: string,
|
||||
onAccessChange: (accessKey: string) => void,
|
||||
showAccessSpecifier: boolean,
|
||||
commentAccess: {
|
||||
icon: string;
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
}[]
|
||||
};
|
||||
onEnterKeyPress?: (e?: any) => void;
|
||||
}
|
||||
|
||||
interface LiteTextEditorProps extends ILiteTextEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const LiteTextEditor = ({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
forwardedRef,
|
||||
commentAccessSpecifier,
|
||||
onEnterKeyPress
|
||||
}: LiteTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
forwardedRef,
|
||||
extensions: LiteTextEditorExtensions(onEnterKeyPress),
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
<div className="w-full mt-4">
|
||||
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} commentAccessSpecifier={commentAccessSpecifier} />
|
||||
</div>
|
||||
</div>
|
||||
</EditorContainer >
|
||||
);
|
||||
};
|
||||
|
||||
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>((props, ref) => (
|
||||
<LiteTextEditor {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";
|
||||
|
||||
export { LiteTextEditor, LiteTextEditorWithRef };
|
||||
@@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
iconName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Icon: React.FC<Props> = ({ iconName, className = "" }) => (
|
||||
<span className={`material-symbols-rounded text-sm leading-5 font-light ${className}`}>
|
||||
{iconName}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { BoldIcon } from "lucide-react";
|
||||
|
||||
import { BoldItem, BulletListItem, cn, CodeItem, ImageItem, ItalicItem, NumberedListItem, QuoteItem, StrikeThroughItem, TableItem, UnderLineItem } from "@plane/editor-core";
|
||||
import { Icon } from "./icon";
|
||||
import { Tooltip } from "../../tooltip";
|
||||
import { UploadImage } from "../..";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
isActive: () => boolean;
|
||||
command: () => void;
|
||||
icon: typeof BoldIcon;
|
||||
}
|
||||
|
||||
type EditorBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
commentAccessSpecifier?: {
|
||||
accessValue: string,
|
||||
onAccessChange: (accessKey: string) => void,
|
||||
showAccessSpecifier: boolean,
|
||||
commentAccess: {
|
||||
icon: string;
|
||||
key: string;
|
||||
label: "Private" | "Public";
|
||||
}[] | undefined;
|
||||
}
|
||||
uploadFile: UploadImage;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
}
|
||||
|
||||
export const FixedMenu = (props: EditorBubbleMenuProps) => {
|
||||
const basicMarkItems: BubbleMenuItem[] = [
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
];
|
||||
|
||||
const listItems: BubbleMenuItem[] = [
|
||||
BulletListItem(props.editor),
|
||||
NumberedListItem(props.editor),
|
||||
];
|
||||
|
||||
const userActionItems: BubbleMenuItem[] = [
|
||||
QuoteItem(props.editor),
|
||||
CodeItem(props.editor),
|
||||
];
|
||||
|
||||
const complexItems: BubbleMenuItem[] = [
|
||||
TableItem(props.editor),
|
||||
ImageItem(props.editor, props.uploadFile, props.setIsSubmitting),
|
||||
];
|
||||
|
||||
const handleAccessChange = (accessKey: string) => {
|
||||
props.commentAccessSpecifier?.onAccessChange(accessKey);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-fit divide-x divide-custom-border-300 rounded border border-custom-border-300 bg-custom-background-100 shadow-xl"
|
||||
>
|
||||
{props.commentAccessSpecifier && (<div className="flex border border-custom-border-300 mt-0 divide-x divide-custom-border-300 rounded overflow-hidden">
|
||||
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
|
||||
<Tooltip key={access.key} tooltipContent={access.label}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAccessChange(access.key)}
|
||||
className={`grid place-basicMarkItems-center p-1 hover:bg-custom-background-80 ${props.commentAccessSpecifier?.accessValue === access.key ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
iconName={access.icon}
|
||||
className={`w-4 h-4 ${props.commentAccessSpecifier?.accessValue === access.key
|
||||
? "!text-custom-text-100"
|
||||
: "!text-custom-text-400"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>)}
|
||||
<div className="flex">
|
||||
{basicMarkItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{listItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{userActionItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex">
|
||||
{complexItems.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={item.command}
|
||||
className={cn(
|
||||
"p-2 text-custom-text-300 hover:bg-custom-primary-100/5 active:bg-custom-primary-100/5 transition-colors",
|
||||
{
|
||||
"text-custom-text-100 bg-custom-primary-100/5": item.isActive(),
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={cn("h-4 w-4", {
|
||||
"text-custom-text-100": item.isActive(),
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client"
|
||||
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
|
||||
import * as React from 'react';
|
||||
|
||||
interface ICoreReadOnlyEditor {
|
||||
value: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
}
|
||||
|
||||
interface EditorCoreProps extends ICoreReadOnlyEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const LiteReadOnlyEditor = ({
|
||||
editorContentCustomClassNames,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
value,
|
||||
forwardedRef,
|
||||
}: EditorCoreProps) => {
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
forwardedRef,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
</div>
|
||||
</EditorContainer >
|
||||
);
|
||||
};
|
||||
|
||||
const LiteReadOnlyEditorWithRef = React.forwardRef<EditorHandle, ICoreReadOnlyEditor>((props, ref) => (
|
||||
<LiteReadOnlyEditor {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
|
||||
|
||||
export { LiteReadOnlyEditor , LiteReadOnlyEditorWithRef };
|
||||
@@ -1,77 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
// next-themes
|
||||
import { useTheme } from "next-themes";
|
||||
// tooltip2
|
||||
import { Tooltip2 } from "@blueprintjs/popover2";
|
||||
|
||||
type Props = {
|
||||
tooltipHeading?: string;
|
||||
tooltipContent: string | React.ReactNode;
|
||||
position?:
|
||||
| "top"
|
||||
| "right"
|
||||
| "bottom"
|
||||
| "left"
|
||||
| "auto"
|
||||
| "auto-end"
|
||||
| "auto-start"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "left-bottom"
|
||||
| "left-top"
|
||||
| "right-bottom"
|
||||
| "right-top"
|
||||
| "top-left"
|
||||
| "top-right";
|
||||
children: JSX.Element;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
openDelay?: number;
|
||||
closeDelay?: number;
|
||||
};
|
||||
|
||||
export const Tooltip: React.FC<Props> = ({
|
||||
tooltipHeading,
|
||||
tooltipContent,
|
||||
position = "top",
|
||||
children,
|
||||
disabled = false,
|
||||
className = "",
|
||||
openDelay = 200,
|
||||
closeDelay,
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<Tooltip2
|
||||
disabled={disabled}
|
||||
hoverOpenDelay={openDelay}
|
||||
hoverCloseDelay={closeDelay}
|
||||
content={
|
||||
<div
|
||||
className={`relative z-50 max-w-xs gap-1 rounded-md p-2 text-xs shadow-md ${
|
||||
theme === "custom"
|
||||
? "bg-custom-background-100 text-custom-text-200"
|
||||
: "bg-black text-gray-400"
|
||||
} break-words overflow-hidden ${className}`}
|
||||
>
|
||||
{tooltipHeading && (
|
||||
<h5
|
||||
className={`font-medium ${
|
||||
theme === "custom" ? "text-custom-text-100" : "text-white"
|
||||
}`}
|
||||
>
|
||||
{tooltipHeading}
|
||||
</h5>
|
||||
)}
|
||||
{tooltipContent}
|
||||
</div>
|
||||
}
|
||||
position={position}
|
||||
renderTarget={({ isOpen: isTooltipOpen, ref: eleReference, ...tooltipProps }) =>
|
||||
React.cloneElement(children, { ref: eleReference, ...tooltipProps, ...children.props })
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
// prefix ui lib classes to avoid conflicting with the app
|
||||
...sharedConfig,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "tsconfig/react.json",
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"index.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"build",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
@@ -1,99 +0,0 @@
|
||||
# @plane/rich-text-editor
|
||||
|
||||
## Description
|
||||
|
||||
The `@plane/rich-text-editor` package extends from the `editor-core` package, inheriting its base functionality while adding its own unique features of Slash Commands and many more.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Exported Components**: There are two components exported from the Rich text editor (with and without Ref), you can choose to use the `withRef` instance whenever you want to control the Editor’s state via a side effect of some external action from within the application code.
|
||||
|
||||
`RichTextEditor` & `RichTextEditorWithRef`
|
||||
|
||||
- **Read Only Editor Instances**: We have added a really light weight *Read Only* Editor instance for the Rich editor types (with and without Ref)
|
||||
`RichReadOnlyEditor` &`RichReadOnlyEditorWithRef`
|
||||
|
||||
## RichTextEditor
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `uploadFile` | `(file: File) => Promise<string>` | A function that handles file upload. It takes a file as input and handles the process of uploading that file. |
|
||||
| `deleteFile` | `(assetUrlWithWorkspaceId: string) => Promise<any>` | A function that handles deleting an image. It takes the asset url from your bucket and handles the process of deleting that image. |
|
||||
| `value` | `html string` | The initial content of the editor. |
|
||||
| `debouncedUpdatesEnabled` | `boolean` | If set to true, the `onChange` event handler is debounced, meaning it will only be invoked after the specified delay (default 1500ms) once the user has stopped typing. |
|
||||
| `onChange` | `(json: any, html: string) => void` | This function is invoked whenever the content of the editor changes. It is passed the new content in both JSON and HTML formats. |
|
||||
| `setIsSubmitting` | `(isSubmitting: "submitting" \| "submitted" \| "saved") => void` | This function is called to update the submission status. |
|
||||
| `setShouldShowAlert` | `(showAlert: boolean) => void` | This function is used to show or hide an alert incase of content not being "saved". |
|
||||
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
|
||||
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
|
||||
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
|
||||
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
|
||||
|
||||
### Usage
|
||||
|
||||
1. Here is an example of how to use the `RichTextEditor` component
|
||||
|
||||
```tsx
|
||||
<RichTextEditor
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
value={value}
|
||||
debouncedUpdatesEnabled={true}
|
||||
setShouldShowAlert={setShowAlert}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
customClassName={
|
||||
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"
|
||||
}
|
||||
noBorder={!isAllowed}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
setIsSubmitting("submitting");
|
||||
onChange(description_html);
|
||||
// custom stuff you want to do
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
2. Example of how to use the `RichTextEditorWithRef` component
|
||||
|
||||
```tsx
|
||||
const editorRef = useRef<any>(null);
|
||||
|
||||
// can use it to set the editor's value
|
||||
editorRef.current?.setEditorValue(`${watch("description_html")}`);
|
||||
|
||||
// can use it to clear the editor
|
||||
editorRef?.current?.clearEditor();
|
||||
|
||||
return (<RichTextEditorWithRef
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
ref={editorRef}
|
||||
debouncedUpdatesEnabled={false}
|
||||
value={value}
|
||||
customClassName="min-h-[150px]"
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
onChange(description_html);
|
||||
// custom stuff you want to do
|
||||
} } />)
|
||||
```
|
||||
|
||||
## RichReadOnlyEditor
|
||||
|
||||
| Prop | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `value` | `html string` | The initial content of the editor. |
|
||||
| `noBorder` | `boolean` | If set to true, the editor will not have a border. |
|
||||
| `borderOnFocus` | `boolean` | If set to true, the editor will show a border when it is focused. |
|
||||
| `customClassName` | `string` | This is a custom CSS class that can be applied to the editor. |
|
||||
| `editorContentCustomClassNames` | `string` | This is a custom CSS class that can be applied to the editor content. |
|
||||
|
||||
### Usage
|
||||
|
||||
Here is an example of how to use the `RichReadOnlyEditor` component
|
||||
|
||||
```tsx
|
||||
<RichReadOnlyEditor
|
||||
value={issueDetails.description_html}
|
||||
customClassName="p-3 min-h-[50px] shadow-sm" />
|
||||
```
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"name": "@plane/rich-text-editor",
|
||||
"version": "0.0.1",
|
||||
"description": "Rich Text Editor that powers Plane",
|
||||
"main": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts",
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.mts",
|
||||
"import": "./dist/index.mjs",
|
||||
"module": "./dist/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "12.3.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"@tiptap/core": "^2.1.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@plane/editor-core": "*",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.1.11",
|
||||
"@tiptap/extension-horizontal-rule": "^2.1.11",
|
||||
"@tiptap/extension-placeholder": "^2.1.11",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"@tiptap/suggestion": "^2.1.7",
|
||||
"clsx": "^1.2.1",
|
||||
"highlight.js": "^11.8.0",
|
||||
"lowlight": "^3.0.0",
|
||||
"lucide-react": "^0.244.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "18.15.3",
|
||||
"@types/react": "^18.2.5",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"eslint": "^7.32.0",
|
||||
"postcss": "^8.4.29",
|
||||
"react": "^18.2.0",
|
||||
"tailwind-config-custom": "*",
|
||||
"tsconfig": "*",
|
||||
"tsup": "^7.2.0",
|
||||
"typescript": "4.9.5"
|
||||
},
|
||||
"keywords": [
|
||||
"editor",
|
||||
"rich-text",
|
||||
"markdown",
|
||||
"nextjs",
|
||||
"react"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// If you want to use other PostCSS plugins, see the following:
|
||||
// https://tailwindcss.com/docs/using-with-preprocessors
|
||||
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import "./styles/github-dark.css";
|
||||
|
||||
export { RichTextEditor, RichTextEditorWithRef } from "./ui";
|
||||
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "./ui/read-only";
|
||||
@@ -1,2 +0,0 @@
|
||||
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}
|
||||
.hljs{color:#c9d1d9;background:#0d1117}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#ff7b72}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#d2a8ff}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#79c0ff}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#a5d6ff}.hljs-built_in,.hljs-symbol{color:#ffa657}.hljs-code,.hljs-comment,.hljs-formula{color:#8b949e}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#7ee787}.hljs-subst{color:#c9d1d9}.hljs-section{color:#1f6feb;font-weight:700}.hljs-bullet{color:#f2cc60}.hljs-emphasis{color:#c9d1d9;font-style:italic}.hljs-strong{color:#c9d1d9;font-weight:700}.hljs-addition{color:#aff5b4;background-color:#033a16}.hljs-deletion{color:#ffdcd7;background-color:#67060c}
|
||||
@@ -1,59 +0,0 @@
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
import { InputRule } from "@tiptap/core";
|
||||
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
|
||||
import SlashCommand from "./slash-command";
|
||||
import { UploadImage } from "../";
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
lowlight.register("ts", ts);
|
||||
|
||||
export const RichTextEditorExtensions = (
|
||||
uploadFile: UploadImage,
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
|
||||
) => [
|
||||
HorizontalRule.extend({
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /^(?:---|—-|___\s|\*\*\*\s)$/,
|
||||
handler: ({ state, range, commands }) => {
|
||||
commands.splitBlock();
|
||||
|
||||
const attributes = {};
|
||||
const { tr } = state;
|
||||
const start = range.from;
|
||||
const end = range.to;
|
||||
// @ts-ignore
|
||||
tr.replaceWith(start - 1, end, this.type.create(attributes));
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
HTMLAttributes: {
|
||||
class: "mb-6 border-t border-custom-border-300",
|
||||
},
|
||||
}),
|
||||
SlashCommand(uploadFile, setIsSubmitting),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === "heading") {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
if (node.type.name === "image" || node.type.name === "table") {
|
||||
return "";
|
||||
}
|
||||
|
||||
return "Press '/' for commands...";
|
||||
},
|
||||
includeChildren: true,
|
||||
}),
|
||||
];
|
||||
@@ -1,80 +0,0 @@
|
||||
"use client"
|
||||
import * as React from 'react';
|
||||
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor } from '@plane/editor-core';
|
||||
import { EditorBubbleMenu } from './menus/bubble-menu';
|
||||
import { RichTextEditorExtensions } from './extensions';
|
||||
|
||||
export type UploadImage = (file: File) => Promise<string>;
|
||||
export type DeleteImage = (assetUrlWithWorkspaceId: string) => Promise<any>;
|
||||
|
||||
interface IRichTextEditor {
|
||||
value: string;
|
||||
uploadFile: UploadImage;
|
||||
deleteFile: DeleteImage;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
onChange?: (json: any, html: string) => void;
|
||||
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
|
||||
setShouldShowAlert?: (showAlert: boolean) => void;
|
||||
forwardedRef?: any;
|
||||
debouncedUpdatesEnabled?: boolean;
|
||||
}
|
||||
|
||||
interface RichTextEditorProps extends IRichTextEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const RichTextEditor = ({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
editorContentCustomClassNames,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
forwardedRef,
|
||||
}: RichTextEditorProps) => {
|
||||
const editor = useEditor({
|
||||
onChange,
|
||||
debouncedUpdatesEnabled,
|
||||
setIsSubmitting,
|
||||
setShouldShowAlert,
|
||||
value,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
forwardedRef,
|
||||
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting)
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
{editor && <EditorBubbleMenu editor={editor} />}
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
</div>
|
||||
</EditorContainer >
|
||||
);
|
||||
};
|
||||
|
||||
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>((props, ref) => (
|
||||
<RichTextEditor {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";
|
||||
|
||||
export { RichTextEditor, RichTextEditorWithRef};
|
||||
@@ -1,54 +0,0 @@
|
||||
"use client"
|
||||
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from '@plane/editor-core';
|
||||
import * as React from 'react';
|
||||
|
||||
interface IRichTextReadOnlyEditor {
|
||||
value: string;
|
||||
editorContentCustomClassNames?: string;
|
||||
noBorder?: boolean;
|
||||
borderOnFocus?: boolean;
|
||||
customClassName?: string;
|
||||
}
|
||||
|
||||
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
|
||||
forwardedRef?: React.Ref<EditorHandle>;
|
||||
}
|
||||
|
||||
interface EditorHandle {
|
||||
clearEditor: () => void;
|
||||
setEditorValue: (content: string) => void;
|
||||
}
|
||||
|
||||
const RichReadOnlyEditor = ({
|
||||
editorContentCustomClassNames,
|
||||
noBorder,
|
||||
borderOnFocus,
|
||||
customClassName,
|
||||
value,
|
||||
forwardedRef,
|
||||
}: RichTextReadOnlyEditorProps) => {
|
||||
const editor = useReadOnlyEditor({
|
||||
value,
|
||||
forwardedRef,
|
||||
});
|
||||
|
||||
const editorClassNames = getEditorClassNames({ noBorder, borderOnFocus, customClassName });
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
|
||||
<div className="flex flex-col">
|
||||
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
|
||||
</div>
|
||||
</EditorContainer >
|
||||
);
|
||||
};
|
||||
|
||||
const RichReadOnlyEditorWithRef = React.forwardRef<EditorHandle, IRichTextReadOnlyEditor>((props, ref) => (
|
||||
<RichReadOnlyEditor {...props} forwardedRef={ref} />
|
||||
));
|
||||
|
||||
RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
|
||||
|
||||
export { RichReadOnlyEditor , RichReadOnlyEditorWithRef };
|
||||
@@ -1,6 +0,0 @@
|
||||
const sharedConfig = require("tailwind-config-custom/tailwind.config.js");
|
||||
|
||||
module.exports = {
|
||||
// prefix ui lib classes to avoid conflicting with the app
|
||||
...sharedConfig,
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"extends": "tsconfig/react.json",
|
||||
"include": [
|
||||
"src/**/*",
|
||||
"index.d.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"build",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineConfig, Options } from "tsup";
|
||||
|
||||
export default defineConfig((options: Options) => ({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: false,
|
||||
external: ["react"],
|
||||
injectStyle: true,
|
||||
...options,
|
||||
}));
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "eslint-config-custom",
|
||||
"version": "0.13.2",
|
||||
"version": "0.0.0",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
{
|
||||
"name": "tailwind-config-custom",
|
||||
"version": "0.13.2",
|
||||
"version": "0.0.1",
|
||||
"description": "common tailwind configuration across monorepo",
|
||||
"main": "index.js",
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-tailwindcss": "^0.3.0",
|
||||
"tailwindcss": "^3.2.7",
|
||||
"tailwindcss-animate": "^1.0.6"
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
const convertToRGB = (variableName) => `rgba(var(${variableName}))`;
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: "class",
|
||||
content: {
|
||||
relative: true,
|
||||
files: [
|
||||
"./components/**/*.tsx",
|
||||
"./constants/**/*.{js,ts,jsx,tsx}",
|
||||
"./layouts/**/*.tsx",
|
||||
"./pages/**/*.tsx",
|
||||
"./ui/**/*.tsx",
|
||||
"../packages/editor/**/*.{js,ts,jsx,tsx}"
|
||||
]
|
||||
},
|
||||
content: [
|
||||
"./components/**/*.tsx",
|
||||
"./constants/**/*.{js,ts,jsx,tsx}",
|
||||
"./layouts/**/*.tsx",
|
||||
"./pages/**/*.tsx",
|
||||
"./ui/**/*.tsx",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
boxShadow: {
|
||||
|
||||
@@ -16,7 +16,5 @@
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tsconfig",
|
||||
"version": "0.13.2",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"files": [
|
||||
"base.json",
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"display": "React Library",
|
||||
"extends": "./base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2015", "DOM"],
|
||||
"jsx": "react",
|
||||
"lib": ["ES2015"],
|
||||
"module": "ESNext",
|
||||
"target": "es6"
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# UI Package
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"version": "0.13.2",
|
||||
"version": "0.0.0",
|
||||
"main": "./index.tsx",
|
||||
"types": "./index.tsx",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -10,12 +10,9 @@ import githubWhiteImage from "public/logos/github-white.svg";
|
||||
|
||||
export interface GithubLoginButtonProps {
|
||||
handleSignIn: React.Dispatch<string>;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// states
|
||||
export const GithubLoginButton: FC<GithubLoginButtonProps> = ({ handleSignIn }) => {
|
||||
const [loginCallBackURL, setLoginCallBackURL] = useState(undefined);
|
||||
const [gitCode, setGitCode] = useState<null | string>(null);
|
||||
|
||||
@@ -41,7 +38,7 @@ export const GithubLoginButton: FC<GithubLoginButtonProps> = (props) => {
|
||||
<div className="w-full flex justify-center items-center">
|
||||
<Link
|
||||
className="w-full"
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
href={`https://github.com/login/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_GITHUB_ID}&redirect_uri=${loginCallBackURL}&scope=read:user,user:email`}
|
||||
>
|
||||
<button className="flex w-full items-center justify-center gap-2 rounded border border-custom-border-300 p-2 text-sm font-medium text-custom-text-100 duration-300 hover:bg-custom-background-80 h-[46px]">
|
||||
<Image
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { FC, useEffect, useRef, useCallback, useState } from "react";
|
||||
import { FC, CSSProperties, useEffect, useRef, useCallback, useState } from "react";
|
||||
|
||||
import Script from "next/script";
|
||||
|
||||
export interface IGoogleLoginButton {
|
||||
clientId: string;
|
||||
text?: string;
|
||||
handleSignIn: React.Dispatch<any>;
|
||||
styles?: CSSProperties;
|
||||
}
|
||||
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = (props) => {
|
||||
const { handleSignIn, clientId } = props;
|
||||
// refs
|
||||
export const GoogleLoginButton: FC<IGoogleLoginButton> = ({ handleSignIn }) => {
|
||||
const googleSignInButton = useRef<HTMLDivElement>(null);
|
||||
// states
|
||||
const [gsiScriptLoaded, setGsiScriptLoaded] = useState(false);
|
||||
|
||||
const loadScript = useCallback(() => {
|
||||
if (!googleSignInButton.current || gsiScriptLoaded) return;
|
||||
|
||||
(window as any)?.google?.accounts.id.initialize({
|
||||
client_id: clientId,
|
||||
client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENTID || "",
|
||||
callback: handleSignIn,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import React from "react";
|
||||
import useSWR from "swr";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
// mobx
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// services
|
||||
import authenticationService from "services/authentication.service";
|
||||
import { AppConfigService } from "services/app-config.service";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// components
|
||||
import { EmailPasswordForm, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||
import { EmailPasswordForm, GithubLoginButton, GoogleLoginButton, EmailCodeForm } from "components/accounts";
|
||||
// images
|
||||
const imagePrefix = Boolean(parseInt(process.env.NEXT_PUBLIC_DEPLOY_WITH_NGINX || "0")) ? "/spaces" : "";
|
||||
|
||||
const appConfig = new AppConfigService();
|
||||
|
||||
export const SignInView = observer(() => {
|
||||
const { user: userStore } = useMobxStore();
|
||||
// router
|
||||
|
||||
const router = useRouter();
|
||||
const { next_path } = router.query as { next_path: string };
|
||||
// toast
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
// fetch app config
|
||||
const { data } = useSWR("APP_CONFIG", () => appConfig.envConfig());
|
||||
|
||||
const onSignInError = (error: any) => {
|
||||
setToastAlert({
|
||||
@@ -35,17 +31,17 @@ export const SignInView = observer(() => {
|
||||
};
|
||||
|
||||
const onSignInSuccess = (response: any) => {
|
||||
const isOnboarded = response?.user?.onboarding_step?.profile_complete || false;
|
||||
|
||||
const nextPath = router.asPath.includes("next_path") ? router.asPath.split("/?next_path=")[1] : "/login";
|
||||
|
||||
userStore.setCurrentUser(response?.user);
|
||||
|
||||
const isOnboard = response?.user?.onboarding_step?.profile_complete || false;
|
||||
|
||||
if (isOnboard) {
|
||||
if (next_path) router.push(next_path);
|
||||
else router.push("/login");
|
||||
} else {
|
||||
if (next_path) router.push(`/onboarding?next_path=${next_path}`);
|
||||
else router.push("/onboarding");
|
||||
if (!isOnboarded) {
|
||||
router.push(`/onboarding?next_path=${nextPath}`);
|
||||
return;
|
||||
}
|
||||
router.push((nextPath ?? "/login").toString());
|
||||
};
|
||||
|
||||
const handleGoogleSignIn = async ({ clientId, credential }: any) => {
|
||||
@@ -67,6 +63,24 @@ export const SignInView = observer(() => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitHubSignIn = async (credential: string) => {
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_GITHUB_ID && credential) {
|
||||
const socialAuthPayload = {
|
||||
medium: "github",
|
||||
credential,
|
||||
clientId: process.env.NEXT_PUBLIC_GITHUB_ID,
|
||||
};
|
||||
const response = await authenticationService.socialAuth(socialAuthPayload);
|
||||
onSignInSuccess(response);
|
||||
} else {
|
||||
throw Error("Cant find credentials");
|
||||
}
|
||||
} catch (err: any) {
|
||||
onSignInError(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSignIn = async (formData: any) => {
|
||||
await authenticationService
|
||||
.emailLogin(formData)
|
||||
@@ -104,32 +118,38 @@ export const SignInView = observer(() => {
|
||||
</div>
|
||||
<div className="grid place-items-center h-full overflow-y-auto py-5 px-7">
|
||||
<div>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">Sign in to Plane</h1>
|
||||
{data?.email_password_login && <EmailPasswordForm onSubmit={handlePasswordSignIn} />}
|
||||
|
||||
{data?.magic_login && (
|
||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||
<div className="pb-7">
|
||||
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<>
|
||||
<h1 className="text-center text-2xl sm:text-2.5xl font-semibold text-custom-text-100">
|
||||
Sign in to Plane
|
||||
</h1>
|
||||
<div className="flex flex-col divide-y divide-custom-border-200">
|
||||
<div className="pb-7">
|
||||
<EmailCodeForm handleSignIn={handleEmailCodeSignIn} />
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||
<GoogleLoginButton handleSignIn={handleGoogleSignIn} />
|
||||
{/* <GithubLoginButton handleSignIn={handleGitHubSignIn} /> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<EmailPasswordForm onSubmit={handlePasswordSignIn} />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col items-center justify-center gap-4 pt-7 sm:w-[360px] mx-auto overflow-hidden">
|
||||
{data?.google && <GoogleLoginButton clientId={data.google} handleSignIn={handleGoogleSignIn} />}
|
||||
</div>
|
||||
|
||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||
By signing up, you agree to the{" "}
|
||||
<a
|
||||
href="https://plane.so/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
</p>
|
||||
{parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? (
|
||||
<p className="pt-16 text-custom-text-200 text-sm text-center">
|
||||
By signing up, you agree to the{" "}
|
||||
<a
|
||||
href="https://plane.so/terms-and-conditions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium underline"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,43 +44,19 @@ const IssueNavbar = observer(() => {
|
||||
}, [projectStore, workspace_slug, project_slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace_slug && project_slug && projectStore?.deploySettings) {
|
||||
const viewsAcceptable: string[] = [];
|
||||
let currentBoard: string | null = null;
|
||||
|
||||
if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list");
|
||||
if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban");
|
||||
if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar");
|
||||
if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt");
|
||||
if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet");
|
||||
|
||||
if (board) {
|
||||
if (viewsAcceptable.includes(board.toString())) {
|
||||
currentBoard = board.toString();
|
||||
} else {
|
||||
if (viewsAcceptable && viewsAcceptable.length > 0) {
|
||||
currentBoard = viewsAcceptable[0];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (viewsAcceptable && viewsAcceptable.length > 0) {
|
||||
currentBoard = viewsAcceptable[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (currentBoard) {
|
||||
if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) {
|
||||
projectStore.setActiveBoard(currentBoard);
|
||||
router.push({
|
||||
pathname: `/${workspace_slug}/${project_slug}`,
|
||||
query: {
|
||||
board: currentBoard,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (workspace_slug && project_slug) {
|
||||
if (!board) {
|
||||
router.push({
|
||||
pathname: `/${workspace_slug}/${project_slug}`,
|
||||
query: {
|
||||
board: "list",
|
||||
},
|
||||
});
|
||||
return projectStore.setActiveBoard("list");
|
||||
}
|
||||
projectStore.setActiveBoard(board.toString());
|
||||
}
|
||||
}, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]);
|
||||
}, [board, workspace_slug, project_slug]);
|
||||
|
||||
return (
|
||||
<div className="px-5 relative w-full flex items-center gap-4">
|
||||
@@ -129,7 +105,7 @@ const IssueNavbar = observer(() => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-shrink-0">
|
||||
<Link href={`/login/?next_path=${router.asPath}`}>
|
||||
<Link href={`/?next_path=${router.asPath}`}>
|
||||
<a>
|
||||
<PrimaryButton className="flex-shrink-0" outline>
|
||||
Sign in
|
||||
|
||||
@@ -11,9 +11,7 @@ import { SecondaryButton } from "components/ui";
|
||||
// types
|
||||
import { Comment } from "types/issue";
|
||||
// components
|
||||
import { LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||
import fileService from "services/file.service";
|
||||
// service
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
|
||||
const defaultValues: Partial<Comment> = {
|
||||
comment_html: "",
|
||||
@@ -71,14 +69,8 @@ export const AddComment: React.FC<Props> = observer((props) => {
|
||||
name="comment_html"
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<LiteTextEditorWithRef
|
||||
onEnterKeyPress={(e) => {
|
||||
userStore.requiredLogin(() => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
});
|
||||
}}
|
||||
uploadFile={fileService.getUploadFileFunction(workspace_slug as string)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspace_slug as string}
|
||||
ref={editorRef}
|
||||
value={
|
||||
!value || value === "" || (typeof value === "object" && Object.keys(value).length === 0)
|
||||
|
||||
@@ -9,8 +9,7 @@ import { Menu, Transition } from "@headlessui/react";
|
||||
// lib
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { LiteReadOnlyEditorWithRef, LiteTextEditorWithRef } from "@plane/lite-text-editor";
|
||||
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
import { CommentReactions } from "components/issues/peek-overview";
|
||||
// icons
|
||||
import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon } from "@heroicons/react/24/outline";
|
||||
@@ -18,8 +17,6 @@ import { ChatBubbleLeftEllipsisIcon, CheckIcon, XMarkIcon, EllipsisVerticalIcon
|
||||
import { timeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { Comment } from "types/issue";
|
||||
import fileService from "services/file.service";
|
||||
// services
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -103,10 +100,8 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
control={control}
|
||||
name="comment_html"
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<LiteTextEditorWithRef
|
||||
onEnterKeyPress={handleSubmit(handleCommentUpdate)}
|
||||
uploadFile={fileService.getUploadFileFunction(workspaceSlug)}
|
||||
deleteFile={fileService.deleteImage}
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
debouncedUpdatesEnabled={false}
|
||||
@@ -136,9 +131,11 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
</form>
|
||||
<div className={`${isEditing ? "hidden" : ""}`}>
|
||||
<LiteReadOnlyEditorWithRef
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
ref={showEditorRef}
|
||||
value={comment.comment_html}
|
||||
editable={false}
|
||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||
/>
|
||||
<CommentReactions commentId={comment.id} projectId={comment.project} />
|
||||
@@ -150,7 +147,7 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
<Menu as="div" className="relative w-min text-left">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
onClick={() => { }}
|
||||
onClick={() => {}}
|
||||
className="relative grid place-items-center rounded p-1 text-custom-text-200 hover:text-custom-text-100 outline-none cursor-pointer hover:bg-custom-background-80"
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5 text-custom-text-200 duration-300" />
|
||||
@@ -174,8 +171,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
onClick={() => {
|
||||
setIsEditing(true);
|
||||
}}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -188,8 +186,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
className={`w-full select-none truncate rounded px-1 py-1.5 text-left text-custom-text-200 hover:bg-custom-background-80 ${
|
||||
active ? "bg-custom-background-80" : ""
|
||||
}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IssueReactions } from "components/issues/peek-overview";
|
||||
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
|
||||
import { TipTapEditor } from "components/tiptap";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { IIssue } from "types/issue";
|
||||
|
||||
@@ -7,22 +8,33 @@ type Props = {
|
||||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => (
|
||||
<div className="space-y-2">
|
||||
<h6 className="font-medium text-custom-text-200">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
</h6>
|
||||
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
|
||||
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
|
||||
<RichReadOnlyEditor
|
||||
value={!issueDetails.description_html ||
|
||||
issueDetails.description_html === "" ||
|
||||
(typeof issueDetails.description_html === "object" &&
|
||||
Object.keys(issueDetails.description_html).length === 0)
|
||||
? "<p></p>"
|
||||
: issueDetails.description_html}
|
||||
customClassName="p-3 min-h-[50px] shadow-sm" />
|
||||
)}
|
||||
<IssueReactions />
|
||||
</div>
|
||||
);
|
||||
export const PeekOverviewIssueDetails: React.FC<Props> = ({ issueDetails }) => {
|
||||
const router = useRouter();
|
||||
const { workspace_slug } = router.query;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h6 className="font-medium text-custom-text-200">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
</h6>
|
||||
<h4 className="break-words text-2xl font-semibold">{issueDetails.name}</h4>
|
||||
{issueDetails.description_html !== "" && issueDetails.description_html !== "<p></p>" && (
|
||||
<TipTapEditor
|
||||
workspaceSlug={workspace_slug as string}
|
||||
value={
|
||||
!issueDetails.description_html ||
|
||||
issueDetails.description_html === "" ||
|
||||
(typeof issueDetails.description_html === "object" &&
|
||||
Object.keys(issueDetails.description_html).length === 0)
|
||||
? "<p></p>"
|
||||
: issueDetails.description_html
|
||||
}
|
||||
customClassName="p-3 min-h-[50px] shadow-sm"
|
||||
debouncedUpdatesEnabled={false}
|
||||
editable={false}
|
||||
/>
|
||||
)}
|
||||
<IssueReactions />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+32
-7
@@ -1,10 +1,10 @@
|
||||
import { BubbleMenu, BubbleMenuProps } from "@tiptap/react";
|
||||
import { FC, useState } from "react";
|
||||
import { BoldIcon } from "lucide-react";
|
||||
import { BoldIcon, ItalicIcon, UnderlineIcon, StrikethroughIcon, CodeIcon } from "lucide-react";
|
||||
|
||||
import { NodeSelector } from "./node-selector";
|
||||
import { LinkSelector } from "./link-selector";
|
||||
import { BoldItem, cn, CodeItem, ItalicItem, StrikeThroughItem, UnderLineItem } from "@plane/editor-core";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -17,11 +17,36 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children">;
|
||||
|
||||
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props: any) => {
|
||||
const items: BubbleMenuItem[] = [
|
||||
BoldItem(props.editor),
|
||||
ItalicItem(props.editor),
|
||||
UnderLineItem(props.editor),
|
||||
StrikeThroughItem(props.editor),
|
||||
CodeItem(props.editor),
|
||||
{
|
||||
name: "bold",
|
||||
isActive: () => props.editor?.isActive("bold"),
|
||||
command: () => props.editor?.chain().focus().toggleBold().run(),
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
isActive: () => props.editor?.isActive("italic"),
|
||||
command: () => props.editor?.chain().focus().toggleItalic().run(),
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
{
|
||||
name: "underline",
|
||||
isActive: () => props.editor?.isActive("underline"),
|
||||
command: () => props.editor?.chain().focus().toggleUnderline().run(),
|
||||
icon: UnderlineIcon,
|
||||
},
|
||||
{
|
||||
name: "strike",
|
||||
isActive: () => props.editor?.isActive("strike"),
|
||||
command: () => props.editor?.chain().focus().toggleStrike().run(),
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
{
|
||||
name: "code",
|
||||
isActive: () => props.editor?.isActive("code"),
|
||||
command: () => props.editor?.chain().focus().toggleCode().run(),
|
||||
icon: CodeIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const bubbleMenuProps: EditorBubbleMenuProps = {
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user