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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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 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>
</>
);
};

3
packages/cmdk/README.md Normal file
View File

@ -0,0 +1,3 @@
# copied directly from https://github.com/pacocoursey/cmdk
will remove after a new CMDK version is published to npm

View File

@ -0,0 +1,12 @@
{
"name": "@affine/cmdk",
"private": true,
"type": "module",
"main": "./src/index.tsx",
"module": "./src/index.tsx",
"devDependencies": {
"react": "18.2.0",
"react-dom": "18.2.0"
},
"version": "0.2.1"
}

View File

@ -0,0 +1,179 @@
/* eslint-disable */
// @ts-nocheck
// The scores are arranged so that a continuous match of characters will
// result in a total score of 1.
//
// The best case, this character is a match, and either this is the start
// of the string, or the previous character was also a match.
var SCORE_CONTINUE_MATCH = 1,
// A new match at the start of a word scores better than a new match
// elsewhere as it's more likely that the user will type the starts
// of fragments.
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
// hyphens, etc.
SCORE_SPACE_WORD_JUMP = 0.9,
SCORE_NON_SPACE_WORD_JUMP = 0.8,
// Any other match isn't ideal, but we include it for completeness.
SCORE_CHARACTER_JUMP = 0.17,
// If the user transposed two letters, it should be significantly penalized.
//
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
SCORE_TRANSPOSITION = 0.1,
// The goodness of a match should decay slightly with each missing
// character.
//
// i.e. "bad" is more likely than "bard" when "bd" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 100 characters are inserted between matches.
PENALTY_SKIPPED = 0.999,
// The goodness of an exact-case match should be higher than a
// case-insensitive match by a small amount.
//
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 1000 characters are inserted between matches.
PENALTY_CASE_MISMATCH = 0.9999,
// Match higher for letters closer to the beginning of the word
PENALTY_DISTANCE_FROM_START = 0.9,
// If the word has more characters than the user typed, it should
// be penalised slightly.
//
// i.e. "html" is more likely than "html5" if I type "html".
//
// However, it may well be the case that there's a sensible secondary
// ordering (like alphabetical) that it makes sense to rely on when
// there are many prefix matches, so we don't make the penalty increase
// with the number of tokens.
PENALTY_NOT_COMPLETE = 0.99;
var IS_GAP_REGEXP = /[\\\/_+.#"@\[\(\{&]/,
COUNT_GAPS_REGEXP = /[\\\/_+.#"@\[\(\{&]/g,
IS_SPACE_REGEXP = /[\s-]/,
COUNT_SPACE_REGEXP = /[\s-]/g;
function commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
stringIndex,
abbreviationIndex,
memoizedResults
) {
if (abbreviationIndex === abbreviation.length) {
if (stringIndex === string.length) {
return SCORE_CONTINUE_MATCH;
}
return PENALTY_NOT_COMPLETE;
}
var memoizeKey = `${stringIndex},${abbreviationIndex}`;
if (memoizedResults[memoizeKey] !== undefined) {
return memoizedResults[memoizeKey];
}
var abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
var index = lowerString.indexOf(abbreviationChar, stringIndex);
var highScore = 0;
var score, transposedScore, wordBreaks, spaceBreaks;
while (index >= 0) {
score = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 1,
memoizedResults
);
if (score > highScore) {
if (index === stringIndex) {
score *= SCORE_CONTINUE_MATCH;
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_NON_SPACE_WORD_JUMP;
wordBreaks = string
.slice(stringIndex, index - 1)
.match(COUNT_GAPS_REGEXP);
if (wordBreaks && stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length);
}
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_SPACE_WORD_JUMP;
spaceBreaks = string
.slice(stringIndex, index - 1)
.match(COUNT_SPACE_REGEXP);
if (spaceBreaks && stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length);
}
} else {
score *= SCORE_CHARACTER_JUMP;
if (stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, index - stringIndex);
}
}
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
score *= PENALTY_CASE_MISMATCH;
}
}
if (
(score < SCORE_TRANSPOSITION &&
lowerString.charAt(index - 1) ===
lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
(lowerAbbreviation.charAt(abbreviationIndex + 1) ===
lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
lowerString.charAt(index - 1) !==
lowerAbbreviation.charAt(abbreviationIndex))
) {
transposedScore = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 2,
memoizedResults
);
if (transposedScore * SCORE_TRANSPOSITION > score) {
score = transposedScore * SCORE_TRANSPOSITION;
}
}
if (score > highScore) {
highScore = score;
}
index = lowerString.indexOf(abbreviationChar, index + 1);
}
memoizedResults[memoizeKey] = highScore;
return highScore;
}
function formatInput(string) {
// convert all valid space characters to space so they match each other
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ');
}
export function commandScore(string: string, abbreviation: string): number {
/* NOTE:
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
*/
return commandScoreInner(
string,
abbreviation,
formatInput(string),
formatInput(abbreviation),
0,
0,
{}
);
}

