mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
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:
@@ -89,7 +89,7 @@ export class DocHistoryManager {
|
||||
workspaceId,
|
||||
id,
|
||||
timestamp: {
|
||||
lte: before,
|
||||
lt: before,
|
||||
},
|
||||
// only include the ones has not expired
|
||||
expiredAt: {
|
||||
|
||||
1
packages/common/env/src/global.ts
vendored
1
packages/common/env/src/global.ts
vendored
@@ -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(),
|
||||
|
||||
@@ -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') {
|
||||
|
||||
7
packages/frontend/core/src/atoms/page-history.ts
Normal file
7
packages/frontend/core/src/atoms/page-history.ts
Normal 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: '',
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './history-modal';
|
||||
@@ -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)',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from '@toeverything/infra/command';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function useRegisterBlocksuiteEditorCommands(
|
||||
export function useRegisterBrowserHistoryCommands(
|
||||
back: () => unknown,
|
||||
forward: () => unknown
|
||||
) {
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
13
packages/frontend/graphql/src/graphql/histories.gql
Normal file
13
packages/frontend/graphql/src/graphql/histories.gql
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
7
packages/frontend/graphql/src/graphql/recover-doc.gql
Normal file
7
packages/frontend/graphql/src/graphql/recover-doc.gql
Normal file
@@ -0,0 +1,7 @@
|
||||
mutation recoverDoc(
|
||||
$workspaceId: String!
|
||||
$docId: String!
|
||||
$timestamp: DateTime!
|
||||
) {
|
||||
recoverDoc(workspaceId: $workspaceId, guid: $docId, timestamp: $timestamp)
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user