diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 2f3c94450..cd0fc11ce 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -93,6 +93,7 @@ from .page import ( PageSerializer, PageLogSerializer, SubPageSerializer, + PageDetailSerializer, PageFavoriteSerializer, ) diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index 4dfe6ea9d..604ac2c2e 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -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() diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 58cec2cd4..1a73e4ed3 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -31,102 +31,51 @@ urlpatterns = [ ), name="project-pages", ), + # favorite pages path( - "workspaces//projects//user-favorite-pages/", + "workspaces//projects//favorite-pages//", PageFavoriteViewSet.as_view( { - "get": "list", "post": "create", - } - ), - name="user-favorite-pages", - ), - path( - "workspaces//projects//user-favorite-pages//", - PageFavoriteViewSet.as_view( - { "delete": "destroy", } ), name="user-favorite-pages", ), + # archived pages path( - "workspaces//projects//pages/", - PageViewSet.as_view( - { - "get": "list", - "post": "create", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//", - PageViewSet.as_view( - { - "get": "retrieve", - "patch": "partial_update", - "delete": "destroy", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//archive/", + "workspaces//projects//pages//archive/", PageViewSet.as_view( { "post": "archive", + "delete": "unarchive", } ), - name="project-page-archive", + name="project-page-archive-unarchive", ), + # lock and unlock path( - "workspaces//projects//pages//unarchive/", - PageViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-page-unarchive", - ), - path( - "workspaces//projects//archived-pages/", - PageViewSet.as_view( - { - "get": "archive_list", - } - ), - name="project-pages", - ), - path( - "workspaces//projects//pages//lock/", + "workspaces//projects//pages//lock/", PageViewSet.as_view( { "post": "lock", + "delete": "unlock", } ), - name="project-pages", + name="project-pages-lock-unlock", ), path( - "workspaces//projects//pages//unlock/", - PageViewSet.as_view( - { - "post": "unlock", - } - ), - ), - path( - "workspaces//projects//pages//transactions/", + "workspaces//projects//pages//transactions/", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//transactions//", + "workspaces//projects//pages//transactions//", PageLogEndpoint.as_view(), name="page-transactions", ), path( - "workspaces//projects//pages//sub-pages/", + "workspaces//projects//pages//sub-pages/", SubPagesEndpoint.as_view(), name="sub-page", ), diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index d60d78500..1bbfdf33f 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -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", "

" + ), + }, ) 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) diff --git a/apiserver/plane/app/views/state/base.py b/apiserver/plane/app/views/state/base.py index 06f118c31..b488d9efb 100644 --- a/apiserver/plane/app/views/state/base.py +++ b/apiserver/plane/app/views/state/base.py @@ -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, diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 45e7bd29c..35772ccf3 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -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) diff --git a/apiserver/plane/bgtasks/page_transaction_task.py b/apiserver/plane/bgtasks/page_transaction_task.py new file mode 100644 index 000000000..57f4f644e --- /dev/null +++ b/apiserver/plane/bgtasks/page_transaction_task.py @@ -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() diff --git a/apiserver/plane/db/migrations/0064_auto_20240409_1134.py b/apiserver/plane/db/migrations/0064_auto_20240409_1134.py new file mode 100644 index 000000000..53e5938af --- /dev/null +++ b/apiserver/plane/db/migrations/0064_auto_20240409_1134.py @@ -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 + ), + ), + ] diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 6ed94798a..da7e050bb 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -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): diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index a0d7351d3..2657d1c7a 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -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", diff --git a/packages/editor/core/src/helpers/scroll-to-node.ts b/packages/editor/core/src/helpers/scroll-to-node.ts new file mode 100644 index 000000000..65d32a7d2 --- /dev/null +++ b/packages/editor/core/src/helpers/scroll-to-node.ts @@ -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); + } +} diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 7e6aa5912..493071a36 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -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; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + 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 : "

