feat: new CMD-K (#4408)

This commit is contained in:
Peng Xiao
2023-09-22 22:31:26 +08:00
committed by GitHub
parent 27e4599c94
commit e0063ebc9b
49 changed files with 2936 additions and 965 deletions

View File

@@ -12,7 +12,8 @@
"./app": "./src/app.tsx",
"./router": "./src/router.ts",
"./bootstrap/setup": "./src/bootstrap/setup.ts",
"./bootstrap/register-plugins": "./src/bootstrap/register-plugins.ts"
"./bootstrap/register-plugins": "./src/bootstrap/register-plugins.ts",
"./components/pure/*": "./src/components/pure/*/index.tsx"
},
"dependencies": {
"@affine-test/fixtures": "workspace:*",
@@ -41,7 +42,6 @@
"@react-hookz/web": "^23.1.0",
"@toeverything/components": "^0.0.43",
"async-call-rpc": "^6.3.1",
"cmdk": "^0.2.0",
"css-spring": "^4.1.0",
"cssnano": "^6.0.1",
"graphql": "^16.8.0",

View File

@@ -9,7 +9,7 @@ import { describe, expect, test } from 'vitest';
import {
pageSettingFamily,
pageSettingsAtom,
recentPageSettingsAtom,
recentPageIdsBaseAtom,
} from '../index';
describe('page mode atom', () => {
@@ -26,20 +26,12 @@ describe('page mode atom', () => {
},
});
expect(store.get(recentPageSettingsAtom)).toEqual([
{
id: 'page0',
mode: 'page',
},
]);
expect(store.get(recentPageIdsBaseAtom)).toEqual(['page0']);
const page1SettingAtom = pageSettingFamily('page1');
store.set(page1SettingAtom, {
mode: 'edgeless',
});
expect(store.get(recentPageSettingsAtom)).toEqual([
{ id: 'page1', mode: 'edgeless' },
{ id: 'page0', mode: 'page' },
]);
expect(store.get(recentPageIdsBaseAtom)).toEqual(['page1', 'page0']);
});
});

View File

