From 83c587f8ad8aac0fded2ce21bd9b8f0418b7f657 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Wed, 27 Nov 2024 03:08:11 +0000 Subject: [PATCH] feat(core): at menu journal entries (#8877) (#8935) fix AF-1790 fix AF-1774 added `DateSelectorDialog` for selecting a date through blocksuite; added `AtMenuConfigService` for constructing the at menu config fix AF-1776 fix PD-1942 resubmitted to replace #8877 --- .../specs/custom/widgets/linked.ts | 195 +------ .../core/src/components/hooks/use-journal.ts | 72 +-- .../core/src/desktop/dialogs/index.tsx | 2 + .../src/desktop/dialogs/selectors/date.tsx | 76 +++ .../core/src/modules/at-menu-config/index.ts | 24 + .../services/__tests__/suggest-date.spec.ts | 166 ++++++ .../modules/at-menu-config/services/index.ts | 488 ++++++++++++++++++ .../core/src/modules/dialogs/constant.ts | 4 + .../services/doc-display-meta.ts | 38 +- packages/frontend/core/src/modules/index.ts | 2 + .../core/src/modules/journal/index.ts | 9 +- .../src/modules/journal/services/journal.ts | 51 +- .../i18n/src/i18n-completenesses.json | 2 +- packages/frontend/i18n/src/resources/en.json | 11 +- tests/affine-cloud/e2e/share-page.spec.ts | 2 +- .../e2e/blocksuite/editor.spec.ts | 2 +- tests/affine-local/e2e/links.spec.ts | 87 ++++ tools/cli/src/webpack/config.ts | 6 + 18 files changed, 965 insertions(+), 272 deletions(-) create mode 100644 packages/frontend/core/src/desktop/dialogs/selectors/date.tsx create mode 100644 packages/frontend/core/src/modules/at-menu-config/index.ts create mode 100644 packages/frontend/core/src/modules/at-menu-config/services/__tests__/suggest-date.spec.ts create mode 100644 packages/frontend/core/src/modules/at-menu-config/services/index.ts diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts index 1bb0ce4f1..2aa40c1fb 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/widgets/linked.ts @@ -1,196 +1,9 @@ -import { WorkspaceDialogService } from '@affine/core/modules/dialogs'; -import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta'; -import { JournalService } from '@affine/core/modules/journal'; -import { I18n } from '@affine/i18n'; -import { track } from '@affine/track'; -import type { EditorHost } from '@blocksuite/affine/block-std'; -import type { - AffineInlineEditor, - LinkedWidgetConfig, -} from '@blocksuite/affine/blocks'; -import { LinkedWidgetUtils } from '@blocksuite/affine/blocks'; -import type { DocMeta } from '@blocksuite/affine/store'; -import { type FrameworkProvider, WorkspaceService } from '@toeverything/infra'; +import { AtMenuConfigService } from '@affine/core/modules/at-menu-config/services'; +import type { LinkedWidgetConfig } from '@blocksuite/affine/blocks'; +import { type FrameworkProvider } from '@toeverything/infra'; -function createNewDocMenuGroup( - framework: FrameworkProvider, - query: string, - abort: () => void, - editorHost: EditorHost, - inlineEditor: AffineInlineEditor -) { - const originalNewDocMenuGroup = LinkedWidgetUtils.createNewDocMenuGroup( - query, - abort, - editorHost, - inlineEditor - ); - - const items = Array.isArray(originalNewDocMenuGroup.items) - ? originalNewDocMenuGroup.items - : originalNewDocMenuGroup.items.value; - - // Patch the import item, to use the custom import dialog. - const importItemIndex = items.findIndex(item => item.key === 'import'); - if (importItemIndex === -1) { - return originalNewDocMenuGroup; - } - - const originalImportItem = items[importItemIndex]; - const customImportItem = { - ...originalImportItem, - action: () => { - abort(); - track.doc.editor.atMenu.import(); - framework - .get(WorkspaceDialogService) - .open('import', undefined, payload => { - if (!payload) { - return; - } - - // 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, - }); - return; - } - - // Otherwise, insert all the doc nodes. - for (const docId of docIds) { - LinkedWidgetUtils.insertLinkedNode({ - inlineEditor, - docId, - }); - } - }); - }, - }; - - // only replace the original import item - items.splice(importItemIndex, 1, customImportItem); - return originalNewDocMenuGroup; -} - -// TODO: fix the type export function createLinkedWidgetConfig( framework: FrameworkProvider ): Partial { - return { - getMenus: ( - query: string, - abort: () => void, - editorHost: EditorHost, - inlineEditor: AffineInlineEditor - ) => { - const currentWorkspace = framework.get(WorkspaceService).workspace; - const rawMetas = currentWorkspace.docCollection.meta.docMetas; - const journalService = framework.get(JournalService); - const isJournal = (d: DocMeta) => - !!journalService.journalDate$(d.id).value; - - const docDisplayMetaService = framework.get(DocDisplayMetaService); - const docMetas = rawMetas - .filter(meta => { - if (isJournal(meta) && !meta.updatedDate) { - return false; - } - return !meta.trash; - }) - .map(meta => { - const title = docDisplayMetaService.title$(meta.id, { - reference: true, - }).value; - return { - ...meta, - title: I18n.t(title), - }; - }) - .filter(({ title }) => isFuzzyMatch(title, query)); - - // TODO need i18n if BlockSuite supported - const MAX_DOCS = 6; - return Promise.resolve([ - { - name: 'Link to Doc', - items: docMetas.map(doc => ({ - key: doc.id, - name: doc.title, - icon: docDisplayMetaService - .icon$(doc.id, { - type: 'lit', - reference: true, - }) - .value(), - action: () => { - abort(); - LinkedWidgetUtils.insertLinkedNode({ - inlineEditor, - docId: doc.id, - }); - track.doc.editor.atMenu.linkDoc(); - }, - })), - maxDisplay: MAX_DOCS, - overflowText: `${docMetas.length - MAX_DOCS} more docs`, - }, - createNewDocMenuGroup( - framework, - query, - abort, - editorHost, - inlineEditor - ), - ]); - }, - mobile: { - useScreenHeight: BUILD_CONFIG.isIOS, - scrollContainer: window, - scrollTopOffset: () => { - const header = document.querySelector('header'); - if (!header) return 0; - - const { y, height } = header.getBoundingClientRect(); - return y + height; - }, - }, - }; -} - -/** - * Checks if the name is a fuzzy match of the query. - * - * @example - * ```ts - * const name = 'John Smith'; - * const query = 'js'; - * const isMatch = isFuzzyMatch(name, query); - * // isMatch: true - * ``` - */ -function isFuzzyMatch(name: string, query: string) { - 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' - ); - return regex.test(pureName); -} - -function escapeRegExp(input: string) { - // escape regex characters in the input string to prevent regex format errors - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return framework.get(AtMenuConfigService).getConfig(); } diff --git a/packages/frontend/core/src/components/hooks/use-journal.ts b/packages/frontend/core/src/components/hooks/use-journal.ts index 7cb0f1330..25156d62f 100644 --- a/packages/frontend/core/src/components/hooks/use-journal.ts +++ b/packages/frontend/core/src/components/hooks/use-journal.ts @@ -1,23 +1,17 @@ import { EditorSettingService } from '@affine/core/modules/editor-setting'; -import { JournalService } from '@affine/core/modules/journal'; +import { + JOURNAL_DATE_FORMAT, + JournalService, + type MaybeDate, +} from '@affine/core/modules/journal'; import { i18nTime } from '@affine/i18n'; import { track } from '@affine/track'; -import { Text } from '@blocksuite/affine/store'; -import { - type DocProps, - DocsService, - initDocFromProps, - useService, - useServices, -} from '@toeverything/infra'; +import { DocsService, useService, useServices } from '@toeverything/infra'; import dayjs from 'dayjs'; import { useCallback, useMemo } from 'react'; import { WorkbenchService } from '../../modules/workbench'; -type MaybeDate = Date | string | number; -export const JOURNAL_DATE_FORMAT = 'YYYY-MM-DD'; - function isJournalString(j?: string | false) { return j ? !!j?.match(/^\d{4}-\d{2}-\d{2}$/) : false; } @@ -33,73 +27,27 @@ function toDayjs(j?: string | false) { * @deprecated use `JournalService` directly */ export const useJournalHelper = () => { - const { docsService, editorSettingService, journalService } = useServices({ + const { journalService } = useServices({ DocsService, EditorSettingService, JournalService, }); - /** - * @internal - */ - const _createJournal = useCallback( - (maybeDate: MaybeDate) => { - const day = dayjs(maybeDate); - const title = day.format(JOURNAL_DATE_FORMAT); - const docRecord = docsService.createDoc(); - const { doc, release } = docsService.open(docRecord.id); - docsService.list.setPrimaryMode(docRecord.id, 'page'); - // set created date to match the journal date - docRecord.setMeta({ - createDate: dayjs() - .set('year', day.year()) - .set('month', day.month()) - .set('date', day.date()) - .toDate() - .getTime(), - }); - const docProps: DocProps = { - page: { title: new Text(title) }, - note: editorSettingService.editorSetting.get('affine:note'), - }; - initDocFromProps(doc.blockSuiteDoc, docProps); - release(); - journalService.setJournalDate(docRecord.id, title); - return docRecord; - }, - [docsService, editorSettingService.editorSetting, journalService] - ); - - /** - * query all journals by date - */ - const getJournalsByDate = useCallback( - (maybeDate: MaybeDate) => { - return journalService.getJournalsByDate( - dayjs(maybeDate).format(JOURNAL_DATE_FORMAT) - ); - }, - [journalService] - ); - /** * get journal by date, create one if not exist */ const getJournalByDate = useCallback( (maybeDate: MaybeDate) => { - const pages = getJournalsByDate(maybeDate); - if (pages.length) return pages[0]; - return _createJournal(maybeDate); + return journalService.ensureJournalByDate(maybeDate); }, - [_createJournal, getJournalsByDate] + [journalService] ); return useMemo( () => ({ - getJournalsByDate, getJournalByDate, }), - [getJournalsByDate, getJournalByDate] + [getJournalByDate] ); }; diff --git a/packages/frontend/core/src/desktop/dialogs/index.tsx b/packages/frontend/core/src/desktop/dialogs/index.tsx index c8b827a61..b97ce58e2 100644 --- a/packages/frontend/core/src/desktop/dialogs/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/index.tsx @@ -15,6 +15,7 @@ import { ImportDialog } from './import'; import { ImportTemplateDialog } from './import-template'; import { ImportWorkspaceDialog } from './import-workspace'; import { CollectionSelectorDialog } from './selectors/collection'; +import { DateSelectorDialog } from './selectors/date'; import { DocSelectorDialog } from './selectors/doc'; import { TagSelectorDialog } from './selectors/tag'; import { SettingDialog } from './setting'; @@ -36,6 +37,7 @@ const WORKSPACE_DIALOGS = { 'tag-selector': TagSelectorDialog, 'doc-selector': DocSelectorDialog, 'collection-selector': CollectionSelectorDialog, + 'date-selector': DateSelectorDialog, import: ImportDialog, } satisfies { [key in keyof WORKSPACE_DIALOG_SCHEMA]?: React.FC< diff --git a/packages/frontend/core/src/desktop/dialogs/selectors/date.tsx b/packages/frontend/core/src/desktop/dialogs/selectors/date.tsx new file mode 100644 index 000000000..0761bf7e0 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/selectors/date.tsx @@ -0,0 +1,76 @@ +import { DatePicker, Menu } from '@affine/component'; +import type { DialogComponentProps } from '@affine/core/modules/dialogs'; +import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant'; +import { useI18n } from '@affine/i18n'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { useCallback, useState } from 'react'; + +/** + * A global date selector popover, mainly used in blocksuite editor + */ +export const DateSelectorDialog = ({ + close, + position, + onSelect, +}: DialogComponentProps) => { + const [selectedDate, setSelectedDate] = useState(); + + const t = useI18n(); + + const onClose = useCallback( + (open: boolean) => { + if (!open) { + close(); + } + }, + [close] + ); + + const handleSelect = useCallback( + (date?: string) => { + setSelectedDate(date); + onSelect?.(date); + }, + [onSelect] + ); + + return ( + + } + > + {/* hack the menu positioning using the following fixed anchor */} +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/at-menu-config/index.ts b/packages/frontend/core/src/modules/at-menu-config/index.ts new file mode 100644 index 000000000..50bf34e19 --- /dev/null +++ b/packages/frontend/core/src/modules/at-menu-config/index.ts @@ -0,0 +1,24 @@ +import { + type Framework, + WorkspaceScope, + WorkspaceService, +} from '@toeverything/infra'; + +import { WorkspaceDialogService } from '../dialogs'; +import { DocDisplayMetaService } from '../doc-display-meta'; +import { JournalService } from '../journal'; +import { RecentDocsService } from '../quicksearch'; +import { AtMenuConfigService } from './services'; + +export function configAtMenuConfigModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(AtMenuConfigService, [ + WorkspaceService, + JournalService, + DocDisplayMetaService, + WorkspaceDialogService, + RecentDocsService, + WorkspaceDialogService, + ]); +} diff --git a/packages/frontend/core/src/modules/at-menu-config/services/__tests__/suggest-date.spec.ts b/packages/frontend/core/src/modules/at-menu-config/services/__tests__/suggest-date.spec.ts new file mode 100644 index 000000000..f330d9112 --- /dev/null +++ b/packages/frontend/core/src/modules/at-menu-config/services/__tests__/suggest-date.spec.ts @@ -0,0 +1,166 @@ +/** + * @vitest-environment happy-dom + */ + +import { JOURNAL_DATE_FORMAT } from '@affine/core/modules/journal'; +import { I18n } from '@affine/i18n'; +import dayjs from 'dayjs'; +import { describe, expect, test } from 'vitest'; + +import { suggestJournalDate } from '../../services/index'; + +describe('suggestJournalDate', () => { + test('today', () => { + expect(suggestJournalDate('t')).toEqual({ + dateString: dayjs().format(JOURNAL_DATE_FORMAT), + alias: I18n.t('com.affine.today'), + }); + }); + + test('yesterday', () => { + expect(suggestJournalDate('y')).toEqual({ + dateString: dayjs().subtract(1, 'day').format(JOURNAL_DATE_FORMAT), + alias: I18n.t('com.affine.yesterday'), + }); + }); + + test('tomorrow', () => { + expect(suggestJournalDate('tm')).toEqual({ + dateString: dayjs().add(1, 'day').format(JOURNAL_DATE_FORMAT), + alias: I18n.t('com.affine.tomorrow'), + }); + }); + + test('last week - monday', () => { + expect(suggestJournalDate('lm')).toEqual({ + dateString: dayjs() + .subtract(1, 'week') + .startOf('week') + .add(1, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Last Monday', + }); + }); + + test('last week - tuesday', () => { + expect(suggestJournalDate('ltt')).toEqual({ + dateString: dayjs() + .subtract(1, 'week') + .startOf('week') + .add(2, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Last Tuesday', + }); + }); + + test('last week - wednesday', () => { + expect(suggestJournalDate('lw')).toEqual({ + dateString: dayjs() + .subtract(1, 'week') + .startOf('week') + .add(3, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Last Wednesday', + }); + }); + + test('last week - thursday', () => { + expect(suggestJournalDate('lth')).toEqual({ + dateString: dayjs() + .subtract(1, 'week') + .startOf('week') + .add(4, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Last Thursday', + }); + }); + + test('last week - friday', () => { + expect(suggestJournalDate('lf')).toEqual({ + dateString: dayjs() + .subtract(1, 'week') + .startOf('week') + .add(5, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Last Friday', + }); + }); + + test('next week - monday', () => { + expect(suggestJournalDate('nm')).toEqual({ + dateString: dayjs() + .add(1, 'week') + .startOf('week') + .add(1, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Next Monday', + }); + }); + + test('next week - tuesday', () => { + expect(suggestJournalDate('nxtus')).toEqual({ + dateString: dayjs() + .add(1, 'week') + .startOf('week') + .add(2, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Next Tuesday', + }); + }); + + test('next week - wednesday', () => { + expect(suggestJournalDate('nw')).toEqual({ + dateString: dayjs() + .add(1, 'week') + .startOf('week') + .add(3, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Next Wednesday', + }); + }); + + test('next week - thursday', () => { + expect(suggestJournalDate('nth')).toEqual({ + dateString: dayjs() + .add(1, 'week') + .startOf('week') + .add(4, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Next Thursday', + }); + }); + + test('next week - friday', () => { + expect(suggestJournalDate('nf')).toEqual({ + dateString: dayjs() + .add(1, 'week') + .startOf('week') + .add(5, 'day') + .format(JOURNAL_DATE_FORMAT), + alias: 'Next Friday', + }); + }); + + test('dec', () => { + const year = dayjs().year(); + const date = dayjs().date(); + expect(suggestJournalDate(`dec`)).toEqual({ + dateString: dayjs(`${year}-12-${date}`).format(JOURNAL_DATE_FORMAT), + }); + }); + + test('dec 1', () => { + const year = dayjs().year(); + expect(suggestJournalDate(`dec 10`)).toEqual({ + dateString: dayjs(`${year}-12-10`).format(JOURNAL_DATE_FORMAT), + }); + }); + + test('dec 33', () => { + const year = dayjs().year(); + const date = dayjs().date(); + expect(suggestJournalDate(`dec 33`)).toEqual({ + dateString: dayjs(`${year}-12-${date}`).format(JOURNAL_DATE_FORMAT), + }); + }); +}); 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 new file mode 100644 index 000000000..fdc749cea --- /dev/null +++ b/packages/frontend/core/src/modules/at-menu-config/services/index.ts @@ -0,0 +1,488 @@ +import { I18n, i18nTime } from '@affine/i18n'; +import track from '@affine/track'; +import { + type AffineInlineEditor, + type LinkedMenuGroup, + type LinkedMenuItem, + type LinkedWidgetConfig, + LinkedWidgetUtils, +} from '@blocksuite/affine/blocks'; +import type { EditorHost } from '@blocksuite/block-std'; +import { DateTimeIcon } from '@blocksuite/icons/lit'; +import type { DocMeta } from '@blocksuite/store'; +import { signal } from '@preact/signals-core'; +import type { 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 { RecentDocsService } from '../../quicksearch'; + +const MAX_DOCS = 3; +const LOAD_CHUNK = 100; +export class AtMenuConfigService extends Service { + constructor( + private readonly workspaceService: WorkspaceService, + private readonly journalService: JournalService, + private readonly docDisplayMetaService: DocDisplayMetaService, + private readonly dialogService: WorkspaceDialogService, + private readonly recentDocsService: RecentDocsService, + private readonly workspaceDialogService: WorkspaceDialogService + ) { + super(); + } + + // todo(@peng17): maybe refactor the config using entity, so that each config + // can be reactive to the query, instead of recreating the whole config? + getConfig(): Partial { + return { + getMenus: this.getMenusFn(), + mobile: this.getMobileConfig(), + }; + } + + private insertDoc(inlineEditor: AffineInlineEditor, id: string) { + LinkedWidgetUtils.insertLinkedNode({ + inlineEditor, + docId: id, + }); + } + + private linkToDocGroup( + query: string, + close: () => void, + inlineEditor: AffineInlineEditor, + abortSignal: AbortSignal + ): LinkedMenuGroup { + const currentWorkspace = this.workspaceService.workspace; + const rawMetas = currentWorkspace.docCollection.meta.docMetas; + const isJournal = (d: DocMeta) => + !!this.journalService.journalDate$(d.id).value; + const docItems = signal([]); + + // 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; + }) + : rawMetas; + + const docDisplayMetaService = this.docDisplayMetaService; + + const toDocItem = (meta: DocMeta): LinkedMenuItem | null => { + if (isJournal(meta) && !meta.updatedDate) { + return null; + } + + if (meta.trash) { + return null; + } + + let title = docDisplayMetaService.title$(meta.id, { + reference: true, + }).value; + + if (typeof title === 'object' && 'i18nKey' in title) { + title = I18n.t(title); + } + + if (!fuzzyMatch(title, query)) { + return null; + } + + return { + name: title, + key: meta.id, + icon: docDisplayMetaService + .icon$(meta.id, { + type: 'lit', + reference: true, + }) + .value(), + action: () => { + close(); + track.doc.editor.atMenu.linkDoc(); + this.insertDoc(inlineEditor, meta.id); + }, + }; + }; + + (async () => { + for (const [index, meta] of sortedRawMetas.entries()) { + if (abortSignal.aborted) { + return; + } + + const item = toDocItem(meta); + if (item) { + docItems.value = [...docItems.value, item]; + } + + if (index % LOAD_CHUNK === 0) { + // use scheduler.yield? + await new Promise(resolve => setTimeout(resolve, 0)); + } + } + })().catch(console.error); + + return { + name: I18n.t('com.affine.editor.at-menu.link-to-doc'), + items: docItems, + maxDisplay: MAX_DOCS, + get overflowText() { + const overflowCount = docItems.value.length - MAX_DOCS; + return I18n.t('com.affine.editor.at-menu.more-docs-hint', { + count: overflowCount > 100 ? '100+' : overflowCount, + }); + }, + }; + } + + private newDocMenuGroup( + query: string, + close: () => void, + editorHost: EditorHost, + inlineEditor: AffineInlineEditor + ): LinkedMenuGroup { + const originalNewDocMenuGroup = LinkedWidgetUtils.createNewDocMenuGroup( + query, + close, + editorHost, + inlineEditor + ); + + // Patch the import item, to use the custom import dialog. + const items = Array.isArray(originalNewDocMenuGroup.items) + ? originalNewDocMenuGroup.items + : originalNewDocMenuGroup.items.value; + + const newDocItem = items.find(item => item.key === 'create'); + const importItem = items.find(item => item.key === 'import'); + + // should have both new doc and import item + if (!newDocItem || !importItem) { + return originalNewDocMenuGroup; + } + + const customNewDocItem: LinkedMenuItem = { + ...newDocItem, + name: I18n.t('com.affine.editor.at-menu.create-doc', { + name: query || I18n.t('Untitled'), + }), + }; + + const customImportItem: LinkedMenuItem = { + ...importItem, + name: I18n.t('com.affine.editor.at-menu.import'), + action: () => { + close(); + track.doc.editor.atMenu.import(); + this.dialogService.open('import', undefined, payload => { + if (!payload) { + return; + } + + // 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, + }); + return; + } + + // Otherwise, insert all the doc nodes. + for (const docId of docIds) { + this.insertDoc(inlineEditor, docId); + } + }); + }, + }; + + return { + ...originalNewDocMenuGroup, + name: I18n.t('com.affine.editor.at-menu.new-doc'), + items: [customNewDocItem, customImportItem], + }; + } + + private journalGroup( + query: string, + close: () => void, + inlineEditor: AffineInlineEditor + ): LinkedMenuGroup { + const suggestedDate = suggestJournalDate(query); + + const items: LinkedMenuItem[] = [ + { + icon: DateTimeIcon(), + key: 'date-picker', + name: I18n.t('com.affine.editor.at-menu.date-picker'), + action: () => { + close(); + + const getRect = () => { + let rect = inlineEditor.getNativeRange()?.getBoundingClientRect(); + + if (!rect || rect.width === 0 || rect.height === 0) { + rect = inlineEditor.rootElement.getBoundingClientRect(); + } + + return rect; + }; + + const { x, y, width, height } = getRect(); + + const id = this.workspaceDialogService.open('date-selector', { + position: [x, y, width, height || 20], + onSelect: date => { + if (date) { + onSelectDate(date); + this.workspaceDialogService.close(id); + } + }, + }); + }, + }, + ]; + + const onSelectDate = (date: string) => { + close(); + const doc = this.journalService.ensureJournalByDate(date); + this.insertDoc(inlineEditor, doc.id); + }; + + if (suggestedDate) { + const { dateString, alias } = suggestedDate; + const dateDisplay = i18nTime(dateString, { + absolute: { accuracy: 'day' }, + }); + + const icon = this.docDisplayMetaService.getJournalIcon(dateString, { + type: 'lit', + }); + + items.unshift({ + icon: icon(), + key: dateString, + name: alias + ? html`${alias}, + ${dateDisplay}` + : dateDisplay, + action: () => { + onSelectDate(dateString); + }, + }); + } + + return { + name: I18n.t('com.affine.editor.at-menu.journal'), + items, + }; + } + + private getMenusFn(): LinkedWidgetConfig['getMenus'] { + return (query, close, editorHost, inlineEditor, abortSignal) => { + return [ + this.journalGroup(query, close, inlineEditor), + this.linkToDocGroup(query, close, inlineEditor, abortSignal), + this.newDocMenuGroup(query, close, editorHost, inlineEditor), + ]; + }; + } + + private getMobileConfig(): Partial { + return { + useScreenHeight: BUILD_CONFIG.isIOS, + scrollContainer: window, + scrollTopOffset: () => { + const header = document.querySelector('header'); + if (!header) return 0; + + const { y, height } = header.getBoundingClientRect(); + return y + height; + }, + }; + } +} + +/** + * 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/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts index 9b1b04c95..04fd38a58 100644 --- a/packages/frontend/core/src/modules/dialogs/constant.ts +++ b/packages/frontend/core/src/modules/dialogs/constant.ts @@ -51,6 +51,10 @@ export type WORKSPACE_DIALOG_SCHEMA = { init: string[]; onBeforeConfirm?: (ids: string[], cb: () => void) => void; }) => string[]; + 'date-selector': (props: { + position: [number, number, number, number]; // [x, y, width, height] + onSelect?: (date?: string) => void; + }) => string; import: () => { docIds: string[]; entryId?: string; diff --git a/packages/frontend/core/src/modules/doc-display-meta/services/doc-display-meta.ts b/packages/frontend/core/src/modules/doc-display-meta/services/doc-display-meta.ts index 0f66163fa..5c46e8935 100644 --- a/packages/frontend/core/src/modules/doc-display-meta/services/doc-display-meta.ts +++ b/packages/frontend/core/src/modules/doc-display-meta/services/doc-display-meta.ts @@ -90,10 +90,38 @@ export class DocDisplayMetaService extends Service { super(); } + getJournalIcon( + journalDate: string | Dayjs, + options?: DocDisplayIconOptions<'rc'> + ): typeof TodayIcon; + + getJournalIcon( + journalDate: string | Dayjs, + options?: DocDisplayIconOptions<'lit'> + ): typeof LitYesterdayIcon; + + getJournalIcon( + journalDate: string | Dayjs, + options?: DocDisplayIconOptions + ): T extends 'rc' ? typeof TodayIcon : typeof LitTodayIcon; + + getJournalIcon( + journalDate: string | Dayjs, + options?: DocDisplayIconOptions + ) { + const iconSet = icons[options?.type ?? 'rc']; + const day = dayjs(journalDate); + return day.isBefore(dayjs(), 'day') + ? iconSet.YesterdayIcon + : day.isAfter(dayjs(), 'day') + ? iconSet.TomorrowIcon + : iconSet.TodayIcon; + } + icon$( docId: string, options?: DocDisplayIconOptions - ): LiveData { + ) { const iconSet = icons[options?.type ?? 'rc']; return LiveData.computed(get => { @@ -113,13 +141,7 @@ export class DocDisplayMetaService extends Service { this.journalService.journalDate$(docId).value ); if (journalDate) { - if (!options?.compareDate) return iconSet.TodayIcon; - const compareDate = dayjs(options?.compareDate); - return journalDate.isBefore(compareDate, 'day') - ? iconSet.YesterdayIcon - : journalDate.isAfter(compareDate, 'day') - ? iconSet.TomorrowIcon - : iconSet.TodayIcon; + return this.getJournalIcon(journalDate, options); } // reference icon diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 26b7181de..213cc6a23 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -2,6 +2,7 @@ import { configureQuotaModule } from '@affine/core/modules/quota'; import { configureInfraModules, type Framework } from '@toeverything/infra'; import { configureAppSidebarModule } from './app-sidebar'; +import { configAtMenuConfigModule } from './at-menu-config'; import { configureCloudModule } from './cloud'; import { configureCollectionModule } from './collection'; import { configureDialogModule } from './dialogs'; @@ -67,4 +68,5 @@ export function configureCommonModules(framework: Framework) { configureDialogModule(framework); configureDocInfoModule(framework); configureOpenInApp(framework); + configAtMenuConfigModule(framework); } diff --git a/packages/frontend/core/src/modules/journal/index.ts b/packages/frontend/core/src/modules/journal/index.ts index 8f97409ca..09ecb4a9e 100644 --- a/packages/frontend/core/src/modules/journal/index.ts +++ b/packages/frontend/core/src/modules/journal/index.ts @@ -6,17 +6,22 @@ import { WorkspaceScope, } from '@toeverything/infra'; +import { EditorSettingService } from '../editor-setting'; import { JournalService } from './services/journal'; import { JournalDocService } from './services/journal-doc'; import { JournalStore } from './store/journal'; -export { JournalService } from './services/journal'; +export { + JOURNAL_DATE_FORMAT, + JournalService, + type MaybeDate, +} from './services/journal'; export { JournalDocService } from './services/journal-doc'; export function configureJournalModule(framework: Framework) { framework .scope(WorkspaceScope) - .service(JournalService, [JournalStore]) + .service(JournalService, [JournalStore, DocsService, EditorSettingService]) .store(JournalStore, [DocsService]) .scope(DocScope) .service(JournalDocService, [DocService, JournalService]); diff --git a/packages/frontend/core/src/modules/journal/services/journal.ts b/packages/frontend/core/src/modules/journal/services/journal.ts index d78cf6123..d0620bc90 100644 --- a/packages/frontend/core/src/modules/journal/services/journal.ts +++ b/packages/frontend/core/src/modules/journal/services/journal.ts @@ -1,10 +1,21 @@ -import { LiveData, Service } from '@toeverything/infra'; +import { Text } from '@blocksuite/affine/store'; +import type { DocProps, DocsService } from '@toeverything/infra'; +import { initDocFromProps, LiveData, Service } from '@toeverything/infra'; import dayjs from 'dayjs'; +import type { EditorSettingService } from '../../editor-setting'; import type { JournalStore } from '../store/journal'; +export type MaybeDate = Date | string | number; + +export const JOURNAL_DATE_FORMAT = 'YYYY-MM-DD'; + export class JournalService extends Service { - constructor(private readonly store: JournalStore) { + constructor( + private readonly store: JournalStore, + private readonly docsService: DocsService, + private readonly editorSettingService: EditorSettingService + ) { super(); } @@ -29,10 +40,40 @@ export class JournalService extends Service { this.store.removeDocJournalDate(docId); } - getJournalsByDate(date: string) { - return this.store.getDocsByJournalDate(date); - } journalsByDate$(date: string) { return this.store.docsByJournalDate$(date); } + + private createJournal(maybeDate: MaybeDate) { + const day = dayjs(maybeDate); + const title = day.format(JOURNAL_DATE_FORMAT); + const docRecord = this.docsService.createDoc(); + const { doc, release } = this.docsService.open(docRecord.id); + this.docsService.list.setPrimaryMode(docRecord.id, 'page'); + // set created date to match the journal date + docRecord.setMeta({ + createDate: dayjs() + .set('year', day.year()) + .set('month', day.month()) + .set('date', day.date()) + .toDate() + .getTime(), + }); + const docProps: DocProps = { + page: { title: new Text(title) }, + note: this.editorSettingService.editorSetting.get('affine:note'), + }; + initDocFromProps(doc.blockSuiteDoc, docProps); + release(); + this.setJournalDate(docRecord.id, title); + return docRecord; + } + + ensureJournalByDate(maybeDate: MaybeDate) { + const day = dayjs(maybeDate); + const title = day.format(JOURNAL_DATE_FORMAT); + const docs = this.journalsByDate$(title).value; + if (docs.length) return docs[0]; + return this.createJournal(maybeDate); + } } diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 5ba12f3e9..47ed394c9 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -7,7 +7,7 @@ "en": 100, "es-AR": 14, "es-CL": 15, - "es": 14, + "es": 13, "fr": 67, "hi": 2, "it-IT": 1, diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 93ab898f6..5a59d5051 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1372,6 +1372,8 @@ "com.affine.toastMessage.successfullyDeleted": "Successfully deleted", "com.affine.today": "Today", "com.affine.tomorrow": "Tomorrow", + "com.affine.last-week": "Last {{weekday}}", + "com.affine.next-week": "Next {{weekday}}", "com.affine.top-tip.mobile": "Limited to view-only on mobile.", "com.affine.trashOperation.delete": "Delete", "com.affine.trashOperation.delete.description": "Once deleted, you can't undo this action. Do you confirm?", @@ -1470,5 +1472,12 @@ "com.affine.m.selector.journal-menu.conflicts": "Duplicate Entries in Today's Journal", "com.affine.attachment.preview.error.title": "Unable to preview this file", "com.affine.attachment.preview.error.subtitle": "file type not supported.", - "com.affine.pdf.page.render.error": "Failed to render page." + "com.affine.pdf.page.render.error": "Failed to render page.", + "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.import": "Import", + "com.affine.editor.at-menu.more-docs-hint": "{{count}} more docs", + "com.affine.editor.at-menu.journal": "Journal", + "com.affine.editor.at-menu.date-picker": "Select a specific date" } diff --git a/tests/affine-cloud/e2e/share-page.spec.ts b/tests/affine-cloud/e2e/share-page.spec.ts index d83fd2900..a58b6ddd7 100644 --- a/tests/affine-cloud/e2e/share-page.spec.ts +++ b/tests/affine-cloud/e2e/share-page.spec.ts @@ -290,7 +290,7 @@ test('The reference links in the shared page should be accessible normally and c const linkedPagePopover = page.locator('.linked-doc-popover'); await expect(linkedPagePopover).toBeVisible(); await page.keyboard.type('Test linked doc', { delay: 50 }); - await page.keyboard.press('Enter'); + await page.locator('icon-button:has-text("Test linked doc")').first().click(); // enable share page and copy page link await enableShare(page); diff --git a/tests/affine-local/e2e/blocksuite/editor.spec.ts b/tests/affine-local/e2e/blocksuite/editor.spec.ts index 459f30db1..dc958d8c8 100644 --- a/tests/affine-local/e2e/blocksuite/editor.spec.ts +++ b/tests/affine-local/e2e/blocksuite/editor.spec.ts @@ -54,7 +54,7 @@ test('link page is useable', async ({ page }) => { await page.keyboard.press('g'); await page.keyboard.press('e'); await page.keyboard.press('1'); - await page.keyboard.press('Enter'); + await page.locator('icon-button:has-text("page1")').first().click(); const link = page.locator('.affine-reference'); await expect(link).toBeVisible(); await page.click('.affine-reference'); diff --git a/tests/affine-local/e2e/links.spec.ts b/tests/affine-local/e2e/links.spec.ts index bd1a8c695..d35b30d93 100644 --- a/tests/affine-local/e2e/links.spec.ts +++ b/tests/affine-local/e2e/links.spec.ts @@ -331,3 +331,90 @@ test('allow switching to embed view when linking to the other document with mode url2.searchParams.delete('refreshKey'); expect(url.toJSON()).toStrictEqual(url2.toJSON()); }); + +test('@ popover should show today menu item', async ({ page }) => { + await page.keyboard.press('Enter'); + await waitForEmptyEditor(page); + await page.keyboard.press('@'); + await expect(page.locator('.linked-doc-popover')).toBeVisible(); + const todayMenuItem = page.locator('.linked-doc-popover').getByText('Today'); + await expect(todayMenuItem).toBeVisible(); + + const textContent = await todayMenuItem.locator('span').textContent(); + await todayMenuItem.click(); + const date = textContent?.trim(); + + // a affine-reference should be created with name date + await expect( + page.locator('affine-reference:has-text("' + date + '")') + ).toBeVisible(); +}); + +test('@ popover with input=tmr', async ({ page }) => { + await page.keyboard.press('Enter'); + await waitForEmptyEditor(page); + await page.keyboard.press('@'); + await page.keyboard.type('tmr'); + await expect(page.locator('.linked-doc-popover')).toBeVisible(); + const tomorrowMenuItem = page + .locator('.linked-doc-popover') + .getByText('Tomorrow'); + await expect(tomorrowMenuItem).toBeVisible(); + + const textContent = await tomorrowMenuItem.locator('span').textContent(); + await tomorrowMenuItem.click(); + + // a affine-reference should be created with name date + await expect( + page.locator('affine-reference:has-text("' + textContent + '")') + ).toBeVisible(); +}); + +test('@ popover with input=dec should create a reference with a December date', async ({ + page, +}) => { + await page.keyboard.press('Enter'); + await waitForEmptyEditor(page); + await page.keyboard.press('@'); + await page.keyboard.type('dc'); + + const decemberMenuItem = page.locator( + '.linked-doc-popover icon-button:has-text("Dec")' + ); + await expect(decemberMenuItem).toBeVisible(); + + const textContent = await decemberMenuItem + .locator('.text-container') + .textContent(); + await decemberMenuItem.click(); + + // a affine-reference should be created with name date + await expect( + page.locator('affine-reference:has-text("' + textContent + '")') + ).toBeVisible(); +}); + +test('@ popover with click "select a specific date" should show a date picker', async ({ + page, +}) => { + await page.keyboard.press('Enter'); + await waitForEmptyEditor(page); + await page.keyboard.press('@'); + + const todayMenuItem = page.locator('.linked-doc-popover').getByText('Today'); + await expect(todayMenuItem).toBeVisible(); + + const textContent = await todayMenuItem.locator('span').textContent(); + const date = textContent?.trim(); + + await page.locator('icon-button:has-text("Select a specific date")').click(); + await expect( + page.locator('[data-is-date-cell][data-is-today=true]') + ).toBeVisible(); + await page.locator('[data-is-date-cell][data-is-today=true]').click(); + + // a affine-reference should be created with name date + await expect( + page.locator('affine-reference:has-text("' + date + '")') + ).toBeVisible(); +}); diff --git a/tools/cli/src/webpack/config.ts b/tools/cli/src/webpack/config.ts index 72c692dc2..63bae6337 100644 --- a/tools/cli/src/webpack/config.ts +++ b/tools/cli/src/webpack/config.ts @@ -149,6 +149,12 @@ export const createConfiguration: ( alias: { yjs: join(workspaceRoot, 'node_modules', 'yjs'), lit: join(workspaceRoot, 'node_modules', 'lit'), + '@preact/signals-core': join( + workspaceRoot, + 'node_modules', + '@preact', + 'signals-core' + ), }, },