1134
packages/cmdk/src/index.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"include": ["./src"],
"compilerOptions": {
"composite": true,
"noEmit": false,
"outDir": "lib"
}
}

View File

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

View File

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

View File

@ -8,4 +8,5 @@ export * from './operation-menu-items';
export * from './styles';
export * from './type';
export * from './use-collection-manager';
export * from './utils';
export * from './view';

View File

@ -584,5 +584,30 @@
"com.affine.workspaceList.addWorkspace.create": "Create Workspace",
"com.affine.workspaceList.workspaceListType.local": "Local Storage",
"com.affine.workspaceList.workspaceListType.cloud": "Cloud Sync",
"Local": "Local"
"Local": "Local",
"com.affine.cmdk.placeholder": "Type a command or search anything...",
"com.affine.cmdk.affine.new-page": "New Page",
"com.affine.cmdk.affine.new-edgeless-page": "New Edgeless",
"com.affine.cmdk.affine.new-workspace": "New Workspace",
"com.affine.cmdk.affine.create-new-page-as": "Create New Page as: <1>{{query}}</1>",
"com.affine.cmdk.affine.create-new-edgeless-as": "Create New Edgeless as: <1>{{query}}</1>",
"com.affine.cmdk.affine.color-scheme.to": "Change Colour Scheme to <1>{{colour}}</1>",
"com.affine.cmdk.affine.left-sidebar.expand": "Expand Left Sidebar",
"com.affine.cmdk.affine.left-sidebar.collapse": "Collapse Left Sidebar",
"com.affine.cmdk.affine.navigation.goto-all-pages": "Go to All Pages",
"com.affine.cmdk.affine.navigation.open-settings": "Go to Settings",
"com.affine.cmdk.affine.navigation.goto-trash": "Go to Trash",
"com.affine.cmdk.affine.category.affine.recent": "Recent",
"com.affine.cmdk.affine.category.affine.navigation": "Navigation",
"com.affine.cmdk.affine.category.affine.pages": "Pages",
"com.affine.cmdk.affine.category.affine.creation": "Create",
"com.affine.cmdk.affine.category.affine.settings": "Settings",
"com.affine.cmdk.affine.category.affine.layout": "Layout Controls",
"com.affine.cmdk.affine.category.affine.help": "Help",
"com.affine.cmdk.affine.category.affine.updates": "Updates",
"com.affine.cmdk.affine.category.affine.general": "General",
"com.affine.cmdk.affine.category.editor.insert-object": "Insert Object",
"com.affine.cmdk.affine.category.editor.page": "Page Commands",
"com.affine.cmdk.affine.category.editor.edgeless": "Edgeless Commands",
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Start Presentation"
}

View File

