- 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;
- insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
- }
- return true;
+ if (imageFiles.length > 0) {
+ const pos = view.state.selection.from;
+ insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
- 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"));
+ 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 (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;
+ 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,
diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx
index 0e06f774bb7..4f166ea2a8e 100644
--- a/packages/editor/src/core/extensions/extensions.tsx
+++ b/packages/editor/src/core/extensions/extensions.tsx
@@ -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 }) => {
diff --git a/packages/editor/src/core/hooks/use-collaborative-editor.ts b/packages/editor/src/core/hooks/use-collaborative-editor.ts
index b3c7d6cfc2e..c6506dd127e 100644
--- a/packages/editor/src/core/hooks/use-collaborative-editor.ts
+++ b/packages/editor/src/core/hooks/use-collaborative-editor.ts
@@ -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,
};
};
diff --git a/packages/editor/src/core/hooks/use-editor.ts b/packages/editor/src/core/hooks/use-editor.ts
index 15fbd19d5c8..05e48e93acf 100644
--- a/packages/editor/src/core/hooks/use-editor.ts
+++ b/packages/editor/src/core/hooks/use-editor.ts
@@ -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
(null);
// refs
const editorRef: MutableRefObject = useRef(null);
const savedSelectionRef = useRef(savedSelection);
- const editor = useTiptapEditor({
- autofocus,
- editorProps: {
- ...CoreEditorProps({
- editorClassName,
- }),
- ...editorProps,
- },
- extensions: [
- ...CoreEditorExtensions({
- disabledExtensions,
- enableHistory,
- fileHandler,
- mentionConfig: {
- mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])),
- mentionHighlights: mentionHandler.highlights,
- },
- placeholder,
- tabIndex,
- }),
- ...extensions,
- ],
- content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "",
- onCreate: () => handleEditorReady?.(true),
- onTransaction: ({ editor }) => {
- setSavedSelection(editor.state.selection);
- onTransaction?.();
+ const editor = useTiptapEditor(
+ {
+ editable,
+ autofocus,
+ editorProps: {
+ ...CoreEditorProps({
+ editorClassName,
+ }),
+ ...editorProps,
+ },
+ extensions: [
+ ...CoreEditorExtensions({
+ editable,
+ disabledExtensions,
+ enableHistory,
+ fileHandler,
+ mentionConfig: {
+ mentionSuggestions: mentionHandler.suggestions ?? (() => Promise.resolve([])),
+ mentionHighlights: mentionHandler.highlights,
+ },
+ placeholder,
+ tabIndex,
+ }),
+ ...extensions,
+ ],
+ content: typeof initialValue === "string" && initialValue.trim() !== "" ? initialValue : "",
+ onCreate: () => handleEditorReady?.(true),
+ onTransaction: ({ editor }) => {
+ setSavedSelection(editor.state.selection);
+ onTransaction?.();
+ },
+ onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
+ onDestroy: () => handleEditorReady?.(false),
},
- onUpdate: ({ editor }) => onChange?.(editor.getJSON(), editor.getHTML()),
- onDestroy: () => handleEditorReady?.(false),
- });
+ [editable]
+ );
// Update the ref whenever savedSelection changes
useEffect(() => {
diff --git a/packages/editor/src/core/hooks/use-file-upload.ts b/packages/editor/src/core/hooks/use-file-upload.ts
index f5f930f2900..65daa2f8e49 100644
--- a/packages/editor/src/core/hooks/use-file-upload.ts
+++ b/packages/editor/src/core/hooks/use-file-upload.ts
@@ -105,7 +105,7 @@ export const useDropZone = (args: TDropzoneArgs) => {
async (e: DragEvent) => {
e.preventDefault();
setDraggedInside(false);
- if (e.dataTransfer.files.length === 0) {
+ if (e.dataTransfer.files.length === 0 || !editor.isEditable) {
return;
}
const filesList = e.dataTransfer.files;
diff --git a/packages/editor/src/core/types/collaboration.ts b/packages/editor/src/core/types/collaboration.ts
index 35fbdb99680..0c0079b00b3 100644
--- a/packages/editor/src/core/types/collaboration.ts
+++ b/packages/editor/src/core/types/collaboration.ts
@@ -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;
diff --git a/packages/editor/src/core/types/editor.ts b/packages/editor/src/core/types/editor.ts
index e91af8e4923..25d22e439d1 100644
--- a/packages/editor/src/core/types/editor.ts
+++ b/packages/editor/src/core/types/editor.ts
@@ -106,6 +106,7 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
// editor props
export interface IEditorProps {
+ editable: boolean;
containerClassName?: string;
displayConfig?: TDisplayConfig;
disabledExtensions: TExtensions[];
diff --git a/web/core/components/icons/syncing-component.tsx b/web/core/components/icons/syncing-component.tsx
new file mode 100644
index 00000000000..1397c9cf23c
--- /dev/null
+++ b/web/core/components/icons/syncing-component.tsx
@@ -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 = (
+
+
+ Syncing
+
+ );
+
+ return (
+ <>
+ {toolTipContent ? {lockedComponent} : <>{lockedComponent}>}
+ >
+ );
+};
diff --git a/web/core/components/pages/editor/editor-body.tsx b/web/core/components/pages/editor/editor-body.tsx
index 6f88445ede9..7b729477075 100644
--- a/web/core/components/pages/editor/editor-body.tsx
+++ b/web/core/components/pages/editor/editor-body.tsx
@@ -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;
editorReady: boolean;
- handleConnectionStatus: (status: boolean) => void;
- handleEditorReady: (value: boolean) => void;
- handleReadOnlyEditorReady: (value: boolean) => void;
+ handleConnectionStatus: Dispatch>;
+ handleEditorReady: Dispatch>;
page: IPage;
- readOnlyEditorRef: React.RefObject;
sidePeekVisible: boolean;
+ setSyncing: (value: boolean) => void;
};
export const PageEditorBody: React.FC = 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 = 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 = observer((props) => {
"w-[5%]": isFullWidth,
})}
>
- {!isFullWidth && (
-
- )}
+ {!isFullWidth && }
= observer((props) => {
readOnly={!isContentEditable}
/>
- {isContentEditable ? (
- {
- 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,
- }}
- />
- ) : (
-
- )}
+ {
+ 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,
+ }}
+ />
+ )