mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat: new CMD-K (#4408)
This commit is contained in:
321
apps/core/src/components/pure/cmdk/data.tsx
Normal file
321
apps/core/src/components/pure/cmdk/data.tsx
Normal 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;
|
||||
}
|
||||
2
apps/core/src/components/pure/cmdk/index.tsx
Normal file
2
apps/core/src/components/pure/cmdk/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './main';
|
||||
export * from './modal';
|
||||
131
apps/core/src/components/pure/cmdk/main.css.ts
Normal file
131
apps/core/src/components/pure/cmdk/main.css.ts
Normal 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)',
|
||||
});
|
||||
215
apps/core/src/components/pure/cmdk/main.tsx
Normal file
215
apps/core/src/components/pure/cmdk/main.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
55
apps/core/src/components/pure/cmdk/modal.css.ts
Normal file
55
apps/core/src/components/pure/cmdk/modal.css.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
67
apps/core/src/components/pure/cmdk/modal.tsx
Normal file
67
apps/core/src/components/pure/cmdk/modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
apps/core/src/components/pure/cmdk/types.ts
Normal file
22
apps/core/src/components/pure/cmdk/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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]
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user