", - 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([])), + mentionHighlights: mentionHandler.highlights ?? [], + }, + deleteFile, + restoreFile, + cancelUploadImage + ), + ...extensions, + ], + content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", + 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 = useRef(null); - editorRef.current = editor; const [savedSelection, setSavedSelection] = useState(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; }; diff --git a/packages/editor/core/src/hooks/use-read-only-editor.tsx b/packages/editor/core/src/hooks/use-read-only-editor.tsx index ecd49255c..9607586d8 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/hooks/use-read-only-editor.tsx @@ -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; extensions?: any; editorProps?: EditorProps; - rerenderOnPropsChange?: { - id: string; - description_html: string; + handleEditorReady?: (value: boolean) => void; + mentionHandler: { + highlights: () => Promise; }; - 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 : "

", - editorProps: { - ...CoreReadOnlyEditorProps, - ...editorProps, - }, - extensions: [ - ...CoreReadOnlyEditorExtensions({ - mentionSuggestions: mentionSuggestions ?? [], - mentionHighlights: mentionHighlights ?? [], - }), - ...extensions, - ], + const editor = useCustomEditor({ + editable: false, + content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "

", + 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 = 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; }; diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index c7e39d240..336daed43 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -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"; diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 7c3e7f11e..e163b17dd 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -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(); diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index c943d4c60..84ad7046e 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -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[]) { diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index dbbea671e..63bc5a6d6 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -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 */ diff --git a/packages/editor/core/src/styles/table.css b/packages/editor/core/src/styles/table.css index 3ba17ee1b..433b77bfe 100644 --- a/packages/editor/core/src/styles/table.css +++ b/packages/editor/core/src/styles/table.css @@ -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; } diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts new file mode 100644 index 000000000..df5df2c7b --- /dev/null +++ b/packages/editor/core/src/types/editor-ref-api.ts @@ -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; +} diff --git a/packages/editor/core/src/types/mention-suggestion.ts b/packages/editor/core/src/types/mention-suggestion.ts index dcaa3148d..aa2ad4ba2 100644 --- a/packages/editor/core/src/types/mention-suggestion.ts +++ b/packages/editor/core/src/types/mention-suggestion.ts @@ -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; diff --git a/packages/editor/core/src/ui/components/editor-container.tsx b/packages/editor/core/src/ui/components/editor-container.tsx index 2d6081525..e7d272b9b 100644 --- a/packages/editor/core/src/ui/components/editor-container.tsx +++ b/packages/editor/core/src/ui/components/editor-container.tsx @@ -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 = (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 = (props) => {
{ - 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}
diff --git a/packages/editor/core/src/ui/components/editor-content.tsx b/packages/editor/core/src/ui/components/editor-content.tsx index 7a6ce30f7..a2427265c 100644 --- a/packages/editor/core/src/ui/components/editor-content.tsx +++ b/packages/editor/core/src/ui/components/editor-content.tsx @@ -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 = (props) => { - const { editor, editorContentCustomClassNames = "", tabIndex, children } = props; + const { editor, tabIndex, children } = props; return ( -
{ - editor?.chain().focus(undefined, { scrollIntoView: false }).run(); - }} - > +
editor?.chain().focus(undefined, { scrollIntoView: false }).run()}> {editor?.isActive("image") && editor?.isEditable && } {children} diff --git a/packages/editor/core/src/ui/extensions/code-inline/index.tsx b/packages/editor/core/src/ui/extensions/code-inline/index.tsx index 1c5d34109..bc629160a 100644 --- a/packages/editor/core/src/ui/extensions/code-inline/index.tsx +++ b/packages/editor/core/src/ui/extensions/code-inline/index.tsx @@ -32,7 +32,8 @@ export const CustomCodeInlineExtension = Mark.create({ 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", }, }; diff --git a/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx b/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx new file mode 100644 index 000000000..f75da438e --- /dev/null +++ b/packages/editor/core/src/ui/extensions/code/code-block-node-view.tsx @@ -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 = ({ node }) => { + const [copied, setCopied] = useState(false); + + const copyToClipboard = async (e: React.MouseEvent) => { + try { + await navigator.clipboard.writeText(node.textContent); + setCopied(true); + setTimeout(() => setCopied(false), 1000); + } catch (error) { + setCopied(false); + } + e.preventDefault(); + e.stopPropagation(); + }; + + return ( + + + +
+        
+      
+
+ ); +}; diff --git a/packages/editor/core/src/ui/extensions/code/index.tsx b/packages/editor/core/src/ui/extensions/code/index.tsx index 64a1740cb..d2851d302 100644 --- a/packages/editor/core/src/ui/extensions/code/index.tsx +++ b/packages/editor/core/src/ui/extensions/code/index.tsx @@ -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 }) => { diff --git a/packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts b/packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts new file mode 100644 index 000000000..3b3cfaab1 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-code-inline/inline-code-plugin.ts @@ -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 }); + }, +}); diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts new file mode 100644 index 000000000..330ebbc12 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers.ts @@ -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; +}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts deleted file mode 100644 index 3bbfd9c93..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/find-list-item-pos.ts +++ /dev/null @@ -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 }; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts deleted file mode 100644 index 2e4f5fbaa..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/get-next-list-depth.ts +++ /dev/null @@ -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; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts deleted file mode 100644 index a4f2d5db9..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-backspace.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts deleted file mode 100644 index 9179e0f20..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/handle-delete.ts +++ /dev/null @@ -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(); -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts deleted file mode 100644 index fb6b95b6a..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-before.ts +++ /dev/null @@ -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; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts deleted file mode 100644 index 4e538ac47..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-after.ts +++ /dev/null @@ -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; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts deleted file mode 100644 index 91fda9bf4..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/has-list-item-before.ts +++ /dev/null @@ -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; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts deleted file mode 100644 index 644953b92..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/index.ts +++ /dev/null @@ -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"; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts deleted file mode 100644 index 7cd1a63f7..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-deeper.ts +++ /dev/null @@ -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; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts deleted file mode 100644 index 3364c3b87..000000000 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-helpers/next-list-is-higher.ts +++ /dev/null @@ -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; -}; diff --git a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts index aabd836d2..dba6b1f46 100644 --- a/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts +++ b/packages/editor/core/src/ui/extensions/custom-list-keymap/list-keymap.ts @@ -29,6 +29,22 @@ export const ListKeymap = Extension.create({ 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; diff --git a/packages/editor/core/src/ui/extensions/image/image-resize.tsx b/packages/editor/core/src/ui/extensions/image/image-resize.tsx index 400938785..7f61cc9cb 100644 --- a/packages/editor/core/src/ui/extensions/image/image-resize.tsx +++ b/packages/editor/core/src/ui/extensions/image/image-resize.tsx @@ -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 ( <> { 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; }} /> diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 1a932d6d5..1a68ee46e 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -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; + mentionHighlights?: () => Promise; }, 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, + }), ]; diff --git a/packages/editor/core/src/ui/extensions/keymap.tsx b/packages/editor/core/src/ui/extensions/keymap.tsx index 0caa194cd..2e0bdd1fe 100644 --- a/packages/editor/core/src/ui/extensions/keymap.tsx +++ b/packages/editor/core/src/ui/extensions/keymap.tsx @@ -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 = []; + 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 }) => { diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/core/src/ui/extensions/table/table/icons.ts index f73c55c09..51c778411 100644 --- a/packages/editor/core/src/ui/extensions/table/table/icons.ts +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -1,10 +1,10 @@ export const icons = { colorPicker: ``, - deleteColumn: ``, - deleteRow: ``, + deleteColumn: ``, + deleteRow: ``, insertLeftTableIcon: ` `, - toggleColumnHeader: ``, - toggleRowHeader: ``, + toggleColumnHeader: ``, + toggleRowHeader: ``, insertBottomTableIcon: ` { 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) diff --git a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx index 75b6bcb12..d4dfcf5c7 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table-view.tsx +++ b/packages/editor/core/src/ui/extensions/table/table/table-view.tsx @@ -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 ?? `A`, 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 diff --git a/packages/editor/core/src/ui/mentions/custom.tsx b/packages/editor/core/src/ui/mentions/custom.tsx index e723ca0d7..8bab79666 100644 --- a/packages/editor/core/src/ui/mentions/custom.tsx +++ b/packages/editor/core/src/ui/mentions/custom.tsx @@ -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; readonly?: boolean; } @@ -32,6 +32,12 @@ export const CustomMention = Mention.extend({ redirect_uri: { default: "/", }, + entity_identifier: { + default: null, + }, + entity_name: { + default: null, + }, }; }, @@ -43,17 +49,6 @@ export const CustomMention = Mention.extend({ 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"), - }; - }, }, ]; }, diff --git a/packages/editor/core/src/ui/mentions/index.tsx b/packages/editor/core/src/ui/mentions/index.tsx index f6d3e5b1f..838622cbb 100644 --- a/packages/editor/core/src/ui/mentions/index.tsx +++ b/packages/editor/core/src/ui/mentions/index.tsx @@ -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; + mentionHighlights?: () => Promise; + 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(); + }, + }; + }, + }, }); diff --git a/packages/editor/core/src/ui/mentions/mention-list.tsx b/packages/editor/core/src/ui/mentions/mention-list.tsx index afbf10970..cd0bf8354 100644 --- a/packages/editor/core/src/ui/mentions/mention-list.tsx +++ b/packages/editor/core/src/ui/mentions/mention-list.tsx @@ -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; } -// eslint-disable-next-line react/display-name export const MentionList = forwardRef((props: MentionListProps, ref) => { + const { query, mentionSuggestions } = props; + const [items, setItems] = useState([]); 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(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 ? ( -
- {props.items.length ? ( - props.items.map((item, index) => ( + return ( +
+ {isLoading ? ( +
Loading...
+ ) : items.length ? ( + items.map((item, index) => (
selectItem(index)} > -
- {item.avatar && item.avatar.trim() !== "" ? ( - {item.title} - ) : ( -
- {item.title[0]} -
- )} -
-
-

{item.title}

- {/*

{item.subtitle}

*/} -
+ + {item.title}
)) ) : ( -
No result
+
No results
)}
- ) : ( - <> ); }); diff --git a/packages/editor/core/src/ui/mentions/mention-node-view.tsx b/packages/editor/core/src/ui/mentions/mention-node-view.tsx index 1c3755f6c..0a1f1b5e0 100644 --- a/packages/editor/core/src/ui/mentions/mention-node-view.tsx +++ b/packages/editor/core/src/ui/mentions/mention-node-view.tsx @@ -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(); + + 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) => { @{props.node.attrs.label} diff --git a/packages/editor/core/src/ui/mentions/suggestion.ts b/packages/editor/core/src/ui/mentions/suggestion.ts index 3f1b8eeec..8eaf0982f 100644 --- a/packages/editor/core/src/ui/mentions/suggestion.ts +++ b/packages/editor/core/src/ui/mentions/suggestion.ts @@ -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); + }; diff --git a/packages/editor/core/src/ui/menus/menu-items/index.tsx b/packages/editor/core/src/ui/menus/menu-items/index.tsx index f60febc59..66736e0ea 100644 --- a/packages/editor/core/src/ui/menus/menu-items/index.tsx +++ b/packages/editor/core/src/ui/menus/menu-items/index.tsx @@ -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 extends (infer U)[] + ? U extends { name: infer N } + ? N + : never + : never; diff --git a/packages/editor/core/src/ui/plugins/delete-image.tsx b/packages/editor/core/src/ui/plugins/delete-image.tsx index afe13730a..03b4dbd10 100644 --- a/packages/editor/core/src/ui/plugins/delete-image.tsx +++ b/packages/editor/core/src/ui/plugins/delete-image.tsx @@ -57,10 +57,7 @@ export const TrackImageDeletionPlugin = (deleteImage: DeleteImage): Plugin => export async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise { 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 { 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); } diff --git a/packages/editor/core/src/ui/plugins/upload-image.tsx b/packages/editor/core/src/ui/plugins/upload-image.tsx index 738653d71..3f6a40bf9 100644 --- a/packages/editor/core/src/ui/plugins/upload-image.tsx +++ b/packages/editor/core/src/ui/plugins/upload-image.tsx @@ -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); diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 1846efe47..aa88fa042 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -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; } diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index 93e1b3887..cf12880f8 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -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; }) => [ 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, + }), ]; diff --git a/packages/editor/core/src/ui/read-only/props.tsx b/packages/editor/core/src/ui/read-only/props.tsx index 79f9fcb0d..bd9b6713b 100644 --- a/packages/editor/core/src/ui/read-only/props.tsx +++ b/packages/editor/core/src/ui/read-only/props.tsx @@ -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 + ), }, -}; +}); diff --git a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx b/packages/editor/document-editor/src/hooks/use-editor-markings.tsx index 1eb72eaab..88f125a26 100644 --- a/packages/editor/document-editor/src/hooks/use-editor-markings.tsx +++ b/packages/editor/document-editor/src/hooks/use-editor-markings.tsx @@ -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([]); - 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, diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index c074009f4..f8eea14ce 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -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"; diff --git a/packages/editor/document-editor/src/types/editor-types.ts b/packages/editor/document-editor/src/types/editor-types.ts index 5a28daf9e..476642103 100644 --- a/packages/editor/document-editor/src/types/editor-types.ts +++ b/packages/editor/document-editor/src/types/editor-types.ts @@ -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; diff --git a/packages/editor/document-editor/src/ui/components/alert-label.tsx b/packages/editor/document-editor/src/ui/components/alert-label.tsx deleted file mode 100644 index 69b6dd02d..000000000 --- a/packages/editor/document-editor/src/ui/components/alert-label.tsx +++ /dev/null @@ -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 ( -
- {Icon && } - {label} -
- ); -}; diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx deleted file mode 100644 index 926d9a53d..000000000 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ /dev/null @@ -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 ( -
-

Outline

-
- {markings.length !== 0 ? ( - markings.map((marking) => - marking.level === 1 ? ( - handleOnClick(marking)} heading={marking.text} /> - ) : marking.level === 2 ? ( - handleOnClick(marking)} subHeading={marking.text} /> - ) : ( - handleOnClick(marking)} /> - ) - ) - ) : ( -

Headings will be displayed here for navigation

- )} -
-
- ); -}; diff --git a/packages/editor/document-editor/src/ui/components/editor-header.tsx b/packages/editor/document-editor/src/ui/components/editor-header.tsx deleted file mode 100644 index 33ac4a0dc..000000000 --- a/packages/editor/document-editor/src/ui/components/editor-header.tsx +++ /dev/null @@ -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 ( -
-
- -
- -
- {!readonly && uploadFile && ( - - )} -
- -
- {isLocked && ( - - )} - {isArchived && archivedAt && ( - - )} - - {!isLocked && !isArchived ? ( -
- {isSubmitting !== "submitted" && isSubmitting !== "saved" && ( - - )} - - {isSubmitting === "submitting" ? "Saving..." : "Saved"} - -
- ) : null} - {!isArchived && } - -
-
- ); -}; diff --git a/packages/editor/document-editor/src/ui/components/heading-component.tsx b/packages/editor/document-editor/src/ui/components/heading-component.tsx deleted file mode 100644 index ce3489418..000000000 --- a/packages/editor/document-editor/src/ui/components/heading-component.tsx +++ /dev/null @@ -1,47 +0,0 @@ -export const HeadingComp = ({ - heading, - onClick, -}: { - heading: string; - onClick: (event: React.MouseEvent) => void; -}) => ( -

- {heading} -

-); - -export const SubheadingComp = ({ - subHeading, - onClick, -}: { - subHeading: string; - onClick: (event: React.MouseEvent) => void; -}) => ( -

- {subHeading} -

-); - -export const HeadingThreeComp = ({ - heading, - onClick, -}: { - heading: string; - onClick: (event: React.MouseEvent) => void; -}) => ( -

- {heading} -

-); diff --git a/packages/editor/document-editor/src/ui/components/index.ts b/packages/editor/document-editor/src/ui/components/index.ts index 1496a3cf4..4d2d76baa 100644 --- a/packages/editor/document-editor/src/ui/components/index.ts +++ b/packages/editor/document-editor/src/ui/components/index.ts @@ -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"; diff --git a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx index 971915439..0cee059df 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx @@ -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(); }; diff --git a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx index ff3fd0263..0bb719d93 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-preview.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-preview.tsx @@ -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(); }; diff --git a/packages/editor/document-editor/src/ui/components/links/link-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-view.tsx index f1d22a68e..9befc514c 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-view.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-view.tsx @@ -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 }) => { diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index 7c2717e80..a6b5eb5e8 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -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(); 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(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 ( -
- {!readonly ? ( - 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} - /> - ) : ( - 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 - /> - )} -
- - + <> +
+ + + {editor && editor.isEditable && }
{isOpen && linkViewProps && coordinates && (
)} -
+ ); }; diff --git a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx b/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx deleted file mode 100644 index 44ede3e8d..000000000 --- a/packages/editor/document-editor/src/ui/components/summary-side-bar.tsx +++ /dev/null @@ -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) => ( -
- -
-); diff --git a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx b/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx deleted file mode 100644 index 43843e507..000000000 --- a/packages/editor/document-editor/src/ui/components/vertical-dropdown-menu.tsx +++ /dev/null @@ -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; -} - -export interface IVerticalDropdownMenuProps { - items: IVerticalDropdownItemProps[]; -} - -const VerticalDropdownItem = ({ Icon, label, action }: IVerticalDropdownItemProps) => ( - - -
{label}
-
-); - -export const VerticalDropdownMenu = ({ items }: IVerticalDropdownMenuProps) => ( - } - > - {items.map((item) => ( - - ))} - -); diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index cedc3ed80..6c106176b 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -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 ""; } diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index eb54a204b..24e712d67 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -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; + mentionHandler: { + highlights: () => Promise; + suggestions: () => Promise; }; - - // 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; -} -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 ( -
- 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} - /> -
- {uploadFile && } -
-
-
- -
-
- -
-
-
-
+ ); }; -const DocumentEditorWithRef = React.forwardRef((props, ref) => ( - +const DocumentEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); DocumentEditorWithRef.displayName = "DocumentEditorWithRef"; diff --git a/packages/editor/document-editor/src/ui/menu/block-menu.tsx b/packages/editor/document-editor/src/ui/menu/block-menu.tsx new file mode 100644 index 000000000..9703113f7 --- /dev/null +++ b/packages/editor/document-editor/src/ui/menu/block-menu.tsx @@ -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(null); + const popup = useRef(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 ( +
+ {MENU_ITEMS.map((item) => { + // Skip rendering the button if it should be disabled + if (item.isDisabled && item.key === "duplicate") { + return null; + } + + return ( + + ); + })} +
+ ); +} diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx deleted file mode 100644 index 397e8c576..000000000 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ /dev/null @@ -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 ( -
-
- {basicMarkItems.map((item) => ( - - ))} -
-
- {listItems.map((item) => ( - - ))} -
-
- {userActionItems.map((item) => ( - - ))} -
-
- {complexItems.map((item) => ( - - ))} -
-
- ); -}; diff --git a/packages/editor/document-editor/src/ui/menu/index.tsx b/packages/editor/document-editor/src/ui/menu/index.tsx deleted file mode 100644 index 1c411fabf..000000000 --- a/packages/editor/document-editor/src/ui/menu/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { FixedMenu } from "./fixed-menu"; diff --git a/packages/editor/document-editor/src/ui/readonly/index.tsx b/packages/editor/document-editor/src/ui/readonly/index.tsx index 22099281e..0e75c2db4 100644 --- a/packages/editor/document-editor/src/ui/readonly/index.tsx +++ b/packages/editor/document-editor/src/ui/readonly/index.tsx @@ -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; + }; + forwardedRef?: React.MutableRefObject; } -interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { - forwardedRef?: React.Ref; -} - -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 ( -
- -
-
- -
-
- Promise.resolve()} - readonly - editor={editor} - editorClassNames={editorClassNames} - documentDetails={documentDetails} - /> -
-
-
-
- ); + return ; }; -const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( - +const DocumentReadOnlyEditorWithRef = forwardRef((props, ref) => ( + } /> )); DocumentReadOnlyEditorWithRef.displayName = "DocumentReadOnlyEditorWithRef"; diff --git a/packages/editor/document-editor/src/utils/menu-actions.ts b/packages/editor/document-editor/src/utils/menu-actions.ts deleted file mode 100644 index 24eda5a05..000000000 --- a/packages/editor/document-editor/src/utils/menu-actions.ts +++ /dev/null @@ -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()); - } -}; diff --git a/packages/editor/document-editor/src/utils/menu-options.ts b/packages/editor/document-editor/src/utils/menu-options.ts deleted file mode 100644 index befed424d..000000000 --- a/packages/editor/document-editor/src/utils/menu-options.ts +++ /dev/null @@ -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; -}; diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 711ab8a96..a57e4f78b 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -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", diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index ce4088413..1c2427418 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -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
    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, }), ]; diff --git a/packages/editor/extensions/src/extensions/index.ts b/packages/editor/extensions/src/extensions/index.ts new file mode 100644 index 000000000..9ceeb6493 --- /dev/null +++ b/packages/editor/extensions/src/extensions/index.ts @@ -0,0 +1,2 @@ +export * from "./drag-drop"; +export * from "./slash-commands"; diff --git a/packages/editor/extensions/src/extensions/slash-commands.tsx b/packages/editor/extensions/src/extensions/slash-commands.tsx index c52178b81..049accb27 100644 --- a/packages/editor/extensions/src/extensions/slash-commands.tsx +++ b/packages/editor/extensions/src/extensions/slash-commands.tsx @@ -54,7 +54,20 @@ const Command = Extension.create({ 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({ }); const getSuggestionItems = - ( - uploadFile: UploadImage, - setIsSubmitting?: (isSubmitting: "submitting" | "submitted" | "saved") => void, - additionalOptions?: Array - ) => + (uploadFile: UploadImage, additionalOptions?: Array) => ({ query }: { query: string }) => { let slashCommands: ISlashCommandItem[] = [ { @@ -186,7 +195,7 @@ const getSuggestionItems = searchTerms: ["img", "photo", "picture", "media"], icon: , 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 - - ))} -
- )} -
-
-
- {basicTextFormattingItems.map((item) => ( - {item.name}}> - - - ))} -
-
- {listFormattingItems.map((item) => ( - {item.name}}> - - - ))} -
-
- {userActionItems.map((item) => ( - {item.name}}> - - - ))} -
-
- {complexItems.map((item) => ( - {item.name}}> - - - ))} -
-
-
{props.submitButton}
-
-
- ); -}; diff --git a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx index 9f81ba5d1..5ceb6956e 100644 --- a/packages/editor/lite-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/read-only/index.tsx @@ -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; + mentionHandler: { + highlights: () => Promise; + }; tabIndex?: number; } -interface EditorCoreProps extends ICoreReadOnlyEditor { - forwardedRef?: React.Ref; -} - -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 ( - +
- +
); }; -const LiteReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - +const LiteTextReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); -LiteReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef"; +LiteTextReadOnlyEditorWithRef.displayName = "LiteReadOnlyEditorWithRef"; -export { LiteReadOnlyEditor, LiteReadOnlyEditorWithRef }; +export { LiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef }; diff --git a/packages/editor/rich-text-editor/src/index.ts b/packages/editor/rich-text-editor/src/index.ts index eb745c45b..ad6f043a1 100644 --- a/packages/editor/rich-text-editor/src/index.ts +++ b/packages/editor/rich-text-editor/src/index.ts @@ -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"; diff --git a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx index 3d1da6cda..57d2e0d41 100644 --- a/packages/editor/rich-text-editor/src/ui/extensions/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/extensions/index.tsx @@ -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, diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 366fa471f..33c14327e 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -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; debouncedUpdatesEnabled?: boolean; - mentionHighlights?: string[]; - mentionSuggestions?: IMentionSuggestion[]; + mentionHandler: { + highlights: () => Promise; + suggestions: () => Promise; + }; tabIndex?: number; }; -export interface RichTextEditorProps extends IRichTextEditor { - forwardedRef?: React.Ref; -} +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 ( - + {editor && }
- +
); }; -const RichTextEditorWithRef = React.forwardRef((props, ref) => ( - +const RichTextEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); RichTextEditorWithRef.displayName = "RichTextEditorWithRef"; diff --git a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx index 9aa308731..3220c477e 100644 --- a/packages/editor/rich-text-editor/src/ui/read-only/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/read-only/index.tsx @@ -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; + mentionHandler: { + highlights: () => Promise; + }; } -interface RichTextReadOnlyEditorProps extends IRichTextReadOnlyEditor { - forwardedRef?: React.Ref; -} +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 ( - +
- +
); }; -const RichReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( - +const RichTextReadOnlyEditorWithRef = React.forwardRef((props, ref) => ( + } /> )); -RichReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; +RichTextReadOnlyEditorWithRef.displayName = "RichReadOnlyEditorWithRef"; -export { RichReadOnlyEditor, RichReadOnlyEditorWithRef }; +export { RichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef }; diff --git a/packages/types/src/cycle/cycle.d.ts b/packages/types/src/cycle/cycle.d.ts index 28be2ec2a..e93d6e444 100644 --- a/packages/types/src/cycle/cycle.d.ts +++ b/packages/types/src/cycle/cycle.d.ts @@ -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; diff --git a/packages/types/src/cycle/index.ts b/packages/types/src/cycle/index.d.ts similarity index 100% rename from packages/types/src/cycle/index.ts rename to packages/types/src/cycle/index.d.ts diff --git a/packages/types/src/dashboard.ts b/packages/types/src/dashboard.d.ts similarity index 96% rename from packages/types/src/dashboard.ts rename to packages/types/src/dashboard.d.ts index be7d7b3be..9abd1bf22 100644 --- a/packages/types/src/dashboard.ts +++ b/packages/types/src/dashboard.d.ts @@ -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" diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts index 259f13e9b..a4d098506 100644 --- a/packages/types/src/enums.ts +++ b/packages/types/src/enums.ts @@ -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", +} diff --git a/packages/types/src/issues/activity/issue_comment.d.ts b/packages/types/src/issues/activity/issue_comment.d.ts index 45d34be08..f361ea720 100644 --- a/packages/types/src/issues/activity/issue_comment.d.ts +++ b/packages/types/src/issues/activity/issue_comment.d.ts @@ -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 = { diff --git a/packages/types/src/module/index.ts b/packages/types/src/module/index.d.ts similarity index 100% rename from packages/types/src/module/index.ts rename to packages/types/src/module/index.d.ts diff --git a/packages/types/src/module/modules.d.ts b/packages/types/src/module/modules.d.ts index 95b084f2f..0019781ba 100644 --- a/packages/types/src/module/modules.d.ts +++ b/packages/types/src/module/modules.d.ts @@ -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; diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts index 652e2776f..571b75765 100644 --- a/packages/types/src/notifications.d.ts +++ b/packages/types/src/notifications.d.ts @@ -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; diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 6e1e78be5..a1df4527e 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,64 +1,50 @@ -// types -import { - TIssue, - IIssueLabel, - IWorkspaceLite, - IProjectLite, -} from "@plane/types"; +import { EPageAccess } from "./enums"; -export interface IPage { - access: number; - archived_at: string | null; - blocks: IPageBlock[]; - color: string; - created_at: string | null; - created_by: string; - description: string; - description_html: string; - description_stripped: string | null; - id: string; +export type TPage = { + access: EPageAccess | undefined; + archived_at: string | null | undefined; + color: string | undefined; + created_at: Date | undefined; + created_by: string | undefined; + description_html: string | undefined; + id: string | undefined; is_favorite: boolean; is_locked: boolean; - label_details: IIssueLabel[]; - labels: string[]; - name: string; - owned_by: string; - project: string; - project_detail: IProjectLite; - updated_at: string | null; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} + labels: string[] | undefined; + name: string | undefined; + owned_by: string | undefined; + project: string | undefined; + updated_at: Date | undefined; + updated_by: string | undefined; + view_props: TPageViewProps | undefined; + workspace: string | undefined; +}; -export interface IRecentPages { - today: string[]; - yesterday: string[]; - this_week: string[]; - older: string[]; - [key: string]: string[]; -} +export type TPageViewProps = { + full_width?: boolean; +}; -export interface IPageBlock { - completed_at: Date | null; - created_at: Date; - created_by: string; - description: any; - description_html: any; - description_stripped: any; - id: string; - issue: string | null; - issue_detail: TIssue | null; - name: string; - page: string; - project: string; - project_detail: IProjectLite; - sort_order: number; - sync: boolean; - updated_at: Date; - updated_by: string; - workspace: string; - workspace_detail: IWorkspaceLite; -} +// page filters +export type TPageNavigationTabs = "public" | "private" | "archived"; -export type TPageViewProps = "list" | "detailed" | "masonry"; +export type TPageFiltersSortKey = + | "name" + | "created_at" + | "updated_at" + | "opened_at"; + +export type TPageFiltersSortBy = "asc" | "desc"; + +export type TPageFilterProps = { + created_at?: string[] | null; + created_by?: string[] | null; + favorites?: boolean; + labels?: string[] | null; +}; + +export type TPageFilters = { + searchQuery: string; + sortKey: TPageFiltersSortKey; + sortBy: TPageFiltersSortBy; + filters?: TPageFilterProps; +}; diff --git a/packages/ui/src/form-fields/textarea.tsx b/packages/ui/src/form-fields/textarea.tsx index b93c1aba8..271b76d83 100644 --- a/packages/ui/src/form-fields/textarea.tsx +++ b/packages/ui/src/form-fields/textarea.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +// helpers +import { cn } from "../../helpers"; export interface TextAreaProps extends React.TextareaHTMLAttributes { mode?: "primary" | "transparent"; @@ -46,13 +48,17 @@ const TextArea = React.forwardRef((props, re value={value} rows={rows} cols={cols} - className={`no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none ${ - mode === "primary" - ? "rounded-md border-[0.5px] border-custom-border-200" - : mode === "transparent" - ? "focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1" - : "" - } ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-100" : ""} ${className}`} + className={cn( + "no-scrollbar w-full bg-transparent px-3 py-2 placeholder-custom-text-400 outline-none", + { + "rounded-md border-[0.5px] border-custom-border-200": mode === "primary", + "focus:ring-theme rounded border-none bg-transparent ring-0 transition-all focus:ring-1": + mode === "transparent", + "border-red-500": hasError, + "bg-red-100": hasError && mode === "primary", + }, + className + )} {...rest} /> ); diff --git a/space/components/editor/index.ts b/space/components/editor/index.ts new file mode 100644 index 000000000..4ec0141e2 --- /dev/null +++ b/space/components/editor/index.ts @@ -0,0 +1,4 @@ +export * from "./lite-text-editor"; +export * from "./lite-text-read-only-editor"; +export * from "./rich-text-read-only-editor"; +export * from "./toolbar"; diff --git a/space/components/editor/lite-text-editor.tsx b/space/components/editor/lite-text-editor.tsx new file mode 100644 index 000000000..677a34257 --- /dev/null +++ b/space/components/editor/lite-text-editor.tsx @@ -0,0 +1,76 @@ +import React from "react"; +// editor +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/lite-text-editor"; +// components +import { IssueCommentToolbar } from "@/components/editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { isEmptyHtmlString } from "@/helpers/string.helper"; +// hooks +import { useMention } from "@/hooks/use-mention"; +// services +import fileService from "@/services/file.service"; + +interface LiteTextEditorWrapperProps extends Omit { + workspaceSlug: string; + workspaceId: string; + isSubmitting?: boolean; + showSubmitButton?: boolean; +} + +export const LiteTextEditor = React.forwardRef((props, ref) => { + const { + containerClassName, + workspaceSlug, + workspaceId, + isSubmitting = false, + showSubmitButton = true, + ...rest + } = props; + // use-mention + const { mentionHighlights } = useMention(); + + function isMutableRefObject(ref: React.ForwardedRef): ref is React.MutableRefObject { + return !!ref && typeof ref === "object" && "current" in ref; + } + const isEmpty = + props.initialValue === "" || + props.initialValue?.trim() === "" || + props.initialValue === "

" || + isEmptyHtmlString(props.initialValue ?? ""); + + return ( +
+ + { + if (isMutableRefObject(ref)) { + ref.current?.executeMenuItemCommand(key); + } + }} + isSubmitting={isSubmitting} + showSubmitButton={showSubmitButton} + handleSubmit={(e) => rest.onEnterKeyPress?.(e)} + isCommentEmpty={isEmpty} + editorRef={isMutableRefObject(ref) ? ref : null} + /> +
+ ); +}); + +LiteTextEditor.displayName = "LiteTextEditor"; diff --git a/space/components/editor/lite-text-read-only-editor.tsx b/space/components/editor/lite-text-read-only-editor.tsx new file mode 100644 index 000000000..6b16a0b07 --- /dev/null +++ b/space/components/editor/lite-text-read-only-editor.tsx @@ -0,0 +1,29 @@ +import React from "react"; +// editor +import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/lite-text-editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMention } from "@/hooks/use-mention"; + +interface LiteTextReadOnlyEditorWrapperProps extends Omit {} + +export const LiteTextReadOnlyEditor = React.forwardRef( + ({ ...props }, ref) => { + const { mentionHighlights } = useMention(); + + return ( + + ); + } +); + +LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor"; diff --git a/space/components/editor/rich-text-read-only-editor.tsx b/space/components/editor/rich-text-read-only-editor.tsx new file mode 100644 index 000000000..562e63581 --- /dev/null +++ b/space/components/editor/rich-text-read-only-editor.tsx @@ -0,0 +1,27 @@ +import React from "react"; +// editor +import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/rich-text-editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMention } from "@/hooks/use-mention"; + +interface RichTextReadOnlyEditorWrapperProps extends Omit {} + +export const RichTextReadOnlyEditor = React.forwardRef( + ({ ...props }, ref) => { + const { mentionHighlights } = useMention(); + + return ( + + ); + } +); + +RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor"; diff --git a/space/components/editor/toolbar.tsx b/space/components/editor/toolbar.tsx new file mode 100644 index 000000000..19bbdab9a --- /dev/null +++ b/space/components/editor/toolbar.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState, useCallback } from "react"; +// editor +import { EditorMenuItemNames, EditorRefApi } from "@plane/lite-text-editor"; +// ui +import { Button, Tooltip } from "@plane/ui"; +// constants +import { TOOLBAR_ITEMS } from "@/constants/editor"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + executeCommand: (commandName: EditorMenuItemNames) => void; + handleSubmit: (event: React.MouseEvent) => void; + isCommentEmpty: boolean; + isSubmitting: boolean; + showSubmitButton: boolean; + editorRef: React.MutableRefObject | null; +}; + +const toolbarItems = TOOLBAR_ITEMS.lite; + +export const IssueCommentToolbar: React.FC = (props) => { + const { executeCommand, handleSubmit, isCommentEmpty, editorRef, isSubmitting, showSubmitButton } = props; + // states + const [activeStates, setActiveStates] = useState>({}); + + // Function to update active states + const updateActiveStates = useCallback(() => { + if (editorRef?.current) { + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // Assert that editorRef.current is not null + newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + }); + setActiveStates(newActiveStates); + } + }, [editorRef]); + + // useEffect to call updateActiveStates when isActive prop changes + useEffect(() => { + if (!editorRef?.current) return; + const unsubscribe = editorRef.current.onStateChange(updateActiveStates); + updateActiveStates(); + return () => unsubscribe(); + }, [editorRef, updateActiveStates]); + + return ( +
+
+
+ {Object.keys(toolbarItems).map((key, index) => ( +
+ {toolbarItems[key].map((item) => ( + + {item.name} + {item.shortcut && {item.shortcut.join(" + ")}} +

+ } + > + +
+ ))} +
+ ))} +
+ {showSubmitButton && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/space/components/issues/peek-overview/comment/add-comment.tsx b/space/components/issues/peek-overview/comment/add-comment.tsx index 6df72e2a8..8ec5b125a 100644 --- a/space/components/issues/peek-overview/comment/add-comment.tsx +++ b/space/components/issues/peek-overview/comment/add-comment.tsx @@ -2,19 +2,15 @@ import React, { useRef } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { useForm, Controller } from "react-hook-form"; -// lib -import { LiteTextEditorWithRef } from "@plane/lite-text-editor"; -import { Button } from "@plane/ui"; -import { useMobxStore } from "@/lib/mobx/store-provider"; -// hooks -import fileService from "@/services/file.service"; -import { RootStore } from "@/store/root"; -import useToast from "hooks/use-toast"; -// ui -// types -import { Comment } from "types/issue"; // components -// service +import { EditorRefApi } from "@plane/lite-text-editor"; +import { LiteTextEditor } from "@/components/editor/lite-text-editor"; +// hooks +import useToast from "@/hooks/use-toast"; +// lib +import { useMobxStore } from "@/lib/mobx/store-provider"; +// types +import { Comment } from "@/types/issue"; const defaultValues: Partial = { comment_html: "", @@ -24,9 +20,20 @@ type Props = { disabled?: boolean; }; -export const AddComment: React.FC = observer((props) => { - const { disabled = false } = props; - +export const AddComment: React.FC = observer(() => { + // const { disabled = false } = props; + // refs + const editorRef = useRef(null); + // router + const router = useRouter(); + const { workspace_slug, project_slug } = router.query; + // store hooks + const { project } = useMobxStore(); + const { user: userStore, issueDetails: issueDetailStore } = useMobxStore(); + // derived values + const workspaceId = project.workspace?.id; + const issueId = issueDetailStore.peekId; + // form info const { handleSubmit, control, @@ -34,37 +41,25 @@ export const AddComment: React.FC = observer((props) => { formState: { isSubmitting }, reset, } = useForm({ defaultValues }); - - const router = useRouter(); - const { project }: RootStore = useMobxStore(); - const workspaceId = project.workspace?.id; - - const { workspace_slug, project_slug } = router.query as { workspace_slug: string; project_slug: string }; - - const { user: userStore, issueDetails: issueDetailStore } = useMobxStore(); - - const issueId = issueDetailStore.peekId; - - const editorRef = useRef(null); - + // toast alert const { setToastAlert } = useToast(); const onSubmit = async (formData: Comment) => { if (!workspace_slug || !project_slug || !issueId || isSubmitting || !formData.comment_html) return; await issueDetailStore - .addIssueComment(workspace_slug, project_slug, issueId, formData) + .addIssueComment(workspace_slug.toString(), project_slug.toString(), issueId, formData) .then(() => { reset(defaultValues); editorRef.current?.clearEditor(); }) - .catch(() => { + .catch(() => setToastAlert({ type: "error", title: "Error!", message: "Comment could not be posted. Please try again.", - }); - }); + }) + ); }; return ( @@ -74,43 +69,18 @@ export const AddComment: React.FC = observer((props) => { name="comment_html" control={control} render={({ field: { value, onChange } }) => ( - { - userStore.requiredLogin(() => { - handleSubmit(onSubmit)(e); - }); - }} - cancelUploadImage={fileService.cancelUpload} - uploadFile={fileService.getUploadFileFunction(workspace_slug as string)} - deleteFile={fileService.getDeleteImageFunction(workspaceId as string)} - restoreFile={fileService.getRestoreImageFunction(workspaceId as string)} + userStore.requiredLogin(() => handleSubmit(onSubmit)(e))} + workspaceId={workspaceId as string} + workspaceSlug={workspace_slug as string} ref={editorRef} - value={ + initialValue={ !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) ? watch("comment_html") : value } - customClassName="p-2" - editorContentCustomClassNames="min-h-[35px]" - debouncedUpdatesEnabled={false} - onChange={(comment_json: unknown, comment_html: string) => { - onChange(comment_html); - }} - submitButton={ - - } + onChange={(comment_json, comment_html) => onChange(comment_html)} + isSubmitting={isSubmitting} /> )} /> diff --git a/space/components/issues/peek-overview/comment/comment-detail-card.tsx b/space/components/issues/peek-overview/comment/comment-detail-card.tsx index 3db64bbd1..e1a139413 100644 --- a/space/components/issues/peek-overview/comment/comment-detail-card.tsx +++ b/space/components/issues/peek-overview/comment/comment-detail-card.tsx @@ -1,23 +1,21 @@ -import React, { useState } from "react"; +import React, { useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; import { Check, MessageSquare, MoreVertical, X } from "lucide-react"; import { Menu, Transition } from "@headlessui/react"; -// mobx store // components -import { LiteReadOnlyEditorWithRef, LiteTextEditorWithRef } from "@plane/lite-text-editor"; - +import { EditorRefApi } from "@plane/lite-text-editor"; +import { LiteTextEditor, LiteTextReadOnlyEditor } from "@/components/editor"; import { CommentReactions } from "@/components/issues/peek-overview"; // helpers import { timeAgo } from "@/helpers/date-time.helper"; +// mobx store import { useMobxStore } from "@/lib/mobx/store-provider"; -// types -// services -import fileService from "@/services/file.service"; +// store import { RootStore } from "@/store/root"; -import useEditorSuggestions from "hooks/use-editor-suggestions"; +// types +import { Comment } from "@/types/issue"; -import { Comment } from "types/issue"; type Props = { workspaceSlug: string; comment: Comment; @@ -32,12 +30,10 @@ export const CommentCard: React.FC = observer((props) => { const { user: userStore, issueDetails: issueDetailStore } = useMobxStore(); // states const [isEditing, setIsEditing] = useState(false); - - const mentionsConfig = useEditorSuggestions(); - - const editorRef = React.useRef(null); - - const showEditorRef = React.useRef(null); + // refs + const editorRef = useRef(null); + const showEditorRef = useRef(null); + // form info const { control, formState: { isSubmitting }, @@ -105,19 +101,16 @@ export const CommentCard: React.FC = observer((props) => { control={control} name="comment_html" render={({ field: { onChange, value } }) => ( - { - onChange(comment_html); - }} + initialValue={value} + value={null} + onChange={(comment_json, comment_html) => onChange(comment_html)} + isSubmitting={isSubmitting} + showSubmitButton={false} /> )} /> @@ -140,12 +133,7 @@ export const CommentCard: React.FC = observer((props) => {
- +
diff --git a/space/components/issues/peek-overview/full-screen-peek-view.tsx b/space/components/issues/peek-overview/full-screen-peek-view.tsx index 32b850b12..5ab4dffd7 100644 --- a/space/components/issues/peek-overview/full-screen-peek-view.tsx +++ b/space/components/issues/peek-overview/full-screen-peek-view.tsx @@ -8,11 +8,12 @@ import { PeekOverviewIssueProperties, } from "@/components/issues/peek-overview"; // types -import { IIssue } from "types/issue"; +import { IIssue } from "@/types/issue"; type Props = { handleClose: () => void; issueDetails: IIssue | undefined; + workspace_slug: string; }; export const FullScreenPeekView: React.FC = observer((props) => { diff --git a/space/components/issues/peek-overview/issue-details.tsx b/space/components/issues/peek-overview/issue-details.tsx index dfc1be430..5fe73f67a 100644 --- a/space/components/issues/peek-overview/issue-details.tsx +++ b/space/components/issues/peek-overview/issue-details.tsx @@ -1,37 +1,30 @@ -import { RichReadOnlyEditor } from "@plane/rich-text-editor"; +// components +import { RichTextReadOnlyEditor } from "@/components/editor"; import { IssueReactions } from "@/components/issues/peek-overview"; // types -import useEditorSuggestions from "hooks/use-editor-suggestions"; -import { IIssue } from "types/issue"; +import { IIssue } from "@/types/issue"; type Props = { issueDetails: IIssue; }; -export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => { - const mentionConfig = useEditorSuggestions(); - - return ( -
-
- {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} -
-

{issueDetails.name}

- {issueDetails.description_html !== "" && issueDetails.description_html !== "

" && ( -

" - : issueDetails.description_html - } - customClassName="p-3 min-h-[50px] shadow-sm" - mentionHighlights={mentionConfig.mentionHighlights} - /> - )} - -
- ); -}; +export const PeekOverviewIssueDetails: React.FC = ({ issueDetails }) => ( +
+
+ {issueDetails.project_detail.identifier}-{issueDetails.sequence_id} +
+

{issueDetails.name}

+ {issueDetails.description_html !== "" && issueDetails.description_html !== "

" && ( +

" + : issueDetails.description_html + } + /> + )} + +
+); diff --git a/space/components/issues/peek-overview/layout.tsx b/space/components/issues/peek-overview/layout.tsx index b76da13f4..6662288ce 100644 --- a/space/components/issues/peek-overview/layout.tsx +++ b/space/components/issues/peek-overview/layout.tsx @@ -117,7 +117,11 @@ export const IssuePeekOverview: React.FC = observer(() => { )} {issueDetailStore.peekMode === "full" && ( - + )}
diff --git a/space/constants/editor.ts b/space/constants/editor.ts new file mode 100644 index 000000000..bdd07f0c5 --- /dev/null +++ b/space/constants/editor.ts @@ -0,0 +1,98 @@ +import { + Bold, + Code2, + Heading1, + Heading2, + Heading3, + Image, + Italic, + List, + ListOrdered, + ListTodo, + LucideIcon, + Quote, + Strikethrough, + Table, + Underline, +} from "lucide-react"; +// editor +import { EditorMenuItemNames } from "@plane/lite-text-editor"; + +type TEditorTypes = "lite" | "document"; + +export type ToolbarMenuItem = { + key: EditorMenuItemNames; + name: string; + icon: LucideIcon; + shortcut?: string[]; + editors: TEditorTypes[]; +}; + +export const BASIC_MARK_ITEMS: ToolbarMenuItem[] = [ + { key: "H1", name: "Heading 1", icon: Heading1, editors: ["document"] }, + { key: "H2", name: "Heading 2", icon: Heading2, editors: ["document"] }, + { key: "H3", name: "Heading 3", icon: Heading3, editors: ["document"] }, + { key: "bold", name: "Bold", icon: Bold, shortcut: ["Cmd", "B"], editors: ["lite", "document"] }, + { key: "italic", name: "Italic", icon: Italic, shortcut: ["Cmd", "I"], editors: ["lite", "document"] }, + { key: "underline", name: "Underline", icon: Underline, shortcut: ["Cmd", "U"], editors: ["lite", "document"] }, + { + key: "strike", + name: "Strikethrough", + icon: Strikethrough, + shortcut: ["Cmd", "Shift", "S"], + editors: ["lite", "document"], + }, +]; + +export const LIST_ITEMS: ToolbarMenuItem[] = [ + { + key: "bullet-list", + name: "Bulleted list", + icon: List, + shortcut: ["Cmd", "Shift", "7"], + editors: ["lite", "document"], + }, + { + key: "ordered-list", + name: "Numbered list", + icon: ListOrdered, + shortcut: ["Cmd", "Shift", "8"], + editors: ["lite", "document"], + }, + { + key: "To-do List", + name: "To-do list", + icon: ListTodo, + shortcut: ["Cmd", "Shift", "9"], + editors: ["lite", "document"], + }, +]; + +export const USER_ACTION_ITEMS: ToolbarMenuItem[] = [ + { key: "quote", name: "Quote", icon: Quote, editors: ["lite", "document"] }, + { key: "code", name: "Code", icon: Code2, editors: ["lite", "document"] }, +]; + +export const COMPLEX_ITEMS: ToolbarMenuItem[] = [ + { key: "table", name: "Table", icon: Table, editors: ["document"] }, + { key: "image", name: "Image", icon: Image, editors: ["lite", "document"] }, +]; + +export const TOOLBAR_ITEMS: { + [editorType in TEditorTypes]: { + [key: string]: ToolbarMenuItem[]; + }; +} = { + lite: { + basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("lite")), + list: LIST_ITEMS.filter((item) => item.editors.includes("lite")), + userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("lite")), + complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("lite")), + }, + document: { + basic: BASIC_MARK_ITEMS.filter((item) => item.editors.includes("document")), + list: LIST_ITEMS.filter((item) => item.editors.includes("document")), + userAction: USER_ACTION_ITEMS.filter((item) => item.editors.includes("document")), + complex: COMPLEX_ITEMS.filter((item) => item.editors.includes("document")), + }, +}; diff --git a/space/helpers/string.helper.ts b/space/helpers/string.helper.ts index 4a265ba4e..b44974d25 100644 --- a/space/helpers/string.helper.ts +++ b/space/helpers/string.helper.ts @@ -1,3 +1,5 @@ +import DOMPurify from "dompurify"; + export const addSpaceIfCamelCase = (str: string) => str.replace(/([a-z])([A-Z])/g, "$1 $2"); const fallbackCopyTextToClipboard = (text: string) => { @@ -47,3 +49,10 @@ export const checkEmailValidity = (email: string): boolean => { return isEmailValid; }; + +export const isEmptyHtmlString = (htmlString: string) => { + // Remove HTML tags using regex + const cleanText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); + // Trim the string and check if it's empty + return cleanText.trim() === ""; +}; diff --git a/space/hooks/use-mention.tsx b/space/hooks/use-mention.tsx new file mode 100644 index 000000000..9f540cf58 --- /dev/null +++ b/space/hooks/use-mention.tsx @@ -0,0 +1,43 @@ +import { useRef, useEffect } from "react"; +import { UserService } from "services/user.service"; +import useSWR from "swr"; +import { IUser } from "@plane/types"; + +export const useMention = () => { + const userService = new UserService(); + const { data: user, isLoading: userDataLoading } = useSWR("currentUser", async () => userService.currentUser()); + + const userRef = useRef(); + + useEffect(() => { + if (userRef) { + // @ts-expect-error mismatch in types + userRef.current = user; + } + }, [user]); + + const waitForUserDate = async () => + new Promise((resolve) => { + const checkData = () => { + if (userRef.current) { + resolve(userRef.current); + } else { + setTimeout(checkData, 100); + } + }; + checkData(); + }); + + const mentionHighlights = async () => { + if (!userDataLoading && userRef.current) { + return [userRef.current.id]; + } else { + const user = await waitForUserDate(); + return [user.id]; + } + }; + + return { + mentionHighlights, + }; +}; diff --git a/space/package.json b/space/package.json index 6e736f9b0..ca811a645 100644 --- a/space/package.json +++ b/space/package.json @@ -25,6 +25,7 @@ "@sentry/nextjs": "^7.108.0", "axios": "^1.3.4", "clsx": "^2.0.0", + "dompurify": "^3.0.11", "dotenv": "^16.3.1", "js-cookie": "^3.0.1", "lowlight": "^2.9.0", @@ -43,6 +44,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "@types/dompurify": "^3.0.5", "@types/js-cookie": "^3.0.3", "@types/node": "18.14.1", "@types/nprogress": "^0.2.0", diff --git a/space/services/project-member.service.ts b/space/services/project-member.service.ts new file mode 100644 index 000000000..c1c2c2732 --- /dev/null +++ b/space/services/project-member.service.ts @@ -0,0 +1,27 @@ +// types +import type { IProjectMember, IProjectMembership } from "@plane/types"; +import { API_BASE_URL } from "@/helpers/common.helper"; +// services +import APIService from "@/services/api.service"; + +export class ProjectMemberService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async fetchProjectMembers(workspaceSlug: string, projectId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async getProjectMember(workspaceSlug: string, projectId: string, memberId: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/members/${memberId}/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/styles/globals.css b/space/styles/globals.css index 92980b0d7..2220225fb 100644 --- a/space/styles/globals.css +++ b/space/styles/globals.css @@ -33,8 +33,8 @@ --color-primary-900: 13, 24, 51; --color-background-100: 255, 255, 255; /* primary bg */ - --color-background-90: 250, 250, 250; /* secondary bg */ - --color-background-80: 245, 245, 245; /* tertiary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ --color-text-100: 23, 23, 23; /* primary text */ --color-text-200: 58, 58, 58; /* secondary text */ @@ -97,8 +97,8 @@ color-scheme: light !important; --color-background-100: 255, 255, 255; /* primary bg */ - --color-background-90: 250, 250, 250; /* secondary bg */ - --color-background-80: 245, 245, 245; /* tertiary bg */ + --color-background-90: 247, 247, 247; /* secondary bg */ + --color-background-80: 232, 232, 232; /* tertiary bg */ /* onboarding colors */ --gradient-onboarding-100: linear-gradient(106deg, #f2f6ff 29.8%, #e1eaff 99.34%); @@ -151,9 +151,9 @@ [data-theme="dark-contrast"] { color-scheme: dark !important; - --color-background-100: 7, 7, 7; /* primary bg */ - --color-background-90: 11, 11, 11; /* secondary bg */ - --color-background-80: 23, 23, 23; /* tertiary bg */ + --color-background-100: 25, 25, 25; /* primary bg */ + --color-background-90: 32, 32, 32; /* secondary bg */ + --color-background-80: 44, 44, 44; /* tertiary bg */ --color-shadow-2xs: 0px 0px 1px 0px rgba(0, 0, 0, 0.15), 0px 1px 3px 0px rgba(0, 0, 0, 0.5); --color-shadow-xs: 0px 0px 2px 0px rgba(0, 0, 0, 0.2), 0px 2px 4px 0px rgba(0, 0, 0, 0.5); diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 490d392e2..00de628b7 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -12,7 +12,7 @@ import { BulkDeleteIssuesModal } from "@/components/core"; import { CycleCreateUpdateModal } from "@/components/cycles"; import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues"; import { CreateUpdateModuleModal } from "@/components/modules"; -import { CreateUpdatePageModal } from "@/components/pages"; +import { CreatePageModal } from "@/components/pages"; import { CreateProjectModal } from "@/components/project"; import { CreateUpdateProjectViewModal } from "@/components/views"; // helpers @@ -294,10 +294,12 @@ export const CommandPalette: FC = observer(() => { workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} /> - toggleCreatePageModal(false)} + toggleCreatePageModal(false)} + redirectionEnabled /> )} diff --git a/web/components/core/modals/gpt-assistant-popover.tsx b/web/components/core/modals/gpt-assistant-popover.tsx index 3348272d6..a67b13f7e 100644 --- a/web/components/core/modals/gpt-assistant-popover.tsx +++ b/web/components/core/modals/gpt-assistant-popover.tsx @@ -6,8 +6,8 @@ import { usePopper } from "react-popper"; // ui import { AlertCircle } from "lucide-react"; import { Popover, Transition } from "@headlessui/react"; -import { RichReadOnlyEditorWithRef } from "@plane/rich-text-editor"; import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +import { RichTextReadOnlyEditor } from "@/components/editor/rich-text-editor/rich-text-read-only-editor"; // icons // components // hooks @@ -173,8 +173,8 @@ export const GptAssistantPopover: React.FC = (props) => { const generateResponseButtonText = isSubmitting ? "Generating response..." : response === "" - ? "Generate response" - : "Generate again"; + ? "Generate response" + : "Generate again"; return ( @@ -202,23 +202,15 @@ export const GptAssistantPopover: React.FC = (props) => { {prompt && (
Content: - +
)} {response !== "" && (
Response: - ${response}

`} - customClassName={response ? "-mx-3 -my-3" : ""} - noBorder - borderOnFocus={false} + ${response}

`} + containerClassName={response ? "-mx-3 -my-3" : ""} ref={responseRef} />
@@ -245,6 +237,7 @@ export const GptAssistantPopover: React.FC = (props) => { prompt && prompt !== "" ? "Tell AI what action to perform on this content..." : "Ask AI anything..." }`} className="w-full" + autoFocus /> )} /> @@ -253,7 +246,7 @@ export const GptAssistantPopover: React.FC = (props) => { <>{responseActionButton} ) : ( <> -
+

By using this feature, you consent to sharing the message with a 3rd party service.

diff --git a/web/components/editor/index.ts b/web/components/editor/index.ts new file mode 100644 index 000000000..72e92a6a8 --- /dev/null +++ b/web/components/editor/index.ts @@ -0,0 +1,2 @@ +export * from "./lite-text-editor"; +export * from "./rich-text-editor"; diff --git a/web/components/editor/lite-text-editor/index.ts b/web/components/editor/lite-text-editor/index.ts new file mode 100644 index 000000000..661c8e755 --- /dev/null +++ b/web/components/editor/lite-text-editor/index.ts @@ -0,0 +1,3 @@ +export * from "./lite-text-editor"; +export * from "./lite-text-read-only-editor"; +export * from "./toolbar"; diff --git a/web/components/editor/lite-text-editor/lite-text-editor.tsx b/web/components/editor/lite-text-editor/lite-text-editor.tsx new file mode 100644 index 000000000..1be78a54e --- /dev/null +++ b/web/components/editor/lite-text-editor/lite-text-editor.tsx @@ -0,0 +1,107 @@ +import React from "react"; +// editor +import { EditorRefApi, ILiteTextEditor, LiteTextEditorWithRef } from "@plane/lite-text-editor"; +// types +import { IUserLite } from "@plane/types"; +// components +import { IssueCommentToolbar } from "@/components/editor"; +// constants +import { EIssueCommentAccessSpecifier } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; +import { isEmptyHtmlString } from "@/helpers/string.helper"; +// hooks +import { useMember, useMention, useUser } from "@/hooks/store"; +// services +import { FileService } from "@/services/file.service"; + +interface LiteTextEditorWrapperProps extends Omit { + workspaceSlug: string; + workspaceId: string; + projectId: string; + accessSpecifier?: EIssueCommentAccessSpecifier; + handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void; + showAccessSpecifier?: boolean; + showSubmitButton?: boolean; + isSubmitting?: boolean; +} + +const fileService = new FileService(); + +export const LiteTextEditor = React.forwardRef((props, ref) => { + const { + containerClassName, + workspaceSlug, + workspaceId, + projectId, + accessSpecifier, + handleAccessChange, + showAccessSpecifier = false, + showSubmitButton = true, + isSubmitting = false, + ...rest + } = props; + // store hooks + const { currentUser } = useUser(); + const { + getUserDetails, + project: { getProjectMemberIds }, + } = useMember(); + // derived values + const projectMemberIds = getProjectMemberIds(projectId); + const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); + // use-mention + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug, + projectId, + members: projectMemberDetails, + user: currentUser ?? undefined, + }); + + const isEmpty = + props.initialValue === "" || + props.initialValue?.trim() === "" || + props.initialValue === "

" || + isEmptyHtmlString(props.initialValue ?? ""); + + function isMutableRefObject(ref: React.ForwardedRef): ref is React.MutableRefObject { + return !!ref && typeof ref === "object" && "current" in ref; + } + + return ( +
+ + { + if (isMutableRefObject(ref)) { + ref.current?.executeMenuItemCommand(key); + } + }} + handleAccessChange={handleAccessChange} + handleSubmit={(e) => rest.onEnterKeyPress?.(e)} + isCommentEmpty={isEmpty} + isSubmitting={isSubmitting} + showAccessSpecifier={showAccessSpecifier} + editorRef={isMutableRefObject(ref) ? ref : null} + showSubmitButton={showSubmitButton} + /> +
+ ); +}); + +LiteTextEditor.displayName = "LiteTextEditor"; diff --git a/web/components/editor/lite-text-editor/lite-text-read-only-editor.tsx b/web/components/editor/lite-text-editor/lite-text-read-only-editor.tsx new file mode 100644 index 000000000..8ee95b585 --- /dev/null +++ b/web/components/editor/lite-text-editor/lite-text-read-only-editor.tsx @@ -0,0 +1,29 @@ +import React from "react"; +// editor +import { EditorReadOnlyRefApi, ILiteTextReadOnlyEditor, LiteTextReadOnlyEditorWithRef } from "@plane/lite-text-editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMention } from "@/hooks/store"; + +interface LiteTextReadOnlyEditorWrapperProps extends Omit {} + +export const LiteTextReadOnlyEditor = React.forwardRef( + ({ ...props }, ref) => { + const { mentionHighlights } = useMention({}); + + return ( + + ); + } +); + +LiteTextReadOnlyEditor.displayName = "LiteTextReadOnlyEditor"; diff --git a/web/components/editor/lite-text-editor/toolbar.tsx b/web/components/editor/lite-text-editor/toolbar.tsx new file mode 100644 index 000000000..d80db9ccd --- /dev/null +++ b/web/components/editor/lite-text-editor/toolbar.tsx @@ -0,0 +1,172 @@ +import React, { useEffect, useState, useCallback } from "react"; +import { Globe2, Lock, LucideIcon } from "lucide-react"; +// editor +import { EditorMenuItemNames } from "@plane/document-editor"; +import { EditorRefApi } from "@plane/lite-text-editor"; +// ui +import { Button, Tooltip } from "@plane/ui"; +// constants +import { TOOLBAR_ITEMS } from "@/constants/editor"; +import { EIssueCommentAccessSpecifier } from "@/constants/issue"; +// helpers +import { cn } from "@/helpers/common.helper"; + +type Props = { + accessSpecifier?: EIssueCommentAccessSpecifier; + executeCommand: (commandName: EditorMenuItemNames) => void; + handleAccessChange?: (accessKey: EIssueCommentAccessSpecifier) => void; + handleSubmit: (event: React.MouseEvent) => void; + isCommentEmpty: boolean; + isSubmitting: boolean; + showAccessSpecifier: boolean; + showSubmitButton: boolean; + editorRef: React.MutableRefObject | null; +}; + +type TCommentAccessType = { + icon: LucideIcon; + key: EIssueCommentAccessSpecifier; + label: "Private" | "Public"; +}; + +const COMMENT_ACCESS_SPECIFIERS: TCommentAccessType[] = [ + { + icon: Lock, + key: EIssueCommentAccessSpecifier.INTERNAL, + label: "Private", + }, + { + icon: Globe2, + key: EIssueCommentAccessSpecifier.EXTERNAL, + label: "Public", + }, +]; + +const toolbarItems = TOOLBAR_ITEMS.lite; + +export const IssueCommentToolbar: React.FC = (props) => { + const { + accessSpecifier, + executeCommand, + handleAccessChange, + handleSubmit, + isCommentEmpty, + isSubmitting, + showAccessSpecifier, + showSubmitButton, + editorRef, + } = props; + + // State to manage active states of toolbar items + const [activeStates, setActiveStates] = useState>({}); + + // Function to update active states + const updateActiveStates = useCallback(() => { + if (editorRef?.current) { + const newActiveStates: Record = {}; + Object.values(toolbarItems) + .flat() + .forEach((item) => { + // Assert that editorRef.current is not null + newActiveStates[item.key] = (editorRef.current as EditorRefApi).isMenuItemActive(item.key); + }); + setActiveStates(newActiveStates); + } + }, [editorRef]); + + // useEffect to call updateActiveStates when isActive prop changes + useEffect(() => { + if (!editorRef?.current) return; + const unsubscribe = editorRef.current.onStateChange(updateActiveStates); + updateActiveStates(); + return () => unsubscribe(); + }, [editorRef, updateActiveStates]); + + return ( +
+ {showAccessSpecifier && ( +
+ {COMMENT_ACCESS_SPECIFIERS.map((access) => { + const isAccessActive = accessSpecifier === access.key; + + return ( + + + + ); + })} +
+ )} +
+
+ {Object.keys(toolbarItems).map((key, index) => ( +
+ {toolbarItems[key].map((item) => ( + + {item.name} + {item.shortcut && {item.shortcut.join(" + ")}} +

+ } + > + +
+ ))} +
+ ))} +
+ {showSubmitButton && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/web/components/editor/rich-text-editor/index.ts b/web/components/editor/rich-text-editor/index.ts new file mode 100644 index 000000000..f185d0054 --- /dev/null +++ b/web/components/editor/rich-text-editor/index.ts @@ -0,0 +1,2 @@ +export * from "./rich-text-editor"; +export * from "./rich-text-read-only-editor"; diff --git a/web/components/editor/rich-text-editor/rich-text-editor.tsx b/web/components/editor/rich-text-editor/rich-text-editor.tsx new file mode 100644 index 000000000..e2173445d --- /dev/null +++ b/web/components/editor/rich-text-editor/rich-text-editor.tsx @@ -0,0 +1,59 @@ +import React, { forwardRef } from "react"; +// editor +import { EditorRefApi, IRichTextEditor, RichTextEditorWithRef } from "@plane/rich-text-editor"; +// types +import { IUserLite } from "@plane/types"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMember, useMention, useUser } from "@/hooks/store"; +// services +import { FileService } from "@/services/file.service"; + +interface RichTextEditorWrapperProps extends Omit { + workspaceSlug: string; + workspaceId: string; + projectId: string; +} + +const fileService = new FileService(); + +export const RichTextEditor = forwardRef((props, ref) => { + const { containerClassName, workspaceSlug, workspaceId, projectId, ...rest } = props; + // store hooks + const { currentUser } = useUser(); + const { + getUserDetails, + project: { getProjectMemberIds }, + } = useMember(); + // derived values + const projectMemberIds = getProjectMemberIds(projectId); + const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); + // use-mention + const { mentionHighlights, mentionSuggestions } = useMention({ + workspaceSlug, + projectId, + members: projectMemberDetails, + user: currentUser ?? undefined, + }); + + return ( + + ); +}); + +RichTextEditor.displayName = "RichTextEditor"; diff --git a/web/components/editor/rich-text-editor/rich-text-read-only-editor.tsx b/web/components/editor/rich-text-editor/rich-text-read-only-editor.tsx new file mode 100644 index 000000000..1415bc098 --- /dev/null +++ b/web/components/editor/rich-text-editor/rich-text-read-only-editor.tsx @@ -0,0 +1,29 @@ +import React from "react"; +// editor +import { EditorReadOnlyRefApi, IRichTextReadOnlyEditor, RichTextReadOnlyEditorWithRef } from "@plane/rich-text-editor"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useMention } from "@/hooks/store"; + +interface RichTextReadOnlyEditorWrapperProps extends Omit {} + +export const RichTextReadOnlyEditor = React.forwardRef( + ({ ...props }, ref) => { + const { mentionHighlights } = useMention({}); + + return ( + + ); + } +); + +RichTextReadOnlyEditor.displayName = "RichTextReadOnlyEditor"; diff --git a/web/components/headers/index.ts b/web/components/headers/index.ts index b57b91534..b50074c8e 100644 --- a/web/components/headers/index.ts +++ b/web/components/headers/index.ts @@ -14,6 +14,7 @@ export * from "./cycles"; export * from "./modules-list"; export * from "./project-settings"; export * from "./workspace-settings"; +export * from "./page-details"; export * from "./pages"; export * from "./project-draft-issues"; export * from "./project-archived-issue-details"; diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index 4130dffce..9a595bf1c 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -24,7 +24,7 @@ export const PageDetailsHeader: FC = observer((props) => { const { commandPalette: commandPaletteStore } = useApplication(); const { currentProjectDetails } = useProject(); - const pageDetails = usePage(pageId as string); + const { name } = usePage(pageId?.toString() ?? ""); return (
@@ -71,10 +71,7 @@ export const PageDetailsHeader: FC = observer((props) => { } - /> + } /> } /> diff --git a/web/components/inbox/content/issue-root.tsx b/web/components/inbox/content/issue-root.tsx index 7edcc2644..627c86c45 100644 --- a/web/components/inbox/content/issue-root.tsx +++ b/web/components/inbox/content/issue-root.tsx @@ -25,11 +25,13 @@ type Props = { isEditable: boolean; isSubmitting: "submitting" | "submitted" | "saved"; setIsSubmitting: Dispatch>; + swrIssueDescription: string | undefined; }; export const InboxIssueMainContent: React.FC = observer((props) => { const router = useRouter(); - const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting } = props; + const { workspaceSlug, projectId, inboxIssue, isEditable, isSubmitting, setIsSubmitting, swrIssueDescription } = + props; // hooks const { currentUser } = useUser(); const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); @@ -49,13 +51,6 @@ export const InboxIssueMainContent: React.FC = observer((props) => { const issue = inboxIssue.issue; if (!issue) return <>; - const issueDescription = - issue.description_html !== undefined || issue.description_html !== null - ? issue.description_html != "" - ? issue.description_html - : "

" - : undefined; - const issueOperations: TIssueOperations = useMemo( () => ({ // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -116,6 +111,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { ); if (!issue?.project_id || !issue?.id) return <>; + return ( <>
@@ -134,8 +130,8 @@ export const InboxIssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} - value={issueDescription} - initialValue={issueDescription} + swrIssueDescription={swrIssueDescription} + initialValue={issue.description_html ?? "

"} disabled={!isEditable} issueOperations={issueOperations} setIsSubmitting={(value) => setIsSubmitting(value)} diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx index 9b5f94a48..9af3da0bc 100644 --- a/web/components/inbox/content/root.tsx +++ b/web/components/inbox/content/root.tsx @@ -22,14 +22,14 @@ export const InboxContentRoot: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); - useSWR( + const { data: swrIssueDetails } = useSWR( workspaceSlug && projectId && inboxIssueId ? `PROJECT_INBOX_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` : null, - () => { - workspaceSlug && projectId && inboxIssueId && fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId); - }, - { revalidateOnFocus: false } + workspaceSlug && projectId && inboxIssueId + ? () => fetchInboxIssueById(workspaceSlug, projectId, inboxIssueId) + : null, + { revalidateOnFocus: true } ); const isEditable = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -57,6 +57,7 @@ export const InboxContentRoot: FC = observer((props) => { isEditable={isEditable && !isIssueDisabled} isSubmitting={isSubmitting} setIsSubmitting={setIsSubmitting} + swrIssueDescription={swrIssueDetails?.issue.description_html} />
diff --git a/web/components/inbox/modals/create-issue-modal.tsx b/web/components/inbox/modals/create-issue-modal.tsx index 9336613c6..3cc642be8 100644 --- a/web/components/inbox/modals/create-issue-modal.tsx +++ b/web/components/inbox/modals/create-issue-modal.tsx @@ -4,7 +4,7 @@ import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import { Sparkle } from "lucide-react"; import { Transition, Dialog } from "@headlessui/react"; -import { RichTextEditorWithRef } from "@plane/rich-text-editor"; +import { EditorRefApi } from "@plane/rich-text-editor"; // types import { TIssue } from "@plane/types"; // ui @@ -12,13 +12,15 @@ import { Button, Input, ToggleSwitch, TOAST_TYPE, setToast } from "@plane/ui"; // components import { GptAssistantPopover } from "@/components/core"; import { PriorityDropdown } from "@/components/dropdowns"; -// constants +import { RichTextEditor } from "@/components/editor/rich-text-editor/rich-text-editor"; import { ISSUE_CREATED } from "@/constants/event-tracker"; -// hooks -import { useApplication, useEventTracker, useWorkspace, useMention, useProjectInbox } from "@/hooks/store"; +import { useApplication, useEventTracker, useWorkspace, useProjectInbox } from "@/hooks/store"; // services import { AIService } from "@/services/ai.service"; -import { FileService } from "@/services/file.service"; +// components +// ui +// types +// constants type Props = { isOpen: boolean; @@ -33,7 +35,6 @@ const defaultValues: Partial = { // services const aiService = new AIService(); -const fileService = new FileService(); export const CreateInboxIssueModal: React.FC = observer((props) => { const { isOpen, onClose } = props; @@ -46,11 +47,11 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); // refs - const editorRef = useRef(null); + const editorRef = useRef(null); // hooks - const { mentionHighlights, mentionSuggestions } = useMention(); const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; + const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug.toString() as string)?.id.toString() as string; + // store hooks const { createInboxIssue } = useProjectInbox(); const { @@ -264,20 +265,16 @@ export const CreateInboxIssueModal: React.FC = observer((props) => { name="description_html" control={control} render={({ field: { value, onChange } }) => ( -

" : value} ref={editorRef} - debouncedUpdatesEnabled={false} - value={!value || value === "" ? "

" : value} - customClassName="min-h-[150px]" - onChange={(description, description_html: string) => { + workspaceSlug={workspaceSlug.toString()} + workspaceId={workspaceId} + projectId={projectId.toString()} + dragDropEnabled={false} + onChange={(_description: object, description_html: string) => { onChange(description_html); }} - mentionSuggestions={mentionSuggestions} - mentionHighlights={mentionHighlights} /> )} /> diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx deleted file mode 100644 index 5af8a418e..000000000 --- a/web/components/issues/description-form.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { ChangeEvent, FC, useCallback, useEffect, useState } from "react"; -import debounce from "lodash/debounce"; -import { observer } from "mobx-react"; -import { Controller, useForm } from "react-hook-form"; -import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; -import { TIssue } from "@plane/types"; -// hooks -import { Loader, TextArea } from "@plane/ui"; -import { useMention, useWorkspace } from "@/hooks/store"; -import useReloadConfirmations from "@/hooks/use-reload-confirmation"; -// components -// types -import { FileService } from "@/services/file.service"; -import { TIssueOperations } from "./issue-detail"; -// services - -export interface IssueDescriptionFormValues { - name: string; - description_html: string; -} - -export interface IssueDetailsProps { - workspaceSlug: string; - projectId: string; - issueId: string; - issue: { - name: string; - description_html: string; - id: string; - project_id?: string; - }; - issueOperations: TIssueOperations; - disabled: boolean; - isSubmitting: "submitting" | "submitted" | "saved"; - setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; -} - -const fileService = new FileService(); - -export const IssueDescriptionForm: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, issue, issueOperations, disabled, isSubmitting, setIsSubmitting } = props; - const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string; - // states - const [characterLimit, setCharacterLimit] = useState(false); - // hooks - const { setShowAlert } = useReloadConfirmations(); - // store hooks - const { mentionHighlights, mentionSuggestions } = useMention(); - // form info - const { - handleSubmit, - watch, - reset, - control, - formState: { errors }, - } = useForm({ - defaultValues: { - name: issue?.name, - description_html: issue?.description_html, - }, - }); - - const [localTitleValue, setLocalTitleValue] = useState(""); - const [localIssueDescription, setLocalIssueDescription] = useState({ - id: issue.id, - description_html: issue.description_html, - }); - - const handleDescriptionFormSubmit = useCallback( - async (formData: Partial) => { - if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; - - await issueOperations.update(workspaceSlug, projectId, issueId, { - name: formData.name ?? "", - description_html: formData.description_html ?? "

", - }); - }, - [workspaceSlug, projectId, issueId, issueOperations] - ); - - useEffect(() => { - if (isSubmitting === "submitted") { - setShowAlert(false); - setTimeout(async () => { - setIsSubmitting("saved"); - }, 2000); - } else if (isSubmitting === "submitting") { - setShowAlert(true); - } - }, [isSubmitting, setShowAlert, setIsSubmitting]); - - // reset form values - useEffect(() => { - if (!issue) return; - - reset({ - ...issue, - }); - setLocalIssueDescription({ - id: issue.id, - description_html: issue.description_html === "" ? "

" : issue.description_html, - }); - setLocalTitleValue(issue.name); - }, [issue, issue.description_html, reset]); - - // ADDING handleDescriptionFormSubmit TO DEPENDENCY ARRAY PRODUCES ADVERSE EFFECTS - // TODO: Verify the exhaustive-deps warning - // eslint-disable-next-line react-hooks/exhaustive-deps - const debouncedFormSave = useCallback( - debounce(async () => { - handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted")); - }, 1500), - [handleSubmit] - ); - - return ( -
-
- {!disabled ? ( - ( -