fix(core): shared page mode syncing (#14756)

### Summary
This fixes a few inconsistencies in shared page behavior:
fixes https://github.com/toeverything/AFFiNE/issues/14751
- shared pages now open in the correct published mode when the URL does
not already include ?mode=...
- switching between page and edgeless in shared mode now keeps the URL
query param in sync
- the default Copy Link action now follows the current editor mode
- shared viewers can toggle between page and edgeless mode in readonly
share pages

---

### What Changed
- updated shared page mode resolution to prefer URL mode, with backend
publish mode as fallback
- added query-param syncing for shared page mode changes
- made the default share link copy use:
  - page link in page mode
  - edgeless link in edgeless mode
- allowed EditorModeSwitch to toggle both ways in shared mode
- extracted shared-mode behavior into small hooks to keep share-page.tsx
cleaner

---

### Demo

https://www.loom.com/share/a287172321fb4fc5b94f7c67a39298a9


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Mode switching between page and edgeless no longer blocked by shared
gating; shared pages initialize and respect the resolved editor mode.
* Shared page URLs stay in sync with editor mode and copy-link actions
include/preserve the selected mode.

* **Tests**
* Added tests for publish-mode resolution, query-string mode handling,
and default share-mode behavior.

* **Bug Fixes**
  * Updated shared-page “not found” UI text to match new messaging.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
This commit is contained in:
chauhan_s
2026-04-05 17:50:58 +05:30
committed by GitHub
parent aa48c1c18b
commit f81abe692d
10 changed files with 201 additions and 31 deletions
@@ -37,6 +37,7 @@ 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';
const useUpdateBasename = (workspace: Workspace | null) => {
const location = useLocation();
@@ -106,7 +107,7 @@ export const SharePage = ({
const SharePageInner = ({
workspaceId,
docId,
publishMode = 'page',
publishMode,
selector,
isTemplate,
templateName,
@@ -128,10 +129,19 @@ const SharePageInner = ({
const [noPermission, setNoPermission] = useState(false);
const [editorContainer, setActiveBlocksuiteEditor] =
useActiveBlocksuiteEditor();
const resolvedPublishMode = publishMode ?? null;
const currentPublishMode = useSharedModeQuerySync({
editor,
resolvedPublishMode,
});
useEffect(() => {
if (editor || workspace || page) {
return;
}
// create a workspace for share page
const { workspace } = workspacesService.open(
const { workspace: sharedWorkspace } = workspacesService.open(
{
metadata: {
id: workspaceId,
@@ -160,16 +170,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');
@@ -178,7 +188,7 @@ const SharePageInner = ({
setPage(doc);
const editor = doc.scope.get(EditorsService).createEditor();
editor.setMode(publishMode);
editor.setMode(resolvedPublishMode ?? doc.getPrimaryMode() ?? 'page');
if (selector) {
editor.setSelector(selector);
@@ -192,13 +202,24 @@ const SharePageInner = ({
});
}, [
docId,
workspaceId,
workspacesService,
publishMode,
editor,
page,
resolvedPublishMode,
selector,
workspaceId,
workspace,
workspacesService,
serverService.server.baseUrl,
]);
useEffect(() => {
if (!editor) {
return;
}
editor.setSelector(selector);
}, [editor, selector]);
const t = useI18n();
const pageTitle = useLiveData(page?.title$);
const { jumpToPageBlock, openPage } = useNavigateHelper();
@@ -244,7 +265,7 @@ const SharePageInner = ({
return <PageNotFound noPermission />;
}
if (!workspace || !page || !editor) {
if (!workspace || !page || !editor || !currentPublishMode) {
return null;
}
@@ -252,13 +273,13 @@ const SharePageInner = ({
<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}
@@ -271,7 +292,7 @@ const SharePageInner = ({
)}
>
<PageDetailEditor onLoad={onEditorLoad} readonly />
{publishMode === 'page' && !BUILD_CONFIG.isElectron ? (
{currentPublishMode === 'page' && !BUILD_CONFIG.isElectron ? (
<ShareFooter />
) : null}
</Scrollable.Viewport>
@@ -279,7 +300,7 @@ const SharePageInner = ({
</Scrollable.Root>
<EditorOutlineViewer
editor={editorContainer?.host ?? null}
show={publishMode === 'page'}
show={currentPublishMode === 'page'}
/>
{!BUILD_CONFIG.isElectron && <SharePageFooter />}
</div>
@@ -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}` : '';
};
@@ -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;
};