@@ -43,10 +43,6 @@ type PageLocalSetting = {
mode: PageMode;
};
type PartialPageLocalSettingWithPageId = Partial<PageLocalSetting> & {
id: string;
};
const pageSettingsBaseAtom = atomWithStorage(
'pageSettings',
{} as Record<string, PageLocalSetting>
@@ -55,22 +51,11 @@ const pageSettingsBaseAtom = atomWithStorage(
// readonly atom by design
export const pageSettingsAtom = atom(get => get(pageSettingsBaseAtom));
const recentPageSettingsBaseAtom = atomWithStorage<string[]>(
export const recentPageIdsBaseAtom = atomWithStorage<string[]>(
'recentPageSettings',
[]
);
export const recentPageSettingsAtom = atom<PartialPageLocalSettingWithPageId[]>(
get => {
const recentPageIDs = get(recentPageSettingsBaseAtom);
const pageSettings = get(pageSettingsAtom);
return recentPageIDs.map(id => ({
...pageSettings[id],
id,
}));
}
);
const defaultPageSetting = {
mode: 'page',
} satisfies PageLocalSetting;
@@ -85,7 +70,9 @@ export const pageSettingFamily: AtomFamily<
...defaultPageSetting,
},
(get, set, patch) => {
set(recentPageSettingsBaseAtom, ids => {
// fixme: this does not work when page reload,
// since atomWithStorage is async
set(recentPageIdsBaseAtom, ids => {
// pick 3 recent page ids
return [...new Set([pageId, ...ids]).values()].slice(0, 3);
});

View File

@@ -0,0 +1,64 @@
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { PlusIcon } from '@blocksuite/icons';
import { registerAffineCommand } from '@toeverything/infra/command';
import type { createStore } from 'jotai';
import { openCreateWorkspaceModalAtom } from '../atoms';
import type { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
export function registerAffineCreationCommands({
store,
pageHelper,
t,
}: {
t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>;
pageHelper: ReturnType<typeof usePageHelper>;
}) {
const unsubs: Array<() => void> = [];
unsubs.push(
registerAffineCommand({
id: 'affine:new-page',
category: 'affine:creation',
label: t['com.affine.cmdk.affine.new-page'],
icon: <PlusIcon />,
keyBinding: environment.isDesktop
? {
binding: '$mod+N',
skipRegister: true,
}
: undefined,
run() {
pageHelper.createPage();
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:new-edgeless-page',
category: 'affine:creation',
icon: <PlusIcon />,
label: t['com.affine.cmdk.affine.new-edgeless-page'],
run() {
pageHelper.createEdgeless();
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:new-workspace',
category: 'affine:creation',
icon: <PlusIcon />,
label: t['com.affine.cmdk.affine.new-workspace'],
run() {
store.set(openCreateWorkspaceModalAtom, 'new');
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}

View File

@@ -0,0 +1,40 @@
import { appSidebarOpenAtom } from '@affine/component/app-sidebar';
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SidebarIcon } from '@blocksuite/icons';
import { registerAffineCommand } from '@toeverything/infra/command';
import type { createStore } from 'jotai';
export function registerAffineLayoutCommands({
t,
store,
}: {
t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>;
}) {
const unsubs: Array<() => void> = [];
unsubs.push(
registerAffineCommand({
id: 'affine:toggle-left-sidebar',
category: 'affine:layout',
icon: <SidebarIcon />,
label: () => {
const open = store.get(appSidebarOpenAtom);
return t[
open
? 'com.affine.cmdk.affine.left-sidebar.collapse'
: 'com.affine.cmdk.affine.left-sidebar.expand'
]();
},
keyBinding: {
binding: '$mod+/',
},
run() {
store.set(appSidebarOpenAtom, v => !v);
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}

View File

@@ -0,0 +1,66 @@
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightBigIcon } from '@blocksuite/icons';
import type { Workspace } from '@blocksuite/store';
import { registerAffineCommand } from '@toeverything/infra/command';
import type { createStore } from 'jotai';
import { openSettingModalAtom } from '../atoms';
import type { useNavigateHelper } from '../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../shared';
export function registerAffineNavigationCommands({
t,
store,
workspace,
navigationHelper,
}: {
t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>;
navigationHelper: ReturnType<typeof useNavigateHelper>;
workspace: Workspace;
}) {
const unsubs: Array<() => void> = [];
unsubs.push(
registerAffineCommand({
id: 'affine:goto-all-pages',
category: 'affine:navigation',
icon: <ArrowRightBigIcon />,
label: () => t['com.affine.cmdk.affine.navigation.goto-all-pages'](),
run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:open-settings',
category: 'affine:navigation',
icon: <ArrowRightBigIcon />,
label: () => t['com.affine.cmdk.affine.navigation.open-settings'](),
run() {
store.set(openSettingModalAtom, {
activeTab: 'appearance',
workspaceId: null,
open: true,
});
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:goto-trash',
category: 'affine:navigation',
icon: <ArrowRightBigIcon />,
label: () => t['com.affine.cmdk.affine.navigation.goto-trash'](),
run() {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.TRASH);
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}

View File

@@ -0,0 +1,100 @@
import { Trans } from '@affine/i18n';
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SettingsIcon } from '@blocksuite/icons';
import {
PreconditionStrategy,
registerAffineCommand,
} from '@toeverything/infra/command';
import type { createStore } from 'jotai';
import type { useTheme } from 'next-themes';
import { openQuickSearchModalAtom } from '../atoms';
export function registerAffineSettingsCommands({
store,
theme,
}: {
t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>;
theme: ReturnType<typeof useTheme>;
}) {
const unsubs: Array<() => void> = [];
unsubs.push(
registerAffineCommand({
id: 'affine:show-quick-search',
preconditionStrategy: PreconditionStrategy.Never,
category: 'affine:general',
keyBinding: {
binding: '$mod+K',
},
icon: <SettingsIcon />,
run() {
store.set(openQuickSearchModalAtom, true);
},
})
);
// color schemes
unsubs.push(
registerAffineCommand({
id: 'affine:change-color-scheme-to-auto',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.color-scheme.to"
values={{ colour: 'Auto' }}
>
Change Colour Scheme to <strong>colour</strong>
</Trans>
),
category: 'affine:settings',
icon: <SettingsIcon />,
preconditionStrategy: () => theme.theme !== 'system',
run() {
theme.setTheme('system');
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:change-color-scheme-to-dark',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.color-scheme.to"
values={{ colour: 'Dark' }}
>
Change Colour Scheme to <strong>colour</strong>
</Trans>
),
category: 'affine:settings',
icon: <SettingsIcon />,
preconditionStrategy: () => theme.theme !== 'dark',
run() {
theme.setTheme('dark');
},
})
);
unsubs.push(
registerAffineCommand({
id: 'affine:change-color-scheme-to-light',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.color-scheme.to"
values={{ colour: 'Light' }}
>
Change Colour Scheme to <strong>colour</strong>
</Trans>
),
category: 'affine:settings',
icon: <SettingsIcon />,
preconditionStrategy: () => theme.theme !== 'light',
run() {
theme.setTheme('light');
},
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}

View File

@@ -0,0 +1,3 @@
export * from './affine-creation';
export * from './affine-layout';
export * from './affine-settings';

View File

@@ -3,7 +3,7 @@ import { WorkspaceSubPath } from '@affine/env/workspace';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { initEmptyPage } from '@toeverything/infra/blocksuite';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { pageSettingsAtom, setPageModeAtom } from '../../../atoms';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
@@ -57,10 +57,17 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
};
showImportModal({ workspace: blockSuiteWorkspace, onSuccess });
}, [blockSuiteWorkspace, openPage, jumpToSubPath]);
return {
createPage: createPageAndOpen,
createEdgeless: createEdgelessAndOpen,
importFile: importFileAndOpen,
isPreferredEdgeless: isPreferredEdgeless,
};
return useMemo(() => {
return {
createPage: createPageAndOpen,
createEdgeless: createEdgelessAndOpen,
importFile: importFileAndOpen,
isPreferredEdgeless: isPreferredEdgeless,
};
}, [
createEdgelessAndOpen,
createPageAndOpen,
importFileAndOpen,
isPreferredEdgeless,
]);
};

View File

@@ -0,0 +1,321 @@
import { commandScore } from '@affine/cmdk';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { Page, PageMeta } from '@blocksuite/store';
import {
useBlockSuitePageMeta,
usePageMetaHelper,
} from '@toeverything/hooks/use-block-suite-page-meta';
import {
getWorkspace,
waitForWorkspace,
} from '@toeverything/infra/__internal__/workspace';
import {
currentPageIdAtom,
currentWorkspaceIdAtom,
getCurrentStore,
} from '@toeverything/infra/atom';
import {
type AffineCommand,
AffineCommandRegistry,
type CommandCategory,
PreconditionStrategy,
} from '@toeverything/infra/command';
import { atom, useAtomValue } from 'jotai';
import groupBy from 'lodash/groupBy';
import { useMemo } from 'react';
import {
openQuickSearchModalAtom,
pageSettingsAtom,
recentPageIdsBaseAtom,
} from '../../../atoms';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import type { CMDKCommand, CommandContext } from './types';
export const cmdkQueryAtom = atom('');
// like currentWorkspaceAtom, but not throw error
const safeCurrentPageAtom = atom<Promise<Page | undefined>>(async get => {
const currentWorkspaceId = get(currentWorkspaceIdAtom);
if (!currentWorkspaceId) {
return;
}
const currentPageId = get(currentPageIdAtom);
if (!currentPageId) {
return;
}
const workspace = getWorkspace(currentWorkspaceId);
await waitForWorkspace(workspace);
const page = workspace.getPage(currentPageId);
if (!page) {
return;
}
if (!page.loaded) {
await page.waitForLoaded();
}
return page;
});
export const commandContextAtom = atom<Promise<CommandContext>>(async get => {
const currentPage = await get(safeCurrentPageAtom);
const pageSettings = get(pageSettingsAtom);
return {
currentPage,
pageMode: currentPage ? pageSettings[currentPage.id]?.mode : undefined,
};
});
function filterCommandByContext(
command: AffineCommand,
context: CommandContext
) {
if (command.preconditionStrategy === PreconditionStrategy.Always) {
return true;
}
if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) {
return context.pageMode === 'edgeless';
}
if (command.preconditionStrategy === PreconditionStrategy.InPaper) {
return context.pageMode === 'page';
}
if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) {
return !!context.currentPage;
}
if (command.preconditionStrategy === PreconditionStrategy.Never) {
return false;
}
if (typeof command.preconditionStrategy === 'function') {
return command.preconditionStrategy();
}
return true;
}
let quickSearchOpenCounter = 0;
const openCountAtom = atom(get => {
if (get(openQuickSearchModalAtom)) {
quickSearchOpenCounter++;
}
return quickSearchOpenCounter;
});
export const filteredAffineCommands = atom(async get => {
const context = await get(commandContextAtom);
// reset when modal open
get(openCountAtom);
const commands = AffineCommandRegistry.getAll();
return commands.filter(command => {
return filterCommandByContext(command, context);
});
});
const useWorkspacePages = () => {
const [currentWorkspace] = useCurrentWorkspace();
const pages = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
return pages;
};
const useRecentPages = () => {
const pages = useWorkspacePages();
const recentPageIds = useAtomValue(recentPageIdsBaseAtom);
return useMemo(() => {
return recentPageIds
.map(pageId => {
const page = pages.find(page => page.id === pageId);
return page;
})
.filter((p): p is PageMeta => !!p);
}, [recentPageIds, pages]);
};
const valueWrapperStart = '__>>>';
const valueWrapperEnd = '<<<__';
export const pageToCommand = (
category: CommandCategory,
page: PageMeta,
store: ReturnType<typeof getCurrentStore>,
navigationHelper: ReturnType<typeof useNavigateHelper>,
t: ReturnType<typeof useAFFiNEI18N>
): CMDKCommand => {
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
const label = page.title || t['Untitled']();
return {
id: page.id,
label: label,
// hack: when comparing, the part between >>> and <<< will be ignored
// adding this patch so that CMDK will not complain about duplicated commands
value:
label + valueWrapperStart + page.id + '.' + category + valueWrapperEnd,
originalValue: label,
category: category,
run: () => {
if (!currentWorkspaceId) {
console.error('current workspace not found');
return;
}
navigationHelper.jumpToPage(currentWorkspaceId, page.id);
},
icon: pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />,
timestamp: page.updatedDate,
};
};
export const usePageCommands = () => {
// todo: considering collections for searching pages
// const { savedCollections } = useCollectionManager(currentCollectionsAtom);
const recentPages = useRecentPages();
const pages = useWorkspacePages();
const store = getCurrentStore();
const [workspace] = useCurrentWorkspace();
const pageHelper = usePageHelper(workspace.blockSuiteWorkspace);
const pageMetaHelper = usePageMetaHelper(workspace.blockSuiteWorkspace);
const query = useAtomValue(cmdkQueryAtom);
const navigationHelper = useNavigateHelper();
const t = useAFFiNEI18N();
return useMemo(() => {
let results: CMDKCommand[] = [];
if (query.trim() === '') {
results = recentPages.map(page => {
return pageToCommand('affine:recent', page, store, navigationHelper, t);
});
} else {
// queried pages that has matched contents
const pageIds = Array.from(
workspace.blockSuiteWorkspace.search({ query }).values()
).map(id => {
if (id.startsWith('space:')) {
return id.slice(6);
} else {
return id;
}
});
results = pages.map(page => {
const command = pageToCommand(
'affine:pages',
page,
store,
navigationHelper,
t
);
if (pageIds.includes(page.id)) {
// hack to make the page always showing in the search result
command.value += query;
}
return command;
});
// check if the pages have exact match. if not, we should show the "create page" command
if (results.every(command => command.originalValue !== query)) {
results.push({
id: 'affine:pages:create-page',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.create-new-page-as"
values={{ query }}
>
Create New Page as: <strong>query</strong>
</Trans>
),
value: 'affine::create-page' + query, // hack to make the page always showing in the search result
category: 'affine:creation',
run: () => {
const pageId = pageHelper.createPage();
// need to wait for the page to be created
setTimeout(() => {
pageMetaHelper.setPageTitle(pageId, query);
});
},
icon: <PageIcon />,
});
results.push({
id: 'affine:pages:create-edgeless',
label: (
<Trans
values={{ query }}
i18nKey="com.affine.cmdk.affine.create-new-edgeless-as"
>
Create New Edgeless as: <strong>query</strong>
</Trans>
),
value: 'affine::create-edgeless' + query, // hack to make the page always showing in the search result
category: 'affine:creation',
run: () => {
const pageId = pageHelper.createEdgeless();
// need to wait for the page to be created
setTimeout(() => {
pageMetaHelper.setPageTitle(pageId, query);
});
},
icon: <EdgelessIcon />,
});
}
}
return results;
}, [
pageHelper,
pageMetaHelper,
navigationHelper,
pages,
query,
recentPages,
store,
t,
workspace.blockSuiteWorkspace,
]);
};
export const useCMDKCommandGroups = () => {
const pageCommands = usePageCommands();
const affineCommands = useAtomValue(filteredAffineCommands);
return useMemo(() => {
const commands = [...pageCommands, ...affineCommands];
const groups = groupBy(commands, command => command.category);
return Object.entries(groups) as [CommandCategory, CMDKCommand[]][];
}, [affineCommands, pageCommands]);
};
export const customCommandFilter = (value: string, search: string) => {
// strip off the part between __>>> and <<<__
const label = value.replace(
new RegExp(valueWrapperStart + '.*' + valueWrapperEnd, 'g'),
''
);
return commandScore(label, search);
};
export const useCommandFilteredStatus = (
groups: [CommandCategory, CMDKCommand[]][]
) => {
// for each of the groups, show the count of commands that has matched the query
const query = useAtomValue(cmdkQueryAtom);
return useMemo(() => {
return Object.fromEntries(
groups.map(([category, commands]) => {
return [category, getCommandFilteredCount(commands, query)] as const;
})
) as Record<CommandCategory, number>;
}, [groups, query]);
};
function getCommandFilteredCount(commands: CMDKCommand[], query: string) {
return commands.filter(command => {
return command.value && customCommandFilter(command.value, query) > 0;
}).length;
}

View File

@@ -0,0 +1,2 @@
export * from './main';
export * from './modal';

View File

@@ -0,0 +1,131 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({});
export const commandsContainer = style({
height: 'calc(100% - 65px)',
padding: '8px 6px 18px 6px',
});
export const searchInput = style({
height: 66,
color: 'var(--affine-text-primary-color)',
fontSize: 'var(--affine-font-h-5)',
padding: '21px 24px',
width: '100%',
borderBottom: '1px solid var(--affine-border-color)',
flexShrink: 0,
'::placeholder': {
color: 'var(--affine-text-secondary-color)',
},
});
export const panelContainer = style({
height: '100%',
display: 'flex',
flexDirection: 'column',
});
export const itemIcon = style({
fontSize: 20,
marginRight: 16,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
color: 'var(--affine-icon-secondary)',
});
export const itemLabel = style({
fontSize: 14,
lineHeight: '1.5',
color: 'var(--affine-text-primary-color)',
flex: 1,
});
export const timestamp = style({
display: 'flex',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
});
export const keybinding = style({
display: 'flex',
fontSize: 'var(--affine-font-xs)',
columnGap: 2,
});
export const keybindingFragment = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
borderRadius: 4,
color: 'var(--affine-text-secondary-color)',
backgroundColor: 'var(--affine-background-tertiary-color)',
width: 24,
height: 20,
});
globalStyle(`${root} [cmdk-root]`, {
height: '100%',
});
globalStyle(`${root} [cmdk-group-heading]`, {
padding: '8px',
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
fontWeight: 600,
lineHeight: '1.67',
});
globalStyle(`${root} [cmdk-group][hidden]`, {
display: 'none',
});
globalStyle(`${root} [cmdk-list]`, {
maxHeight: 400,
overflow: 'auto',
overscrollBehavior: 'contain',
transition: '.1s ease',
transitionProperty: 'height',
height: 'min(330px, calc(var(--cmdk-list-height) + 8px))',
padding: '0 6px 8px 6px',
});
globalStyle(`${root} [cmdk-list]::-webkit-scrollbar`, {
width: 8,
height: 8,
});
globalStyle(`${root} [cmdk-list]::-webkit-scrollbar-thumb`, {
borderRadius: 4,
border: '1px solid transparent',
backgroundClip: 'padding-box',
});
globalStyle(`${root} [cmdk-list]:hover::-webkit-scrollbar-thumb`, {
backgroundColor: 'var(--affine-divider-color)',
});
globalStyle(`${root} [cmdk-list]:hover::-webkit-scrollbar-thumb:hover`, {
backgroundColor: 'var(--affine-icon-color)',
});
globalStyle(`${root} [cmdk-item]`, {
display: 'flex',
height: 44,
padding: '0 12px',
alignItems: 'center',
cursor: 'default',
borderRadius: 4,
userSelect: 'none',
});
globalStyle(`${root} [cmdk-item][data-selected=true]`, {
background: 'var(--affine-background-secondary-color)',
});
globalStyle(`${root} [cmdk-item][data-selected=true] ${itemIcon}`, {
color: 'var(--affine-icon-color)',
});

View File

@@ -0,0 +1,215 @@
import { Command } from '@affine/cmdk';
import { formatDate } from '@affine/component/page-list';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { CommandCategory } from '@toeverything/infra/command';
import clsx from 'clsx';
import { useAtom, useSetAtom } from 'jotai';
import { Suspense, useEffect, useMemo } from 'react';
import {
cmdkQueryAtom,
customCommandFilter,
useCMDKCommandGroups,
} from './data';
import * as styles from './main.css';
import { CMDKModal, type CMDKModalProps } from './modal';
import type { CMDKCommand } from './types';
type NoParametersKeys<T> = {
[K in keyof T]: T[K] extends () => any ? K : never;
}[keyof T];
type i18nKey = NoParametersKeys<ReturnType<typeof useAFFiNEI18N>>;
const categoryToI18nKey: Record<CommandCategory, i18nKey> = {
'affine:recent': 'com.affine.cmdk.affine.category.affine.recent',
'affine:navigation': 'com.affine.cmdk.affine.category.affine.navigation',
'affine:creation': 'com.affine.cmdk.affine.category.affine.creation',
'affine:general': 'com.affine.cmdk.affine.category.affine.general',
'affine:layout': 'com.affine.cmdk.affine.category.affine.layout',
'affine:pages': 'com.affine.cmdk.affine.category.affine.pages',
'affine:settings': 'com.affine.cmdk.affine.category.affine.settings',
'affine:updates': 'com.affine.cmdk.affine.category.affine.updates',
'affine:help': 'com.affine.cmdk.affine.category.affine.help',
'editor:edgeless': 'com.affine.cmdk.affine.category.editor.edgeless',
'editor:insert-object':
'com.affine.cmdk.affine.category.editor.insert-object',
'editor:page': 'com.affine.cmdk.affine.category.editor.page',
};
const QuickSearchGroup = ({
category,
commands,
onOpenChange,
}: {
category: CommandCategory;
commands: CMDKCommand[];
onOpenChange?: (open: boolean) => void;
}) => {
const t = useAFFiNEI18N();
const i18nkey = categoryToI18nKey[category];
const setQuery = useSetAtom(cmdkQueryAtom);
return (
<Command.Group key={category} heading={t[i18nkey]()}>
{commands.map(command => {
return (
<Command.Item
key={command.id}
onSelect={() => {
command.run();
setQuery('');
onOpenChange?.(false);
}}
value={command.value}
>
<div className={styles.itemIcon}>{command.icon}</div>
<div
data-testid="cmdk-label"
className={styles.itemLabel}
data-value={
command.originalValue ? command.originalValue : undefined
}
>
{command.label}
</div>
{command.timestamp ? (
<div className={styles.timestamp}>
{formatDate(new Date(command.timestamp))}
</div>
) : null}
{command.keyBinding ? (
<CMDKKeyBinding
keyBinding={
typeof command.keyBinding === 'string'
? command.keyBinding
: command.keyBinding.binding
}
/>
) : null}
</Command.Item>
);
})}
</Command.Group>
);
};
const QuickSearchCommands = ({
onOpenChange,
}: {
onOpenChange?: (open: boolean) => void;
}) => {
const groups = useCMDKCommandGroups();
return groups.map(([category, commands]) => {
return (
<QuickSearchGroup
key={category}
onOpenChange={onOpenChange}
category={category}
commands={commands}
/>
);
});
};
export const CMDKContainer = ({
className,
onQueryChange,
query,
children,
...rest
}: React.PropsWithChildren<{
className?: string;
query: string;
onQueryChange: (query: string) => void;
}>) => {
const t = useAFFiNEI18N();
return (
<Command
{...rest}
data-testid="cmdk-quick-search"
filter={customCommandFilter}
className={clsx(className, styles.panelContainer)}
// Handle KeyboardEvent conflicts with blocksuite
onKeyDown={(e: React.KeyboardEvent) => {
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight'
) {
e.stopPropagation();
}
}}
>
{/* todo: add page context here */}
<Command.Input
placeholder={t['com.affine.cmdk.placeholder']()}
autoFocus
{...rest}
value={query}
onValueChange={onQueryChange}
className={clsx(className, styles.searchInput)}
/>
<Command.List>{children}</Command.List>
</Command>
);
};
export const CMDKQuickSearchModal = (props: CMDKModalProps) => {
const [query, setQuery] = useAtom(cmdkQueryAtom);
useEffect(() => {
if (props.open) {
setQuery('');
}
}, [props.open, setQuery]);
return (
<CMDKModal {...props}>
<CMDKContainer
className={styles.root}
query={query}
onQueryChange={setQuery}
>
<Suspense fallback={<Command.Loading />}>
<QuickSearchCommands onOpenChange={props.onOpenChange} />
</Suspense>
</CMDKContainer>
</CMDKModal>
);
};
const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => {
const isMacOS = environment.isBrowser && environment.isMacOs;
const fragments = useMemo(() => {
return keyBinding.split('+').map(fragment => {
if (fragment === '$mod') {
return isMacOS ? '⌘' : 'Ctrl';
}
if (fragment === 'ArrowUp') {
return '↑';
}
if (fragment === 'ArrowDown') {
return '↓';
}
if (fragment === 'ArrowLeft') {
return '←';
}
if (fragment === 'ArrowRight') {
return '→';
}
return fragment;
});
}, [isMacOS, keyBinding]);
return (
<div className={styles.keybinding}>
{fragments.map((fragment, index) => {
return (
<div key={index} className={styles.keybindingFragment}>
{fragment}
</div>
);
})}
</div>
);
};

View File

@@ -0,0 +1,55 @@
import { keyframes, style } from '@vanilla-extract/css';
const contentShow = keyframes({
from: { opacity: 0, transform: 'translateY(-2%) scale(0.96)' },
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
});
const contentHide = keyframes({
to: { opacity: 0, transform: 'translateY(-2%) scale(0.96)' },
from: { opacity: 1, transform: 'translateY(0) scale(1)' },
});
export const modalOverlay = style({
position: 'fixed',
inset: 0,
backgroundColor: 'transparent',
zIndex: 'var(--affine-z-index-modal)',
});
export const modalContentWrapper = style({
position: 'fixed',
inset: 0,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
zIndex: 'var(--affine-z-index-modal)',
padding: '13vh 16px 16px',
});
export const modalContent = style({
width: 640,
// height: 530,
backgroundColor: 'var(--affine-background-overlay-panel-color)',
boxShadow: 'var(--affine-cmd-shadow)',
borderRadius: '12px',
maxWidth: 'calc(100vw - 50px)',
minWidth: 480,
// minHeight: 420,
// :focus-visible will set outline
outline: 'none',
position: 'relative',
zIndex: 'var(--affine-z-index-modal)',
willChange: 'transform, opacity',
selectors: {
'&[data-state=entered], &[data-state=entering]': {
animation: `${contentShow} 120ms cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
},
'&[data-state=exited], &[data-state=exiting]': {
animation: `${contentHide} 120ms cubic-bezier(0.42, 0, 0.58, 1)`,
animationFillMode: 'forwards',
},
},
});

View File

@@ -0,0 +1,67 @@
import * as Dialog from '@radix-ui/react-dialog';
import { useEffect, useReducer } from 'react';
import * as styles from './modal.css';
// a CMDK modal that can be used to display a CMDK command
// it has a smooth animation and can be closed by clicking outside of the modal
export interface CMDKModalProps {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
type ModalAnimationState = 'entering' | 'entered' | 'exiting' | 'exited';
function reduceAnimationState(
state: ModalAnimationState,
action: 'open' | 'close' | 'finish'
) {
switch (action) {
case 'open':
return state === 'entered' || state === 'entering' ? state : 'entering';
case 'close':
return state === 'exited' || state === 'exiting' ? state : 'exiting';
case 'finish':
return state === 'entering' ? 'entered' : 'exited';
}
}
export const CMDKModal = ({
onOpenChange,
open,
children,
}: React.PropsWithChildren<CMDKModalProps>) => {
const [animationState, dispatch] = useReducer(reduceAnimationState, 'exited');
useEffect(() => {
dispatch(open ? 'open' : 'close');
const timeout = setTimeout(() => {
dispatch('finish');
}, 120);
return () => {
clearTimeout(timeout);
};
}, [open]);
return (
<Dialog.Root
modal
open={animationState !== 'exited'}
onOpenChange={onOpenChange}
>
<Dialog.Portal>
<Dialog.Overlay className={styles.modalOverlay} />
<div className={styles.modalContentWrapper}>
<Dialog.Content
className={styles.modalContent}
data-state={animationState}
>
{children}
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
);
};

View File

@@ -0,0 +1,22 @@
import type { Page } from '@blocksuite/store';
import type { CommandCategory } from '@toeverything/infra/command';
export interface CommandContext {
currentPage: Page | undefined;
pageMode: 'page' | 'edgeless' | undefined;
}
// similar to AffineCommand, but for rendering into the UI
// it unifies all possible commands into a single type so that
// we can use a single render function to render all different commands
export interface CMDKCommand {
id: string;
label: string | React.ReactNode;
icon?: React.ReactNode;
category: CommandCategory;
keyBinding?: string | { binding: string };
timestamp?: number;
value?: string; // this is used for item filtering
originalValue?: string; // some values may be transformed, this is the original value
run: (e?: Event) => void | Promise<void>;
}

View File

@@ -1,60 +0,0 @@
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
DeleteTemporarilyIcon,
FolderIcon,
SettingsIcon,
} from '@blocksuite/icons';
import { useAtom } from 'jotai';
import type { ReactElement, SVGProps } from 'react';
import { useMemo } from 'react';
import { openSettingModalAtom } from '../../../atoms';
type IconComponent = (props: SVGProps<SVGSVGElement>) => ReactElement;
interface ConfigItem {
title: string;
icon: IconComponent;
onClick: () => void;
}
interface ConfigPathItem {
title: string;
icon: IconComponent;
subPath: WorkspaceSubPath;
}
export type Config = ConfigItem | ConfigPathItem;
export const useSwitchToConfig = (workspaceId: string): Config[] => {
const t = useAFFiNEI18N();
const [, setOpenSettingModalAtom] = useAtom(openSettingModalAtom);
return useMemo(
() => [
{
title: t['com.affine.workspaceSubPath.all'](),
subPath: WorkspaceSubPath.ALL,
icon: FolderIcon,
},
{
title: t['Workspace Settings'](),
onClick: () => {
setOpenSettingModalAtom({
open: true,
activeTab: 'workspace',
workspaceId,
});
},
icon: SettingsIcon,
},
{
title: t['com.affine.workspaceSubPath.trash'](),
subPath: WorkspaceSubPath.TRASH,
icon: DeleteTemporarilyIcon,
},
],
[t, workspaceId, setOpenSettingModalAtom]
);
};

View File

@@ -1,59 +0,0 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertEquals } from '@blocksuite/global/utils';
import { PlusIcon } from '@blocksuite/icons';
import { nanoid } from '@blocksuite/store';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { initEmptyPage } from '@toeverything/infra/blocksuite';
import { Command } from 'cmdk';
import { useCallback } from 'react';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import type { BlockSuiteWorkspace } from '../../../shared';
import { StyledModalFooterContent } from './style';
export interface FooterProps {
query: string;
onClose: () => void;
blockSuiteWorkspace: BlockSuiteWorkspace;
}
export const Footer = ({
query,
onClose,
blockSuiteWorkspace,
}: FooterProps) => {
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const t = useAFFiNEI18N();
const { jumpToPage } = useNavigateHelper();
const MAX_QUERY_SHOW_LENGTH = 20;
const normalizedQuery =
query.length > MAX_QUERY_SHOW_LENGTH
? query.slice(0, MAX_QUERY_SHOW_LENGTH) + '...'
: query;
return (
<Command.Item
data-testid="quick-search-add-new-page"
onSelect={useCallback(async () => {
const id = nanoid();
const page = createPage(id);
assertEquals(page.id, id);
await initEmptyPage(page, query);
blockSuiteWorkspace.setPageMeta(page.id, {
title: query,
});
onClose();
jumpToPage(blockSuiteWorkspace.id, page.id);
}, [blockSuiteWorkspace, createPage, jumpToPage, onClose, query])}
>
<StyledModalFooterContent>
<PlusIcon />
{query ? (
<span>{t['New Keyword Page']({ query: normalizedQuery })}</span>
) : (
<span>{t['New Page']()}</span>
)}
</StyledModalFooterContent>
</Command.Item>
);
};

View File

@@ -1,164 +0,0 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Modal } from '@toeverything/components/modal';
import { Command } from 'cmdk';
import { startTransition, Suspense } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AllWorkspace } from '../../../shared';
import { Footer } from './footer';
import { Results } from './results';
import { SearchInput } from './search-input';
import {
StyledContent,
StyledModalDivider,
StyledModalFooter,
StyledModalHeader,
StyledNotFound,
StyledShortcut,
} from './style';
export interface QuickSearchModalProps {
workspace: AllWorkspace;
open: boolean;
setOpen: (value: boolean) => void;
}
export const QuickSearchModal = ({
open,
setOpen,
workspace,
}: QuickSearchModalProps) => {
const blockSuiteWorkspace = workspace?.blockSuiteWorkspace;
const t = useAFFiNEI18N();
const inputRef = useRef<HTMLInputElement>(null);
const [query, _setQuery] = useState('');
const setQuery = useCallback((query: string) => {
startTransition(() => {
_setQuery(query);
});
}, []);
const [showCreatePage, setShowCreatePage] = useState(true);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
// Add ‘⌘+K shortcut keys as switches
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
if ((e.key === 'k' && e.metaKey) || (e.key === 'k' && e.ctrlKey)) {
const selection = window.getSelection();
// prevent search bar focus in firefox
e.preventDefault();
setQuery('');
if (selection?.toString()) {
setOpen(false);
return;
}
setOpen(!open);
}
};
document.addEventListener('keydown', keydown, { capture: true });
return () =>
document.removeEventListener('keydown', keydown, { capture: true });
}, [open, setOpen, setQuery]);
useEffect(() => {
if (open) {
// Waiting for DOM rendering
requestAnimationFrame(() => {
const inputElement = inputRef.current;
inputElement?.focus();
});
}
}, [open]);
return (
<Modal
open={open}
onOpenChange={setOpen}
width={608}
withoutCloseButton
contentOptions={{
['data-testid' as string]: 'quickSearch',
style: {
maxHeight: '80vh',
minHeight: '412px',
top: '80px',
overflow: 'hidden',
transform: 'translateX(-50%)',
padding: 0,
},
}}
>
<Command
shouldFilter={false}
//Handle KeyboardEvent conflicts with blocksuite
onKeyDown={(e: React.KeyboardEvent) => {
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight'
) {
e.stopPropagation();
}
}}
>
<StyledModalHeader>
<SearchInput
ref={inputRef}
onValueChange={value => {
setQuery(value);
}}
onKeyDown={e => {
// Avoid triggering the cmdk onSelect event when the input method is in use
if (e.nativeEvent.isComposing) {
e.stopPropagation();
return;
}
}}
placeholder={t['Quick search placeholder']()}
/>
<StyledShortcut>
{environment.isBrowser && environment.isMacOs
? '⌘ + K'
: 'Ctrl + K'}
</StyledShortcut>
</StyledModalHeader>
<StyledModalDivider />
<Command.List>
<StyledContent>
<Suspense
fallback={
<StyledNotFound>
<span>{t['com.affine.loading']()}</span>
</StyledNotFound>
}
>
<Results
query={query}
onClose={handleClose}
workspace={workspace}
setShowCreatePage={setShowCreatePage}
/>
</Suspense>
</StyledContent>
{showCreatePage ? (
<>
<StyledModalDivider />
<StyledModalFooter>
<Footer
query={query}
onClose={handleClose}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
</StyledModalFooter>
</>
) : null}
</Command.List>
</Command>
</Modal>
);
};
export default QuickSearchModal;

View File

@@ -1,188 +0,0 @@
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
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 { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { Command } from 'cmdk';
import { type Atom, atom, useAtomValue } from 'jotai';
import type { Dispatch, SetStateAction } from 'react';
import { startTransition, useEffect } from 'react';
import { recentPageSettingsAtom } from '../../../atoms';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import type { AllWorkspace } from '../../../shared';
import { useSwitchToConfig } from './config';
import { StyledListItem, StyledNotFound } from './style';
export interface ResultsProps {
workspace: AllWorkspace;
query: string;
onClose: () => void;
setShowCreatePage: Dispatch<SetStateAction<boolean>>;
}
const loadAllPageWeakMap = new WeakMap<Workspace, Atom<Promise<void>>>();
function getLoadAllPage(workspace: Workspace) {
if (loadAllPageWeakMap.has(workspace)) {
return loadAllPageWeakMap.get(workspace) as Atom<Promise<void>>;
} else {
const aAtom = atom(async () => {
// fixme: we have to load all pages here and re-index them
// there might have performance issue
await Promise.all(
[...workspace.pages.values()].map(page =>
page.waitForLoaded().then(() => {
workspace.indexer.search.refreshPageIndex(page.id, page.spaceDoc);
})
)
);
});
loadAllPageWeakMap.set(workspace, aAtom);
return aAtom;
}
}
export const Results = ({
query,
workspace,
setShowCreatePage,
onClose,
}: ResultsProps) => {
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const pageList = useBlockSuitePageMeta(blockSuiteWorkspace);
assertExists(blockSuiteWorkspace.id);
const list = useSwitchToConfig(workspace.id);
useAtomValue(getLoadAllPage(blockSuiteWorkspace));
const recentPageSetting = useAtomValue(recentPageSettingsAtom);
const t = useAFFiNEI18N();
const { jumpToPage, jumpToSubPath } = useNavigateHelper();
const pageIds = [...blockSuiteWorkspace.search({ query }).values()].map(
id => {
if (id.startsWith('space:')) {
return id.slice(6);
} else {
return id;
}
}
);
const resultsPageMeta = pageList.filter(
page => pageIds.indexOf(page.id) > -1 && !page.trash
);
const recentlyViewedItem = recentPageSetting.filter(recent => {
const page = pageList.find(page => recent.id === page.id);
if (!page) {
return false;
} else {
return page.trash !== true;
}
});
useEffect(() => {
startTransition(() => {
setShowCreatePage(resultsPageMeta.length === 0);
});
}, [resultsPageMeta.length, setShowCreatePage]);
if (!query) {
return (
<>
{recentlyViewedItem.length > 0 && (
<Command.Group heading={t['Recent']()}>
{recentlyViewedItem.map(recent => {
const page = pageList.find(page => recent.id === page.id);
assertExists(page);
return (
<Command.Item
key={page.id}
value={page.id}
onSelect={() => {
onClose();
jumpToPage(blockSuiteWorkspace.id, page.id);
}}
>
<StyledListItem>
{recent.mode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PageIcon />
)}
<span>{page.title || UNTITLED_WORKSPACE_NAME}</span>
</StyledListItem>
</Command.Item>
);
})}
</Command.Group>
)}
<Command.Group heading={t['Jump to']()}>
{list.map(link => {
return (
<Command.Item
key={link.title}
value={link.title}
onSelect={() => {
onClose();
if ('subPath' in link) {
jumpToSubPath(blockSuiteWorkspace.id, link.subPath);
} else if ('onClick' in link) {
link.onClick();
} else {
throw new Error('Invalid link');
}
}}
>
<StyledListItem>
<link.icon />
<span>{link.title}</span>
</StyledListItem>
</Command.Item>
);
})}
</Command.Group>
</>
);
}
if (!resultsPageMeta.length) {
return (
<StyledNotFound>
<span>{t['Find 0 result']()}</span>
<img
alt="no result"
src="/imgs/no-result.svg"
width={200}
height={200}
/>
</StyledNotFound>
);
}
return (
<Command.Group
heading={t['Find results']({ number: `${resultsPageMeta.length}` })}
>
{resultsPageMeta.map(result => {
return (
<Command.Item
key={result.id}
onSelect={() => {
onClose();
assertExists(blockSuiteWorkspace.id);
jumpToPage(blockSuiteWorkspace.id, result.id);
}}
value={result.id}
>
<StyledListItem>
{result.mode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />}
<span>{result.title || UNTITLED_WORKSPACE_NAME}</span>
</StyledListItem>
</Command.Item>
);
})}
</Command.Group>
);
};

View File

@@ -1,33 +0,0 @@
import { SearchIcon } from '@blocksuite/icons';
import { Command } from 'cmdk';
import { forwardRef } from 'react';
import { StyledInputContent, StyledLabel } from './style';
export const SearchInput = forwardRef<
HTMLInputElement,
Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'value' | 'onChange' | 'type'
> & {
/**
* Optional controlled state for the value of the search input.
*/
value?: string;
/**
* Event handler called when the search value changes.
*/
onValueChange?: (search: string) => void;
} & React.RefAttributes<HTMLInputElement>
>((props, ref) => {
return (
<StyledInputContent>
<StyledLabel htmlFor=":r5:">
<SearchIcon />
</StyledLabel>
<Command.Input ref={ref} {...props} />
</StyledInputContent>
);
});
SearchInput.displayName = 'SearchInput';

View File

@@ -1,180 +0,0 @@
import { displayFlex, styled, textEllipsis } from '@affine/component';
export const StyledContent = styled('div')(() => {
return {
minHeight: '290px',
maxHeight: '70vh',
width: '100%',
overflow: 'auto',
marginBottom: '10px',
...displayFlex('flex-start', 'flex-start'),
flexDirection: 'column',
color: 'var(--affine-text-primary-color)',
transition: 'all 0.15s',
letterSpacing: '0.06em',
'[cmdk-group]': {
width: '100%',
},
'[cmdk-group-heading]': {
...displayFlex('start', 'center'),
margin: '0 16px',
height: '36px',
lineHeight: '22px',
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
},
'[cmdk-item]': {
margin: '0 4px',
},
'[aria-selected="true"]': {
transition: 'all 0.15s',
borderRadius: '4px',
color: 'var(--affine-primary-color)',
backgroundColor: 'var(--affine-hover-color)',
padding: '0 2px',
},
};
});
export const StyledJumpTo = styled('div')(() => {
return {
...displayFlex('center', 'start'),
flexDirection: 'column',
padding: '10px 10px 10px 0',
fontSize: 'var(--affine-font-base)',
strong: {
fontWeight: '500',
marginBottom: '10px',
},
};
});
export const StyledNotFound = styled('div')(() => {
return {
width: '612px',
...displayFlex('center', 'center'),
flexDirection: 'column',
padding: '0 16px',
fontSize: 'var(--affine-font-sm)',
lineHeight: '22px',
color: 'var(--affine-text-secondary-color)',
span: {
...displayFlex('flex-start', 'center'),
width: '100%',
fontWeight: '400',
height: '36px',
},
img: {
marginTop: '10px',
},
};
});
export const StyledInputContent = styled('div')(() => {
return {
...displayFlex('space-between', 'center'),
input: {
width: '492px',
height: '22px',
padding: '0 12px',
fontSize: 'var(--affine-font-base)',
...displayFlex('space-between', 'center'),
letterSpacing: '0.06em',
color: 'var(--affine-text-primary-color)',
'::placeholder': {
color: 'var(--affine-placeholder-color)',
},
},
};
});
export const StyledShortcut = styled('div')(() => {
return {
color: 'var(--affine-placeholder-color)',
fontSize: 'var(--affine-font-sm)',
whiteSpace: 'nowrap',
};
});
export const StyledLabel = styled('label')(() => {
return {
width: '20px',
height: '20px',
color: 'var(--affine-icon-color)',
fontSize: '20px',
};
});
export const StyledModalHeader = styled('div')(() => {
return {
height: '36px',
margin: '12px 16px 0px 16px',
...displayFlex('space-between', 'center'),
};
});
export const StyledModalDivider = styled('div')(() => {
return {
width: 'auto',
height: '0',
margin: '6px 16px',
borderTop: '0.5px solid var(--affine-border-color)',
};
});
export const StyledModalFooter = styled('div')(() => {
return {
fontSize: 'inherit',
lineHeight: '22px',
marginBottom: '8px',
textAlign: 'center',
color: 'var(--affine-text-primary-color)',
...displayFlex('center', 'center'),
transition: 'all .15s',
'[cmdk-item]': {
margin: '0 4px',
},
'[aria-selected="true"]': {
transition: 'all 0.15s',
borderRadius: '4px',
color: 'var(--affine-primary-color)',
backgroundColor: 'var(--affine-hover-color)',
'span,svg': {
transition: 'all 0.15s',
transform: 'scale(1.02)',
},
},
};
});
export const StyledModalFooterContent = styled('button')(() => {
return {
width: '600px',
height: '32px',
fontSize: 'var(--affine-font-base)',
lineHeight: '22px',
textAlign: 'center',
...displayFlex('center', 'center'),
color: 'inherit',
borderRadius: '4px',
transition: 'background .15s, color .15s',
'>svg': {
fontSize: '20px',
marginRight: '12px',
},
};
});
export const StyledListItem = styled('button')(() => {
return {
width: '100%',
height: '32px',
fontSize: 'inherit',
color: 'inherit',
padding: '0 12px',
borderRadius: '4px',
transition: 'all .15s',
...displayFlex('flex-start', 'center'),
span: {
...textEllipsis(1),
},
'> svg': {
fontSize: '20px',
marginRight: '12px',
},
};
});

View File

@@ -20,7 +20,7 @@ import {
import type { Page } from '@blocksuite/store';
import { useDroppable } from '@dnd-kit/core';
import { Menu } from '@toeverything/components/menu';
import { useAtom } from 'jotai';
import { useAtomValue } from 'jotai';
import type { HTMLAttributes, ReactElement } from 'react';
import { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
@@ -115,7 +115,7 @@ export const RootAppSidebar = ({
return;
}, [onClickNewPage]);
const [sidebarOpen, setSidebarOpen] = useAtom(appSidebarOpenAtom);
const sidebarOpen = useAtomValue(appSidebarOpenAtom);
useEffect(() => {
if (environment.isDesktop) {
window.apis?.ui.handleSidebarVisibilityChange(sidebarOpen).catch(err => {
@@ -124,17 +124,6 @@ export const RootAppSidebar = ({
}
}, [sidebarOpen]);
useEffect(() => {
const keydown = (e: KeyboardEvent) => {
if ((e.key === '/' && e.metaKey) || (e.key === '/' && e.ctrlKey)) {
setSidebarOpen(!sidebarOpen);
}
};
document.addEventListener('keydown', keydown, { capture: true });
return () =>
document.removeEventListener('keydown', keydown, { capture: true });
}, [sidebarOpen, setSidebarOpen]);
const [history, setHistory] = useHistoryAtom();
const router = useMemo(() => {
return {

View File

@@ -0,0 +1,54 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useStore } from 'jotai';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import {
registerAffineCreationCommands,
registerAffineLayoutCommands,
registerAffineSettingsCommands,
} from '../commands';
import { registerAffineNavigationCommands } from '../commands/affine-navigation';
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
import { useCurrentWorkspace } from './current/use-current-workspace';
import { useNavigateHelper } from './use-navigate-helper';
export function useRegisterWorkspaceCommands() {
const store = useStore();
const t = useAFFiNEI18N();
const theme = useTheme();
const [currentWorkspace] = useCurrentWorkspace();
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
const navigationHelper = useNavigateHelper();
useEffect(() => {
const unsubs: Array<() => void> = [];
unsubs.push(
registerAffineNavigationCommands({
store,
t,
workspace: currentWorkspace.blockSuiteWorkspace,
navigationHelper,
})
);
unsubs.push(registerAffineSettingsCommands({ store, t, theme }));
unsubs.push(registerAffineLayoutCommands({ store, t }));
unsubs.push(
registerAffineCreationCommands({
store,
pageHelper: pageHelper,
t,
})
);
return () => {
unsubs.forEach(unsub => unsub());
};
}, [
store,
pageHelper,
t,
theme,
currentWorkspace.blockSuiteWorkspace,
navigationHelper,
]);
}

View File

@@ -54,6 +54,7 @@ import {
import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
import {
AllWorkspaceModals,
CurrentWorkspaceModals,
@@ -61,9 +62,9 @@ import {
import { pathGenerator } from '../shared';
import { toast } from '../utils';
const QuickSearchModal = lazy(() =>
import('../components/pure/quick-search-modal').then(module => ({
default: module.QuickSearchModal,
const CMDKQuickSearchModal = lazy(() =>
import('../components/pure/cmdk').then(module => ({
default: module.CMDKQuickSearchModal,
}))
);
@@ -79,10 +80,9 @@ export const QuickSearch = () => {
}
return (
<QuickSearchModal
workspace={currentWorkspace}
<CMDKQuickSearchModal
open={openQuickSearchModal}
setOpen={setOpenQuickSearchModalAtom}
onOpenChange={setOpenQuickSearchModalAtom}
/>
);
};
@@ -141,6 +141,10 @@ export const WorkspaceLayoutInner = ({
}: PropsWithChildren<WorkspaceLayoutProps>) => {
const [currentWorkspace] = useCurrentWorkspace();
const { openPage } = useNavigateHelper();
const pageHelper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
const t = useAFFiNEI18N();
useRegisterWorkspaceCommands();
useEffect(() => {
// hotfix for blockVersions
@@ -164,15 +168,13 @@ export const WorkspaceLayoutInner = ({
usePassiveWorkspaceEffect(currentWorkspace.blockSuiteWorkspace);
const helper = usePageHelper(currentWorkspace.blockSuiteWorkspace);
const handleCreatePage = useCallback(() => {
const id = nanoid();
helper.createPage(id);
pageHelper.createPage(id);
const page = currentWorkspace.blockSuiteWorkspace.getPage(id);
assertExists(page);
return page;
}, [currentWorkspace.blockSuiteWorkspace, helper]);
}, [currentWorkspace.blockSuiteWorkspace, pageHelper]);
const [, setOpenQuickSearchModalAtom] = useAtom(openQuickSearchModalAtom);
const handleOpenQuickSearchModal = useCallback(() => {
@@ -205,7 +207,6 @@ export const WorkspaceLayoutInner = ({
const { removeToTrash: moveToTrash } = useBlockSuiteMetaHelper(
currentWorkspace.blockSuiteWorkspace
);
const t = useAFFiNEI18N();
const handleDragEnd = useCallback(
(e: DragEndEvent) => {

View File

@@ -55,6 +55,7 @@
"jotai": "^2.4.1",
"lodash-es": "^4.17.21",
"rxjs": "^7.8.1",
"tinykeys": "^2.1.0",
"ts-node": "^10.9.1",
"undici": "^5.23.0",
"uuid": "^9.0.0",

View File

@@ -45,6 +45,7 @@ export const config = () => {
'@toeverything/plugin-infra',
'yjs',
'semver',
'tinykeys',
],
define: define,
format: 'cjs',

View File

@@ -36,6 +36,13 @@ export default {
async viteFinal(config, _options) {
return mergeConfig(config, {
assetsInclude: ['**/*.md'],
resolve: {
alias: {
'@toeverything/infra': fileURLToPath(
new URL('../../../packages/infra/src', import.meta.url)
),
},
},
plugins: [
vanillaExtractPlugin(),
tsconfigPaths({

View File

@@ -1,10 +1,11 @@
import { Empty } from '@affine/component';
import { toast } from '@affine/component';
import { Empty, toast } from '@affine/component';
import type { OperationCellProps } from '@affine/component/page-list';
import { PageListTrashView } from '@affine/component/page-list';
import { PageList } from '@affine/component/page-list';
import { NewPageButton } from '@affine/component/page-list';
import { OperationCell } from '@affine/component/page-list';
import {
NewPageButton,
OperationCell,
PageList,
PageListTrashView,
} from '@affine/component/page-list';
import { PageIcon } from '@blocksuite/icons';
import { expect } from '@storybook/jest';
import type { Meta, StoryFn } from '@storybook/react';

View File

@@ -0,0 +1,80 @@
import { CMDKQuickSearchModal } from '@affine/core/components/pure/cmdk';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { getOrCreateWorkspace } from '@affine/workspace/manager';
import type { Meta, StoryFn } from '@storybook/react';
import { currentWorkspaceIdAtom } from '@toeverything/infra/atom';
import {
registerAffineCreationCommands,
registerAffineLayoutCommands,
registerAffineSettingsCommands,
} from 'apps/core/src/commands';
import { useStore } from 'jotai';
import { useEffect, useLayoutEffect } from 'react';
import { withRouter } from 'storybook-addon-react-router-v6';
export default {
title: 'AFFiNE/QuickSearch',
parameters: {
chromatic: { disableSnapshot: true },
},
} satisfies Meta;
function useRegisterCommands() {
const t = useAFFiNEI18N();
const store = useStore();
useEffect(() => {
const unsubs = [
registerAffineSettingsCommands({
t,
store,
theme: {
setTheme: () => {},
theme: 'auto',
themes: ['auto', 'dark', 'light'],
},
}),
registerAffineCreationCommands({
t,
store,
pageHelper: {
createEdgeless: () => 'noop',
createPage: () => 'noop',
importFile: () => Promise.resolve(),
isPreferredEdgeless: () => false,
},
}),
registerAffineLayoutCommands({ t, store }),
];
return () => {
unsubs.forEach(unsub => unsub());
};
}, [store, t]);
}
function usePrepareWorkspace() {
const store = useStore();
useLayoutEffect(() => {
const workspaceId = 'test-workspace';
getOrCreateWorkspace(workspaceId, WorkspaceFlavour.LOCAL);
store.set(rootWorkspacesMetadataAtom, [
{
id: workspaceId,
flavour: WorkspaceFlavour.LOCAL,
version: 4,
},
]);
store.set(currentWorkspaceIdAtom, workspaceId);
}, [store]);
}
export const CMDKStoryWithCommands: StoryFn = () => {
usePrepareWorkspace();
useRegisterCommands();
return <CMDKQuickSearchModal open />;
};
CMDKStoryWithCommands.decorators = [withRouter];

View File

@@ -0,0 +1,37 @@
import { CMDKContainer, CMDKModal } from '@affine/core/components/pure/cmdk';
import type { Meta, StoryFn } from '@storybook/react';
import { Button } from '@toeverything/components/button';
import { useState } from 'react';
export default {
title: 'AFFiNE/QuickSearch',
parameters: {
chromatic: { disableSnapshot: true },
},
} satisfies Meta;
export const CMDKModalStory: StoryFn = () => {
const [open, setOpen] = useState(false);
const [counter, setCounter] = useState(0);
return (
<>
<Button onClick={() => setOpen(true)}>Open Modal</Button>
<CMDKModal key={counter} open={open} onOpenChange={setOpen}>
<Button onClick={() => setCounter(c => c + 1)}>
Trigger new modal
</Button>
</CMDKModal>
</>
);
};
export const CMDKPanelStory: StoryFn = () => {
const [query, setQuery] = useState('');
return (
<>
<CMDKModal open>
<CMDKContainer query={query} onQueryChange={setQuery} />
</CMDKModal>
</>
);
};