[WEB-460] refactor: editors, chore: pages list improvement (#4090)

* fix: stroing the transactions in page

* fix: page details changes

* chore: page response change

* chore: removed duplicated endpoints

* chore: optimised the urls

* chore: removed archived and favorite pages

* chore: revamping pages store and components

* mentions loading state part done

* fixed mentions not showing in modals

* removed comments and cleaned up types

* removed unused types

* reset: head

* chore: pages store and component updates

* style: pages list item UI

* fix: improved colors and drag handle width

* fix: slash commands are no more shown in the code blocks

* fix: cleanup/hide drag handles post drop

* fix: hide/cleanup drag handles post drag start

* fix: aligning the drag handles better with the node post css changes of the length

* fix: juggling back and forth of drag handles in ordered and unordered lists

* chore: fix imports, ts errors and other things

* fix: clearing nodes to default node i.e paragraph before converting it to other types of nodes

For more reference on what this does, please refer https://tiptap.dev/docs/editor/api/commands/clear-nodes

* chore: clearNodes after delete in case of selections being present

* fix: hiding link selector in the bubble menu if inline code block is selected

* chore: filtering, ordering and searching implemented

* chore: updated pages store and updated UI

* chore: new core editor just for document editor created

* chore: removed setIsSubmitting prop in doc editor

* fix: fixed submitting state for image uploads

* refactor: setShouldShowAlert removed

* refactor: rerenderOnPropsChange prop removed

* chore: type inference magic in ref to expose an api for controlling editor menu items from outside

* fix: naming imports

* chore: change names of the exposed functions and removing old types

* refactor: remove debouncedUpdatesEnabled prop;

* refactor: editor heading markings now parsed using html

* chore: removed unrelated components from the document editor

* refactor: page details granular components

* fix: remove onActionCompleteHandler

* refactor: removed rerenderOnProps change prop

* feat: added getMarkDown function

* chore: update dropdown option actions

* fix: sidebar markings update logic

* chore: add image and to-do list actions to the toolbar

* fix: handling refs and populating them via callbacks

* feat: scroll to node api exposed

* cleaning up editor refs when the editor is destroyed

* feat: scrolling added to read only instance of the editor

* fix: markings logic

* fix: build errors with types

* fix: build erros

* fix: subscribing to transactions of editor via ref

* chore: remove debug statements

* fix: type errors

* fix: temporary different slash commands for document editor

* chore: inline code extension style

* chore: remove border from readOnly editor

* fix: editor bottom padding

* chore: pages improvements

* chore: handle Enter key on the page title

* feat: added loading indicator logic in mentions

* fix: mentions and slash commands now work well with multiple editors in one place

* refactor: page store structure, filtering logic

* feat: added better seperation in inline code blocks

* feat: list autojoining added

* fix: pages folder structure

* fix: image refocus from external parts

* working lists somewhat

* chore: implement page reactions

* fix: build errors

* fix: build errors

* fixed drag handles stuff

* task list item fixed

* working

* fix: working on multiple nested lists

* chore: remove debug statements

* fix: Tab key on first list item handled to not go out of editor focus

* feat: threshold auto scroll support added and multi nested list selection fixed

* fix: caret color bug with improved inline code blocks

* fix: node range error when bulk deleting with list

* fix: removed slash commands from working in code blocks

* chore: update typography margins

* chore: new field added in page model

* fix: better type inference in slash commands

* chore: code block UI

* feat: image insertion at correct position using ref added

* feat: added improved mentions support for space

* fix: type errors in mentions for comments in web app

* sync: core with document-core

* fix: build errors

* fix: fallback for appendTo not being able to find active container instantly

* fix: page store

* fix: page description

* fix: css quality issues

* chore: code cleanup

* chore: removed placeholder text in codeblocks

* chore: archived pages response change

* chore: archived pages response change

* fix: initial pages list fetch

* fix: pages list filters and ordering

* chore: add access change option in the quick actions dropdown

* fix: inline code block caret fixed

* regression: removing extra text

* chore: caret color removed

* feat: copy code button added in code blocks

* fix: initial load of page details

* fix: initial load of page details

* fix: image resizing weird behavior on click/expanding it too much fixed now

* chore: copy page response

* fix: todo list spacing

* chore: description html in the copy page

* chore: handle latest description on refetch

* fix: saner scroll behaviours

* fix: block menu positioning

* fix: updated empty string description

* feat: tab change sync support added

* fix: infinite rerendering with markings

* fix: block menu finally

* fix: intial load on reload bug fixed

* fix: nested lists alignment

* fix: editor padding

* fix: first level list items copyable

* chore: list spacing

* fix: title change

* fix: pages list block items interaction

* fix: saving chip position

* fix: delete action from block menu to focus properly

* fix: margin-bottom as 0 to avoid weird spacing when a paragraph node follows a list node

* style: table, chore: lite text editor toolbar

* fix: page description tab sync

* fix: lists spacing and alignment

* refactor: document editor props

* feat: rich text editor wrapper created and migrated core

* feat: created wrapper around lite text editor and merged core

* chore: add lite text editor toolbar

* fix: build errors

* fix: type errors and addead live updation of toolbar

* chore: pages migration

* fix: inbox issue

* refactor: remove redundant package

* refactor: unused files

* fix: add dompurify to space app

* fix: inline code margin

* fix: editor className props

* fix: build errors

* fix: traversing up the tree before assuming the parent is not a list item

* fix: drag handle positions for list items fixed

* fix: removed focus at end logic after deleting block

* fix: image wrapper overflow scroll fix with block menu's position

* fix: selection and deletion logic for nested lists fixed!!

* fix: hiding the block menu while scrolling in the document/app

* fix: merge conflicts resolved from develop

* fix: inbox issue description

* chore: move page title to the web app

* fix: handling edge cases for table selection

* chore: lint issues

* refactor: list item functions moved to same file

* refactor: use mention hook

* fix: added try catch blocks for mention suggestions

* chore: remove unused code

* fix: remove console logs

* fix: remove console logs

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
Co-authored-by: sriram veeraghanta <veeraghanta.sriram@gmail.com>
Co-authored-by: gurusainath <gurusainath007@gmail.com>
Co-authored-by: Palanikannan1437 <73993394+Palanikannan1437@users.noreply.github.com>
This commit is contained in:
Aaryan Khandelwal 2024-04-11 21:28:59 +05:30 committed by GitHub
parent 8b6035d315
commit 3e2355e223
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
248 changed files with 7602 additions and 5619 deletions

View File

@ -93,6 +93,7 @@ from .page import (
PageSerializer,
PageLogSerializer,
SubPageSerializer,
PageDetailSerializer,
PageFavoriteSerializer,
)

View File

@ -3,9 +3,6 @@ from rest_framework import serializers
# Module imports
from .base import BaseSerializer
from .issue import LabelLiteSerializer
from .workspace import WorkspaceLiteSerializer
from .project import ProjectLiteSerializer
from plane.db.models import (
Page,
PageLog,
@ -17,22 +14,33 @@ from plane.db.models import (
class PageSerializer(BaseSerializer):
is_favorite = serializers.BooleanField(read_only=True)
label_details = LabelLiteSerializer(
read_only=True, source="labels", many=True
)
labels = serializers.ListField(
child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.all()),
write_only=True,
required=False,
)
project_detail = ProjectLiteSerializer(source="project", read_only=True)
workspace_detail = WorkspaceLiteSerializer(
source="workspace", read_only=True
)
class Meta:
model = Page
fields = "__all__"
fields = [
"id",
"name",
"owned_by",
"access",
"color",
"labels",
"parent",
"is_favorite",
"is_locked",
"archived_at",
"workspace",
"project",
"created_at",
"updated_at",
"created_by",
"updated_by",
"view_props",
]
read_only_fields = [
"workspace",
"project",
@ -48,8 +56,12 @@ class PageSerializer(BaseSerializer):
labels = validated_data.pop("labels", None)
project_id = self.context["project_id"]
owned_by_id = self.context["owned_by_id"]
description_html = self.context["description_html"]
page = Page.objects.create(
**validated_data, project_id=project_id, owned_by_id=owned_by_id
**validated_data,
description_html=description_html,
project_id=project_id,
owned_by_id=owned_by_id,
)
if labels is not None:
@ -91,6 +103,13 @@ class PageSerializer(BaseSerializer):
return super().update(instance, validated_data)
class PageDetailSerializer(PageSerializer):
description_html = serializers.CharField()
class Meta(PageSerializer.Meta):
fields = PageSerializer.Meta.fields + ["description_html"]
class SubPageSerializer(BaseSerializer):
entity_details = serializers.SerializerMethodField()

View File

@ -31,102 +31,51 @@ urlpatterns = [
),
name="project-pages",
),
# favorite pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/",
"workspaces/<str:slug>/projects/<uuid:project_id>/favorite-pages/<uuid:pk>/",
PageFavoriteViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="user-favorite-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/user-favorite-pages/<uuid:page_id>/",
PageFavoriteViewSet.as_view(
{
"delete": "destroy",
}
),
name="user-favorite-pages",
),
# archived pages
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
PageViewSet.as_view(
{
"get": "list",
"post": "create",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/",
PageViewSet.as_view(
{
"get": "retrieve",
"patch": "partial_update",
"delete": "destroy",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/archive/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/archive/",
PageViewSet.as_view(
{
"post": "archive",
"delete": "unarchive",
}
),
name="project-page-archive",
name="project-page-archive-unarchive",
),
# lock and unlock
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unarchive/",
PageViewSet.as_view(
{
"post": "unarchive",
}
),
name="project-page-unarchive",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/archived-pages/",
PageViewSet.as_view(
{
"get": "archive_list",
}
),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/lock/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/lock/",
PageViewSet.as_view(
{
"post": "lock",
"delete": "unlock",
}
),
name="project-pages",
name="project-pages-lock-unlock",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/unlock/",
PageViewSet.as_view(
{
"post": "unlock",
}
),
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/transactions/<uuid:transaction>/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/transactions/<uuid:transaction>/",
PageLogEndpoint.as_view(),
name="page-transactions",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/sub-pages/",
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:pk>/sub-pages/",
SubPagesEndpoint.as_view(),
name="sub-page",
),

View File

@ -1,4 +1,5 @@
# Python imports
import json
from datetime import datetime
# Django imports
@ -17,6 +18,7 @@ from plane.app.serializers import (
PageLogSerializer,
PageSerializer,
SubPageSerializer,
PageDetailSerializer,
)
from plane.db.models import (
Page,
@ -28,6 +30,8 @@ from plane.db.models import (
# Module imports
from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
def unarchive_archive_page_and_descendants(page_id, archived_at):
# Your SQL query
@ -87,11 +91,21 @@ class PageViewSet(BaseViewSet):
def create(self, request, slug, project_id):
serializer = PageSerializer(
data=request.data,
context={"project_id": project_id, "owned_by_id": request.user.id},
context={
"project_id": project_id,
"owned_by_id": request.user.id,
"description_html": request.data.get(
"description_html", "<p></p>"
),
},
)
if serializer.is_valid():
serializer.save()
# capture the page transaction
page_transaction.delay(request.data, None, serializer.data["id"])
page = Page.objects.get(pk=serializer.data["id"])
serializer = PageDetailSerializer(page)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -125,9 +139,22 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
serializer = PageSerializer(page, data=request.data, partial=True)
serializer = PageDetailSerializer(
page, data=request.data, partial=True
)
if serializer.is_valid():
serializer.save()
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data,
old_value=json.dumps(
{
"description_html": page.description_html,
}
),
page_id=pk,
)
return Response(serializer.data, status=status.HTTP_200_OK)
return Response(
serializer.errors, status=status.HTTP_400_BAD_REQUEST
@ -140,18 +167,24 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
def lock(self, request, slug, project_id, page_id):
def retrieve(self, request, slug, project_id, pk=None):
page = self.get_queryset().filter(pk=pk).first()
return Response(
PageDetailSerializer(page).data, status=status.HTTP_200_OK
)
def lock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, project_id=project_id
).first()
page.is_locked = True
page.save()
return Response(status=status.HTTP_204_NO_CONTENT)
def unlock(self, request, slug, project_id, page_id):
def unlock(self, request, slug, project_id, pk):
page = Page.objects.filter(
pk=page_id, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, project_id=project_id
).first()
page.is_locked = False
@ -160,13 +193,13 @@ class PageViewSet(BaseViewSet):
return Response(status=status.HTTP_204_NO_CONTENT)
def list(self, request, slug, project_id):
queryset = self.get_queryset().filter(archived_at__isnull=True)
queryset = self.get_queryset()
pages = PageSerializer(queryset, many=True).data
return Response(pages, status=status.HTTP_200_OK)
def archive(self, request, slug, project_id, page_id):
def archive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, project_id=project_id
)
# only the owner or admin can archive the page
@ -184,13 +217,16 @@ class PageViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
unarchive_archive_page_and_descendants(page_id, datetime.now())
unarchive_archive_page_and_descendants(pk, datetime.now())
return Response(status=status.HTTP_204_NO_CONTENT)
return Response(
{"archived_at": str(datetime.now())},
status=status.HTTP_200_OK,
)
def unarchive(self, request, slug, project_id, page_id):
def unarchive(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=page_id, workspace__slug=slug, project_id=project_id
pk=pk, workspace__slug=slug, project_id=project_id
)
# only the owner or admin can un archive the page
@ -213,19 +249,10 @@ class PageViewSet(BaseViewSet):
page.parent = None
page.save(update_fields=["parent"])
unarchive_archive_page_and_descendants(page_id, None)
unarchive_archive_page_and_descendants(pk, None)
return Response(status=status.HTTP_204_NO_CONTENT)
def archive_list(self, request, slug, project_id):
pages = Page.objects.filter(
project_id=project_id,
workspace__slug=slug,
).filter(archived_at__isnull=False)
pages = PageSerializer(pages, many=True).data
return Response(pages, status=status.HTTP_200_OK)
def destroy(self, request, slug, project_id, pk):
page = Page.objects.get(
pk=pk, workspace__slug=slug, project_id=project_id
@ -269,29 +296,20 @@ class PageFavoriteViewSet(BaseViewSet):
serializer_class = PageFavoriteSerializer
model = PageFavorite
def get_queryset(self):
return self.filter_queryset(
super()
.get_queryset()
.filter(archived_at__isnull=True)
.filter(workspace__slug=self.kwargs.get("slug"))
.filter(user=self.request.user)
.select_related("page", "page__owned_by")
def create(self, request, slug, project_id, pk):
_ = PageFavorite.objects.create(
project_id=project_id,
page_id=pk,
user=request.user,
)
return Response(status=status.HTTP_204_NO_CONTENT)
def create(self, request, slug, project_id):
serializer = PageFavoriteSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, project_id=project_id)
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, page_id):
def destroy(self, request, slug, project_id, pk):
page_favorite = PageFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
page_id=page_id,
page_id=pk,
)
page_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -14,6 +14,7 @@ from plane.app.permissions import (
from plane.db.models import State, Issue
from plane.utils.cache import invalidate_cache
class StateViewSet(BaseViewSet):
serializer_class = StateSerializer
model = State
@ -38,7 +39,9 @@ class StateViewSet(BaseViewSet):
.distinct()
)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
def create(self, request, slug, project_id):
serializer = StateSerializer(data=request.data)
if serializer.is_valid():
@ -59,7 +62,9 @@ class StateViewSet(BaseViewSet):
return Response(state_dict, status=status.HTTP_200_OK)
return Response(states, status=status.HTTP_200_OK)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
def mark_as_default(self, request, slug, project_id, pk):
# Select all the states which are marked as default
_ = State.objects.filter(
@ -70,7 +75,9 @@ class StateViewSet(BaseViewSet):
).update(default=True)
return Response(status=status.HTTP_204_NO_CONTENT)
@invalidate_cache(path="workspaces/:slug/states/", url_params=True, user=False)
@invalidate_cache(
path="workspaces/:slug/states/", url_params=True, user=False
)
def destroy(self, request, slug, project_id, pk):
state = State.objects.get(
is_triage=False,

View File

@ -326,11 +326,11 @@ class IssueViewFavoriteViewSet(BaseViewSet):
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
def destroy(self, request, slug, project_id, view_id):
view_favourite = IssueViewFavorite.objects.get(
view_favorite = IssueViewFavorite.objects.get(
project=project_id,
user=request.user,
workspace__slug=slug,
view_id=view_id,
)
view_favourite.delete()
view_favorite.delete()
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -0,0 +1,76 @@
# Python imports
import json
# Django imports
from django.utils import timezone
# Third-party imports
from bs4 import BeautifulSoup
# Module imports
from plane.db.models import Page, PageLog
from celery import shared_task
def extract_components(value, tag):
try:
mentions = []
html = value.get("description_html")
soup = BeautifulSoup(html, "html.parser")
mention_tags = soup.find_all(tag)
for mention_tag in mention_tags:
mention = {
"id": mention_tag.get("id"),
"entity_identifier": mention_tag.get("entity_identifier"),
"entity_name": mention_tag.get("entity_name"),
}
mentions.append(mention)
return mentions
except Exception:
return []
@shared_task
def page_transaction(new_value, old_value, page_id):
page = Page.objects.get(pk=page_id)
new_page_mention = PageLog.objects.filter(page_id=page_id).exists()
old_value = json.loads(old_value)
new_transactions = []
deleted_transaction_ids = set()
# TODO - Add "issue-embed-component", "img", "todo" components
components = ["mention-component"]
for component in components:
old_mentions = extract_components(old_value, component)
new_mentions = extract_components(new_value, component)
new_mentions_ids = {mention["id"] for mention in new_mentions}
old_mention_ids = {mention["id"] for mention in old_mentions}
deleted_transaction_ids.update(old_mention_ids - new_mentions_ids)
new_transactions.extend(
PageLog(
transaction=mention["id"],
page_id=page_id,
entity_identifier=mention["entity_identifier"],
entity_name=mention["entity_name"],
workspace_id=page.workspace_id,
project_id=page.project_id,
created_at=timezone.now(),
updated_at=timezone.now(),
)
for mention in new_mentions
if mention["id"] not in old_mention_ids or not new_page_mention
)
# Create new PageLog objects for new transactions
PageLog.objects.bulk_create(new_transactions, batch_size=10, ignore_conflicts=True)
# Delete the removed transactions
PageLog.objects.filter(
transaction__in=deleted_transaction_ids
).delete()

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.10 on 2024-04-09 11:34
from django.db import migrations, models
import plane.db.models.page
class Migration(migrations.Migration):
dependencies = [
('db', '0063_state_is_triage_alter_state_group'),
]
operations = [
migrations.AddField(
model_name="page",
name="view_props",
field=models.JSONField(
default=plane.db.models.page.get_view_props
),
),
]

View File

@ -9,6 +9,10 @@ from . import ProjectBaseModel
from plane.utils.html_processor import strip_tags
def get_view_props():
return {"full_width": False}
class Page(ProjectBaseModel):
name = models.CharField(max_length=255)
description = models.JSONField(default=dict, blank=True)
@ -35,6 +39,7 @@ class Page(ProjectBaseModel):
)
archived_at = models.DateField(null=True)
is_locked = models.BooleanField(default=False)
view_props = models.JSONField(default=get_view_props)
class Meta:
verbose_name = "Page"
@ -81,7 +86,7 @@ class PageLog(ProjectBaseModel):
ordering = ("-created_at",)
def __str__(self):
return f"{self.page.name} {self.type}"
return f"{self.page.name} {self.entity_name}"
class PageBlock(ProjectBaseModel):

View File

@ -28,6 +28,7 @@
"react-dom": "18.2.0"
},
"dependencies": {
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/extension-blockquote": "^2.1.13",
"@tiptap/extension-code-block-lowlight": "^2.1.13",
@ -39,6 +40,7 @@
"@tiptap/extension-task-list": "^2.1.13",
"@tiptap/extension-text-style": "^2.1.13",
"@tiptap/extension-underline": "^2.1.13",
"prosemirror-codemark": "^0.4.2",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",
"@tiptap/starter-kit": "^2.1.13",

View File

@ -0,0 +1,40 @@
import { Editor } from "@tiptap/react";
export interface IMarking {
type: "heading";
level: number;
text: string;
sequence: number;
}
function findNthH1(editor: Editor, n: number, level: number): number {
let count = 0;
let pos = 0;
editor.state.doc.descendants((node, position) => {
if (node.type.name === "heading" && node.attrs.level === level) {
count++;
if (count === n) {
pos = position;
return false;
}
}
});
return pos;
}
function scrollToNode(editor: Editor, pos: number): void {
const headingNode = editor.state.doc.nodeAt(pos);
if (headingNode) {
const headingDOM = editor.view.nodeDOM(pos);
if (headingDOM instanceof HTMLElement) {
headingDOM.scrollIntoView({ behavior: "smooth" });
}
}
}
export function scrollSummary(editor: Editor, marking: IMarking) {
if (editor) {
const pos = findNthH1(editor, marking.sequence, marking.level);
scrollToNode(editor, pos);
}
}

View File

@ -1,111 +1,178 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject, useState } from "react";
import { useImperativeHandle, useRef, MutableRefObject, useState, useEffect } from "react";
import { CoreEditorProps } from "src/ui/props";
import { CoreEditorExtensions } from "src/ui/extensions";
import { EditorProps } from "@tiptap/pm/view";
import { getTrimmedHTML } from "src/lib/utils";
import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
import { UploadImage } from "src/types/upload-image";
import { Selection } from "@tiptap/pm/state";
import { insertContentAtSavedSelection } from "src/helpers/insert-content-at-cursor-position";
import { EditorMenuItemNames, getEditorMenuItems } from "src/ui/menus/menu-items";
import { EditorRefApi } from "src/types/editor-ref-api";
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
interface CustomEditorProps {
id?: string;
uploadFile: UploadImage;
restoreFile: RestoreImage;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
deleteFile: DeleteImage;
cancelUploadImage?: () => any;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
value: string;
debouncedUpdatesEnabled?: boolean;
onStart?: (json: any, html: string) => void;
onChange?: (json: any, html: string) => void;
initialValue: string;
editorClassName: string;
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value: string | null | undefined;
onChange?: (json: object, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
forwardedRef?: any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
forwardedRef?: MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
handleEditorReady?: (value: boolean) => void;
}
export const useEditor = ({
uploadFile,
id = "",
deleteFile,
cancelUploadImage,
editorProps = {},
initialValue,
editorClassName,
value,
rerenderOnPropsChange,
extensions = [],
onStart,
onChange,
setIsSubmitting,
forwardedRef,
restoreFile,
setShouldShowAlert,
mentionHighlights,
mentionSuggestions,
handleEditorReady,
mentionHandler,
}: CustomEditorProps) => {
const editor = useCustomEditor(
{
editorProps: {
...CoreEditorProps(uploadFile, setIsSubmitting),
...editorProps,
},
extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
},
deleteFile,
restoreFile,
cancelUploadImage
),
...extensions,
],
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
onCreate: async ({ editor }) => {
onStart?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
onTransaction: async ({ editor }) => {
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
setIsSubmitting?.("submitting");
setShouldShowAlert?.(true);
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
const editor = useCustomEditor({
editorProps: {
...CoreEditorProps(uploadFile, editorClassName),
...editorProps,
},
[rerenderOnPropsChange]
);
extensions: [
...CoreEditorExtensions(
{
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights ?? [],
},
deleteFile,
restoreFile,
cancelUploadImage
),
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: async () => {
handleEditorReady?.(true);
},
onTransaction: async ({ editor }) => {
setSavedSelection(editor.state.selection);
},
onUpdate: async ({ editor }) => {
onChange?.(editor.getJSON(), getTrimmedHTML(editor.getHTML()));
},
onDestroy: async () => {
handleEditorReady?.(false);
},
});
// for syncing swr data on tab refocus etc, can remove it once this is merged
// https://github.com/ueberdosis/tiptap/pull/4453
useEffect(() => {
// value is null when intentionally passed where syncing is not yet
// supported and value is undefined when the data from swr is not populated
if (value === null || value === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(value);
}, [editor, value, id]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
}));
useImperativeHandle(
forwardedRef,
() => ({
clearEditor: () => {
editorRef.current?.commands.clearContent();
},
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
setEditorValueAtCursorPosition: (content: string) => {
if (savedSelection) {
insertContentAtSavedSelection(editorRef, content, savedSelection);
}
},
executeMenuItemCommand: (itemName: EditorMenuItemNames) => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
const item = getEditorMenuItem(itemName);
if (item) {
if (item.name === "image") {
item.command(savedSelection);
} else {
item.command();
}
} else {
console.warn(`No command found for item: ${itemName}`);
}
},
isMenuItemActive: (itemName: EditorMenuItemNames): boolean => {
const editorItems = getEditorMenuItems(editorRef.current, uploadFile);
const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.name === itemName);
const item = getEditorMenuItem(itemName);
return item ? item.isActive() : false;
},
onStateChange: (callback: () => void) => {
// Subscribe to editor state changes
editorRef.current?.on("transaction", () => {
callback();
});
// Return a function to unsubscribe to the continuous transactions of
// the editor on unmounting the component that has subscribed to this
// method
return () => {
editorRef.current?.off("transaction");
};
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
setFocusAtPosition: (position: number) => {
if (!editorRef.current) return;
editorRef.current
.chain()
.insertContentAt(position, [{ type: "paragraph" }])
.focus()
.run();
},
}),
[editorRef, savedSelection, uploadFile]
);
if (!editor) {
return null;
}
// the editorRef is used to access the editor instance from outside the hook
// and should only be used after editor is initialized
editorRef.current = editor;
return editor;
};

View File

@ -1,53 +1,61 @@
import { useEditor as useCustomEditor, Editor } from "@tiptap/react";
import { useImperativeHandle, useRef, MutableRefObject } from "react";
import { useImperativeHandle, useRef, MutableRefObject, useEffect } from "react";
import { CoreReadOnlyEditorExtensions } from "src/ui/read-only/extensions";
import { CoreReadOnlyEditorProps } from "src/ui/read-only/props";
import { EditorProps } from "@tiptap/pm/view";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { EditorReadOnlyRefApi } from "src/types/editor-ref-api";
import { IMarking, scrollSummary } from "src/helpers/scroll-to-node";
import { IMentionHighlight } from "src/types/mention-suggestion";
interface CustomReadOnlyEditorProps {
value: string;
forwardedRef?: any;
initialValue: string;
editorClassName: string;
forwardedRef?: MutableRefObject<EditorReadOnlyRefApi | null>;
extensions?: any;
editorProps?: EditorProps;
rerenderOnPropsChange?: {
id: string;
description_html: string;
handleEditorReady?: (value: boolean) => void;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
}
export const useReadOnlyEditor = ({
value,
initialValue,
editorClassName,
forwardedRef,
extensions = [],
editorProps = {},
rerenderOnPropsChange,
mentionHighlights,
mentionSuggestions,
handleEditorReady,
mentionHandler,
}: CustomReadOnlyEditorProps) => {
const editor = useCustomEditor(
{
editable: false,
content: typeof value === "string" && value.trim() !== "" ? value : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps,
...editorProps,
},
extensions: [
...CoreReadOnlyEditorExtensions({
mentionSuggestions: mentionSuggestions ?? [],
mentionHighlights: mentionHighlights ?? [],
}),
...extensions,
],
const editor = useCustomEditor({
editable: false,
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
editorProps: {
...CoreReadOnlyEditorProps(editorClassName),
...editorProps,
},
[rerenderOnPropsChange]
);
onCreate: async () => {
handleEditorReady?.(true);
},
extensions: [
...CoreReadOnlyEditorExtensions({
mentionHighlights: mentionHandler.highlights,
}),
...extensions,
],
onDestroy: () => {
handleEditorReady?.(false);
},
});
// for syncing swr data on tab refocus etc
useEffect(() => {
if (initialValue === null || initialValue === undefined) return;
if (editor && !editor.isDestroyed) editor?.commands.setContent(initialValue);
}, [editor, initialValue]);
const editorRef: MutableRefObject<Editor | null> = useRef(null);
editorRef.current = editor;
useImperativeHandle(forwardedRef, () => ({
clearEditor: () => {
@ -56,11 +64,20 @@ export const useReadOnlyEditor = ({
setEditorValue: (content: string) => {
editorRef.current?.commands.setContent(content);
},
getMarkDown: (): string => {
const markdownOutput = editorRef.current?.storage.markdown.getMarkdown();
return markdownOutput;
},
scrollSummary: (marking: IMarking): void => {
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
}));
if (!editor) {
return null;
}
editorRef.current = editor;
return editor;
};

View File

@ -26,6 +26,7 @@ export * from "src/lib/editor-commands";
// types
export type { DeleteImage } from "src/types/delete-image";
export type { UploadImage } from "src/types/upload-image";
export type { EditorRefApi, EditorReadOnlyRefApi } from "src/types/editor-ref-api";
export type { RestoreImage } from "src/types/restore-image";
export type { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
export type { ISlashCommandItem, CommandProps } from "src/types/slash-commands-suggestion";

View File

@ -1,21 +1,22 @@
import { Editor, Range } from "@tiptap/core";
import { startImageUpload } from "src/ui/plugins/upload-image";
import { findTableAncestor } from "src/lib/utils";
import { Selection } from "@tiptap/pm/state";
import { UploadImage } from "src/types/upload-image";
export const toggleHeadingOne = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().setNode("heading", { level: 1 }).run();
else editor.chain().focus().clearNodes().toggleHeading({ level: 1 }).run();
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).clearNodes().setNode("heading", { level: 2 }).run();
else editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run();
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).clearNodes().setNode("heading", { level: 3 }).run();
else editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run();
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) => {
@ -37,10 +38,10 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
// Check if code block is active then toggle code block
if (editor.isActive("codeBlock")) {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
return;
}
editor.chain().focus().clearNodes().toggleCodeBlock().run();
editor.chain().focus().toggleCodeBlock().run();
return;
}
@ -49,32 +50,32 @@ export const toggleCodeBlock = (editor: Editor, range?: Range) => {
if (isSelectionEmpty) {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().toggleCodeBlock().run();
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
return;
}
editor.chain().focus().clearNodes().toggleCodeBlock().run();
editor.chain().focus().toggleCodeBlock().run();
} else {
if (range) {
editor.chain().focus().deleteRange(range).clearNodes().toggleCode().run();
editor.chain().focus().deleteRange(range).toggleCode().run();
return;
}
editor.chain().focus().clearNodes().toggleCode().run();
editor.chain().focus().toggleCode().run();
}
};
export const toggleOrderedList = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleOrderedList().run();
else editor.chain().focus().clearNodes().toggleOrderedList().run();
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).clearNodes().toggleBulletList().run();
else editor.chain().focus().clearNodes().toggleBulletList().run();
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).clearNodes().toggleTaskList().run();
else editor.chain().focus().clearNodes().toggleTaskList().run();
if (range) editor.chain().focus().deleteRange(range).toggleTaskList().run();
else editor.chain().focus().toggleTaskList().run();
};
export const toggleStrike = (editor: Editor, range?: Range) => {
@ -83,17 +84,19 @@ export const toggleStrike = (editor: Editor, range?: Range) => {
};
export const toggleBlockquote = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).clearNodes().toggleBlockquote().run();
else editor.chain().focus().clearNodes().toggleBlockquote().run();
if (range) editor.chain().focus().deleteRange(range).toggleBlockquote().run();
else editor.chain().focus().toggleBlockquote().run();
};
export const insertTableCommand = (editor: Editor, range?: Range) => {
if (typeof window !== "undefined") {
const selection: any = window?.getSelection();
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
const selection = window.getSelection();
if (selection) {
if (selection.rangeCount !== 0) {
const range = selection.getRangeAt(0);
if (findTableAncestor(range.startContainer)) {
return;
}
}
}
}
@ -112,7 +115,7 @@ export const setLinkEditor = (editor: Editor, url: string) => {
export const insertImageCommand = (
editor: Editor,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
savedSelection?: Selection | null,
range?: Range
) => {
if (range) editor.chain().focus().deleteRange(range).run();
@ -122,8 +125,8 @@ export const insertImageCommand = (
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);
const pos = savedSelection?.anchor ?? editor.view.state.selection.from;
startImageUpload(file, editor.view, pos, uploadFile);
}
};
input.click();

View File

@ -4,15 +4,17 @@ import { twMerge } from "tailwind-merge";
interface EditorClassNames {
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
containerClassName?: string;
}
export const getEditorClassNames = ({ noBorder, borderOnFocus, customClassName }: EditorClassNames) =>
export const getEditorClassNames = ({ noBorder, borderOnFocus, containerClassName }: 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
"w-full max-w-full sm:rounded-lg focus:outline-none focus:border-0",
{
"border border-custom-border-200": !noBorder,
"focus:border border-custom-border-300": borderOnFocus,
},
containerClassName
);
export function cn(...inputs: ClassValue[]) {

View File

@ -7,10 +7,17 @@
}
/* block quotes */
.ProseMirror blockquote {
font-style: normal;
font-weight: 400;
border-left: 3px solid rgb(var(--color-border-300));
}
.ProseMirror blockquote p::before,
.ProseMirror blockquote p::after {
display: none;
}
/* end block quotes */
.ProseMirror code::before,
.ProseMirror code::after {
@ -28,8 +35,8 @@
/* Custom image styles */
.ProseMirror img {
transition: filter 0.1s ease-in-out;
margin-top: 0 !important;
margin-bottom: 0 !important;
margin-top: 8px;
margin-bottom: 0;
&:hover {
cursor: pointer;
@ -37,22 +44,52 @@
}
&.ProseMirror-selectednode {
outline: 3px solid #5abbf7;
outline: 3px solid rgba(var(--color-primary-100));
filter: brightness(90%);
}
}
.ProseMirror-gapcursor:after {
/* Custom list item styles */
/* Custom gap cursor styles */
.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 {
font-size: 1rem;
line-height: 1.5;
}
ul[data-type="taskList"] li > label {
margin-right: 0.2rem;
margin: 0.1rem 0.15rem 0 0;
user-select: none;
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
border: 1px solid rgba(var(--color-border-300)) !important;
outline: none;
border-radius: 2px;
transform: scale(1.05);
}
ul[data-type="taskList"] li > label input[type="checkbox"]:hover {
background-color: rgba(var(--color-background-80)) !important;
}
ul[data-type="taskList"] li > label input[type="checkbox"]:checked {
background-color: rgba(var(--color-primary-100)) !important;
border-color: rgba(var(--color-primary-100)) !important;
color: white !important;
}
ul[data-type="taskList"] li > label input[type="checkbox"]:checked:hover {
background-color: rgba(var(--color-primary-300)) !important;
border-color: rgba(var(--color-primary-300)) !important;
}
@media screen and (max-width: 768px) {
ul[data-type="taskList"] li > label {
margin-right: 0.5rem;
@ -60,6 +97,7 @@ ul[data-type="taskList"] li > label {
}
ul[data-type="taskList"] li > label input[type="checkbox"] {
position: relative;
-webkit-appearance: none;
appearance: none;
background-color: rgb(var(--color-background-100));
@ -71,8 +109,6 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
border: 1.5px solid rgb(var(--color-text-100));
margin-right: 0.2rem;
margin-top: 0.15rem;
display: grid;
place-content: center;
&:hover {
background-color: rgb(var(--color-background-80));
@ -82,24 +118,28 @@ ul[data-type="taskList"] li > label input[type="checkbox"] {
background-color: rgb(var(--color-background-90));
}
/* check sign */
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 0.5em;
height: 0.5em;
transform: scale(0);
transform-origin: center;
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);
transform: scale(1) translate(-50%, -50%);
}
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: rgb(var(--color-text-200));
color: rgb(var(--color-text-400));
text-decoration: line-through;
text-decoration-thickness: 2px;
}
@ -133,12 +173,12 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
-moz-appearance: textfield;
}
.fadeIn {
.fade-in {
opacity: 1;
transition: opacity 0.3s ease-in;
}
.fadeOut {
.fade-out {
opacity: 0;
transition: opacity 0.2s ease-out;
}
@ -149,7 +189,7 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
margin-top: 0 !important;
margin-bottom: 0 !important;
&:before {
&::before {
content: "";
box-sizing: border-box;
position: absolute;
@ -175,21 +215,13 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p {
cursor: col-resize;
}
.ProseMirror table * p {
padding: 0px 1px;
margin: 6px 2px;
}
.ProseMirror table * .is-empty::before {
opacity: 0;
}
.ProseMirror pre {
background: rgba(var(--color-background-80));
border-radius: 0.5rem;
color: rgba(var(--color-text-100));
font-family: "JetBrainsMono", monospace;
padding: 0.75rem 1rem;
font-family: JetBrainsMono, monospace;
tab-size: 2;
}
.ProseMirror pre code {
@ -214,3 +246,107 @@ div[data-type="horizontalRule"] {
.moveable-control-box {
z-index: 10 !important;
}
/* Cursor styles for the inline code blocks */
@keyframes blink {
49% {
border-color: unset;
}
50% {
border-color: transparent;
}
99% {
border-color: transparent;
}
}
.no-cursor {
caret-color: transparent;
}
div:focus .fake-cursor,
span:focus .fake-cursor {
margin-right: -1px;
border-left-width: 1.5px;
border-left-style: solid;
animation: blink 1s;
animation-iteration-count: infinite;
position: relative;
z-index: 1;
}
/* number, bulleted and to-do lists */
.prose ol:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
.prose
ul:not([data-type="taskList"]):where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)),
.prose ul[data-type="taskList"]:where(.prose > :first-child) {
margin-top: 0.25rem !important;
margin-bottom: 1px !important;
}
.prose ol:not(:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))),
.prose
ul:not([data-type="taskList"]):not(
:where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *))
),
.prose ul[data-type="taskList"]:not(:where(.prose > :first-child)) {
margin-top: calc(0.25rem + 3px) !important;
margin-bottom: 1px !important;
}
ol ol,
ol ul:not([data-type="taskList"]),
ul:not([data-type="taskList"]) ul:not([data-type="taskList"]),
ul:not([data-type="taskList"]) ol {
margin-top: 0.45rem !important;
}
ul[data-type="taskList"] ul[data-type="taskList"] {
margin-top: 0.6rem;
}
/* end number, bulleted and to-do lists */
/* tailwind typography */
.prose :where(h1):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 2rem;
margin-bottom: 4px;
font-size: 1.875rem;
font-weight: 700;
line-height: 1.3;
}
.prose :where(h2):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1.4rem;
margin-bottom: 1px;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.3;
}
.prose :where(h3):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 1rem;
margin-bottom: 1px;
font-size: 1.25rem;
line-height: 1.3;
}
.prose :where(p):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0.25rem;
margin-bottom: 1px;
padding: 3px 2px;
font-size: 1rem;
line-height: 1.5;
}
.prose :where(ol):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p,
.prose :where(ul):not(:where([class~="not-prose"], [class~="not-prose"] *)) li p {
font-size: 1rem;
line-height: 1.5;
}
.prose :where(.prose > :first-child):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
margin-top: 0;
}
/* end tailwind typography */

