diff --git a/apps/web/src/components/affine/pinboard/pinboard-menu/index.tsx b/apps/web/src/components/affine/pinboard/pinboard-menu/index.tsx index 3c4c64ebeb..bfafb0315b 100644 --- a/apps/web/src/components/affine/pinboard/pinboard-menu/index.tsx +++ b/apps/web/src/components/affine/pinboard/pinboard-menu/index.tsx @@ -45,7 +45,7 @@ export const PinboardMenu = ({ meta => !meta.trash && meta.title.includes(query) ); - const { handleDrop } = usePinboardHandler({ + const { dropPin } = usePinboardHandler({ blockSuiteWorkspace, metas, }); @@ -54,7 +54,7 @@ export const PinboardMenu = ({ (dropId: string) => { const targetTitle = metas.find(m => m.id === dropId)?.title; - handleDrop(currentMeta.id, dropId, { + dropPin(currentMeta.id, dropId, { bottomLine: false, topLine: false, internal: true, @@ -62,7 +62,7 @@ export const PinboardMenu = ({ onPinboardClick?.({ dragId: currentMeta.id, dropId }); toast(`Moved "${currentMeta.title}" to "${targetTitle}"`); }, - [currentMeta.id, currentMeta.title, handleDrop, metas, onPinboardClick] + [currentMeta.id, currentMeta.title, dropPin, metas, onPinboardClick] ); const { data } = usePinboardData({ diff --git a/apps/web/src/components/page-detail-editor.tsx b/apps/web/src/components/page-detail-editor.tsx index c2ee8c14e5..5eb3fe3bbc 100644 --- a/apps/web/src/components/page-detail-editor.tsx +++ b/apps/web/src/components/page-detail-editor.tsx @@ -80,7 +80,13 @@ export const PageDetailEditor: React.FC = ({ }, [onInit, setEditor] )} - onLoad={onLoad} + onLoad={useCallback( + (page: Page, editor: EditorContainer) => { + setEditor(editor); + onLoad?.(page, editor); + }, + [onLoad, setEditor] + )} /> ); diff --git a/apps/web/src/components/pure/workspace-slider-bar/Pinboard.tsx b/apps/web/src/components/pure/workspace-slider-bar/Pinboard.tsx index b7d0ec0b10..6a3dfd50d1 100644 --- a/apps/web/src/components/pure/workspace-slider-bar/Pinboard.tsx +++ b/apps/web/src/components/pure/workspace-slider-bar/Pinboard.tsx @@ -41,7 +41,7 @@ export const Pinboard = ({ showOperationButton: true, }); - const { handleAdd, handleDelete, handleDrop } = usePinboardHandler({ + const { addPin, deletePin, dropPin } = usePinboardHandler({ blockSuiteWorkspace: blockSuiteWorkspace, metas: allMetas, onAdd, @@ -54,9 +54,9 @@ export const Pinboard = ({
diff --git a/apps/web/src/hooks/affine/use-reference-link.ts b/apps/web/src/hooks/affine/use-reference-link.ts new file mode 100644 index 0000000000..7cabc19104 --- /dev/null +++ b/apps/web/src/hooks/affine/use-reference-link.ts @@ -0,0 +1,42 @@ +import { useAtomValue } from 'jotai'; +import { useEffect } from 'react'; + +import { currentEditorAtom } from '../../atoms'; + +export function useReferenceLink(props?: { + pageLinkClicked?: (params: { pageId: string }) => void; + subpageLinked?: (params: { pageId: string }) => void; + subpageUnlinked?: (params: { pageId: string }) => void; +}) { + const { pageLinkClicked, subpageLinked, subpageUnlinked } = props ?? {}; + const editor = useAtomValue(currentEditorAtom); + + useEffect(() => { + if (!editor) { + return; + } + + const linkClickedDisposable = editor.slots.pageLinkClicked.on( + ({ pageId }) => { + pageLinkClicked?.({ pageId }); + } + ); + + const subpageLinkedDisposable = editor.slots.subpageLinked.on( + ({ pageId }) => { + subpageLinked?.({ pageId }); + } + ); + const subpageUnlinkedDisposable = editor.slots.subpageUnlinked.on( + ({ pageId }) => { + subpageUnlinked?.({ pageId }); + } + ); + + return () => { + linkClickedDisposable.dispose(); + subpageLinkedDisposable.dispose(); + subpageUnlinkedDisposable.dispose(); + }; + }, [editor, pageLinkClicked, subpageLinked, subpageUnlinked]); +} diff --git a/apps/web/src/hooks/use-pinboard-handler.ts b/apps/web/src/hooks/use-pinboard-handler.ts index c2ed11de99..1bea29b6ea 100644 --- a/apps/web/src/hooks/use-pinboard-handler.ts +++ b/apps/web/src/hooks/use-pinboard-handler.ts @@ -7,7 +7,7 @@ import { useCallback } from 'react'; import type { BlockSuiteWorkspace } from '../shared'; import { useBlockSuiteWorkspaceHelper } from './use-blocksuite-workspace-helper'; import { usePageMetaHelper } from './use-page-meta'; -import type { NodeRenderProps, PinboardNode } from './use-pinboard-data'; +import type { NodeRenderProps } from './use-pinboard-data'; const logger = new DebugLogger('pinboard'); @@ -25,7 +25,7 @@ export function usePinboardHandler({ onDelete, onDrop, }: { - blockSuiteWorkspace: BlockSuiteWorkspace; + blockSuiteWorkspace: BlockSuiteWorkspace | null; metas: PageMeta[]; onAdd?: (addedId: string, parentId: string) => void; onDelete?: TreeViewProps['onDelete']; @@ -34,17 +34,43 @@ export function usePinboardHandler({ const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); const { getPageMeta, setPageMeta } = usePageMetaHelper(blockSuiteWorkspace); - const handleAdd = useCallback( - (node: PinboardNode) => { - const id = nanoid(); - createPage(id, node.id); - onAdd?.(id, node.id); + // Just need handle add operation, delete check is handled in blockSuite's reference link + const addReferenceLink = useCallback( + (pageId: string, referenceId: string) => { + const page = blockSuiteWorkspace?.getPage(pageId); + if (!page) { + return; + } + const text = page.Text.fromDelta([ + { + insert: ' ', + attributes: { + reference: { + type: 'Subpage', + pageId: referenceId, + }, + }, + }, + ]); + const [frame] = page.getBlockByFlavour('affine:frame'); + + frame && page.addBlock('affine:paragraph', { text }, frame.id); }, - [createPage, onAdd] + [blockSuiteWorkspace] ); - const handleDelete = useCallback( - (node: PinboardNode) => { + const addPin = useCallback( + (parentId: string) => { + const id = nanoid(); + createPage(id, parentId); + onAdd?.(id, parentId); + addReferenceLink(parentId, id); + }, + [addReferenceLink, createPage, onAdd] + ); + + const deletePin = useCallback( + (deleteId: string) => { const removeToTrash = (currentMeta: PageMeta) => { const { subpageIds = [] } = currentMeta; setPageMeta(currentMeta.id, { @@ -52,17 +78,17 @@ export function usePinboardHandler({ trashDate: +new Date(), }); subpageIds.forEach(id => { - const subcurrentMeta = getPageMeta(id); - subcurrentMeta && removeToTrash(subcurrentMeta); + const subCurrentMeta = getPageMeta(id); + subCurrentMeta && removeToTrash(subCurrentMeta); }); }; - removeToTrash(metas.find(m => m.id === node.id)!); - onDelete?.(node); + removeToTrash(metas.find(m => m.id === deleteId)!); + onDelete?.(deleteId); }, [metas, getPageMeta, onDelete, setPageMeta] ); - const handleDrop = useCallback( + const dropPin = useCallback( ( dragId: string, dropId: string, @@ -96,7 +122,7 @@ export function usePinboardHandler({ const dropParentMeta = metas.find(m => m.subpageIds?.includes(dropId)); if (dropParentMeta?.id === dragParentMeta?.id) { - // same parent + // same parent, resort node const newSubpageIds = [...(dragParentMeta?.subpageIds ?? [])]; const deleteIndex = newSubpageIds.findIndex(id => id === dragId); newSubpageIds.splice(deleteIndex, 1); @@ -109,6 +135,7 @@ export function usePinboardHandler({ }); return onDrop?.(dragId, dropId, position); } + // Old parent will delete drag node, new parent will be added const newDragParentSubpageIds = [...(dragParentMeta?.subpageIds ?? [])]; const deleteIndex = newDragParentSubpageIds.findIndex( id => id === dragId @@ -127,6 +154,7 @@ export function usePinboardHandler({ setPageMeta(dropParentMeta.id, { subpageIds: newDropParentSubpageIds, }); + dropParentMeta && addReferenceLink(dropParentMeta.id, dragId); return onDrop?.(dragId, dropId, position); } @@ -149,14 +177,15 @@ export function usePinboardHandler({ setPageMeta(dropMeta.id, { subpageIds: newSubpageIds, }); + addReferenceLink(dropMeta.id, dragId); }, - [metas, onDrop, setPageMeta] + [addReferenceLink, metas, onDrop, setPageMeta] ); return { - handleDrop, - handleAdd, - handleDelete, + dropPin, + addPin, + deletePin, }; } diff --git a/apps/web/src/hooks/use-router-helper.ts b/apps/web/src/hooks/use-router-helper.ts index eda05b2cdd..9232e09878 100644 --- a/apps/web/src/hooks/use-router-helper.ts +++ b/apps/web/src/hooks/use-router-helper.ts @@ -1,5 +1,5 @@ import type { NextRouter } from 'next/router'; -import { useMemo } from 'react'; +import { useCallback } from 'react'; import type { WorkspaceSubPath } from '../shared'; @@ -9,47 +9,70 @@ export const enum RouteLogic { } export function useRouterHelper(router: NextRouter) { - return useMemo( - () => ({ - jumpToPage: ( - workspaceId: string, - pageId: string, - logic: RouteLogic = RouteLogic.PUSH - ) => { - return router[logic]({ - pathname: `/workspace/[workspaceId]/[pageId]`, - query: { - workspaceId, - pageId, - }, - }); - }, - jumpToPublicWorkspacePage: ( - workspaceId: string, - pageId: string, - logic: RouteLogic = RouteLogic.PUSH - ) => { - return router[logic]({ - pathname: `/public-workspace/[workspaceId]/[pageId]`, - query: { - workspaceId, - pageId, - }, - }); - }, - jumpToSubPath: ( - workspaceId: string, - subPath: WorkspaceSubPath, - logic: RouteLogic = RouteLogic.PUSH - ): Promise => { - return router[logic]({ - pathname: `/workspace/[workspaceId]/${subPath}`, - query: { - workspaceId, - }, - }); - }, - }), + const jumpToPage = useCallback( + ( + workspaceId: string, + pageId: string, + logic: RouteLogic = RouteLogic.PUSH + ) => { + return router[logic]({ + pathname: `/workspace/[workspaceId]/[pageId]`, + query: { + workspaceId, + pageId, + }, + }); + }, [router] ); + const jumpToPublicWorkspacePage = useCallback( + ( + workspaceId: string, + pageId: string, + logic: RouteLogic = RouteLogic.PUSH + ) => { + return router[logic]({ + pathname: `/public-workspace/[workspaceId]/[pageId]`, + query: { + workspaceId, + pageId, + }, + }); + }, + [router] + ); + const jumpToSubPath = useCallback( + ( + workspaceId: string, + subPath: WorkspaceSubPath, + logic: RouteLogic = RouteLogic.PUSH + ): Promise => { + return router[logic]({ + pathname: `/workspace/[workspaceId]/${subPath}`, + query: { + workspaceId, + }, + }); + }, + [router] + ); + const openPage = useCallback( + (workspaceId: string, pageId: string) => { + const isPublicWorkspace = + router.pathname.split('/')[1] === 'public-workspace'; + if (isPublicWorkspace) { + return jumpToPublicWorkspacePage(workspaceId, pageId); + } else { + return jumpToPage(workspaceId, pageId); + } + }, + [jumpToPage, jumpToPublicWorkspacePage, router.pathname] + ); + + return { + jumpToPage, + jumpToPublicWorkspacePage, + jumpToSubPath, + openPage, + }; } diff --git a/apps/web/src/layouts/index.tsx b/apps/web/src/layouts/index.tsx index 142f411a54..5a19477160 100644 --- a/apps/web/src/layouts/index.tsx +++ b/apps/web/src/layouts/index.tsx @@ -233,7 +233,7 @@ export const WorkspaceLayoutInner: FC = ({ children }) => { } }, [currentWorkspace]); const router = useRouter(); - const { jumpToPage, jumpToPublicWorkspacePage } = useRouterHelper(router); + const { openPage } = useRouterHelper(router); const [, setOpenWorkspacesModal] = useAtom(openWorkspacesModalAtom); const helper = useBlockSuiteWorkspaceHelper( currentWorkspace?.blockSuiteWorkspace ?? null @@ -241,17 +241,6 @@ export const WorkspaceLayoutInner: FC = ({ children }) => { const isPublicWorkspace = router.pathname.split('/')[1] === 'public-workspace'; const title = useRouterTitle(router); - const handleOpenPage = useCallback( - (pageId: string) => { - assertExists(currentWorkspace); - if (isPublicWorkspace) { - jumpToPublicWorkspacePage(currentWorkspace.id, pageId); - } else { - jumpToPage(currentWorkspace.id, pageId); - } - }, - [currentWorkspace, isPublicWorkspace, jumpToPage, jumpToPublicWorkspacePage] - ); const handleCreatePage = useCallback(() => { return helper.createPage(nanoid()); }, [helper]); @@ -319,7 +308,13 @@ export const WorkspaceLayoutInner: FC = ({ children }) => { currentWorkspace={currentWorkspace} currentPageId={currentPageId} onOpenWorkspaceListModal={handleOpenWorkspaceListModal} - openPage={handleOpenPage} + openPage={useCallback( + (pageId: string) => { + assertExists(currentWorkspace); + return openPage(currentWorkspace.id, pageId); + }, + [currentWorkspace, openPage] + )} createPage={handleCreatePage} currentPath={router.asPath.split('?')[0]} paths={isPublicWorkspace ? publicPathGenerator : pathGenerator} diff --git a/apps/web/src/pages/public-workspace/[workspaceId]/[pageId].tsx b/apps/web/src/pages/public-workspace/[workspaceId]/[pageId].tsx index abcae19af3..ae09ea31b8 100644 --- a/apps/web/src/pages/public-workspace/[workspaceId]/[pageId].tsx +++ b/apps/web/src/pages/public-workspace/[workspaceId]/[pageId].tsx @@ -1,13 +1,14 @@ import { Breadcrumbs, displayFlex, styled } from '@affine/component'; import { useTranslation } from '@affine/i18n'; import { PageIcon } from '@blocksuite/icons'; +import { assertExists } from '@blocksuite/store'; import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-blocksuite-workspace-avatar-url'; import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-blocksuite-workspace-name'; import { useAtomValue, useSetAtom } from 'jotai'; import Link from 'next/link'; import { useRouter } from 'next/router'; import type React from 'react'; -import { Suspense, useEffect } from 'react'; +import { Suspense, useCallback, useEffect } from 'react'; import { publicPageBlockSuiteAtom, @@ -18,6 +19,8 @@ import { QueryParamError } from '../../../components/affine/affine-error-eoundar import { PageDetailEditor } from '../../../components/page-detail-editor'; import { WorkspaceAvatar } from '../../../components/pure/footer'; import { PageLoading } from '../../../components/pure/loading'; +import { useReferenceLink } from '../../../hooks/affine/use-reference-link'; +import { useRouterHelper } from '../../../hooks/use-router-helper'; import { PublicWorkspaceLayout } from '../../../layouts/public-workspace-layout'; import type { NextPageWithLayout } from '../../../shared'; import { initPage } from '../../../utils'; @@ -56,9 +59,21 @@ const PublicWorkspaceDetailPageInner: React.FC<{ if (!blockSuiteWorkspace) { throw new Error('cannot find workspace'); } + const router = useRouter(); + const { openPage } = useRouterHelper(router); + useEffect(() => { blockSuiteWorkspace.awarenessStore.setFlag('enable_block_hub', false); }, [blockSuiteWorkspace]); + useReferenceLink({ + pageLinkClicked: useCallback( + ({ pageId }: { pageId: string }) => { + assertExists(currentWorkspace); + return openPage(blockSuiteWorkspace.id, pageId); + }, + [blockSuiteWorkspace.id, openPage] + ), + }); const { t } = useTranslation(); const [name] = useBlockSuiteWorkspaceName(blockSuiteWorkspace); const [avatar] = useBlockSuiteWorkspaceAvatarUrl(blockSuiteWorkspace); diff --git a/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx b/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx index 244bb2f42d..2bc29df93a 100644 --- a/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx +++ b/apps/web/src/pages/workspace/[workspaceId]/[pageId].tsx @@ -1,13 +1,18 @@ import { WorkspaceFlavour } from '@affine/workspace/type'; +import { assertExists } from '@blocksuite/store'; import { useRouter } from 'next/router'; import type React from 'react'; -import { useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { Unreachable } from '../../../components/affine/affine-error-eoundary'; import { PageLoading } from '../../../components/pure/loading'; +import { useReferenceLink } from '../../../hooks/affine/use-reference-link'; import { useCurrentPageId } from '../../../hooks/current/use-current-page-id'; import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; +import { usePageMeta } from '../../../hooks/use-page-meta'; +import { usePinboardHandler } from '../../../hooks/use-pinboard-handler'; import { useSyncRecentViewsWithRouter } from '../../../hooks/use-recent-views'; +import { useRouterHelper } from '../../../hooks/use-router-helper'; import { useSyncRouterWithCurrentWorkspaceAndPage } from '../../../hooks/use-sync-router-with-current-workspace-and-page'; import { WorkspaceLayout } from '../../../layouts'; import { WorkspacePlugins } from '../../../plugins'; @@ -21,12 +26,37 @@ function enableFullFlags(blockSuiteWorkspace: BlockSuiteWorkspace) { blockSuiteWorkspace.awarenessStore.setFlag('enable_block_hub', true); blockSuiteWorkspace.awarenessStore.setFlag('enable_drag_handle', true); blockSuiteWorkspace.awarenessStore.setFlag('enable_surface', true); + blockSuiteWorkspace.awarenessStore.setFlag('enable_linked_page', true); } const WorkspaceDetail: React.FC = () => { + const router = useRouter(); + const { openPage } = useRouterHelper(router); const [pageId] = useCurrentPageId(); const [currentWorkspace] = useCurrentWorkspace(); - useSyncRecentViewsWithRouter(useRouter()); + + const { deletePin } = usePinboardHandler({ + blockSuiteWorkspace: currentWorkspace?.blockSuiteWorkspace ?? null, + metas: usePageMeta(currentWorkspace?.blockSuiteWorkspace ?? null ?? null), + }); + + useSyncRecentViewsWithRouter(router); + + useReferenceLink({ + pageLinkClicked: useCallback( + ({ pageId }: { pageId: string }) => { + assertExists(currentWorkspace); + return openPage(currentWorkspace.id, pageId); + }, + [currentWorkspace, openPage] + ), + subpageUnlinked: useCallback( + ({ pageId }: { pageId: string }) => { + deletePin(pageId); + }, + [deletePin] + ), + }); useEffect(() => { if (currentWorkspace) { enableFullFlags(currentWorkspace.blockSuiteWorkspace); diff --git a/packages/component/src/ui/tree-view/TreeNode.tsx b/packages/component/src/ui/tree-view/TreeNode.tsx index de70cd3b0a..496ae8f7cf 100644 --- a/packages/component/src/ui/tree-view/TreeNode.tsx +++ b/packages/component/src/ui/tree-view/TreeNode.tsx @@ -110,8 +110,8 @@ const TreeNodeItem = ({
{node.render?.(node, { isOver: isOver && canDrop, - onAdd: () => onAdd?.(node), - onDelete: () => onDelete?.(node), + onAdd: () => onAdd?.(node.id), + onDelete: () => onDelete?.(node.id), collapsed, setCollapsed, isSelected: selectedId === node.id, diff --git a/packages/component/src/ui/tree-view/types.ts b/packages/component/src/ui/tree-view/types.ts index b63a87a997..1b2be07962 100644 --- a/packages/component/src/ui/tree-view/types.ts +++ b/packages/component/src/ui/tree-view/types.ts @@ -34,8 +34,8 @@ type CommonProps = { enableDnd?: boolean; enableKeyboardSelection?: boolean; indent?: CSSProperties['paddingLeft']; - onAdd?: (node: Node) => void; - onDelete?: (node: Node) => void; + onAdd?: (parentId: string) => void; + onDelete?: (deleteId: string) => void; onDrop?: OnDrop; // Only trigger when the enableKeyboardSelection is true onSelect?: (id: string) => void; diff --git a/tests/parallels/pin-board.spec.ts b/tests/parallels/pin-board.spec.ts index 163ab814fb..a79b2adf55 100644 --- a/tests/parallels/pin-board.spec.ts +++ b/tests/parallels/pin-board.spec.ts @@ -34,6 +34,16 @@ async function openPinboardPageOperationMenu(page: Page, id: string) { await node.getByTestId('pinboard-operation-button').click(); } +async function checkIsChildInsertToParentInEditor(page: Page, pageId: string) { + await page + .getByTestId('sidebar-pinboard-container') + .getByTestId(`pinboard-${pageId}`) + .click(); + await page.waitForTimeout(200); + const referenceLink = await page.locator('.affine-reference'); + expect(referenceLink).not.toBeNull(); +} + test.describe('PinBoard interaction', () => { test('Have initial root pinboard page when first in', async ({ page }) => { const rootPinboardMeta = await initHomePageWithPinboard(page); @@ -77,6 +87,7 @@ test.describe('PinBoard interaction', () => { .getByTestId('[data-testid="sidebar-pinboard-container"]') .getByTestId(`pinboard-${meta?.id}`) ).not.toBeNull(); + await checkIsChildInsertToParentInEditor(page, rootPinboardMeta?.id ?? ''); }); test('Add pinboard by sidebar operation menu', async ({ page }) => { @@ -92,6 +103,8 @@ test.describe('PinBoard interaction', () => { .getByTestId('sidebar-pinboard-container') .getByTestId(`pinboard-${newPageMeta?.id}`) ).not.toBeNull(); + console.log('rootPinboardMeta', rootPinboardMeta); + await checkIsChildInsertToParentInEditor(page, rootPinboardMeta?.id ?? ''); }); test('Move pinboard to another in sidebar', async ({ page }) => { @@ -116,7 +129,6 @@ test.describe('PinBoard interaction', () => { await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test1'); await createPinboardPage(page, rootPinboardMeta?.id ?? '', 'test2'); const childMeta = (await getMetas(page)).find(m => m.title === 'test1'); - const childMeta2 = (await getMetas(page)).find(m => m.title === 'test2'); await page.getByTestId('all-pages').click(); await page