feat(core): page info adapter for journal (#5561)

Page info adapter + schema.
Adapted for journal features.

![image](https://github.com/toeverything/AFFiNE/assets/584378/2731ed2b-a125-4d62-b658-f2aff49d0e17)
This commit is contained in:
Peng Xiao
2024-01-22 08:25:27 +00:00
parent 8b92cc0cae
commit 735e1cb117
18 changed files with 412 additions and 68 deletions

View File

@@ -1,5 +1,6 @@
import { usePageMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspacePage } from '@affine/core/hooks/use-block-suite-workspace-page';
import { timestampToLocalDate } from '@affine/core/utils';
import { DebugLogger } from '@affine/debug';
import {
fetchWithTraceReport,
@@ -176,11 +177,7 @@ export const useSnapshotPage = (
export const historyListGroupByDay = (histories: DocHistory[]) => {
const map = new Map<string, DocHistory[]>();
for (const history of histories) {
const day = new Date(history.timestamp).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const day = timestampToLocalDate(history.timestamp);
const list = map.get(day) ?? [];
list.push(history);
map.set(day, list);

View File

@@ -10,6 +10,7 @@ import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useUserSubscription } from '@affine/core/hooks/use-subscription';
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
import { timestampToLocalTime } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -88,14 +89,6 @@ const ModalContainer = ({
);
};
const localTimeFormatter = new Intl.DateTimeFormat('en', {
timeStyle: 'short',
});
const timestampToLocalTime = (ts: string) => {
return localTimeFormatter.format(new Date(ts));
};
interface HistoryEditorPreviewProps {
ts?: string;
snapshotPage?: Page;

View File

@@ -19,7 +19,7 @@ export const JournalWeekDatePicker = ({
page,
}: JournalWeekDatePickerProps) => {
const handleRef = useRef<WeekDatePickerHandle>(null);
const { journalDate } = useJournalInfoHelper(page.meta);
const { journalDate } = useJournalInfoHelper(workspace, page.id);
const { openJournal } = useJournalHelper(workspace);
const [date, setDate] = useState(
(journalDate ?? dayjs()).format('YYYY-MM-DD')

View File

@@ -19,9 +19,11 @@ export const AppSidebarJournalButton = ({
}: AppSidebarJournalButtonProps) => {
const t = useAFFiNEI18N();
const currentPageId = useAtomValue(currentPageIdAtom);
const currentPage = currentPageId ? workspace.getPage(currentPageId) : null;
const { openToday } = useJournalHelper(workspace);
const { journalDate, isJournal } = useJournalInfoHelper(currentPage?.meta);
const { journalDate, isJournal } = useJournalInfoHelper(
workspace,
currentPageId
);
const params = useParams();
const isJournalActive = isJournal && !!params.pageId;

View File

@@ -0,0 +1,39 @@
import type { Workspace } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { useEffect, useMemo, useReducer } from 'react';
import {
currentWorkspacePropertiesAdapterAtom,
WorkspacePropertiesAdapter,
} from '../modules/workspace';
const useReactiveAdapter = (adapter: WorkspacePropertiesAdapter) => {
const [, forceUpdate] = useReducer(c => c + 1, 0);
useEffect(() => {
// todo: track which properties are used and then filter by property path change
// using Y.YEvent.path
function observe() {
forceUpdate();
}
adapter.properties.observeDeep(observe);
return () => {
adapter.properties.unobserveDeep(observe);
};
}, [adapter]);
return adapter;
};
export function useCurrentWorkspacePropertiesAdapter() {
const adapter = useAtomValue(currentWorkspacePropertiesAdapterAtom);
return useReactiveAdapter(adapter);
}
export function useWorkspacePropertiesAdapter(workspace: Workspace) {
const adapter = useMemo(
() => new WorkspacePropertiesAdapter(workspace),
[workspace]
);
return useReactiveAdapter(adapter);
}

View File

@@ -1,29 +0,0 @@
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import type { GetPageInfoById } from '@affine/env/page-info';
import type { Workspace } from '@blocksuite/store';
import { useAtomValue } from 'jotai';
import { useCallback, useMemo } from 'react';
import { pageSettingsAtom } from '../atoms';
export const useGetPageInfoById = (workspace: Workspace): GetPageInfoById => {
const pageMetas = useBlockSuitePageMeta(workspace);
const pageMap = useMemo(
() => Object.fromEntries(pageMetas.map(page => [page.id, page])),
[pageMetas]
);
const pageSettings = useAtomValue(pageSettingsAtom);
return useCallback(
(id: string) => {
const page = pageMap[id];
if (!page) {
return;
}
return {
...page,
isEdgeless: pageSettings[id]?.mode === 'edgeless',
};
},
[pageMap, pageSettings]
);
};

View File

@@ -1,31 +1,34 @@
import type { PageMeta } from '@blocksuite/store';
import { initEmptyPage } from '@toeverything/infra/blocksuite';
import dayjs from 'dayjs';
import { useCallback, useMemo } from 'react';
import type { BlockSuiteWorkspace } from '../shared';
import { timestampToLocalDate } from '../utils';
import { useWorkspacePropertiesAdapter } from './use-affine-adapter';
import { useBlockSuiteWorkspaceHelper } from './use-block-suite-workspace-helper';
import { useNavigateHelper } from './use-navigate-helper';
type MaybeDate = Date | string | number;
export const JOURNAL_DATE_FORMAT = 'YYYY-MM-DD';
function isPageJournal(pageMeta?: PageMeta) {
function isJournalString(j?: string | false) {
if (!runtimeConfig.enableJournal) return false;
return !!(pageMeta && pageMeta.title.match(/^\d{4}-\d{2}-\d{2}$/));
return j ? !!j?.match(/^\d{4}-\d{2}-\d{2}$/) : false;
}
function getJournalDate(pageMeta?: PageMeta) {
if (!isPageJournal(pageMeta)) return null;
if (!pageMeta?.title) return null;
if (!dayjs(pageMeta.title).isValid()) return null;
return dayjs(pageMeta.title);
function toDayjs(j?: string | false) {
if (!j || !isJournalString(j)) return null;
const day = dayjs(j);
if (!day.isValid()) return null;
return day;
}
export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
const bsWorkspaceHelper = useBlockSuiteWorkspaceHelper(workspace);
const navigateHelper = useNavigateHelper();
const adapter = useWorkspacePropertiesAdapter(workspace);
/**
* @internal
*/
@@ -36,9 +39,17 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
initEmptyPage(page, title).catch(err =>
console.error('Failed to load journal page', err)
);
adapter.setJournalPageDateString(page.id, title);
return page;
},
[bsWorkspaceHelper]
[adapter, bsWorkspaceHelper]
);
const isPageJournal = useCallback(
(pageId: string) => {
return !!adapter.getJournalPageDateString(pageId);
},
[adapter]
);
/**
@@ -48,14 +59,15 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
(maybeDate: MaybeDate) => {
const day = dayjs(maybeDate);
return Array.from(workspace.pages.values()).filter(page => {
if (!isPageJournal(page.meta)) return false;
const pageId = page.id;
if (!isPageJournal(pageId)) return false;
if (page.meta.trash) return false;
const journalDate = getJournalDate(page.meta);
const journalDate = adapter.getJournalPageDateString(page.id);
if (!journalDate) return false;
return day.isSame(journalDate, 'day');
});
},
[workspace.pages]
[adapter, isPageJournal, workspace.pages]
);
/**
@@ -89,26 +101,59 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
openJournal(date);
}, [openJournal]);
const getJournalDateString = useCallback(
(pageId: string) => {
return adapter.getJournalPageDateString(pageId);
},
[adapter]
);
const getLocalizedJournalDateString = useCallback(
(pageId: string) => {
const journalDateString = getJournalDateString(pageId);
if (!journalDateString) return null;
return timestampToLocalDate(journalDateString);
},
[getJournalDateString]
);
return useMemo(
() => ({
getJournalsByDate,
getJournalByDate,
getJournalDateString,
getLocalizedJournalDateString,
openJournal,
openToday,
isPageJournal,
}),
[getJournalByDate, getJournalsByDate, openJournal, openToday]
[
getJournalByDate,
getJournalDateString,
getJournalsByDate,
getLocalizedJournalDateString,
isPageJournal,
openJournal,
openToday,
]
);
};
export const useJournalInfoHelper = (pageMeta?: PageMeta) => {
const isJournal = isPageJournal(pageMeta);
const journalDate = useMemo(() => getJournalDate(pageMeta), [pageMeta]);
export const useJournalInfoHelper = (
workspace: BlockSuiteWorkspace,
pageId?: string | null
) => {
const { isPageJournal, getJournalDateString } = useJournalHelper(workspace);
const journalDate = useMemo(
() => (pageId ? toDayjs(getJournalDateString(pageId)) : null),
[getJournalDateString, pageId]
);
return useMemo(
() => ({
isJournal,
isJournal: pageId ? isPageJournal(pageId) : false,
journalDate,
}),
[isJournal, journalDate]
[isPageJournal, journalDate, pageId]
);
};

View File

@@ -1 +1,2 @@
export * from './atoms';
export * from './properties';

View File

@@ -0,0 +1,156 @@
// the adapter is to bridge the workspace rootdoc & native js bindings
import { createYProxy, type Workspace, type Y } from '@blocksuite/store';
import { defaultsDeep } from 'lodash-es';
import {
PagePropertyType,
PageSystemPropertyId,
type TagOption,
type WorkspaceAffineProperties,
type WorkspaceFavoriteItem,
} from './schema';
const AFFINE_PROPERTIES_ID = 'affine:workspace-properties';
/**
* WorkspacePropertiesAdapter is a wrapper for workspace properties.
* Users should not directly access the workspace properties via yjs, but use this adapter instead.
*
* Question for enhancement in the future:
* May abstract the adapter for each property type, e.g. PagePropertiesAdapter, SchemaAdapter, etc.
* So that the adapter could be more focused and easier to maintain (like assigning default values)
* However the properties for an abstraction may not be limited to a single yjs map.
*/
export class WorkspacePropertiesAdapter {
// provides a easy-to-use interface for workspace properties
private readonly proxy: WorkspaceAffineProperties;
public readonly properties: Y.Map<any>;
constructor(private readonly workspace: Workspace) {
// check if properties exists, if not, create one
const rootDoc = workspace.doc;
this.properties = rootDoc.getMap(AFFINE_PROPERTIES_ID);
this.proxy = createYProxy(this.properties);
// fixme: deal with migration issue?
this.ensureRootProperties();
}
private ensureRootProperties() {
// todo: deal with schema change issue
// fixme: may not to be called every time
defaultsDeep(this.proxy, {
schema: {
pageProperties: {
custom: {},
system: {
journal: {
id: PageSystemPropertyId.Journal,
name: 'Journal',
source: 'system',
type: PagePropertyType.Date,
},
tags: {
id: PageSystemPropertyId.Tags,
name: 'Tags',
source: 'system',
type: PagePropertyType.Tags,
options: this.workspace.meta.properties.tags?.options ?? [], // better use a one time migration
},
},
},
},
favorites: {},
pageProperties: {},
});
}
private ensurePageProperties(pageId: string) {
// fixme: may not to be called every time
defaultsDeep(this.proxy.pageProperties, {
[pageId]: {
custom: {},
system: {
[PageSystemPropertyId.Journal]: {
id: PageSystemPropertyId.Journal,
value: false,
},
[PageSystemPropertyId.Tags]: {
id: PageSystemPropertyId.Tags,
value: [],
},
},
},
});
}
get schema() {
return this.proxy.schema;
}
get favorites() {
return this.proxy.favorites;
}
get pageProperties() {
return this.proxy.pageProperties;
}
// ====== utilities ======
getPageProperties(pageId: string) {
return this.pageProperties[pageId];
}
isFavorite(id: string, type: WorkspaceFavoriteItem['type']) {
return this.favorites[id]?.type === type;
}
getJournalPageDateString(id: string) {
this.ensurePageProperties(id);
return this.pageProperties[id].system[PageSystemPropertyId.Journal].value;
}
setJournalPageDateString(id: string, date: string) {
this.ensurePageProperties(id);
const pageProperties = this.pageProperties[id];
pageProperties.system[PageSystemPropertyId.Journal].value = date;
}
get tagOptions() {
return this.schema.pageProperties.system[PageSystemPropertyId.Tags].options;
}
// page tags could be reactive
getPageTags(pageId: string) {
this.ensurePageProperties(pageId);
const tags =
this.pageProperties[pageId].system[PageSystemPropertyId.Tags].value;
const optionsMap = Object.fromEntries(this.tagOptions.map(o => [o.id, o]));
return tags.map(tag => optionsMap[tag]).filter((t): t is TagOption => !!t);
}
addPageTag(pageId: string, tag: TagOption | string) {
this.ensurePageProperties(pageId);
const tags = this.getPageTags(pageId);
const tagId = typeof tag === 'string' ? tag : tag.id;
if (tags.some(t => t.id === tagId)) {
return;
}
const pageProperties = this.pageProperties[pageId];
pageProperties.system[PageSystemPropertyId.Tags].value.push(tagId);
}
removePageTag(pageId: string, tag: TagOption | string) {
this.ensurePageProperties(pageId);
const tags = this.getPageTags(pageId);
const tagId = typeof tag === 'string' ? tag : tag.id;
const index = tags.findIndex(t => t.id === tagId);
if (index === -1) {
return;
}
const pageProperties = this.pageProperties[pageId];
pageProperties.system[PageSystemPropertyId.Tags].value.splice(index, 1);
}
}

View File

@@ -0,0 +1,16 @@
import type { Workspace } from '@affine/workspace/workspace';
import { atomWithObservable } from 'jotai/utils';
import { filter, map, of } from 'rxjs';
import { currentWorkspaceAtom } from '../atoms';
import { WorkspacePropertiesAdapter } from './adapter';
export const currentWorkspacePropertiesAdapterAtom =
atomWithObservable<WorkspacePropertiesAdapter>(get => {
return of(get(currentWorkspaceAtom)).pipe(
filter((workspace): workspace is Workspace => !!workspace),
map(workspace => {
return new WorkspacePropertiesAdapter(workspace.blockSuiteWorkspace);
})
);
});

View File

@@ -0,0 +1,2 @@
export * from './adapter';
export * from './atom';

View File

@@ -0,0 +1,101 @@
import { z } from 'zod';
// ===== workspace-wide page property schema =====
export const TagOptionSchema = z.object({
id: z.string(),
name: z.string(),
color: z.string(),
});
export type TagOption = z.infer<typeof TagOptionSchema>;
export enum PageSystemPropertyId {
Tags = 'tags',
Journal = 'journal',
}
export enum PagePropertyType {
String = 'string',
Number = 'number',
Boolean = 'boolean',
Date = 'date',
Tags = 'tags',
}
export const PagePropertyMetaBaseSchema = z.object({
id: z.string(),
name: z.string(),
source: z.string(),
type: z.string(),
});
export const PageSystemPropertyMetaBaseSchema =
PagePropertyMetaBaseSchema.extend({
source: z.literal('system'),
});
export const PageCustomPropertyMetaSchema = PagePropertyMetaBaseSchema.extend({
source: z.literal('custom'),
type: z.nativeEnum(PagePropertyType),
});
// ====== page info schema ======
export const PageInfoItemSchema = z.object({
id: z.string(), // property id. Maps to PagePropertyMetaSchema.id
hidden: z.boolean().optional(),
value: z.any(), // corresponds to PagePropertyMetaSchema.type
});
export const PageInfoJournalItemSchema = PageInfoItemSchema.extend({
id: z.literal(PageSystemPropertyId.Journal),
value: z.union([z.string(), z.literal(false)]),
});
export const PageInfoTagsItemSchema = PageInfoItemSchema.extend({
id: z.literal(PageSystemPropertyId.Tags),
value: z.array(z.string()),
});
// ====== workspace properties schema ======
export const WorkspaceFavoriteItemSchema = z.object({
id: z.string(),
order: z.number(),
type: z.enum(['page', 'collection']),
});
export type WorkspaceFavoriteItem = z.infer<typeof WorkspaceFavoriteItemSchema>;
const WorkspaceAffinePropertiesSchemaSchema = z.object({
pageProperties: z.object({
custom: z.record(PageCustomPropertyMetaSchema),
system: z.object({
[PageSystemPropertyId.Journal]: PageSystemPropertyMetaBaseSchema.extend({
id: z.literal(PageSystemPropertyId.Journal),
type: z.literal(PagePropertyType.Date),
}),
[PageSystemPropertyId.Tags]: PagePropertyMetaBaseSchema.extend({
id: z.literal(PageSystemPropertyId.Tags),
type: z.literal(PagePropertyType.Tags),
options: z.array(TagOptionSchema),
}),
}),
}),
});
const WorkspacePagePropertiesSchema = z.object({
custom: z.record(PageInfoItemSchema.extend({ order: z.number() })),
system: z.object({
[PageSystemPropertyId.Journal]: PageInfoJournalItemSchema,
[PageSystemPropertyId.Tags]: PageInfoTagsItemSchema,
}),
});
export const WorkspaceAffinePropertiesSchema = z.object({
schema: WorkspaceAffinePropertiesSchemaSchema,
favorites: z.record(WorkspaceFavoriteItemSchema),
pageProperties: z.record(WorkspacePagePropertiesSchema),
});
export type WorkspaceAffineProperties = z.infer<
typeof WorkspaceAffinePropertiesSchema
>;

View File

@@ -178,7 +178,7 @@ export function NormalPageHeader({
export function DetailPageHeader(props: PageHeaderProps) {
const { page } = props;
const { isJournal } = useJournalInfoHelper(page.meta);
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
const isInTrash = page.meta.trash;
return isJournal && !isInTrash ? (

View File

@@ -27,7 +27,7 @@ export const ExtensionTabs = ({ page }: ExtensionTabsProps) => {
FeatureType.Copilot
);
const { isJournal } = useJournalInfoHelper(page.meta);
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
const exts = useAtomValue(editorExtensionsAtom).filter(ext => {
if (ext.name === 'copilot' && !copilotEnabled) return false;

View File

@@ -43,7 +43,7 @@ interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
right?: ReactNode;
}
const PageItem = ({ page, right, className, ...attrs }: PageItemProps) => {
const { isJournal } = useJournalInfoHelper(page.meta);
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
const Icon = isJournal
? TodayIcon
@@ -77,7 +77,10 @@ interface JournalBlockProps extends EditorExtensionProps {
const EditorJournalPanel = (props: EditorExtensionProps) => {
const { workspace, page } = props;
const { journalDate, isJournal } = useJournalInfoHelper(page?.meta);
const { journalDate, isJournal } = useJournalInfoHelper(
page.workspace,
page.id
);
const { openJournal } = useJournalHelper(workspace);
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));

View File

@@ -52,7 +52,7 @@ export const Component = (): ReactElement => {
setCurrentWorkspace(null);
return undefined;
}
setCurrentWorkspace(workspace ?? null);
setCurrentWorkspace(workspace);
// for debug purpose
window.currentWorkspace = workspace;

View File

@@ -1,3 +1,4 @@
export * from './create-emotion-cache';
export * from './intl-formatter';
export * from './string2color';
export * from './toast';

View File

@@ -0,0 +1,17 @@
const timeFormatter = new Intl.DateTimeFormat(undefined, {
timeStyle: 'short',
});
const dateFormatter = new Intl.DateTimeFormat(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
export const timestampToLocalTime = (ts: string) => {
return timeFormatter.format(new Date(ts));
};
export const timestampToLocalDate = (ts: string) => {
return dateFormatter.format(new Date(ts));
};