🚸(frontend) separate viewers from editors

We are now totally separating the viewers with
the editors. We will not load the provider
when we are in viewer mode, meaning the
viewers will not be aware of other users and
will not show their cursors anymore.
We still get the document updates in real-time.
This commit is contained in:
Anthony LC
2025-10-21 17:28:25 +02:00
parent 39c22b074d
commit eb71028f6b
8 changed files with 152 additions and 123 deletions

View File

@@ -17,11 +17,7 @@ import { useTranslation } from 'react-i18next';
import * as Y from 'yjs';
import { Box, TextErrors } from '@/components';
import {
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { Doc, useProviderStore } from '@/docs/doc-management';
import { useAuth } from '@/features/auth';
import {
@@ -32,7 +28,6 @@ import {
useUploadStatus,
} from '../hook';
import { useEditorStore } from '../stores';
import { cssEditor } from '../styles';
import { DocsBlockNoteEditor } from '../types';
import { randomColor } from '../utils';
@@ -85,25 +80,19 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
const { t } = useTranslation();
const { isSynced: isConnectedToCollabServer } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const isDeletedDoc = !!doc.deleted_at;
useSaveDoc(doc.id, provider.document, !readOnly, isConnectedToCollabServer);
useSaveDoc(doc.id, provider.document, isConnectedToCollabServer);
const { i18n } = useTranslation();
const lang = i18n.resolvedLanguage;
const { uploadFile, errorAttachment } = useUploadFile(doc.id);
const collabName = readOnly
? 'Reader'
: user?.full_name || user?.email || t('Anonymous');
const collabName = user?.full_name || user?.email || t('Anonymous');
const showCursorLabels: 'always' | 'activity' | (string & {}) = 'activity';
const editor: DocsBlockNoteEditor = useCreateBlockNote(
{
collaboration: {
provider,
provider: provider,
fragment: provider.document.getXmlFragment('document-store'),
user: {
name: collabName,
@@ -117,10 +106,6 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
renderCursor: (user: { color: string; name: string }) => {
const cursorElement = document.createElement('span');
if (user.name === 'Reader') {
return cursorElement;
}
cursorElement.classList.add('collaboration-cursor-custom__base');
const caretElement = document.createElement('span');
caretElement.classList.add('collaboration-cursor-custom__caret');
@@ -181,12 +166,7 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
}, [setEditor, editor]);
return (
<Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly, isDeletedDoc)}
className="--docs--editor-container"
>
<>
{errorAttachment && (
<Box $margin={{ bottom: 'big', top: 'none', horizontal: 'large' }}>
<TextErrors
@@ -201,24 +181,21 @@ export const BlockNoteEditor = ({ doc, provider }: BlockNoteEditorProps) => {
editor={editor}
formattingToolbar={false}
slashMenu={false}
editable={!readOnly}
theme="light"
>
<BlockNoteSuggestionMenu />
<BlockNoteToolbar />
</BlockNoteView>
</Box>
</>
);
};
interface BlockNoteEditorVersionProps {
interface BlockNoteReaderProps {
initialContent: Y.XmlFragment;
}
export const BlockNoteEditorVersion = ({
initialContent,
}: BlockNoteEditorVersionProps) => {
const readOnly = true;
export const BlockNoteReader = ({ initialContent }: BlockNoteReaderProps) => {
const { setEditor } = useEditorStore();
const editor = useCreateBlockNote(
{
collaboration: {
@@ -234,9 +211,23 @@ export const BlockNoteEditorVersion = ({
[initialContent],
);
useEffect(() => {
setEditor(editor);
return () => {
setEditor(undefined);
};
}, [setEditor, editor]);
useHeadings(editor);
return (
<Box $css={cssEditor(readOnly, true)} className="--docs--editor-container">
<BlockNoteView editor={editor} editable={!readOnly} theme="light" />
</Box>
<BlockNoteView
editor={editor}
editable={false}
theme="light"
formattingToolbar={false}
slashMenu={false}
/>
);
};

View File

@@ -3,21 +3,31 @@ import { css } from 'styled-components';
import { Box, Loading } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import { Doc, useProviderStore } from '@/docs/doc-management';
import {
Doc,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { useSkeletonStore } from '@/features/skeletons';
import { useResponsiveStore } from '@/stores';
import { BlockNoteEditor } from './BlockNoteEditor';
import { cssEditor } from '../styles';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
interface DocEditorContainerProps {
docHeader: React.ReactNode;
docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
}
export const DocEditorContainer = ({
docHeader,
docEditor,
isDeletedDoc,
readOnly,
}: DocEditorContainerProps) => {
const { isDesktop } = useResponsiveStore();
@@ -44,7 +54,14 @@ export const DocEditorContainer = ({
className="--docs--doc-editor-content"
>
<Box $css="flex:1;" $position="relative" $width="100%">
{docEditor}
<Box
$padding={{ top: 'md' }}
$background="white"
$css={cssEditor(readOnly, isDeletedDoc)}
className="--docs--editor-container"
>
{docEditor}
</Box>
</Box>
</Box>
</Box>
@@ -59,6 +76,8 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => {
const { isDesktop } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const readOnly = !doc.abilities.partial_update || !isEditable || isLoading;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
@@ -87,7 +106,19 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
)}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={<BlockNoteEditor doc={doc} provider={provider} />}
docEditor={
readOnly ? (
<BlockNoteReader
initialContent={provider.document.getXmlFragment(
'document-store',
)}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)
}
isDeletedDoc={!!doc.deleted_at}
readOnly={readOnly}
/>
</>
);

View File

@@ -43,7 +43,7 @@ describe('useSaveDoc', () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -62,37 +62,6 @@ describe('useSaveDoc', () => {
addEventListenerSpy.mockRestore();
});
it('should not save when canSave is false', () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
const docId = 'test-doc-id';
fetchMock.patch('http://test.jest/api/v1.0/documents/test-doc-id/', {
body: JSON.stringify({
id: 'test-doc-id',
content: 'test-content',
title: 'test-title',
}),
});
renderHook(() => useSaveDoc(docId, yDoc, false, true), {
wrapper: AppWrapper,
});
act(() => {
// Trigger a local update
yDoc.getMap('test').set('key', 'value');
// Advance timers to trigger the save interval
vi.advanceTimersByTime(61000);
});
// Since canSave is false, no API call should be made
expect(fetchMock.calls().length).toBe(0);
vi.useRealTimers();
});
it('should save when there are local changes', async () => {
vi.useFakeTimers();
const yDoc = new Y.Doc();
@@ -106,7 +75,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -143,7 +112,7 @@ describe('useSaveDoc', () => {
}),
});
renderHook(() => useSaveDoc(docId, yDoc, true, true), {
renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});
@@ -163,7 +132,7 @@ describe('useSaveDoc', () => {
const docId = 'test-doc-id';
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true, true), {
const { unmount } = renderHook(() => useSaveDoc(docId, yDoc, true), {
wrapper: AppWrapper,
});

View File

@@ -13,7 +13,6 @@ const SAVE_INTERVAL = 60000;
export const useSaveDoc = (
docId: string,
yDoc: Y.Doc,
canSave: boolean,
isConnectedToCollabServer: boolean,
) => {
const { mutate: updateDoc } = useUpdateDoc({
@@ -47,7 +46,7 @@ export const useSaveDoc = (
}, [yDoc]);
const saveDoc = useCallback(() => {
if (!canSave || !isLocalChange) {
if (!isLocalChange) {
return false;
}
@@ -58,14 +57,7 @@ export const useSaveDoc = (
});
return true;
}, [
canSave,
isLocalChange,
updateDoc,
docId,
yDoc,
isConnectedToCollabServer,
]);
}, [isLocalChange, updateDoc, docId, yDoc, isConnectedToCollabServer]);
const router = useRouter();

View File

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { Box, Text, TextErrors } from '@/components';
import { BlockNoteEditorVersion, DocEditorContainer } from '@/docs/doc-editor/';
import { BlockNoteReader, DocEditorContainer } from '@/docs/doc-editor/';
import { Doc, base64ToBlocknoteXmlFragment } from '@/docs/doc-management';
import { Versions, useDocVersion } from '@/docs/doc-versioning/';
@@ -77,7 +77,9 @@ export const DocVersionEditor = ({
return (
<DocEditorContainer
docHeader={<DocVersionHeader />}
docEditor={<BlockNoteEditorVersion initialContent={initialContent} />}
docEditor={<BlockNoteReader initialContent={initialContent} />}
isDeletedDoc={false}
readOnly={true}
/>
);
};