Compare commits

...

5 Commits

Author SHA1 Message Date
Palanikannan M
6c71d7c87b wip: optimization of pages binary data 2024-12-10 18:05:04 +05:30
Palanikannan M
d6f27f7019 Merge branch 'preview' into fix/pages-loading-optimization 2024-12-09 20:34:10 +05:30
Palanikannan M
97ff3832fd Merge branch 'chore/page-description' into fix/pages-loading-optimization 2024-12-09 20:32:30 +05:30
Palanikannan M
d29ab80762 fix: removing readonly editor 2024-12-09 20:30:44 +05:30
NarayanBavisetti
e5ebee664b chore: removed binary decoding 2024-11-27 00:41:34 +05:30
27 changed files with 394 additions and 347 deletions

View File

@@ -39,6 +39,7 @@ from ..base import BaseAPIView, BaseViewSet
from plane.bgtasks.page_transaction_task import page_transaction
from plane.bgtasks.page_version_task import page_version
from plane.bgtasks.recent_visited_task import recent_visited_task
from rest_framework.parsers import MultiPartParser, FormParser
def unarchive_archive_page_and_descendants(page_id, archived_at):
@@ -470,6 +471,8 @@ class SubPagesEndpoint(BaseAPIView):
class PagesDescriptionViewSet(BaseViewSet):
parser_classes = [MultiPartParser]
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
def retrieve(self, request, slug, project_id, pk):
page = (
@@ -526,30 +529,33 @@ class PagesDescriptionViewSet(BaseViewSet):
existing_instance = json.dumps(
{"description_html": page.description_html}, cls=DjangoJSONEncoder
)
print("before the variables")
# Get the base64 data from the request
base64_data = request.data.get("description_binary")
# If base64 data is provided
if base64_data:
# Decode the base64 data to bytes
new_binary_data = base64.b64decode(base64_data)
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
# Store the updated binary data
page.description_binary = new_binary_data
page.description_html = request.data.get("description_html")
page.description = request.data.get("description")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
# capture the page transaction
if request.data.get("description_html"):
page_transaction.delay(
new_value=request.data, old_value=existing_instance, page_id=pk
)
return Response({"message": "Updated successfully"})
else:
if not request.data.get("description_binary"):
return Response({"error": "No binary data provided"})
# Store the updated binary data
page.description_html = request.data.get(
"description_html", page.description_html
)
page.description = request.data.get("description", page.description)
page.description_binary = request.POST.get("description_binary")
page.description_binary = base64.b64decode(
request.POST.get("description_binary")
)
print("before the ssssave")
page.save()
# Return a success response
page_version.delay(
page_id=page.id,
existing_instance=existing_instance,
user_id=request.user.id,
)
return Response({"message": "Updated successfully"})

View File

@@ -1,9 +1,15 @@
import { getSchema } from "@tiptap/core";
import { generateHTML, generateJSON } from "@tiptap/html";
import { prosemirrorJSONToYDoc, yXmlFragmentToProseMirrorRootNode } from "y-prosemirror";
import * as Y from "yjs"
import {
prosemirrorJSONToYDoc,
yXmlFragmentToProseMirrorRootNode,
} from "y-prosemirror";
import * as Y from "yjs";
// plane editor
import { CoreEditorExtensionsWithoutProps, DocumentEditorExtensionsWithoutProps } from "@plane/editor/lib";
import {
CoreEditorExtensionsWithoutProps,
DocumentEditorExtensionsWithoutProps,
} from "@plane/editor/lib";
const DOCUMENT_EDITOR_EXTENSIONS = [
...CoreEditorExtensionsWithoutProps,
@@ -11,7 +17,9 @@ const DOCUMENT_EDITOR_EXTENSIONS = [
];
const documentEditorSchema = getSchema(DOCUMENT_EDITOR_EXTENSIONS);
export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
export const getAllDocumentFormatsFromBinaryData = (
description: Uint8Array,
): {
contentBinaryEncoded: string;
contentJSON: object;
contentHTML: string;
@@ -24,7 +32,7 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
const type = yDoc.getXmlFragment("default");
const contentJSON = yXmlFragmentToProseMirrorRootNode(
type,
documentEditorSchema
documentEditorSchema,
).toJSON();
// convert to HTML
const contentHTML = generateHTML(contentJSON, DOCUMENT_EDITOR_EXTENSIONS);
@@ -34,26 +42,29 @@ export const getAllDocumentFormatsFromBinaryData = (description: Uint8Array): {
contentJSON,
contentHTML,
};
}
};
export const getBinaryDataFromHTMLString = (descriptionHTML: string): {
contentBinary: Uint8Array
export const getBinaryDataFromHTMLString = (
descriptionHTML: string,
): {
contentBinary: Uint8Array;
} => {
// convert HTML to JSON
const contentJSON = generateJSON(
descriptionHTML ?? "<p></p>",
DOCUMENT_EDITOR_EXTENSIONS
DOCUMENT_EDITOR_EXTENSIONS,
);
// convert JSON to Y.Doc format
const transformedData = prosemirrorJSONToYDoc(
documentEditorSchema,
contentJSON,
"default"
"default",
);
// convert Y.Doc to Uint8Array format
const encodedData = Y.encodeStateAsUpdate(transformedData);
return {
contentBinary: encodedData
}
}
contentBinary: encodedData,
};
};

