[WEB-1727] refactor: pages editor sync logic solidified (#4926)

* feat: pages editor sync logic solidified

* chore: added validation for archive and lock in a page

* feat: pages editor sync logic solidified

* fix: updated the auto save hook to run every 10s instead of 10s after the user stops typing!!

* chore: custom status code for pages

* fix: forceSync in case of auto save

* fix: modifying a locked and archived page shows a toast for now!

* fix: build errors and better error messages

* chore: page root moved

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
M. Palanikannan 2024-06-25 18:58:57 +05:30 committed by GitHub
parent c919435598
commit 99184371f7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 444 additions and 201 deletions

View file

@ -1,3 +1,4 @@
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
export interface CompleteCollaboratorProviderConfiguration {
@ -12,7 +13,7 @@ export interface CompleteCollaboratorProviderConfiguration {
/**
* onChange callback
*/
onChange: (updates: Uint8Array) => void;
onChange: (updates: Uint8Array, source?: string) => void;
/**
* Whether connection to the database has been established and all available content has been loaded or not.
*/
@ -25,20 +26,28 @@ export type CollaborationProviderConfiguration = Required<Pick<CompleteCollabora
export class CollaborationProvider {
public configuration: CompleteCollaboratorProviderConfiguration = {
name: "",
// @ts-expect-error cannot be undefined
document: undefined,
document: new Y.Doc(),
onChange: () => {},
hasIndexedDBSynced: false,
};
unsyncedChanges = 0;
private initialSync = false;
constructor(configuration: CollaborationProviderConfiguration) {
this.setConfiguration(configuration);
this.configuration.document = configuration.document ?? new Y.Doc();
this.indexeddbProvider = new IndexeddbPersistence(`page-${this.configuration.name}`, this.document);
this.indexeddbProvider.on("synced", () => {
this.configuration.hasIndexedDBSynced = true;
});
this.document.on("update", this.documentUpdateHandler.bind(this));
this.document.on("destroy", this.documentDestroyHandler.bind(this));
}
private indexeddbProvider: IndexeddbPersistence;
public setConfiguration(configuration: Partial<CompleteCollaboratorProviderConfiguration> = {}): void {
this.configuration = {
...this.configuration,
@ -50,17 +59,49 @@ export class CollaborationProvider {
return this.configuration.document;
}
setHasIndexedDBSynced(hasIndexedDBSynced: boolean) {
this.configuration.hasIndexedDBSynced = hasIndexedDBSynced;
public hasUnsyncedChanges(): boolean {
return this.unsyncedChanges > 0;
}
documentUpdateHandler(update: Uint8Array, origin: any) {
if (!this.configuration.hasIndexedDBSynced) return;
private resetUnsyncedChanges() {
this.unsyncedChanges = 0;
}
private incrementUnsyncedChanges() {
this.unsyncedChanges += 1;
}
public setSynced() {
this.resetUnsyncedChanges();
}
public async hasIndexedDBSynced() {
await this.indexeddbProvider.whenSynced;
return this.configuration.hasIndexedDBSynced;
}
async documentUpdateHandler(_update: Uint8Array, origin: any) {
await this.indexeddbProvider.whenSynced;
// return if the update is from the provider itself
if (origin === this) return;
// call onChange with the update
this.configuration.onChange?.(update);
const stateVector = Y.encodeStateAsUpdate(this.document);
if (!this.initialSync) {
this.configuration.onChange?.(stateVector, "initialSync");
this.initialSync = true;
return;
}
this.configuration.onChange?.(stateVector);
this.incrementUnsyncedChanges();
}
getUpdateFromIndexedDB(): Uint8Array {
const update = Y.encodeStateAsUpdate(this.document);
return update;
}
documentDestroyHandler() {

View file

@ -53,7 +53,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
};
// use document editor
const editor = useDocumentEditor({
const { editor, isIndexedDbSynced } = useDocumentEditor({
id,
editorClassName,
embedHandler,
@ -74,7 +74,7 @@ const DocumentEditor = (props: IDocumentEditor) => {
containerClassName,
});
if (!editor) return null;
if (!editor || !isIndexedDbSynced) return null;
return (
<PageRenderer

View file

@ -1,7 +1,6 @@
import { useEffect, useLayoutEffect, useMemo } from "react";
import { useLayoutEffect, useMemo, useState } from "react";
import Collaboration from "@tiptap/extension-collaboration";
import { EditorProps } from "@tiptap/pm/view";
import { IndexeddbPersistence } from "y-indexeddb";
import * as Y from "yjs";
// extensions
import { DragAndDrop, IssueWidget } from "@/extensions";
@ -62,21 +61,27 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
[id]
);
// update document on value change
useEffect(() => {
if (value.byteLength > 0) Y.applyUpdate(provider.document, value);
}, [value, provider.document]);
const [isIndexedDbSynced, setIndexedDbIsSynced] = useState(false);
// indexedDB provider
// update document on value change from server
useLayoutEffect(() => {
const localProvider = new IndexeddbPersistence(id, provider.document);
localProvider.on("synced", () => {
provider.setHasIndexedDBSynced(true);
});
if (value.length > 0) {
Y.applyUpdate(provider.document, value);
}
}, [value, provider.document, id]);
// watch for indexedDb to complete syncing, only after which the editor is
// rendered
useLayoutEffect(() => {
async function checkIndexDbSynced() {
const hasSynced = await provider.hasIndexedDBSynced();
setIndexedDbIsSynced(hasSynced);
}
checkIndexDbSynced();
return () => {
localProvider?.destroy();
setIndexedDbIsSynced(false);
};
}, [provider, id]);
}, [provider]);
const editor = useEditor({
id,
@ -101,8 +106,9 @@ export const useDocumentEditor = (props: DocumentEditorProps) => {
}),
],
placeholder,
provider,
tabIndex,
});
return editor;
return { editor, isIndexedDbSynced };
};

View file

@ -13,6 +13,7 @@ import { IMarking, scrollSummary } from "@/helpers/scroll-to-node";
import { CoreEditorProps } from "@/props";
// types
import { DeleteImage, EditorRefApi, IMentionHighlight, IMentionSuggestion, RestoreImage, UploadImage } from "@/types";
import { CollaborationProvider } from "@/plane-editor/providers";
export type TFileHandler = {
cancel: () => void;
@ -29,6 +30,7 @@ export interface CustomEditorProps {
// undefined when prop is not passed, null if intentionally passed to stop
// swr syncing
value?: string | null | undefined;
provider?: CollaborationProvider;
onChange?: (json: object, html: string) => void;
extensions?: any;
editorProps?: EditorProps;
@ -54,6 +56,7 @@ export const useEditor = ({
forwardedRef,
tabIndex,
handleEditorReady,
provider,
mentionHandler,
placeholder,
}: CustomEditorProps) => {
@ -187,6 +190,18 @@ export const useEditor = ({
if (!editorRef.current) return;
scrollSummary(editorRef.current, marking);
},
setSynced: () => {
if (provider) {
provider.setSynced();
}
},
hasUnsyncedChanges: () => {
if (provider) {
return provider.hasUnsyncedChanges();
} else {
return false;
}
},
isEditorReadyToDiscard: () => editorRef.current?.storage.image.uploadInProgress === false,
setFocusAtPosition: (position: number) => {
if (!editorRef.current || editorRef.current.isDestroyed) {

View file

@ -19,6 +19,8 @@ export interface EditorRefApi extends EditorReadOnlyRefApi {
onStateChange: (callback: () => void) => () => void;
setFocusAtPosition: (position: number) => void;
isEditorReadyToDiscard: () => boolean;
setSynced: () => void;
hasUnsyncedChanges: () => boolean;
}
export interface IEditorProps {