feat(core): simple recovery history ui poc (#5033)

Simple recovery history UI poc.
What's missing
- [x] e2e

All biz logic should be done, excluding complete ui details.
- [ ] offline prompt
- [ ] history timeline
- [ ] page ui

https://github.com/toeverything/AFFiNE/assets/584378/fc3f6a48-ff7f-4265-b9f5-9c0087cb2635
This commit is contained in:
Peng Xiao
2023-11-27 02:41:19 +00:00
parent f04ec50d12
commit 34d575078c
23 changed files with 1291 additions and 14 deletions

View File

@@ -89,7 +89,7 @@ export class DocHistoryManager {
workspaceId,
id,
timestamp: {
lte: before,
lt: before,
},
// only include the ones has not expired
expiredAt: {

View File

@@ -32,6 +32,7 @@ export const runtimeFlagsSchema = z.object({
enableCaptcha: z.boolean(),
enableEnhanceShareMode: z.boolean(),
enablePayment: z.boolean(),
enablePageHistory: z.boolean(),
// this is for the electron app
serverUrlPrefix: z.string(),
enableMoveDatabase: z.boolean(),

View File

@@ -33,6 +33,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableCaptcha: true,
enableEnhanceShareMode: false,
enablePayment: true,
enablePageHistory: false,
serverUrlPrefix: 'https://insider.affine.pro', // Let insider be stable environment temporarily.
editorFlags,
appVersion: packageJson.version,
@@ -41,6 +42,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
get beta() {
return {
...this.stable,
enablePageHistory: false,
serverUrlPrefix: 'https://insider.affine.pro',
};
},
@@ -75,6 +77,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableCaptcha: true,
enableEnhanceShareMode: false,
enablePayment: true,
enablePageHistory: true,
serverUrlPrefix: 'https://affine.fail',
editorFlags,
appVersion: packageJson.version,
@@ -142,6 +145,11 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
: buildFlags.mode === 'development'
? true
: currentBuildPreset.enablePayment,
enablePageHistory: process.env.ENABLE_PAGE_HISTORY
? process.env.ENABLE_PAGE_HISTORY === 'true'
: buildFlags.mode === 'development'
? true
: currentBuildPreset.enablePageHistory,
};
if (buildFlags.mode === 'development') {

View File

@@ -0,0 +1,7 @@
import { atom } from 'jotai';
// make page history controllable by atom to make it easier to use in CMDK
export const pageHistoryModalAtom = atom({
open: false,
pageId: '',
});

View File

@@ -0,0 +1,251 @@
import { DebugLogger } from '@affine/debug';
import {
fetchWithTraceReport,
type ListHistoryQuery,
listHistoryQuery,
recoverDocMutation,
} from '@affine/graphql';
import {
useMutateQueryResource,
useMutation,
useQueryInfinite,
} from '@affine/workspace/affine/gql';
import { createAffineCloudBlobEngine } from '@affine/workspace/blob';
import { globalBlockSuiteSchema } from '@affine/workspace/manager';
import { assertEquals } from '@blocksuite/global/utils';
import { Workspace } from '@blocksuite/store';
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
import { revertUpdate } from '@toeverything/y-indexeddb';
import { useMemo } from 'react';
import useSWRImmutable from 'swr/immutable';
import { applyUpdate } from 'yjs';
const logger = new DebugLogger('page-history');
type DocHistory = ListHistoryQuery['workspace']['histories'][number];
export const usePageSnapshotList = (workspaceId: string, pageDocId: string) => {
const pageSize = 10;
const { data, loadingMore, loadMore } = useQueryInfinite({
query: listHistoryQuery,
getVariables: (_, previousPageData) => {
// use the timestamp of the last history as the cursor
const before = previousPageData?.workspace.histories.at(-1)?.timestamp;
const vars = {
pageDocId: pageDocId,
workspaceId: workspaceId,
before: before,
take: pageSize,
};
return vars;
},
});
const shouldLoadMore = useMemo(() => {
if (!data) {
return false;
}
const lastPage = data.at(-1);
if (!lastPage) {
return false;
}
return lastPage.workspace.histories.length === pageSize;
}, [data]);
const histories = useMemo(() => {
if (!data) {
return [];
}
return data.flatMap(page => page.workspace.histories);
}, [data]);
return [
histories,
shouldLoadMore ? loadMore : undefined,
loadingMore,
] as const;
};
const snapshotFetcher = async (
[workspaceId, pageDocId, ts]: [
workspaceId: string,
pageDocId: string,
ts: string,
] // timestamp is the key to the history/snapshot
) => {
if (!ts) {
return null;
}
const res = await fetchWithTraceReport(
runtimeConfig.serverUrlPrefix +
`/api/workspaces/${workspaceId}/docs/${pageDocId}/histories/${ts}`,
{
priority: 'high',
}
);
if (!res.ok) {
throw new Error('Failed to fetch snapshot');
}
const snapshot = await res.arrayBuffer();
if (!snapshot) {
throw new Error('Invalid snapshot');
}
return snapshot;
};
// attach the Page shown in the modal to a temporary workspace
// so that we do not need to worry about providers etc
// todo: fix references to the page (the referenced page will shown as deleted)
// if we simply clone the current workspace, it maybe time consuming right?
const workspaceMap = new Map<string, Workspace>();
// assume the workspace is a cloud workspace since the history feature is only enabled for cloud workspace
const getOrCreateWorkspace = (workspaceId: string) => {
let workspace = workspaceMap.get(workspaceId);
if (!workspace) {
const blobEngine = createAffineCloudBlobEngine(workspaceId);
workspace = new Workspace({
id: workspaceId,
providerCreators: [],
blobStorages: [
() => ({
crud: {
async get(key) {
return (await blobEngine.get(key)) ?? null;
},
async set(key, value) {
await blobEngine.set(key, value);
return key;
},
async delete(key) {
return blobEngine.delete(key);
},
async list() {
return blobEngine.list();
},
},
}),
],
schema: globalBlockSuiteSchema,
});
workspaceMap.set(workspaceId, workspace);
}
return workspace;
};
// workspace id + page id + timestamp -> snapshot (update binary)
export const usePageHistory = (
workspaceId: string,
pageDocId: string,
ts?: string
) => {
// snapshot should be immutable. so we use swr immutable to disable revalidation
const { data } = useSWRImmutable<ArrayBuffer | null>(
[workspaceId, pageDocId, ts],
{
fetcher: snapshotFetcher,
suspense: false,
}
);
return data ?? undefined;
};
// workspace id + page id + timestamp + snapshot -> Page (to be used for rendering in blocksuite editor)
export const useSnapshotPage = (
workspaceId: string,
pageDocId: string,
ts?: string,
snapshot?: ArrayBuffer
) => {
const page = useMemo(() => {
if (!ts) {
return null;
}
const pageId = pageDocId + '-' + ts;
const historyShellWorkspace = getOrCreateWorkspace(workspaceId);
let page = historyShellWorkspace.getPage(pageId);
if (!page && snapshot) {
page = historyShellWorkspace.createPage({
id: pageId,
});
page.awarenessStore.setReadonly(page, true);
const spaceDoc = page.spaceDoc;
page
.load(() => applyUpdate(spaceDoc, new Uint8Array(snapshot)))
.catch(console.error); // must load before applyUpdate
}
return page;
}, [pageDocId, snapshot, ts, workspaceId]);
return page;
};
export const historyListGroupByDay = (histories: DocHistory[]) => {
const map = new Map<string, DocHistory[]>();
for (const history of histories) {
const day = new Date(history.timestamp).toLocaleDateString();
const list = map.get(day) ?? [];
list.push(history);
map.set(day, list);
}
return [...map.entries()];
};
export const useRestorePage = (workspace: Workspace, pageId: string) => {
const page = useBlockSuiteWorkspacePage(workspace, pageId);
const mutateQueryResource = useMutateQueryResource();
const { trigger: recover, isMutating } = useMutation({
mutation: recoverDocMutation,
});
const { getPageMeta, setPageTitle } = usePageMetaHelper(workspace);
const onRestore = useMemo(() => {
return async (version: string, update: Uint8Array) => {
if (!page) {
return;
}
const pageDocId = page.spaceDoc.guid;
revertUpdate(page.spaceDoc, update, key => {
assertEquals(key, 'blocks'); // only expect this value is 'blocks'
return 'Map';
});
// should also update the page title, since it may be changed in the history
const title = page.meta.title;
if (getPageMeta(pageDocId)?.title !== title) {
setPageTitle(pageDocId, title);
}
await recover({
docId: pageDocId,
timestamp: version,
workspaceId: workspace.id,
});
await mutateQueryResource(listHistoryQuery, vars => {
return (
vars.pageDocId === pageDocId && vars.workspaceId === workspace.id
);
});
logger.info('Page restored', pageDocId, version);
};
}, [
getPageMeta,
mutateQueryResource,
page,
recover,
setPageTitle,
workspace.id,
]);
return {
onRestore,
isMutating,
};
};

View File

@@ -0,0 +1,119 @@
export const EmptyHistoryShape = () => (
<svg
width="200"
height="174"
viewBox="0 0 200 174"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="51.724"
y="38.4615"
width="96.5517"
height="96.5517"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M51.8339 86.7374L99.9999 38.5714L148.166 86.7374L99.9999 134.903L51.8339 86.7374Z"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M99.6052 38.1966C107.662 33.4757 117.043 30.7695 127.055 30.7695C157.087 30.7695 181.432 55.1148 181.432 85.1462C181.432 107.547 167.887 126.783 148.541 135.113"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M148.375 86.4722C153.096 94.5291 155.802 103.91 155.802 113.922C155.802 143.954 131.457 168.299 101.426 168.299C79.0254 168.299 59.7886 154.754 51.4587 135.408"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M100.394 135.113C92.3373 139.834 82.9568 142.54 72.9441 142.54C42.9127 142.54 18.5675 118.195 18.5675 88.1634C18.5675 65.763 32.1124 46.5261 51.4587 38.1963"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M51.4585 87.1318C46.7377 79.0749 44.0315 69.6943 44.0315 59.6817C44.0315 29.6503 68.3767 5.30503 98.4081 5.30503C120.809 5.30503 140.045 18.8499 148.375 38.1963"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M51.4587 38.1963L148.541 135.279"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M148.541 38.1963L51.4587 135.279"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M99.9998 38.1963V135.279"
stroke="var(--affine-text-disable-color)"
/>
<path
d="M148.541 86.7378L51.4588 86.7378"
stroke="var(--affine-text-disable-color)"
/>
<ellipse
cx="148.275"
cy="38.4617"
rx="3.97878"
ry="3.97878"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="148.275"
cy="135.013"
rx="3.97878"
ry="3.97878"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="148.275"
cy="86.7376"
rx="3.97878"
ry="3.97878"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="51.7241"
cy="38.4617"
rx="3.97878"
ry="3.97878"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="51.7241"
cy="135.013"
rx="3.97878"
ry="3.97878"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="51.7241"
cy="86.7376"
rx="3.97878"
ry="3.97878"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="100"
cy="38.4617"
rx="3.97878"
ry="3.97878"
transform="rotate(-90 100 38.4617)"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="100"
cy="86.2073"
rx="3.97878"
ry="3.97878"
transform="rotate(-90 100 86.2073)"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="100"
cy="135.013"
rx="3.97878"
ry="3.97878"
transform="rotate(-90 100 135.013)"
fill="var(--affine-text-primary-color)"
/>
</svg>
);

View File

@@ -0,0 +1,446 @@
import { Scrollable } from '@affine/component';
import {
BlockSuiteEditor,
BlockSuiteFallback,
} from '@affine/component/block-suite-editor';
import type { PageMode } from '@affine/core/atoms';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace } from '@blocksuite/store';
import type { DialogContentProps } from '@radix-ui/react-dialog';
import { Button } from '@toeverything/components/button';
import { ConfirmModal, Modal } from '@toeverything/components/modal';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useAtom, useAtomValue } from 'jotai';
import {
type PropsWithChildren,
Suspense,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import { currentModeAtom } from '../../../atoms/mode';
import { pageHistoryModalAtom } from '../../../atoms/page-history';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { StyledEditorModeSwitch } from '../../blocksuite/block-suite-mode-switch/style';
import {
EdgelessSwitchItem,
PageSwitchItem,
} from '../../blocksuite/block-suite-mode-switch/switch-items';
import { AffineErrorBoundary } from '../affine-error-boundary';
import {
historyListGroupByDay,
usePageHistory,
usePageSnapshotList,
useRestorePage,
useSnapshotPage,
} from './data';
import { EmptyHistoryShape } from './empty-history-shape';
import * as styles from './styles.css';
export interface PageHistoryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
workspace: Workspace;
pageId: string;
}
const contentOptions: DialogContentProps = {
['data-testid' as string]: 'page-history-modal',
onPointerDownOutside: e => {
e.preventDefault();
},
style: {
padding: 0,
maxWidth: 944,
backgroundColor: 'var(--affine-background-primary-color)',
overflow: 'hidden',
},
};
const ModalContainer = ({
onOpenChange,
open,
children,
}: PropsWithChildren<{
open: boolean;
onOpenChange: (open: boolean) => void;
}>) => {
return (
<Modal
open={open}
onOpenChange={onOpenChange}
width="calc(100% - 64px)"
height="80%"
withoutCloseButton
contentOptions={contentOptions}
>
<AffineErrorBoundary>{children}</AffineErrorBoundary>
</Modal>
);
};
const localTimeFormatter = new Intl.DateTimeFormat('en', {
timeStyle: 'short',
});
const timestampToLocalTime = (ts: string) => {
return localTimeFormatter.format(new Date(ts));
};
interface HistoryEditorPreviewProps {
workspaceId: string;
pageDocId: string;
ts?: string;
snapshot?: ArrayBuffer;
mode: PageMode;
onModeChange: (mode: PageMode) => void;
title: string;
}
const HistoryEditorPreview = ({
ts,
snapshot,
onModeChange,
mode,
workspaceId,
pageDocId,
title,
}: HistoryEditorPreviewProps) => {
const onSwitchToPageMode = useCallback(() => {
onModeChange('page');
}, [onModeChange]);
const onSwitchToEdgelessMode = useCallback(() => {
onModeChange('edgeless');
}, [onModeChange]);
const page = useSnapshotPage(workspaceId, pageDocId, ts, snapshot);
return (
<div className={styles.previewWrapper}>
<div className={styles.previewHeader}>
<StyledEditorModeSwitch switchLeft={mode === 'page'}>
<PageSwitchItem
data-testid="switch-page-mode-button"
active={mode === 'page'}
onClick={onSwitchToPageMode}
/>
<EdgelessSwitchItem
data-testid="switch-edgeless-mode-button"
active={mode === 'edgeless'}
onClick={onSwitchToEdgelessMode}
/>
</StyledEditorModeSwitch>
<div className={styles.previewHeaderTitle}>{title}</div>
<div className={styles.previewHeaderTimestamp}>
{ts ? timestampToLocalTime(ts) : null}
</div>
</div>
{page ? (
<BlockSuiteEditor
className={styles.editor}
mode={mode}
page={page}
onModeChange={onModeChange}
/>
) : (
<BlockSuiteFallback />
)}
</div>
);
};
const PageHistoryList = ({
pageDocId,
workspaceId,
activeVersion,
onVersionChange,
}: {
workspaceId: string;
pageDocId: string;
activeVersion?: string;
onVersionChange: (version: string) => void;
}) => {
const [historyList, loadMore, loadingMore] = usePageSnapshotList(
workspaceId,
pageDocId
);
const historyListByDay = useMemo(() => {
return historyListGroupByDay(historyList);
}, [historyList]);
const t = useAFFiNEI18N();
useLayoutEffect(() => {
if (historyList.length > 0 && !activeVersion) {
onVersionChange(historyList[0].timestamp);
}
}, [activeVersion, historyList, onVersionChange]);
return (
<div className={styles.historyList}>
<div className={styles.historyListHeader}>
{t['com.affine.history.version-history']()}
</div>
<Scrollable.Root className={styles.historyListScrollable}>
<Scrollable.Viewport className={styles.historyListScrollableInner}>
{historyListByDay.map(([day, list]) => {
return (
<div key={day} className={styles.historyItemGroup}>
<div className={styles.historyItemGroupTitle}>{day}</div>
{list.map(history => (
<div
className={styles.historyItem}
key={history.timestamp}
data-testid="version-history-item"
onClick={e => {
e.stopPropagation();
onVersionChange(history.timestamp);
}}
data-active={activeVersion === history.timestamp}
>
<button>{timestampToLocalTime(history.timestamp)}</button>
</div>
))}
</div>
);
})}
{loadMore ? (
<Button
type="plain"
loading={loadingMore}
disabled={loadingMore}
className={styles.historyItemLoadMore}
onClick={loadMore}
>
Load More
</Button>
) : null}
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
</div>
);
};
interface ConfirmRestoreModalProps {
open: boolean;
onConfirm: (res: boolean) => void;
isMutating: boolean;
}
const ConfirmRestoreModal = ({
isMutating,
open,
onConfirm,
}: ConfirmRestoreModalProps) => {
const t = useAFFiNEI18N();
const handleConfirm = useCallback(() => {
onConfirm(true);
}, [onConfirm]);
const handleCancel = useCallback(() => {
onConfirm(false);
}, [onConfirm]);
return (
<ConfirmModal
open={open}
onOpenChange={handleCancel}
title={t['com.affine.history.restore-current-version']()}
description={t['com.affine.history.confirm-restore-modal.hint']()}
cancelText={t['Cancel']()}
contentOptions={{
['data-testid' as string]: 'confirm-restore-history-modal',
style: {
padding: '20px 26px',
},
}}
confirmButtonOptions={{
loading: isMutating,
type: 'primary',
['data-testid' as string]: 'confirm-restore-history-button',
children: t['com.affine.history.confirm-restore-modal.restore'](),
}}
onConfirm={handleConfirm}
></ConfirmModal>
);
};
const EmptyHistoryPrompt = () => {
const t = useAFFiNEI18N();
return (
<div
className={styles.emptyHistoryPrompt}
data-testid="empty-history-prompt"
>
<EmptyHistoryShape />
<div className={styles.emptyHistoryPromptTitle}>
{t['com.affine.history.empty-prompt.title']()}
</div>
<div className={styles.emptyHistoryPromptDescription}>
{t['com.affine.history.empty-prompt.description']()}
</div>
</div>
);
};
const PageHistoryManager = ({
workspace,
pageId,
onClose,
}: {
workspace: Workspace;
pageId: string;
onClose: () => void;
}) => {
const workspaceId = workspace.id;
const [activeVersion, setActiveVersion] = useState<string>();
const pageDocId = useMemo(() => {
return workspace.getPage(pageId)?.spaceDoc.guid ?? pageId;
}, [pageId, workspace]);
const snapshot = usePageHistory(workspaceId, pageDocId, activeVersion);
const t = useAFFiNEI18N();
const { onRestore, isMutating } = useRestorePage(workspace, pageId);
const handleRestore = useMemo(
() => async () => {
if (!activeVersion || !snapshot) {
return;
}
await onRestore(activeVersion, new Uint8Array(snapshot));
// close the modal after restore
onClose();
},
[activeVersion, onClose, onRestore, snapshot]
);
const defaultPreviewPageMode = useAtomValue(currentModeAtom);
const [mode, setMode] = useState<PageMode>(defaultPreviewPageMode);
const title = useMemo(
() => workspace.getPage(pageId)?.meta.title || t['Untitled'](),
[pageId, t, workspace]
);
const [showRestoreConfirmModal, setShowRestoreConfirmModal] = useState(false);
const showRestoreConfirm = useCallback(() => {
setShowRestoreConfirmModal(true);
}, []);
const onConfirmRestore = useAsyncCallback(
async res => {
if (res) {
await handleRestore();
}
setShowRestoreConfirmModal(false);
},
[handleRestore]
);
return (
<div className={styles.root}>
<div className={styles.modalContent} data-empty={!activeVersion}>
<HistoryEditorPreview
workspaceId={workspaceId}
pageDocId={pageDocId}
ts={activeVersion}
snapshot={snapshot}
mode={mode}
onModeChange={setMode}
title={title}
/>
<PageHistoryList
workspaceId={workspaceId}
pageDocId={pageDocId}
activeVersion={activeVersion}
onVersionChange={setActiveVersion}
/>
</div>
{!activeVersion ? (
<div className={styles.modalContent}>
<EmptyHistoryPrompt />
</div>
) : null}
<div className={styles.historyFooter}>
<Button type="plain" onClick={onClose}>
{t['com.affine.history.back-to-page']()}
</Button>
<div className={styles.spacer} />
<Button
type="primary"
onClick={showRestoreConfirm}
disabled={isMutating || !activeVersion}
>
{t['com.affine.history.restore-current-version']()}
</Button>
</div>
<ConfirmRestoreModal
open={showRestoreConfirmModal}
isMutating={isMutating}
onConfirm={onConfirmRestore}
/>
</div>
);
};
export const PageHistoryModal = ({
onOpenChange,
open,
pageId,
workspace,
}: PageHistoryModalProps) => {
const onClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
return (
<ModalContainer onOpenChange={onOpenChange} open={open}>
<Suspense fallback={<BlockSuiteFallback />}>
<PageHistoryManager
onClose={onClose}
pageId={pageId}
workspace={workspace}
/>
</Suspense>
</ModalContainer>
);
};
export const GlobalPageHistoryModal = () => {
const [{ open, pageId }, setState] = useAtom(pageHistoryModalAtom);
const [workspace] = useCurrentWorkspace();
const handleOpenChange = useCallback(
(open: boolean) => {
setState(prev => ({
...prev,
open,
}));
},
[setState]
);
return (
<PageHistoryModal
open={open}
onOpenChange={handleOpenChange}
pageId={pageId}
workspace={workspace.blockSuiteWorkspace}
/>
);
};

View File

@@ -0,0 +1 @@
export * from './history-modal';

View File

@@ -0,0 +1,185 @@
import { createVar, globalStyle, style } from '@vanilla-extract/css';
const headerHeight = createVar('header-height');
const footerHeight = createVar('footer-height');
const historyListWidth = createVar('history-list-width');
export const root = style({
height: '100%',
width: '100%',
vars: {
[headerHeight]: '52px',
[footerHeight]: '68px',
[historyListWidth]: '160px',
},
});
export const modalContent = style({
display: 'flex',
height: `calc(100% - ${footerHeight})`,
width: '100%',
position: 'absolute',
selectors: {
'&[data-empty="true"]': {
opacity: 0,
pointerEvents: 'none',
},
},
});
export const previewWrapper = style({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
height: '100%',
width: `calc(100% - ${historyListWidth})`,
backgroundColor: 'var(--affine-background-secondary-color)',
});
export const previewHeader = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: headerHeight,
borderBottom: '1px solid var(--affine-border-color)',
backgroundColor: 'var(--affine-background-primary-color)',
padding: '0 12px',
flexShrink: 0,
gap: 12,
});
export const previewHeaderTitle = style({
fontSize: 'var(--affine-font-xs)',
fontWeight: 600,
maxWidth: 400, // better responsiveness
});
export const previewHeaderTimestamp = style({
color: 'var(--affine-text-secondary-color)',
backgroundColor: 'var(--affine-background-secondary-color)',
padding: '0 10px',
borderRadius: 4,
fontSize: 'var(--affine-font-xs)',
});
export const editor = style({
height: '100%',
flexGrow: 1,
overflow: 'hidden',
});
export const historyList = style({
overflow: 'hidden',
height: '100%',
width: historyListWidth,
flexShrink: 0,
borderLeft: '1px solid var(--affine-border-color)',
});
export const historyListScrollable = style({
height: `calc(100% - ${headerHeight})`,
});
export const historyListScrollableInner = style({
display: 'flex',
gap: 16,
flexDirection: 'column',
});
export const historyListHeader = style({
display: 'flex',
alignItems: 'center',
height: 52,
borderBottom: '1px solid var(--affine-border-color)',
fontWeight: 'bold',
flexShrink: 0,
padding: '0 12px',
});
export const historyItemGroup = style({
display: 'flex',
flexDirection: 'column',
rowGap: 6,
});
export const historyItemGroupTitle = style({
display: 'flex',
alignItems: 'center',
padding: '12px',
fontWeight: 'bold',
backgroundColor: 'var(--affine-background-primary-color)',
position: 'sticky',
top: 0,
});
export const historyItem = style({
display: 'flex',
alignItems: 'center',
padding: '0 12px',
height: 32,
cursor: 'pointer',
selectors: {
'&:hover, &[data-active=true]': {
backgroundColor: 'var(--affine-hover-color)',
},
},
});
export const historyItemLoadMore = style([
historyItem,
{
cursor: 'pointer',
color: 'var(--affine-text-secondary-color)',
flexShrink: 0,
borderRadius: 0,
selectors: {
'&:hover': {
backgroundColor: 'var(--affine-hover-color)',
},
},
},
]);
globalStyle(`${historyItem} button`, {
color: 'inherit',
});
export const historyFooter = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: 68,
borderTop: '1px solid var(--affine-border-color)',
padding: '0 24px',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
});
export const spacer = style({
flexGrow: 1,
});
export const emptyHistoryPrompt = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%',
zIndex: 1,
gap: 20,
});
export const emptyHistoryPromptTitle = style({
fontWeight: 600,
fontSize: 'var(--affine-font-h-5)',
});
export const emptyHistoryPromptDescription = style({
width: 320,
textAlign: 'center',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
});