View File

@@ -26,18 +26,41 @@ export const updatePageDescription = async (
const { contentBinaryEncoded, contentHTML, contentJSON } =
getAllDocumentFormatsFromBinaryData(updatedDescription);
try {
const payload = {
description_binary: contentBinaryEncoded,
description_html: contentHTML,
description: contentJSON,
};
// Generate a unique boundary
const boundary = `----FormBoundary${Date.now().toString()}`;
// Construct the multipart form data manually
let formData = "";
// Add binary content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_binary"\r\n';
formData += "Content-Type: application/octet-stream\r\n\r\n";
formData += updatedDescription + "\r\n";
// Add HTML content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description_html"\r\n';
formData += "Content-Type: text/html\r\n\r\n";
formData += contentHTML + "\r\n";
// Add JSON content
formData += `--${boundary}\r\n`;
formData += 'Content-Disposition: form-data; name="description"\r\n';
formData += "Content-Type: application/json\r\n\r\n";
formData += JSON.stringify(contentJSON) + "\r\n";
// End boundary
formData += `--${boundary}--\r\n`;
await pageService.updateDescription(
workspaceSlug,
projectId,
pageId,
payload,
formData,
boundary,
cookie,
);
} catch (error) {
@@ -46,6 +69,8 @@ export const updatePageDescription = async (
}
};
// Update the service method
const fetchDescriptionHTMLAndTransform = async (
workspaceSlug: string,
projectId: string,
@@ -90,7 +115,9 @@ export const fetchPageDescriptionBinary = async (
pageId,
cookie,
);
console.log("response", response);
const binaryData = new Uint8Array(response);
console.log("binaryData", binaryData);
if (binaryData.byteLength === 0) {
const binary = await fetchDescriptionHTMLAndTransform(

View File

@@ -12,7 +12,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<TPage> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/`,
@@ -20,7 +20,7 @@ export class PageService extends APIService {
headers: {
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -32,7 +32,7 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
cookie: string
cookie: string,
): Promise<any> {
return this.get(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
@@ -42,7 +42,7 @@ export class PageService extends APIService {
Cookie: cookie,
},
responseType: "arraybuffer",
}
},
)
.then((response) => response?.data)
.catch((error) => {
@@ -54,21 +54,19 @@ export class PageService extends APIService {
workspaceSlug: string,
projectId: string,
pageId: string,
data: {
description_binary: string;
description_html: string;
description: object;
},
cookie: string
formData: string,
boundary: string,
cookie: string,
): Promise<any> {
return this.patch(
`/api/workspaces/${workspaceSlug}/projects/${projectId}/pages/${pageId}/description/`,
data,
formData,
{
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
Cookie: cookie,
},
}
},
)
.then((response) => response?.data)
.catch((error) => {

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useState } from "react";
// components
import { DocumentContentLoader, PageRenderer } from "@/components/editors";
// constants
@@ -19,6 +19,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
embedHandler,
fileHandler,
@@ -43,23 +44,25 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
}
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
onTransaction,
disabledExtensions,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const { editor, hasServerConnectionFailed, hasServerSynced, localProvider, hasIndexedDbSynced } =
useCollaborativeEditor({
disabledExtensions,
editable,
editorClassName,
embedHandler,
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
id,
mentionHandler,
onTransaction,
placeholder,
realtimeConfig,
serverHandler,
tabIndex,
user,
});
const editorContainerClassNames = getEditorClassNames({
noBorder: true,
@@ -67,9 +70,30 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
containerClassName,
});
const [hasIndexedDbEntry, setHasIndexedDbEntry] = useState(null);
useEffect(() => {
async function documentIndexedDbEntry(dbName: string) {
try {
const databases = await indexedDB.databases();
const hasEntry = databases.some((db) => db.name === dbName);
setHasIndexedDbEntry(hasEntry);
} catch (error) {
console.error("Error checking database existence:", error);
return false;
}
}
documentIndexedDbEntry(id);
}, [id, localProvider]);
if (!editor) return null;
if (!hasServerSynced && !hasServerConnectionFailed) return <DocumentContentLoader />;
// Wait until we know about IndexedDB status
if (hasIndexedDbEntry === null) return null;
if (hasServerConnectionFailed || (!hasIndexedDbEntry && !hasServerSynced) || !hasIndexedDbSynced) {
return <DocumentContentLoader />;
}
return (
<PageRenderer

View File

@@ -129,6 +129,7 @@ export const PageRenderer = (props: IPageRenderer) => {
[editor, cleanup]
);
console.log("rendered");
return (
<>
<div className="frame-renderer flex-grow w-full -mx-5" onMouseOver={handleLinkHover}>
@@ -139,12 +140,12 @@ export const PageRenderer = (props: IPageRenderer) => {
id={id}
>
<EditorContentWrapper editor={editor} id={id} tabIndex={tabIndex} />
{editor.isEditable && (
<>
<BlockMenu editor={editor} />
<AIFeaturesMenu menu={aiHandler?.menu} />
</>
)}
{/* {editor.isEditable && ( */}
{/* <> */}
{/* <BlockMenu editor={editor} /> */}
{/* <AIFeaturesMenu menu={aiHandler?.menu} /> */}
{/* </> */}
{/* )} */}
</EditorContainer>
</div>
{isOpen && linkViewProps && coordinates && (

View File

@@ -21,6 +21,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
containerClassName,
disabledExtensions,
displayConfig = DEFAULT_DISPLAY_CONFIG,
editable,
editorClassName = "",
extensions,
id,
@@ -38,6 +39,7 @@ export const EditorWrapper: React.FC<Props> = (props) => {
} = props;
const editor = useEditor({
editable,
disabledExtensions,
editorClassName,
enableHistory: true,

View File

@@ -127,24 +127,30 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
return "Uploading...";
}
if (draggedInside) {
if (draggedInside && editor.isEditable) {
return "Drop image here";
}
return "Add an image";
if (!editor.isEditable) {
return "Viewing Mode: Image Upload Disabled";
} else {
return "Add an image";
}
}, [draggedInside, failedToLoadImage, isImageBeingUploaded]);
return (
<div
className={cn(
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 hover:text-custom-text-200 bg-custom-background-90 hover:bg-custom-background-80 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
"image-upload-component flex items-center justify-start gap-2 py-3 px-2 rounded-lg text-custom-text-300 bg-custom-background-90 border border-dashed border-custom-border-300 transition-all duration-200 ease-in-out cursor-default",
{
"hover:text-custom-text-200 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside,
"text-custom-primary-200 bg-custom-primary-100/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200 border-custom-primary-200/10":
selected,
"text-red-500 cursor-default hover:text-red-500": failedToLoadImage,
"bg-red-500/10 hover:bg-red-500/10": failedToLoadImage && selected,
"hover:text-custom-text-200 hover:bg-custom-background-80 cursor-pointer": editor.isEditable,
"bg-custom-background-80 text-custom-text-200": draggedInside && editor.isEditable,
"text-custom-primary-200 bg-custom-primary-100/10 border-custom-primary-200/10 hover:bg-custom-primary-100/10 hover:text-custom-primary-200":
selected && editor.isEditable,
"text-red-500 cursor-default": failedToLoadImage,
"hover:text-red-500": failedToLoadImage && editor.isEditable,
"bg-red-500/10": failedToLoadImage && selected,
"hover:bg-red-500/10": failedToLoadImage && selected && editor.isEditable,
}
)}
onDrop={onDrop}

View File

@@ -2,58 +2,67 @@ import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export const DropHandlerExtension = () =>
Extension.create({
name: "dropHandler",
priority: 1000,
export const DropHandlerExtension = Extension.create({
name: "dropHandler",
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (event.clipboardData && event.clipboardData.files && event.clipboardData.files.length > 0) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (
editor.isEditable &&
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (
editor.isEditable &&
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
}
return false;
},
}
return false;
},
}),
];
},
});
},
}),
];
},
});
export const insertImagesSafely = async ({
editor,
files,

View File

@@ -47,10 +47,11 @@ type TArguments = {
};
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
editable?: boolean;
};
export const CoreEditorExtensions = (args: TArguments): Extensions => {
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
const { disabledExtensions, enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, editable } = args;
return [
StarterKit.configure({
@@ -89,7 +90,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
...(enableHistory ? {} : { history: false }),
}),
CustomQuoteExtension,
DropHandlerExtension(),
DropHandlerExtension,
CustomHorizontalRule.configure({
HTMLAttributes: {
class: "py-4 border-custom-border-400",
@@ -145,9 +146,9 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
TableCell,
TableRow,
CustomMention({
mentionSuggestions: mentionConfig.mentionSuggestions,
mentionSuggestions: editable ? mentionConfig.mentionSuggestions : undefined,
mentionHighlights: mentionConfig.mentionHighlights,
readonly: false,
readonly: !editable,
}),
Placeholder.configure({
placeholder: ({ editor, node }) => {

View File

@@ -15,6 +15,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const {
onTransaction,
disabledExtensions,
editable,
editorClassName,
editorProps = {},
embedHandler,
@@ -33,6 +34,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
// states
const [hasServerConnectionFailed, setHasServerConnectionFailed] = useState(false);
const [hasServerSynced, setHasServerSynced] = useState(false);
const [hasIndexedDbSynced, setHasIndexedDbSynced] = useState(false);
// initialize Hocuspocus provider
const provider = useMemo(
() =>
@@ -53,7 +55,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
setHasServerConnectionFailed(true);
}
},
onSynced: () => setHasServerSynced(true),
onSynced: () => {
serverHandler?.onServerSync?.();
setHasServerSynced(true);
},
}),
[id, realtimeConfig, serverHandler, user]
);
@@ -63,6 +68,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
[id, provider]
);
localProvider?.on("synced", () => {
setHasIndexedDbSynced(true);
});
// destroy and disconnect all providers connection on unmount
useEffect(
() => () => {
@@ -75,7 +84,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const editor = useEditor({
disabledExtensions,
id,
onTransaction,
editable,
editorProps,
editorClassName,
enableHistory: false,
@@ -97,9 +106,10 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
}),
],
fileHandler,
handleEditorReady,
forwardedRef,
handleEditorReady,
mentionHandler,
onTransaction,
placeholder,
provider,
tabIndex,
@@ -109,5 +119,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
editor,
hasServerConnectionFailed,
hasServerSynced,
hasIndexedDbSynced,
localProvider,
};
};

View File

@@ -27,6 +27,7 @@ import type {
} from "@/types";
export interface CustomEditorProps {
editable: boolean;
editorClassName: string;
editorProps?: EditorProps;
enableHistory: boolean;
@@ -55,6 +56,7 @@ export interface CustomEditorProps {
export const useEditor = (props: CustomEditorProps) => {
const {
disabledExtensions,
editable,
editorClassName,
editorProps = {},
enableHistory,
@@ -74,42 +76,46 @@ export const useEditor = (props: CustomEditorProps) => {
autofocus = false,
} = props;
// states
const [savedSelection, setSavedSelection] = useState<Selection | null>(null);
// refs
const editorRef: MutableRefObject<Editor | null> = useRef(null);
const savedSelectionRef = useRef(savedSelection);
const editor = useTiptapEditor({
autofocus,
editorProps: {
...CoreEditorProps({
editorClassName,
}),
...editorProps,
const editor = useTiptapEditor(
{
editable,
autofocus,
editorProps: {
...CoreEditorProps({
editorClassName,
}),
...editorProps,
},
extensions: [
...CoreEditorExtensions({
editable,
disabledExtensions,
enableHistory,
fileHandler,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights,
},
placeholder,
tabIndex,
}),
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
},
extensions: [
...CoreEditorExtensions({
disabledExtensions,
enableHistory,
fileHandler,
mentionConfig: {
mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve<IMentionSuggestion[]>([])),
mentionHighlights: mentionHandler.highlights,
},
placeholder,
tabIndex,
}),
...extensions,
],
content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "<p></p>",
onCreate: () => handleEditorReady?.(true),
onTransaction: ({ editor }) => {
setSavedSelection(editor.state.selection);
onTransaction?.();
},
onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
onDestroy: () => handleEditorReady?.(false),
});
[editable]
);
// Update the ref whenever savedSelection changes
useEffect(() => {

View File

@@ -105,7 +105,7 @@ export const useDropZone = (args: TDropzoneArgs) => {
async (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDraggedInside(false);
if (e.dataTransfer.files.length === 0) {
if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
return;
}
const filesList = e.dataTransfer.files;

View File

@@ -17,10 +17,12 @@ import {
export type TServerHandler = {
onConnect?: () => void;
onServerError?: () => void;
onServerSync?: () => void;
};
type TCollaborativeEditorHookProps = {
disabledExtensions: TExtensions[];
editable?: boolean;
editorClassName: string;
editorProps?: EditorProps;
extensions?: Extensions;

View File

@@ -106,6 +106,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
// editor props
export interface IEditorProps {
editable: boolean;
containerClassName?: string;
displayConfig?: TDisplayConfig;
disabledExtensions: TExtensions[];

View File

@@ -0,0 +1,18 @@
import { RefreshCcw } from "lucide-react";
import { Tooltip } from "@plane/ui";
export const SyncingComponent = (props: { toolTipContent?: string }) => {
const { toolTipContent } = props;
const lockedComponent = (
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-custom-background-80 px-3 py-0.5 text-xs font-medium text-custom-text-300">
<RefreshCcw className="h-3 w-3" />
<span>Syncing</span>
</div>
);
return (
<>
{toolTipContent ? <Tooltip tooltipContent={toolTipContent}>{lockedComponent}</Tooltip> : <>{lockedComponent}</>}
</>
);
};

View File

@@ -1,11 +1,9 @@
import { useCallback, useMemo } from "react";
import { Dispatch, SetStateAction, useCallback, useMemo } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// document-editor
import {
CollaborativeDocumentEditorWithRef,
CollaborativeDocumentReadOnlyEditorWithRef,
EditorReadOnlyRefApi,
EditorRefApi,
TAIMenuProps,
TDisplayConfig,
@@ -20,7 +18,7 @@ import { Row } from "@plane/ui";
import { PageContentBrowser, PageContentLoader, PageEditorTitle } from "@/components/pages";
// helpers
import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
import { getEditorFileHandlers, getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { generateRandomColor } from "@/helpers/string.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
@@ -42,24 +40,15 @@ const fileService = new FileService();
type Props = {
editorRef: React.RefObject<EditorRefApi>;
editorReady: boolean;
handleConnectionStatus: (status: boolean) => void;
handleEditorReady: (value: boolean) => void;
handleReadOnlyEditorReady: (value: boolean) => void;
handleConnectionStatus: Dispatch<SetStateAction<boolean>>;
handleEditorReady: Dispatch<SetStateAction<boolean>>;
page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
sidePeekVisible: boolean;
setSyncing: (value: boolean) => void;
};
export const PageEditorBody: React.FC<Props> = observer((props) => {
const {
editorRef,
handleConnectionStatus,
handleEditorReady,
handleReadOnlyEditorReady,
page,
readOnlyEditorRef,
sidePeekVisible,
} = props;
const { editorRef, handleConnectionStatus, handleEditorReady, page, sidePeekVisible, setSyncing } = props;
// router
const { workspaceSlug, projectId } = useParams();
// store hooks
@@ -118,12 +107,17 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
handleConnectionStatus(true);
}, []);
const handleServerSynced = useCallback(() => {
setSyncing(true);
}, []);
const serverHandler: TServerHandler = useMemo(
() => ({
onConnect: handleServerConnect,
onServerError: handleServerError,
onServerSync: handleServerSynced,
}),
[handleServerConnect, handleServerError]
[handleServerConnect, handleServerError, handleServerSynced]
);
const realtimeConfig: TRealtimeConfig | undefined = useMemo(() => {
@@ -169,9 +163,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
"w-[5%]": isFullWidth,
})}
>
{!isFullWidth && (
<PageContentBrowser editorRef={(isContentEditable ? editorRef : readOnlyEditorRef)?.current} />
)}
{!isFullWidth && <PageContentBrowser editorRef={editorRef.current} />}
</Row>
<div
className={cn("h-full w-full pt-5 duration-200", {
@@ -188,72 +180,48 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
readOnly={!isContentEditable}
/>
</div>
{isContentEditable ? (
<CollaborativeDocumentEditorWithRef
id={pageId}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId: projectId?.toString() ?? "",
uploadFile: async (file) => {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
{
entity_identifier: pageId,
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file
);
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
handleEditorReady={handleEditorReady}
ref={editorRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
embedHandler={{
issue: issueEmbedProps,
}}
realtimeConfig={realtimeConfig}
serverHandler={serverHandler}
user={userConfig}
disabledExtensions={disabledExtensions}
aiHandler={{
menu: getAIMenu,
}}
/>
) : (
<CollaborativeDocumentReadOnlyEditorWithRef
id={pageId}
ref={readOnlyEditorRef}
disabledExtensions={disabledExtensions}
fileHandler={getReadOnlyEditorFileHandlers({
projectId: projectId?.toString() ?? "",
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
handleEditorReady={handleReadOnlyEditorReady}
containerClassName="p-0 pb-64 border-none"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
}}
embedHandler={{
issue: {
widgetCallback: issueEmbedProps.widgetCallback,
},
}}
realtimeConfig={realtimeConfig}
user={userConfig}
/>
)}
<CollaborativeDocumentEditorWithRef
editable={isContentEditable}
id={pageId}
fileHandler={getEditorFileHandlers({
maxFileSize,
projectId: projectId?.toString() ?? "",
uploadFile: async (file) => {
const { asset_id } = await fileService.uploadProjectAsset(
workspaceSlug?.toString() ?? "",
projectId?.toString() ?? "",
{
entity_identifier: pageId,
entity_type: EFileAssetType.PAGE_DESCRIPTION,
},
file
);
return asset_id;
},
workspaceId,
workspaceSlug: workspaceSlug?.toString() ?? "",
})}
handleEditorReady={handleEditorReady}
ref={editorRef}
containerClassName="h-full p-0 pb-64"
displayConfig={displayConfig}
editorClassName="pl-10"
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
}}
embedHandler={{
issue: issueEmbedProps,
}}
realtimeConfig={realtimeConfig}
serverHandler={serverHandler}
user={userConfig}
disabledExtensions={disabledExtensions}
aiHandler={{
menu: getAIMenu,
}}
/>
)
</div>
</div>
<div

View File

@@ -2,11 +2,12 @@
import { observer } from "mobx-react";
// editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, FavoriteStar, setToast, TOAST_TYPE, Tooltip } from "@plane/ui";
// components
import { LockedComponent } from "@/components/icons/locked-component";
import { SyncingComponent } from "@/components/icons/syncing-component";
import { PageInfoPopover, PageOptionsDropdown } from "@/components/pages";
// helpers
import { renderFormattedDate } from "@/helpers/date-time.helper";
@@ -19,11 +20,11 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
syncState: boolean | null;
};
export const PageExtraOptions: React.FC<Props> = observer((props) => {
const { editorRef, handleDuplicatePage, page, readOnlyEditorRef } = props;
const { editorRef, syncState, handleDuplicatePage, page } = props;
// derived values
const {
archived_at,
@@ -60,6 +61,7 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
return (
<div className="flex items-center justify-end gap-3">
{is_locked && <LockedComponent />}
{!syncState && <SyncingComponent />}
{archived_at && (
<div className="flex-shrink-0 flex h-7 items-center gap-2 rounded-full bg-blue-500/20 px-3 py-0.5 text-xs font-medium text-blue-500">
<ArchiveIcon className="flex-shrink-0 size-3" />
@@ -85,12 +87,8 @@ export const PageExtraOptions: React.FC<Props> = observer((props) => {
iconClassName="text-custom-text-100"
/>
)}
<PageInfoPopover editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current} />
<PageOptionsDropdown
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
handleDuplicatePage={handleDuplicatePage}
page={page}
/>
<PageInfoPopover editorRef={editorRef.current} />
<PageOptionsDropdown editorRef={editorRef.current} handleDuplicatePage={handleDuplicatePage} page={page} />
</div>
);
});

View File

@@ -2,12 +2,12 @@ import { useState } from "react";
import { usePopper } from "react-popper";
import { Info } from "lucide-react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// helpers
import { getReadTimeFromWordsCount } from "@/helpers/date-time.helper";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
};
export const PageInfoPopover: React.FC<Props> = (props) => {

View File

@@ -13,52 +13,34 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
};
export const PageEditorMobileHeaderRoot: React.FC<Props> = observer((props) => {
const {
editorReady,
editorRef,
handleDuplicatePage,
page,
readOnlyEditorReady,
readOnlyEditorRef,
setSidePeekVisible,
sidePeekVisible,
} = props;
const { editorReady, editorRef, handleDuplicatePage, page, setSidePeekVisible, sidePeekVisible } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
if (!editorRef.current && !readOnlyEditorRef.current) return null;
if (!editorRef.current) return null;
return (
<>
<Header variant={EHeaderVariant.SECONDARY}>
<div className="flex-shrink-0 my-auto">
<PageSummaryPopover
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
editorRef={editorRef.current}
isFullWidth={isFullWidth}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
/>
<PageExtraOptions editorRef={editorRef} handleDuplicatePage={handleDuplicatePage} page={page} />
</Header>
<Header variant={EHeaderVariant.TERNARY}>
{(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && (
<PageToolbar editorRef={editorRef?.current} />
)}
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
</Header>
</>
);

View File

@@ -15,7 +15,7 @@ import {
LucideIcon,
} from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// ui
import { ArchiveIcon, CustomMenu, type ISvgIcons, TOAST_TYPE, ToggleSwitch, setToast } from "@plane/ui";
// components
@@ -30,7 +30,7 @@ import { useQueryParams } from "@/hooks/use-query-params";
import { IPage } from "@/store/pages/page";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
handleDuplicatePage: () => void;
page: IPage;
};

View File

@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// components
import { Header, EHeaderVariant } from "@plane/ui";
import { PageEditorMobileHeaderRoot, PageExtraOptions, PageSummaryPopover, PageToolbar } from "@/components/pages";
@@ -15,35 +15,25 @@ type Props = {
editorRef: React.RefObject<EditorRefApi>;
handleDuplicatePage: () => void;
page: IPage;
readOnlyEditorReady: boolean;
readOnlyEditorRef: React.RefObject<EditorReadOnlyRefApi>;
setSidePeekVisible: (sidePeekState: boolean) => void;
sidePeekVisible: boolean;
syncState: boolean | null;
};
export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
const {
editorReady,
editorRef,
handleDuplicatePage,
page,
readOnlyEditorReady,
readOnlyEditorRef,
setSidePeekVisible,
sidePeekVisible,
} = props;
const { editorReady, editorRef, setSidePeekVisible, sidePeekVisible, handleDuplicatePage, page, syncState } = props;
// derived values
const { isContentEditable } = page;
// page filters
const { isFullWidth } = usePageFilters();
if (!editorRef.current && !readOnlyEditorRef.current) return null;
if (!editorRef.current) return null;
return (
<>
<Header variant={EHeaderVariant.SECONDARY} showOnMobile={false}>
<Header.LeftItem className="gap-0 w-full">
{(editorReady || readOnlyEditorReady) && (
{editorReady && (
<div
className={cn("flex-shrink-0 my-auto", {
"w-40 lg:w-56": !isFullWidth,
@@ -51,30 +41,26 @@ export const PageEditorHeaderRoot: React.FC<Props> = observer((props) => {
})}
>
<PageSummaryPopover
editorRef={isContentEditable ? editorRef.current : readOnlyEditorRef.current}
editorRef={editorRef.current}
isFullWidth={isFullWidth}
sidePeekVisible={sidePeekVisible}
setSidePeekVisible={setSidePeekVisible}
/>
</div>
)}
{(editorReady || readOnlyEditorReady) && isContentEditable && editorRef.current && (
<PageToolbar editorRef={editorRef?.current} />
)}
{editorReady && isContentEditable && editorRef.current && <PageToolbar editorRef={editorRef?.current} />}
</Header.LeftItem>
<PageExtraOptions
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
syncState={syncState}
/>
</Header>
<div className="md:hidden">
<PageEditorMobileHeaderRoot
editorRef={editorRef}
readOnlyEditorRef={readOnlyEditorRef}
editorReady={editorReady}
readOnlyEditorReady={readOnlyEditorReady}
handleDuplicatePage={handleDuplicatePage}
page={page}
sidePeekVisible={sidePeekVisible}

View File

@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
// editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// types
import { TPage } from "@plane/types";
// ui
@@ -32,12 +32,11 @@ export const PageRoot = observer((props: TPageRootProps) => {
// states
const [editorReady, setEditorReady] = useState(false);
const [hasConnectionFailed, setHasConnectionFailed] = useState(false);
const [readOnlyEditorReady, setReadOnlyEditorReady] = useState(false);
const [sidePeekVisible, setSidePeekVisible] = useState(window.innerWidth >= 768);
const [syncState, setSyncing] = useState<boolean | null>(null);
const [isVersionsOverlayOpen, setIsVersionsOverlayOpen] = useState(false);
// refs
const editorRef = useRef<EditorRefApi>(null);
const readOnlyEditorRef = useRef<EditorReadOnlyRefApi>(null);
// router
const router = useAppRouter();
// search params
@@ -99,9 +98,7 @@ export const PageRoot = observer((props: TPageRootProps) => {
editorRef.current?.clearEditor();
editorRef.current?.setEditorValue(descriptionHTML);
};
const currentVersionDescription = isContentEditable
? editorRef.current?.getDocument().html
: readOnlyEditorRef.current?.getDocument().html;
const currentVersionDescription = editorRef.current?.getDocument().html;
return (
<>
@@ -137,20 +134,18 @@ export const PageRoot = observer((props: TPageRootProps) => {
editorRef={editorRef}
handleDuplicatePage={handleDuplicatePage}
page={page}
readOnlyEditorReady={readOnlyEditorReady}
readOnlyEditorRef={readOnlyEditorRef}
setSidePeekVisible={(state) => setSidePeekVisible(state)}
sidePeekVisible={sidePeekVisible}
syncState={syncState}
/>
<PageEditorBody
editorReady={editorReady}
editorRef={editorRef}
handleConnectionStatus={(status) => setHasConnectionFailed(status)}
handleEditorReady={(val) => setEditorReady(val)}
handleReadOnlyEditorReady={() => setReadOnlyEditorReady(true)}
handleConnectionStatus={setHasConnectionFailed}
handleEditorReady={setEditorReady}
page={page}
readOnlyEditorRef={readOnlyEditorRef}
sidePeekVisible={sidePeekVisible}
setSyncing={setSyncing}
/>
</>
);

View File

@@ -1,11 +1,11 @@
import { useState, useEffect } from "react";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi, IMarking } from "@plane/editor";
import { EditorRefApi, IMarking } from "@plane/editor";
// components
import { OutlineHeading1, OutlineHeading2, OutlineHeading3 } from "./heading-components";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
setSidePeekVisible?: (sidePeekState: boolean) => void;
};

View File

@@ -2,14 +2,14 @@ import { useState } from "react";
import { usePopper } from "react-popper";
import { List } from "lucide-react";
// document editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// helpers
import { cn } from "@/helpers/common.helper";
// components
import { PageContentBrowser } from "./content-browser";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
isFullWidth: boolean;
sidePeekVisible: boolean;
setSidePeekVisible: (sidePeekState: boolean) => void;

View File

@@ -4,7 +4,7 @@ import { useState } from "react";
import { PageProps, pdf } from "@react-pdf/renderer";
import { Controller, useForm } from "react-hook-form";
// plane editor
import { EditorReadOnlyRefApi, EditorRefApi } from "@plane/editor";
import { EditorRefApi } from "@plane/editor";
// plane ui
import { Button, CustomSelect, EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui";
// components
@@ -16,7 +16,7 @@ import {
} from "@/helpers/editor.helper";
type Props = {
editorRef: EditorRefApi | EditorReadOnlyRefApi | null;
editorRef: EditorRefApi | null;
isOpen: boolean;
onClose: () => void;
pageTitle: string;

View File

@@ -50,6 +50,10 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
try {
await actionDetails.execute(isPerformedByCurrentUser);
if (isPerformedByCurrentUser) {
const serverEventName = getServerEventName(clientAction);
if (serverEventName) {
editorRef?.emitRealTimeUpdate(serverEventName);
}
setCurrentActionBeingProcessed(clientAction);
}
} catch {
@@ -60,18 +64,9 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
});
}
},
[actionHandlerMap]
[actionHandlerMap, editorRef]
);
useEffect(() => {
if (currentActionBeingProcessed) {
const serverEventName = getServerEventName(currentActionBeingProcessed);
if (serverEventName) {
editorRef?.emitRealTimeUpdate(serverEventName);
}
}
}, [currentActionBeingProcessed, editorRef]);
useEffect(() => {
const realTimeStatelessMessageListener = editorRef?.listenToRealTimeUpdate();
@@ -95,6 +90,5 @@ export const useCollaborativePageActions = (editorRef: EditorRefApi | EditorRead
return {
executeCollaborativeAction,
EVENT_ACTION_DETAILS_MAP: actionHandlerMap,
};
};