mirror of
https://github.com/suitenumerique/docs.git
synced 2026-08-17 21:25:43 +02:00
⚡️(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:
@@ -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} />;
|
||||
};
|
||||
|
||||
+8
-5
@@ -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';
|
||||
|
||||
+7
-8
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+113
-77
@@ -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 });
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user