feat(core): add editor commanads (#4514)

Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
JimmFly
2023-10-02 11:22:12 +08:00
committed by GitHub
parent aab1a1e50a
commit 69db99636b
29 changed files with 673 additions and 413 deletions

View File

@@ -0,0 +1,13 @@
import { atom } from 'jotai';
export type TrashModal = {
open: boolean;
pageId: string;
pageTitle: string;
};
export const trashModalAtom = atom<TrashModal>({
open: false,
pageId: '',
pageTitle: '',
});

View File

@@ -17,14 +17,14 @@ export function registerAffineNavigationCommands({
store, store,
workspace, workspace,
navigationHelper, navigationHelper,
pageMode, pageListMode,
setPageMode, setPageListMode,
}: { }: {
t: ReturnType<typeof useAFFiNEI18N>; t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>; store: ReturnType<typeof createStore>;
navigationHelper: ReturnType<typeof useNavigateHelper>; navigationHelper: ReturnType<typeof useNavigateHelper>;
pageMode: PageModeOption; pageListMode: PageModeOption;
setPageMode: React.Dispatch<React.SetStateAction<PageModeOption>>; setPageListMode: React.Dispatch<React.SetStateAction<PageModeOption>>;
workspace: Workspace; workspace: Workspace;
}) { }) {
const unsubs: Array<() => void> = []; const unsubs: Array<() => void> = [];
@@ -36,7 +36,7 @@ export function registerAffineNavigationCommands({
label: () => t['com.affine.cmdk.affine.navigation.goto-all-pages'](), label: () => t['com.affine.cmdk.affine.navigation.goto-all-pages'](),
run() { run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
setPageMode('all'); setPageListMode('all');
}, },
}) })
); );
@@ -47,12 +47,12 @@ export function registerAffineNavigationCommands({
category: 'affine:navigation', category: 'affine:navigation',
icon: <ArrowRightBigIcon />, icon: <ArrowRightBigIcon />,
preconditionStrategy: () => { preconditionStrategy: () => {
return pageMode !== 'page'; return pageListMode !== 'page';
}, },
label: () => t['com.affine.cmdk.affine.navigation.goto-page-list'](), label: () => t['com.affine.cmdk.affine.navigation.goto-page-list'](),
run() { run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
setPageMode('page'); setPageListMode('page');
}, },
}) })
); );
@@ -63,12 +63,12 @@ export function registerAffineNavigationCommands({
category: 'affine:navigation', category: 'affine:navigation',
icon: <ArrowRightBigIcon />, icon: <ArrowRightBigIcon />,
preconditionStrategy: () => { preconditionStrategy: () => {
return pageMode !== 'edgeless'; return pageListMode !== 'edgeless';
}, },
label: () => t['com.affine.cmdk.affine.navigation.goto-edgeless-list'](), label: () => t['com.affine.cmdk.affine.navigation.goto-edgeless-list'](),
run() { run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
setPageMode('edgeless'); setPageListMode('edgeless');
}, },
}) })
); );
@@ -109,7 +109,7 @@ export function registerAffineNavigationCommands({
label: () => t['com.affine.cmdk.affine.navigation.goto-trash'](), label: () => t['com.affine.cmdk.affine.navigation.goto-trash'](),
run() { run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.TRASH); navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.TRASH);
setPageMode('all'); setPageListMode('all');
}, },
}) })
); );

View File