@ -15,6 +15,11 @@
"import": "./dist/blocksuite.js",
"require": "./dist/blocksuite.cjs"
},
"./command": {
"types": "./dist/src/command/index.d.ts",
"import": "./dist/command.js",
"require": "./dist/command.cjs"
},
"./core/*": {
"types": "./dist/src/core/*.d.ts",
"import": "./dist/core/*.js",
@ -54,6 +59,7 @@
"@blocksuite/global": "0.0.0-20230921103931-38d8f07a-nightly",
"@blocksuite/store": "0.0.0-20230921103931-38d8f07a-nightly",
"jotai": "^2.4.1",
"tinykeys": "^2.1.0",
"zod": "^3.22.2"
},
"devDependencies": {

View File

@ -0,0 +1,5 @@
# AFFiNE Command Abstractions
This package contains the command abstractions for the AFFiNE framework to be used for CMD-K.
The implementation is highly inspired by the [VSCode Command Abstractions](https://github.com/microsoft/vscode)

View File

@ -0,0 +1,80 @@
import type { ReactNode } from 'react';
// TODO: need better way for composing different precondition strategies
export enum PreconditionStrategy {
Always,
InPaperOrEdgeless,
InPaper,
InEdgeless,
InEdgelessPresentationMode,
NoSearchResult,
Never,
}
export type CommandCategory =
| 'editor:insert-object'
| 'editor:page'
| 'editor:edgeless'
| 'affine:recent'
| 'affine:pages'
| 'affine:navigation'
| 'affine:creation'
| 'affine:settings'
| 'affine:layout'
| 'affine:updates'
| 'affine:help'
| 'affine:general';
export interface KeybindingOptions {
binding: string;
// some keybindings are already registered in blocksuite
// we can skip the registration of these keybindings __FOR NOW__
skipRegister?: boolean;
}
export interface AffineCommandOptions {
id: string;
// a set of predefined precondition strategies, but also allow user to customize their own
preconditionStrategy?: PreconditionStrategy | (() => boolean);
// main text on the left..
// make text a function so that we can do i18n and interpolation when we need to
label?: string | (() => string) | ReactNode | (() => ReactNode);
icon: React.ReactNode; // todo: need a mapping from string -> React element/SVG
category?: CommandCategory;
// we use https://github.com/jamiebuilds/tinykeys so that we can use the same keybinding definition
// for both mac and windows
// todo: render keybinding in command palette
keyBinding?: KeybindingOptions | string;
run: () => void | Promise<void>;
}
export interface AffineCommand {
readonly id: string;
readonly preconditionStrategy: PreconditionStrategy | (() => boolean);
readonly label?: ReactNode | string;
readonly icon?: React.ReactNode; // icon name
readonly category: CommandCategory;
readonly keyBinding?: KeybindingOptions;
run(): void | Promise<void>;
}
export function createAffineCommand(
options: AffineCommandOptions
): AffineCommand {
return {
id: options.id,
run: options.run,
icon: options.icon,
preconditionStrategy:
options.preconditionStrategy ?? PreconditionStrategy.Always,
category: options.category ?? 'affine:general',
get label() {
const label = options.label;
return typeof label === 'function' ? label?.() : label;
},
keyBinding:
typeof options.keyBinding === 'string'
? { binding: options.keyBinding }
: options.keyBinding,
};
}

View File

@ -0,0 +1,2 @@
export * from './command';
export * from './registry';

View File

@ -0,0 +1,64 @@
// @ts-expect-error upstream type is wrong
import { tinykeys } from 'tinykeys';
import {
type AffineCommand,
type AffineCommandOptions,
createAffineCommand,
} from './command';
export const AffineCommandRegistry = new (class {
readonly commands: Map<string, AffineCommand> = new Map();
register(options: AffineCommandOptions) {
if (this.commands.has(options.id)) {
console.warn(`Command ${options.id} already registered.`);
return () => {};
}
const command = createAffineCommand(options);
this.commands.set(command.id, command);
let unsubKb: (() => void) | undefined;
if (
command.keyBinding &&
!command.keyBinding.skipRegister &&
typeof window !== 'undefined'
) {
const { binding: keybinding } = command.keyBinding;
unsubKb = tinykeys(window, {
[keybinding]: async (e: Event) => {
e.preventDefault();
try {
await command.run();
} catch (e) {
console.error(`Failed to invoke keybinding [${keybinding}]`, e);
}
},
});
}
console.debug(`Registered command ${command.id}`);
return () => {
unsubKb?.();
this.commands.delete(command.id);
console.debug(`Unregistered command ${command.id}`);
};
}
get(id: string): AffineCommand | undefined {
if (!this.commands.has(id)) {
console.warn(`Command ${id} not registered.`);
return undefined;
}
return this.commands.get(id);
}
getAll(): AffineCommand[] {
return Array.from(this.commands.values());
}
})();
export function registerAffineCommand(options: AffineCommandOptions) {
return AffineCommandRegistry.register(options);
}

View File

@ -14,6 +14,7 @@ export default defineConfig({
blocksuite: resolve(root, 'src/blocksuite/index.ts'),
index: resolve(root, 'src/index.ts'),
atom: resolve(root, 'src/atom.ts'),
command: resolve(root, 'src/command/index.ts'),
type: resolve(root, 'src/type.ts'),
'core/event-emitter': resolve(root, 'src/core/event-emitter.ts'),
'preload/electron': resolve(root, 'src/preload/electron.ts'),

View File

@ -13,6 +13,20 @@ const openQuickSearchByShortcut = async (page: Page) => {
await page.waitForTimeout(500);
};
const keyboardDownAndSelect = async (page: Page, label: string) => {
await page.keyboard.press('ArrowDown');
if (
(await page
.locator('[cmdk-item][data-selected] [data-testid="cmdk-label"]')
.innerText()) !== label
) {
await keyboardDownAndSelect(page, label);
} else {
await page.pause();
await page.keyboard.press('Enter');
}
};
async function assertTitle(page: Page, text: string) {
const edgeless = page.locator('affine-edgeless-page');
if (!edgeless) {
@ -23,7 +37,9 @@ async function assertTitle(page: Page, text: string) {
}
async function assertResultList(page: Page, texts: string[]) {
const actual = await page.locator('[cmdk-item]').allInnerTexts();
const actual = await page
.locator('[cmdk-item] [data-testid=cmdk-label]')
.allInnerTexts();
expect(actual).toEqual(texts);
}
@ -44,29 +60,21 @@ test('Click slider bar button', async ({ page }) => {
'[data-testid=slider-bar-quick-search-button]'
);
await quickSearchButton.click();
const quickSearch = page.locator('[data-testid=quickSearch]');
const quickSearch = page.locator('[data-testid=cmdk-quick-search]');
await expect(quickSearch).toBeVisible();
});
test('Click arrowDown icon after title', async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await clickNewPageButton(page);
const quickSearchButton = page.locator(
'[data-testid=slider-bar-quick-search-button]'
);
await quickSearchButton.click();
const quickSearch = page.locator('[data-testid=quickSearch]');
await expect(quickSearch).toBeVisible();
});
test('Press the shortcut key cmd+k', async ({ page }) => {
test('Press the shortcut key cmd+k and close with esc', async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await clickNewPageButton(page);
await openQuickSearchByShortcut(page);
const quickSearch = page.locator('[data-testid=quickSearch]');
const quickSearch = page.locator('[data-testid=cmdk-quick-search]');
await expect(quickSearch).toBeVisible();
// press esc to close quick search
await page.keyboard.press('Escape');
await expect(quickSearch).toBeVisible({ visible: false });
});
test('Create a new page without keyword', async ({ page }) => {
@ -74,7 +82,7 @@ test('Create a new page without keyword', async ({ page }) => {
await waitForEditorLoad(page);
await clickNewPageButton(page);
await openQuickSearchByShortcut(page);
const addNewPage = page.locator('[data-testid=quick-search-add-new-page]');
const addNewPage = page.locator('[cmdk-item] >> text=New Page');
await addNewPage.click();
await page.waitForTimeout(300);
await assertTitle(page, '');
@ -86,7 +94,9 @@ test('Create a new page with keyword', async ({ page }) => {
await clickNewPageButton(page);
await openQuickSearchByShortcut(page);
await page.keyboard.insertText('test123456');
const addNewPage = page.locator('[data-testid=quick-search-add-new-page]');
const addNewPage = page.locator(
'[cmdk-item] >> text=Create New Page as: test123456'
);
await addNewPage.click();
await page.waitForTimeout(300);
await assertTitle(page, 'test123456');
@ -110,7 +120,9 @@ test('Create a new page and search this page', async ({ page }) => {
// input title and create new page
await page.keyboard.insertText('test123456');
await page.waitForTimeout(300);
const addNewPage = page.locator('[data-testid=quick-search-add-new-page]');
const addNewPage = page.locator(
'[cmdk-item] >> text=Create New Page as: test123456'
);
await addNewPage.click();
await page.waitForTimeout(300);
@ -137,10 +149,10 @@ test('Navigate to the 404 page and try to open quick search', async ({
page,
}) => {
await page.goto('http://localhost:8080/404');
const notFoundTip = page.getByTestId('not-found');
const notFoundTip = page.locator('button >> text=Back to My Content');
await expect(notFoundTip).toBeVisible();
await openQuickSearchByShortcut(page);
const quickSearch = page.locator('[data-testid=quickSearch]');
const quickSearch = page.locator('[data-testid=cmdk-quick-search]');
await expect(quickSearch).toBeVisible({ visible: false });
});
@ -177,18 +189,18 @@ test('Focus title after creating a new page', async ({ page }) => {
await waitForEditorLoad(page);
await clickNewPageButton(page);
await openQuickSearchByShortcut(page);
const addNewPage = page.locator('[data-testid=quick-search-add-new-page]');
const addNewPage = page.locator('[cmdk-item] >> text=New Page');
await addNewPage.click();
await titleIsFocused(page);
});
test('Not show navigation path if page is not a subpage or current page is not in editor', async ({
page,
}) => {
test('can use keyboard down to select goto setting', async ({ page }) => {
await openHomePage(page);
await waitForEditorLoad(page);
await openQuickSearchByShortcut(page);
expect(await page.getByTestId('navigation-path').count()).toBe(0);
await keyboardDownAndSelect(page, 'Go to Settings');
await expect(page.getByTestId('setting-modal')).toBeVisible();
});
test('assert the recent browse pages are on the recent list', async ({
@ -209,7 +221,7 @@ test('assert the recent browse pages are on the recent list', async ({
// create second page
await openQuickSearchByShortcut(page);
const addNewPage = page.getByTestId('quick-search-add-new-page');
const addNewPage = page.locator('[cmdk-item] >> text=New Page');
await addNewPage.click();
{
const title = getBlockSuiteEditorTitle(page);
@ -234,7 +246,9 @@ test('assert the recent browse pages are on the recent list', async ({
await page.waitForTimeout(200);
{
// check does all 3 pages exists on recent page list
const quickSearchItems = page.locator('[cmdk-item]');
const quickSearchItems = page.locator(
'[cmdk-item] [data-testid="cmdk-label"]'
);
expect(await quickSearchItems.nth(0).textContent()).toBe('battlekot');
expect(await quickSearchItems.nth(1).textContent()).toBe('theliquidhorse');
expect(await quickSearchItems.nth(2).textContent()).toBe('sgtokidoki');
@ -245,7 +259,7 @@ test('assert the recent browse pages are on the recent list', async ({
await waitForEditorLoad(page);
await openQuickSearchByShortcut(page);
{
const addNewPage = page.getByTestId('quick-search-add-new-page');
const addNewPage = page.locator('[cmdk-item] >> text=New Page');
await addNewPage.click();
}
await page.waitForTimeout(200);
@ -258,7 +272,9 @@ test('assert the recent browse pages are on the recent list', async ({
await page.waitForTimeout(1000);
await openQuickSearchByShortcut(page);
{
const quickSearchItems = page.locator('[cmdk-item]');
const quickSearchItems = page.locator(
'[cmdk-item] [data-testid="cmdk-label"]'
);
expect(await quickSearchItems.nth(0).textContent()).toBe(
'affine is the best'
);

View File

@ -159,6 +159,9 @@
{
"path": "./packages/y-indexeddb"
},
{
"path": "./packages/cmdk"
},
// Tests
{
"path": "./tests/kit"

201
yarn.lock
View File

@ -184,6 +184,15 @@ __metadata:
languageName: unknown
linkType: soft
"@affine/cmdk@workspace:packages/cmdk":
version: 0.0.0-use.local
resolution: "@affine/cmdk@workspace:packages/cmdk"
dependencies:
react: 18.2.0
react-dom: 18.2.0
languageName: unknown
linkType: soft
"@affine/component@workspace:*, @affine/component@workspace:packages/component":
version: 0.0.0-use.local
resolution: "@affine/component@workspace:packages/component"
@ -310,7 +319,6 @@ __metadata:
"@types/lodash-es": ^4.17.9
"@types/webpack-env": ^1.18.1
async-call-rpc: ^6.3.1
cmdk: ^0.2.0
copy-webpack-plugin: ^11.0.0
css-loader: ^6.8.1
css-spring: ^4.1.0
@ -428,6 +436,7 @@ __metadata:
lodash-es: ^4.17.21
nanoid: ^4.0.2
rxjs: ^7.8.1
tinykeys: ^2.1.0
ts-node: ^10.9.1
undici: ^5.23.0
uuid: ^9.0.0
@ -9101,32 +9110,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-dialog@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-dialog@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/primitive": 1.0.0
"@radix-ui/react-compose-refs": 1.0.0
"@radix-ui/react-context": 1.0.0
"@radix-ui/react-dismissable-layer": 1.0.0
"@radix-ui/react-focus-guards": 1.0.0
"@radix-ui/react-focus-scope": 1.0.0
"@radix-ui/react-id": 1.0.0
"@radix-ui/react-portal": 1.0.0
"@radix-ui/react-presence": 1.0.0
"@radix-ui/react-primitive": 1.0.0
"@radix-ui/react-slot": 1.0.0
"@radix-ui/react-use-controllable-state": 1.0.0
aria-hidden: ^1.1.1
react-remove-scroll: 2.5.4
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: 32a1ab36a483ceae40b804f116611577b976e1781f33c8d74093f4132fd230b325eadd0306efd77a628d604b61c3a592d4c720a5b7e69e74dbfb56ce85b2c6fd
languageName: node
linkType: hard
"@radix-ui/react-dialog@npm:^1.0.4":
version: 1.0.4
resolution: "@radix-ui/react-dialog@npm:1.0.4"
@ -9186,23 +9169,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-dismissable-layer@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-dismissable-layer@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/primitive": 1.0.0
"@radix-ui/react-compose-refs": 1.0.0
"@radix-ui/react-primitive": 1.0.0
"@radix-ui/react-use-callback-ref": 1.0.0
"@radix-ui/react-use-escape-keydown": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: c5af6445ea3f584bad1fb3ed01703c2d4a889d9b99b23bc2c821a2c166fdbb75cfdd1e4870d3f958d9ac78f5e1b8006f762317cba765839592e5c5af1183a7a7
languageName: node
linkType: hard
"@radix-ui/react-dismissable-layer@npm:1.0.4":
version: 1.0.4
resolution: "@radix-ui/react-dismissable-layer@npm:1.0.4"
@ -9253,17 +9219,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-focus-guards@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-focus-guards@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
checksum: 8c714e8caa6032f5402eecb0323addd7456d3496946dbad1b9ee8ebf5845943876945e7af9bca179e9f8ffe5100e61cb4ba54a185873949125c310c406be5aa4
languageName: node
linkType: hard
"@radix-ui/react-focus-guards@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-focus-guards@npm:1.0.1"
@ -9279,21 +9234,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-focus-scope@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-focus-scope@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-compose-refs": 1.0.0
"@radix-ui/react-primitive": 1.0.0
"@radix-ui/react-use-callback-ref": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: 2ee0b9a2d1905aba25d0225c203d20745fd798aa61d65f55c6041169701d47d6b801d4392a57d94968f0d8ef3410eb79fcab19c9c80f03e9b9f6b24f6f997f98
languageName: node
linkType: hard
"@radix-ui/react-focus-scope@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-focus-scope@npm:1.0.3"
@ -9316,18 +9256,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-id@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-id@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-use-layout-effect": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
checksum: ba323cedd6a6df6f6e51ed1f7f7747988ce432b47fd94d860f962b14b342dcf049eae33f8ad0b72fd7df6329a7375542921132271fba64ab0a271c93f09c48d1
languageName: node
linkType: hard
"@radix-ui/react-id@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-id@npm:1.0.1"
@ -9444,19 +9372,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-portal@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-portal@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-primitive": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: dfb194b2df32830db2daf01569176c6e4cf3af2c6e393ece60532543902acf13a6629f9a45003902c99df195c2249bc56d4f4425a5fed2897555df9bdf01efa0
languageName: node
linkType: hard
"@radix-ui/react-portal@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-portal@npm:1.0.3"
@ -9512,19 +9427,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-primitive@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-primitive@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-slot": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
checksum: fb3fe8c8c5a57995716cce4d7e9039e474c09ba5d714994419ad4940bc954da670f1188813cc931f189b23d9bd5a67adf7087bf44fe1d4272b4a334a3514d38b
languageName: node
linkType: hard
"@radix-ui/react-primitive@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-primitive@npm:1.0.1"
@ -9724,18 +9626,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-slot@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-slot@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-compose-refs": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
checksum: 60c0190ebdca21785b4f8b58a0c52717600c98953fc49da9580870519c60f52d5cf873dffa05446f4bb539066326ccec0827f4ca252b02ec4ff1a4ae203f59d7
languageName: node
linkType: hard
"@radix-ui/react-slot@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-slot@npm:1.0.1"
@ -9926,18 +9816,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-controllable-state@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-use-controllable-state@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-use-callback-ref": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
checksum: 35f1e714bbe3fc9f5362a133339dd890fb96edb79b63168a99403c65dd5f2b63910e0c690255838029086719e31360fa92544a55bc902cfed4442bb3b55822e2
languageName: node
linkType: hard
"@radix-ui/react-use-controllable-state@npm:1.0.1":
version: 1.0.1
resolution: "@radix-ui/react-use-controllable-state@npm:1.0.1"
@ -9954,18 +9832,6 @@ __metadata:
languageName: node
linkType: hard
"@radix-ui/react-use-escape-keydown@npm:1.0.0":
version: 1.0.0
resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.0"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/react-use-callback-ref": 1.0.0
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0
checksum: a6728d40e059fdf2da0703cde9afb10defbcd951d6e1dc48522f33f9399f5aa0514751d9e25847bdcc57328b9d745a3baa36baf9f6af6453a5c894dfcbd40352
languageName: node
linkType: hard
"@radix-ui/react-use-escape-keydown@npm:1.0.3":
version: 1.0.3
resolution: "@radix-ui/react-use-escape-keydown@npm:1.0.3"
@ -12608,6 +12474,7 @@ __metadata:
electron: "link:../../apps/electron/node_modules/electron"
jotai: ^2.4.1
react: ^18.2.0
tinykeys: ^2.1.0
vite: ^4.4.9
vite-plugin-dts: 3.5.3
yjs: ^13.6.8
@ -17113,19 +16980,6 @@ __metadata:
languageName: node
linkType: hard
"cmdk@npm:^0.2.0":
version: 0.2.0
resolution: "cmdk@npm:0.2.0"
dependencies:
"@radix-ui/react-dialog": 1.0.0
command-score: 0.1.2
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
checksum: b81add6daebb192e0ab79bd776aa22c75a9aa5c8ca3c40d6c99cbe3ab4f276bf580eb49d1e568126b6af1e72c0012b53ec260c241a594baf782a80b866a0d17e
languageName: node
linkType: hard
"co@npm:^4.6.0":
version: 4.6.0
resolution: "co@npm:4.6.0"
@ -17247,13 +17101,6 @@ __metadata:
languageName: node
linkType: hard
"command-score@npm:0.1.2":
version: 0.1.2
resolution: "command-score@npm:0.1.2"
checksum: b733fd552d7e569070da3d474b1ed5f54785fdf3dd61670002e0a00b2eff1a547c2b6d3af3683c012f4f39c6455f9e7ee5e9997a79c08048ec37ec2195d3df08
languageName: node
linkType: hard
"commander@npm:11.0.0":
version: 11.0.0
resolution: "commander@npm:11.0.0"
@ -29806,25 +29653,6 @@ __metadata:
languageName: node
linkType: hard
"react-remove-scroll@npm:2.5.4":
version: 2.5.4
resolution: "react-remove-scroll@npm:2.5.4"
dependencies:
react-remove-scroll-bar: ^2.3.3
react-style-singleton: ^2.2.1
tslib: ^2.1.0
use-callback-ref: ^1.3.0
use-sidecar: ^1.1.2
peerDependencies:
"@types/react": ^16.8.0 || ^17.0.0 || ^18.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0
peerDependenciesMeta:
"@types/react":
optional: true
checksum: 01b0f65542a4c8803ee748b4e6cf2adad66d034e15fb72e8455773b0d7b178ec806b3194d74f412db7064670c45552cc666c04e9fb3b5d466dce5fb48e634825
languageName: node
linkType: hard
"react-remove-scroll@npm:2.5.5":
version: 2.5.5
resolution: "react-remove-scroll@npm:2.5.5"
@ -32829,6 +32657,13 @@ __metadata:
languageName: node
linkType: hard
"tinykeys@npm:^2.1.0":
version: 2.1.0
resolution: "tinykeys@npm:2.1.0"
checksum: 49e8f7cf09c29372758f434d072429ed5a93cd3148d218af9938ccbbac4e15a2697c93a34dcfe7138a11e569a2145b35709947e5d2b74e48a8e17c575b8beb7f
languageName: node
linkType: hard
"tinypool@npm:^0.6.0":
version: 0.6.0
resolution: "tinypool@npm:0.6.0"