From ff03c0b718aef82b8467729ac7207f34ca741b16 Mon Sep 17 00:00:00 2001
From: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com>
Date: Sun, 26 May 2024 16:37:10 +0530
Subject: [PATCH] [WEB-1322] dev: conflict free pages collaboration (#4463)
* chore: pages realtime
* chore: empty binary response
* chore: added a ypy package
* feat: pages collaboration
* chore: update fetching logic
* chore: degrade ypy version
* chore: replace useEffect fetch logic with useSWR
* chore: move all the update logic to the page store
* refactor: remove react-hook-form
* chore: save description_html as well
* chore: migrate old data logic
* fix: added description_binary as field name
* fix: code cleanup
* refactor: create separate hook to handle page description
* fix: build errors
* chore: combine updates instead of using the whole document
* chore: removed ypy package
* chore: added conflict resolving logic to the client side
* chore: add a save changes button
* chore: add read-only validation
* chore: remove saving state information
* chore: added permission class
* chore: removed the migration file
* chore: corrected the model field
* chore: rename pageStore to page
* chore: update collaboration provider
* chore: add try catch to handle error
---------
Co-authored-by: NarayanBavisetti
---
apiserver/plane/app/serializers/page.py | 4 +-
apiserver/plane/app/urls/page.py | 11 ++
apiserver/plane/app/views/__init__.py | 1 +
apiserver/plane/app/views/page/base.py | 47 ++++++
apiserver/plane/db/models/page.py | 2 +-
packages/editor/core/src/hooks/use-editor.tsx | 41 ++---
.../core/src/hooks/use-read-only-editor.tsx | 4 +
packages/editor/core/src/index.ts | 1 +
packages/editor/core/src/lib/utils.ts | 19 +++
.../editor/core/src/types/editor-ref-api.ts | 1 +
.../src/ui/extensions/core-without-props.tsx | 121 ++++++++++++++
.../image/image-extension-without-props.tsx | 33 ++++
.../src/ui/mentions/mention-without-props.tsx | 79 +++++++++
packages/editor/document-editor/package.json | 7 +-
.../src/hooks/use-document-editor.ts | 85 ++++++++++
packages/editor/document-editor/src/index.ts | 2 +
.../src/providers/collaboration-provider.ts | 60 +++++++
.../src/ui/extensions/index.tsx | 8 +-
.../editor/document-editor/src/ui/index.tsx | 47 ++----
.../editor/document-editor/src/utils/yjs.ts | 76 +++++++++
.../editor/lite-text-editor/src/ui/index.tsx | 18 +--
.../editor/rich-text-editor/src/ui/index.tsx | 21 +--
web/components/headers/page-details.tsx | 48 +++---
web/components/pages/editor/editor-body.tsx | 88 +++++-----
.../pages/editor/header/extra-options.tsx | 30 +---
.../pages/editor/header/info-popover.tsx | 6 +-
.../pages/editor/header/mobile-root.tsx | 11 +-
.../pages/editor/header/options-dropdown.tsx | 6 +-
web/components/pages/editor/header/root.tsx | 14 +-
web/components/pages/editor/title.tsx | 2 +-
web/components/pages/index.ts | 1 -
.../pages/loaders/page-content-loader.tsx | 125 +++++++++++---
.../pages/modals/delete-page-modal.tsx | 10 +-
web/components/pages/page-detail/index.ts | 3 -
web/components/pages/page-detail/loader.tsx | 118 --------------
web/components/pages/page-detail/root.tsx | 54 -------
web/hooks/use-page-description.ts | 153 ++++++++++++++++++
.../projects/[projectId]/pages/[pageId].tsx | 73 +++------
web/services/api.service.ts | 7 +-
web/services/page.service.ts | 29 ++++
web/store/pages/page.store.ts | 125 +++++++-------
yarn.lock | 52 ++++++
42 files changed, 1134 insertions(+), 509 deletions(-)
create mode 100644 packages/editor/core/src/ui/extensions/core-without-props.tsx
create mode 100644 packages/editor/core/src/ui/extensions/image/image-extension-without-props.tsx
create mode 100644 packages/editor/core/src/ui/mentions/mention-without-props.tsx
create mode 100644 packages/editor/document-editor/src/hooks/use-document-editor.ts
create mode 100644 packages/editor/document-editor/src/providers/collaboration-provider.ts
create mode 100644 packages/editor/document-editor/src/utils/yjs.ts
delete mode 100644 web/components/pages/page-detail/index.ts
delete mode 100644 web/components/pages/page-detail/loader.tsx
delete mode 100644 web/components/pages/page-detail/root.tsx
create mode 100644 web/hooks/use-page-description.ts
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) => {
) : (
<>