diff --git a/packages/frontend/core/src/modules/at-menu-config/index.ts b/packages/frontend/core/src/modules/at-menu-config/index.ts index 50bf34e19..d6500afa1 100644 --- a/packages/frontend/core/src/modules/at-menu-config/index.ts +++ b/packages/frontend/core/src/modules/at-menu-config/index.ts @@ -1,4 +1,5 @@ import { + DocsService, type Framework, WorkspaceScope, WorkspaceService, @@ -6,6 +7,7 @@ import { import { WorkspaceDialogService } from '../dialogs'; import { DocDisplayMetaService } from '../doc-display-meta'; +import { EditorSettingService } from '../editor-setting'; import { JournalService } from '../journal'; import { RecentDocsService } from '../quicksearch'; import { AtMenuConfigService } from './services'; @@ -19,6 +21,7 @@ export function configAtMenuConfigModule(framework: Framework) { DocDisplayMetaService, WorkspaceDialogService, RecentDocsService, - WorkspaceDialogService, + EditorSettingService, + DocsService, ]); } diff --git a/packages/frontend/core/src/modules/at-menu-config/services/index.ts b/packages/frontend/core/src/modules/at-menu-config/services/index.ts index fdc749cea..4b9574fad 100644 --- a/packages/frontend/core/src/modules/at-menu-config/services/index.ts +++ b/packages/frontend/core/src/modules/at-menu-config/services/index.ts @@ -1,25 +1,32 @@ +import { fuzzyMatch } from '@affine/core/utils/fuzzy-match'; import { I18n, i18nTime } from '@affine/i18n'; import track from '@affine/track'; import { type AffineInlineEditor, + type DocMode, type LinkedMenuGroup, type LinkedMenuItem, type LinkedWidgetConfig, LinkedWidgetUtils, } from '@blocksuite/affine/blocks'; +import { Text } from '@blocksuite/affine/store'; import type { EditorHost } from '@blocksuite/block-std'; -import { DateTimeIcon } from '@blocksuite/icons/lit'; +import { + DateTimeIcon, + NewXxxEdgelessIcon, + NewXxxPageIcon, +} from '@blocksuite/icons/lit'; import type { DocMeta } from '@blocksuite/store'; import { signal } from '@preact/signals-core'; -import type { WorkspaceService } from '@toeverything/infra'; +import type { DocsService, WorkspaceService } from '@toeverything/infra'; import { Service } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; -import dayjs from 'dayjs'; import { html } from 'lit'; import type { WorkspaceDialogService } from '../../dialogs'; import type { DocDisplayMetaService } from '../../doc-display-meta'; -import { JOURNAL_DATE_FORMAT, type JournalService } from '../../journal'; +import type { EditorSettingService } from '../../editor-setting'; +import { type JournalService, suggestJournalDate } from '../../journal'; import type { RecentDocsService } from '../../quicksearch'; const MAX_DOCS = 3; @@ -31,7 +38,8 @@ export class AtMenuConfigService extends Service { private readonly docDisplayMetaService: DocDisplayMetaService, private readonly dialogService: WorkspaceDialogService, private readonly recentDocsService: RecentDocsService, - private readonly workspaceDialogService: WorkspaceDialogService + private readonly editorSettingService: EditorSettingService, + private readonly docsService: DocsService ) { super(); } @@ -64,26 +72,17 @@ export class AtMenuConfigService extends Service { !!this.journalService.journalDate$(d.id).value; const docItems = signal([]); + const showRecent = query.trim().length === 0; + // recent docs should be at the top - const recentDocs = this.recentDocsService.getRecentDocs(); - - const sortedRawMetas = - query.trim().length === 0 - ? rawMetas.toSorted((a, b) => { - const indexA = recentDocs.findIndex(doc => doc.id === a.id); - const indexB = recentDocs.findIndex(doc => doc.id === b.id); - - if (indexA > -1 && indexB < 0) { - return -1; - } else if (indexA < 0 && indexB > -1) { - return 1; - } else if (indexA > -1 && indexB > -1) { - return indexA - indexB; - } - - return Number.MAX_SAFE_INTEGER; + const docMetas = showRecent + ? this.recentDocsService + .getRecentDocs() + .map(record => { + return rawMetas.find(meta => meta.id === record.id); }) - : rawMetas; + .filter((m): m is DocMeta => !!m) + : rawMetas; const docDisplayMetaService = this.docDisplayMetaService; @@ -126,7 +125,7 @@ export class AtMenuConfigService extends Service { }; (async () => { - for (const [index, meta] of sortedRawMetas.entries()) { + for (const [index, meta] of docMetas.entries()) { if (abortSignal.aborted) { return; } @@ -144,7 +143,11 @@ export class AtMenuConfigService extends Service { })().catch(console.error); return { - name: I18n.t('com.affine.editor.at-menu.link-to-doc'), + name: showRecent + ? I18n.t('com.affine.editor.at-menu.recent-docs') + : I18n.t('com.affine.editor.at-menu.link-to-doc', { + query, + }), items: docItems, maxDisplay: MAX_DOCS, get overflowText() { @@ -182,13 +185,50 @@ export class AtMenuConfigService extends Service { return originalNewDocMenuGroup; } - const customNewDocItem: LinkedMenuItem = { - ...newDocItem, - name: I18n.t('com.affine.editor.at-menu.create-doc', { - name: query || I18n.t('Untitled'), - }), + const createPage = (mode: DocMode) => { + const page = this.docsService.createDoc({ + docProps: { + note: this.editorSettingService.editorSetting.get('affine:note'), + page: { title: new Text(query) }, + }, + primaryMode: mode, + }); + + return page; }; + const customNewDocItems: LinkedMenuItem[] = [ + { + key: 'create-page', + icon: NewXxxPageIcon(), + name: I18n.t('com.affine.editor.at-menu.create-page', { + name: query || I18n.t('Untitled'), + }), + action: () => { + close(); + const page = createPage('page'); + this.insertDoc(inlineEditor, page.id); + track.doc.editor.atMenu.createDoc({ + mode: 'page', + }); + }, + }, + { + key: 'create-edgeless', + icon: NewXxxEdgelessIcon(), + name: I18n.t('com.affine.editor.at-menu.create-edgeless', { + name: query || I18n.t('Untitled'), + }), + action: () => { + close(); + const page = createPage('edgeless'); + this.insertDoc(inlineEditor, page.id); + track.doc.editor.atMenu.createDoc({ + mode: 'edgeless', + }); + }, + }, + ]; const customImportItem: LinkedMenuItem = { ...importItem, name: I18n.t('com.affine.editor.at-menu.import'), @@ -203,10 +243,7 @@ export class AtMenuConfigService extends Service { // If the imported file is a workspace file, insert the entry page node. const { docIds, entryId, isWorkspaceFile } = payload; if (isWorkspaceFile && entryId) { - LinkedWidgetUtils.insertLinkedNode({ - inlineEditor, - docId: entryId, - }); + this.insertDoc(inlineEditor, entryId); return; } @@ -221,7 +258,7 @@ export class AtMenuConfigService extends Service { return { ...originalNewDocMenuGroup, name: I18n.t('com.affine.editor.at-menu.new-doc'), - items: [customNewDocItem, customImportItem], + items: [...customNewDocItems, customImportItem], }; } @@ -252,12 +289,12 @@ export class AtMenuConfigService extends Service { const { x, y, width, height } = getRect(); - const id = this.workspaceDialogService.open('date-selector', { + const id = this.dialogService.open('date-selector', { position: [x, y, width, height || 20], onSelect: date => { if (date) { onSelectDate(date); - this.workspaceDialogService.close(id); + this.dialogService.close(id); } }, }); @@ -326,163 +363,3 @@ export class AtMenuConfigService extends Service { }; } } - -/** - * Checks if the name is a fuzzy match of the query. - * - * @example - * ```ts - * const name = 'John Smith'; - * const query = 'js'; - * const isMatch = fuzzyMatch(name, query); - * // isMatch: true - * ``` - * - * if initialMatch = true, the first char must match as well - */ -function fuzzyMatch(name: string, query: string, matchInitial?: boolean) { - const pureName = name - .trim() - .toLowerCase() - .split('') - .filter(char => char !== ' ') - .join(''); - - const regex = new RegExp( - query - .split('') - .filter(char => char !== ' ') - .map(item => `${escapeRegExp(item)}.*`) - .join(''), - 'i' - ); - - if (matchInitial && query.length > 0 && !pureName.startsWith(query[0])) { - return false; - } - - return regex.test(pureName); -} - -function escapeRegExp(input: string) { - // escape regex characters in the input string to prevent regex format errors - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -// todo: infer locale from user's locale? -const monthNames = Array.from({ length: 12 }, (_, index) => - new Intl.DateTimeFormat('en-US', { month: 'long' }).format( - new Date(2024, index) - ) -); - -// todo: infer locale from user's locale? -const weekDayNames = Array.from({ length: 7 }, (_, index) => - new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format( - new Date(2024, 0, index) - ) -); - -export function suggestJournalDate(query: string): { - dateString: string; - alias?: string; -} | null { - // given a query string, suggest a journal date - // if the query is empty or, starts with "t" AND matches today - // -> suggest today's date - // if the query starts with "y" AND matches "yesterday" - // -> suggest yesterday's date - // if the query starts with "l" AND matches last - // -> suggest last week's date - // if the query starts with "n" AND matches "next" - // -> suggest next week's date - // if the query starts with the first letter of a month and matches the month name - // -> if the trailing part matches a number - // -> suggest the date of the month - // -> otherwise, suggest the current day of the month - // otherwise, return null - query = query.trim().toLowerCase().split(' ').join(''); - - if (query === '' || fuzzyMatch('today', query, true)) { - return { - dateString: dayjs().format(JOURNAL_DATE_FORMAT), - alias: I18n.t('com.affine.today'), - }; - } - - if (fuzzyMatch('tomorrow', query, true)) { - return { - dateString: dayjs().add(1, 'day').format(JOURNAL_DATE_FORMAT), - alias: I18n.t('com.affine.tomorrow'), - }; - } - - if (fuzzyMatch('yesterday', query, true)) { - return { - dateString: dayjs().subtract(1, 'day').format(JOURNAL_DATE_FORMAT), - alias: I18n.t('com.affine.yesterday'), - }; - } - - // next week dates, start from monday - const nextWeekDates = Array.from({ length: 7 }, (_, index) => - dayjs() - .add(1, 'week') - .startOf('week') - .add(index, 'day') - .format(JOURNAL_DATE_FORMAT) - ).map(date => ({ - dateString: date, - alias: I18n.t('com.affine.next-week', { - weekday: weekDayNames[dayjs(date).day()], - }), - })); - - const lastWeekDates = Array.from({ length: 7 }, (_, index) => - dayjs() - .subtract(1, 'week') - .startOf('week') - .add(index, 'day') - .format(JOURNAL_DATE_FORMAT) - ).map(date => ({ - dateString: date, - alias: I18n.t('com.affine.last-week', { - weekday: weekDayNames[dayjs(date).day()], - }), - })); - - for (const date of [...nextWeekDates, ...lastWeekDates]) { - const matched = fuzzyMatch(date.alias, query, true); - if (matched) { - return date; - } - } - - // if query is a string that starts with alphabet letters and/or numbers - const regex = new RegExp(`^([a-z]+)(\\d*)$`, 'i'); - const matched = query.match(regex); - - if (matched) { - const [_, letters, numbers] = matched; - - for (const month of monthNames) { - const monthMatched = fuzzyMatch(month, letters, true); - if (monthMatched) { - let day = numbers ? parseInt(numbers) : dayjs().date(); - const invalidDay = day < 1 || day > 31; - if (invalidDay) { - // fallback to today's day - day = dayjs().date(); - } - const year = dayjs().year(); - return { - dateString: dayjs(`${year}-${month}-${day}`).format( - JOURNAL_DATE_FORMAT - ), - }; - } - } - } - - return null; -} diff --git a/packages/frontend/core/src/modules/at-menu-config/services/__tests__/suggest-date.spec.ts b/packages/frontend/core/src/modules/journal/__tests__/suggest-date.spec.ts similarity index 98% rename from packages/frontend/core/src/modules/at-menu-config/services/__tests__/suggest-date.spec.ts rename to packages/frontend/core/src/modules/journal/__tests__/suggest-date.spec.ts index f330d9112..f71b65ee0 100644 --- a/packages/frontend/core/src/modules/at-menu-config/services/__tests__/suggest-date.spec.ts +++ b/packages/frontend/core/src/modules/journal/__tests__/suggest-date.spec.ts @@ -7,7 +7,7 @@ import { I18n } from '@affine/i18n'; import dayjs from 'dayjs'; import { describe, expect, test } from 'vitest'; -import { suggestJournalDate } from '../../services/index'; +import { suggestJournalDate } from '../suggest-journal-date'; describe('suggestJournalDate', () => { test('today', () => { diff --git a/packages/frontend/core/src/modules/journal/index.ts b/packages/frontend/core/src/modules/journal/index.ts index 09ecb4a9e..47838bc69 100644 --- a/packages/frontend/core/src/modules/journal/index.ts +++ b/packages/frontend/core/src/modules/journal/index.ts @@ -17,6 +17,7 @@ export { type MaybeDate, } from './services/journal'; export { JournalDocService } from './services/journal-doc'; +export { suggestJournalDate } from './suggest-journal-date'; export function configureJournalModule(framework: Framework) { framework diff --git a/packages/frontend/core/src/modules/journal/suggest-journal-date.ts b/packages/frontend/core/src/modules/journal/suggest-journal-date.ts new file mode 100644 index 000000000..2b497d59a --- /dev/null +++ b/packages/frontend/core/src/modules/journal/suggest-journal-date.ts @@ -0,0 +1,123 @@ +import { fuzzyMatch } from '@affine/core/utils/fuzzy-match'; +import { I18n } from '@affine/i18n'; +import dayjs from 'dayjs'; + +import { JOURNAL_DATE_FORMAT } from './services/journal'; + +// todo: infer locale from user's locale? +const monthNames = Array.from({ length: 12 }, (_, index) => + new Intl.DateTimeFormat('en-US', { month: 'long' }).format( + new Date(2024, index) + ) +); + +// todo: infer locale from user's locale? +const weekDayNames = Array.from({ length: 7 }, (_, index) => + new Intl.DateTimeFormat('en-US', { weekday: 'long' }).format( + new Date(2024, 0, index) + ) +); + +export function suggestJournalDate(query: string): { + dateString: string; + alias?: string; +} | null { + // given a query string, suggest a journal date + // if the query is empty or, starts with "t" AND matches today + // -> suggest today's date + // if the query starts with "y" AND matches "yesterday" + // -> suggest yesterday's date + // if the query starts with "l" AND matches last + // -> suggest last week's date + // if the query starts with "n" AND matches "next" + // -> suggest next week's date + // if the query starts with the first letter of a month and matches the month name + // -> if the trailing part matches a number + // -> suggest the date of the month + // -> otherwise, suggest the current day of the month + // otherwise, return null + query = query.trim().toLowerCase().split(' ').join(''); + + if (query === '' || fuzzyMatch('today', query, true)) { + return { + dateString: dayjs().format(JOURNAL_DATE_FORMAT), + alias: I18n.t('com.affine.today'), + }; + } + + if (fuzzyMatch('tomorrow', query, true)) { + return { + dateString: dayjs().add(1, 'day').format(JOURNAL_DATE_FORMAT), + alias: I18n.t('com.affine.tomorrow'), + }; + } + + if (fuzzyMatch('yesterday', query, true)) { + return { + dateString: dayjs().subtract(1, 'day').format(JOURNAL_DATE_FORMAT), + alias: I18n.t('com.affine.yesterday'), + }; + } + + // next week dates, start from monday + const nextWeekDates = Array.from({ length: 7 }, (_, index) => + dayjs() + .add(1, 'week') + .startOf('week') + .add(index, 'day') + .format(JOURNAL_DATE_FORMAT) + ).map(date => ({ + dateString: date, + alias: I18n.t('com.affine.next-week', { + weekday: weekDayNames[dayjs(date).day()], + }), + })); + + const lastWeekDates = Array.from({ length: 7 }, (_, index) => + dayjs() + .subtract(1, 'week') + .startOf('week') + .add(index, 'day') + .format(JOURNAL_DATE_FORMAT) + ).map(date => ({ + dateString: date, + alias: I18n.t('com.affine.last-week', { + weekday: weekDayNames[dayjs(date).day()], + }), + })); + + for (const date of [...nextWeekDates, ...lastWeekDates]) { + const matched = fuzzyMatch(date.alias, query, true); + if (matched) { + return date; + } + } + + // if query is a string that starts with alphabet letters and/or numbers + const regex = new RegExp(`^([a-z]+)(\\d*)$`, 'i'); + const matched = query.match(regex); + + if (matched) { + const [_, letters, numbers] = matched; + + for (const month of monthNames) { + const monthMatched = fuzzyMatch(month, letters, true); + if (monthMatched) { + let day = numbers ? parseInt(numbers) : dayjs().date(); + const invalidDay = day < 1 || day > 31; + if (invalidDay) { + // fallback to today's day + day = dayjs().date(); + } + const year = dayjs().year(); + return { + dateString: dayjs(`${year}-${month}-${day}`).format( + JOURNAL_DATE_FORMAT + ), + }; + } + } + } + + return null; +} diff --git a/packages/frontend/core/src/modules/quicksearch/impls/creation.ts b/packages/frontend/core/src/modules/quicksearch/impls/creation.ts index 0eaf2b5e4..74735df3f 100644 --- a/packages/frontend/core/src/modules/quicksearch/impls/creation.ts +++ b/packages/frontend/core/src/modules/quicksearch/impls/creation.ts @@ -1,5 +1,5 @@ import type { DocMode } from '@blocksuite/affine/blocks'; -import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc'; +import { NewXxxEdgelessIcon, NewXxxPageIcon } from '@blocksuite/icons/rc'; import { Entity, LiveData } from '@toeverything/infra'; import type { QuickSearchSession } from '../providers/quick-search-provider'; @@ -34,7 +34,7 @@ export class CreationQuickSearchSession options: { keyWord: query }, }, group, - icon: PageIcon, + icon: NewXxxPageIcon, payload: { mode: 'edgeless', title: query }, }, { @@ -45,7 +45,7 @@ export class CreationQuickSearchSession options: { keyWord: query }, }, group, - icon: EdgelessIcon, + icon: NewXxxEdgelessIcon, payload: { mode: 'edgeless', title: query }, }, ] as QuickSearchItem<'creation', { title: string; mode: DocMode }>[]; diff --git a/packages/frontend/core/src/utils/fuzzy-match.ts b/packages/frontend/core/src/utils/fuzzy-match.ts new file mode 100644 index 000000000..a0a25ce25 --- /dev/null +++ b/packages/frontend/core/src/utils/fuzzy-match.ts @@ -0,0 +1,45 @@ +/** + * Checks if the name is a fuzzy match of the query. + * + * @example + * ```ts + * const name = 'John Smith'; + * const query = 'js'; + * const isMatch = fuzzyMatch(name, query); + * // isMatch: true + * ``` + * + * if initialMatch = true, the first char must match as well + */ +export function fuzzyMatch( + name: string, + query: string, + matchInitial?: boolean +) { + const pureName = name + .trim() + .toLowerCase() + .split('') + .filter(char => char !== ' ') + .join(''); + + const regex = new RegExp( + query + .split('') + .filter(char => char !== ' ') + .map(item => `${escapeRegExp(item)}.*`) + .join(''), + 'i' + ); + + if (matchInitial && query.length > 0 && !pureName.startsWith(query[0])) { + return false; + } + + return regex.test(pureName); +} + +function escapeRegExp(input: string) { + // escape regex characters in the input string to prevent regex format errors + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 30d609acb..e12dc6140 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -193,7 +193,6 @@ export interface CreateCheckoutSessionInput { coupon?: InputMaybe; idempotencyKey?: InputMaybe; plan?: InputMaybe; - quantity?: InputMaybe; recurring?: InputMaybe; successCallbackLink: Scalars['String']['input']; variant?: InputMaybe; @@ -1257,6 +1256,9 @@ export interface WorkspaceType { id: Scalars['ID']['output']; /** is current workspace initialized */ initialized: Scalars['Boolean']['output']; + /** Get user invoice count */ + invoiceCount: Scalars['Int']['output']; + invoices: Array; /** member count of workspace */ memberCount: Scalars['Int']['output']; /** Members of workspace */ @@ -1290,6 +1292,11 @@ export interface WorkspaceTypeHistoriesArgs { take?: InputMaybe; } +export interface WorkspaceTypeInvoicesArgs { + skip?: InputMaybe; + take?: InputMaybe; +} + export interface WorkspaceTypeMembersArgs { skip?: InputMaybe; take?: InputMaybe; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index b00d4bd04..7d90cabc2 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1492,9 +1492,11 @@ "com.affine.attachment.preview.error.subtitle": "file type not supported.", "com.affine.pdf.page.render.error": "Failed to render page.", "com.affine.editor.journal-conflict.title": "Duplicate Entries in Today's Journal", - "com.affine.editor.at-menu.link-to-doc": "Link to Doc", - "com.affine.editor.at-menu.new-doc": "New Doc", - "com.affine.editor.at-menu.create-doc": "Create \"{{name}}\" Doc", + "com.affine.editor.at-menu.link-to-doc": "Search for \"{{query}}\"", + "com.affine.editor.at-menu.recent-docs": "Recent", + "com.affine.editor.at-menu.new-doc": "New", + "com.affine.editor.at-menu.create-page": "New \"{{name}}\" page", + "com.affine.editor.at-menu.create-edgeless": "New \"{{name}}\" edgeless", "com.affine.editor.at-menu.import": "Import", "com.affine.editor.at-menu.more-docs-hint": "{{count}} more docs", "com.affine.editor.at-menu.journal": "Journal", diff --git a/packages/frontend/track/src/events.ts b/packages/frontend/track/src/events.ts index efe6ec90b..20f102b22 100644 --- a/packages/frontend/track/src/events.ts +++ b/packages/frontend/track/src/events.ts @@ -278,7 +278,7 @@ const PageEvents = { doc: { editor: { slashMenu: ['linkDoc', 'createDoc', 'bookmark'], - atMenu: ['linkDoc', 'import'], + atMenu: ['linkDoc', 'import', 'createDoc'], quickSearch: ['createDoc'], formatToolbar: ['bold'], pageRef: ['navigate'], diff --git a/tests/kit/utils/page-logic.ts b/tests/kit/utils/page-logic.ts index dc0b6c802..da0d3857b 100644 --- a/tests/kit/utils/page-logic.ts +++ b/tests/kit/utils/page-logic.ts @@ -70,9 +70,10 @@ export const createLinkedPage = async (page: Page, pageName?: string) => { await expect(linkedPagePopover).toBeVisible(); await type(page, pageName || 'Untitled'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('ArrowUp'); - await page.keyboard.press('Enter', { delay: 50 }); + await linkedPagePopover + .locator(`icon-button`) + .filter({ hasText: `New "${pageName}" page` }) + .click(); }; export async function clickPageMoreActions(page: Page) {