Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/frontend/core/src/__tests__/share-page.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { PublicDocMode } from '@affine/graphql';
import { describe, expect, test } from 'vitest';

import {
getResolvedPublishMode,
getSearchWithMode,
} from '../desktop/pages/workspace/share/share-page.utils';

describe('getResolvedPublishMode', () => {
test('prefers the query mode when it is present', () => {
expect(getResolvedPublishMode('edgeless', PublicDocMode.Page)).toBe(
'edgeless'
);
expect(getResolvedPublishMode('page', PublicDocMode.Edgeless)).toBe(
'page'
);
});

test('falls back to the published public mode for shared docs', () => {
expect(getResolvedPublishMode(null, PublicDocMode.Edgeless)).toBe(
'edgeless'
);
expect(getResolvedPublishMode(null, PublicDocMode.Page)).toBe('page');
});

test('defaults to page when no mode is available', () => {
expect(getResolvedPublishMode(null, null)).toBe('page');
expect(getResolvedPublishMode(null, undefined)).toBe('page');
});
});

describe('getSearchWithMode', () => {
test('adds mode to an empty search string', () => {
expect(getSearchWithMode('', 'edgeless')).toBe('?mode=edgeless');
});

test('replaces an existing mode and preserves other params', () => {
expect(getSearchWithMode('?foo=1&mode=page&bar=2', 'edgeless')).toBe(
'?foo=1&mode=edgeless&bar=2'
);
});
});
14 changes: 14 additions & 0 deletions packages/frontend/core/src/__tests__/use-share-url.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { describe, expect, test } from 'vitest';

import { getDefaultShareMode } from '../components/hooks/affine/use-share-url.utils';

