diff --git a/apiserver/plane/app/serializers/page.py b/apiserver/plane/app/serializers/page.py index 4f3cde39b..41f46c6e4 100644 --- a/apiserver/plane/app/serializers/page.py +++ b/apiserver/plane/app/serializers/page.py @@ -106,7 +106,9 @@ class PageDetailSerializer(PageSerializer): description_html = serializers.CharField() class Meta(PageSerializer.Meta): - fields = PageSerializer.Meta.fields + ["description_html"] + fields = PageSerializer.Meta.fields + [ + "description_html", + ] class SubPageSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/page.py b/apiserver/plane/app/urls/page.py index 1a73e4ed3..a6d43600f 100644 --- a/apiserver/plane/app/urls/page.py +++ b/apiserver/plane/app/urls/page.py @@ -6,6 +6,7 @@ from plane.app.views import ( PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) @@ -79,4 +80,14 @@ urlpatterns = [ SubPagesEndpoint.as_view(), name="sub-page", ), + path( + "workspaces//projects//pages//description/", + PagesDescriptionViewSet.as_view( + { + "get": "retrieve", + "patch": "partial_update", + } + ), + name="page-description", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index bf765e719..0c489593d 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -177,6 +177,7 @@ from .page.base import ( PageFavoriteViewSet, PageLogEndpoint, SubPagesEndpoint, + PagesDescriptionViewSet, ) from .search import GlobalSearchEndpoint, IssueSearchEndpoint diff --git a/apiserver/plane/app/views/page/base.py b/apiserver/plane/app/views/page/base.py index 16ea78033..c7f53b9fe 100644 --- a/apiserver/plane/app/views/page/base.py +++ b/apiserver/plane/app/views/page/base.py @@ -1,5 +1,6 @@ # Python imports import json +import base64 from datetime import datetime from django.core.serializers.json import DjangoJSONEncoder @@ -8,6 +9,7 @@ from django.db import connection from django.db.models import Exists, OuterRef, Q from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page +from django.http import StreamingHttpResponse # Third party imports from rest_framework import status @@ -388,3 +390,48 @@ class SubPagesEndpoint(BaseAPIView): return Response( SubPageSerializer(pages, many=True).data, status=status.HTTP_200_OK ) + + +class PagesDescriptionViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + def retrieve(self, request, slug, project_id, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + binary_data = page.description_binary + + def stream_data(): + if binary_data: + yield binary_data + else: + yield b"" + + response = StreamingHttpResponse( + stream_data(), content_type="application/octet-stream" + ) + response["Content-Disposition"] = ( + 'attachment; filename="page_description.bin"' + ) + return response + + def partial_update(self, request, slug, project_id, pk): + page = Page.objects.get( + pk=pk, workspace__slug=slug, project_id=project_id + ) + + base64_data = request.data.get("description_binary") + + if base64_data: + # Decode the base64 data to bytes + new_binary_data = base64.b64decode(base64_data) + + # Store the updated binary data + page.description_binary = new_binary_data + page.description_html = request.data.get("description_html") + page.save() + return Response({"message": "Updated successfully"}) + else: + return Response({"error": "No binary data provided"}) diff --git a/apiserver/plane/db/models/page.py b/apiserver/plane/db/models/page.py index 3602bce1f..e079dcbe5 100644 --- a/apiserver/plane/db/models/page.py +++ b/apiserver/plane/db/models/page.py @@ -18,6 +18,7 @@ def get_view_props(): class Page(ProjectBaseModel): name = models.CharField(max_length=255, blank=True) description = models.JSONField(default=dict, blank=True) + description_binary = models.BinaryField(null=True) description_html = models.TextField(blank=True, default="

") description_stripped = models.TextField(blank=True, null=True) owned_by = models.ForeignKey( @@ -43,7 +44,6 @@ class Page(ProjectBaseModel): is_locked = models.BooleanField(default=False) view_props = models.JSONField(default=get_view_props) logo_props = models.JSONField(default=dict) - description_binary = models.BinaryField(null=True) class Meta: verbose_name = "Page" diff --git a/packages/editor/core/src/hooks/use-editor.tsx b/packages/editor/core/src/hooks/use-editor.tsx index 778fdc5e4..2d2e1662a 100644 --- a/packages/editor/core/src/hooks/use-editor.tsx +++ b/packages/editor/core/src/hooks/use-editor.tsx @@ -13,17 +13,21 @@ 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 { +export type TFileHandler = { + cancel: () => void; + delete: DeleteImage; + upload: UploadImage; + restore: RestoreImage; +}; + +export interface CustomEditorProps { id?: string; - uploadFile: UploadImage; - restoreFile: RestoreImage; - deleteFile: DeleteImage; - cancelUploadImage?: () => void; - initialValue: string; + fileHandler: TFileHandler; + initialValue?: string; editorClassName: string; // undefined when prop is not passed, null if intentionally passed to stop // swr syncing - value: string | null | undefined; + value?: string | null | undefined; onChange?: (json: object, html: string) => void; extensions?: any; editorProps?: EditorProps; @@ -38,19 +42,16 @@ interface CustomEditorProps { } export const useEditor = ({ - uploadFile, id = "", - deleteFile, - cancelUploadImage, editorProps = {}, initialValue, editorClassName, value, extensions = [], + fileHandler, onChange, forwardedRef, tabIndex, - restoreFile, handleEditorReady, mentionHandler, placeholder, @@ -67,10 +68,10 @@ export const useEditor = ({ mentionHighlights: mentionHandler.highlights ?? [], }, fileConfig: { - deleteFile, - restoreFile, - cancelUploadImage, - uploadFile, + uploadFile: fileHandler.upload, + deleteFile: fileHandler.delete, + restoreFile: fileHandler.restore, + cancelUploadImage: fileHandler.cancel, }, placeholder, tabIndex, @@ -139,7 +140,7 @@ export const useEditor = ({ } }, executeMenuItemCommand: (itemName: EditorMenuItemNames) => { - const editorItems = getEditorMenuItems(editorRef.current, uploadFile); + const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); @@ -155,7 +156,7 @@ export const useEditor = ({ } }, isMenuItemActive: (itemName: EditorMenuItemNames): boolean => { - const editorItems = getEditorMenuItems(editorRef.current, uploadFile); + const editorItems = getEditorMenuItems(editorRef.current, fileHandler.upload); const getEditorMenuItem = (itemName: EditorMenuItemNames) => editorItems.find((item) => item.key === itemName); const item = getEditorMenuItem(itemName); @@ -177,6 +178,10 @@ export const useEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, + getHTML: (): string => { + const htmlOutput = editorRef.current?.getHTML() ?? "

"; + return htmlOutput; + }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; scrollSummary(editorRef.current, marking); @@ -199,7 +204,7 @@ export const useEditor = ({ } }, }), - [editorRef, savedSelection, uploadFile] + [editorRef, savedSelection, fileHandler.upload] ); if (!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 9607586d8..8b16d1e76 100644 --- a/packages/editor/core/src/hooks/use-read-only-editor.tsx +++ b/packages/editor/core/src/hooks/use-read-only-editor.tsx @@ -68,6 +68,10 @@ export const useReadOnlyEditor = ({ const markdownOutput = editorRef.current?.storage.markdown.getMarkdown(); return markdownOutput; }, + getHTML: (): string => { + const htmlOutput = editorRef.current?.getHTML() ?? "

"; + return htmlOutput; + }, scrollSummary: (marking: IMarking): void => { if (!editorRef.current) return; scrollSummary(editorRef.current, marking); diff --git a/packages/editor/core/src/index.ts b/packages/editor/core/src/index.ts index 336daed43..86066eeba 100644 --- a/packages/editor/core/src/index.ts +++ b/packages/editor/core/src/index.ts @@ -24,6 +24,7 @@ export * from "src/ui/menus/menu-items"; export * from "src/lib/editor-commands"; // types +export type { CustomEditorProps, TFileHandler } from "src/hooks/use-editor"; 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"; diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index 84ad7046e..137c70c2e 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -1,5 +1,7 @@ +import { Extensions, generateJSON, getSchema } from "@tiptap/core"; import { Selection } from "@tiptap/pm/state"; import { clsx, type ClassValue } from "clsx"; +import { CoreEditorExtensionsWithoutProps } from "src/ui/extensions/core-without-props"; import { twMerge } from "tailwind-merge"; interface EditorClassNames { noBorder?: boolean; @@ -58,3 +60,20 @@ export const isValidHttpUrl = (string: string): boolean => { return url.protocol === "http:" || url.protocol === "https:"; }; + +/** + * @description return an object with contentJSON and editorSchema + * @description contentJSON- ProseMirror JSON from HTML content + * @description editorSchema- editor schema from extensions + * @param {string} html + * @returns {object} {contentJSON, editorSchema} + */ +export const generateJSONfromHTML = (html: string) => { + const extensions = CoreEditorExtensionsWithoutProps(); + const contentJSON = generateJSON(html ?? "

", extensions as Extensions); + const editorSchema = getSchema(extensions as Extensions); + return { + contentJSON, + editorSchema, + }; +}; diff --git a/packages/editor/core/src/types/editor-ref-api.ts b/packages/editor/core/src/types/editor-ref-api.ts index df5df2c7b..4eed815d6 100644 --- a/packages/editor/core/src/types/editor-ref-api.ts +++ b/packages/editor/core/src/types/editor-ref-api.ts @@ -3,6 +3,7 @@ import { EditorMenuItemNames } from "src/ui/menus/menu-items"; export type EditorReadOnlyRefApi = { getMarkDown: () => string; + getHTML: () => string; clearEditor: () => void; setEditorValue: (content: string) => void; scrollSummary: (marking: IMarking) => void; diff --git a/packages/editor/core/src/ui/extensions/core-without-props.tsx b/packages/editor/core/src/ui/extensions/core-without-props.tsx new file mode 100644 index 000000000..3bb00010b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/core-without-props.tsx @@ -0,0 +1,121 @@ +import TaskItem from "@tiptap/extension-task-item"; +import TaskList from "@tiptap/extension-task-list"; +import TextStyle from "@tiptap/extension-text-style"; +import TiptapUnderline from "@tiptap/extension-underline"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Markdown } from "tiptap-markdown"; + +import { Table } from "src/ui/extensions/table/table"; +import { TableCell } from "src/ui/extensions/table/table-cell/table-cell"; +import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; +import { TableRow } from "src/ui/extensions/table/table-row/table-row"; + +import { isValidHttpUrl } from "src/lib/utils"; + +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; +import { CustomKeymap } from "src/ui/extensions/keymap"; +import { CustomQuoteExtension } from "src/ui/extensions/quote"; + +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 "src/ui/extensions/custom-code-inline/inline-code-plugin"; +import { MentionsWithoutProps } from "src/ui/mentions/mention-without-props"; +import { ImageExtensionWithoutProps } from "src/ui/extensions/image/image-extension-without-props"; + +import StarterKit from "@tiptap/starter-kit"; + +export const CoreEditorExtensionsWithoutProps = () => [ + StarterKit.configure({ + bulletList: { + HTMLAttributes: { + class: "list-disc pl-7 space-y-2", + }, + }, + orderedList: { + HTMLAttributes: { + class: "list-decimal pl-7 space-y-2", + }, + }, + listItem: { + HTMLAttributes: { + class: "not-prose space-y-2", + }, + }, + code: false, + codeBlock: false, + horizontalRule: false, + blockquote: false, + dropcursor: { + color: "rgba(var(--color-text-100))", + width: 1, + }, + }), + CustomQuoteExtension, + CustomHorizontalRule.configure({ + HTMLAttributes: { + class: "my-4 border-custom-border-400", + }, + }), + CustomKeymap, + // ListKeymap, + CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, + protocols: ["http", "https"], + validate: (url: string) => isValidHttpUrl(url), + HTMLAttributes: { + class: + "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", + }, + }), + CustomTypographyExtension, + ImageExtensionWithoutProps().configure({ + HTMLAttributes: { + class: "rounded-md", + }, + }), + TiptapUnderline, + TextStyle, + TaskList.configure({ + HTMLAttributes: { + class: "not-prose pl-2 space-y-2", + }, + }), + TaskItem.configure({ + HTMLAttributes: { + class: "flex", + }, + nested: true, + }), + CustomCodeBlockExtension.configure({ + HTMLAttributes: { + class: "", + }, + }), + CustomCodeMarkPlugin, + CustomCodeInlineExtension, + Markdown.configure({ + html: true, + transformPastedText: true, + }), + Table, + TableHeader, + TableCell, + TableRow, + MentionsWithoutProps(), + Placeholder.configure({ + placeholder: ({ editor, node }) => { + if (node.type.name === "heading") return `Heading ${node.attrs.level}`; + + const shouldHidePlaceholder = + editor.isActive("table") || editor.isActive("codeBlock") || editor.isActive("image"); + if (shouldHidePlaceholder) return ""; + + return "Press '/' for commands..."; + }, + includeChildren: true, + }), +]; diff --git a/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx b/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx new file mode 100644 index 000000000..838a6a1c9 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx @@ -0,0 +1,33 @@ +import ImageExt from "@tiptap/extension-image"; +import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; +import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; + +export const ImageExtensionWithoutProps = () => + ImageExt.extend({ + addKeyboardShortcuts() { + return { + ArrowDown: insertLineBelowImageAction, + ArrowUp: insertLineAboveImageAction, + }; + }, + + // storage to keep track of image states Map + addStorage() { + return { + images: new Map(), + uploadInProgress: false, + }; + }, + + addAttributes() { + return { + ...this.parent?.(), + width: { + default: "35%", + }, + height: { + default: null, + }, + }; + }, + }); diff --git a/packages/editor/core/src/ui/mentions/mention-without-props.tsx b/packages/editor/core/src/ui/mentions/mention-without-props.tsx new file mode 100644 index 000000000..a0d22ef4f --- /dev/null +++ b/packages/editor/core/src/ui/mentions/mention-without-props.tsx @@ -0,0 +1,79 @@ +import { CustomMention } from "./custom"; +import { ReactRenderer } from "@tiptap/react"; +import { Editor } from "@tiptap/core"; +import tippy from "tippy.js"; + +import { MentionList } from "./mention-list"; + +export const MentionsWithoutProps = () => + CustomMention.configure({ + HTMLAttributes: { + class: "mention", + }, + // mentionHighlights: mentionHighlights, + suggestion: { + // @ts-expect-error - Tiptap types are incorrect + render: () => { + 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 }, + 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/document-editor/package.json b/packages/editor/document-editor/package.json index 47e68a87e..d3bfbd6aa 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -34,12 +34,17 @@ "@plane/ui": "*", "@tippyjs/react": "^4.2.6", "@tiptap/core": "^2.1.13", + "@tiptap/extension-collaboration": "^2.3.2", "@tiptap/pm": "^2.1.13", "@tiptap/suggestion": "^2.1.13", "lucide-react": "^0.378.0", "react-popper": "^2.3.0", "tippy.js": "^6.3.7", - "uuid": "^9.0.1" + "uuid": "^9.0.1", + "y-indexeddb": "^9.0.12", + "y-prosemirror": "^1.2.5", + "y-protocols": "^1.0.6", + "yjs": "^13.6.15" }, "devDependencies": { "@types/node": "18.15.3", diff --git a/packages/editor/document-editor/src/hooks/use-document-editor.ts b/packages/editor/document-editor/src/hooks/use-document-editor.ts new file mode 100644 index 000000000..c2070a9f3 --- /dev/null +++ b/packages/editor/document-editor/src/hooks/use-document-editor.ts @@ -0,0 +1,85 @@ +import { useEffect, useLayoutEffect, useMemo } from "react"; +import { EditorProps } from "@tiptap/pm/view"; +import { IndexeddbPersistence } from "y-indexeddb"; +import * as Y from "yjs"; +// editor-core +import { EditorRefApi, IMentionHighlight, IMentionSuggestion, TFileHandler, useEditor } from "@plane/editor-core"; +// custom provider +import { CollaborationProvider } from "src/providers/collaboration-provider"; +// extensions +import { DocumentEditorExtensions } from "src/ui/extensions"; + +type DocumentEditorProps = { + id: string; + fileHandler: TFileHandler; + value: Uint8Array; + editorClassName: string; + onChange: (updates: Uint8Array) => void; + editorProps?: EditorProps; + forwardedRef?: React.MutableRefObject; + mentionHandler: { + highlights: () => Promise; + suggestions?: () => Promise; + }; + handleEditorReady?: (value: boolean) => void; + placeholder?: string | ((isFocused: boolean, value: string) => string); + setHideDragHandleFunction: (hideDragHandlerFromDragDrop: () => void) => void; + tabIndex?: number; +}; + +export const useDocumentEditor = ({ + id, + editorProps = {}, + value, + editorClassName, + fileHandler, + onChange, + forwardedRef, + tabIndex, + handleEditorReady, + mentionHandler, + placeholder, + setHideDragHandleFunction, +}: DocumentEditorProps) => { + const provider = useMemo( + () => + new CollaborationProvider({ + name: id, + onChange, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [id] + ); + + // update document on value change + useEffect(() => { + if (value.byteLength > 0) Y.applyUpdate(provider.document, value); + }, [value, provider.document]); + + // indexedDB provider + useLayoutEffect(() => { + const localProvider = new IndexeddbPersistence(id, provider.document); + return () => { + localProvider?.destroy(); + }; + }, [provider, id]); + + const editor = useEditor({ + id, + editorProps, + editorClassName, + fileHandler, + handleEditorReady, + forwardedRef, + mentionHandler, + extensions: DocumentEditorExtensions({ + uploadFile: fileHandler.upload, + setHideDragHandle: setHideDragHandleFunction, + provider, + }), + placeholder, + tabIndex, + }); + + return editor; +}; diff --git a/packages/editor/document-editor/src/index.ts b/packages/editor/document-editor/src/index.ts index f8eea14ce..9e8407ce3 100644 --- a/packages/editor/document-editor/src/index.ts +++ b/packages/editor/document-editor/src/index.ts @@ -3,6 +3,8 @@ export { DocumentReadOnlyEditor, DocumentReadOnlyEditorWithRef } from "src/ui/re // hooks export { useEditorMarkings } from "src/hooks/use-editor-markings"; +// utils +export { proseMirrorJSONToBinaryString, applyUpdates, mergeUpdates } from "src/utils/yjs"; export type { EditorRefApi, EditorReadOnlyRefApi, EditorMenuItem, EditorMenuItemNames } from "@plane/editor-core"; diff --git a/packages/editor/document-editor/src/providers/collaboration-provider.ts b/packages/editor/document-editor/src/providers/collaboration-provider.ts new file mode 100644 index 000000000..b61ceebd5 --- /dev/null +++ b/packages/editor/document-editor/src/providers/collaboration-provider.ts @@ -0,0 +1,60 @@ +import * as Y from "yjs"; + +export interface CompleteCollaboratorProviderConfiguration { + /** + * The identifier/name of your document + */ + name: string; + /** + * The actual Y.js document + */ + document: Y.Doc; + /** + * onChange callback + */ + onChange: (updates: Uint8Array) => void; +} + +export type CollaborationProviderConfiguration = Required> & + Partial; + +export class CollaborationProvider { + public configuration: CompleteCollaboratorProviderConfiguration = { + name: "", + // @ts-expect-error cannot be undefined + document: undefined, + onChange: () => {}, + }; + + constructor(configuration: CollaborationProviderConfiguration) { + this.setConfiguration(configuration); + + this.configuration.document = configuration.document ?? new Y.Doc(); + this.document.on("update", this.documentUpdateHandler.bind(this)); + this.document.on("destroy", this.documentDestroyHandler.bind(this)); + } + + public setConfiguration(configuration: Partial = {}): void { + this.configuration = { + ...this.configuration, + ...configuration, + }; + } + + get document() { + return this.configuration.document; + } + + documentUpdateHandler(update: Uint8Array, origin: any) { + // return if the update is from the provider itself + if (origin === this) return; + + // call onChange with the update + this.configuration.onChange?.(update); + } + + documentDestroyHandler() { + this.document.off("update", this.documentUpdateHandler); + this.document.off("destroy", this.documentDestroyHandler); + } +} diff --git a/packages/editor/document-editor/src/ui/extensions/index.tsx b/packages/editor/document-editor/src/ui/extensions/index.tsx index b2816974e..10c9fa596 100644 --- a/packages/editor/document-editor/src/ui/extensions/index.tsx +++ b/packages/editor/document-editor/src/ui/extensions/index.tsx @@ -2,14 +2,20 @@ import { IssueWidgetPlaceholder } from "src/ui/extensions/widgets/issue-embed-wi import { SlashCommand, DragAndDrop } from "@plane/editor-extensions"; import { UploadImage } from "@plane/editor-core"; +import { CollaborationProvider } from "src/providers/collaboration-provider"; +import Collaboration from "@tiptap/extension-collaboration"; type TArguments = { uploadFile: UploadImage; setHideDragHandle?: (hideDragHandlerFromDragDrop: () => void) => void; + provider: CollaborationProvider; }; -export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle }: TArguments) => [ +export const DocumentEditorExtensions = ({ uploadFile, setHideDragHandle, provider }: TArguments) => [ SlashCommand(uploadFile), DragAndDrop(setHideDragHandle), IssueWidgetPlaceholder(), + Collaboration.configure({ + document: provider.document, + }), ]; diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index 1f1c5f706..1cafe6de7 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -1,30 +1,25 @@ import React, { useState } from "react"; +// editor-core import { - UploadImage, - DeleteImage, - RestoreImage, getEditorClassNames, - useEditor, EditorRefApi, IMentionHighlight, IMentionSuggestion, + TFileHandler, } from "@plane/editor-core"; -import { DocumentEditorExtensions } from "src/ui/extensions"; +// components import { PageRenderer } from "src/ui/components/page-renderer"; +// hooks +import { useDocumentEditor } from "src/hooks/use-document-editor"; interface IDocumentEditor { - initialValue: string; - value?: string; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + id: string; + value: Uint8Array; + fileHandler: TFileHandler; handleEditorReady?: (value: boolean) => void; containerClassName?: string; editorClassName?: string; - onChange: (json: object, html: string) => void; + onChange: (updates: Uint8Array) => void; forwardedRef?: React.MutableRefObject; mentionHandler: { highlights: () => Promise; @@ -37,7 +32,7 @@ interface IDocumentEditor { const DocumentEditor = (props: IDocumentEditor) => { const { onChange, - initialValue, + id, value, fileHandler, containerClassName, @@ -50,32 +45,24 @@ const DocumentEditor = (props: IDocumentEditor) => { } = 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) { - onChange(json, html); - }, + + // use document editor + const editor = useDocumentEditor({ + id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, - initialValue, + fileHandler, value, + onChange, handleEditorReady, forwardedRef, mentionHandler, - extensions: DocumentEditorExtensions({ - uploadFile: fileHandler.upload, - setHideDragHandle: setHideDragHandleFunction, - }), placeholder, + setHideDragHandleFunction, tabIndex, }); diff --git a/packages/editor/document-editor/src/utils/yjs.ts b/packages/editor/document-editor/src/utils/yjs.ts new file mode 100644 index 000000000..71a945d3c --- /dev/null +++ b/packages/editor/document-editor/src/utils/yjs.ts @@ -0,0 +1,76 @@ +import { Schema } from "@tiptap/pm/model"; +import { prosemirrorJSONToYDoc } from "y-prosemirror"; +import * as Y from "yjs"; + +const defaultSchema: Schema = new Schema({ + nodes: { + text: {}, + doc: { content: "text*" }, + }, +}); + +/** + * @description converts ProseMirror JSON to Yjs document + * @param document prosemirror JSON + * @param fieldName + * @param schema + * @returns {Y.Doc} Yjs document + */ +export const proseMirrorJSONToBinaryString = ( + document: any, + fieldName: string | Array = "default", + schema?: Schema +): string => { + if (!document) { + throw new Error( + `You've passed an empty or invalid document to the Transformer. Make sure to pass ProseMirror-compatible JSON. Actually passed JSON: ${document}` + ); + } + + // allow a single field name + if (typeof fieldName === "string") { + const yDoc = prosemirrorJSONToYDoc(schema ?? defaultSchema, document, fieldName); + const docAsUint8Array = Y.encodeStateAsUpdate(yDoc); + const base64Doc = Buffer.from(docAsUint8Array).toString("base64"); + return base64Doc; + } + + const yDoc = new Y.Doc(); + + fieldName.forEach((field) => { + const update = Y.encodeStateAsUpdate(prosemirrorJSONToYDoc(schema ?? defaultSchema, document, field)); + + Y.applyUpdate(yDoc, update); + }); + + const docAsUint8Array = Y.encodeStateAsUpdate(yDoc); + const base64Doc = Buffer.from(docAsUint8Array).toString("base64"); + + return base64Doc; +}; + +/** + * @description apply updates to a doc and return the updated doc in base64(binary) format + * @param {Uint8Array} document + * @param {Uint8Array} updates + * @returns {string} base64(binary) form of the updated doc + */ +export const applyUpdates = (document: Uint8Array, updates: Uint8Array): string => { + const yDoc = new Y.Doc(); + Y.applyUpdate(yDoc, document); + Y.applyUpdate(yDoc, updates); + + const encodedDoc = Y.encodeStateAsUpdate(yDoc); + const base64Updates = Buffer.from(encodedDoc).toString("base64"); + return base64Updates; +}; + +/** + * @description merge multiple updates into one single update + * @param {Uint8Array[]} updates + * @returns {Uint8Array} merged updates + */ +export const mergeUpdates = (updates: Uint8Array[]): Uint8Array => { + const mergedUpdates = Y.mergeUpdates(updates); + return mergedUpdates; +}; diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 6b22809d6..77d3ca0ec 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -1,27 +1,22 @@ import * as React from "react"; +// editor-core import { - UploadImage, - DeleteImage, IMentionSuggestion, - RestoreImage, EditorContainer, EditorContentWrapper, getEditorClassNames, useEditor, IMentionHighlight, EditorRefApi, + TFileHandler, } from "@plane/editor-core"; +// extensions import { LiteTextEditorExtensions } from "src/ui/extensions"; export interface ILiteTextEditor { initialValue: string; value?: string | null; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + fileHandler: TFileHandler; containerClassName?: string; editorClassName?: string; onChange?: (json: object, html: string) => void; @@ -58,10 +53,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => { value, id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, + fileHandler, forwardedRef, extensions: LiteTextEditorExtensions(onEnterKeyPress), mentionHandler, diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index ec5aa7359..2b8348a62 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -1,30 +1,26 @@ "use client"; +import * as React from "react"; +// editor-core import { - DeleteImage, EditorContainer, EditorContentWrapper, getEditorClassNames, IMentionHighlight, IMentionSuggestion, - RestoreImage, - UploadImage, useEditor, EditorRefApi, + TFileHandler, } from "@plane/editor-core"; -import * as React from "react"; +// extensions import { RichTextEditorExtensions } from "src/ui/extensions"; +// components import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; export type IRichTextEditor = { initialValue: string; value?: string | null; dragDropEnabled?: boolean; - fileHandler: { - cancel: () => void; - delete: DeleteImage; - upload: UploadImage; - restore: RestoreImage; - }; + fileHandler: TFileHandler; id?: string; containerClassName?: string; editorClassName?: string; @@ -69,10 +65,7 @@ const RichTextEditor = (props: IRichTextEditor) => { const editor = useEditor({ id, editorClassName, - restoreFile: fileHandler.restore, - uploadFile: fileHandler.upload, - deleteFile: fileHandler.delete, - cancelUploadImage: fileHandler.cancel, + fileHandler, onChange, initialValue, value, diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index 0a02c1528..3e5424305 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -1,30 +1,26 @@ -import { FC } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { FileText } from "lucide-react"; -// hooks // ui import { Breadcrumbs, Button } from "@plane/ui"; -// helpers -import { BreadcrumbLink } from "@/components/common"; // components +import { BreadcrumbLink } from "@/components/common"; import { ProjectLogo } from "@/components/project"; -import { useCommandPalette, usePage, useProject } from "@/hooks/store"; +// hooks +import { usePage, useProject } from "@/hooks/store"; +import { usePlatformOS } from "@/hooks/use-platform-os"; -export interface IPagesHeaderProps { - showButton?: boolean; -} - -export const PageDetailsHeader: FC = observer((props) => { - const { showButton = false } = props; +export const PageDetailsHeader = observer(() => { // router const router = useRouter(); const { workspaceSlug, pageId } = router.query; // store hooks - const { toggleCreatePageModal } = useCommandPalette(); const { currentProjectDetails } = useProject(); - - const { name } = usePage(pageId?.toString() ?? ""); + const { isContentEditable, isSubmitting, name } = usePage(pageId?.toString() ?? ""); + // use platform + const { platform } = usePlatformOS(); + // derived values + const isMac = platform === "MacOS"; return (
@@ -77,12 +73,24 @@ export const PageDetailsHeader: FC = observer((props) => {
- {showButton && ( -
- -
+ {isContentEditable && ( + )} ); diff --git a/web/components/pages/editor/editor-body.tsx b/web/components/pages/editor/editor-body.tsx index a896bcc58..28f879013 100644 --- a/web/components/pages/editor/editor-body.tsx +++ b/web/components/pages/editor/editor-body.tsx @@ -1,8 +1,7 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { useRouter } from "next/router"; -import { Control, Controller } from "react-hook-form"; -// document editor +// document-editor import { DocumentEditorWithRef, DocumentReadOnlyEditorWithRef, @@ -11,15 +10,15 @@ import { IMarking, } from "@plane/document-editor"; // types -import { IUserLite, TPage } from "@plane/types"; +import { IUserLite } from "@plane/types"; // components import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store"; +import { usePageDescription } from "@/hooks/use-page-description"; import { usePageFilters } from "@/hooks/use-page-filters"; -import useReloadConfirmations from "@/hooks/use-reload-confirmation"; // services import { FileService } from "@/services/file.service"; // store @@ -28,13 +27,10 @@ import { IPageStore } from "@/store/pages/page.store"; const fileService = new FileService(); type Props = { - control: Control; editorRef: React.RefObject; readOnlyEditorRef: React.RefObject; - swrPageDetails: TPage | undefined; - handleSubmit: () => void; markings: IMarking[]; - pageStore: IPageStore; + page: IPageStore; sidePeekVisible: boolean; handleEditorReady: (value: boolean) => void; handleReadOnlyEditorReady: (value: boolean) => void; @@ -43,15 +39,12 @@ type Props = { export const PageEditorBody: React.FC = observer((props) => { const { - control, handleReadOnlyEditorReady, handleEditorReady, editorRef, markings, readOnlyEditorRef, - handleSubmit, - pageStore, - swrPageDetails, + page, sidePeekVisible, updateMarkings, } = props; @@ -67,11 +60,19 @@ export const PageEditorBody: React.FC = observer((props) => { } = useMember(); // derived values const workspaceId = workspaceSlug ? getWorkspaceBySlug(workspaceSlug.toString())?.id ?? "" : ""; - const pageTitle = pageStore?.name ?? ""; - const pageDescription = pageStore?.description_html; - const { description_html, isContentEditable, updateTitle, isSubmitting, setIsSubmitting } = pageStore; + const pageId = page?.id; + const pageTitle = page?.name ?? ""; + const pageDescription = page?.description_html; + const { isContentEditable, updateTitle, setIsSubmitting } = page; const projectMemberIds = projectId ? getProjectMemberIds(projectId.toString()) : []; const projectMemberDetails = projectMemberIds?.map((id) => getUserDetails(id) as IUserLite); + // project-description + const { handleDescriptionChange, isDescriptionReady, pageDescriptionYJS } = usePageDescription({ + editorRef, + page, + projectId, + workspaceSlug, + }); // use-mention const { mentionHighlights, mentionSuggestions } = useMention({ workspaceSlug: workspaceSlug?.toString() ?? "", @@ -82,13 +83,11 @@ export const PageEditorBody: React.FC = observer((props) => { // page filters const { isFullWidth } = usePageFilters(); - const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); - useEffect(() => { - updateMarkings(description_html ?? "

"); - }, [description_html, updateMarkings]); + updateMarkings(pageDescription ?? "

"); + }, [pageDescription, updateMarkings]); - if (pageDescription === undefined) return ; + if (pageId === undefined || !pageDescriptionYJS || !isDescriptionReady) return ; return (
@@ -122,35 +121,24 @@ export const PageEditorBody: React.FC = observer((props) => { />
{isContentEditable ? ( - ( -

"} - value={swrPageDetails?.description_html ?? "

"} - ref={editorRef} - containerClassName="p-0 pb-64" - editorClassName="lg:px-10 pl-8" - onChange={(_description_json, description_html) => { - setIsSubmitting("submitting"); - setShowAlert(true); - onChange(description_html); - handleSubmit(); - }} - mentionHandler={{ - highlights: mentionHighlights, - suggestions: mentionSuggestions, - }} - /> - )} + ) : ( = observer((props) => { initialValue={pageDescription ?? "

"} handleEditorReady={handleReadOnlyEditorReady} containerClassName="p-0 pb-64 border-none" - editorClassName="lg:px-10 pl-8" + editorClassName="pl-10" mentionHandler={{ highlights: mentionHighlights, }} diff --git a/web/components/pages/editor/header/extra-options.tsx b/web/components/pages/editor/header/extra-options.tsx index dee77d19e..632799846 100644 --- a/web/components/pages/editor/header/extra-options.tsx +++ b/web/components/pages/editor/header/extra-options.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { observer } from "mobx-react"; -import { Lock, RefreshCw, Sparkle } from "lucide-react"; +import { Lock, Sparkle } from "lucide-react"; // editor import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/document-editor"; // ui @@ -9,7 +9,6 @@ import { ArchiveIcon } from "@plane/ui"; import { GptAssistantPopover } from "@/components/core"; import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages"; // helpers -import { cn } from "@/helpers/common.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper"; // hooks import { useInstance } from "@/hooks/store"; @@ -19,20 +18,19 @@ import { IPageStore } from "@/store/pages/page.store"; type Props = { editorRef: React.RefObject; handleDuplicatePage: () => void; - isSyncing: boolean; - pageStore: IPageStore; + page: IPageStore; projectId: string; readOnlyEditorRef: React.RefObject; }; export const PageExtraOptions: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, isSyncing, pageStore, projectId, readOnlyEditorRef } = props; + const { editorRef, handleDuplicatePage, page, projectId, readOnlyEditorRef } = props; // states const [gptModalOpen, setGptModal] = useState(false); // store hooks const { config } = useInstance(); // derived values - const { archived_at, isContentEditable, isSubmitting, is_locked } = pageStore; + const { archived_at, isContentEditable, is_locked } = page; const handleAiAssistance = async (response: string) => { if (!editorRef) return; @@ -41,22 +39,6 @@ export const PageExtraOptions: React.FC = observer((props) => { return (
- {isContentEditable && ( -
- {isSubmitting === "submitting" && } - {isSubmitting === "submitting" ? "Saving..." : "Saved"} -
- )} - {isSyncing && ( -
- - Syncing... -
- )} {is_locked && (
@@ -93,11 +75,11 @@ export const PageExtraOptions: React.FC = observer((props) => { className="!min-w-[38rem]" /> )} - +
); diff --git a/web/components/pages/editor/header/info-popover.tsx b/web/components/pages/editor/header/info-popover.tsx index 55b4b28fb..270da934b 100644 --- a/web/components/pages/editor/header/info-popover.tsx +++ b/web/components/pages/editor/header/info-popover.tsx @@ -7,11 +7,11 @@ import { renderFormattedDate } from "@/helpers/date-time.helper"; import { IPageStore } from "@/store/pages/page.store"; type Props = { - pageStore: IPageStore; + page: IPageStore; }; export const PageInfoPopover: React.FC = (props) => { - const { pageStore } = props; + const { page } = props; // states const [isPopoverOpen, setIsPopoverOpen] = useState(false); // refs @@ -22,7 +22,7 @@ export const PageInfoPopover: React.FC = (props) => { placement: "bottom-start", }); // derived values - const { created_at, updated_at } = pageStore; + const { created_at, updated_at } = page; return (
setIsPopoverOpen(true)} onMouseLeave={() => setIsPopoverOpen(false)}> diff --git a/web/components/pages/editor/header/mobile-root.tsx b/web/components/pages/editor/header/mobile-root.tsx index de0425879..44cd9d38b 100644 --- a/web/components/pages/editor/header/mobile-root.tsx +++ b/web/components/pages/editor/header/mobile-root.tsx @@ -11,9 +11,8 @@ type Props = { editorRef: React.RefObject; readOnlyEditorRef: React.RefObject; handleDuplicatePage: () => void; - isSyncing: boolean; markings: IMarking[]; - pageStore: IPageStore; + page: IPageStore; projectId: string; sidePeekVisible: boolean; setSidePeekVisible: (sidePeekState: boolean) => void; @@ -29,14 +28,13 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { markings, readOnlyEditorReady, handleDuplicatePage, - isSyncing, - pageStore, + page, projectId, sidePeekVisible, setSidePeekVisible, } = props; // derived values - const { isContentEditable } = pageStore; + const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); @@ -57,8 +55,7 @@ export const PageEditorMobileHeaderRoot: React.FC = observer((props) => { diff --git a/web/components/pages/editor/header/options-dropdown.tsx b/web/components/pages/editor/header/options-dropdown.tsx index 9d3c8627b..9aeb2a679 100644 --- a/web/components/pages/editor/header/options-dropdown.tsx +++ b/web/components/pages/editor/header/options-dropdown.tsx @@ -16,11 +16,11 @@ import { IPageStore } from "@/store/pages/page.store"; type Props = { editorRef: EditorRefApi | EditorReadOnlyRefApi | null; handleDuplicatePage: () => void; - pageStore: IPageStore; + page: IPageStore; }; export const PageOptionsDropdown: React.FC = observer((props) => { - const { editorRef, handleDuplicatePage, pageStore } = props; + const { editorRef, handleDuplicatePage, page } = props; // store values const { archived_at, @@ -33,7 +33,7 @@ export const PageOptionsDropdown: React.FC = observer((props) => { canCurrentUserDuplicatePage, canCurrentUserLockPage, restore, - } = pageStore; + } = page; // store hooks const { workspaceSlug, projectId } = useAppRouter(); // page filters diff --git a/web/components/pages/editor/header/root.tsx b/web/components/pages/editor/header/root.tsx index 7234f3ad4..7f17c43c3 100644 --- a/web/components/pages/editor/header/root.tsx +++ b/web/components/pages/editor/header/root.tsx @@ -13,9 +13,8 @@ type Props = { editorRef: React.RefObject; readOnlyEditorRef: React.RefObject; handleDuplicatePage: () => void; - isSyncing: boolean; markings: IMarking[]; - pageStore: IPageStore; + page: IPageStore; projectId: string; sidePeekVisible: boolean; setSidePeekVisible: (sidePeekState: boolean) => void; @@ -31,14 +30,13 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { markings, readOnlyEditorReady, handleDuplicatePage, - isSyncing, - pageStore, + page, projectId, sidePeekVisible, setSidePeekVisible, } = props; // derived values - const { isContentEditable } = pageStore; + const { isContentEditable } = page; // page filters const { isFullWidth } = usePageFilters(); @@ -67,8 +65,7 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { @@ -81,8 +78,7 @@ export const PageEditorHeaderRoot: React.FC = observer((props) => { readOnlyEditorReady={readOnlyEditorReady} markings={markings} handleDuplicatePage={handleDuplicatePage} - isSyncing={isSyncing} - pageStore={pageStore} + page={page} projectId={projectId} sidePeekVisible={sidePeekVisible} setSidePeekVisible={setSidePeekVisible} diff --git a/web/components/pages/editor/title.tsx b/web/components/pages/editor/title.tsx index f472ecb6d..0c9473690 100644 --- a/web/components/pages/editor/title.tsx +++ b/web/components/pages/editor/title.tsx @@ -33,7 +33,6 @@ export const PageEditorTitle: React.FC = observer((props) => { ) : ( <>