View File

@@ -1,5 +1,6 @@
import { FlexWrapper } from '@affine/component';
import { Export, MoveToTrash } from '@affine/component/page-list';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import {
@@ -8,6 +9,7 @@ import {
EditIcon,
FavoritedIcon,
FavoriteIcon,
HistoryIcon,
ImportIcon,
PageIcon,
} from '@blocksuite/icons';
@@ -25,7 +27,7 @@ import {
} from '@toeverything/hooks/use-block-suite-page-meta';
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback, useRef } from 'react';
import { useCallback, useRef, useState } from 'react';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
import { setPageModeAtom } from '../../../atoms';
@@ -36,6 +38,7 @@ import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helpe
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { toast } from '../../../utils';
import { PageHistoryModal } from '../../affine/page-history-modal/history-modal';
import { HeaderDropDownButton } from '../../pure/header-drop-down-button';
import { usePageHelper } from '../block-suite-page-list/utils';
@@ -68,6 +71,12 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const openHistoryModal = useCallback(() => {
setHistoryModalOpen(true);
}, []);
const handleOpenTrashModal = useCallback(() => {
setTrashModal({
open: true,
@@ -207,6 +216,23 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
>
{t['Import']()}
</MenuItem>
{workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD &&
runtimeConfig.enablePageHistory ? (
<MenuItem
preFix={
<MenuIcon>
<HistoryIcon />
</MenuIcon>
}
data-testid="editor-option-menu-history"
onSelect={openHistoryModal}
style={menuItemStyle}
>
{t['com.affine.history.view-history-version']()}
</MenuItem>
) : null}
<Export exportHandler={exportHandler} />
<MenuSeparator />
<MoveToTrash
@@ -228,6 +254,14 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
>
<HeaderDropDownButton />
</Menu>
{workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
<PageHistoryModal
workspace={workspace.blockSuiteWorkspace}
open={historyModalOpen}
pageId={pageId}
onOpenChange={setHistoryModalOpen}
/>
) : null}
</FlexWrapper>
);
};

View File

@@ -30,8 +30,8 @@ import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import { useRegisterBrowserHistoryCommands } from '../../hooks/use-browser-history-commands';
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/use-shortcut-commands';
import type { AllWorkspace } from '../../shared';
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
@@ -169,7 +169,7 @@ export const RootAppSidebar = ({
const closeUserWorkspaceList = useCallback(() => {
setOpenUserWorkspaceList(false);
}, [setOpenUserWorkspaceList]);
useRegisterBlocksuiteEditorCommands(router.back, router.forward);
useRegisterBrowserHistoryCommands(router.back, router.forward);
const userInfo = useDeleteCollectionInfo();
return (
<AppSidebar

View File

@@ -4,6 +4,7 @@ import { useSWRConfig } from 'swr';
export function useMutateCloud() {
const { mutate } = useSWRConfig();
return useCallback(async () => {
// todo: should not mutate all graphql cache
return mutate(key => {
if (Array.isArray(key)) {
return key[0] === 'cloud';

View File

@@ -1,25 +1,29 @@
import { toast } from '@affine/component';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { Workspace } from '@blocksuite/store';
import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons';
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
import {
PreconditionStrategy,
registerAffineCommand,
} from '@toeverything/infra/command';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { pageHistoryModalAtom } from '../../atoms/page-history';
import { useCurrentWorkspace } from '../current/use-current-workspace';
import { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
import { useExportPage } from './use-export-page';
import { useTrashModalHelper } from './use-trash-modal-helper';
export function useRegisterBlocksuiteEditorCommands(
blockSuiteWorkspace: Workspace,
pageId: string,
mode: 'page' | 'edgeless'
) {
const t = useAFFiNEI18N();
const [workspace] = useCurrentWorkspace();
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const { getPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const currentPage = blockSuiteWorkspace.getPage(pageId);
assertExists(currentPage);
@@ -28,6 +32,15 @@ export function useRegisterBlocksuiteEditorCommands(
const favorite = pageMeta.favorite ?? false;
const trash = pageMeta.trash ?? false;
const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom);
const openHistoryModal = useCallback(() => {
setPageHistoryModalState(() => ({
pageId,
open: true,
}));
}, [pageId, setPageHistoryModalState]);
const { togglePageMode, toggleFavorite, restoreFromTrash } =
useBlockSuiteMetaHelper(blockSuiteWorkspace);
const exportHandler = useExportPage(currentPage);
@@ -40,12 +53,14 @@ export function useRegisterBlocksuiteEditorCommands(
});
}, [pageId, pageMeta.title, setTrashModal]);
const isCloudWorkspace = workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
useEffect(() => {
const unsubs: Array<() => void> = [];
const preconditionStrategy = () =>
PreconditionStrategy.InPaperOrEdgeless && !trash;
//TODO: add back when edgeless presentation is ready
// TODO: add back when edgeless presentation is ready
// this is pretty hack and easy to break. need a better way to communicate with blocksuite editor
// unsubs.push(
@@ -189,6 +204,20 @@ export function useRegisterBlocksuiteEditorCommands(
})
);
if (runtimeConfig.enablePageHistory && isCloudWorkspace) {
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-page-history`,
category: `editor:${mode}`,
icon: <HistoryIcon />,
label: t['com.affine.cmdk.affine.editor.reveal-page-history-modal'](),
run() {
openHistoryModal();
},
})
);
}
return () => {
unsubs.forEach(unsub => unsub());
};
@@ -198,11 +227,12 @@ export function useRegisterBlocksuiteEditorCommands(
onClickDelete,
exportHandler,
pageId,
pageMeta.title,
restoreFromTrash,
t,
toggleFavorite,
togglePageMode,
trash,
isCloudWorkspace,
openHistoryModal,
]);
}

View File

@@ -12,7 +12,12 @@ const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => {
export const useServerFlavor = () => {
const { data: config, error } = useQuery(
{ query: serverConfigQuery },
{ use: [errorHandler] }
{
use: [errorHandler],
revalidateOnFocus: false,
revalidateOnMount: false,
revalidateIfStale: false,
}
);
if (error || !config) {

View File

@@ -4,7 +4,7 @@ import {
} from '@toeverything/infra/command';
import { useEffect } from 'react';
export function useRegisterBlocksuiteEditorCommands(
export function useRegisterBrowserHistoryCommands(
back: () => unknown,
forward: () => unknown
) {

View File

@@ -24,6 +24,7 @@ import { setPageModeAtom } from '../../atoms';
import { collectionsCRUDAtom } from '../../atoms/collections';
import { currentModeAtom } from '../../atoms/mode';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { GlobalPageHistoryModal } from '../../components/affine/page-history-modal';
import { WorkspaceHeader } from '../../components/workspace-header';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
import { useCurrentSyncEngineStatus } from '../../hooks/current/use-current-sync-engine';
@@ -41,7 +42,7 @@ const DetailPageImpl = (): ReactElement => {
const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom);
const mode = useAtomValue(currentModeAtom);
const setPageMode = useSetAtom(setPageModeAtom);
useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode);
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
const onLoad = useCallback(
(page: Page, editor: EditorContainer) => {
try {
@@ -101,6 +102,8 @@ const DetailPageImpl = (): ReactElement => {
currentPageId={currentPageId}
onLoadEditor={onLoad}
/>
<GlobalPageHistoryModal />
</>
);
};

View File

@@ -0,0 +1,13 @@
query listHistory(
$workspaceId: String!
$pageDocId: String!
$take: Int
$before: DateTime
) {
workspace(id: $workspaceId) {
histories(guid: $pageDocId, take: $take, before: $before) {
id
timestamp
}
}
}

View File

@@ -362,6 +362,22 @@ query getWorkspaces {
}`,
};
export const listHistoryQuery = {
id: 'listHistoryQuery' as const,
operationName: 'listHistory',
definitionName: 'workspace',
containsFile: false,
query: `
query listHistory($workspaceId: String!, $pageDocId: String!, $take: Int, $before: DateTime) {
workspace(id: $workspaceId) {
histories(guid: $pageDocId, take: $take, before: $before) {
id
timestamp
}
}
}`,
};
export const getInvoicesCountQuery = {
id: 'getInvoicesCountQuery' as const,
operationName: 'getInvoicesCount',
@@ -445,6 +461,17 @@ mutation publishPage($workspaceId: String!, $pageId: String!, $mode: PublicPageM
}`,
};
export const recoverDocMutation = {
id: 'recoverDocMutation' as const,
operationName: 'recoverDoc',
definitionName: 'recoverDoc',
containsFile: false,
query: `
mutation recoverDoc($workspaceId: String!, $docId: String!, $timestamp: DateTime!) {
recoverDoc(workspaceId: $workspaceId, guid: $docId, timestamp: $timestamp)
}`,
};
export const removeAvatarMutation = {
id: 'removeAvatarMutation' as const,
operationName: 'removeAvatar',

View File

@@ -0,0 +1,7 @@
mutation recoverDoc(
$workspaceId: String!
$docId: String!
$timestamp: DateTime!
) {
recoverDoc(workspaceId: $workspaceId, guid: $docId, timestamp: $timestamp)
}

View File

@@ -373,6 +373,25 @@ export type GetWorkspacesQuery = {
workspaces: Array<{ __typename?: 'WorkspaceType'; id: string }>;
};
export type ListHistoryQueryVariables = Exact<{
workspaceId: Scalars['String']['input'];
pageDocId: Scalars['String']['input'];
take: InputMaybe<Scalars['Int']['input']>;
before: InputMaybe<Scalars['DateTime']['input']>;
}>;
export type ListHistoryQuery = {
__typename?: 'Query';
workspace: {
__typename?: 'WorkspaceType';
histories: Array<{
__typename?: 'DocHistoryType';
id: string;
timestamp: string;
}>;
};
};
export type GetInvoicesCountQueryVariables = Exact<{ [key: string]: never }>;
export type GetInvoicesCountQuery = {
@@ -445,6 +464,17 @@ export type PublishPageMutation = {
};
};
export type RecoverDocMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
docId: Scalars['String']['input'];
timestamp: Scalars['DateTime']['input'];
}>;
export type RecoverDocMutation = {
__typename?: 'Mutation';
recoverDoc: string;
};
export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>;
export type RemoveAvatarMutation = {
@@ -729,6 +759,11 @@ export type Queries =
variables: GetWorkspacesQueryVariables;
response: GetWorkspacesQuery;
}
| {
name: 'listHistoryQuery';
variables: ListHistoryQueryVariables;
response: ListHistoryQuery;
}
| {
name: 'getInvoicesCountQuery';
variables: GetInvoicesCountQueryVariables;
@@ -816,6 +851,11 @@ export type Mutations =
variables: PublishPageMutationVariables;
response: PublishPageMutation;
}
| {
name: 'recoverDocMutation';
variables: RecoverDocMutationVariables;
response: RecoverDocMutation;
}
| {
name: 'removeAvatarMutation';
variables: RemoveAvatarMutationVariables;

View File

@@ -518,6 +518,7 @@
"com.affine.cmdk.affine.switch-state.on": "ON",
"com.affine.cmdk.affine.translucent-ui-on-the-sidebar.to": "Change Translucent UI On The Sidebar to",
"com.affine.cmdk.affine.whats-new": "What's New",
"com.affine.cmdk.affine.editor.reveal-page-history-modal": "Reveal Page History Modal",
"com.affine.cmdk.placeholder": "Type a command or search anything...",
"com.affine.collection-bar.action.tooltip.delete": "Delete",
"com.affine.collection-bar.action.tooltip.edit": "Edit",
@@ -972,5 +973,13 @@
"system": "System",
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
"will be moved to Trash": "{{title}} will be moved to Trash",
"will delete member": "will delete member"
"will delete member": "will delete member",
"com.affine.history.version-history": "Version History",
"com.affine.history.view-history-version": "View History Version",
"com.affine.history.restore-current-version": "Restore current version",
"com.affine.history.back-to-page": "Back to page",
"com.affine.history.empty-prompt.title": "Empty",
"com.affine.history.empty-prompt.description": "This document is such a spring chicken, it hasn't sprouted a single historical sprig yet!",
"com.affine.history.confirm-restore-modal.restore": "Restore",
"com.affine.history.confirm-restore-modal.hint": "You are about to restore the current version of the page to the latest version available. This action will overwrite any changes made prior to the latest version."
}

View File

@@ -4,6 +4,7 @@
"exports": {
"./atom": "./src/atom.ts",
"./manager": "./src/manager/index.ts",
"./blob": "./src/blob/index.ts",
"./local/crud": "./src/local/crud.ts",
"./affine/*": "./src/affine/*.ts",
"./providers": "./src/providers/index.ts"

View File

@@ -5,12 +5,15 @@ import type {
QueryOptions,
QueryResponse,
QueryVariables,
RecursiveMaybeFields,
} from '@affine/graphql';
import { gqlFetcherFactory } from '@affine/graphql';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import type { GraphQLError } from 'graphql';
import { useMemo } from 'react';
import type { Key, SWRConfiguration, SWRResponse } from 'swr';
import useSWR from 'swr';
import useSWR, { useSWRConfig } from 'swr';
import useSWRInfinite from 'swr/infinite';
import type {
SWRMutationConfiguration,
SWRMutationResponse,
@@ -86,6 +89,63 @@ export function useQuery<Query extends GraphQLQuery>(
);
}
export function useQueryInfinite<Query extends GraphQLQuery>(
options: Omit<QueryOptions<Query>, 'variables'> & {
getVariables: (
pageIndex: number,
previousPageData: QueryResponse<Query>
) => QueryOptions<Query>['variables'];
},
config?: Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
typeof fetcher<Query>
>,
'fetcher'
>
) {
const configWithSuspense: SWRConfiguration = useMemo(
() => ({
suspense: true,
...config,
}),
[config]
);
const { data, setSize, size, error } = useSWRInfinite<
QueryResponse<Query>,
GraphQLError | GraphQLError[]
>(
(pageIndex: number, previousPageData: QueryResponse<Query>) => [
'cloud',
options.query.id,
options.getVariables(pageIndex, previousPageData),
],
async ([_, __, variables]) => {
const params = { ...options, variables } as QueryOptions<Query>;
return fetcher(params);
},
configWithSuspense
);
const loadingMore = size > 0 && data && !data[size - 1];
// todo: find a generic way to know whether or not there are more items to load
const loadMore = useAsyncCallback(async () => {
if (loadingMore) {
return;
}
await setSize(size => size + 1);
}, [loadingMore, setSize]);
return {
data,
error,
loadingMore,
loadMore,
};
}
/**
* A useSWRMutation wrapper for sending graphql mutations
*
@@ -138,3 +198,32 @@ export function useMutation(
}
export const gql = fetcher;
// use this to revalidate all queries that match the filter
export const useMutateQueryResource = () => {
const { mutate } = useSWRConfig();
const revalidateResource = useMemo(
() =>
<Q extends GraphQLQuery>(
query: Q,
varsFilter: (
vars: RecursiveMaybeFields<QueryVariables<Q>>
) => boolean = _vars => true
) => {
return mutate(key => {
const res =
Array.isArray(key) &&
key[0] === 'cloud' &&
key[1] === query.id &&
varsFilter(key[2]);
if (res) {
console.debug('revalidate resource', key);
}
return res;
});
},
[mutate]
);
return revalidateResource;
};