From f81abe692de211633f2f7fc58bbefe10f2e6c2df Mon Sep 17 00:00:00 2001 From: chauhan_s Date: Sun, 5 Apr 2026 17:50:58 +0530 Subject: [PATCH] fix(core): shared page mode syncing (#14756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 ## 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. --------- Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com> --- .../core/src/__tests__/share-page.spec.ts | 40 +++++++++++++ .../src/__tests__/use-share-url.utils.spec.ts | 14 +++++ .../block-suite-mode-switch/index.tsx | 18 +++--- .../use-register-copy-link-commands.tsx | 10 +++- .../hooks/affine/use-share-url.utils.ts | 7 +++ .../pages/workspace/share/share-page.tsx | 53 +++++++++++----- .../pages/workspace/share/share-page.utils.ts | 21 +++++++ .../share/use-shared-mode-query-sync.ts | 60 +++++++++++++++++++ .../view/share-menu/copy-link-button.tsx | 5 +- tests/affine-cloud/e2e/share-page-2.spec.ts | 4 +- 10 files changed, 201 insertions(+), 31 deletions(-) create mode 100644 packages/frontend/core/src/__tests__/share-page.spec.ts create mode 100644 packages/frontend/core/src/__tests__/use-share-url.utils.spec.ts create mode 100644 packages/frontend/core/src/components/hooks/affine/use-share-url.utils.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts create mode 100644 packages/frontend/core/src/desktop/pages/workspace/share/use-shared-mode-query-sync.ts diff --git a/packages/frontend/core/src/__tests__/share-page.spec.ts b/packages/frontend/core/src/__tests__/share-page.spec.ts new file mode 100644 index 0000000000..0ff672c79b --- /dev/null +++ b/packages/frontend/core/src/__tests__/share-page.spec.ts @@ -0,0 +1,40 @@ +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' + ); + }); +}); diff --git a/packages/frontend/core/src/__tests__/use-share-url.utils.spec.ts b/packages/frontend/core/src/__tests__/use-share-url.utils.spec.ts new file mode 100644 index 0000000000..119ea3c53e --- /dev/null +++ b/packages/frontend/core/src/__tests__/use-share-url.utils.spec.ts @@ -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(); + }); +}); diff --git a/packages/frontend/core/src/blocksuite/block-suite-mode-switch/index.tsx b/packages/frontend/core/src/blocksuite/block-suite-mode-switch/index.tsx index 641cbcf6f3..4b594322a6 100644 --- a/packages/frontend/core/src/blocksuite/block-suite-mode-switch/index.tsx +++ b/packages/frontend/core/src/blocksuite/block-suite-mode-switch/index.tsx @@ -39,7 +39,6 @@ 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; @@ -47,18 +46,18 @@ export const EditorModeSwitch = () => { 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) => { @@ -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', @@ -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 ( { unsubs.forEach(unsub => unsub()); }; - }, [docId, isActiveView, isCloud, onClickCopyLink]); + }, [currentMode, docId, isActiveView, isCloud, onClickCopyLink]); } diff --git a/packages/frontend/core/src/components/hooks/affine/use-share-url.utils.ts b/packages/frontend/core/src/components/hooks/affine/use-share-url.utils.ts new file mode 100644 index 0000000000..039de4e0a9 --- /dev/null +++ b/packages/frontend/core/src/components/hooks/affine/use-share-url.utils.ts @@ -0,0 +1,7 @@ +import type { DocMode } from '@blocksuite/affine/model'; + +export const getDefaultShareMode = ( + currentMode?: DocMode +): DocMode | undefined => { + return currentMode === 'edgeless' ? 'edgeless' : undefined; +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx index 857f3d6acb..4e6f5e40f3 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx @@ -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 ; } - if (!workspace || !page || !editor) { + if (!workspace || !page || !editor || !currentPublishMode) { return null; } @@ -252,13 +273,13 @@ const SharePageInner = ({ - +
- {publishMode === 'page' && !BUILD_CONFIG.isElectron ? ( + {currentPublishMode === 'page' && !BUILD_CONFIG.isElectron ? ( ) : null} @@ -279,7 +300,7 @@ const SharePageInner = ({ {!BUILD_CONFIG.isElectron && }
diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts new file mode 100644 index 0000000000..9ce7e76a9f --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.utils.ts @@ -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}` : ''; +}; diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/use-shared-mode-query-sync.ts b/packages/frontend/core/src/desktop/pages/workspace/share/use-shared-mode-query-sync.ts new file mode 100644 index 0000000000..24dcafd3f2 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/share/use-shared-mode-query-sync.ts @@ -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(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; +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.tsx index 0ded6696cd..290ee42bd5 100644 --- a/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.tsx +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.tsx @@ -3,6 +3,7 @@ import { getSelectedNodes, 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 { useI18n } from '@affine/i18n'; import type { DocMode } from '@blocksuite/affine/model'; @@ -46,8 +47,8 @@ export const CopyLinkButton = ({ }, [onClickCopyLink, currentMode, blockIds, elementIds]); const onCopyLink = useCallback(() => { - onClickCopyLink(); - }, [onClickCopyLink]); + onClickCopyLink(getDefaultShareMode(currentMode)); + }, [currentMode, onClickCopyLink]); return (