@@ -112,7 +112,7 @@ globalStyle(`${accountButton} .avatar`, {
width: '28px', width: '28px',
height: '28px', height: '28px',
borderRadius: '50%', borderRadius: '50%',
fontSize: '22px', fontSize: '20px',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@@ -120,9 +120,10 @@ globalStyle(`${accountButton} .avatar`, {
}); });
globalStyle(`${accountButton} .avatar.not-sign`, { globalStyle(`${accountButton} .avatar.not-sign`, {
borderColor: 'var(--affine-icon-secondary)',
color: 'var(--affine-icon-secondary)', color: 'var(--affine-icon-secondary)',
background: 'var(--affine-white)', background: 'var(--affine-white)',
paddingBottom: '2px',
border: '1px solid var(--affine-icon-secondary)',
}); });
globalStyle(`${accountButton} .content`, { globalStyle(`${accountButton} .content`, {
flexGrow: '1', flexGrow: '1',

View File

@@ -6,6 +6,7 @@ import {
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
import { useState } from 'react'; import { useState } from 'react';
import { useExportPage } from '../../../hooks/affine/use-export-page';
import { useIsSharedPage } from '../../../hooks/affine/use-is-shared-page'; import { useIsSharedPage } from '../../../hooks/affine/use-is-shared-page';
import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace'; import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace';
import { EnableAffineCloudModal } from '../enable-affine-cloud-modal'; import { EnableAffineCloudModal } from '../enable-affine-cloud-modal';
@@ -18,6 +19,7 @@ type SharePageModalProps = {
export const SharePageModal = ({ workspace, page }: SharePageModalProps) => { export const SharePageModal = ({ workspace, page }: SharePageModalProps) => {
const onTransformWorkspace = useOnTransformWorkspace(); const onTransformWorkspace = useOnTransformWorkspace();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const exportHandler = useExportPage(page);
return ( return (
<> <>
<ShareMenu <ShareMenu
@@ -26,6 +28,7 @@ export const SharePageModal = ({ workspace, page }: SharePageModalProps) => {
useIsSharedPage={useIsSharedPage} useIsSharedPage={useIsSharedPage}
onEnableAffineCloud={() => setOpen(true)} onEnableAffineCloud={() => setOpen(true)}
togglePagePublic={async () => {}} togglePagePublic={async () => {}}
exportHandler={exportHandler}
/> />
{workspace.flavour === WorkspaceFlavour.LOCAL ? ( {workspace.flavour === WorkspaceFlavour.LOCAL ? (
<EnableAffineCloudModal <EnableAffineCloudModal

View File

@@ -23,12 +23,15 @@ import {
usePageMetaHelper, usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta'; } from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper'; import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { useAtom, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback, useRef, useState } from 'react'; import { useCallback, useRef } from 'react';
import { applyUpdate, encodeStateAsUpdate } from 'yjs'; import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { pageSettingFamily, setPageModeAtom } from '../../../atoms'; import { setPageModeAtom } from '../../../atoms';
import { currentModeAtom } from '../../../atoms/mode';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useExportPage } from '../../../hooks/affine/use-export-page';
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { toast } from '../../../utils'; import { toast } from '../../../utils';
@@ -43,72 +46,81 @@ type PageMenuProps = {
export const PageMenu = ({ rename, pageId }: PageMenuProps) => { export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const ref = useRef(null); const ref = useRef(null);
const { openPage } = useNavigateHelper();
// fixme(himself65): remove these hooks ASAP // fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace(); const [workspace] = useCurrentWorkspace();
const blockSuiteWorkspace = workspace.blockSuiteWorkspace; const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const currentPage = blockSuiteWorkspace.getPage(pageId);
assertExists(currentPage);
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId meta => meta.id === pageId
) as PageMeta; ) as PageMeta;
const [setting, setSetting] = useAtom(pageSettingFamily(pageId)); const currentMode = useAtomValue(currentModeAtom);
const mode = setting?.mode ?? 'page';
const favorite = pageMeta.favorite ?? false; const favorite = pageMeta.favorite ?? false;
const { setPageMeta, setPageTitle } = usePageMetaHelper(blockSuiteWorkspace); const { setPageMeta, setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
const [openConfirm, setOpenConfirm] = useState(false); const { togglePageMode, toggleFavorite } =
const { removeToTrash } = useBlockSuiteMetaHelper(blockSuiteWorkspace); useBlockSuiteMetaHelper(blockSuiteWorkspace);
const { importFile } = usePageHelper(blockSuiteWorkspace); const { importFile } = usePageHelper(blockSuiteWorkspace);
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
const handleOpenTrashModal = useCallback(() => {
setTrashModal({
open: true,
pageId,
pageTitle: pageMeta.title,
});
}, [pageId, pageMeta.title, setTrashModal]);
const handleFavorite = useCallback(() => { const handleFavorite = useCallback(() => {
setPageMeta(pageId, { favorite: !favorite }); toggleFavorite(pageId);
toast( toast(
favorite favorite
? t['com.affine.toastMessage.removedFavorites']() ? t['com.affine.toastMessage.removedFavorites']()
: t['com.affine.toastMessage.addedFavorites']() : t['com.affine.toastMessage.addedFavorites']()
); );
}, [favorite, pageId, setPageMeta, t]); }, [favorite, pageId, t, toggleFavorite]);
const handleSwitchMode = useCallback(() => { const handleSwitchMode = useCallback(() => {
setSetting(setting => ({ togglePageMode(pageId);
mode: setting?.mode === 'page' ? 'edgeless' : 'page',
}));
toast( toast(
mode === 'page' currentMode === 'page'
? t['com.affine.toastMessage.edgelessMode']() ? t['com.affine.toastMessage.edgelessMode']()
: t['com.affine.toastMessage.pageMode']() : t['com.affine.toastMessage.pageMode']()
); );
}, [mode, setSetting, t]); }, [currentMode, pageId, t, togglePageMode]);
const handleOnConfirm = useCallback(() => {
removeToTrash(pageId);
toast(t['com.affine.toastMessage.movedTrash']());
setOpenConfirm(false);
}, [pageId, removeToTrash, t]);
const menuItemStyle = { const menuItemStyle = {
padding: '4px 12px', padding: '4px 12px',
transition: 'all 0.3s', transition: 'all 0.3s',
}; };
const { openPage } = useNavigateHelper();
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace); const exportHandler = useExportPage(currentPage);
const setPageMode = useSetAtom(setPageModeAtom); const setPageMode = useSetAtom(setPageModeAtom);
const duplicate = useCallback(async () => { const duplicate = useCallback(async () => {
const currentPage = blockSuiteWorkspace.getPage(pageId);
assertExists(currentPage);
const currentPageMeta = currentPage.meta; const currentPageMeta = currentPage.meta;
const newPage = createPage(); const newPage = createPage();
await newPage.waitForLoaded(); await newPage.waitForLoaded();
const update = encodeStateAsUpdate(currentPage.spaceDoc); const update = encodeStateAsUpdate(currentPage.spaceDoc);
applyUpdate(newPage.spaceDoc, update); applyUpdate(newPage.spaceDoc, update);
setPageMeta(newPage.id, { setPageMeta(newPage.id, {
tags: currentPageMeta.tags, tags: currentPageMeta.tags,
favorite: currentPageMeta.favorite, favorite: currentPageMeta.favorite,
}); });
setPageMode(newPage.id, mode); setPageMode(newPage.id, currentMode);
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`); setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
openPage(blockSuiteWorkspace.id, newPage.id); openPage(blockSuiteWorkspace.id, newPage.id);
}, [ }, [
blockSuiteWorkspace, blockSuiteWorkspace.id,
createPage, createPage,
mode, currentMode,
currentPage.meta,
currentPage.spaceDoc,
openPage, openPage,
pageId,
setPageMeta, setPageMeta,
setPageMode, setPageMode,
setPageTitle, setPageTitle,
@@ -130,7 +142,7 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
<MenuItem <MenuItem
preFix={ preFix={
<MenuIcon> <MenuIcon>
{mode === 'page' ? <EdgelessIcon /> : <PageIcon />} {currentMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
</MenuIcon> </MenuIcon>
} }
data-testid="editor-option-menu-edgeless" data-testid="editor-option-menu-edgeless"
@@ -138,7 +150,7 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
style={menuItemStyle} style={menuItemStyle}
> >
{t['Convert to ']()} {t['Convert to ']()}
{mode === 'page' {currentMode === 'page'
? t['com.affine.pageMode.edgeless']() ? t['com.affine.pageMode.edgeless']()
: t['com.affine.pageMode.page']()} : t['com.affine.pageMode.page']()}
</MenuItem> </MenuItem>
@@ -194,13 +206,11 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
> >
{t['Import']()} {t['Import']()}
</MenuItem> </MenuItem>
<Export /> <Export exportHandler={exportHandler} />
<MenuSeparator /> <MenuSeparator />
<MoveToTrash <MoveToTrash
data-testid="editor-option-menu-delete" data-testid="editor-option-menu-delete"
onSelect={() => { onSelect={handleOpenTrashModal}
setOpenConfirm(true);
}}
/> />
</> </>
); );
@@ -218,12 +228,6 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
> >
<HeaderDropDownButton /> <HeaderDropDownButton />
</Menu> </Menu>
<MoveToTrash.ConfirmModal
open={openConfirm}
title={pageMeta.title}
onConfirm={handleOnConfirm}
onOpenChange={setOpenConfirm}
/>
</FlexWrapper> </FlexWrapper>
</> </>
); );

View File

@@ -2,11 +2,12 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils'; import { assertExists } from '@blocksuite/global/utils';
import { Tooltip } from '@toeverything/components/tooltip'; import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta'; import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtom } from 'jotai'; import { useAtomValue } from 'jotai';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import { useEffect } from 'react'; import { useCallback, useEffect } from 'react';
import { pageSettingFamily } from '../../../atoms'; import { currentModeAtom } from '../../../atoms/mode';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import type { BlockSuiteWorkspace } from '../../../shared'; import type { BlockSuiteWorkspace } from '../../../shared';
import { toast } from '../../../utils'; import { toast } from '../../../utils';
import { StyledEditorModeSwitch, StyledKeyboardItem } from './style'; import { StyledEditorModeSwitch, StyledKeyboardItem } from './style';
@@ -34,14 +35,17 @@ export const EditorModeSwitch = ({
blockSuiteWorkspace, blockSuiteWorkspace,
pageId, pageId,
}: EditorModeSwitchProps) => { }: EditorModeSwitchProps) => {
const [setting, setSetting] = useAtom(pageSettingFamily(pageId)); const t = useAFFiNEI18N();
const currentMode = setting?.mode ?? 'page';
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId meta => meta.id === pageId
); );
const t = useAFFiNEI18N();
assertExists(pageMeta); assertExists(pageMeta);
const { trash } = pageMeta; const { trash } = pageMeta;
const { togglePageMode, switchToEdgelessMode, switchToPageMode } =
useBlockSuiteMetaHelper(blockSuiteWorkspace);
const currentMode = useAtomValue(currentModeAtom);
useEffect(() => { useEffect(() => {
if (trash) { if (trash) {
return; return;
@@ -53,21 +57,33 @@ export const EditorModeSwitch = ({
: e.key === 's' && e.altKey : e.key === 's' && e.altKey
) { ) {
e.preventDefault(); e.preventDefault();
setSetting(setting => { togglePageMode(pageId);
if (setting?.mode !== 'page') { toast(
toast(t['com.affine.toastMessage.pageMode']()); currentMode === 'page'
return { ...setting, mode: 'page' }; ? t['com.affine.toastMessage.edgelessMode']()
} else { : t['com.affine.toastMessage.pageMode']()
toast(t['com.affine.toastMessage.edgelessMode']()); );
return { ...setting, mode: 'edgeless' };
}
});
} }
}; };
document.addEventListener('keydown', keydown, { capture: true }); document.addEventListener('keydown', keydown, { capture: true });
return () => return () =>
document.removeEventListener('keydown', keydown, { capture: true }); document.removeEventListener('keydown', keydown, { capture: true });
}, [setSetting, t, trash]); }, [currentMode, pageId, t, togglePageMode, trash]);
const onSwitchToPageMode = useCallback(() => {
if (currentMode === 'page') {
return;
}
switchToPageMode(pageId);
toast(t['com.affine.toastMessage.pageMode']());
}, [currentMode, pageId, switchToPageMode, t]);
const onSwitchToEdgelessMode = useCallback(() => {
if (currentMode === 'edgeless') {
return;
}
switchToEdgelessMode(pageId);
toast(t['com.affine.toastMessage.edgelessMode']());
}, [currentMode, pageId, switchToEdgelessMode, t]);
return ( return (
<Tooltip content={<TooltipContent />}> <Tooltip content={<TooltipContent />}>
@@ -81,28 +97,14 @@ export const EditorModeSwitch = ({
active={currentMode === 'page'} active={currentMode === 'page'}
hide={trash && currentMode !== 'page'} hide={trash && currentMode !== 'page'}
trash={trash} trash={trash}
onClick={() => { onClick={onSwitchToPageMode}
setSetting(setting => {
if (setting?.mode !== 'page') {
toast(t['com.affine.toastMessage.pageMode']());
}
return { ...setting, mode: 'page' };
});
}}
/> />
<EdgelessSwitchItem <EdgelessSwitchItem
data-testid="switch-edgeless-mode-button" data-testid="switch-edgeless-mode-button"
active={currentMode === 'edgeless'} active={currentMode === 'edgeless'}
hide={trash && currentMode !== 'edgeless'} hide={trash && currentMode !== 'edgeless'}
trash={trash} trash={trash}
onClick={() => { onClick={onSwitchToEdgelessMode}
setSetting(setting => {
if (setting?.mode !== 'edgeless') {
toast(t['com.affine.toastMessage.edgelessMode']());
}
return { ...setting, mode: 'edgeless' };
});
}}
/> />
</StyledEditorModeSwitch> </StyledEditorModeSwitch>
</Tooltip> </Tooltip>

View File

@@ -15,6 +15,7 @@ import { Suspense, useCallback, useMemo } from 'react';
import { allPageModeSelectAtom } from '../../../atoms'; import { allPageModeSelectAtom } from '../../../atoms';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
import { useGetPageInfoById } from '../../../hooks/use-get-page-info'; import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
import type { BlockSuiteWorkspace } from '../../../shared'; import type { BlockSuiteWorkspace } from '../../../shared';
import { toast } from '../../../utils'; import { toast } from '../../../utils';
@@ -134,7 +135,6 @@ export const BlockSuitePageList = ({
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace); const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
const { const {
toggleFavorite, toggleFavorite,
removeToTrash,
restoreFromTrash, restoreFromTrash,
permanentlyDeletePage, permanentlyDeletePage,
cancelPublicPage, cancelPublicPage,
@@ -144,6 +144,8 @@ export const BlockSuitePageList = ({
usePageHelper(blockSuiteWorkspace); usePageHelper(blockSuiteWorkspace);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const getPageInfo = useGetPageInfoById(blockSuiteWorkspace); const getPageInfo = useGetPageInfoById(blockSuiteWorkspace);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
const tagOptionMap = useMemo( const tagOptionMap = useMemo(
() => () =>
Object.fromEntries( Object.fromEntries(
@@ -246,10 +248,13 @@ export const BlockSuitePageList = ({
onClickRestore: () => { onClickRestore: () => {
restoreFromTrash(pageMeta.id); restoreFromTrash(pageMeta.id);
}, },
removeToTrash: () => { removeToTrash: () =>
removeToTrash(pageMeta.id); setTrashModal({
toast(t['com.affine.toastMessage.successfullyDeleted']()); open: true,
}, pageId: pageMeta.id,
pageTitle: pageMeta.title,
}),
onRestorePage: () => { onRestorePage: () => {
restoreFromTrash(pageMeta.id); restoreFromTrash(pageMeta.id);
toast( toast(

View File

@@ -133,7 +133,23 @@ globalStyle(`${root} [cmdk-item]`, {
globalStyle(`${root} [cmdk-item][data-selected=true]`, { globalStyle(`${root} [cmdk-item][data-selected=true]`, {
background: 'var(--affine-background-secondary-color)', background: 'var(--affine-background-secondary-color)',
}); });
globalStyle(`${root} [cmdk-item][data-selected=true][data-is-danger=true]`, {
background: 'var(--affine-background-error-color)',
color: 'var(--affine-error-color)',
});
globalStyle(`${root} [cmdk-item][data-selected=true] ${itemIcon}`, { globalStyle(`${root} [cmdk-item][data-selected=true] ${itemIcon}`, {
color: 'var(--affine-icon-color)', color: 'var(--affine-icon-color)',
}); });
globalStyle(
`${root} [cmdk-item][data-selected=true][data-is-danger=true] ${itemIcon}`,
{
color: 'var(--affine-error-color)',
}
);
globalStyle(
`${root} [cmdk-item][data-selected=true][data-is-danger=true] ${itemLabel}`,
{
color: 'var(--affine-error-color)',
}
);

View File

@@ -64,6 +64,10 @@ const QuickSearchGroup = ({
onOpenChange?.(false); onOpenChange?.(false);
}} }}
value={command.value} value={command.value}
data-is-danger={
command.id === 'editor:page-move-to-trash' ||
command.id === 'editor:edgeless-move-to-trash'
}
> >
<div className={styles.itemIcon}>{command.icon}</div> <div className={styles.itemIcon}>{command.icon}</div>
<div <div

View File

@@ -24,7 +24,7 @@ import React, { useCallback, useMemo } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { pageSettingFamily } from '../../../../atoms'; import { pageSettingFamily } from '../../../../atoms';
import { useBlockSuiteMetaHelper } from '../../../../hooks/affine/use-block-suite-meta-helper'; import { useTrashModalHelper } from '../../../../hooks/affine/use-trash-modal-helper';
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper'; import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
import { ReferencePage } from '../components/reference-page'; import { ReferencePage } from '../components/reference-page';
import * as styles from './styles.css'; import * as styles from './styles.css';
@@ -44,8 +44,15 @@ export const PageOperations = ({
inExcludeList: boolean; inExcludeList: boolean;
addToExcludeList: (id: string) => void; addToExcludeList: (id: string) => void;
}) => { }) => {
const { removeToTrash } = useBlockSuiteMetaHelper(workspace);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const { setTrashModal } = useTrashModalHelper(workspace);
const onClickDelete = useCallback(() => {
setTrashModal({
open: true,
pageId: page.id,
pageTitle: page.title,
});
}, [page.id, page.title, setTrashModal]);
const actions = useMemo< const actions = useMemo<
Array< Array<
| { | {
@@ -97,9 +104,7 @@ export const PageOperations = ({
</MenuIcon> </MenuIcon>
), ),
name: t['com.affine.trashOperation.delete'](), name: t['com.affine.trashOperation.delete'](),
click: () => { click: onClickDelete,
removeToTrash(page.id);
},
type: 'danger', type: 'danger',
}, },
], ],
@@ -107,10 +112,10 @@ export const PageOperations = ({
inAllowList, inAllowList,
t, t,
inExcludeList, inExcludeList,
onClickDelete,
removeFromAllowList, removeFromAllowList,
page.id, page.id,
addToExcludeList, addToExcludeList,
removeToTrash,
] ]
); );
return ( return (

View File

@@ -10,7 +10,7 @@ import {
SidebarContainer, SidebarContainer,
SidebarScrollableContainer, SidebarScrollableContainer,
} from '@affine/component/app-sidebar'; } from '@affine/component/app-sidebar';
import { useCollectionManager } from '@affine/component/page-list'; import { MoveToTrash, useCollectionManager } from '@affine/component/page-list';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { import {
DeleteTemporarilyIcon, DeleteTemporarilyIcon,
@@ -27,6 +27,7 @@ import { forwardRef, useCallback, useEffect, useMemo } from 'react';
import { openWorkspaceListModalAtom } from '../../atoms'; import { openWorkspaceListModalAtom } from '../../atoms';
import { useHistoryAtom } from '../../atoms/history'; import { useHistoryAtom } from '../../atoms/history';
import { useAppSetting } from '../../atoms/settings'; import { useAppSetting } from '../../atoms/settings';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import type { AllWorkspace } from '../../shared'; import type { AllWorkspace } from '../../shared';
import { currentCollectionsAtom } from '../../utils/user-setting'; import { currentCollectionsAtom } from '../../utils/user-setting';
import { CollectionsList } from '../pure/workspace-slider-bar/collections'; import { CollectionsList } from '../pure/workspace-slider-bar/collections';
@@ -110,6 +111,20 @@ export const RootAppSidebar = ({
openPage(page.id); openPage(page.id);
}, [createPage, openPage]); }, [createPage, openPage]);
const { trashModal, setTrashModal, handleOnConfirm } =
useTrashModalHelper(blockSuiteWorkspace);
const deletePageTitle = trashModal.pageTitle;
const trashConfirmOpen = trashModal.open;
const onTrashConfirmOpenChange = useCallback(
(open: boolean) => {
setTrashModal({
...trashModal,
open,
});
},
[trashModal, setTrashModal]
);
// Listen to the "New Page" action from the menu // Listen to the "New Page" action from the menu
useEffect(() => { useEffect(() => {
if (environment.isDesktop) { if (environment.isDesktop) {
@@ -159,6 +174,12 @@ export const RootAppSidebar = ({
) )
} }
> >
<MoveToTrash.ConfirmModal
open={trashConfirmOpen}
onConfirm={handleOnConfirm}
onOpenChange={onTrashConfirmOpenChange}
title={deletePageTitle}
/>
<SidebarContainer> <SidebarContainer>
<Menu <Menu
rootOptions={{ rootOptions={{

View File

@@ -2,8 +2,11 @@ import {
useBlockSuitePageMeta, useBlockSuitePageMeta,
usePageMetaHelper, usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta'; } from '@toeverything/hooks/use-block-suite-page-meta';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { setPageModeAtom } from '../../atoms';
import { currentModeAtom } from '../../atoms/mode';
import type { BlockSuiteWorkspace } from '../../shared'; import type { BlockSuiteWorkspace } from '../../shared';
import { useReferenceLinkHelper } from './use-reference-link-helper'; import { useReferenceLinkHelper } from './use-reference-link-helper';
@@ -14,6 +17,27 @@ export function useBlockSuiteMetaHelper(
usePageMetaHelper(blockSuiteWorkspace); usePageMetaHelper(blockSuiteWorkspace);
const { addReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace); const { addReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace);
const metas = useBlockSuitePageMeta(blockSuiteWorkspace); const metas = useBlockSuitePageMeta(blockSuiteWorkspace);
const setPageMode = useSetAtom(setPageModeAtom);
const currentMode = useAtomValue(currentModeAtom);
const switchToPageMode = useCallback(
(pageId: string) => {
setPageMode(pageId, 'page');
},
[setPageMode]
);
const switchToEdgelessMode = useCallback(
(pageId: string) => {
setPageMode(pageId, 'edgeless');
},
[setPageMode]
);
const togglePageMode = useCallback(
(pageId: string) => {
setPageMode(pageId, currentMode === 'edgeless' ? 'page' : 'edgeless');
},
[currentMode, setPageMode]
);
const addToFavorite = useCallback( const addToFavorite = useCallback(
(pageId: string) => { (pageId: string) => {
@@ -115,6 +139,10 @@ export function useBlockSuiteMetaHelper(
); );
return { return {
switchToPageMode,
switchToEdgelessMode,
togglePageMode,
publicPage, publicPage,
cancelPublicPage, cancelPublicPage,

View File

@@ -0,0 +1,79 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageBlockModel } from '@blocksuite/blocks';
import { ContentParser } from '@blocksuite/blocks/content-parser';
import type { Page } from '@blocksuite/store';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
type ExportType = 'pdf' | 'html' | 'png' | 'markdown';
const typeToContentParserMethodMap = {
pdf: 'exportPdf',
html: 'exportHtml',
png: 'exportPng',
markdown: 'exportMarkdown',
} satisfies Record<ExportType, keyof ContentParser>;
const contentParserWeakMap = new WeakMap<Page, ContentParser>();
const getContentParser = (page: Page) => {
if (!contentParserWeakMap.has(page)) {
contentParserWeakMap.set(
page,
new ContentParser(page, {
imageProxyEndpoint: !environment.isDesktop
? runtimeConfig.imageProxyUrl
: undefined,
})
);
}
return contentParserWeakMap.get(page) as ContentParser;
};
interface ExportHandlerOptions {
page: Page;
type: ExportType;
}
async function exportHandler({ page, type }: ExportHandlerOptions) {
if (type === 'pdf' && environment.isDesktop && page.meta.mode === 'page') {
window.apis?.export.savePDFFileAs(
(page.root as PageBlockModel).title.toString()
);
} else {
const contentParser = getContentParser(page);
const method = typeToContentParserMethodMap[type];
await contentParser[method]();
}
}
export const useExportPage = (page: Page) => {
const pushNotification = useSetAtom(pushNotificationAtom);
const t = useAFFiNEI18N();
const onClickHandler = useCallback(
async (type: ExportType) => {
try {
await exportHandler({
page,
type,
});
pushNotification({
title: t['com.affine.export.success.title'](),
message: t['com.affine.export.success.message'](),
type: 'success',
});
} catch (err) {
console.error(err);
pushNotification({
title: t['com.affine.export.error.title'](),
message: t['com.affine.export.error.message'](),
type: 'error',
});
}
},
[page, pushNotification, t]
);
return onClickHandler;
};

View File

@@ -0,0 +1,208 @@
import { toast } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { Workspace } from '@blocksuite/store';
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
import {
PreconditionStrategy,
registerAffineCommand,
} from '@toeverything/infra/command';
import { useCallback, useEffect } from 'react';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
import { useExportPage } from './use-export-page';
import { useTrashModalHelper } from './use-trash-modal-helper';
export function useRegisterBlocksuiteEditorCommands(
blockSuiteWorkspace: Workspace,
pageId: string,
mode: 'page' | 'edgeless'
) {
const t = useAFFiNEI18N();
const { getPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const currentPage = blockSuiteWorkspace.getPage(pageId);
assertExists(currentPage);
const pageMeta = getPageMeta(pageId);
assertExists(pageMeta);
const favorite = pageMeta.favorite ?? false;
const trash = pageMeta.trash ?? false;
const { togglePageMode, toggleFavorite, restoreFromTrash } =
useBlockSuiteMetaHelper(blockSuiteWorkspace);
const exportHandler = useExportPage(currentPage);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
const onClickDelete = useCallback(() => {
setTrashModal({
open: true,
pageId: pageId,
pageTitle: pageMeta.title,
});
}, [pageId, pageMeta.title, setTrashModal]);
useEffect(() => {
const unsubs: Array<() => void> = [];
const preconditionStrategy = () =>
PreconditionStrategy.InPaperOrEdgeless && !trash;
//TODO: add back when edgeless presentation is ready
// this is pretty hack and easy to break. need a better way to communicate with blocksuite editor
// unsubs.push(
// registerAffineCommand({
// id: 'editor:edgeless-presentation-start',
// preconditionStrategy: () => PreconditionStrategy.InEdgeless && !trash,
// category: 'editor:edgeless',
// icon: <EdgelessIcon />,
// label: t['com.affine.cmdk.affine.editor.edgeless.presentation-start'](),
// run() {
// document
// .querySelector<HTMLElement>('edgeless-toolbar')
// ?.shadowRoot?.querySelector<HTMLElement>(
// '.edgeless-toolbar-left-part > edgeless-tool-icon-button:last-child'
// )
// ?.click();
// },
// })
// );
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-${favorite ? 'remove-from' : 'add-to'}-favourites`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: favorite
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add'](),
run() {
toggleFavorite(pageId);
toast(
favorite
? t['com.affine.cmdk.affine.editor.remove-from-favourites']()
: t['com.affine.cmdk.affine.editor.add-to-favourites']()
);
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-convert-to-${
mode === 'page' ? 'edgeless' : 'page'
}`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: `${t['Convert to ']()}${
mode === 'page'
? t['com.affine.pageMode.edgeless']()
: t['com.affine.pageMode.page']()
}`,
run() {
togglePageMode(pageId);
toast(
mode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
: t['com.affine.toastMessage.pageMode']()
);
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-pdf`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to PDF'](),
run() {
exportHandler('pdf');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-html`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to HTML'](),
run() {
exportHandler('html');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-png`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to PNG'](),
run() {
exportHandler('png');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-export-to-markdown`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['Export to Markdown'](),
run() {
exportHandler('markdown');
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-move-to-trash`,
preconditionStrategy,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.moveToTrash.title'](),
run() {
onClickDelete();
},
})
);
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-restore-from-trash`,
preconditionStrategy: () =>
PreconditionStrategy.InPaperOrEdgeless && trash,
category: `editor:${mode}`,
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.cmdk.affine.editor.restore-from-trash'](),
run() {
restoreFromTrash(pageId);
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [
favorite,
mode,
onClickDelete,
exportHandler,
pageId,
pageMeta.title,
restoreFromTrash,
t,
toggleFavorite,
togglePageMode,
trash,
]);
}

View File

@@ -0,0 +1,27 @@
import { toast } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace } from '@blocksuite/store';
import { useAtom } from 'jotai';
import { useCallback } from 'react';
import { trashModalAtom } from '../../atoms/trash-modal';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
export function useTrashModalHelper(blocksuiteWorkspace: Workspace) {
const t = useAFFiNEI18N();
const [trashModal, setTrashModal] = useAtom(trashModalAtom);
const { pageId } = trashModal;
const { removeToTrash } = useBlockSuiteMetaHelper(blocksuiteWorkspace);
const handleOnConfirm = useCallback(() => {
removeToTrash(pageId);
toast(t['com.affine.toastMessage.movedTrash']());
setTrashModal({ ...trashModal, open: false });
}, [pageId, removeToTrash, setTrashModal, t, trashModal]);
return {
trashModal,
setTrashModal,
handleOnConfirm,
};
}

View File

@@ -21,7 +21,7 @@ export function useRegisterWorkspaceCommands() {
const [currentWorkspace] = useCurrentWorkspace(); const [currentWorkspace] = useCurrentWorkspace();
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace); const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
const navigationHelper = useNavigateHelper(); const navigationHelper = useNavigateHelper();
const [pageMode, setPageMode] = useAtom(allPageModeSelectAtom); const [pageListMode, setPageListMode] = useAtom(allPageModeSelectAtom);
useEffect(() => { useEffect(() => {
const unsubs: Array<() => void> = []; const unsubs: Array<() => void> = [];
unsubs.push( unsubs.push(
@@ -30,8 +30,8 @@ export function useRegisterWorkspaceCommands() {
t, t,
workspace: currentWorkspace.blockSuiteWorkspace, workspace: currentWorkspace.blockSuiteWorkspace,
navigationHelper, navigationHelper,
pageMode, pageListMode,
setPageMode, setPageListMode,
}) })
); );
unsubs.push(registerAffineSettingsCommands({ store, t, theme })); unsubs.push(registerAffineSettingsCommands({ store, t, theme }));
@@ -54,7 +54,7 @@ export function useRegisterWorkspaceCommands() {
theme, theme,
currentWorkspace.blockSuiteWorkspace, currentWorkspace.blockSuiteWorkspace,
navigationHelper, navigationHelper,
pageMode, pageListMode,
setPageMode, setPageListMode,
]); ]);
} }

View File

@@ -24,6 +24,7 @@ import type { Map as YMap } from 'yjs';
import { getUIAdapter } from '../../adapters/workspace'; import { getUIAdapter } from '../../adapters/workspace';
import { setPageModeAtom } from '../../atoms'; import { setPageModeAtom } from '../../atoms';
import { currentModeAtom } from '../../atoms/mode'; import { currentModeAtom } from '../../atoms/mode';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace'; import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../hooks/use-navigate-helper'; import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { currentCollectionsAtom } from '../../utils/user-setting'; import { currentCollectionsAtom } from '../../utils/user-setting';
@@ -38,7 +39,7 @@ const DetailPageImpl = (): ReactElement => {
const collectionManager = useCollectionManager(currentCollectionsAtom); const collectionManager = useCollectionManager(currentCollectionsAtom);
const mode = useAtomValue(currentModeAtom); const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom); const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode);
const onLoad = useCallback( const onLoad = useCallback(
(page: Page, editor: EditorContainer) => { (page: Page, editor: EditorContainer) => {
try { try {

View File

@@ -24,7 +24,6 @@ export const AffineOperationCell: StoryFn<OperationCellProps> = ({
}) => <OperationCell {...props} />; }) => <OperationCell {...props} />;
AffineOperationCell.args = { AffineOperationCell.args = {
title: 'Example Page',
favorite: false, favorite: false,
isPublic: true, isPublic: true,
onToggleFavoritePage: () => toast('Toggle favorite page'), onToggleFavoritePage: () => toast('Toggle favorite page'),

View File

@@ -92,6 +92,7 @@ export const Basic: StoryFn = () => {
workspace={localWorkspace} workspace={localWorkspace}
onEnableAffineCloud={unimplemented} onEnableAffineCloud={unimplemented}
togglePagePublic={unimplemented} togglePagePublic={unimplemented}
exportHandler={unimplemented}
/> />
); );
}; };
@@ -122,6 +123,7 @@ export const AffineBasic: StoryFn = () => {
workspace={affineWorkspace} workspace={affineWorkspace}
onEnableAffineCloud={unimplemented} onEnableAffineCloud={unimplemented}
togglePagePublic={unimplemented} togglePagePublic={unimplemented}
exportHandler={unimplemented}
/> />
); );
}; };

View File

@@ -13,7 +13,6 @@ import {
blockSuiteEditorHeaderStyle, blockSuiteEditorHeaderStyle,
blockSuiteEditorStyle, blockSuiteEditorStyle,
} from './index.css'; } from './index.css';
import { useRegisterBlocksuiteEditorCommands } from './use-register-blocksuite-editor-commands';
export type EditorProps = { export type EditorProps = {
page: Page; page: Page;
@@ -165,7 +164,6 @@ export const BlockSuiteFallback = memo(function BlockSuiteFallback() {
export const BlockSuiteEditor = memo(function BlockSuiteEditor( export const BlockSuiteEditor = memo(function BlockSuiteEditor(
props: EditorProps & ErrorBoundaryProps props: EditorProps & ErrorBoundaryProps
): ReactElement { ): ReactElement {
useRegisterBlocksuiteEditorCommands();
return ( return (
<ErrorBoundary <ErrorBoundary
fallbackRender={useCallback( fallbackRender={useCallback(

View File

@@ -1,35 +0,0 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon } from '@blocksuite/icons';
import { registerAffineCommand } from '@toeverything/infra/command';
import { useEffect } from 'react';
export function useRegisterBlocksuiteEditorCommands() {
const t = useAFFiNEI18N();
useEffect(() => {
const unsubs: Array<() => void> = [];
const getEdgeless = () => {
return document.querySelector('affine-edgeless-page');
};
unsubs.push(
registerAffineCommand({
id: 'editor:edgeless-presentation-start',
preconditionStrategy: () => !!getEdgeless(),
category: 'editor:edgeless',
icon: <EdgelessIcon />,
label: t['com.affine.cmdk.affine.editor.edgeless.presentation-start'](),
run() {
// this is pretty hack and easy to break. need a better way to communicate with blocksuite editor
document
.querySelector<HTMLElement>('edgeless-toolbar')
?.shadowRoot?.querySelector<HTMLElement>(
'.edgeless-toolbar-left-part > edgeless-tool-icon-button:last-child'
)
?.click();
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [t]);
}

View File

@@ -131,7 +131,6 @@ export const AllPagesBody = ({
active={!!favorite} active={!!favorite}
/> />
<OperationCell <OperationCell
title={title}
favorite={favorite} favorite={favorite}
isPublic={isPublicPage} isPublic={isPublicPage}
onOpenPageInNewTab={onOpenPageInNewTab} onOpenPageInNewTab={onOpenPageInNewTab}

View File

@@ -17,7 +17,6 @@ import { FlexWrapper } from '../../..';
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items'; import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
export interface OperationCellProps { export interface OperationCellProps {
title: string;
favorite: boolean; favorite: boolean;
isPublic: boolean; isPublic: boolean;
onOpenPageInNewTab: () => void; onOpenPageInNewTab: () => void;
@@ -27,7 +26,6 @@ export interface OperationCellProps {
} }
export const OperationCell = ({ export const OperationCell = ({
title,
favorite, favorite,
isPublic, isPublic,
onOpenPageInNewTab, onOpenPageInNewTab,
@@ -36,7 +34,6 @@ export const OperationCell = ({
onDisablePublicSharing, onDisablePublicSharing,
}: OperationCellProps) => { }: OperationCellProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
const [openDisableShared, setOpenDisableShared] = useState(false); const [openDisableShared, setOpenDisableShared] = useState(false);
const OperationMenu = ( const OperationMenu = (
@@ -77,12 +74,7 @@ export const OperationCell = ({
{t['com.affine.openPageOperation.newTab']()} {t['com.affine.openPageOperation.newTab']()}
</MenuItem> </MenuItem>
)} )}
<MoveToTrash <MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
data-testid="move-to-trash"
onSelect={() => {
setOpen(true);
}}
/>
</> </>
); );
return ( return (
@@ -99,15 +91,6 @@ export const OperationCell = ({
</IconButton> </IconButton>
</Menu> </Menu>
</FlexWrapper> </FlexWrapper>
<MoveToTrash.ConfirmModal
open={open}
title={title}
onConfirm={() => {
onRemoveToTrash();
setOpen(false);
}}
onOpenChange={setOpen}
/>
<DisablePublicSharing.DisablePublicSharingModal <DisablePublicSharing.DisablePublicSharingModal
onConfirm={onDisablePublicSharing} onConfirm={onDisablePublicSharing}
open={openDisableShared} open={openDisableShared}

View File

@@ -1,6 +1,4 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageBlockModel } from '@blocksuite/blocks';
import { import {
ExportIcon, ExportIcon,
ExportToHtmlIcon, ExportToHtmlIcon,
@@ -9,264 +7,107 @@ import {
ExportToPngIcon, ExportToPngIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import { MenuIcon, MenuItem, MenuSub } from '@toeverything/components/menu'; import { MenuIcon, MenuItem, MenuSub } from '@toeverything/components/menu';
import { useSetAtom } from 'jotai'; import { type ReactNode, useMemo } from 'react';
import { useCallback } from 'react';
import { getContentParser } from './get-content-parser';
import { transitionStyle } from './index.css'; import { transitionStyle } from './index.css';
import type { CommonMenuItemProps } from './types';
export const ExportToPdfMenuItem = ({ interface ExportMenuItemProps<T> {
onSelect: () => void;
className?: string;
type: T;
icon: ReactNode;
label: string;
}
interface ExportProps {
exportHandler: (type: 'pdf' | 'html' | 'png' | 'markdown') => Promise<void>;
className?: string;
}
export function ExportMenuItem<T>({
onSelect, onSelect,
className, className,
}: CommonMenuItemProps<{ type: 'pdf' }>) => { type,
const t = useAFFiNEI18N(); icon,
const { currentEditor } = globalThis; label,
const setPushNotification = useSetAtom(pushNotificationAtom); }: ExportMenuItemProps<T>) {
const onClickDownloadPDF = useCallback(() => {
if (!currentEditor) {
return;
}
if (environment.isDesktop && currentEditor.mode === 'page') {
window.apis?.export
.savePDFFileAs(
(currentEditor.page.root as PageBlockModel).title.toString()
)
.then(() => {
onSelect?.({ type: 'pdf' });
setPushNotification({
title: t['com.affine.export.success.title'](),
message: t['com.affine.export.success.message'](),
type: 'success',
});
})
.catch(err => {
console.error(err);
setPushNotification({
title: t['com.affine.export.error.title'](),
message: t['com.affine.export.error.message'](),
type: 'error',
});
});
} else {
const contentParser = getContentParser(currentEditor.page);
contentParser
.exportPdf()
.then(() => {
onSelect?.({ type: 'pdf' });
setPushNotification({
title: t['com.affine.export.success.title'](),
message: t['com.affine.export.success.message'](),
type: 'success',
});
})
.catch(err => {
console.error(err);
setPushNotification({
title: t['com.affine.export.error.title'](),
message: t['com.affine.export.error.message'](),
type: 'error',
});
});
}
}, [currentEditor, onSelect, setPushNotification, t]);
return ( return (
<MenuItem <MenuItem
className={className} className={className}
data-testid="export-to-pdf" data-testid={`export-to-${type}`}
onSelect={onClickDownloadPDF} onSelect={onSelect}
block block
preFix={ preFix={<MenuIcon>{icon}</MenuIcon>}
<MenuIcon>
<ExportToPdfIcon />
</MenuIcon>
}
> >
{t['Export to PDF']()} {label}
</MenuItem> </MenuItem>
); );
}; }
export const ExportToHtmlMenuItem = ({ export const ExportMenuItems = ({
onSelect, exportHandler,
className, className = transitionStyle,
}: CommonMenuItemProps<{ type: 'html' }>) => { }: ExportProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const { currentEditor } = globalThis; const itemMap = useMemo(
const setPushNotification = useSetAtom(pushNotificationAtom); () => [
const onClickExportHtml = useCallback(() => { {
if (!currentEditor) { component: ExportMenuItem,
return; props: {
} onSelect: () => exportHandler('pdf'),
const contentParser = getContentParser(currentEditor.page); className: className,
contentParser type: 'pdf',
.exportHtml() icon: <ExportToPdfIcon />,
.then(() => { label: t['Export to PDF'](),
onSelect?.({ type: 'html' }); },
setPushNotification({ },
title: t['com.affine.export.success.title'](), {
message: t['com.affine.export.success.message'](), component: ExportMenuItem,
type: 'success', props: {
}); onSelect: () => exportHandler('html'),
}) className: className,
.catch(err => { type: 'html',
console.error(err); icon: <ExportToHtmlIcon />,
setPushNotification({ label: t['Export to HTML'](),
title: t['com.affine.export.error.title'](), },
message: t['com.affine.export.error.message'](), },
type: 'error', {
}); component: ExportMenuItem,
}); props: {
onSelect?.({ type: 'html' }); onSelect: () => exportHandler('png'),
}, [currentEditor, onSelect, setPushNotification, t]); className: className,
return ( type: 'png',
<> icon: <ExportToPngIcon />,
<MenuItem label: t['Export to PNG'](),
className={className} },
data-testid="export-to-html" },
onSelect={onClickExportHtml} {
block component: ExportMenuItem,
preFix={ props: {
<MenuIcon> onSelect: () => exportHandler('markdown'),
<ExportToHtmlIcon /> className: className,
</MenuIcon> type: 'markdown',
} icon: <ExportToMarkdownIcon />,
> label: t['Export to Markdown'](),
{t['Export to HTML']()} },
</MenuItem> },
</> ],
[className, exportHandler, t]
); );
const items = itemMap.map(({ component: Component, props }) => (
<Component key={props.label} {...props} />
));
return <>{items}</>;
}; };
export const ExportToPngMenuItem = ({ export const Export = ({ exportHandler, className }: ExportProps) => {
onSelect,
className,
}: CommonMenuItemProps<{ type: 'png' }>) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const { currentEditor } = globalThis; const items = (
const setPushNotification = useSetAtom(pushNotificationAtom); <ExportMenuItems exportHandler={exportHandler} className={className} />
const onClickDownloadPNG = useCallback(() => {
if (!currentEditor) {
return;
}
const contentParser = getContentParser(currentEditor.page);
contentParser
.exportPng()
.then(() => {
onSelect?.({ type: 'png' });
setPushNotification({
title: t['com.affine.export.success.title'](),
message: t['com.affine.export.success.message'](),
type: 'success',
});
})
.catch(err => {
console.error(err);
setPushNotification({
title: t['com.affine.export.error.title'](),
message: t['com.affine.export.error.message'](),
type: 'error',
});
});
}, [currentEditor, onSelect, setPushNotification, t]);
return (
<>
<MenuItem
className={className}
data-testid="export-to-png"
onSelect={onClickDownloadPNG}
block
preFix={
<MenuIcon>
<ExportToPngIcon />
</MenuIcon>
}
>
{t['Export to PNG']()}
</MenuItem>
</>
); );
};
export const ExportToMarkdownMenuItem = ({
onSelect,
className,
}: CommonMenuItemProps<{ type: 'markdown' }>) => {
const t = useAFFiNEI18N();
const { currentEditor } = globalThis;
const setPushNotification = useSetAtom(pushNotificationAtom);
const onClickExportMarkdown = useCallback(() => {
if (!currentEditor) {
return;
}
const contentParser = getContentParser(currentEditor.page);
contentParser
.exportMarkdown()
.then(() => {
onSelect?.({ type: 'markdown' });
setPushNotification({
title: t['com.affine.export.success.title'](),
message: t['com.affine.export.success.message'](),
type: 'success',
});
})
.catch(err => {
console.error(err);
setPushNotification({
title: t['com.affine.export.error.title'](),
message: t['com.affine.export.error.message'](),
type: 'error',
});
});
onSelect?.({ type: 'markdown' });
}, [currentEditor, onSelect, setPushNotification, t]);
return (
<>
<MenuItem
className={className}
data-testid="export-to-markdown"
onSelect={onClickExportMarkdown}
block
preFix={
<MenuIcon>
<ExportToMarkdownIcon />
</MenuIcon>
}
>
{t['Export to Markdown']()}
</MenuItem>
</>
);
};
// fixme: refactor this file, export function may should be passed by 'props', this file is just a ui component
export const Export = () => {
const t = useAFFiNEI18N();
return ( return (
<MenuSub <MenuSub
items={ items={items}
<>
<ExportToPdfMenuItem
className={transitionStyle}
></ExportToPdfMenuItem>
<ExportToHtmlMenuItem
className={transitionStyle}
></ExportToHtmlMenuItem>
<ExportToPngMenuItem
className={transitionStyle}
></ExportToPngMenuItem>
<ExportToMarkdownMenuItem
className={transitionStyle}
></ExportToMarkdownMenuItem>
</>
}
triggerOptions={{ triggerOptions={{
className: transitionStyle, className: transitionStyle,
preFix: ( preFix: (

View File

@@ -3,20 +3,19 @@ import { LinkIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button'; import { Button } from '@toeverything/components/button';
import { Divider } from '@toeverything/components/divider'; import { Divider } from '@toeverything/components/divider';
import { import { ExportMenuItems } from '../page-list/operation-menu-items/export';
ExportToHtmlMenuItem,
ExportToMarkdownMenuItem,
ExportToPdfMenuItem,
ExportToPngMenuItem,
} from '../page-list/operation-menu-items/export';
import * as styles from './index.css'; import * as styles from './index.css';
import type { ShareMenuProps } from './share-menu'; import type { ShareMenuProps } from './share-menu';
import { useSharingUrl } from './use-share-url'; import { useSharingUrl } from './use-share-url';
export const ShareExport = (props: ShareMenuProps) => { export const ShareExport = ({
workspace,
currentPage,
exportHandler,
}: ShareMenuProps) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const workspaceId = props.workspace.id; const workspaceId = workspace.id;
const pageId = props.currentPage.id; const pageId = currentPage.id;
const { onClickCopyLink } = useSharingUrl({ const { onClickCopyLink } = useSharingUrl({
workspaceId, workspaceId,
pageId, pageId,
@@ -29,10 +28,10 @@ export const ShareExport = (props: ShareMenuProps) => {
{t['com.affine.share-menu.ShareViaExport']()} {t['com.affine.share-menu.ShareViaExport']()}
</div> </div>
<div> <div>
<ExportToPdfMenuItem className={styles.menuItemStyle} /> <ExportMenuItems
<ExportToHtmlMenuItem className={styles.menuItemStyle} /> exportHandler={exportHandler}
<ExportToPngMenuItem className={styles.menuItemStyle} /> className={styles.menuItemStyle}
<ExportToMarkdownMenuItem className={styles.menuItemStyle} /> />
</div> </div>
<div className={styles.columnContainerStyle}> <div className={styles.columnContainerStyle}>
<div className={styles.descriptionStyle}> <div className={styles.descriptionStyle}>

View File

@@ -27,6 +27,7 @@ export interface ShareMenuProps<
) => [isSharePage: boolean, setIsSharePage: (enable: boolean) => void]; ) => [isSharePage: boolean, setIsSharePage: (enable: boolean) => void];
onEnableAffineCloud: () => void; onEnableAffineCloud: () => void;
togglePagePublic: () => Promise<void>; togglePagePublic: () => Promise<void>;
exportHandler: (type: 'pdf' | 'html' | 'png' | 'markdown') => Promise<void>;
} }
export const ShareMenu = (props: ShareMenuProps) => { export const ShareMenu = (props: ShareMenuProps) => {

View File

@@ -615,5 +615,8 @@
"com.affine.cmdk.affine.navigation.goto-workspace": "Go to Workspace", "com.affine.cmdk.affine.navigation.goto-workspace": "Go to Workspace",
"com.affine.cmdk.affine.category.affine.edgeless": "Edgeless", "com.affine.cmdk.affine.category.affine.edgeless": "Edgeless",
"com.affine.cmdk.affine.category.affine.collections": "Collections", "com.affine.cmdk.affine.category.affine.collections": "Collections",
"com.affine.cmdk.affine.import-workspace": "Import Workspace" "com.affine.cmdk.affine.import-workspace": "Import Workspace",
"com.affine.cmdk.affine.editor.add-to-favourites": "Add to Favourites",
"com.affine.cmdk.affine.editor.remove-from-favourites": "Remove from Favourites",
"com.affine.cmdk.affine.editor.restore-from-trash": "Restore from Trash"
} }

View File

@@ -67,6 +67,7 @@ test('Show collections items in sidebar', async ({ page }) => {
.getByTestId('collection-page-option') .getByTestId('collection-page-option')
.getByText('Delete'); .getByText('Delete');
await deletePage.click(); await deletePage.click();
await page.getByTestId('confirm-delete-page').click();
expect(await collections.getByTestId('collection-page').count()).toBe(0); expect(await collections.getByTestId('collection-page').count()).toBe(0);
await first.hover(); await first.hover();
await first.getByTestId('collection-options').click(); await first.getByTestId('collection-options').click();

View File

@@ -27,6 +27,12 @@ const keyboardDownAndSelect = async (page: Page, label: string) => {
} }
}; };
const commandsIsVisible = async (page: Page, label: string) => {
const locators = page.locator('[cmdk-item] [data-testid="cmdk-label"]');
const actual = (await locators.allInnerTexts()).find(text => text === label);
return !!actual;
};
async function assertTitle(page: Page, text: string) { async function assertTitle(page: Page, text: string) {
const edgeless = page.locator('affine-edgeless-page'); const edgeless = page.locator('affine-edgeless-page');
if (!edgeless) { if (!edgeless) {
@@ -280,3 +286,49 @@ test('assert the recent browse pages are on the recent list', async ({
); );
} }
}); });
test('can use cmdk to export pdf', async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await clickNewPageButton(page);
await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to export');
await openQuickSearchByShortcut(page);
const [download] = await Promise.all([
page.waitForEvent('download'),
keyboardDownAndSelect(page, 'Export to PDF'),
]);
expect(download.suggestedFilename()).toBe('this is a new page to export.pdf');
});
test('can use cmdk to export png', async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await clickNewPageButton(page);
await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to export');
await openQuickSearchByShortcut(page);
const [download] = await Promise.all([
page.waitForEvent('download'),
keyboardDownAndSelect(page, 'Export to PNG'),
]);
expect(download.suggestedFilename()).toBe('this is a new page to export.png');
});
test('can use cmdk to delete page and restore it', async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await clickNewPageButton(page);
await getBlockSuiteEditorTitle(page).click();
await getBlockSuiteEditorTitle(page).fill('this is a new page to delete');
await openQuickSearchByShortcut(page);
await keyboardDownAndSelect(page, 'Move to Trash');
await page.getByTestId('confirm-delete-page').click();
const restoreButton = page.getByTestId('page-restore-button');
await expect(restoreButton).toBeVisible();
await openQuickSearchByShortcut(page);
expect(await commandsIsVisible(page, 'Move to Trash')).toBe(false);
expect(await commandsIsVisible(page, 'Export to PDF')).toBe(false);
expect(await commandsIsVisible(page, 'Restore from Trash')).toBe(true);
await keyboardDownAndSelect(page, 'Restore from Trash');
await expect(restoreButton).not.toBeVisible();
});