View File

@ -1,23 +1,25 @@
.tableWrapper {
.table-wrapper {
overflow-x: auto;
padding: 2px;
width: fit-content;
max-width: 100%;
}
.tableWrapper table {
.table-wrapper table {
border-collapse: collapse;
table-layout: fixed;
margin: 0;
margin-bottom: 1rem;
border: 2px solid rgba(var(--color-border-300));
margin: 0.5rem 0 1rem 0;
border: 1px solid rgba(var(--color-border-200));
width: 100%;
}
.tableWrapper table td,
.tableWrapper table th {
.table-wrapper table p {
font-size: 14px;
}
.table-wrapper table td,
.table-wrapper table th {
min-width: 1em;
border: 1px solid rgba(var(--color-border-300));
border: 1px solid rgba(var(--color-border-200));
padding: 10px 15px;
vertical-align: top;
box-sizing: border-box;
@ -29,86 +31,45 @@
}
}
.tableWrapper table td > *,
.tableWrapper table th > * {
.table-wrapper table td > *,
.table-wrapper table th > * {
margin: 0 !important;
padding: 0.25rem 0 !important;
}
.tableWrapper table td.has-focus,
.tableWrapper table th.has-focus {
.table-wrapper table td.has-focus,
.table-wrapper table th.has-focus {
box-shadow: rgba(var(--color-primary-300), 0.1) 0px 0px 0px 2px inset !important;
}
.tableWrapper table th {
font-weight: bold;
.table-wrapper table th {
font-weight: 500;
text-align: left;
background-color: #d9e4ff;
color: #171717;
background-color: rgba(var(--color-background-90));
}
.tableWrapper table th * {
font-weight: 600;
.table-wrapper table .selectedCell {
border-color: rgba(var(--color-primary-100));
}
.tableWrapper table .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;
}
.colorPicker {
display: grid;
padding: 8px 8px;
grid-template-columns: repeat(6, 1fr);
gap: 5px;
}
.colorPickerLabel {
font-size: 0.85rem;
color: #6b7280;
padding: 8px 8px;
padding-bottom: 0px;
}
.colorPickerItem {
margin: 2px 0px;
width: 24px;
height: 24px;
border-radius: 4px;
border: none;
cursor: pointer;
}
.divider {
background-color: #e5e7eb;
height: 1px;
margin: 3px 0;
}
.tableWrapper table .column-resize-handle {
/* table dropdown */
.table-wrapper table .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: -2px;
width: 4px;
width: 2px;
height: 100%;
z-index: 5;
background-color: #d9e4ff;
background-color: rgba(var(--color-primary-100));
pointer-events: none;
}
.tableWrapper .tableControls {
.table-wrapper .table-controls {
position: absolute;
}
.tableWrapper .tableControls .columnsControl,
.tableWrapper .tableControls .rowsControl {
.table-wrapper .table-controls .columns-control,
.table-wrapper .table-controls .rows-control {
transition: opacity ease-in 100ms;
position: absolute;
z-index: 5;
@ -117,124 +78,50 @@
align-items: center;
}
.tableWrapper .tableControls .columnsControl {
.table-wrapper .table-controls .columns-control {
height: 20px;
transform: translateY(-50%);
}
.tableWrapper .tableControls .columnsControl .columnsControlDiv {
.table-wrapper .table-controls .columns-control .columns-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M4.5 10.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S6 12.825 6 12s-.675-1.5-1.5-1.5zm15 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5S21 12.825 21 12s-.675-1.5-1.5-1.5zm-7.5 0c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
width: 30px;
height: 15px;
}
.tableWrapper .tableControls .rowsControl {
.table-wrapper .table-controls .rows-control {
width: 20px;
transform: translateX(-50%);
}
.tableWrapper .tableControls .rowsControl .rowsControlDiv {
.table-wrapper .table-controls .rows-control .rows-control-div {
color: white;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='none' d='M0 0h24v24H0z'/%3E%3Cpath fill='%238F95B2' d='M12 3c-.825 0-1.5.675-1.5 1.5S11.175 6 12 6s1.5-.675 1.5-1.5S12.825 3 12 3zm0 15c-.825 0-1.5.675-1.5 1.5S11.175 21 12 21s1.5-.675 1.5-1.5S12.825 18 12 18zm0-7.5c-.825 0-1.5.675-1.5 1.5s.675 1.5 1.5 1.5 1.5-.675 1.5-1.5-.675-1.5-1.5-1.5z'/%3E%3C/svg%3E");
height: 30px;
width: 15px;
}
.tableWrapper .tableControls .rowsControlDiv {
background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .columnsControlDiv {
background-color: #d9e4ff;
border: 1px solid rgba(var(--color-border-200));
border-radius: 2px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: #000 0px 2px 4px;
cursor: pointer;
}
.tableWrapper .tableControls .tableToolbox,
.tableWrapper .tableControls .tableColorPickerToolbox {
border: 1px solid rgba(var(--color-border-300));
background-color: rgba(var(--color-background-100));
border-radius: 5px;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
display: flex;
flex-direction: column;
width: max-content;
gap: 0.25rem;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem {
background-color: rgba(var(--color-background-100));
display: flex;
align-items: center;
gap: 0.5rem;
border: none;
padding: 0.3rem 0.5rem 0.1rem 0.1rem;
.table-wrapper .table-controls .rows-control-div,
.table-wrapper .table-controls .columns-control-div {
background-color: rgba(var(--color-background-80));
border: 0.5px solid rgba(var(--color-border-200));
border-radius: 4px;
background-size: 1.25rem;
background-repeat: no-repeat;
background-position: center;
transition:
transform ease-out 100ms,
background-color ease-out 100ms;
outline: none;
box-shadow: rgba(var(--color-shadow-2xs));
cursor: pointer;
transition: all 0.2s;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem:hover,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover {
background-color: rgba(var(--color-background-80), 0.6);
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer {
padding: 4px 0px;
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg,
.tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg {
width: 1rem;
height: 1rem;
}
.tableToolbox {
background-color: rgba(var(--color-background-100));
}
.tableWrapper .tableControls .tableToolbox .toolboxItem .label,
.tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .label {
font-size: 0.85rem;
color: rgba(var(--color-text-300));
}
.resize-cursor .tableWrapper .tableControls .rowsControl,
.tableWrapper.controls--disabled .tableControls .rowsControl,
.resize-cursor .tableWrapper .tableControls .columnsControl,
.tableWrapper.controls--disabled .tableControls .columnsControl {
.resize-cursor .table-wrapper .table-controls .rows-control,
.table-wrapper.controls--disabled .table-controls .rows-control,
.resize-cursor .table-wrapper .table-controls .columns-control,
.table-wrapper.controls--disabled .table-controls .columns-control {
opacity: 0;
pointer-events: none;
}

View File

@ -0,0 +1,17 @@
import { IMarking } from "src/helpers/scroll-to-node";
import { EditorMenuItemNames } from "src/ui/menus/menu-items";
export type EditorReadOnlyRefApi = {
getMarkDown: () => string;
clearEditor: () => void;
setEditorValue: (content: string) => void;
scrollSummary: (marking: IMarking) => void;
};
export interface EditorRefApi extends EditorReadOnlyRefApi {
setEditorValueAtCursorPosition: (content: string) => void;
executeMenuItemCommand: (itemName: EditorMenuItemNames) => void;
isMenuItemActive: (itemName: EditorMenuItemNames) => boolean;
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
}

View File

@ -1,10 +1,18 @@
import { Editor, Range } from "@tiptap/react";
export type IMentionSuggestion = {
id: string;
type: string;
entity_name: string;
entity_identifier: string;
avatar: string;
title: string;
subtitle: string;
redirect_uri: string;
};
export type CommandProps = {
editor: Editor;
range: Range;
};
export type IMentionHighlight = string;

View File

@ -4,13 +4,13 @@ import { cn } from "src/lib/utils";
interface EditorContainerProps {
editor: Editor | null;
editorClassNames: string;
editorContainerClassName: string;
children: ReactNode;
hideDragHandle?: () => void;
}
export const EditorContainer: FC<EditorContainerProps> = (props) => {
const { editor, editorClassNames, hideDragHandle, children } = props;
const { editor, editorContainerClassName, hideDragHandle, children } = props;
const handleContainerClick = () => {
if (!editor) return;
@ -51,10 +51,14 @@ export const EditorContainer: FC<EditorContainerProps> = (props) => {
<div
id="editor-container"
onClick={handleContainerClick}
onMouseLeave={() => {
hideDragHandle?.();
}}
className={cn(`cursor-text`, { "active-editor": editor?.isFocused && editor?.isEditable }, editorClassNames)}
onMouseLeave={hideDragHandle}
className={cn(
"cursor-text relative",
{
"active-editor": editor?.isFocused && editor?.isEditable,
},
editorContainerClassName
)}
>
{children}
</div>

View File

@ -4,22 +4,15 @@ import { ImageResizer } from "src/ui/extensions/image/image-resize";
interface EditorContentProps {
editor: Editor | null;
editorContentCustomClassNames: string | undefined;
children?: ReactNode;
tabIndex?: number;
}
export const EditorContentWrapper: FC<EditorContentProps> = (props) => {
const { editor, editorContentCustomClassNames = "", tabIndex, children } = props;
const { editor, tabIndex, children } = props;
return (
<div
className={`contentEditor ${editorContentCustomClassNames}`}
tabIndex={tabIndex}
onFocus={() => {
editor?.chain().focus(undefined, { scrollIntoView: false }).run();
}}
>
<div tabIndex={tabIndex} onFocus={() => editor?.chain().focus(undefined, { scrollIntoView: false }).run()}>
<EditorContent editor={editor} />
{editor?.isActive("image") && editor?.isEditable && <ImageResizer editor={editor} />}
{children}

View File

@ -32,7 +32,8 @@ export const CustomCodeInlineExtension = Mark.create<CodeOptions>({
addOptions() {
return {
HTMLAttributes: {
class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-[2px] font-mono font-medium text-custom-text-1000",
class:
"rounded bg-custom-background-80 px-1 py-[2px] font-mono font-medium text-orange-500 border-[0.5px] border-custom-border-200 text-sm",
spellcheck: "false",
},
};

View File

@ -0,0 +1,56 @@
import { useState } from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import { common, createLowlight } from "lowlight";
import ts from "highlight.js/lib/languages/typescript";
import { CopyIcon, CheckIcon } from "lucide-react";
import { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { cn } from "src/lib/utils";
// we just have ts support for now
const lowlight = createLowlight(common);
lowlight.register("ts", ts);
interface CodeBlockComponentProps {
node: ProseMirrorNode;
}
export const CodeBlockComponent: React.FC<CodeBlockComponentProps> = ({ node }) => {
const [copied, setCopied] = useState(false);
const copyToClipboard = async (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
try {
await navigator.clipboard.writeText(node.textContent);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
} catch (error) {
setCopied(false);
}
e.preventDefault();
e.stopPropagation();
};
return (
<NodeViewWrapper className="code-block relative">
<button
type="button"
className={cn(
"group absolute top-2 right-2 z-10 flex items-center justify-center w-8 h-8 rounded-md bg-custom-background-80 border border-custom-border-200 transition duration-150 ease-in-out",
{
"bg-green-500/10 hover:bg-green-500/10 active:bg-green-500/10": copied,
}
)}
onClick={copyToClipboard}
>
{copied ? (
<CheckIcon className="h-3 w-3 text-green-500" strokeWidth={3} />
) : (
<CopyIcon className="h-3 w-3 text-custom-text-300 group-hover:text-custom-text-100" />
)}
</button>
<pre className="bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4">
<NodeViewContent as="code" />
</pre>
</NodeViewWrapper>
);
};

View File

@ -7,8 +7,14 @@ const lowlight = createLowlight(common);
lowlight.register("ts", ts);
import { Selection } from "@tiptap/pm/state";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { CodeBlockComponent } from "./code-block-node-view";
export const CustomCodeBlockExtension = CodeBlockLowlight.extend({
addNodeView() {
return ReactNodeViewRenderer(CodeBlockComponent);
},
addKeyboardShortcuts() {
return {
Tab: ({ editor }) => {

View File

@ -0,0 +1,9 @@
import { Extension } from "@tiptap/core";
import codemark from "prosemirror-codemark";
export const CustomCodeMarkPlugin = Extension.create({
name: "codemarkPlugin",
addProseMirrorPlugins() {
return codemark({ markType: this.editor.schema.marks.code });
},
});

View File

@ -0,0 +1,363 @@
import { EditorState } from "@tiptap/pm/state";
import { Editor, getNodeType, getNodeAtPosition, isAtEndOfNode, isAtStartOfNode, isNodeActive } from "@tiptap/core";
import { Node, NodeType } from "@tiptap/pm/model";
const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection;
const nodeType = getNodeType(typeOrName, state.schema);
let currentNode = null;
let currentDepth = $from.depth;
let currentPos = $from.pos;
let targetDepth: number | null = null;
while (currentDepth > 0 && targetDepth === null) {
currentNode = $from.node(currentDepth);
if (currentNode.type === nodeType) {
targetDepth = currentDepth;
} else {
currentDepth -= 1;
currentPos -= 1;
}
}
if (targetDepth === null) {
return null;
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
};
const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth > listItemPos.depth) {
return true;
}
return false;
};
const getNextListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
return depth;
};
const getPrevListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
let depth = 0;
const pos = listItemPos.$pos;
// Adjust the position to ensure we're within the list item, especially for edge cases
const resolvedPos = state.doc.resolve(Math.max(pos.pos - 1, 0));
// Traverse up the document structure from the adjusted position
for (let d = resolvedPos.depth; d > 0; d--) {
const node = resolvedPos.node(d);
if (node.type.name === "bulletList" || node.type.name === "orderedList") {
// Increment depth for each list ancestor found
depth++;
}
}
// Subtract 1 from the calculated depth to get the parent list's depth
// This adjustment is necessary because the depth calculation includes the current list
// By subtracting 1, we aim to get the depth of the parent list, which helps in identifying if the current list is a sublist
depth = depth > 0 ? depth - 1 : 0;
// Double the depth value to get results as 2, 4, 6, 8, etc.
depth = depth * 2;
return depth;
};
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
// this is required to still handle the undo handling
if (editor.commands.undoInputRule()) {
return true;
}
// Check if a node range is selected, and if so, fall back to default backspace functionality
const { from, to } = editor.state.selection;
if (from !== to) {
// A range is selected, not just a cursor position; fall back to default behavior
return false; // Let the editor handle backspace by default
}
// if the current item is NOT inside a list item &
// the previous item is a list (orderedList or bulletList)
// move the cursor into the list and delete the current item
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
const { $anchor } = editor.state.selection;
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
const listDescendants: Array<{ node: Node; pos: number }> = [];
$listPos.node().descendants((node, pos) => {
if (node.type.name === name) {
listDescendants.push({ node, pos });
}
});
const lastItem = listDescendants.at(-1);
if (!lastItem) {
return false;
}
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
// Check if positions are within the valid range
const startPos = $anchor.start() - 1;
const endPos = $anchor.end() + 1;
if (startPos < 0 || endPos > editor.state.doc.content.size) {
return false; // Invalid position, abort operation
}
return editor.chain().cut({ from: startPos, to: endPos }, $lastItemPos.end()).joinForward().run();
}
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the start of a node
// do nothing and proceed
if (!isAtStartOfNode(editor.state)) {
return false;
}
const isParaSibling = isCurrentParagraphASibling(editor.state);
const isCurrentListItemSublist = prevListIsHigher(name, editor.state);
const listItemPos = findListItemPos(name, editor.state);
const nextListItemIsSibling = nextListIsSibling(name, editor.state);
if (!listItemPos) {
return false;
}
const currentNode = listItemPos.$pos.node(listItemPos.depth);
const currentListItemHasSubList = listItemHasSubList(name, editor.state, currentNode);
if (currentListItemHasSubList && isCurrentListItemSublist && isParaSibling) {
return false;
}
if (currentListItemHasSubList && isCurrentListItemSublist) {
editor.chain().liftListItem(name).run();
return editor.commands.joinItemBackward();
}
if (isCurrentListItemSublist && nextListItemIsSibling) {
return false;
}
if (isCurrentListItemSublist) {
return false;
}
if (currentListItemHasSubList) {
return false;
}
if (hasListItemBefore(name, editor.state)) {
return editor.chain().liftListItem(name).run();
}
if (!currentListItemHasSubList) {
return false;
}
// otherwise in the end, a backspace should
// always just lift the list item if
// joining / merging is not possible
return editor.chain().liftListItem(name).run();
};
export const handleDelete = (editor: Editor, name: string) => {
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the end of a node
// do nothing and proceed
if (!isAtEndOfNode(editor.state, name)) {
return false;
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(name)
.joinBackward()
.run();
}
if (nextListIsHigher(name, editor.state)) {
return editor.chain().joinForward().joinBackward().run();
}
return editor.commands.joinItemForward();
};
const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
const { $anchor } = editorState.selection;
const previousNodePos = Math.max(0, $anchor.pos - 2);
const previousNode = editorState.doc.resolve(previousNodePos).node();
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
return false;
}
return true;
};
const prevListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getPrevListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};
const nextListIsSibling = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth === listItemPos.depth) {
return true;
}
return false;
};
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};
const listItemHasSubList = (typeOrName: string, state: EditorState, node?: Node) => {
if (!node) {
return false;
}
const nodeType = getNodeType(typeOrName, state.schema);
let hasSubList = false;
node.descendants((child) => {
if (child.type === nodeType) {
hasSubList = true;
}
});
return hasSubList;
};
const isCurrentParagraphASibling = (state: EditorState): boolean => {
const { $from } = state.selection;
const listItemNode = $from.node(-1); // Get the parent node of the current selection, assuming it's a list item.
const currentParagraphNode = $from.parent; // Get the current node where the selection is.
// Ensure we're in a paragraph and the parent is a list item.
if (currentParagraphNode.type.name === "paragraph" && listItemNode.type.name === "listItem") {
let paragraphNodesCount = 0;
listItemNode.forEach((child) => {
if (child.type.name === "paragraph") {
paragraphNodesCount++;
}
});
// If there are more than one paragraph nodes, the current paragraph is a sibling.
return paragraphNodesCount > 1;
}
return false;
};
export function isCursorInSubList(editor: Editor) {
const { selection } = editor.state;
const { $from } = selection;
// Check if the current node is a list item
const listItem = editor.schema.nodes.listItem;
// Traverse up the document tree from the current position
for (let depth = $from.depth; depth > 0; depth--) {
const node = $from.node(depth);
if (node.type === listItem) {
// If the parent of the list item is also a list, it's a sub-list
const parent = $from.node(depth - 1);
if (
parent &&
(parent.type === editor.schema.nodes.bulletList || parent.type === editor.schema.nodes.orderedList)
) {
return true;
}
}
}
return false;
}
const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection;
const $targetPos = state.doc.resolve($anchor.pos - 2);
if ($targetPos.index() === 0) {
return false;
}
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
return false;
}
return true;
};

View File

@ -1,30 +0,0 @@
import { getNodeType } from "@tiptap/core";
import { NodeType } from "@tiptap/pm/model";
import { EditorState } from "@tiptap/pm/state";
export const findListItemPos = (typeOrName: string | NodeType, state: EditorState) => {
const { $from } = state.selection;
const nodeType = getNodeType(typeOrName, state.schema);
let currentNode = null;
let currentDepth = $from.depth;
let currentPos = $from.pos;
let targetDepth: number | null = null;
while (currentDepth > 0 && targetDepth === null) {
currentNode = $from.node(currentDepth);
if (currentNode.type === nodeType) {
targetDepth = currentDepth;
} else {
currentDepth -= 1;
currentPos -= 1;
}
}
if (targetDepth === null) {
return null;
}
return { $pos: state.doc.resolve(currentPos), depth: targetDepth };
};

View File

@ -1,16 +0,0 @@
import { getNodeAtPosition } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
export const getNextListDepth = (typeOrName: string, state: EditorState) => {
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos) {
return false;
}
const [, depth] = getNodeAtPosition(state, typeOrName, listItemPos.$pos.pos + 4);
return depth;
};

View File

@ -1,66 +0,0 @@
import { Editor, isAtStartOfNode, isNodeActive } from "@tiptap/core";
import { Node } from "@tiptap/pm/model";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
import { hasListBefore } from "src/ui/extensions/custom-list-keymap/list-helpers/has-list-before";
export const handleBackspace = (editor: Editor, name: string, parentListTypes: string[]) => {
// this is required to still handle the undo handling
if (editor.commands.undoInputRule()) {
return true;
}
// if the cursor is not at the start of a node
// do nothing and proceed
if (!isAtStartOfNode(editor.state)) {
return false;
}
// if the current item is NOT inside a list item &
// the previous item is a list (orderedList or bulletList)
// move the cursor into the list and delete the current item
if (!isNodeActive(editor.state, name) && hasListBefore(editor.state, name, parentListTypes)) {
const { $anchor } = editor.state.selection;
const $listPos = editor.state.doc.resolve($anchor.before() - 1);
const listDescendants: Array<{ node: Node; pos: number }> = [];
$listPos.node().descendants((node, pos) => {
if (node.type.name === name) {
listDescendants.push({ node, pos });
}
});
const lastItem = listDescendants.at(-1);
if (!lastItem) {
return false;
}
const $lastItemPos = editor.state.doc.resolve($listPos.start() + lastItem.pos + 1);
return editor
.chain()
.cut({ from: $anchor.start() - 1, to: $anchor.end() + 1 }, $lastItemPos.end())
.joinForward()
.run();
}
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
const listItemPos = findListItemPos(name, editor.state);
if (!listItemPos) {
return false;
}
// if current node is a list item and cursor it at start of a list node,
// simply lift the list item i.e. remove it as a list item (task/bullet/ordered)
// irrespective of above node being a list or not
return editor.chain().liftListItem(name).run();
};

View File

@ -1,34 +0,0 @@
import { Editor, isAtEndOfNode, isNodeActive } from "@tiptap/core";
import { nextListIsDeeper } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper";
import { nextListIsHigher } from "src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher";
export const handleDelete = (editor: Editor, name: string) => {
// if the cursor is not inside the current node type
// do nothing and proceed
if (!isNodeActive(editor.state, name)) {
return false;
}
// if the cursor is not at the end of a node
// do nothing and proceed
if (!isAtEndOfNode(editor.state, name)) {
return false;
}
// check if the next node is a list with a deeper depth
if (nextListIsDeeper(name, editor.state)) {
return editor
.chain()
.focus(editor.state.selection.from + 4)
.lift(name)
.joinBackward()
.run();
}
if (nextListIsHigher(name, editor.state)) {
return editor.chain().joinForward().joinBackward().run();
}
return editor.commands.joinItemForward();
};

View File

@ -1,15 +0,0 @@
import { EditorState } from "@tiptap/pm/state";
export const hasListBefore = (editorState: EditorState, name: string, parentListTypes: string[]) => {
const { $anchor } = editorState.selection;
const previousNodePos = Math.max(0, $anchor.pos - 2);
const previousNode = editorState.doc.resolve(previousNodePos).node();
if (!previousNode || !parentListTypes.includes(previousNode.type.name)) {
return false;
}
return true;
};

View File

@ -1,17 +0,0 @@
import { EditorState } from "@tiptap/pm/state";
export const hasListItemAfter = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection;
const $targetPos = state.doc.resolve($anchor.pos - $anchor.parentOffset - 2);
if ($targetPos.index() === $targetPos.parent.childCount - 1) {
return false;
}
if ($targetPos.nodeAfter?.type.name !== typeOrName) {
return false;
}
return true;
};

View File

@ -1,17 +0,0 @@
import { EditorState } from "@tiptap/pm/state";
export const hasListItemBefore = (typeOrName: string, state: EditorState): boolean => {
const { $anchor } = state.selection;
const $targetPos = state.doc.resolve($anchor.pos - 2);
if ($targetPos.index() === 0) {
return false;
}
if ($targetPos.nodeBefore?.type.name !== typeOrName) {
return false;
}
return true;
};

View File

@ -1,9 +0,0 @@
export * from "./find-list-item-pos";
export * from "./get-next-list-depth";
export * from "./handle-backspace";
export * from "./handle-delete";
export * from "./has-list-before";
export * from "./has-list-item-after";
export * from "./has-list-item-before";
export * from "./next-list-is-deeper";
export * from "./next-list-is-higher";

View File

@ -1,19 +0,0 @@
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth";
export const nextListIsDeeper = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth > listItemPos.depth) {
return true;
}
return false;
};

View File

@ -1,19 +0,0 @@
import { EditorState } from "@tiptap/pm/state";
import { findListItemPos } from "src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos";
import { getNextListDepth } from "src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth";
export const nextListIsHigher = (typeOrName: string, state: EditorState) => {
const listDepth = getNextListDepth(typeOrName, state);
const listItemPos = findListItemPos(typeOrName, state);
if (!listItemPos || !listDepth) {
return false;
}
if (listDepth < listItemPos.depth) {
return true;
}
return false;
};

View File

@ -29,6 +29,22 @@ export const ListKeymap = Extension.create<ListKeymapOptions>({
addKeyboardShortcuts() {
return {
Tab: () => {
if (this.editor.commands.sinkListItem("listItem")) {
return true;
} else if (this.editor.commands.sinkListItem("taskItem")) {
return true;
}
return true;
},
"Shift-Tab": () => {
if (this.editor.commands.liftListItem("listItem")) {
return true;
} else if (this.editor.commands.liftListItem("taskItem")) {
return true;
}
return true;
},
Delete: ({ editor }) => {
let handled = false;

View File

@ -7,10 +7,19 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
const imageInfo = document.querySelector(".ProseMirror-selectednode") as HTMLImageElement;
if (imageInfo) {
const selection = editor.state.selection;
// Use the style width/height if available, otherwise fall back to the element's natural width/height
const width = imageInfo.style.width
? Number(imageInfo.style.width.replace("px", ""))
: imageInfo.getAttribute("width");
const height = imageInfo.style.height
? Number(imageInfo.style.height.replace("px", ""))
: imageInfo.getAttribute("height");
editor.commands.setImage({
src: imageInfo.src,
width: Number(imageInfo.style.width.replace("px", "")),
height: Number(imageInfo.style.height.replace("px", "")),
width: width,
height: height,
} as any);
editor.commands.setNodeSelection(selection.from);
}
@ -21,7 +30,7 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
return (
<>
<Moveable
target={document.querySelector(".ProseMirror-selectednode") as any}
target={document.querySelector(".ProseMirror-selectednode") as HTMLElement}
container={null}
origin={false}
edge={false}
@ -37,27 +46,29 @@ export const ImageResizer = ({ editor }: { editor: Editor }) => {
setAspectRatio(originalWidth / originalHeight);
}
}}
onResize={({ target, width, height, delta }: any) => {
if (delta[0]) {
const newWidth = Math.max(width, 100);
const newHeight = newWidth / aspectRatio;
target!.style.width = `${newWidth}px`;
target!.style.height = `${newHeight}px`;
}
if (delta[1]) {
const newHeight = Math.max(height, 100);
const newWidth = newHeight * aspectRatio;
target!.style.height = `${newHeight}px`;
target!.style.width = `${newWidth}px`;
onResize={({ target, width, height, delta }) => {
if (delta[0] || delta[1]) {
let newWidth, newHeight;
if (delta[0]) {
// Width change detected
newWidth = Math.max(width, 100);
newHeight = newWidth / aspectRatio;
} else if (delta[1]) {
// Height change detected
newHeight = Math.max(height, 100);
newWidth = newHeight * aspectRatio;
}
target.style.width = `${newWidth}px`;
target.style.height = `${newHeight}px`;
}
}}
onResizeEnd={() => {
updateMediaSize();
}}
scalable
renderDirections={["w", "e"]}
onScale={({ target, transform }: any) => {
target!.style.transform = transform;
renderDirections={["se"]}
onScale={({ target, transform }) => {
target.style.transform = transform;
}}
/>
</>

View File

@ -1,4 +1,3 @@
import { Color } from "@tiptap/extension-color";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import TextStyle from "@tiptap/extension-text-style";
@ -22,17 +21,18 @@ import { CustomKeymap } from "src/ui/extensions/keymap";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
import { DeleteImage } from "src/types/delete-image";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
import { RestoreImage } from "src/types/restore-image";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
import { CustomTypographyExtension } from "src/ui/extensions/typography";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomCodeMarkPlugin } from "./custom-code-inline/inline-code-plugin";
export const CoreEditorExtensions = (
mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
},
deleteFile: DeleteImage,
restoreFile: RestoreImage,
@ -41,17 +41,17 @@ export const CoreEditorExtensions = (
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
class: "list-disc pl-7 space-y-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
class: "list-decimal pl-7 space-y-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
class: "not-prose space-y-2",
},
},
code: false,
@ -60,14 +60,17 @@ export const CoreEditorExtensions = (
blockquote: false,
dropcursor: {
color: "rgba(var(--color-text-100))",
width: 2,
width: 1,
},
}),
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
// BulletList,
// OrderedList,
// ListItem,
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" },
HTMLAttributes: {
class: "my-4",
},
}),
CustomKeymap,
ListKeymap,
@ -85,33 +88,40 @@ export const CoreEditorExtensions = (
CustomTypographyExtension,
ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
class: "rounded-md",
},
}),
TiptapUnderline,
TextStyle,
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
class: "flex",
},
nested: true,
}),
CustomCodeBlockExtension,
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "",
},
}),
CustomCodeMarkPlugin,
CustomCodeInlineExtension,
Markdown.configure({
html: true,
transformCopiedText: true,
transformPastedText: true,
}),
Table,
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, false),
Mentions({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
}),
];

View File

@ -1,4 +1,7 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, Transaction } from "@tiptap/pm/state";
import { canJoin } from "@tiptap/pm/transform";
import { NodeType } from "@tiptap/pm/model";
declare module "@tiptap/core" {
// eslint-disable-next-line no-unused-vars
@ -12,6 +15,51 @@ declare module "@tiptap/core" {
}
}
function autoJoin(tr: Transaction, newTr: Transaction, nodeType: NodeType) {
if (!tr.isGeneric) return false;
// Find all ranges where we might want to join.
const ranges: Array<number> = [];
for (let i = 0; i < tr.mapping.maps.length; i++) {
const map = tr.mapping.maps[i];
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]);
map.forEach((_s, _e, from, to) => ranges.push(from, to));
}
// Figure out which joinable points exist inside those ranges,
// by checking all node boundaries in their parent nodes.
const joinable = [];
for (let i = 0; i < ranges.length; i += 2) {
const from = ranges[i],
to = ranges[i + 1];
const $from = tr.doc.resolve(from),
depth = $from.sharedDepth(to),
parent = $from.node(depth);
for (let index = $from.indexAfter(depth), pos = $from.after(depth + 1); pos <= to; ++index) {
const after = parent.maybeChild(index);
if (!after) break;
if (index && joinable.indexOf(pos) == -1) {
const before = parent.child(index - 1);
if (before.type == after.type && before.type === nodeType) joinable.push(pos);
}
pos += after.nodeSize;
}
}
let joined = false;
// Join the joinable points
joinable.sort((a, b) => a - b);
for (let i = joinable.length - 1; i >= 0; i--) {
if (canJoin(tr.doc, joinable[i])) {
newTr.join(joinable[i]);
joined = true;
}
}
return joined;
}
export const CustomKeymap = Extension.create({
name: "CustomKeymap",
@ -32,6 +80,42 @@ export const CustomKeymap = Extension.create({
};
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey("ordered-list-merging"),
appendTransaction(transactions, oldState, newState) {
// Create a new transaction.
const newTr = newState.tr;
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["orderedList"]);
joined = anotherJoin || joined;
}
if (joined) {
return newTr;
}
},
}),
new Plugin({
key: new PluginKey("unordered-list-merging"),
appendTransaction(transactions, oldState, newState) {
// Create a new transaction.
const newTr = newState.tr;
let joined = false;
for (const transaction of transactions) {
const anotherJoin = autoJoin(transaction, newTr, newState.schema.nodes["bulletList"]);
joined = anotherJoin || joined;
}
if (joined) {
return newTr;
}
},
}),
];
},
addKeyboardShortcuts() {
return {
"Mod-a": ({ editor }) => {

View File

@ -1,10 +1,10 @@
export const icons = {
colorPicker: `<svg xmlns="http://www.w3.org/2000/svg" length="24" viewBox="0 0 24 24" style="transform: ;msFilter:;"><path fill="rgb(var(--color-text-300))" 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" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
deleteColumn: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
deleteRow: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash-2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>`,
insertLeftTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
length={24}
length={12}
viewBox="0 -960 960 960"
>
<path
@ -15,7 +15,7 @@ export const icons = {
`,
insertRightTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
length={24}
length={12}
viewBox="0 -960 960 960"
>
<path
@ -35,8 +35,8 @@ export const icons = {
/>
</svg>
`,
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
toggleColumnHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
toggleRowHeader: `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="rgb(var(--color-text-300))" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-toggle-right"><rect width="20" height="12" x="2" y="6" rx="6" ry="6"/><circle cx="16" cy="12" r="2"/></svg>`,
insertBottomTableIcon: `<svg
xmlns="http://www.w3.org/2000/svg"
length={24}

View File

@ -20,7 +20,7 @@ export function tableControls() {
mousemove: (view, event) => {
const pluginState = key.getState(view.state);
if (!(event.target as HTMLElement).closest(".tableWrapper") && pluginState.values.hoveredTable) {
if (!(event.target as HTMLElement).closest(".table-wrapper") && pluginState.values.hoveredTable) {
return view.dispatch(
view.state.tr.setMeta(key, {
setHoveredTable: null,
@ -34,7 +34,7 @@ export function tableControls() {
top: event.clientY,
});
if (!pos) return;
if (!pos || pos.pos < 0 || pos.pos > view.state.doc.content.size) return;
const table = findParentNode((node) => node.type.name === "table")(
TextSelection.create(view.state.doc, pos.pos)

View File

@ -177,7 +177,7 @@ const rowsToolboxItems: ToolboxItem[] = [
action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox`
},
{
label: "Delete Row",
label: "Delete row",
icon: icons.deleteRow,
action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteRow().run(),
},
@ -189,7 +189,7 @@ function createToolbox({
tippyOptions,
onSelectColor,
onClickItem,
colors = {},
colors,
}: {
triggerButton: Element | null;
items: ToolboxItem[];
@ -202,38 +202,44 @@ function createToolbox({
const toolbox = tippy(triggerButton, {
content: h(
"div",
{ className: "tableToolbox" },
items.map((item, index) => {
{
className:
"rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 text-xs shadow-custom-shadow-rg min-w-[12rem] whitespace-nowrap",
},
items.map((item) => {
if (item.label === "Pick color") {
return h("div", { className: "flex flex-col" }, [
h("div", { className: "divider" }),
h("div", { className: "colorPickerLabel" }, item.label),
h("hr", { className: "my-2 border-custom-border-200" }),
h("div", { className: "text-custom-text-200 text-sm" }, item.label),
h(
"div",
{ className: "colorPicker grid" },
{ className: "grid grid-cols-6 gap-x-1 gap-y-2.5 mt-2" },
Object.entries(colors).map(([colorName, colorValue]) =>
h("div", {
className: "colorPickerItem flex items-center justify-center",
style: `background-color: ${colorValue.backgroundColor};
color: ${colorValue.textColor || "inherit"};`,
className: "grid place-items-center size-6 rounded cursor-pointer",
style: `background-color: ${colorValue.backgroundColor};color: ${colorValue.textColor || "inherit"};`,
innerHTML:
colorValue.icon ?? `<span class="text-md" style:"color: ${colorValue.backgroundColor}>A</span>`,
onClick: () => onSelectColor(colorValue),
})
)
),
h("div", { className: "divider" }),
h("hr", { className: "my-2 border-custom-border-200" }),
]);
} else {
return h(
"div",
{
className: "toolboxItem",
className:
"flex items-center gap-2 px-1 py-1.5 bg-custom-background-100 hover:bg-custom-background-80 text-sm text-custom-text-200 rounded cursor-pointer",
itemType: "div",
onClick: () => onClickItem(item),
},
[
h("div", { className: "iconContainer", innerHTML: item.icon }),
h("span", {
className: "h-3 w-3 flex-shrink-0",
innerHTML: item.icon,
}),
h("div", { className: "label" }, item.label),
]
);
@ -290,27 +296,27 @@ export class TableView implements NodeView {
if (editor.isEditable) {
this.rowsControl = h(
"div",
{ className: "rowsControl" },
{ className: "rows-control" },
h("div", {
itemType: "button",
className: "rowsControlDiv",
className: "rows-control-div",
onClick: () => this.selectRow(),
})
);
this.columnsControl = h(
"div",
{ className: "columnsControl" },
{ className: "columns-control" },
h("div", {
itemType: "button",
className: "columnsControlDiv",
className: "columns-control-div",
onClick: () => this.selectColumn(),
})
);
this.controls = h(
"div",
{ className: "tableControls", contentEditable: "false" },
{ className: "table-controls", contentEditable: "false" },
this.rowsControl,
this.columnsControl
);
@ -331,7 +337,7 @@ export class TableView implements NodeView {
};
this.columnsToolbox = createToolbox({
triggerButton: this.columnsControl.querySelector(".columnsControlDiv"),
triggerButton: this.columnsControl.querySelector(".columns-control-div"),
items: columnsToolboxItems,
colors: columnColors,
onSelectColor: (color) => setCellsBackgroundColor(this.editor, color),
@ -380,7 +386,7 @@ export class TableView implements NodeView {
this.root = h(
"div",
{
className: "tableWrapper controls--disabled",
className: "table-wrapper horizontal-scrollbar scrollbar-md controls--disabled",
},
this.controls,
this.table

View File

@ -5,7 +5,7 @@ import { MentionNodeView } from "src/ui/mentions/mention-node-view";
import { IMentionHighlight } from "src/types/mention-suggestion";
export interface CustomMentionOptions extends MentionOptions {
mentionHighlights: IMentionHighlight[];
mentionHighlights: () => Promise<IMentionHighlight[]>;
readonly?: boolean;
}
@ -32,6 +32,12 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
redirect_uri: {
default: "/",
},
entity_identifier: {
default: null,
},
entity_name: {
default: null,
},
};
},
@ -43,17 +49,6 @@ export const CustomMention = Mention.extend<CustomMentionOptions>({
return [
{
tag: "mention-component",
getAttrs: (node: string | HTMLElement) => {
if (typeof node === "string") {
return null;
}
return {
id: node.getAttribute("data-mention-id") || "",
target: node.getAttribute("data-mention-target") || "",
label: node.innerText.slice(1) || "",
redirect_uri: node.getAttribute("redirect_uri"),
};
},
},
];
},

View File

@ -1,15 +1,90 @@
// @ts-nocheck
import { Suggestion } from "src/ui/mentions/suggestion";
import { CustomMention } from "src/ui/mentions/custom";
import { IMentionHighlight } from "src/types/mention-suggestion";
import { IMentionHighlight, IMentionSuggestion } from "src/types/mention-suggestion";
import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
export const Mentions = (mentionSuggestions: IMentionSuggestion[], mentionHighlights: IMentionHighlight[], readonly) =>
import { MentionList } from "src/ui/mentions/mention-list";
export const Mentions = ({
mentionHighlights,
mentionSuggestions,
readonly,
}: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
mentionHighlights?: () => Promise<IMentionHighlight[]>;
readonly: boolean;
}) =>
CustomMention.configure({
HTMLAttributes: {
class: "mention",
},
readonly: readonly,
mentionHighlights: mentionHighlights,
suggestion: Suggestion(mentionSuggestions),
suggestion: {
// @ts-expect-error - Tiptap types are incorrect
render: () => {
if (!mentionSuggestions) return;
let component: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
if (!props.clientRect) {
return;
}
component = new ReactRenderer(MentionList, {
props: { ...props, mentionSuggestions },
editor: props.editor,
});
props.editor.storage.mentionsOpen = true;
// @ts-expect-error - Tippy types are incorrect
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
component?.updateProps(props);
if (!props.clientRect) {
return;
}
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-expect-error - Tippy types are incorrect
component?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
component?.destroy();
},
};
},
},
});

View File

@ -1,36 +1,106 @@
import { Editor } from "@tiptap/react";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState } from "react";
import { cn } from "src/lib/utils";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { v4 as uuidv4 } from "uuid";
import { Avatar } from "@plane/ui";
interface MentionListProps {
items: IMentionSuggestion[];
command: (item: { id: string; label: string; target: string; redirect_uri: string }) => void;
command: (item: {
id: string;
label: string;
entity_name: string;
entity_identifier: string;
target: string;
redirect_uri: string;
}) => void;
query: string;
editor: Editor;
mentionSuggestions: () => Promise<IMentionSuggestion[]>;
}
// eslint-disable-next-line react/display-name
export const MentionList = forwardRef((props: MentionListProps, ref) => {
const { query, mentionSuggestions } = props;
const [items, setItems] = useState<IMentionSuggestion[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchSuggestions = async () => {
setIsLoading(true);
try {
const suggestions = await mentionSuggestions();
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
const transactionId = uuidv4();
return {
...suggestion,
id: transactionId,
};
});
const filteredSuggestions = mappedSuggestions.filter((suggestion) =>
suggestion.title.toLowerCase().startsWith(query.toLowerCase())
);
setItems(filteredSuggestions);
} catch (error) {
console.error("Failed to fetch suggestions:", error);
} finally {
setIsLoading(false);
}
};
fetchSuggestions();
}, [query, mentionSuggestions]);
const selectItem = (index: number) => {
const item = props.items[index];
try {
const item = items[index];
if (item) {
props.command({
id: item.id,
label: item.title,
target: "users",
redirect_uri: item.redirect_uri,
});
if (item) {
props.command({
id: item.id,
label: item.title,
entity_identifier: item.entity_identifier,
entity_name: item.entity_name,
target: "users",
redirect_uri: item.redirect_uri,
});
}
} catch (error) {
console.error("Error selecting item:", error);
}
};
const commandListContainer = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const container = commandListContainer?.current;
const item = container?.children[selectedIndex] as HTMLElement;
if (item && container) updateScrollView(container, item);
}, [selectedIndex]);
const updateScrollView = (container: HTMLElement, item: HTMLElement) => {
const containerHeight = container.offsetHeight;
const itemHeight = item ? item.offsetHeight : 0;
const top = item.offsetTop;
const bottom = top + itemHeight;
if (top < container.scrollTop) {
container.scrollTop -= container.scrollTop - top + 5;
} else if (bottom > containerHeight + container.scrollTop) {
container.scrollTop += bottom - containerHeight - container.scrollTop + 5;
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
setSelectedIndex((selectedIndex + 1) % items.length);
};
const enterHandler = () => {
@ -39,7 +109,7 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
useEffect(() => {
setSelectedIndex(0);
}, [props.items]);
}, [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
@ -62,38 +132,33 @@ export const MentionList = forwardRef((props: MentionListProps, ref) => {
},
}));
return props.items && props.items.length !== 0 ? (
<div className="mentions absolute max-h-40 w-48 space-y-0.5 overflow-y-auto rounded-md bg-custom-background-100 p-1 text-sm text-custom-text-300 shadow-custom-shadow-sm">
{props.items.length ? (
props.items.map((item, index) => (
return (
<div
ref={commandListContainer}
className="mentions absolute max-h-48 min-w-[12rem] rounded-md bg-custom-background-100 border-[0.5px] border-custom-border-300 px-2 py-2.5 text-xs shadow-custom-shadow-rg overflow-y-scroll"
>
{isLoading ? (
<div className="text-center text-custom-text-400">Loading...</div>
) : items.length ? (
items.map((item, index) => (
<div
key={item.id}
className={`flex cursor-pointer items-center gap-2 rounded p-1 hover:bg-custom-background-80 ${
index === selectedIndex ? "bg-custom-background-80" : ""
}`}
className={cn(
"flex cursor-pointer items-center gap-2 rounded px-1 py-1.5 hover:bg-custom-background-80 text-custom-text-200",
{
"bg-custom-background-80": index === selectedIndex,
}
)}
onClick={() => selectItem(index)}
>
<div className="grid h-4 w-4 flex-shrink-0 place-items-center overflow-hidden">
{item.avatar && item.avatar.trim() !== "" ? (
<img src={item.avatar} className="h-full w-full rounded-sm object-cover" alt={item.title} />
) : (
<div className="grid h-full w-full place-items-center rounded-sm bg-gray-700 text-xs capitalize text-white">
{item.title[0]}
</div>
)}
</div>
<div className="flex-grow space-y-1 truncate">
<p className="truncate text-sm font-medium">{item.title}</p>
{/* <p className="text-xs text-gray-400">{item.subtitle}</p> */}
</div>
<Avatar name={item?.title} src={item?.avatar} />
<span className="flex-grow truncate">{item.title}</span>
</div>
))
) : (
<div className="item">No result</div>
<div className="text-center text-custom-text-400">No results</div>
)}
</div>
) : (
<></>
);
});

View File

@ -4,11 +4,21 @@ import { NodeViewWrapper } from "@tiptap/react";
import { cn } from "src/lib/utils";
import { useRouter } from "next/router";
import { IMentionHighlight } from "src/types/mention-suggestion";
import { useEffect, useState } from "react";
// eslint-disable-next-line import/no-anonymous-default-export
export const MentionNodeView = (props) => {
const router = useRouter();
const highlights = props.extension.options.mentionHighlights as IMentionHighlight[];
const [highlightsState, setHighlightsState] = useState<IMentionHighlight[]>();
useEffect(() => {
if (!props.extension.options.mentionHighlights) return;
const hightlights = async () => {
const userId = await props.extension.options.mentionHighlights();
setHighlightsState(userId);
};
hightlights();
}, [props.extension.options]);
const handleClick = () => {
if (!props.extension.options.readonly) {
@ -20,13 +30,12 @@ export const MentionNodeView = (props) => {
<NodeViewWrapper className="mention-component inline w-fit">
<span
className={cn("mention rounded bg-custom-primary-100/20 px-1 py-0.5 font-medium text-custom-primary-100", {
"bg-yellow-500/20 text-yellow-500": highlights ? highlights.includes(props.node.attrs.id) : false,
"bg-yellow-500/20 text-yellow-500": highlightsState
? highlightsState.includes(props.node.attrs.entity_identifier)
: false,
"cursor-pointer": !props.extension.options.readonly,
// "hover:bg-custom-primary-300" : !props.extension.options.readonly && !highlights.includes(props.node.attrs.id)
})}
onClick={handleClick}
data-mention-target={props.node.attrs.target}
data-mention-id={props.node.attrs.id}
>
@{props.node.attrs.label}
</span>

View File

@ -1,66 +1,17 @@
import { ReactRenderer } from "@tiptap/react";
import { Editor } from "@tiptap/core";
import tippy from "tippy.js";
import { MentionList } from "src/ui/mentions/mention-list";
import { v4 as uuidv4 } from "uuid";
import { IMentionSuggestion } from "src/types/mention-suggestion";
export const Suggestion = (suggestions: IMentionSuggestion[]) => ({
items: ({ query }: { query: string }) =>
suggestions.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5),
render: () => {
let reactRenderer: ReactRenderer | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect: DOMRect }) => {
props.editor.storage.mentionsOpen = true;
reactRenderer = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
// @ts-ignore
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
content: reactRenderer.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: { editor: Editor; clientRect: DOMRect }) => {
reactRenderer?.updateProps(props);
popup &&
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0].hide();
return true;
}
const navigationKeys = ["ArrowUp", "ArrowDown", "Enter"];
if (navigationKeys.includes(props.event.key)) {
// @ts-ignore
reactRenderer?.ref?.onKeyDown(props);
event?.stopPropagation();
return true;
}
return false;
},
onExit: (props: { editor: Editor; event: KeyboardEvent }) => {
props.editor.storage.mentionsOpen = false;
popup?.[0].destroy();
reactRenderer?.destroy();
},
};
},
});
export const getSuggestionItems =
(suggestions: IMentionSuggestion[]) =>
({ query }: { query: string }) => {
const mappedSuggestions: IMentionSuggestion[] = suggestions.map((suggestion): IMentionSuggestion => {
const transactionId = uuidv4();
return {
...suggestion,
id: transactionId,
};
});
return mappedSuggestions
.filter((suggestion) => suggestion.title.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
};

View File

@ -33,6 +33,7 @@ import {
} from "src/lib/editor-commands";
import { LucideIconType } from "src/types/lucide-icon";
import { UploadImage } from "src/types/upload-image";
import { Selection } from "@tiptap/pm/state";
export interface EditorMenuItem {
name: string;
@ -41,104 +42,142 @@ export interface EditorMenuItem {
icon: LucideIconType;
}
export const HeadingOneItem = (editor: Editor): EditorMenuItem => ({
name: "H1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
});
export const HeadingOneItem = (editor: Editor) =>
({
name: "H1",
isActive: () => editor.isActive("heading", { level: 1 }),
command: () => toggleHeadingOne(editor),
icon: Heading1,
}) as const satisfies EditorMenuItem;
export const HeadingTwoItem = (editor: Editor): EditorMenuItem => ({
name: "H2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
});
export const HeadingTwoItem = (editor: Editor) =>
({
name: "H2",
isActive: () => editor.isActive("heading", { level: 2 }),
command: () => toggleHeadingTwo(editor),
icon: Heading2,
}) as const satisfies EditorMenuItem;
export const HeadingThreeItem = (editor: Editor): EditorMenuItem => ({
name: "H3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
});
export const HeadingThreeItem = (editor: Editor) =>
({
name: "H3",
isActive: () => editor.isActive("heading", { level: 3 }),
command: () => toggleHeadingThree(editor),
icon: Heading3,
}) as const satisfies EditorMenuItem;
export const BoldItem = (editor: Editor): EditorMenuItem => ({
name: "bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
});
export const BoldItem = (editor: Editor) =>
({
name: "bold",
isActive: () => editor?.isActive("bold"),
command: () => toggleBold(editor),
icon: BoldIcon,
}) as const satisfies EditorMenuItem;
export const ItalicItem = (editor: Editor): EditorMenuItem => ({
name: "italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
});
export const ItalicItem = (editor: Editor) =>
({
name: "italic",
isActive: () => editor?.isActive("italic"),
command: () => toggleItalic(editor),
icon: ItalicIcon,
}) as const satisfies EditorMenuItem;
export const UnderLineItem = (editor: Editor): EditorMenuItem => ({
name: "underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
});
export const UnderLineItem = (editor: Editor) =>
({
name: "underline",
isActive: () => editor?.isActive("underline"),
command: () => toggleUnderline(editor),
icon: UnderlineIcon,
}) as const satisfies EditorMenuItem;
export const StrikeThroughItem = (editor: Editor): EditorMenuItem => ({
name: "strike",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
});
export const StrikeThroughItem = (editor: Editor) =>
({
name: "strike",
isActive: () => editor?.isActive("strike"),
command: () => toggleStrike(editor),
icon: StrikethroughIcon,
}) as const satisfies EditorMenuItem;
export const BulletListItem = (editor: Editor): EditorMenuItem => ({
name: "bullet-list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
});
export const BulletListItem = (editor: Editor) =>
({
name: "bullet-list",
isActive: () => editor?.isActive("bulletList"),
command: () => toggleBulletList(editor),
icon: ListIcon,
}) as const satisfies EditorMenuItem;
export const TodoListItem = (editor: Editor): EditorMenuItem => ({
name: "To-do List",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
});
export const TodoListItem = (editor: Editor) =>
({
name: "To-do List",
isActive: () => editor.isActive("taskItem"),
command: () => toggleTaskList(editor),
icon: CheckSquare,
}) as const satisfies EditorMenuItem;
export const CodeItem = (editor: Editor): EditorMenuItem => ({
name: "code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
});
export const CodeItem = (editor: Editor) =>
({
name: "code",
isActive: () => editor?.isActive("code") || editor?.isActive("codeBlock"),
command: () => toggleCodeBlock(editor),
icon: CodeIcon,
}) as const satisfies EditorMenuItem;
export const NumberedListItem = (editor: Editor): EditorMenuItem => ({
name: "ordered-list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
});
export const NumberedListItem = (editor: Editor) =>
({
name: "ordered-list",
isActive: () => editor?.isActive("orderedList"),
command: () => toggleOrderedList(editor),
icon: ListOrderedIcon,
}) as const satisfies EditorMenuItem;
export const QuoteItem = (editor: Editor): EditorMenuItem => ({
name: "quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
});
export const QuoteItem = (editor: Editor) =>
({
name: "quote",
isActive: () => editor?.isActive("blockquote"),
command: () => toggleBlockquote(editor),
icon: QuoteIcon,
}) as const satisfies EditorMenuItem;
export const TableItem = (editor: Editor): EditorMenuItem => ({
name: "table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
});
export const TableItem = (editor: Editor) =>
({
name: "table",
isActive: () => editor?.isActive("table"),
command: () => insertTableCommand(editor),
icon: TableIcon,
}) as const satisfies EditorMenuItem;
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,
});
export const ImageItem = (editor: Editor, uploadFile: UploadImage) =>
({
name: "image",
isActive: () => editor?.isActive("image"),
command: (savedSelection: Selection | null) => insertImageCommand(editor, uploadFile, savedSelection),
icon: ImageIcon,
}) as const;
export function getEditorMenuItems(editor: Editor | null, uploadFile: UploadImage) {
if (!editor) {
return [];
}
return [
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
BoldItem(editor),
ItalicItem(editor),
UnderLineItem(editor),
StrikeThroughItem(editor),
BulletListItem(editor),
TodoListItem(editor),
CodeItem(editor),
NumberedListItem(editor),
QuoteItem(editor),
TableItem(editor),
ImageItem(editor, uploadFile),
];
}
export type EditorMenuItemNames = ReturnType<typeof getEditorMenuItems> extends (infer U)[]
? U extends { name: infer N }
? N
: never
: never;

View File

@ -57,10 +57,7 @@ export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin =>
export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await deleteImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image deleted successfully");
}
await deleteImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error deleting image: ", error);
}
@ -69,10 +66,7 @@ export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Prom
export async function onNodeRestored(src: string, restoreImage: RestoreImage): Promise<void> {
try {
const assetUrlWithWorkspaceId = new URL(src).pathname.substring(1);
const resStatus = await restoreImage(assetUrlWithWorkspaceId);
if (resStatus === 204) {
console.log("Image restored successfully");
}
await restoreImage(assetUrlWithWorkspaceId);
} catch (error) {
console.error("Error restoring image: ", error);
}

View File

@ -21,7 +21,7 @@ export const UploadImagesPlugin = (cancelUploadImage?: () => any) =>
const placeholder = document.createElement("div");
placeholder.setAttribute("class", "img-placeholder");
const image = document.createElement("img");
image.setAttribute("class", "opacity-10 rounded-lg border border-custom-border-300");
image.setAttribute("class", "opacity-60 rounded-lg border border-custom-border-300");
image.src = src;
placeholder.appendChild(image);
@ -73,13 +73,7 @@ const removePlaceholder = (view: EditorView, id: {}) => {
view.dispatch(removePlaceholderTr);
};
export async function startImageUpload(
file: File,
view: EditorView,
pos: number,
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
) {
export async function startImageUpload(file: File, view: EditorView, pos: number, uploadFile: UploadImage) {
if (!file) {
alert("No file selected. Please select a file to upload.");
return;
@ -120,7 +114,7 @@ export async function startImageUpload(
return;
};
setIsSubmitting?.("submitting");
// setIsSubmitting?.("submitting");
try {
const src = await UploadImageHandler(file, uploadFile);
@ -134,6 +128,7 @@ export async function startImageUpload(
const transaction = view.state.tr.insert(pos - 1, node).setMeta(uploadKey, { remove: { id } });
view.dispatch(transaction);
view.focus();
} catch (error) {
console.error("Upload error: ", error);
removePlaceholder(view, id);

View File

@ -1,15 +1,15 @@
import { EditorProps } from "@tiptap/pm/view";
import { findTableAncestor } from "src/lib/utils";
import { cn, findTableAncestor } from "src/lib/utils";
import { UploadImage } from "src/types/upload-image";
import { startImageUpload } from "src/ui/plugins/upload-image";
export function CoreEditorProps(
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
): EditorProps {
export function CoreEditorProps(uploadFile: UploadImage, editorClassName: string): EditorProps {
return {
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
class: cn(
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
editorClassName
),
},
handleDOMEvents: {
keydown: (_view, event) => {
@ -36,7 +36,7 @@ export function CoreEditorProps(
event.preventDefault();
const file = event.clipboardData.files[0];
const pos = view.state.selection.from;
startImageUpload(file, view, pos, uploadFile, setIsSubmitting);
startImageUpload(file, view, pos, uploadFile);
return true;
}
return false;
@ -50,7 +50,7 @@ export function CoreEditorProps(
top: event.clientY,
});
if (coordinates) {
startImageUpload(file, view, coordinates.pos - 1, uploadFile, setIsSubmitting);
startImageUpload(file, view, coordinates.pos - 1, uploadFile);
}
return true;
}

View File

@ -14,7 +14,7 @@ import { TableRow } from "src/ui/extensions/table/table-row/table-row";
import { ReadOnlyImageExtension } from "src/ui/extensions/image/read-only-image";
import { isValidHttpUrl } from "src/lib/utils";
import { Mentions } from "src/ui/mentions";
import { IMentionSuggestion } from "src/types/mention-suggestion";
import { IMentionHighlight } from "src/types/mention-suggestion";
import { CustomLinkExtension } from "src/ui/extensions/custom-link";
import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule";
import { CustomQuoteExtension } from "src/ui/extensions/quote";
@ -23,23 +23,22 @@ import { CustomCodeBlockExtension } from "src/ui/extensions/code";
import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline";
export const CoreReadOnlyEditorExtensions = (mentionConfig: {
mentionSuggestions: IMentionSuggestion[];
mentionHighlights: string[];
mentionHighlights?: () => Promise<IMentionHighlight[]>;
}) => [
StarterKit.configure({
bulletList: {
HTMLAttributes: {
class: "list-disc list-outside leading-3 -mt-2",
class: "list-disc pl-7 space-y-2",
},
},
orderedList: {
HTMLAttributes: {
class: "list-decimal list-outside leading-3 -mt-2",
class: "list-decimal pl-7 space-y-2",
},
},
listItem: {
HTMLAttributes: {
class: "leading-normal -mb-2",
class: "not-prose space-y-2",
},
},
code: false,
@ -49,11 +48,9 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
dropcursor: false,
gapcursor: false,
}),
CustomQuoteExtension.configure({
HTMLAttributes: { className: "border-l-4 border-custom-border-300" },
}),
CustomQuoteExtension,
CustomHorizontalRule.configure({
HTMLAttributes: { class: "mt-4 mb-4" },
HTMLAttributes: { class: "my-4" },
}),
CustomLinkExtension.configure({
openOnClick: true,
@ -69,7 +66,7 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
CustomTypographyExtension,
ReadOnlyImageExtension.configure({
HTMLAttributes: {
class: "rounded-lg border border-custom-border-300",
class: "rounded-md",
},
}),
TiptapUnderline,
@ -77,16 +74,20 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
Color,
TaskList.configure({
HTMLAttributes: {
class: "not-prose pl-2",
class: "not-prose pl-2 space-y-2",
},
}),
TaskItem.configure({
HTMLAttributes: {
class: "flex items-start my-4",
class: "flex pointer-events-none",
},
nested: true,
}),
CustomCodeBlockExtension,
CustomCodeBlockExtension.configure({
HTMLAttributes: {
class: "bg-custom-background-90 text-custom-text-100 rounded-lg p-8 pl-9 pr-4",
},
}),
CustomCodeInlineExtension,
Markdown.configure({
html: true,
@ -96,5 +97,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: {
TableHeader,
TableCell,
TableRow,
Mentions(mentionConfig.mentionSuggestions, mentionConfig.mentionHighlights, true),
Mentions({
mentionHighlights: mentionConfig.mentionHighlights,
readonly: true,
}),
];

View File

@ -1,7 +1,11 @@
import { EditorProps } from "@tiptap/pm/view";
import { cn } from "src/lib/utils";
export const CoreReadOnlyEditorProps: EditorProps = {
export const CoreReadOnlyEditorProps = (editorClassName: string): EditorProps => ({
attributes: {
class: `prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none`,
class: cn(
"prose prose-brand max-w-full prose-headings:font-display font-default focus:outline-none",
editorClassName
),
},
};
});

View File

@ -1,33 +1,30 @@
import { useState } from "react";
import { useCallback, useState } from "react";
import { IMarking } from "src/types/editor-types";
export const useEditorMarkings = () => {
const [markings, setMarkings] = useState<IMarking[]>([]);
const updateMarkings = (json: any) => {
const nodes = json.content as any[];
const updateMarkings = useCallback((html: string) => {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const headings = doc.querySelectorAll("h1, h2, h3");
const tempMarkings: IMarking[] = [];
let h1Sequence: number = 0;
let h2Sequence: number = 0;
let h3Sequence: number = 0;
if (nodes) {
nodes.forEach((node) => {
if (
node.type === "heading" &&
(node.attrs.level === 1 || node.attrs.level === 2 || node.attrs.level === 3) &&
node.content
) {
tempMarkings.push({
type: "heading",
level: node.attrs.level,
text: node.content[0].text,
sequence: node.attrs.level === 1 ? ++h1Sequence : node.attrs.level === 2 ? ++h2Sequence : ++h3Sequence,
});
}
headings.forEach((heading) => {
const level = parseInt(heading.tagName[1]); // Extract the number from h1, h2, h3
tempMarkings.push({
type: "heading",
level: level,
text: heading.textContent || "",
sequence: level === 1 ? ++h1Sequence : level === 2 ? ++h2Sequence : ++h3Sequence,
});
}
});
setMarkings(tempMarkings);
};
}, []);
return {
updateMarkings,

View File

@ -1,3 +1,9 @@
export { DocumentEditor, DocumentEditorWithRef } from "src/ui";
export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/readonly";
export { FixedMenu } from "src/ui/menu/fixed-menu";
// hooks
export { useEditorMarkings } from "src/hooks/use-editor-markings";
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";
export type { IMarking } from "src/types/editor-types";

View File

@ -1,10 +1,3 @@
export interface DocumentDetails {
title: string;
created_by: string;
created_on: Date;
last_updated_by: string;
last_updated_at: Date;
}
export interface IMarking {
type: "heading";
level: number;

View File

@ -1,20 +0,0 @@
import { LucideIconType } from "@plane/editor-core";
interface IAlertLabelProps {
Icon?: LucideIconType;
backgroundColor: string;
textColor?: string;
label: string;
}
export const AlertLabel = (props: IAlertLabelProps) => {
const { Icon, backgroundColor, textColor, label } = props;
return (
<div
className={`flex h-7 items-center gap-2 rounded-full px-3 py-0.5 text-xs font-medium ${backgroundColor} ${textColor}`}
>
{Icon && <Icon className="h-3 w-3" />}
<span>{label}</span>
</div>
);
};

View File

@ -1,40 +0,0 @@
import { HeadingComp, HeadingThreeComp, SubheadingComp } from "src/ui/components/heading-component";
import { IMarking } from "src/types/editor-types";
import { Editor } from "@tiptap/react";
import { scrollSummary } from "src/utils/editor-summary-utils";
interface ContentBrowserProps {
editor: Editor;
markings: IMarking[];
setSidePeekVisible?: (sidePeekState: boolean) => void;
}
export const ContentBrowser = (props: ContentBrowserProps) => {
const { editor, markings, setSidePeekVisible } = props;
const handleOnClick = (marking: IMarking) => {
scrollSummary(editor, marking);
if (setSidePeekVisible) setSidePeekVisible(false);
};
return (
<div className="flex h-full flex-col overflow-hidden">
<h2 className="font-medium">Outline</h2>
<div className="h-full overflow-y-auto">
{markings.length !== 0 ? (
markings.map((marking) =>
marking.level === 1 ? (
<HeadingComp onClick={() => handleOnClick(marking)} heading={marking.text} />
) : marking.level === 2 ? (
<SubheadingComp onClick={() => handleOnClick(marking)} subHeading={marking.text} />
) : (
<HeadingThreeComp heading={marking.text} onClick={() => handleOnClick(marking)} />
)
)
) : (
<p className="mt-3 text-xs text-custom-text-400">Headings will be displayed here for navigation</p>
)}
</div>
</div>
);
};

View File

@ -1,99 +0,0 @@
import { Editor } from "@tiptap/react";
import { Archive, RefreshCw, Lock } from "lucide-react";
import { IMarking, DocumentDetails } from "src/types/editor-types";
import { FixedMenu } from "src/ui/menu";
import { UploadImage } from "@plane/editor-core";
import { AlertLabel } from "src/ui/components/alert-label";
import { IVerticalDropdownItemProps, VerticalDropdownMenu } from "src/ui/components/vertical-dropdown-menu";
import { SummaryPopover } from "src/ui/components/summary-popover";
import { InfoPopover } from "src/ui/components/info-popover";
import { getDate } from "src/utils/date-utils";
interface IEditorHeader {
editor: Editor;
KanbanMenuOptions: IVerticalDropdownItemProps[];
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;
markings: IMarking[];
isLocked: boolean;
isArchived: boolean;
archivedAt?: Date;
readonly: boolean;
uploadFile?: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
documentDetails: DocumentDetails;
isSubmitting?: "submitting" | "submitted" | "saved";
}
export const EditorHeader = (props: IEditorHeader) => {
const {
documentDetails,
archivedAt,
editor,
sidePeekVisible,
readonly,
setSidePeekVisible,
markings,
uploadFile,
setIsSubmitting,
KanbanMenuOptions,
isArchived,
isLocked,
isSubmitting,
} = props;
return (
<div className="flex items-center border-b border-custom-border-200 md:px-5 px-3 py-2">
<div className="md:w-56 flex-shrink-0 lg:w-72 w-fit">
<SummaryPopover
editor={editor}
markings={markings}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<div className="flex-shrink-0 hidden md:flex">
{!readonly && uploadFile && (
<FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />
)}
</div>
<div className="flex flex-grow items-center justify-end gap-3">
{isLocked && (
<AlertLabel
Icon={Lock}
backgroundColor="bg-custom-background-80"
textColor="text-custom-text-300"
label="Locked"
/>
)}
{isArchived && archivedAt && (
<AlertLabel
Icon={Archive}
backgroundColor="bg-blue-500/20"
textColor="text-blue-500"
label={`Archived at ${getDate(archivedAt)?.toLocaleString()}`}
/>
)}
{!isLocked && !isArchived ? (
<div
className={`absolute right-[120px] flex items-center gap-x-2 transition-all duration-300 ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting !== "submitted" && isSubmitting !== "saved" && (
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
)}
<span className="text-sm text-custom-text-300">
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</span>
</div>
) : null}
{!isArchived && <InfoPopover documentDetails={documentDetails} />}
<VerticalDropdownMenu items={KanbanMenuOptions} />
</div>
</div>
);
};

View File

@ -1,47 +0,0 @@
export const HeadingComp = ({
heading,
onClick,
}: {
heading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<h3
onClick={onClick}
className="ml-4 mt-3 cursor-pointer text-sm font-medium leading-[125%] tracking-tight hover:text-custom-primary max-md:ml-2.5"
role="button"
>
{heading}
</h3>
);
export const SubheadingComp = ({
subHeading,
onClick,
}: {
subHeading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<p
onClick={onClick}
className="ml-6 mt-2 cursor-pointer text-xs font-medium tracking-tight text-gray-400 hover:text-custom-primary"
role="button"
>
{subHeading}
</p>
);
export const HeadingThreeComp = ({
heading,
onClick,
}: {
heading: string;
onClick: (event: React.MouseEvent<HTMLParagraphElement, MouseEvent>) => void;
}) => (
<p
onClick={onClick}
className="ml-8 mt-2 cursor-pointer text-xs font-medium tracking-tight text-gray-400 hover:text-custom-primary"
role="button"
>
{heading}
</p>
);

View File

@ -1,9 +1 @@
export * from "./alert-label";
export * from "./content-browser";
export * from "./editor-header";
export * from "./heading-component";
export * from "./info-popover";
export * from "./page-renderer";
export * from "./summary-popover";
export * from "./summary-side-bar";
export * from "./vertical-dropdown-menu";

View File

@ -115,11 +115,6 @@ export const LinkEditView = ({
const removeLink = () => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
linkRemoved.current = true;
viewProps.onActionCompleteHandler({
title: "Link successfully removed",
message: "The link was removed from the text.",
type: "success",
});
viewProps.closeLinkView();
};

View File

@ -12,21 +12,11 @@ export const LinkPreview = ({
const removeLink = () => {
editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link));
viewProps.onActionCompleteHandler({
title: "Link successfully removed",
message: "The link was removed from the text.",
type: "success",
});
viewProps.closeLinkView();
};
const copyLinkToClipboard = () => {
navigator.clipboard.writeText(url);
viewProps.onActionCompleteHandler({
title: "Link successfully copied",
message: "The link was copied to the clipboard.",
type: "success",
});
viewProps.closeLinkView();
};

View File

@ -11,11 +11,6 @@ export interface LinkViewProps {
to: number;
url: string;
closeLinkView: () => void;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
}
export const LinkView = (props: LinkViewProps & { style: CSSProperties }) => {

View File

@ -1,9 +1,8 @@
import { useCallback, useRef, useState } from "react";
import { EditorContainer, EditorContentWrapper } from "@plane/editor-core";
import { Node } from "@tiptap/pm/model";
import { EditorView } from "@tiptap/pm/view";
import { Editor, ReactRenderer } from "@tiptap/react";
import { useCallback, useRef, useState } from "react";
import { DocumentDetails } from "src/types/editor-types";
import { LinkView, LinkViewProps } from "./links/link-view";
import {
autoUpdate,
@ -15,40 +14,22 @@ import {
useFloating,
useInteractions,
} from "@floating-ui/react";
import BlockMenu from "../menu//block-menu";
type IPageRenderer = {
documentDetails: DocumentDetails;
updatePageTitle: (title: string) => void;
editor: Editor;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
editorClassNames: string;
editorContentCustomClassNames?: string;
editorContainerClassName: string;
hideDragHandle?: () => void;
readonly: boolean;
tabIndex?: number;
};
export const PageRenderer = (props: IPageRenderer) => {
const {
documentDetails,
tabIndex,
editor,
editorClassNames,
editorContentCustomClassNames,
updatePageTitle,
readonly,
hideDragHandle,
} = props;
const [pageTitle, setPagetitle] = useState(documentDetails.title);
const { tabIndex, editor, hideDragHandle, editorContainerClassName } = props;
// states
const [linkViewProps, setLinkViewProps] = useState<LinkViewProps>();
const [isOpen, setIsOpen] = useState(false);
const [coordinates, setCoordinates] = useState<{ x: number; y: number }>();
const [cleanup, setCleanup] = useState(() => () => {});
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
@ -63,18 +44,9 @@ export const PageRenderer = (props: IPageRenderer) => {
const { getFloatingProps } = useInteractions([dismiss]);
const handlePageTitleChange = (title: string) => {
setPagetitle(title);
updatePageTitle(title);
};
const [cleanup, setcleanup] = useState(() => () => {});
const floatingElementRef = useRef<HTMLElement | null>(null);
const closeLinkView = () => {
setIsOpen(false);
};
const closeLinkView = () => setIsOpen(false);
const handleLinkHover = useCallback(
(event: React.MouseEvent) => {
@ -137,7 +109,6 @@ export const PageRenderer = (props: IPageRenderer) => {
setCoordinates({ x: x - 300, y: y - 50 });
setIsOpen(true);
setLinkViewProps({
onActionCompleteHandler: props.onActionCompleteHandler,
closeLinkView: closeLinkView,
view: "LinkPreview",
url: href,
@ -148,45 +119,32 @@ export const PageRenderer = (props: IPageRenderer) => {
});
});
setcleanup(cleanupFunc);
setCleanup(cleanupFunc);
},
[editor, cleanup]
);
return (
<div className="w-full h-full pb-20 pl-7 pt-5 page-renderer">
{!readonly ? (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="-mt-2 w-full break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
value={pageTitle}
/>
) : (
<input
onChange={(e) => handlePageTitleChange(e.target.value)}
className="-mt-2 w-full overflow-x-clip break-words border-none bg-custom-background pr-5 text-4xl font-bold outline-none"
value={pageTitle}
disabled
/>
)}
<div className="flex relative h-full w-full flex-col pr-5 editor-renderer" onMouseOver={handleLinkHover}>
<EditorContainer hideDragHandle={hideDragHandle} editor={editor} editorClassNames={editorClassNames}>
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
<EditorContainer
editor={editor}
hideDragHandle={hideDragHandle}
editorContainerClassName={editorContainerClassName}
>
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
{editor && editor.isEditable && <BlockMenu editor={editor} />}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (
<div
style={{ ...floatingStyles, left: `${coordinates.x}px`, top: `${coordinates.y}px` }}
className={`absolute`}
className="absolute"
ref={refs.setFloating}
>
<LinkView {...linkViewProps} style={floatingStyles} {...getFloatingProps()} />
</div>
)}
</div>
</>
);
};

View File

@ -1,19 +0,0 @@
import { Editor } from "@tiptap/react";
import { IMarking } from "src/types/editor-types";
import { ContentBrowser } from "src/ui/components/content-browser";
interface ISummarySideBarProps {
editor: Editor;
markings: IMarking[];
sidePeekVisible: boolean;
}
export const SummarySideBar = ({ editor, markings, sidePeekVisible }: ISummarySideBarProps) => (
<div
className={`h-full transform overflow-hidden p-5 transition-all duration-200 ${
sidePeekVisible ? "translate-x-0" : "-translate-x-full"
}`}
>
<ContentBrowser editor={editor} markings={markings} />
</div>
);

View File

@ -1,46 +0,0 @@
import { LucideIconType } from "@plane/editor-core";
import { CustomMenu } from "@plane/ui";
import { MoreVertical } from "lucide-react";
type TMenuItems =
| "archive_page"
| "unarchive_page"
| "lock_page"
| "unlock_page"
| "copy_markdown"
| "close_page"
| "copy_page_link"
| "duplicate_page";
export interface IVerticalDropdownItemProps {
key: number;
type: TMenuItems;
Icon: LucideIconType;
label: string;
action: () => Promise<void> | void;
}
export interface IVerticalDropdownMenuProps {
items: IVerticalDropdownItemProps[];
}
const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => (
<CustomMenu.MenuItem onClick={action} className="flex items-center gap-2">
<Icon className="h-3 w-3" />
<div className="text-custom-text-300">{label}</div>
</CustomMenu.MenuItem>
);
export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => (
<CustomMenu
maxHeight={"md"}
className={"h-4.5 mt-1"}
placement={"bottom-start"}
optionsClassName={"border-custom-border border-r border-solid transition-all duration-200 ease-in-out "}
customButton={<MoreVertical size={14} />}
>
{items.map((item) => (
<VerticalDropdownItem key={item.key} type={item.type} Icon={item.Icon} label={item.label} action={item.action} />
))}
</CustomMenu>
);

View File

@ -6,17 +6,17 @@ import { UploadImage } from "@plane/editor-core";
export const DocumentEditorExtensions = (
uploadFile: UploadImage,
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
) => [
SlashCommand(uploadFile, setIsSubmitting),
SlashCommand(uploadFile),
DragAndDrop(setHideDragHandle),
Placeholder.configure({
placeholder: ({ node }) => {
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
if (editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image")) {
return "";
}

View File

@ -1,187 +1,97 @@
"use client";
import React, { useState } from "react";
import { UploadImage, DeleteImage, RestoreImage, getEditorClassNames, useEditor } from "@plane/editor-core";
import {
UploadImage,
DeleteImage,
RestoreImage,
getEditorClassNames,
useEditor,
EditorRefApi,
IMentionHighlight,
IMentionSuggestion,
} from "@plane/editor-core";
import { DocumentEditorExtensions } from "src/ui/extensions";
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions";
import { EditorHeader } from "src/ui/components/editor-header";
import { useEditorMarkings } from "src/hooks/use-editor-markings";
import { SummarySideBar } from "src/ui/components/summary-side-bar";
import { DocumentDetails } from "src/types/editor-types";
import { PageRenderer } from "src/ui/components/page-renderer";
import { getMenuOptions } from "src/utils/menu-options";
import { useRouter } from "next/router";
import { FixedMenu } from "src";
interface IDocumentEditor {
// document info
documentDetails: DocumentDetails;
value: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
initialValue: string;
value?: string;
fileHandler: {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
handleEditorReady?: (value: boolean) => void;
containerClassName?: string;
editorClassName?: string;
onChange: (json: object, html: string) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions: () => Promise<IMentionSuggestion[]>;
};
// file operations
uploadFile: UploadImage;
deleteFile: DeleteImage;
restoreFile: RestoreImage;
cancelUploadImage: () => any;
// editor state managers
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
customClassName?: string;
editorContentCustomClassNames?: string;
onChange: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
updatePageTitle: (title: string) => void;
debouncedUpdatesEnabled?: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
// embed configuration
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
tabIndex?: number;
}
interface DocumentEditorProps extends IDocumentEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
setEditorValueAtCursorPosition: (content: string) => void;
}
const DocumentEditor = ({
documentDetails,
onChange,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
uploadFile,
deleteFile,
restoreFile,
isSubmitting,
customClassName,
forwardedRef,
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
updatePageTitle,
cancelUploadImage,
onActionCompleteHandler,
rerenderOnPropsChange,
tabIndex,
}: IDocumentEditor) => {
const { markings, updateMarkings } = useEditorMarkings();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const router = useRouter();
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
const DocumentEditor = (props: IDocumentEditor) => {
const {
onChange,
initialValue,
value,
fileHandler,
containerClassName,
editorClassName = "",
mentionHandler,
handleEditorReady,
forwardedRef,
tabIndex,
} = props;
// states
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
// loads such that we can invoke it from react when the cursor leaves the container
const setHideDragHandleFunction = (hideDragHandlerFromDragDrop: () => void) => {
setHideDragHandleOnMouseLeave(() => hideDragHandlerFromDragDrop);
};
// use editor
const editor = useEditor({
onChange(json, html) {
updateMarkings(json);
onChange(json, html);
},
onStart(json) {
updateMarkings(json);
},
debouncedUpdatesEnabled,
restoreFile,
setIsSubmitting,
setShouldShowAlert,
editorClassName,
restoreFile: fileHandler.restore,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
initialValue,
value,
uploadFile,
deleteFile,
cancelUploadImage,
rerenderOnPropsChange,
handleEditorReady,
forwardedRef,
extensions: DocumentEditorExtensions(uploadFile, setHideDragHandleFunction, setIsSubmitting),
mentionHandler,
extensions: DocumentEditorExtensions(fileHandler.upload, setHideDragHandleFunction),
});
if (!editor) {
return null;
}
const KanbanMenuOptions = getMenuOptions({
editor: editor,
router: router,
duplicationConfig: duplicationConfig,
pageLockConfig: pageLockConfig,
pageArchiveConfig: pageArchiveConfig,
onActionCompleteHandler,
});
const editorClassNames = getEditorClassNames({
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
customClassName,
containerClassName,
});
if (!editor) return null;
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<EditorHeader
readonly={false}
KanbanMenuOptions={KanbanMenuOptions}
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={(val) => setSidePeekVisible(val)}
markings={markings}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
documentDetails={documentDetails}
isSubmitting={isSubmitting}
/>
<div className="flex-shrink-0 md:hidden border-b border-custom-border-200 pl-3 py-2">
{uploadFile && <FixedMenu editor={editor} uploadFile={uploadFile} setIsSubmitting={setIsSubmitting} />}
</div>
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-72 hidden md:block">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-full md:w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler}
hideDragHandle={hideDragHandleOnMouseLeave}
readonly={false}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
editorClassNames={editorClassNames}
documentDetails={documentDetails}
updatePageTitle={updatePageTitle}
/>
</div>
<div className="hidden w-56 flex-shrink-0 lg:block lg:w-72" />
</div>
</div>
<PageRenderer
tabIndex={tabIndex}
editor={editor}
editorContainerClassName={editorContainerClassNames}
hideDragHandle={hideDragHandleOnMouseLeave}
/>
);
};
const DocumentEditorWithRef = React.forwardRef<EditorHandle, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref} />
const DocumentEditorWithRef = React.forwardRef<EditorRefApi, IDocumentEditor>((props, ref) => (
<DocumentEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
DocumentEditorWithRef.displayName = "DocumentEditorWithRef";

View File

@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef } from "react";
import tippy, { Instance } from "tippy.js";
import { Copy, LucideIcon, Trash2 } from "lucide-react";
import { Editor } from "@tiptap/react";
interface BlockMenuProps {
editor: Editor;
}
export default function BlockMenu(props: BlockMenuProps) {
const { editor } = props;
const menuRef = useRef<HTMLDivElement>(null);
const popup = useRef<Instance | null>(null);
const handleClickDragHandle = useCallback((event: MouseEvent) => {
const target = event.target as HTMLElement;
if (target.matches(".drag-handle-dots") || target.matches(".drag-handle-dot")) {
event.preventDefault();
popup.current?.setProps({
getReferenceClientRect: () => target.getBoundingClientRect(),
});
popup.current?.show();
return;
}
popup.current?.hide();
return;
}, []);
useEffect(() => {
if (menuRef.current) {
menuRef.current.remove();
menuRef.current.style.visibility = "visible";
// @ts-expect-error - tippy types are incorrect
popup.current = tippy(document.body, {
getReferenceClientRect: null,
content: menuRef.current,
appendTo: () => document.querySelector(".frame-renderer"),
trigger: "manual",
interactive: true,
arrow: false,
placement: "left-start",
animation: "shift-away",
maxWidth: 500,
hideOnClick: true,
onShown: () => {
menuRef.current?.focus();
},
});
}
return () => {
popup.current?.destroy();
popup.current = null;
};
}, []);
useEffect(() => {
const handleKeyDown = () => {
popup.current?.hide();
};
const handleScroll = () => {
popup.current?.hide();
};
document.addEventListener("click", handleClickDragHandle);
document.addEventListener("keydown", handleKeyDown);
document.addEventListener("scroll", handleScroll, true); // Using capture phase
return () => {
document.removeEventListener("click", handleClickDragHandle);
document.removeEventListener("keydown", handleKeyDown);
document.removeEventListener("scroll", handleScroll, true);
};
}, [handleClickDragHandle]);
const MENU_ITEMS: {
icon: LucideIcon;
key: string;
label: string;
onClick: (e: React.MouseEvent) => void;
isDisabled?: boolean;
}[] = [
{
icon: Trash2,
key: "delete",
label: "Delete",
onClick: (e) => {
editor.chain().deleteSelection().focus().run();
popup.current?.hide();
e.preventDefault();
e.stopPropagation();
},
},
{
icon: Copy,
key: "duplicate",
label: "Duplicate",
isDisabled: editor.state.selection.content().content.firstChild?.type.name === "image",
onClick: (e) => {
const { view } = editor;
const { state } = view;
const { selection } = state;
editor
.chain()
.insertContentAt(selection.to, selection.content().content.firstChild!.toJSON(), {
updateSelection: true,
})
.focus(selection.to + 1, { scrollIntoView: false })
.run();
popup.current?.hide();
e.preventDefault();
e.stopPropagation();
},
},
];
return (
<div
ref={menuRef}
className="z-10 max-h-60 min-w-[7rem] overflow-y-scroll rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 px-2 py-2.5 shadow-custom-shadow-rg"
>
{MENU_ITEMS.map((item) => {
// Skip rendering the button if it should be disabled
if (item.isDisabled && item.key === "duplicate") {
return null;
}
return (
<button
key={item.key}
type="button"
className="flex w-full items-center gap-2 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-80"
onClick={item.onClick}
disabled={item.isDisabled}
>
<item.icon className="h-3 w-3" />
{item.label}
</button>
);
})}
</div>
);
}

View File

@ -1,141 +0,0 @@
import { Editor } from "@tiptap/react";
import {
BoldItem,
BulletListItem,
isCellSelection,
cn,
CodeItem,
ImageItem,
ItalicItem,
NumberedListItem,
QuoteItem,
StrikeThroughItem,
TableItem,
UnderLineItem,
HeadingOneItem,
HeadingTwoItem,
HeadingThreeItem,
findTableAncestor,
EditorMenuItem,
UploadImage,
} from "@plane/editor-core";
export type BubbleMenuItem = EditorMenuItem;
type EditorBubbleMenuProps = {
editor: Editor;
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
};
export const FixedMenu = (props: EditorBubbleMenuProps) => {
const { editor, uploadFile, setIsSubmitting } = props;
const basicMarkItems: BubbleMenuItem[] = [
HeadingOneItem(editor),
HeadingTwoItem(editor),
HeadingThreeItem(editor),
BoldItem(editor),
ItalicItem(editor),
UnderLineItem(editor),
StrikeThroughItem(editor),
];
const listItems: BubbleMenuItem[] = [BulletListItem(editor), NumberedListItem(editor)];
const userActionItems: BubbleMenuItem[] = [QuoteItem(editor), CodeItem(editor)];
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(editor)];
items.push(ImageItem(editor, uploadFile, setIsSubmitting));
return items;
}
const complexItems: BubbleMenuItem[] = getComplexItems();
return (
<div className="flex flex-wrap items-center divide-x divide-custom-border-200">
<div className="flex items-center gap-0.5 pr-2">
{basicMarkItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon className="h-4 w-4" />
</button>
))}
</div>
<div className="flex items-center gap-0.5 px-2">
{listItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex items-center gap-0.5 px-2">
{userActionItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
<div className="flex items-center gap-0.5 pl-2">
{complexItems.map((item) => (
<button
key={item.name}
type="button"
onClick={item.command}
className={cn(
"grid h-7 w-7 place-items-center rounded text-custom-text-300 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-4 w-4", {
"text-custom-text-100": item.isActive(),
})}
/>
</button>
))}
</div>
</div>
);
};

View File

@ -1 +0,0 @@
export { FixedMenu } from "./fixed-menu";

View File

@ -1,132 +1,53 @@
import { getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import { useRouter } from "next/router";
import { useState, forwardRef, useEffect } from "react";
import { EditorHeader } from "src/ui/components/editor-header";
import { forwardRef, MutableRefObject } from "react";
import { EditorReadOnlyRefApi, getEditorClassNames, IMentionHighlight, useReadOnlyEditor } from "@plane/editor-core";
// components
import { PageRenderer } from "src/ui/components/page-renderer";
import { SummarySideBar } from "src/ui/components/summary-side-bar";
import { useEditorMarkings } from "src/hooks/use-editor-markings";
import { DocumentDetails } from "src/types/editor-types";
import { IPageArchiveConfig, IPageLockConfig, IDuplicationConfig } from "src/types/menu-actions";
import { getMenuOptions } from "src/utils/menu-options";
import { IssueWidgetPlaceholder } from "../extensions/widgets/issue-embed-widget";
interface IDocumentReadOnlyEditor {
value: string;
rerenderOnPropsChange?: {
id: string;
description_html: string;
};
noBorder: boolean;
borderOnFocus: boolean;
customClassName: string;
documentDetails: DocumentDetails;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
pageDuplicationConfig?: IDuplicationConfig;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
initialValue: string;
containerClassName: string;
editorClassName?: string;
tabIndex?: number;
handleEditorReady?: (value: boolean) => void;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
}
interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
const DocumentReadOnlyEditor = ({
noBorder,
borderOnFocus,
customClassName,
value,
documentDetails,
forwardedRef,
pageDuplicationConfig,
pageLockConfig,
pageArchiveConfig,
rerenderOnPropsChange,
onActionCompleteHandler,
tabIndex,
}: DocumentReadOnlyEditorProps) => {
const router = useRouter();
const [sidePeekVisible, setSidePeekVisible] = useState(true);
const { markings, updateMarkings } = useEditorMarkings();
const editor = useReadOnlyEditor({
value,
const DocumentReadOnlyEditor = (props: IDocumentReadOnlyEditor) => {
const {
containerClassName,
editorClassName = "",
initialValue,
forwardedRef,
rerenderOnPropsChange,
tabIndex,
handleEditorReady,
mentionHandler,
} = props;
const editor = useReadOnlyEditor({
initialValue,
editorClassName,
mentionHandler,
forwardedRef,
handleEditorReady,
extensions: [IssueWidgetPlaceholder()],
});
useEffect(() => {
if (editor) {
updateMarkings(editor.getJSON());
}
}, [editor]);
if (!editor) {
return null;
}
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
const KanbanMenuOptions = getMenuOptions({
editor: editor,
router: router,
pageArchiveConfig: pageArchiveConfig,
pageLockConfig: pageLockConfig,
duplicationConfig: pageDuplicationConfig,
onActionCompleteHandler,
});
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<EditorHeader
isLocked={!pageLockConfig ? false : pageLockConfig.is_locked}
isArchived={!pageArchiveConfig ? false : pageArchiveConfig.is_archived}
readonly
editor={editor}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
KanbanMenuOptions={KanbanMenuOptions}
markings={markings}
documentDetails={documentDetails}
archivedAt={pageArchiveConfig && pageArchiveConfig.archived_at}
/>
<div className="flex h-full w-full overflow-y-auto frame-renderer">
<div className="sticky top-0 h-full w-56 flex-shrink-0 lg:w-80">
<SummarySideBar editor={editor} markings={markings} sidePeekVisible={sidePeekVisible} />
</div>
<div className="h-full w-[calc(100%-14rem)] lg:w-[calc(100%-18rem-18rem)] page-renderer">
<PageRenderer
tabIndex={tabIndex}
onActionCompleteHandler={onActionCompleteHandler}
updatePageTitle={() => Promise.resolve()}
readonly
editor={editor}
editorClassNames={editorClassNames}
documentDetails={documentDetails}
/>
</div>
<div className="hidden w-56 flex-shrink-0 lg:block lg:w-80" />
</div>
</div>
);
return <PageRenderer tabIndex={tabIndex} editor={editor} editorContainerClassName={editorContainerClassName} />;
};
const DocumentReadOnlyEditorWithRef = forwardRef<EditorHandle, IDocumentReadOnlyEditor>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref} />
const DocumentReadOnlyEditorWithRef = forwardRef<EditorReadOnlyRefApi, IDocumentReadOnlyEditor>((props, ref) => (
<DocumentReadOnlyEditor {...props} forwardedRef={ref as MutableRefObject<EditorReadOnlyRefApi | null>} />
));
DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef";

View File

@ -1,12 +0,0 @@
import { Editor } from "@tiptap/core";
export const copyMarkdownToClipboard = (editor: Editor | null) => {
const markdownOutput = editor?.storage.markdown.getMarkdown();
navigator.clipboard.writeText(markdownOutput);
};
export const CopyPageLink = () => {
if (window) {
navigator.clipboard.writeText(window.location.toString());
}
};

View File

@ -1,153 +0,0 @@
import { Editor } from "@tiptap/react";
import { Archive, ArchiveRestoreIcon, ClipboardIcon, Copy, Link, Lock, Unlock } from "lucide-react";
import { NextRouter } from "next/router";
import { IVerticalDropdownItemProps } from "src/ui/components/vertical-dropdown-menu";
import { IDuplicationConfig, IPageArchiveConfig, IPageLockConfig } from "src/types/menu-actions";
import { copyMarkdownToClipboard, CopyPageLink } from "src/utils/menu-actions";
export interface MenuOptionsProps {
editor: Editor;
router: NextRouter;
duplicationConfig?: IDuplicationConfig;
pageLockConfig?: IPageLockConfig;
pageArchiveConfig?: IPageArchiveConfig;
onActionCompleteHandler: (action: {
title: string;
message: string;
type: "success" | "error" | "warning" | "info";
}) => void;
}
export const getMenuOptions = ({
editor,
router,
duplicationConfig,
pageLockConfig,
pageArchiveConfig,
onActionCompleteHandler,
}: MenuOptionsProps) => {
const KanbanMenuOptions: IVerticalDropdownItemProps[] = [
{
key: 1,
type: "copy_markdown",
Icon: ClipboardIcon,
action: () => {
onActionCompleteHandler({
title: "Markdown Copied",
message: "Page Copied as Markdown",
type: "success",
});
copyMarkdownToClipboard(editor);
},
label: "Copy markdown",
},
// {
// key: 2,
// type: "close_page",
// Icon: XCircle,
// action: () => router.back(),
// label: "Close page",
// },
{
key: 3,
type: "copy_page_link",
Icon: Link,
action: () => {
onActionCompleteHandler({
title: "Link Copied",
message: "Link to the page has been copied to clipboard",
type: "success",
});
CopyPageLink();
},
label: "Copy page link",
},
];
// If duplicateConfig is given, page duplication will be allowed
if (duplicationConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
type: "duplicate_page",
Icon: Copy,
action: () => {
duplicationConfig
.action()
.then(() => {
onActionCompleteHandler({
title: "Page Copied",
message: "Page has been copied as 'Copy of' followed by page title",
type: "success",
});
})
.catch(() => {
onActionCompleteHandler({
title: "Copy Failed",
message: "Sorry, page cannot be copied, please try again later.",
type: "error",
});
});
},
label: "Make a copy",
});
}
// If Lock Configuration is given then, lock page option will be available in the kanban menu
if (pageLockConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
type: pageLockConfig.is_locked ? "unlock_page" : "lock_page",
Icon: pageLockConfig.is_locked ? Unlock : Lock,
label: pageLockConfig.is_locked ? "Unlock page" : "Lock page",
action: () => {
const state = pageLockConfig.is_locked ? "Unlocked" : "Locked";
pageLockConfig
.action()
.then(() => {
onActionCompleteHandler({
title: `Page ${state}`,
message: `Page has been ${state}, no one will be able to change the state of lock except you.`,
type: "success",
});
})
.catch(() => {
onActionCompleteHandler({
title: `Page cannot be ${state}`,
message: `Sorry, page cannot be ${state}, please try again later`,
type: "error",
});
});
},
});
}
// Archiving will be visible in the menu bar config once the pageArchiveConfig is given.
if (pageArchiveConfig) {
KanbanMenuOptions.push({
key: KanbanMenuOptions.length++,
type: pageArchiveConfig.is_archived ? "unarchive_page" : "archive_page",
Icon: pageArchiveConfig.is_archived ? ArchiveRestoreIcon : Archive,
label: pageArchiveConfig.is_archived ? "Restore page" : "Archive page",
action: () => {
const state = pageArchiveConfig.is_archived ? "Unarchived" : "Archived";
pageArchiveConfig
.action()
.then(() => {
onActionCompleteHandler({
title: `Page ${state}`,
message: `Page has been ${state}, you can checkout all archived tab and can restore the page later.`,
type: "success",
});
})
.catch(() => {
onActionCompleteHandler({
title: `Page cannot be ${state}`,
message: `Sorry, page cannot be ${state}, please try again later.`,
type: "success",
});
});
},
});
}
return KanbanMenuOptions;
};

View File

@ -29,6 +29,7 @@
},
"dependencies": {
"@plane/editor-core": "*",
"@plane/ui": "*",
"@tiptap/core": "^2.1.13",
"@tiptap/pm": "^2.1.13",
"@tiptap/react": "^2.1.13",

View File

@ -1,9 +1,18 @@
import { Extension } from "@tiptap/core";
import { PluginKey, NodeSelection, Plugin } from "@tiptap/pm/state";
// @ts-ignore
import { NodeSelection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { Fragment, Slice, Node } from "@tiptap/pm/model";
// @ts-expect-error __serializeForClipboard's is not exported
import { __serializeForClipboard, EditorView } from "@tiptap/pm/view";
import React from "react";
export interface DragHandleOptions {
dragHandleWidth: number;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
scrollThreshold: {
up: number;
down: number;
};
}
function createDragHandleElement(): HTMLElement {
const dragHandleElement = document.createElement("div");
@ -29,13 +38,8 @@ function createDragHandleElement(): HTMLElement {
return dragHandleElement;
}
export interface DragHandleOptions {
dragHandleWidth: number;
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void;
}
function absoluteRect(node: Element) {
const data = node?.getBoundingClientRect();
const data = node.getBoundingClientRect();
return {
top: data.top,
@ -57,55 +61,77 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) {
"pre",
"blockquote",
"h1, h2, h3",
".table-wrapper",
"[data-type=horizontalRule]",
".tableWrapper",
].join(", ")
)
);
}
function nodePosAtDOM(node: Element, view: EditorView) {
const boundingRect = node?.getBoundingClientRect();
if (node.nodeName === "IMG") {
return view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.pos;
}
if (node.nodeName === "PRE") {
return (
view.posAtCoords({
left: boundingRect.left + 1,
top: boundingRect.top + 1,
})?.pos! - 1
);
}
function nodePosAtDOM(node: Element, view: EditorView, options: DragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 1,
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1,
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
function DragHandle(options: DragHandleOptions) {
let listType = "";
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords({
x: event.clientX + options.dragHandleWidth + 50,
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
if (nodePos === null || nodePos === undefined || nodePos < 0) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)));
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === "doc") differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
const multiNodeSelection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
view.dispatch(view.state.tr.setSelection(multiNodeSelection));
} else {
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === "listItem") {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = __serializeForClipboard(view, slice);
@ -123,8 +149,6 @@ function DragHandle(options: DragHandleOptions) {
function handleClick(event: MouseEvent, view: EditorView) {
view.focus();
view.dom.classList.remove("dragging");
const node = nodeDOMAtCoords({
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
@ -132,11 +156,18 @@ function DragHandle(options: DragHandleOptions) {
if (!(node instanceof Element)) return;
const nodePos = nodePosAtDOM(node, view);
let nodePos = nodePosAtDOM(node, view, options);
if (nodePos === null || nodePos === undefined || nodePos < 0) return;
if (nodePos === null || nodePos === undefined) return;
view.dispatch(view.state.tr.setSelection(NodeSelection.create(view.state.doc, nodePos)));
// Adjust the nodePos to point to the start of the node, ensuring NodeSelection can be applied
nodePos = calcNodePos(nodePos, view);
// Use NodeSelection to select the node at the calculated position
const nodeSelection = NodeSelection.create(view.state.doc, nodePos);
// Dispatch the transaction to update the selection
view.dispatch(view.state.tr.setSelection(nodeSelection));
}
let dragHandleElement: HTMLElement | null = null;
@ -166,11 +197,15 @@ function DragHandle(options: DragHandleOptions) {
handleClick(e, view);
});
dragHandleElement.addEventListener("dragstart", (e) => {
handleDragStart(e, view);
});
dragHandleElement.addEventListener("click", (e) => {
handleClick(e, view);
dragHandleElement.addEventListener("drag", (e) => {
hideDragHandle();
const a = document.querySelector(".frame-renderer");
if (!a) return;
if (e.clientY < options.scrollThreshold.up) {
a.scrollBy({ top: -70, behavior: "smooth" });
} else if (window.innerHeight - e.clientY < options.scrollThreshold.down) {
a.scrollBy({ top: 70, behavior: "smooth" });
}
});
hideDragHandle();
@ -192,11 +227,11 @@ function DragHandle(options: DragHandleOptions) {
}
const node = nodeDOMAtCoords({
x: event.clientX + options.dragHandleWidth,
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY,
});
if (!(node instanceof Element)) {
if (!(node instanceof Element) || node.matches("ul, ol")) {
hideDragHandle();
return;
}
@ -207,32 +242,74 @@ function DragHandle(options: DragHandleOptions) {
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += (lineHeight - 20) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches("ul:not([data-type=taskList]) li, ol li")) {
rect.left -= options.dragHandleWidth;
rect.top += 4;
rect.left -= 18;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top + 3}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
wheel: () => {
mousewheel: () => {
hideDragHandle();
},
// dragging className is used for CSS
dragstart: (view) => {
dragenter: (view) => {
view.dom.classList.add("dragging");
hideDragHandle();
},
drop: (view) => {
drop: (view, event) => {
view.dom.classList.remove("dragging");
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
let isDroppedInsideList = false;
// Traverse up the document tree to find if we're inside a list item
for (let i = resolvedPos.depth; i > 0; i--) {
if (resolvedPos.node(i).type.name === "listItem") {
isDroppedInsideList = true;
break;
}
}
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === "listItem" &&
!isDroppedInsideList &&
listType == "OL"
) {
const text = droppedNode.textContent;
if (!text) return;
const paragraph = view.state.schema.nodes.paragraph?.createAndFill({}, view.state.schema.text(text));
const listItem = view.state.schema.nodes.listItem?.createAndFill({}, paragraph);
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, listItem);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove("dragging");
@ -250,6 +327,7 @@ export const DragAndDrop = (setHideDragHandle?: (hideDragHandlerFromDragDrop: ()
return [
DragHandle({
dragHandleWidth: 24,
scrollThreshold: { up: 300, down: 100 },
setHideDragHandle,
}),
];

View File

@ -0,0 +1,2 @@
export * from "./drag-drop";
export * from "./slash-commands";

View File

@ -54,7 +54,20 @@ const Command = Extension.create<SlashCommandOptions>({
props.command({ editor, range });
},
allow({ editor }: { editor: Editor }) {
return !editor.isActive("table");
const { selection } = editor.state;
const parentNode = selection.$from.node(selection.$from.depth);
const blockType = parentNode.type.name;
if (blockType === "codeBlock") {
return false;
}
if (editor.isActive("table")) {
return false;
}
return true;
},
allowSpaces: true,
},
@ -71,11 +84,7 @@ const Command = Extension.create<SlashCommandOptions>({
});
const getSuggestionItems =
(
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
additionalOptions?: Array<ISlashCommandItem>
) =>
(uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
({ query }: { query: string }) => {
let slashCommands: ISlashCommandItem[] = [
{
@ -186,7 +195,7 @@ const getSuggestionItems =
searchTerms: ["img", "photo", "picture", "media"],
icon: <ImageIcon className="h-3.5 w-3.5" />,
command: ({ editor, range }: CommandProps) => {
insertImageCommand(editor, uploadFile, setIsSubmitting, range);
insertImageCommand(editor, uploadFile, null, range);
},
},
{
@ -300,9 +309,9 @@ const CommandList = ({ items, command }: { items: CommandItemProps[]; command: a
<button
key={item.key}
className={cn(
`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-custom-text-100 hover:bg-custom-primary-100/5`,
`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-custom-text-100 hover:bg-custom-background-80`,
{
"bg-custom-primary-100/5": index === selectedIndex,
"bg-custom-background-80": index === selectedIndex,
}
)}
onClick={() => selectItem(index)}
@ -315,19 +324,21 @@ const CommandList = ({ items, command }: { items: CommandItemProps[]; command: a
) : null;
};
const renderItems = () => {
let component: ReactRenderer | null = null;
let popup: any | null = null;
interface CommandListInstance {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
const renderItems = () => {
let component: ReactRenderer<CommandListInstance, typeof CommandList> | null = null;
let popup: any | null = null;
return {
onStart: (props: { editor: Editor; clientRect?: (() => DOMRect | null) | null }) => {
component = new ReactRenderer(CommandList, {
props,
// @ts-ignore
editor: props.editor,
});
// @ts-ignore
// @ts-expect-error Tippy overloads are messed up
popup = tippy("body", {
getReferenceClientRect: props.clientRect,
appendTo: () => document.querySelector(".active-editor") ?? document.querySelector("#editor-container"),
@ -353,8 +364,10 @@ const renderItems = () => {
return true;
}
// @ts-ignore
return component?.ref?.onKeyDown(props);
if (component?.ref?.onKeyDown(props)) {
return true;
}
return false;
},
onExit: () => {
popup?.[0].destroy();
@ -363,14 +376,10 @@ const renderItems = () => {
};
};
export const SlashCommand = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
additionalOptions?: Array<ISlashCommandItem>
) =>
export const SlashCommand = (uploadFile: UploadImage, additionalOptions?: Array<ISlashCommandItem>) =>
Command.configure({
suggestion: {
items: getSuggestionItems(uploadFile, setIsSubmitting, additionalOptions),
items: getSuggestionItems(uploadFile, additionalOptions),
render: renderItems,
},
});

View File

@ -1,4 +1,3 @@
import "src/styles/drag-drop.css";
export { SlashCommand } from "src/extensions/slash-commands";
export { DragAndDrop } from "src/extensions/drag-drop";
export { DragAndDrop, SlashCommand } from "src/extensions";

View File

@ -1,25 +1,30 @@
/* drag handle */
.drag-handle {
position: fixed;
opacity: 1;
transition: opacity ease-in 0.2s;
height: 18px;
height: 20px;
width: 15px;
display: grid;
place-items: center;
z-index: 10;
z-index: 5;
cursor: grab;
border-radius: 2px;
background-color: rgb(var(--color-background-90));
}
.drag-handle:hover {
background-color: rgb(var(--color-background-80));
transition: background-color 0.2s;
}
.drag-handle.hidden {
opacity: 0;
pointer-events: none;
&:hover {
background-color: rgba(var(--color-background-80));
}
&:active {
background-color: rgba(var(--color-background-80));
cursor: grabbing;
}
&.hidden {
opacity: 0;
pointer-events: none;
}
}
@media screen and (max-width: 600px) {
@ -32,7 +37,6 @@
.drag-handle-container {
height: 15px;
width: 15px;
cursor: grab;
display: grid;
place-items: center;
}
@ -46,8 +50,46 @@
}
.drag-handle-dot {
height: 2.75px;
width: 3px;
background-color: rgba(var(--color-text-200));
height: 2.5px;
width: 2.5px;
background-color: rgba(var(--color-text-300));
border-radius: 50%;
}
/* end drag handle */
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
position: relative;
cursor: grab;
outline: none !important;
box-shadow: none;
}
.ProseMirror:not(.dragging) .ProseMirror-selectednode::after {
content: "";
position: absolute;
top: 0;
left: -5px;
height: 100%;
width: 100%;
background-color: rgba(var(--color-primary-100), 0.2);
border-radius: 4px;
}
.ProseMirror img {
transition: filter 0.1s ease-in-out;
cursor: pointer;
&:hover {
filter: brightness(90%);
}
&.ProseMirror-selectednode {
filter: brightness(90%);
}
}
:not(.dragging) .ProseMirror-selectednode.table-wrapper {
padding: 4px 2px;
background-color: rgba(var(--color-primary-300), 0.1) !important;
box-shadow: rgba(var(--color-primary-100)) 0px 0px 0px 2px inset !important;
}

View File

@ -1,3 +1,7 @@
export { LiteTextEditor, LiteTextEditorWithRef } from "src/ui";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef } from "src/ui/read-only";
export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "src/ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core";
export type { ILiteTextEditor } from "src/ui";
export type { ILiteTextReadOnlyEditor } from "src/ui/read-only";
export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core";

View File

@ -8,124 +8,79 @@ import {
EditorContentWrapper,
getEditorClassNames,
useEditor,
IMentionHighlight,
EditorRefApi,
} from "@plane/editor-core";
import { FixedMenu } from "src/ui/menus/fixed-menu";
import { LiteTextEditorExtensions } from "src/ui/extensions";
interface ILiteTextEditor {
value: string;
uploadFile: UploadImage;
deleteFile: DeleteImage;
restoreFile: RestoreImage;
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: any;
key: string;
label: "Private" | "Public";
}[];
export interface ILiteTextEditor {
initialValue: string;
value?: string | null;
fileHandler: {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
containerClassName?: string;
editorClassName?: string;
onChange?: (json: object, html: string) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
onEnterKeyPress?: (e?: any) => void;
cancelUploadImage?: () => any;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
submitButton?: React.ReactNode;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions?: () => Promise<IMentionSuggestion[]>;
};
tabIndex?: number;
}
interface LiteTextEditorProps extends ILiteTextEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
const LiteTextEditor = (props: LiteTextEditorProps) => {
const LiteTextEditor = (props: ILiteTextEditor) => {
const {
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
initialValue,
fileHandler,
value,
uploadFile,
deleteFile,
restoreFile,
noBorder,
borderOnFocus,
customClassName,
containerClassName,
editorClassName = "",
forwardedRef,
commentAccessSpecifier,
onEnterKeyPress,
mentionHighlights,
mentionSuggestions,
submitButton,
tabIndex,
mentionHandler,
} = props;
const editor = useEditor({
onChange,
cancelUploadImage,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
initialValue,
value,
uploadFile,
deleteFile,
restoreFile,
editorClassName,
restoreFile: fileHandler.restore,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
forwardedRef,
extensions: LiteTextEditorExtensions(onEnterKeyPress),
mentionHighlights,
mentionSuggestions,
mentionHandler,
});
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
const editorContainerClassName = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
<div className="flex flex-col">
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<div className="mt-4 w-full">
<FixedMenu
editor={editor}
uploadFile={uploadFile}
setIsSubmitting={setIsSubmitting}
commentAccessSpecifier={commentAccessSpecifier}
submitButton={submitButton}
/>
</div>
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
</div>
</EditorContainer>
);
};
const LiteTextEditorWithRef = React.forwardRef<EditorHandle, ILiteTextEditor>((props, ref) => (
<LiteTextEditor {...props} forwardedRef={ref} />
const LiteTextEditorWithRef = React.forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
<LiteTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
LiteTextEditorWithRef.displayName = "LiteTextEditorWithRef";

View File

@ -1,10 +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 font-light leading-5 ${className}`}>{iconName}</span>
);

View File

@ -1,199 +0,0 @@
import { Editor } from "@tiptap/react";
import {
BoldItem,
BulletListItem,
cn,
CodeItem,
findTableAncestor,
ImageItem,
isCellSelection,
ItalicItem,
LucideIconType,
NumberedListItem,
QuoteItem,
StrikeThroughItem,
TableItem,
UnderLineItem,
UploadImage,
} from "@plane/editor-core";
import { Tooltip } from "@plane/ui";
export interface BubbleMenuItem {
name: string;
isActive: () => boolean;
command: () => void;
icon: LucideIconType;
}
type EditorBubbleMenuProps = {
editor: Editor;
commentAccessSpecifier?: {
accessValue: string;
onAccessChange: (accessKey: string) => void;
showAccessSpecifier: boolean;
commentAccess:
| {
icon: any;
key: string;
label: "Private" | "Public";
}[]
| undefined;
};
uploadFile: UploadImage;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
submitButton: React.ReactNode;
};
export const FixedMenu = (props: EditorBubbleMenuProps) => {
const basicTextFormattingItems: BubbleMenuItem[] = [
BoldItem(props.editor),
ItalicItem(props.editor),
UnderLineItem(props.editor),
StrikeThroughItem(props.editor),
];
const listFormattingItems: BubbleMenuItem[] = [BulletListItem(props.editor), NumberedListItem(props.editor)];
const userActionItems: BubbleMenuItem[] = [QuoteItem(props.editor), CodeItem(props.editor)];
function getComplexItems(): BubbleMenuItem[] {
const items: BubbleMenuItem[] = [TableItem(props.editor)];
items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting));
return items;
}
const complexItems: BubbleMenuItem[] = getComplexItems();
const handleAccessChange = (accessKey: string) => {
props.commentAccessSpecifier?.onAccessChange(accessKey);
};
return (
<div className="flex h-9 w-full items-stretch gap-1.5 overflow-x-scroll">
{props.commentAccessSpecifier && (
<div className="flex flex-shrink-0 items-stretch gap-0.5 rounded border-[0.5px] border-custom-border-200 p-1">
{props?.commentAccessSpecifier.commentAccess?.map((access) => (
<Tooltip key={access.key} tooltipContent={access.label}>
<button
type="button"
onClick={() => handleAccessChange(access.key)}
className={`grid aspect-square place-items-center rounded-sm p-1 hover:bg-custom-background-90 ${
props.commentAccessSpecifier?.accessValue === access.key ? "bg-custom-background-90" : ""
}`}
>
<access.icon
className={`h-3.5 w-3.5 ${
props.commentAccessSpecifier?.accessValue === access.key
? "text-custom-text-100"
: "text-custom-text-400"
}`}
strokeWidth={2}
/>
</button>
</Tooltip>
))}
</div>
)}
<div className="flex w-full items-stretch justify-between gap-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 p-1">
<div className="flex items-stretch">
<div className="flex items-stretch gap-0.5 border-r border-custom-border-200 pr-2.5">
{basicTextFormattingItems.map((item) => (
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
<button
type="button"
onClick={item.command}
className={cn(
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
<div className="flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5">
{listFormattingItems.map((item) => (
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
<button
type="button"
onClick={item.command}
className={cn(
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
<div className="flex items-stretch gap-0.5 border-r border-custom-border-200 px-2.5">
{userActionItems.map((item) => (
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
<button
type="button"
onClick={item.command}
className={cn(
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
<div className="flex items-stretch gap-0.5 pl-2.5">
{complexItems.map((item) => (
<Tooltip key={item.name} tooltipContent={<span className="capitalize">{item.name}</span>}>
<button
type="button"
onClick={item.command}
className={cn(
"grid aspect-square place-items-center rounded-sm p-1 text-custom-text-400 hover:bg-custom-background-80",
{
"bg-custom-background-80 text-custom-text-100": item.isActive(),
}
)}
>
<item.icon
className={cn("h-3.5 w-3.5", {
"text-custom-text-100": item.isActive(),
})}
strokeWidth={2.5}
/>
</button>
</Tooltip>
))}
</div>
</div>
<div className="sticky right-1">{props.submitButton}</div>
</div>
</div>
);
};

View File

@ -1,66 +1,59 @@
import * as React from "react";
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import {
EditorContainer,
EditorContentWrapper,
EditorReadOnlyRefApi,
getEditorClassNames,
IMentionHighlight,
useReadOnlyEditor,
} from "@plane/editor-core";
interface ICoreReadOnlyEditor {
value: string;
editorContentCustomClassNames?: string;
noBorder?: boolean;
export interface ILiteTextReadOnlyEditor {
initialValue: string;
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights: string[];
containerClassName?: string;
editorClassName?: string;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
tabIndex?: number;
}
interface EditorCoreProps extends ICoreReadOnlyEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
const LiteReadOnlyEditor = ({
editorContentCustomClassNames,
noBorder,
borderOnFocus,
customClassName,
value,
const LiteTextReadOnlyEditor = ({
containerClassName,
editorClassName = "",
initialValue,
forwardedRef,
mentionHighlights,
mentionHandler,
tabIndex,
}: EditorCoreProps) => {
}: ILiteTextReadOnlyEditor) => {
const editor = useReadOnlyEditor({
value,
initialValue,
editorClassName,
forwardedRef,
mentionHighlights,
mentionHandler,
});
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
<div className="flex flex-col">
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
</div>
</EditorContainer>
);
};
const LiteReadOnlyEditorWithRef = React.forwardRef<EditorHandle, ICoreReadOnlyEditor>((props, ref) => (
<LiteReadOnlyEditor {...props} forwardedRef={ref} />
const LiteTextReadOnlyEditorWithRef = React.forwardRef<EditorReadOnlyRefApi, ILiteTextReadOnlyEditor>((props, ref) => (
<LiteTextReadOnlyEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef";
export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef };
export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef };

View File

@ -1,4 +1,8 @@
export { RichTextEditor, RichTextEditorWithRef } from "src/ui";
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef } from "src/ui/read-only";
export type { RichTextEditorProps, IRichTextEditor } from "src/ui";
export type { IMentionHighlight, IMentionSuggestion } from "@plane/editor-core";
export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "src/ui/read-only";
export type { IRichTextEditor } from "src/ui";
export type { IRichTextReadOnlyEditor } from "src/ui/read-only";
export type { IMentionSuggestion, IMentionHighlight } from "@plane/editor-core";
export type { EditorRefApi, EditorReadOnlyRefApi } from "@plane/editor-core";

View File

@ -4,23 +4,21 @@ import Placeholder from "@tiptap/extension-placeholder";
export const RichTextEditorExtensions = (
uploadFile: UploadImage,
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void,
dragDropEnabled?: boolean,
setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void
) => [
SlashCommand(uploadFile, setIsSubmitting),
SlashCommand(uploadFile),
dragDropEnabled === true && DragAndDrop(setHideDragHandle),
Placeholder.configure({
placeholder: ({ node }) => {
placeholder: ({ editor, node }) => {
if (node.type.name === "heading") {
return `Heading ${node.attrs.level}`;
}
if (node.type.name === "image" || node.type.name === "table") {
if (editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image")) {
return "";
}
if (node.type.name === "codeBlock") {
return "Type in your code here...";
}
return "Press '/' for commands...";
},
includeChildren: true,

View File

@ -4,73 +4,56 @@ import {
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
IMentionHighlight,
IMentionSuggestion,
RestoreImage,
UploadImage,
useEditor,
EditorRefApi,
} from "@plane/editor-core";
import * as React from "react";
import { RichTextEditorExtensions } from "src/ui/extensions";
import { EditorBubbleMenu } from "src/ui/menus/bubble-menu";
export type IRichTextEditor = {
value: string;
initialValue?: string;
initialValue: string;
value?: string | null;
dragDropEnabled?: boolean;
uploadFile: UploadImage;
restoreFile: RestoreImage;
deleteFile: DeleteImage;
noBorder?: boolean;
borderOnFocus?: boolean;
cancelUploadImage?: () => any;
rerenderOnPropsChange?: {
id: string;
description_html: string;
fileHandler: {
cancel: () => void;
delete: DeleteImage;
upload: UploadImage;
restore: RestoreImage;
};
customClassName?: string;
editorContentCustomClassNames?: string;
onChange?: (json: any, html: string) => void;
setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void;
setShouldShowAlert?: (showAlert: boolean) => void;
forwardedRef?: any;
id?: string;
containerClassName?: string;
editorClassName?: string;
onChange?: (json: object, html: string) => void;
forwardedRef?: React.MutableRefObject<EditorRefApi | null>;
debouncedUpdatesEnabled?: boolean;
mentionHighlights?: string[];
mentionSuggestions?: IMentionSuggestion[];
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
suggestions: () => Promise<IMentionSuggestion[]>;
};
tabIndex?: number;
};
export interface RichTextEditorProps extends IRichTextEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
const RichTextEditor = (props: IRichTextEditor) => {
const {
onChange,
dragDropEnabled,
initialValue,
value,
fileHandler,
containerClassName,
editorClassName = "",
forwardedRef,
// rerenderOnPropsChange,
id = "",
tabIndex,
mentionHandler,
} = props;
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
setEditorValueAtCursorPosition: (content: string) => void;
}
const RichTextEditor = ({
onChange,
dragDropEnabled,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
editorContentCustomClassNames,
value,
initialValue,
uploadFile,
deleteFile,
noBorder,
cancelUploadImage,
borderOnFocus,
customClassName,
restoreFile,
forwardedRef,
mentionHighlights,
rerenderOnPropsChange,
mentionSuggestions,
tabIndex,
}: RichTextEditorProps) => {
const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {});
// this essentially sets the hideDragHandle function from the DragAndDrop extension as the Plugin
@ -80,50 +63,45 @@ const RichTextEditor = ({
};
const editor = useEditor({
id,
editorClassName,
restoreFile: fileHandler.restore,
uploadFile: fileHandler.upload,
deleteFile: fileHandler.delete,
cancelUploadImage: fileHandler.cancel,
onChange,
debouncedUpdatesEnabled,
setIsSubmitting,
setShouldShowAlert,
initialValue,
value,
uploadFile,
cancelUploadImage,
deleteFile,
restoreFile,
forwardedRef,
rerenderOnPropsChange,
extensions: RichTextEditorExtensions(uploadFile, setIsSubmitting, dragDropEnabled, setHideDragHandleFunction),
mentionHighlights,
mentionSuggestions,
// rerenderOnPropsChange,
extensions: RichTextEditorExtensions(fileHandler.upload, dragDropEnabled, setHideDragHandleFunction),
mentionHandler,
});
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
const editorContainerClassName = getEditorClassNames({
noBorder: true,
borderOnFocus: false,
containerClassName,
});
// React.useEffect(() => {
// if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue);
// }, [editor, initialValue]);
//
if (!editor) return null;
return (
<EditorContainer hideDragHandle={hideDragHandleOnMouseLeave} editor={editor} editorClassNames={editorClassNames}>
<EditorContainer
hideDragHandle={hideDragHandleOnMouseLeave}
editor={editor}
editorContainerClassName={editorContainerClassName}
>
{editor && <EditorBubbleMenu editor={editor} />}
<div className="flex flex-col">
<EditorContentWrapper
tabIndex={tabIndex}
editor={editor}
editorContentCustomClassNames={editorContentCustomClassNames}
/>
<EditorContentWrapper tabIndex={tabIndex} editor={editor} />
</div>
</EditorContainer>
);
};
const RichTextEditorWithRef = React.forwardRef<EditorHandle, IRichTextEditor>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref} />
const RichTextEditorWithRef = React.forwardRef<EditorRefApi, IRichTextEditor>((props, ref) => (
<RichTextEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorRefApi | null>} />
));
RichTextEditorWithRef.displayName = "RichTextEditorWithRef";

View File

@ -1,62 +1,54 @@
"use client";
import { EditorContainer, EditorContentWrapper, getEditorClassNames, useReadOnlyEditor } from "@plane/editor-core";
import {
EditorReadOnlyRefApi,
EditorContainer,
EditorContentWrapper,
getEditorClassNames,
IMentionHighlight,
useReadOnlyEditor,
} from "@plane/editor-core";
import * as React from "react";
interface IRichTextReadOnlyEditor {
value: string;
editorContentCustomClassNames?: string;
noBorder?: boolean;
borderOnFocus?: boolean;
customClassName?: string;
mentionHighlights?: string[];
export interface IRichTextReadOnlyEditor {
initialValue: string;
containerClassName?: string;
editorClassName?: string;
tabIndex?: number;
forwardedRef?: React.MutableRefObject<EditorReadOnlyRefApi | null>;
mentionHandler: {
highlights: () => Promise<IMentionHighlight[]>;
};
}
interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor {
forwardedRef?: React.Ref<EditorHandle>;
}
const RichTextReadOnlyEditor = (props: IRichTextReadOnlyEditor) => {
const { containerClassName, editorClassName = "", initialValue, forwardedRef, mentionHandler } = props;
interface EditorHandle {
clearEditor: () => void;
setEditorValue: (content: string) => void;
}
const RichReadOnlyEditor = ({
editorContentCustomClassNames,
noBorder,
borderOnFocus,
customClassName,
value,
forwardedRef,
mentionHighlights,
}: RichTextReadOnlyEditorProps) => {
const editor = useReadOnlyEditor({
value,
initialValue,
editorClassName,
forwardedRef,
mentionHighlights,
mentionHandler,
});
const editorClassNames = getEditorClassNames({
noBorder,
borderOnFocus,
customClassName,
const editorContainerClassName = getEditorClassNames({
containerClassName,
});
if (!editor) return null;
return (
<EditorContainer editor={editor} editorClassNames={editorClassNames}>
<EditorContainer editor={editor} editorContainerClassName={editorContainerClassName}>
<div className="flex flex-col">
<EditorContentWrapper editor={editor} editorContentCustomClassNames={editorContentCustomClassNames} />
<EditorContentWrapper editor={editor} />
</div>
</EditorContainer>
);
};
const RichReadOnlyEditorWithRef = React.forwardRef<EditorHandle, IRichTextReadOnlyEditor>((props, ref) => (
<RichReadOnlyEditor {...props} forwardedRef={ref} />
const RichTextReadOnlyEditorWithRef = React.forwardRef<EditorReadOnlyRefApi, IRichTextReadOnlyEditor>((props, ref) => (
<RichTextReadOnlyEditor {...props} forwardedRef={ref as React.MutableRefObject<EditorReadOnlyRefApi | null>} />
));
RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef";
export { RichReadOnlyEditor, RichReadOnlyEditorWithRef };
export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef };

View File

@ -97,10 +97,6 @@ export type SelectCycleType =
| (ICycle & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type SelectIssue =
| (TIssue & { actionType: "edit" | "delete" | "create" })
| null;
export type CycleDateCheckData = {
start_date: string;
end_date: string;

View File

@ -1,17 +1,9 @@
import { EDurationFilters } from "./enums";
import { IIssueActivity, TIssuePriorities } from "./issues";
import { TIssue } from "./issues/issue";
import { TIssueRelationTypes } from "./issues/issue_relation";
import { TStateGroups } from "./state";
enum EDurationFilters {
NONE = "none",
TODAY = "today",
THIS_WEEK = "this_week",
THIS_MONTH = "this_month",
THIS_YEAR = "this_year",
CUSTOM = "custom",
}
export type TWidgetKeys =
| "overview_stats"
| "assigned_issues"

View File

@ -4,3 +4,23 @@ export enum EUserProjectRoles {
MEMBER = 15,
ADMIN = 20,
}
// project pages
export enum EPageAccess {
PUBLIC = 0,
PRIVATE = 1,
}
export enum EDurationFilters {
NONE = "none",
TODAY = "today",
THIS_WEEK = "this_week",
THIS_MONTH = "this_month",
THIS_YEAR = "this_year",
CUSTOM = "custom",
}
export enum EIssueCommentAccessSpecifier {
EXTERNAL = "EXTERNAL",
INTERNAL = "INTERNAL",
}

View File

@ -4,6 +4,7 @@ import {
TIssueActivityIssueDetail,
TIssueActivityUserDetail,
} from "./base";
import { EIssueCommentAccessSpecifier } from "../../enums";
export type TIssueComment = {
id: string;
@ -20,14 +21,13 @@ export type TIssueComment = {
created_by: string | undefined;
updated_by: string | undefined;
attachments: any[];
comment_reactions: any[];
comment_stripped: string;
comment_html: string;
comment_json: object;
external_id: string | undefined;
external_source: string | undefined;
access: "EXTERNAL" | "INTERNAL";
access: EIssueCommentAccessSpecifier;
};
export type TIssueCommentMap = {

View File

@ -1,4 +1,11 @@
import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types";
import type {
TIssue,
IIssueFilterOptions,
ILinkDetails,
TAssigneesDistribution,
TCompletionChartDistribution,
TLabelsDistribution,
} from "@plane/types";
export type TModuleStatus =
| "backlog"
@ -69,7 +76,3 @@ export type ModuleLink = {
export type SelectModuleType =
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
| undefined;
export type SelectIssue =
| (TIssue & { actionType: "edit" | "delete" | "create" })
| undefined;

View File

@ -36,7 +36,7 @@ export interface IUserNotification {
}
export interface Data {
issue: IIssueLite;
issue: INotificationIssueLite;
issue_activity: {
actor: string;
field: string;
@ -48,7 +48,7 @@ export interface Data {
};
}
export interface IIssueLite {
export interface INotificationIssueLite {
id: string;
name: string;
identifier: string;

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