diff --git a/apps/core/package.json b/apps/core/package.json index 54e57a8be5..b0fb01d6d1 100644 --- a/apps/core/package.json +++ b/apps/core/package.json @@ -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", diff --git a/apps/core/src/atoms/__tests__/atom.spec.ts b/apps/core/src/atoms/__tests__/atom.spec.ts index 0c1873fcfd..c2b0cb4767 100644 --- a/apps/core/src/atoms/__tests__/atom.spec.ts +++ b/apps/core/src/atoms/__tests__/atom.spec.ts @@ -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']); }); }); diff --git a/apps/core/src/atoms/index.ts b/apps/core/src/atoms/index.ts index b2f2b86910..8ae902a9e7 100644 --- a/apps/core/src/atoms/index.ts +++ b/apps/core/src/atoms/index.ts @@ -43,10 +43,6 @@ type PageLocalSetting = { mode: PageMode; }; -type PartialPageLocalSettingWithPageId = Partial & { - id: string; -}; - const pageSettingsBaseAtom = atomWithStorage( 'pageSettings', {} as Record @@ -55,22 +51,11 @@ const pageSettingsBaseAtom = atomWithStorage( // readonly atom by design export const pageSettingsAtom = atom(get => get(pageSettingsBaseAtom)); -const recentPageSettingsBaseAtom = atomWithStorage( +export const recentPageIdsBaseAtom = atomWithStorage( 'recentPageSettings', [] ); -export const recentPageSettingsAtom = atom( - 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); }); diff --git a/apps/core/src/commands/affine-creation.tsx b/apps/core/src/commands/affine-creation.tsx new file mode 100644 index 0000000000..870df41ab3 --- /dev/null +++ b/apps/core/src/commands/affine-creation.tsx @@ -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; + store: ReturnType; + pageHelper: ReturnType; +}) { + const unsubs: Array<() => void> = []; + unsubs.push( + registerAffineCommand({ + id: 'affine:new-page', + category: 'affine:creation', + label: t['com.affine.cmdk.affine.new-page'], + icon: , + keyBinding: environment.isDesktop + ? { + binding: '$mod+N', + skipRegister: true, + } + : undefined, + run() { + pageHelper.createPage(); + }, + }) + ); + + unsubs.push( + registerAffineCommand({ + id: 'affine:new-edgeless-page', + category: 'affine:creation', + icon: , + label: t['com.affine.cmdk.affine.new-edgeless-page'], + run() { + pageHelper.createEdgeless(); + }, + }) + ); + + unsubs.push( + registerAffineCommand({ + id: 'affine:new-workspace', + category: 'affine:creation', + icon: , + label: t['com.affine.cmdk.affine.new-workspace'], + run() { + store.set(openCreateWorkspaceModalAtom, 'new'); + }, + }) + ); + + return () => { + unsubs.forEach(unsub => unsub()); + }; +} diff --git a/apps/core/src/commands/affine-layout.tsx b/apps/core/src/commands/affine-layout.tsx new file mode 100644 index 0000000000..df1db49f8e --- /dev/null +++ b/apps/core/src/commands/affine-layout.tsx @@ -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; + store: ReturnType; +}) { + const unsubs: Array<() => void> = []; + unsubs.push( + registerAffineCommand({ + id: 'affine:toggle-left-sidebar', + category: 'affine:layout', + icon: , + 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()); + }; +} diff --git a/apps/core/src/commands/affine-navigation.tsx b/apps/core/src/commands/affine-navigation.tsx new file mode 100644 index 0000000000..0a10fe13fe --- /dev/null +++ b/apps/core/src/commands/affine-navigation.tsx @@ -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; + store: ReturnType; + navigationHelper: ReturnType; + workspace: Workspace; +}) { + const unsubs: Array<() => void> = []; + unsubs.push( + registerAffineCommand({ + id: 'affine:goto-all-pages', + category: 'affine:navigation', + icon: , + 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: , + 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: , + label: () => t['com.affine.cmdk.affine.navigation.goto-trash'](), + run() { + navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.TRASH); + }, + }) + ); + + return () => { + unsubs.forEach(unsub => unsub()); + }; +} diff --git a/apps/core/src/commands/affine-settings.tsx b/apps/core/src/commands/affine-settings.tsx new file mode 100644 index 0000000000..cbce95be81 --- /dev/null +++ b/apps/core/src/commands/affine-settings.tsx @@ -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; + store: ReturnType; + theme: ReturnType; +}) { + const unsubs: Array<() => void> = []; + unsubs.push( + registerAffineCommand({ + id: 'affine:show-quick-search', + preconditionStrategy: PreconditionStrategy.Never, + category: 'affine:general', + keyBinding: { + binding: '$mod+K', + }, + icon: , + run() { + store.set(openQuickSearchModalAtom, true); + }, + }) + ); + + // color schemes + unsubs.push( + registerAffineCommand({ + id: 'affine:change-color-scheme-to-auto', + label: ( + + Change Colour Scheme to colour + + ), + category: 'affine:settings', + icon: , + preconditionStrategy: () => theme.theme !== 'system', + run() { + theme.setTheme('system'); + }, + }) + ); + unsubs.push( + registerAffineCommand({ + id: 'affine:change-color-scheme-to-dark', + label: ( + + Change Colour Scheme to colour + + ), + category: 'affine:settings', + icon: , + preconditionStrategy: () => theme.theme !== 'dark', + run() { + theme.setTheme('dark'); + }, + }) + ); + + unsubs.push( + registerAffineCommand({ + id: 'affine:change-color-scheme-to-light', + label: ( + + Change Colour Scheme to colour + + ), + category: 'affine:settings', + icon: , + preconditionStrategy: () => theme.theme !== 'light', + run() { + theme.setTheme('light'); + }, + }) + ); + + return () => { + unsubs.forEach(unsub => unsub()); + }; +} diff --git a/apps/core/src/commands/index.ts b/apps/core/src/commands/index.ts new file mode 100644 index 0000000000..cb3a1ece40 --- /dev/null +++ b/apps/core/src/commands/index.ts @@ -0,0 +1,3 @@ +export * from './affine-creation'; +export * from './affine-layout'; +export * from './affine-settings'; diff --git a/apps/core/src/components/blocksuite/block-suite-page-list/utils.tsx b/apps/core/src/components/blocksuite/block-suite-page-list/utils.tsx index f3beaa4329..940d57f10a 100644 --- a/apps/core/src/components/blocksuite/block-suite-page-list/utils.tsx +++ b/apps/core/src/components/blocksuite/block-suite-page-list/utils.tsx @@ -3,7 +3,7 @@ import { WorkspaceSubPath } from '@affine/env/workspace'; import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper'; import { initEmptyPage } from '@toeverything/infra/blocksuite'; import { useAtomValue, useSetAtom } from 'jotai'; -import { useCallback } from 'react'; +import { useCallback, useMemo } from 'react'; import { pageSettingsAtom, setPageModeAtom } from '../../../atoms'; import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; @@ -57,10 +57,17 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => { }; showImportModal({ workspace: blockSuiteWorkspace, onSuccess }); }, [blockSuiteWorkspace, openPage, jumpToSubPath]); - return { - createPage: createPageAndOpen, - createEdgeless: createEdgelessAndOpen, - importFile: importFileAndOpen, - isPreferredEdgeless: isPreferredEdgeless, - }; + return useMemo(() => { + return { + createPage: createPageAndOpen, + createEdgeless: createEdgelessAndOpen, + importFile: importFileAndOpen, + isPreferredEdgeless: isPreferredEdgeless, + }; + }, [ + createEdgelessAndOpen, + createPageAndOpen, + importFileAndOpen, + isPreferredEdgeless, + ]); }; diff --git a/apps/core/src/components/pure/cmdk/data.tsx b/apps/core/src/components/pure/cmdk/data.tsx new file mode 100644 index 0000000000..a9004b1b29 --- /dev/null +++ b/apps/core/src/components/pure/cmdk/data.tsx @@ -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>(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>(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, + navigationHelper: ReturnType, + t: ReturnType +): 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' ? : , + 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: ( + + Create New Page as: query + + ), + 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: , + }); + + results.push({ + id: 'affine:pages:create-edgeless', + label: ( + + Create New Edgeless as: query + + ), + 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: , + }); + } + } + 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; + }, [groups, query]); +}; + +function getCommandFilteredCount(commands: CMDKCommand[], query: string) { + return commands.filter(command => { + return command.value && customCommandFilter(command.value, query) > 0; + }).length; +} diff --git a/apps/core/src/components/pure/cmdk/index.tsx b/apps/core/src/components/pure/cmdk/index.tsx new file mode 100644 index 0000000000..fc2b5fe89a --- /dev/null +++ b/apps/core/src/components/pure/cmdk/index.tsx @@ -0,0 +1,2 @@ +export * from './main'; +export * from './modal'; diff --git a/apps/core/src/components/pure/cmdk/main.css.ts b/apps/core/src/components/pure/cmdk/main.css.ts new file mode 100644 index 0000000000..ae29261e35 --- /dev/null +++ b/apps/core/src/components/pure/cmdk/main.css.ts @@ -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)', +}); diff --git a/apps/core/src/components/pure/cmdk/main.tsx b/apps/core/src/components/pure/cmdk/main.tsx new file mode 100644 index 0000000000..35fb5ad70f --- /dev/null +++ b/apps/core/src/components/pure/cmdk/main.tsx @@ -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 = { + [K in keyof T]: T[K] extends () => any ? K : never; +}[keyof T]; + +type i18nKey = NoParametersKeys>; + +const categoryToI18nKey: Record = { + '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 ( + + {commands.map(command => { + return ( + { + command.run(); + setQuery(''); + onOpenChange?.(false); + }} + value={command.value} + > +
{command.icon}
+
+ {command.label} +
+ {command.timestamp ? ( +
+ {formatDate(new Date(command.timestamp))} +
+ ) : null} + {command.keyBinding ? ( + + ) : null} +
+ ); + })} +
+ ); +}; + +const QuickSearchCommands = ({ + onOpenChange, +}: { + onOpenChange?: (open: boolean) => void; +}) => { + const groups = useCMDKCommandGroups(); + + return groups.map(([category, commands]) => { + return ( + + ); + }); +}; + +export const CMDKContainer = ({ + className, + onQueryChange, + query, + children, + ...rest +}: React.PropsWithChildren<{ + className?: string; + query: string; + onQueryChange: (query: string) => void; +}>) => { + const t = useAFFiNEI18N(); + return ( + { + if ( + e.key === 'ArrowDown' || + e.key === 'ArrowUp' || + e.key === 'ArrowLeft' || + e.key === 'ArrowRight' + ) { + e.stopPropagation(); + } + }} + > + {/* todo: add page context here */} + + {children} + + ); +}; + +export const CMDKQuickSearchModal = (props: CMDKModalProps) => { + const [query, setQuery] = useAtom(cmdkQueryAtom); + useEffect(() => { + if (props.open) { + setQuery(''); + } + }, [props.open, setQuery]); + return ( + + + }> + + + + + ); +}; + +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 ( +
+ {fragments.map((fragment, index) => { + return ( +
+ {fragment} +
+ ); + })} +
+ ); +}; diff --git a/apps/core/src/components/pure/cmdk/modal.css.ts b/apps/core/src/components/pure/cmdk/modal.css.ts new file mode 100644 index 0000000000..80ec393723 --- /dev/null +++ b/apps/core/src/components/pure/cmdk/modal.css.ts @@ -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', + }, + }, +}); diff --git a/apps/core/src/components/pure/cmdk/modal.tsx b/apps/core/src/components/pure/cmdk/modal.tsx new file mode 100644 index 0000000000..6b8102814a --- /dev/null +++ b/apps/core/src/components/pure/cmdk/modal.tsx @@ -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) => { + const [animationState, dispatch] = useReducer(reduceAnimationState, 'exited'); + + useEffect(() => { + dispatch(open ? 'open' : 'close'); + const timeout = setTimeout(() => { + dispatch('finish'); + }, 120); + + return () => { + clearTimeout(timeout); + }; + }, [open]); + + return ( + + + +
+ + {children} + +
+
+
+ ); +}; diff --git a/apps/core/src/components/pure/cmdk/types.ts b/apps/core/src/components/pure/cmdk/types.ts new file mode 100644 index 0000000000..330319ed41 --- /dev/null +++ b/apps/core/src/components/pure/cmdk/types.ts @@ -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; +} diff --git a/apps/core/src/components/pure/quick-search-modal/config.ts b/apps/core/src/components/pure/quick-search-modal/config.ts deleted file mode 100644 index 29b8033acb..0000000000 --- a/apps/core/src/components/pure/quick-search-modal/config.ts +++ /dev/null @@ -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) => 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] - ); -}; diff --git a/apps/core/src/components/pure/quick-search-modal/footer.tsx b/apps/core/src/components/pure/quick-search-modal/footer.tsx deleted file mode 100644 index 1764ecd0e0..0000000000 --- a/apps/core/src/components/pure/quick-search-modal/footer.tsx +++ /dev/null @@ -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 ( - { - 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])} - > - - - {query ? ( - {t['New Keyword Page']({ query: normalizedQuery })} - ) : ( - {t['New Page']()} - )} - - - ); -}; diff --git a/apps/core/src/components/pure/quick-search-modal/index.tsx b/apps/core/src/components/pure/quick-search-modal/index.tsx deleted file mode 100644 index 81a2daa571..0000000000 --- a/apps/core/src/components/pure/quick-search-modal/index.tsx +++ /dev/null @@ -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(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 ( - - { - if ( - e.key === 'ArrowDown' || - e.key === 'ArrowUp' || - e.key === 'ArrowLeft' || - e.key === 'ArrowRight' - ) { - e.stopPropagation(); - } - }} - > - - { - 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']()} - /> - - {environment.isBrowser && environment.isMacOs - ? '⌘ + K' - : 'Ctrl + K'} - - - - - - - {t['com.affine.loading']()} - - } - > - - - - {showCreatePage ? ( - <> - - -