describe('getDefaultShareMode', () => {
test('returns edgeless when the current mode is edgeless', () => {
expect(getDefaultShareMode('edgeless')).toBe('edgeless');
});

test('returns undefined for page mode or an unset mode', () => {
expect(getDefaultShareMode('page')).toBeUndefined();
expect(getDefaultShareMode(undefined)).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,26 +39,25 @@ export const EditorModeSwitch = () => {
const t = useI18n();
const editor = useService(EditorService).editor;
const trash = useLiveData(editor.doc.trash$);
const isSharedMode = editor.isSharedMode;
const currentMode = useLiveData(editor.mode$);
const view = useServiceOptional(ViewService)?.view;
const workbench = useServiceOptional(WorkbenchService)?.workbench;
const activeView = useLiveData(workbench?.activeView$);
const isActiveView = activeView?.id && activeView?.id === view?.id;

const togglePage = useCallback(() => {
if (currentMode === 'page' || isSharedMode || trash) return;
if (currentMode === 'page' || trash) return;
editor.setMode('page');
editor.setSelector(undefined);
track.$.header.actions.switchPageMode({ mode: 'page' });
}, [currentMode, editor, isSharedMode, trash]);
}, [currentMode, editor, trash]);

const toggleEdgeless = useCallback(() => {
if (currentMode === 'edgeless' || isSharedMode || trash) return;
if (currentMode === 'edgeless' || trash) return;
editor.setMode('edgeless');
editor.setSelector(undefined);
track.$.header.actions.switchPageMode({ mode: 'edgeless' });
}, [currentMode, editor, isSharedMode, trash]);
}, [currentMode, editor, trash]);

const onModeChange = useCallback(
(mode: DocMode) => {
Expand All @@ -68,13 +67,12 @@ export const EditorModeSwitch = () => {
);

const shouldHide = useCallback(
(mode: DocMode) => (trash || isSharedMode) && currentMode !== mode,
[currentMode, isSharedMode, trash]
(mode: DocMode) => trash && currentMode !== mode,
[currentMode, trash]
);

useEffect(() => {
if (trash || isSharedMode || currentMode === undefined || !isActiveView)
return;
if (trash || currentMode === undefined || !isActiveView) return;
return registerAffineCommand({
id: 'affine:doc-mode-switch',
category: 'editor:page',
Expand All @@ -89,7 +87,7 @@ export const EditorModeSwitch = () => {
},
run: () => onModeChange(currentMode === 'edgeless' ? 'page' : 'edgeless'),
});
}, [currentMode, isActiveView, isSharedMode, onModeChange, t, trash]);
}, [currentMode, isActiveView, onModeChange, t, trash]);

return (
<PureEditorModeSwitch
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import {
registerAffineCommand,
} from '@affine/core/commands';
import { useSharingUrl } from '@affine/core/components/hooks/affine/use-share-url';
import { getDefaultShareMode } from '@affine/core/components/hooks/affine/use-share-url.utils';
import { EditorService } from '@affine/core/modules/editor';
import { useIsActiveView } from '@affine/core/modules/workbench';
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';

export function useRegisterCopyLinkCommands({
Expand All @@ -18,6 +21,7 @@ export function useRegisterCopyLinkCommands({
const isActiveView = useIsActiveView();
const workspaceId = workspaceMeta.id;
const isCloud = workspaceMeta.flavour !== 'local';
const currentMode = useLiveData(useService(EditorService).editor.mode$);

const { onClickCopyLink } = useSharingUrl({
workspaceId,
Expand All @@ -42,12 +46,14 @@ export function useRegisterCopyLinkCommands({
icon: null,
run() {
track.$.cmdk.general.copyShareLink();
isActiveView && isCloud && onClickCopyLink();
isActiveView &&
isCloud &&
onClickCopyLink(getDefaultShareMode(currentMode));
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [docId, isActiveView, isCloud, onClickCopyLink]);
}, [currentMode, docId, isActiveView, isCloud, onClickCopyLink]);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { DocMode } from '@blocksuite/affine/model';

export const getDefaultShareMode = (
currentMode?: DocMode
): DocMode | undefined => {
return currentMode === 'edgeless' ? 'edgeless' : undefined;
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { PageNotFound } from '../../404';
import { ShareFooter } from './share-footer';
import { ShareHeader } from './share-header';
import * as styles from './share-page.css';
import { useSharedModeQuerySync } from './use-shared-mode-query-sync';
import { useSharedPublishMode } from './use-shared-publish-mode';

const useUpdateBasename = (workspace: Workspace | null) => {
const location = useLocation();
Expand Down Expand Up @@ -106,7 +108,7 @@ export const SharePage = ({
const SharePageInner = ({
workspaceId,
docId,
publishMode = 'page',
publishMode,
selector,
isTemplate,
templateName,
Expand All @@ -128,10 +130,23 @@ const SharePageInner = ({
const [noPermission, setNoPermission] = useState(false);
const [editorContainer, setActiveBlocksuiteEditor] =
useActiveBlocksuiteEditor();
const { hasPublishModeError, resolvedPublishMode } = useSharedPublishMode({
docId,
publishMode,
workspaceId,
});
const currentPublishMode = useSharedModeQuerySync({
editor,
resolvedPublishMode,
});

useEffect(() => {
if (!resolvedPublishMode || editor || workspace || page) {
return;
}

// create a workspace for share page
const { workspace } = workspacesService.open(
const { workspace: sharedWorkspace } = workspacesService.open(
{
metadata: {
id: workspaceId,
Expand Down Expand Up @@ -160,16 +175,16 @@ const SharePageInner = ({
}
);

setWorkspace(workspace);
setWorkspace(sharedWorkspace);

workspace.engine.doc
.waitForDocLoaded(workspace.id)
sharedWorkspace.engine.doc
.waitForDocLoaded(sharedWorkspace.id)
.then(async () => {
const { doc } = workspace.scope.get(DocsService).open(docId);
const { doc } = sharedWorkspace.scope.get(DocsService).open(docId);
doc.blockSuiteDoc.load();
doc.blockSuiteDoc.readonly = true;

await workspace.engine.doc.waitForDocLoaded(docId);
await sharedWorkspace.engine.doc.waitForDocLoaded(docId);

if (!doc.blockSuiteDoc.root) {
throw new Error('Doc is empty');
Expand All @@ -178,7 +193,7 @@ const SharePageInner = ({
setPage(doc);

const editor = doc.scope.get(EditorsService).createEditor();
editor.setMode(publishMode);
editor.setMode(resolvedPublishMode);

if (selector) {
editor.setSelector(selector);
Expand All @@ -192,13 +207,24 @@ const SharePageInner = ({
});
}, [
docId,
editor,
page,
resolvedPublishMode,
selector,
workspaceId,
workspace,
workspacesService,
publishMode,
selector,
serverService.server.baseUrl,
]);

useEffect(() => {
if (!editor) {
return;
}

editor.setSelector(selector);
}, [editor, selector]);

const t = useI18n();
const pageTitle = useLiveData(page?.title$);
const { jumpToPageBlock, openPage } = useNavigateHelper();
Expand Down Expand Up @@ -240,25 +266,29 @@ const SharePageInner = ({
[editor, setActiveBlocksuiteEditor, jumpToPageBlock, openPage, workspaceId]
);

if (hasPublishModeError && !workspace && !page && !editor) {
return null;
}

if (noPermission) {
return <PageNotFound noPermission />;
}

if (!workspace || !page || !editor) {
if (!workspace || !page || !editor || !currentPublishMode) {
return null;
Comment on lines +269 to 278
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t turn publish-mode lookup failures into a blank page.

When packages/frontend/core/src/desktop/pages/workspace/share/use-shared-publish-mode.ts sets hasPublishModeError at Lines 54-60, this component still renders null here, and the loading guard below does the same. A failed mode lookup now looks like an infinite blank screen instead of a recoverable error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx`
around lines 268 - 277, The current guards turn publish-mode lookup failures
into a blank page; update the conditions so hasPublishModeError does not fall
through to the generic null loading guard: remove hasPublishModeError from the
early "return null" check and add an explicit branch that when
hasPublishModeError && !currentPublishMode renders an error UI (e.g., a
PublishModeError component or reuse PageNotFound with an error message) instead
of returning null; adjust the three-way guard that checks workspace, page,
editor, currentPublishMode so only genuine loading states return null while
publish-mode failures are handled by the new error branch (refer to
hasPublishModeError, currentPublishMode, workspace, page, editor, and
PageNotFound/use-shared-publish-mode.ts).

}

return (
<FrameworkScope scope={workspace.scope}>
<FrameworkScope scope={page.scope}>
<FrameworkScope scope={editor.scope}>
<ViewIcon icon={publishMode === 'page' ? 'doc' : 'edgeless'} />
<ViewIcon icon={currentPublishMode === 'page' ? 'doc' : 'edgeless'} />
<ViewTitle title={pageTitle ?? t['unnamed']()} />
<div className={styles.root}>
<div className={styles.mainContainer}>
<ShareHeader
pageId={page.id}
publishMode={publishMode}
publishMode={currentPublishMode}
isTemplate={isTemplate}
templateName={templateName}
snapshotUrl={templateSnapshotUrl}
Expand All @@ -271,15 +301,15 @@ const SharePageInner = ({
)}
>
<PageDetailEditor onLoad={onEditorLoad} readonly />
{publishMode === 'page' && !BUILD_CONFIG.isElectron ? (
{currentPublishMode === 'page' && !BUILD_CONFIG.isElectron ? (
<ShareFooter />
) : null}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
<EditorOutlineViewer
editor={editorContainer?.host ?? null}
show={publishMode === 'page'}
show={currentPublishMode === 'page'}
/>
{!BUILD_CONFIG.isElectron && <SharePageFooter />}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { PublicDocMode } from '@affine/graphql';
import { type DocMode, DocModes } from '@blocksuite/affine/model';

export const getResolvedPublishMode = (
queryMode: DocMode | null,
publicMode?: PublicDocMode | null
): DocMode => {
if (queryMode && DocModes.includes(queryMode)) {
return queryMode;
}

return publicMode === PublicDocMode.Edgeless ? 'edgeless' : 'page';
};

export const getSearchWithMode = (search: string, mode: DocMode) => {
const searchParams = new URLSearchParams(search);
searchParams.set('mode', mode);

const nextSearch = searchParams.toString();
return nextSearch ? `?${nextSearch}` : '';
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { Editor } from '@affine/core/modules/editor';
import type { DocMode } from '@blocksuite/affine/model';
import { useLiveData } from '@toeverything/infra';
import { useEffect, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';

import { getSearchWithMode } from './share-page.utils';

export const useSharedModeQuerySync = ({
editor,
resolvedPublishMode,
}: {
editor: Editor | null;
resolvedPublishMode: DocMode | null;
}) => {
const location = useLocation();
const navigate = useNavigate();
const currentPublishMode = useLiveData(editor?.mode$) ?? resolvedPublishMode;
const previousPublishModeRef = useRef<DocMode | null>(null);

useEffect(() => {
if (!editor || !resolvedPublishMode) {
return;
}

if (editor.mode$.value !== resolvedPublishMode) {
editor.setMode(resolvedPublishMode);
}
}, [editor, resolvedPublishMode]);

useEffect(() => {
if (!currentPublishMode) {
return;
}

if (previousPublishModeRef.current === null) {
previousPublishModeRef.current = currentPublishMode;
return;
}

if (previousPublishModeRef.current === currentPublishMode) {
return;
}

previousPublishModeRef.current = currentPublishMode;

const nextSearch = getSearchWithMode(location.search, currentPublishMode);
if (nextSearch !== location.search) {
navigate(
{
pathname: location.pathname,
search: nextSearch,
},
{ replace: true }
);
}
}, [currentPublishMode, location.pathname, location.search, navigate]);

return currentPublishMode;
};
Loading
Loading