mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #21415 from opf/bug/69724-documents-loss-of-content-when-another-user-opens-the-document
bug/69724 Prevent potential data loss on initial multi-user collaborative document load by deferring editor creation until the provider syncs the client Y.Doc instance
This commit is contained in:
@@ -28,23 +28,11 @@
|
||||
* ++
|
||||
*/
|
||||
|
||||
import { BlockNoteEditorOptions, BlockNoteSchema, filterSuggestionItems } from '@blocknote/core';
|
||||
import { User } from '@blocknote/core/comments';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { IUploadFile } from 'core-app/core/upload/upload.service';
|
||||
import { initializeOpBlockNoteExtensions, openProjectWorkPackageBlockSpec, openProjectWorkPackageSlashMenu } from 'op-blocknote-extensions';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import * as Y from 'yjs';
|
||||
import { BlockNoteLocaleResult, useBlockNoteLocale } from './hooks/useBlockNoteLocale';
|
||||
import { OpBlockNoteEditor } from './components/OpBlockNoteEditor';
|
||||
import { useCollaboration } from './hooks/useCollaboration';
|
||||
import { useOpTheme } from './hooks/useOpTheme';
|
||||
|
||||
interface CollaborativeUser {
|
||||
name:string;
|
||||
color:string;
|
||||
}
|
||||
|
||||
export interface OpBlockNoteContainerProps {
|
||||
inputField:HTMLInputElement;
|
||||
@@ -57,11 +45,8 @@ export interface OpBlockNoteContainerProps {
|
||||
hocuspocusProvider?:HocuspocusProvider;
|
||||
}
|
||||
|
||||
const schema = BlockNoteSchema.create().extend({
|
||||
blockSpecs: {
|
||||
openProjectWorkPackage: openProjectWorkPackageBlockSpec(),
|
||||
},
|
||||
});
|
||||
const SKELETON_TITLE_STYLE = { width: '25%', height: '40px' };
|
||||
const SKELETON_CONTENT_STYLE = { width: '100%', height: '150px' };
|
||||
|
||||
export default function OpBlockNoteContainer({ inputField,
|
||||
inputText,
|
||||
@@ -71,103 +56,37 @@ export default function OpBlockNoteContainer({ inputField,
|
||||
attachmentsUploadUrl,
|
||||
attachmentsCollectionKey,
|
||||
hocuspocusProvider }:OpBlockNoteContainerProps) {
|
||||
const { localeString, localeDictionary }:BlockNoteLocaleResult = useBlockNoteLocale(window.I18n.locale);
|
||||
|
||||
initializeOpBlockNoteExtensions({ baseUrl: openProjectUrl, locale: localeString });
|
||||
|
||||
let doc:Y.Doc;
|
||||
|
||||
let editorParams:Partial<BlockNoteEditorOptions<typeof schema.blockSchema, typeof schema.inlineContentSchema, typeof schema.styleSchema>>;
|
||||
if(hocuspocusProvider) {
|
||||
doc = hocuspocusProvider.document;
|
||||
|
||||
editorParams = {
|
||||
schema,
|
||||
collaboration: {
|
||||
provider: hocuspocusProvider,
|
||||
fragment: doc.getXmlFragment('document-store'),
|
||||
user: {
|
||||
id: activeUser.id,
|
||||
name: activeUser.username,
|
||||
color: '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0'),
|
||||
} as unknown as CollaborativeUser,
|
||||
showCursorLabels: 'activity'
|
||||
},
|
||||
dictionary: localeDictionary,
|
||||
...(isReadyForAttachmentUpload() && { uploadFile }),
|
||||
};
|
||||
} else { // collaboration disabled (for test environments)
|
||||
doc = new Y.Doc();
|
||||
|
||||
if (inputText) {
|
||||
try {
|
||||
const update = Uint8Array.from(atob(inputText), c => c.charCodeAt(0));
|
||||
Y.applyUpdate(doc, update);
|
||||
} catch (e) {
|
||||
console.error('Failed to load document binary', e);
|
||||
doc = new Y.Doc();
|
||||
const doc:Y.Doc = hocuspocusProvider
|
||||
? hocuspocusProvider.document
|
||||
: (() => {
|
||||
// NOTE: This should only be used in TEST environments where there is no provider.
|
||||
const newDoc = new Y.Doc();
|
||||
if (inputText) {
|
||||
try {
|
||||
const update = Uint8Array.from(atob(inputText), c => c.charCodeAt(0));
|
||||
Y.applyUpdate(newDoc, update);
|
||||
} catch (e) {
|
||||
console.error('Failed to load document binary', e);
|
||||
return new Y.Doc();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
editorParams = {
|
||||
schema,
|
||||
collaboration: {
|
||||
provider: null,
|
||||
fragment: doc.getXmlFragment('document-store'),
|
||||
user: {
|
||||
name: activeUser.username,
|
||||
color: '#333333',
|
||||
},
|
||||
},
|
||||
dictionary: localeDictionary,
|
||||
...(isReadyForAttachmentUpload() && { uploadFile }),
|
||||
};
|
||||
}
|
||||
|
||||
const editor = useCreateBlockNote(editorParams, [activeUser]);
|
||||
type EditorType = typeof editor;
|
||||
|
||||
function isReadyForAttachmentUpload():boolean {
|
||||
return (
|
||||
attachmentsCollectionKey !== undefined &&
|
||||
attachmentsCollectionKey !== '' &&
|
||||
attachmentsUploadUrl !== undefined &&
|
||||
attachmentsUploadUrl !== ''
|
||||
);
|
||||
}
|
||||
const fileToIUploadFile = (file:File):IUploadFile => ({
|
||||
file: file
|
||||
});
|
||||
|
||||
async function uploadFile(file:File) {
|
||||
const pluginContext = await window.OpenProject.getPluginContext();
|
||||
try {
|
||||
const service = pluginContext.services.attachmentsResourceService;
|
||||
const iUploadFile = fileToIUploadFile(file);
|
||||
const result = await firstValueFrom(
|
||||
service.addAttachments(attachmentsCollectionKey, attachmentsUploadUrl, [iUploadFile])
|
||||
);
|
||||
|
||||
return result?.[0]._links.staticDownloadLocation.href ?? '';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch(error:any) {
|
||||
const toastService = pluginContext.services.notifications;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
toastService.addError(error);
|
||||
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
const getCustomSlashMenuItems = (editor:EditorType) => {
|
||||
return [
|
||||
...getDefaultReactSlashMenuItems(editor),
|
||||
openProjectWorkPackageSlashMenu(editor),
|
||||
];
|
||||
};
|
||||
return newDoc;
|
||||
})();
|
||||
|
||||
const { isLoading, connectionError } = useCollaboration(hocuspocusProvider, doc, inputField);
|
||||
const theme = useOpTheme();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<div className={'mb-3'}>
|
||||
<div style={SKELETON_TITLE_STYLE} className={'SkeletonBox'} />
|
||||
</div>
|
||||
<div className={'mb-3'}>
|
||||
<div style={SKELETON_CONTENT_STYLE} className={'SkeletonBox'} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (connectionError) {
|
||||
return (
|
||||
@@ -179,29 +98,14 @@ export default function OpBlockNoteContainer({ inputField,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isLoading ? <div>
|
||||
<div className={'mb-3'}>
|
||||
<div style={{width: '25%', height: '40px'}} className={'SkeletonBox'}/>
|
||||
</div>
|
||||
<div className={'mb-3'}>
|
||||
<div style={{width: '100%', height: '150px'}} className={'SkeletonBox'}/>
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
slashMenu={false}
|
||||
theme={theme}
|
||||
editable={!readOnly}
|
||||
className={'block-note-editor-container'}
|
||||
>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="/"
|
||||
getItems={async (query:string) => Promise.resolve(filterSuggestionItems(getCustomSlashMenuItems(editor), query))}
|
||||
/>
|
||||
</BlockNoteView>
|
||||
}
|
||||
</>
|
||||
<OpBlockNoteEditor
|
||||
activeUser={activeUser}
|
||||
readOnly={readOnly}
|
||||
openProjectUrl={openProjectUrl}
|
||||
attachmentsUploadUrl={attachmentsUploadUrl}
|
||||
attachmentsCollectionKey={attachmentsCollectionKey}
|
||||
hocuspocusProvider={hocuspocusProvider}
|
||||
doc={doc}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* -- copyright
|
||||
* OpenProject is an open source project management software.
|
||||
* Copyright (C) the OpenProject GmbH
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 3.
|
||||
*
|
||||
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
* Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
* Copyright (C) 2010-2013 the ChiliProject Team
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*
|
||||
* See COPYRIGHT and LICENSE files for more details.
|
||||
* ++
|
||||
*/
|
||||
|
||||
import { BlockNoteEditorOptions, BlockNoteSchema, filterSuggestionItems } from '@blocknote/core';
|
||||
import { User } from '@blocknote/core/comments';
|
||||
import { BlockNoteView } from '@blocknote/mantine';
|
||||
import { getDefaultReactSlashMenuItems, SuggestionMenuController, useCreateBlockNote } from '@blocknote/react';
|
||||
import { HocuspocusProvider } from '@hocuspocus/provider';
|
||||
import { initializeOpBlockNoteExtensions, openProjectWorkPackageBlockSpec, openProjectWorkPackageSlashMenu } from 'op-blocknote-extensions';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { useBlockNoteAttachments } from '../hooks/useBlockNoteAttachments';
|
||||
import { useBlockNoteLocale } from '../hooks/useBlockNoteLocale';
|
||||
import { useOpTheme } from '../hooks/useOpTheme';
|
||||
|
||||
interface CollaborativeUser {
|
||||
name:string;
|
||||
color:string;
|
||||
}
|
||||
|
||||
export interface OpBlockNoteEditorProps {
|
||||
activeUser:User;
|
||||
readOnly:boolean;
|
||||
openProjectUrl:string;
|
||||
attachmentsUploadUrl:string;
|
||||
attachmentsCollectionKey:string;
|
||||
hocuspocusProvider?:HocuspocusProvider;
|
||||
doc:Y.Doc;
|
||||
}
|
||||
|
||||
const schema = BlockNoteSchema.create().extend({
|
||||
blockSpecs: {
|
||||
openProjectWorkPackage: openProjectWorkPackageBlockSpec(),
|
||||
},
|
||||
});
|
||||
|
||||
function generateRandomColor() {
|
||||
return '#' + Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0');
|
||||
}
|
||||
|
||||
export function OpBlockNoteEditor({
|
||||
activeUser,
|
||||
readOnly,
|
||||
openProjectUrl,
|
||||
attachmentsUploadUrl,
|
||||
attachmentsCollectionKey,
|
||||
hocuspocusProvider,
|
||||
doc,
|
||||
}:OpBlockNoteEditorProps) {
|
||||
const { localeString, localeDictionary } = useBlockNoteLocale(window.I18n.locale);
|
||||
const { enabled: attachmentsEnabled, uploadFile } = useBlockNoteAttachments(attachmentsCollectionKey, attachmentsUploadUrl);
|
||||
|
||||
useEffect(() => {
|
||||
initializeOpBlockNoteExtensions({ baseUrl: openProjectUrl, locale: localeString });
|
||||
}, [openProjectUrl, localeString]);
|
||||
|
||||
const editorParams = useMemo<Partial<BlockNoteEditorOptions<typeof schema.blockSchema, typeof schema.inlineContentSchema, typeof schema.styleSchema>>>(() => {
|
||||
const baseCollaboration = {
|
||||
fragment: doc.getXmlFragment('document-store'),
|
||||
user: {
|
||||
name: activeUser.username,
|
||||
color: hocuspocusProvider ? generateRandomColor() : '#333333',
|
||||
...(hocuspocusProvider && { id: activeUser.id }),
|
||||
} as unknown as CollaborativeUser,
|
||||
};
|
||||
|
||||
return {
|
||||
schema,
|
||||
collaboration: {
|
||||
...baseCollaboration,
|
||||
provider: hocuspocusProvider ?? null,
|
||||
...(hocuspocusProvider && { showCursorLabels: 'activity' as const }),
|
||||
},
|
||||
dictionary: localeDictionary,
|
||||
...(attachmentsEnabled && { uploadFile }),
|
||||
};
|
||||
}, [hocuspocusProvider, doc, activeUser, localeDictionary, attachmentsEnabled, uploadFile]);
|
||||
|
||||
const editor = useCreateBlockNote(editorParams, [activeUser]);
|
||||
type EditorType = typeof editor;
|
||||
const theme = useOpTheme();
|
||||
|
||||
const getCustomSlashMenuItems = useMemo(() => {
|
||||
return (editorInstance:EditorType) => [
|
||||
...getDefaultReactSlashMenuItems(editorInstance),
|
||||
openProjectWorkPackageSlashMenu(editorInstance),
|
||||
];
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BlockNoteView
|
||||
editor={editor}
|
||||
slashMenu={false}
|
||||
theme={theme}
|
||||
editable={!readOnly}
|
||||
className={'block-note-editor-container'}
|
||||
>
|
||||
<SuggestionMenuController
|
||||
triggerCharacter="/"
|
||||
getItems={async (query:string) => Promise.resolve(filterSuggestionItems(getCustomSlashMenuItems(editor), query))}
|
||||
/>
|
||||
</BlockNoteView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* -- copyright
|
||||
* OpenProject is an open source project management software.
|
||||
* Copyright (C) the OpenProject GmbH
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 3.
|
||||
*
|
||||
* OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
|
||||
* Copyright (C) 2006-2013 Jean-Philippe Lang
|
||||
* Copyright (C) 2010-2013 the ChiliProject Team
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License
|
||||
* as published by the Free Software Foundation; either version 2
|
||||
* of the License, or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program; if not, write to the Free Software
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||||
*
|
||||
* See COPYRIGHT and LICENSE files for more details.
|
||||
* ++
|
||||
*/
|
||||
|
||||
import { IUploadFile } from 'core-app/core/upload/upload.service';
|
||||
import { useCallback } from 'react';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface BlockNoteAttachmentsResult {
|
||||
enabled:boolean;
|
||||
uploadFile?:(file:File) => Promise<string>;
|
||||
}
|
||||
|
||||
export function useBlockNoteAttachments(
|
||||
attachmentsCollectionKey:string,
|
||||
attachmentsUploadUrl:string,
|
||||
):BlockNoteAttachmentsResult {
|
||||
const enabled = (
|
||||
attachmentsCollectionKey !== undefined &&
|
||||
attachmentsCollectionKey !== '' &&
|
||||
attachmentsUploadUrl !== undefined &&
|
||||
attachmentsUploadUrl !== ''
|
||||
);
|
||||
|
||||
const uploadFile = useCallback(async (file:File):Promise<string> => {
|
||||
const pluginContext = await window.OpenProject.getPluginContext();
|
||||
try {
|
||||
const service = pluginContext.services.attachmentsResourceService;
|
||||
const uploadFiles:IUploadFile[] = [{ file }];
|
||||
const result = await firstValueFrom(
|
||||
service.addAttachments(attachmentsCollectionKey, attachmentsUploadUrl, uploadFiles)
|
||||
);
|
||||
|
||||
return result?.[0]._links.staticDownloadLocation.href ?? '';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (error:any) {
|
||||
const toastService = pluginContext.services.notifications;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
toastService.addError(error);
|
||||
|
||||
return '';
|
||||
}
|
||||
}, [attachmentsCollectionKey, attachmentsUploadUrl]);
|
||||
|
||||
if (!enabled) {
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
return { enabled, uploadFile };
|
||||
}
|
||||
|
||||
export default useBlockNoteAttachments;
|
||||
@@ -46,7 +46,9 @@ class LiveCollaborationManagerClass {
|
||||
* @returns void
|
||||
*/
|
||||
initializeYjsProvider(provider:HocuspocusProvider, doc:Doc) {
|
||||
this.destroy(); // Clean up old state first
|
||||
this.destroyYjsProvider();
|
||||
this.destroyYjsDoc();
|
||||
|
||||
this.yjsProviderInstance = provider;
|
||||
this.yjsDocInstance = doc;
|
||||
this.listeners.forEach((listener) => listener(this.yjsProviderInstance!));
|
||||
@@ -57,11 +59,20 @@ class LiveCollaborationManagerClass {
|
||||
* This method should be called when a collaboration session is ended
|
||||
*/
|
||||
destroy():void {
|
||||
this.destroyYjsProvider();
|
||||
this.destroyYjsDoc();
|
||||
|
||||
this.listeners = [];
|
||||
}
|
||||
|
||||
private destroyYjsProvider():void {
|
||||
if (this.yjsProviderInstance) {
|
||||
this.yjsProviderInstance.destroy();
|
||||
this.yjsProviderInstance = null;
|
||||
}
|
||||
}
|
||||
|
||||
private destroyYjsDoc():void {
|
||||
if (this.yjsDocInstance) {
|
||||
this.yjsDocInstance.destroy();
|
||||
this.yjsDocInstance = null;
|
||||
|
||||
Reference in New Issue
Block a user