mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 18:02:47 +08:00
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
This commit is contained in:
@@ -1,196 +1,9 @@
|
|||||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import { AtMenuConfigService } from '@affine/core/modules/at-menu-config/services';
|
||||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
import type { LinkedWidgetConfig } from '@blocksuite/affine/blocks';
|
||||||
import { JournalService } from '@affine/core/modules/journal';
|
import { type FrameworkProvider } from '@toeverything/infra';
|
||||||
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';
|
|
||||||
|
|
||||||
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(
|
export function createLinkedWidgetConfig(
|
||||||
framework: FrameworkProvider
|
framework: FrameworkProvider
|
||||||
): Partial<LinkedWidgetConfig> {
|
): Partial<LinkedWidgetConfig> {
|
||||||
return {
|
return framework.get(AtMenuConfigService).getConfig();
|
||||||
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, '\\$&');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
import { EditorSettingService } from '@affine/core/modules/editor-setting';
|
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 { i18nTime } from '@affine/i18n';
|
||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import { Text } from '@blocksuite/affine/store';
|
import { DocsService, useService, useServices } from '@toeverything/infra';
|
||||||
import {
|
|
||||||
type DocProps,
|
|
||||||
DocsService,
|
|
||||||
initDocFromProps,
|
|
||||||
useService,
|
|
||||||
useServices,
|
|
||||||
} from '@toeverything/infra';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { WorkbenchService } from '../../modules/workbench';
|
import { WorkbenchService } from '../../modules/workbench';
|
||||||
|
|
||||||
type MaybeDate = Date | string | number;
|
|
||||||
export const JOURNAL_DATE_FORMAT = 'YYYY-MM-DD';
|
|
||||||
|
|
||||||
function isJournalString(j?: string | false) {
|
function isJournalString(j?: string | false) {
|
||||||
return j ? !!j?.match(/^\d{4}-\d{2}-\d{2}$/) : 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
|
* @deprecated use `JournalService` directly
|
||||||
*/
|
*/
|
||||||
export const useJournalHelper = () => {
|
export const useJournalHelper = () => {
|
||||||
const { docsService, editorSettingService, journalService } = useServices({
|
const { journalService } = useServices({
|
||||||
DocsService,
|
DocsService,
|
||||||
EditorSettingService,
|
EditorSettingService,
|
||||||
JournalService,
|
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
|
* get journal by date, create one if not exist
|
||||||
*/
|
*/
|
||||||
const getJournalByDate = useCallback(
|
const getJournalByDate = useCallback(
|
||||||
(maybeDate: MaybeDate) => {
|
(maybeDate: MaybeDate) => {
|
||||||
const pages = getJournalsByDate(maybeDate);
|
return journalService.ensureJournalByDate(maybeDate);
|
||||||
if (pages.length) return pages[0];
|
|
||||||
return _createJournal(maybeDate);
|
|
||||||
},
|
},
|
||||||
[_createJournal, getJournalsByDate]
|
[journalService]
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
getJournalsByDate,
|
|
||||||
getJournalByDate,
|
getJournalByDate,
|
||||||
}),
|
}),
|
||||||
[getJournalsByDate, getJournalByDate]
|
[getJournalByDate]
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ImportDialog } from './import';
|
|||||||
import { ImportTemplateDialog } from './import-template';
|
import { ImportTemplateDialog } from './import-template';
|
||||||
import { ImportWorkspaceDialog } from './import-workspace';
|
import { ImportWorkspaceDialog } from './import-workspace';
|
||||||
import { CollectionSelectorDialog } from './selectors/collection';
|
import { CollectionSelectorDialog } from './selectors/collection';
|
||||||
|
import { DateSelectorDialog } from './selectors/date';
|
||||||
import { DocSelectorDialog } from './selectors/doc';
|
import { DocSelectorDialog } from './selectors/doc';
|
||||||
import { TagSelectorDialog } from './selectors/tag';
|
import { TagSelectorDialog } from './selectors/tag';
|
||||||
import { SettingDialog } from './setting';
|
import { SettingDialog } from './setting';
|
||||||
@@ -36,6 +37,7 @@ const WORKSPACE_DIALOGS = {
|
|||||||
'tag-selector': TagSelectorDialog,
|
'tag-selector': TagSelectorDialog,
|
||||||
'doc-selector': DocSelectorDialog,
|
'doc-selector': DocSelectorDialog,
|
||||||
'collection-selector': CollectionSelectorDialog,
|
'collection-selector': CollectionSelectorDialog,
|
||||||
|
'date-selector': DateSelectorDialog,
|
||||||
import: ImportDialog,
|
import: ImportDialog,
|
||||||
} satisfies {
|
} satisfies {
|
||||||
[key in keyof WORKSPACE_DIALOG_SCHEMA]?: React.FC<
|
[key in keyof WORKSPACE_DIALOG_SCHEMA]?: React.FC<
|
||||||
|
|||||||
@@ -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<WORKSPACE_DIALOG_SCHEMA['date-selector']>) => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>();
|
||||||
|
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const onClose = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[close]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(date?: string) => {
|
||||||
|
setSelectedDate(date);
|
||||||
|
onSelect?.(date);
|
||||||
|
},
|
||||||
|
[onSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu
|
||||||
|
rootOptions={{
|
||||||
|
modal: true,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: onClose,
|
||||||
|
}}
|
||||||
|
contentOptions={{
|
||||||
|
side: 'bottom',
|
||||||
|
sideOffset: 8,
|
||||||
|
align: 'start',
|
||||||
|
style: {
|
||||||
|
padding: 20,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: cssVarV2('layer/background/primary'),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
items={
|
||||||
|
<DatePicker
|
||||||
|
weekDays={t['com.affine.calendar-date-picker.week-days']()}
|
||||||
|
monthNames={t['com.affine.calendar-date-picker.month-names']()}
|
||||||
|
todayLabel={t['com.affine.calendar-date-picker.today']()}
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={handleSelect}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* hack the menu positioning using the following fixed anchor */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: position[0],
|
||||||
|
top: position[1],
|
||||||
|
width: position[2],
|
||||||
|
height: position[3],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
packages/frontend/core/src/modules/at-menu-config/index.ts
Normal file
24
packages/frontend/core/src/modules/at-menu-config/index.ts
Normal file
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<LinkedWidgetConfig> {
|
||||||
|
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<LinkedMenuItem[]>([]);
|
||||||
|
|
||||||
|
// 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},
|
||||||
|
<span style="color: ${cssVarV2('text/secondary')}"
|
||||||
|
>${dateDisplay}</span
|
||||||
|
>`
|
||||||
|
: 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<LinkedWidgetConfig['mobile']> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ export type WORKSPACE_DIALOG_SCHEMA = {
|
|||||||
init: string[];
|
init: string[];
|
||||||
onBeforeConfirm?: (ids: string[], cb: () => void) => void;
|
onBeforeConfirm?: (ids: string[], cb: () => void) => void;
|
||||||
}) => string[];
|
}) => string[];
|
||||||
|
'date-selector': (props: {
|
||||||
|
position: [number, number, number, number]; // [x, y, width, height]
|
||||||
|
onSelect?: (date?: string) => void;
|
||||||
|
}) => string;
|
||||||
import: () => {
|
import: () => {
|
||||||
docIds: string[];
|
docIds: string[];
|
||||||
entryId?: string;
|
entryId?: string;
|
||||||
|
|||||||
@@ -90,10 +90,38 @@ export class DocDisplayMetaService extends Service {
|
|||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getJournalIcon(
|
||||||
|
journalDate: string | Dayjs,
|
||||||
|
options?: DocDisplayIconOptions<'rc'>
|
||||||
|
): typeof TodayIcon;
|
||||||
|
|
||||||
|
getJournalIcon(
|
||||||
|
journalDate: string | Dayjs,
|
||||||
|
options?: DocDisplayIconOptions<'lit'>
|
||||||
|
): typeof LitYesterdayIcon;
|
||||||
|
|
||||||
|
getJournalIcon<T extends IconType = 'rc'>(
|
||||||
|
journalDate: string | Dayjs,
|
||||||
|
options?: DocDisplayIconOptions<T>
|
||||||
|
): T extends 'rc' ? typeof TodayIcon : typeof LitTodayIcon;
|
||||||
|
|
||||||
|
getJournalIcon<T extends IconType = 'rc'>(
|
||||||
|
journalDate: string | Dayjs,
|
||||||
|
options?: DocDisplayIconOptions<T>
|
||||||
|
) {
|
||||||
|
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$<T extends IconType = 'rc'>(
|
icon$<T extends IconType = 'rc'>(
|
||||||
docId: string,
|
docId: string,
|
||||||
options?: DocDisplayIconOptions<T>
|
options?: DocDisplayIconOptions<T>
|
||||||
): LiveData<T extends 'lit' ? typeof LitTodayIcon : typeof TodayIcon> {
|
) {
|
||||||
const iconSet = icons[options?.type ?? 'rc'];
|
const iconSet = icons[options?.type ?? 'rc'];
|
||||||
|
|
||||||
return LiveData.computed(get => {
|
return LiveData.computed(get => {
|
||||||
@@ -113,13 +141,7 @@ export class DocDisplayMetaService extends Service {
|
|||||||
this.journalService.journalDate$(docId).value
|
this.journalService.journalDate$(docId).value
|
||||||
);
|
);
|
||||||
if (journalDate) {
|
if (journalDate) {
|
||||||
if (!options?.compareDate) return iconSet.TodayIcon;
|
return this.getJournalIcon(journalDate, options);
|
||||||
const compareDate = dayjs(options?.compareDate);
|
|
||||||
return journalDate.isBefore(compareDate, 'day')
|
|
||||||
? iconSet.YesterdayIcon
|
|
||||||
: journalDate.isAfter(compareDate, 'day')
|
|
||||||
? iconSet.TomorrowIcon
|
|
||||||
: iconSet.TodayIcon;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reference icon
|
// reference icon
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { configureQuotaModule } from '@affine/core/modules/quota';
|
|||||||
import { configureInfraModules, type Framework } from '@toeverything/infra';
|
import { configureInfraModules, type Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
import { configureAppSidebarModule } from './app-sidebar';
|
import { configureAppSidebarModule } from './app-sidebar';
|
||||||
|
import { configAtMenuConfigModule } from './at-menu-config';
|
||||||
import { configureCloudModule } from './cloud';
|
import { configureCloudModule } from './cloud';
|
||||||
import { configureCollectionModule } from './collection';
|
import { configureCollectionModule } from './collection';
|
||||||
import { configureDialogModule } from './dialogs';
|
import { configureDialogModule } from './dialogs';
|
||||||
@@ -67,4 +68,5 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureDialogModule(framework);
|
configureDialogModule(framework);
|
||||||
configureDocInfoModule(framework);
|
configureDocInfoModule(framework);
|
||||||
configureOpenInApp(framework);
|
configureOpenInApp(framework);
|
||||||
|
configAtMenuConfigModule(framework);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,22 @@ import {
|
|||||||
WorkspaceScope,
|
WorkspaceScope,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { EditorSettingService } from '../editor-setting';
|
||||||
import { JournalService } from './services/journal';
|
import { JournalService } from './services/journal';
|
||||||
import { JournalDocService } from './services/journal-doc';
|
import { JournalDocService } from './services/journal-doc';
|
||||||
import { JournalStore } from './store/journal';
|
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 { JournalDocService } from './services/journal-doc';
|
||||||
|
|
||||||
export function configureJournalModule(framework: Framework) {
|
export function configureJournalModule(framework: Framework) {
|
||||||
framework
|
framework
|
||||||
.scope(WorkspaceScope)
|
.scope(WorkspaceScope)
|
||||||
.service(JournalService, [JournalStore])
|
.service(JournalService, [JournalStore, DocsService, EditorSettingService])
|
||||||
.store(JournalStore, [DocsService])
|
.store(JournalStore, [DocsService])
|
||||||
.scope(DocScope)
|
.scope(DocScope)
|
||||||
.service(JournalDocService, [DocService, JournalService]);
|
.service(JournalDocService, [DocService, JournalService]);
|
||||||
|
|||||||
@@ -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 dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import type { EditorSettingService } from '../../editor-setting';
|
||||||
import type { JournalStore } from '../store/journal';
|
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 {
|
export class JournalService extends Service {
|
||||||
constructor(private readonly store: JournalStore) {
|
constructor(
|
||||||
|
private readonly store: JournalStore,
|
||||||
|
private readonly docsService: DocsService,
|
||||||
|
private readonly editorSettingService: EditorSettingService
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,10 +40,40 @@ export class JournalService extends Service {
|
|||||||
this.store.removeDocJournalDate(docId);
|
this.store.removeDocJournalDate(docId);
|
||||||
}
|
}
|
||||||
|
|
||||||
getJournalsByDate(date: string) {
|
|
||||||
return this.store.getDocsByJournalDate(date);
|
|
||||||
}
|
|
||||||
journalsByDate$(date: string) {
|
journalsByDate$(date: string) {
|
||||||
return this.store.docsByJournalDate$(date);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 14,
|
"es-AR": 14,
|
||||||
"es-CL": 15,
|
"es-CL": 15,
|
||||||
"es": 14,
|
"es": 13,
|
||||||
"fr": 67,
|
"fr": 67,
|
||||||
"hi": 2,
|
"hi": 2,
|
||||||
"it-IT": 1,
|
"it-IT": 1,
|
||||||
|
|||||||
@@ -1372,6 +1372,8 @@
|
|||||||
"com.affine.toastMessage.successfullyDeleted": "Successfully deleted",
|
"com.affine.toastMessage.successfullyDeleted": "Successfully deleted",
|
||||||
"com.affine.today": "Today",
|
"com.affine.today": "Today",
|
||||||
"com.affine.tomorrow": "Tomorrow",
|
"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.top-tip.mobile": "Limited to view-only on mobile.",
|
||||||
"com.affine.trashOperation.delete": "Delete",
|
"com.affine.trashOperation.delete": "Delete",
|
||||||
"com.affine.trashOperation.delete.description": "Once deleted, you can't undo this action. Do you confirm?",
|
"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.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.title": "Unable to preview this file",
|
||||||
"com.affine.attachment.preview.error.subtitle": "file type not supported.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
const linkedPagePopover = page.locator('.linked-doc-popover');
|
||||||
await expect(linkedPagePopover).toBeVisible();
|
await expect(linkedPagePopover).toBeVisible();
|
||||||
await page.keyboard.type('Test linked doc', { delay: 50 });
|
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
|
// enable share page and copy page link
|
||||||
await enableShare(page);
|
await enableShare(page);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ test('link page is useable', async ({ page }) => {
|
|||||||
await page.keyboard.press('g');
|
await page.keyboard.press('g');
|
||||||
await page.keyboard.press('e');
|
await page.keyboard.press('e');
|
||||||
await page.keyboard.press('1');
|
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');
|
const link = page.locator('.affine-reference');
|
||||||
await expect(link).toBeVisible();
|
await expect(link).toBeVisible();
|
||||||
await page.click('.affine-reference');
|
await page.click('.affine-reference');
|
||||||
|
|||||||
@@ -331,3 +331,90 @@ test('allow switching to embed view when linking to the other document with mode
|
|||||||
url2.searchParams.delete('refreshKey');
|
url2.searchParams.delete('refreshKey');
|
||||||
expect(url.toJSON()).toStrictEqual(url2.toJSON());
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -149,6 +149,12 @@ export const createConfiguration: (
|
|||||||
alias: {
|
alias: {
|
||||||
yjs: join(workspaceRoot, 'node_modules', 'yjs'),
|
yjs: join(workspaceRoot, 'node_modules', 'yjs'),
|
||||||
lit: join(workspaceRoot, 'node_modules', 'lit'),
|
lit: join(workspaceRoot, 'node_modules', 'lit'),
|
||||||
|
'@preact/signals-core': join(
|
||||||
|
workspaceRoot,
|
||||||
|
'node_modules',
|
||||||
|
'@preact',
|
||||||
|
'signals-core'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user