️(frontend) add skeleton on content loading

Content is longer to load than other parts of the
editor because of the connection with websocket
to the collaboration server. To improve the user
experience, we add a skeleton on the content part
of the editor while the others parts are displayed.
This commit is contained in:
Anthony LC
2026-05-05 12:12:11 +02:00
parent d41e44dcd5
commit b0d9ed15c0
14 changed files with 285 additions and 165 deletions
+4
View File
@@ -6,6 +6,10 @@ and this project adheres to
## [Unreleased]
### Added
- ⚡️(frontend) add skeleton on content loading #2254
### Fixed
- 🐛(frontend) sanitize pasted and dropped content in document title #2210
@@ -688,25 +688,23 @@ test.describe('Doc Editor', () => {
test('it checks interlink feature', async ({ page, browserName }) => {
const [randomDoc] = await createDoc(page, 'doc-interlink', browserName, 1);
await verifyDocName(page, randomDoc);
const { name: docChild1 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-1',
);
await verifyDocName(page, docChild1);
const { name: docChild2 } = await createRootSubPage(
page,
browserName,
'doc-interlink-child-2',
);
await verifyDocName(page, docChild2);
const treeRow = await getTreeRow(page, docChild2);
// To let the time for the emoji-picker to load
await page.waitForTimeout(500);
await treeRow.locator('.--docs--doc-icon').click();
await page.getByRole('button', { name: '😀' }).first().click();
@@ -104,6 +104,9 @@ test.describe('Doc Header', () => {
browserName,
1,
);
await writeInEditor({ page, text: 'Hello Content' });
await page.getByRole('button', { name: 'Share' }).click();
await updateShareLink(page, 'Public', 'Editing');
@@ -116,7 +119,9 @@ test.describe('Doc Header', () => {
docTitle,
});
// Wait for other page to sync
await expect(otherPage.getByText('Hello Content')).toBeVisible();
// Wait for other page to broadcast sync
await page.waitForTimeout(1000);
await page.keyboard.press('Escape');
@@ -124,9 +129,8 @@ test.describe('Doc Header', () => {
await expect(elTitle).toBeVisible();
await elTitle.fill('Hello World');
await elTitle.blur();
await verifyDocName(page, 'Hello World');
// Wait for other page to sync
// Wait for other page to broadcast sync
await page.waitForTimeout(1000);
// Check other user page
@@ -531,7 +535,7 @@ test.describe('Doc Header', () => {
browserName === 'webkit',
'navigator.clipboard is not working with webkit and playwright',
);
const uuid = await mockedDocument(page, {
await mockedDocument(page, {
abilities: {
destroy: false, // Means owner
link_configuration: true,
@@ -552,7 +556,6 @@ test.describe('Doc Header', () => {
name: 'Share',
exact: true,
});
await expect(shareButton).toBeVisible();
await shareButton.click();
await page.getByRole('button', { name: 'Copy link' }).click();
@@ -563,8 +566,8 @@ test.describe('Doc Header', () => {
);
const clipboardContent = await handle.jsonValue();
const origin = await page.evaluate(() => window.location.origin);
expect(clipboardContent.trim()).toMatch(`${origin}/docs/${uuid}/`);
const url = page.url();
expect(clipboardContent.trim()).toMatch(url);
});
test('it pins a document', async ({ page, browserName }) => {
@@ -31,6 +31,8 @@ test.describe('Inherited share accesses', () => {
.getByRole('link')
.click();
await page.getByRole('button', { name: 'close' }).first().click();
await verifyDocName(page, parentTitle);
});
@@ -185,23 +185,23 @@ test.describe('Doc Version', () => {
await page.getByLabel('Restore', { exact: true }).click();
await page.waitForTimeout(500);
const mainEditor = page.getByLabel('Document editor');
await expect(editor.getByText('Hello')).toBeVisible();
await expect(editor.getByText('World')).toBeHidden();
await expect(mainEditor.getByText('Hello')).toBeVisible();
await expect(mainEditor.getByText('World')).toBeHidden();
// The old comment is not restored
await expect(editor.getByText('Hello')).toHaveCSS(
await expect(mainEditor.getByText('Hello')).toHaveCSS(
'background-color',
'rgba(0, 0, 0, 0)',
);
// We can add a new comment
await editor.getByText('Hello').selectText();
await mainEditor.getByText('Hello').selectText();
await page.getByRole('button', { name: 'Add comment' }).click();
await thread.getByRole('paragraph').first().fill('This is a comment');
await thread.locator('[data-test="save"]').click();
await expect(editor.getByText('Hello')).toHaveClass('bn-thread-mark');
await expect(mainEditor.getByText('Hello')).toHaveClass('bn-thread-mark');
});
});
@@ -131,42 +131,64 @@ export const createDoc = async (
await openHeaderMenu(page);
}
const responsePromiseCreateDoc = page.waitForResponse(
(response) =>
response.url().includes('/api/v1.0/documents/') &&
response.status() === 201 &&
response.request().method() === 'POST',
);
await page
.getByRole('button', {
name: 'New doc',
})
.click();
await page.waitForURL('**/docs/**', {
timeout: 10000,
waitUntil: 'networkidle',
});
const responseCreateDoc = await responsePromiseCreateDoc;
expect(responseCreateDoc.ok()).toBeTruthy();
const { id: docId } = (await responseCreateDoc.json()) as { id: string };
const responsePromiseUpdateDoc = page.waitForResponse(
(response) =>
response.url().includes(`/api/v1.0/documents/${docId}`) &&
response.status() === 200 &&
response.request().method() === 'PATCH',
);
const input = page.getByLabel('Document title');
await expect(input).toBeVisible({
timeout: 10000,
});
await expect(input).toHaveText('');
await expect(input).toHaveText('', {
timeout: 10000,
});
await input.fill(randomDocs[i]);
await input.blur();
void input.blur();
const responseUpdateDoc = await responsePromiseUpdateDoc;
expect(responseUpdateDoc.ok()).toBeTruthy();
}
return randomDocs;
};
export const verifyDocName = async (page: Page, docName: string) => {
await expect(
page.getByLabel('It is the card information about the document.'),
).toBeVisible({
const card = page.getByLabel(
'It is the card information about the document.',
);
await expect(card).toBeVisible({
timeout: 10000,
});
/*replace toHaveText with toContainText to handle cases where emojis or other characters might be added*/
try {
await expect(
page.getByRole('textbox', { name: 'Document title' }),
).toContainText(docName, {
timeout: 3000,
});
} catch {
await expect(page.getByRole('heading', { name: docName })).toBeVisible();
}
await expect(card).toHaveText(new RegExp(docName), {
timeout: 10000,
});
};
export const getGridRow = async (page: Page, title: string) => {
@@ -228,11 +250,9 @@ export const updateDocTitle = async (page: Page, title: string) => {
const input = page.getByRole('textbox', { name: 'Document title' });
await expect(input).toHaveText('');
await expect(input).toBeVisible();
await input.click();
await input.fill(title, {
force: true,
});
await input.click();
await input.blur();
await verifyDocName(page, title);
};
@@ -248,10 +268,11 @@ export const waitForResponseCreateDoc = (page: Page) => {
export const mockedDocument = async (page: Page, data: object) => {
// document/[ID]/ or document/[ID]/tree/ routes
const uuid = crypto.randomUUID();
let uuid: string | undefined;
await page.route(/.*\/documents\/[^/]+\/(?:$|tree\/.*)/, async (route) => {
const request = route.request();
if (request.method().includes('GET') && !request.url().includes('page=')) {
uuid = request.url().match(/\/documents\/([^/]+)\//)?.[1];
const { abilities, ...doc } = data as unknown as {
abilities?: Record<string, unknown>;
};
@@ -11,11 +11,15 @@ vi.mock('@/stores', () => ({
useResponsiveStore: () => ({ isDesktop: false }),
}));
vi.mock('@/features/skeletons', () => ({
useSkeletonStore: () => ({
setIsSkeletonVisible: vi.fn(),
}),
}));
vi.mock('@/features/skeletons', async () => {
const actual = await vi.importActual<any>('../../../skeletons');
return {
...actual,
useSkeletonStore: () => ({
setIsSkeletonVisible: vi.fn(),
}),
};
});
vi.mock('../../doc-management', async () => {
const actual = await vi.importActual<any>('../../doc-management');
@@ -1,39 +1,41 @@
import clsx from 'clsx';
import { useEffect, useState } from 'react';
import { PropsWithChildren, useEffect, useState } from 'react';
import { css } from 'styled-components';
import { Box, Loading } from '@/components';
import { Box } from '@/components';
import { DocHeader } from '@/docs/doc-header/';
import {
Doc,
LinkReach,
getDocLinkReach,
useCollaboration,
useIsCollaborativeEditable,
useProviderStore,
} from '@/docs/doc-management';
import { TableContent } from '@/docs/doc-table-content/';
import { useAuth } from '@/features/auth/';
import { useSkeletonStore } from '@/features/skeletons';
import { SkeletonEditorCore, useSkeletonStore } from '@/features/skeletons';
import { useSkeletonFadeOut } from '@/features/skeletons/hooks/useFadeOut';
import { useAnalytics } from '@/libs';
import { useResponsiveStore } from '@/stores';
import { useCollaboration } from '../hook/useCollaboration';
import { BlockNoteEditor, BlockNoteReader } from './BlockNoteEditor';
const DOCS_EDITOR_CLASS = '--docs--doc-editor';
interface DocEditorContainerProps {
docHeader: React.ReactNode;
docEditor: React.ReactNode;
isDeletedDoc: boolean;
readOnly: boolean;
}
export const DocEditorContainer = ({
children,
docHeader,
docEditor,
isDeletedDoc,
readOnly,
}: DocEditorContainerProps) => {
}: PropsWithChildren<DocEditorContainerProps>) => {
const { isDesktop } = useResponsiveStore();
return (
@@ -68,7 +70,7 @@ export const DocEditorContainer = ({
})}
$height="100%"
>
{docEditor}
{children}
</Box>
</Box>
</Box>
@@ -84,23 +86,19 @@ interface DocEditorProps {
export const DocEditor = ({ doc }: DocEditorProps) => {
useCollaboration(doc.id);
const { isDesktop } = useResponsiveStore();
const { provider, isReady } = useProviderStore();
const { isEditable, isLoading } = useIsCollaborativeEditable(doc);
const isDeletedDoc = !!doc.deleted_at;
const readOnly =
!doc.abilities.partial_update || !isEditable || isLoading || isDeletedDoc;
const { setIsSkeletonVisible } = useSkeletonStore();
const isProviderReady = isReady && provider;
const { trackEvent } = useAnalytics();
const [hasTracked, setHasTracked] = useState(false);
const { authenticated } = useAuth();
const isPublicDoc = getDocLinkReach(doc) === LinkReach.PUBLIC;
const { setIsSkeletonVisible } = useSkeletonStore();
useEffect(() => {
if (isProviderReady) {
setIsSkeletonVisible(false);
}
}, [isProviderReady, setIsSkeletonVisible]);
setIsSkeletonVisible(false);
}, [setIsSkeletonVisible, doc.id]);
/**
* Track doc view event only once per doc change
@@ -126,30 +124,57 @@ export const DocEditor = ({ doc }: DocEditorProps) => {
});
}, [authenticated, hasTracked, isPublicDoc, trackEvent]);
if (!isProviderReady || provider?.configuration.name !== doc.id) {
return <Loading />;
}
return (
<>
{isDesktop && <TableContent selector={`.${DOCS_EDITOR_CLASS}`} />}
<DocEditorContainer
docHeader={<DocHeader doc={doc} />}
docEditor={
readOnly ? (
<BlockNoteReader
initialContent={provider.document.getXmlFragment(
'document-store',
)}
docId={doc.id}
/>
) : (
<BlockNoteEditor doc={doc} provider={provider} />
)
}
isDeletedDoc={isDeletedDoc}
readOnly={readOnly}
/>
>
<DocCoreEditor doc={doc} readOnly={readOnly} />
</DocEditorContainer>
</>
);
};
interface DocCoreEditorProps {
doc: Doc;
readOnly: boolean;
}
export const DocCoreEditor = ({ doc, readOnly }: DocCoreEditorProps) => {
useCollaboration(doc.id);
const { provider, isReady } = useProviderStore();
const isProviderReady = isReady && provider;
const showContent = !!(
isProviderReady && provider?.configuration.name === doc.id
);
const { skeletonVisible, isFadingOut } = useSkeletonFadeOut(showContent);
if (
skeletonVisible ||
!isProviderReady ||
provider?.configuration.name !== doc.id
) {
return (
<SkeletonEditorCore
isFadingOut={isFadingOut}
$css={css`
padding-top: 0px;
`}
/>
);
}
if (readOnly) {
return (
<BlockNoteReader
initialContent={provider.document.getXmlFragment('document-store')}
docId={doc.id}
/>
);
}
return <BlockNoteEditor doc={doc} provider={provider} />;
};
@@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useCollaborationUrl } from '@/core/config';
import { KEY_DOC } from '@/docs/doc-management/api/useDoc';
import {
KEY_DOC_CONTENT,
useDocContent,
@@ -10,13 +11,15 @@ import { useProviderStore } from '@/docs/doc-management/stores/useProviderStore'
import { useIsOffline } from '@/features/service-worker/hooks/useOffline';
import { useBroadcastStore } from '@/stores/useBroadcastStore';
import { KEY_DOC } from '../api';
export const useCollaboration = (room: string) => {
const collaborationUrl = useCollaborationUrl(room);
const { addTask } = useBroadcastStore();
const queryClient = useQueryClient();
const { setBroadcastProvider, cleanupBroadcast } = useBroadcastStore();
const {
setBroadcastProvider,
cleanupBroadcast,
provider: broadcastProvider,
} = useBroadcastStore();
const {
provider,
createProvider,
@@ -65,7 +68,7 @@ export const useCollaboration = (room: string) => {
* when the document visibility changes.
*/
useEffect(() => {
if (!room || !isReady) {
if (!room || broadcastProvider?.document?.guid !== room) {
return;
}
@@ -74,7 +77,7 @@ export const useCollaboration = (room: string) => {
queryKey: [KEY_DOC, { id: room }],
});
});
}, [addTask, room, queryClient, isReady]);
}, [addTask, room, queryClient, broadcastProvider?.document?.guid]);
/**
* Set the provider when the collaboration URL and the document content are available.
@@ -1,4 +1,3 @@
export * from './useCollaboration';
export * from './useCopyDocLink';
export * from './useCreateChildDocTree';
export * from './useDocTitleUpdate';
@@ -85,15 +85,14 @@ export const DocVersionEditor = ({
return (
<DocEditorContainer
docHeader={<DocVersionHeader />}
docEditor={
<BlockNoteReader
initialContent={initialContent}
docId={version.id}
isMainEditor={false}
/>
}
isDeletedDoc={false}
readOnly={true}
/>
>
<BlockNoteReader
initialContent={initialContent}
docId={version.id}
isMainEditor={false}
/>
</DocEditorContainer>
);
};
@@ -1,13 +1,12 @@
import { Box } from '@/components';
import { css, keyframes } from 'styled-components';
import { Box, BoxType } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { useResponsiveStore } from '@/stores';
import { SkeletonCircle, SkeletonLine } from './SkeletionUI';
export const DocEditorSkeleton = () => {
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
return (
<>
{/* Main Editor Container */}
@@ -17,80 +16,117 @@ export const DocEditorSkeleton = () => {
$height="100%"
className="--docs--doc-editor-skeleton"
>
{/* Header Skeleton */}
<Box
$padding={{ horizontal: isDesktop ? '70px' : 'base' }}
className="--docs--doc-editor-header-skeleton"
>
<Box
$width="100%"
$padding={{ top: isDesktop ? '65px' : 'md' }}
$gap={spacingsTokens['base']}
>
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
{/* Title and metadata skeleton */}
<Box $gap="0.25rem" $css="flex:1;">
{/* Title - "Untitled Document" style */}
<SkeletonLine $width="35%" $height="40px" />
{/* Metadata (role and last update) */}
<Box $direction="row" $gap="0.5rem" $align="center">
<SkeletonLine $maxWidth="260px" $height="12px" />
</Box>
</Box>
{/* Toolbox skeleton (buttons) */}
<Box $direction="row" $gap="0.75rem" $align="center">
{/* Share button */}
<SkeletonLine $width="90px" $height="40px" />
{/* Download icon */}
<SkeletonCircle $width="40px" $height="40px" />
{/* Menu icon */}
<SkeletonCircle $width="40px" $height="40px" />
</Box>
</Box>
</Box>
{/* Separator */}
<SkeletonLine $height="1px" />
</Box>
</Box>
{/* Content Skeleton */}
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content-skeleton"
>
<Box
$css="flex:1;"
$position="relative"
$width="100%"
$padding={{ horizontal: isDesktop ? '70px' : 'base', top: 'lg' }}
>
{/* Placeholder text similar to screenshot */}
<Box $gap="0rem">
{/* Single placeholder line like in the screenshot */}
<SkeletonLine $width="85%" $height="20px" />
</Box>
</Box>
</Box>
<SkeletonEditorHeader />
<SkeletonEditorCore />
</Box>
</>
);
};
const SkeletonEditorHeader = () => {
const { isDesktop } = useResponsiveStore();
const { spacingsTokens } = useCunninghamTheme();
return (
<Box
$padding={{ horizontal: isDesktop ? '54px' : 'base' }}
className="--docs--doc-editor-header-skeleton"
>
<Box
$width="100%"
$padding={{ top: isDesktop ? '65px' : 'md' }}
$gap={spacingsTokens['base']}
>
<Box
$direction="row"
$align="center"
$width="100%"
$padding={{ bottom: 'xs' }}
>
<Box
$direction="row"
$justify="space-between"
$css="flex:1;"
$gap="0.5rem 1rem"
$align="center"
$maxWidth="100%"
>
{/* Title and metadata skeleton */}
<Box $gap="0.25rem" $css="flex:1;">
{/* Title - "Untitled Document" style */}
<SkeletonLine $width="35%" $height="40px" />
{/* Metadata (role and last update) */}
<Box $direction="row" $gap="0.5rem" $align="center">
<SkeletonLine $maxWidth="260px" $height="12px" />
</Box>
</Box>
{/* Toolbox skeleton (buttons) */}
<Box $direction="row" $gap={spacingsTokens['t']} $align="center">
{/* Share button */}
<SkeletonLine $width="90px" $height="40px" />
{/* Download icon */}
<SkeletonCircle $width="40px" $height="40px" />
{/* Menu icon */}
<SkeletonCircle $width="40px" $height="40px" />
</Box>
</Box>
</Box>
{/* Separator */}
<SkeletonLine $height="1px" />
</Box>
</Box>
);
};
export const SKELETON_FADE_DURATION_MS = 150;
const skeletonFadeOut = keyframes`
from { opacity: 1; }
to { opacity: 0; }
`;
type SkeletonEditorCoreProps = Partial<BoxType> & {
isFadingOut?: boolean;
};
export const SkeletonEditorCore = ({
isFadingOut,
$css,
...props
}: SkeletonEditorCoreProps) => {
const { isDesktop } = useResponsiveStore();
return (
<Box
$direction="row"
$width="100%"
$css="overflow-x: clip; flex: 1;"
$position="relative"
className="--docs--doc-editor-content-skeleton"
>
<Box
$position="relative"
$width="100%"
$padding={{ horizontal: isDesktop ? '54px' : 'base', top: 'md' }}
$flex="1"
$css={css`
${$css}
${isFadingOut &&
css`
animation: ${skeletonFadeOut} ${SKELETON_FADE_DURATION_MS}ms
ease-in-out forwards;
`}
`}
{...props}
>
<Box $gap="1.5rem">
<SkeletonLine $width="65%" $height="35px" />
<SkeletonLine $width="55%" $height="25px" />
<SkeletonLine $width="35%" $height="20px" />
</Box>
</Box>
</Box>
);
};
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
import { SKELETON_FADE_DURATION_MS } from '../components/DocEditorSkeleton';
export const useSkeletonFadeOut = (showContent: boolean) => {
const [skeletonVisible, setSkeletonVisible] = useState(!showContent);
const [isFadingOut, setIsFadingOut] = useState(false);
useEffect(() => {
if (showContent) {
setIsFadingOut(true);
const timer = setTimeout(
() => setSkeletonVisible(false),
SKELETON_FADE_DURATION_MS,
);
return () => clearTimeout(timer);
} else {
setSkeletonVisible(true);
setIsFadingOut(false);
}
}, [showContent]);
return { skeletonVisible, isFadingOut };
};
@@ -109,5 +109,7 @@ export const useBroadcastStore = create<BroadcastState>((set, get) => ({
Object.values(get().tasks).forEach(({ task, observer }) => {
task.unobserve(observer);
});
set({ tasks: {}, provider: undefined });
},
}));