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,
|
workspaceId,
|
||||||
id,
|
id,
|
||||||
timestamp: {
|
timestamp: {
|
||||||
lte: before,
|
lt: before,
|
||||||
},
|
},
|
||||||
// only include the ones has not expired
|
// only include the ones has not expired
|
||||||
expiredAt: {
|
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(),
|
enableCaptcha: z.boolean(),
|
||||||
enableEnhanceShareMode: z.boolean(),
|
enableEnhanceShareMode: z.boolean(),
|
||||||
enablePayment: z.boolean(),
|
enablePayment: z.boolean(),
|
||||||
|
enablePageHistory: z.boolean(),
|
||||||
// this is for the electron app
|
// this is for the electron app
|
||||||
serverUrlPrefix: z.string(),
|
serverUrlPrefix: z.string(),
|
||||||
enableMoveDatabase: z.boolean(),
|
enableMoveDatabase: z.boolean(),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableCaptcha: true,
|
enableCaptcha: true,
|
||||||
enableEnhanceShareMode: false,
|
enableEnhanceShareMode: false,
|
||||||
enablePayment: true,
|
enablePayment: true,
|
||||||
|
enablePageHistory: false,
|
||||||
serverUrlPrefix: 'https://insider.affine.pro', // Let insider be stable environment temporarily.
|
serverUrlPrefix: 'https://insider.affine.pro', // Let insider be stable environment temporarily.
|
||||||
editorFlags,
|
editorFlags,
|
||||||
appVersion: packageJson.version,
|
appVersion: packageJson.version,
|
||||||
@@ -41,6 +42,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
get beta() {
|
get beta() {
|
||||||
return {
|
return {
|
||||||
...this.stable,
|
...this.stable,
|
||||||
|
enablePageHistory: false,
|
||||||
serverUrlPrefix: 'https://insider.affine.pro',
|
serverUrlPrefix: 'https://insider.affine.pro',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -75,6 +77,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableCaptcha: true,
|
enableCaptcha: true,
|
||||||
enableEnhanceShareMode: false,
|
enableEnhanceShareMode: false,
|
||||||
enablePayment: true,
|
enablePayment: true,
|
||||||
|
enablePageHistory: true,
|
||||||
serverUrlPrefix: 'https://affine.fail',
|
serverUrlPrefix: 'https://affine.fail',
|
||||||
editorFlags,
|
editorFlags,
|
||||||
appVersion: packageJson.version,
|
appVersion: packageJson.version,
|
||||||
@@ -142,6 +145,11 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
: buildFlags.mode === 'development'
|
: buildFlags.mode === 'development'
|
||||||
? true
|
? true
|
||||||
: currentBuildPreset.enablePayment,
|
: currentBuildPreset.enablePayment,
|
||||||
|
enablePageHistory: process.env.ENABLE_PAGE_HISTORY
|
||||||
|
? process.env.ENABLE_PAGE_HISTORY === 'true'
|
||||||
|
: buildFlags.mode === 'development'
|
||||||
|
? true
|
||||||
|
: currentBuildPreset.enablePageHistory,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (buildFlags.mode === 'development') {
|
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 { FlexWrapper } from '@affine/component';
|
||||||
import { Export, MoveToTrash } from '@affine/component/page-list';
|
import { Export, MoveToTrash } from '@affine/component/page-list';
|
||||||
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +9,7 @@ import {
|
|||||||
EditIcon,
|
EditIcon,
|
||||||
FavoritedIcon,
|
FavoritedIcon,
|
||||||
FavoriteIcon,
|
FavoriteIcon,
|
||||||
|
HistoryIcon,
|
||||||
ImportIcon,
|
ImportIcon,
|
||||||
PageIcon,
|
PageIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
} from '@toeverything/hooks/use-block-suite-page-meta';
|
} from '@toeverything/hooks/use-block-suite-page-meta';
|
||||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||||
|
|
||||||
import { setPageModeAtom } from '../../../atoms';
|
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 { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
||||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||||
import { toast } from '../../../utils';
|
import { toast } from '../../../utils';
|
||||||
|
import { PageHistoryModal } from '../../affine/page-history-modal/history-modal';
|
||||||
import { HeaderDropDownButton } from '../../pure/header-drop-down-button';
|
import { HeaderDropDownButton } from '../../pure/header-drop-down-button';
|
||||||
import { usePageHelper } from '../block-suite-page-list/utils';
|
import { usePageHelper } from '../block-suite-page-list/utils';
|
||||||
|
|
||||||
@@ -68,6 +71,12 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
|||||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||||
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
||||||
|
|
||||||
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const openHistoryModal = useCallback(() => {
|
||||||
|
setHistoryModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleOpenTrashModal = useCallback(() => {
|
const handleOpenTrashModal = useCallback(() => {
|
||||||
setTrashModal({
|
setTrashModal({
|
||||||
open: true,
|
open: true,
|
||||||
@@ -207,6 +216,23 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
|||||||
>
|
>
|
||||||
{t['Import']()}
|
{t['Import']()}
|
||||||
</MenuItem>
|
</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} />
|
<Export exportHandler={exportHandler} />
|
||||||
<MenuSeparator />
|
<MenuSeparator />
|
||||||
<MoveToTrash
|
<MoveToTrash
|
||||||
@@ -228,6 +254,14 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
|||||||
>
|
>
|
||||||
<HeaderDropDownButton />
|
<HeaderDropDownButton />
|
||||||
</Menu>
|
</Menu>
|
||||||
|
{workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
|
||||||
|
<PageHistoryModal
|
||||||
|
workspace={workspace.blockSuiteWorkspace}
|
||||||
|
open={historyModalOpen}
|
||||||
|
pageId={pageId}
|
||||||
|
onOpenChange={setHistoryModalOpen}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</FlexWrapper>
|
</FlexWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
|
|||||||
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
|
import { useDeleteCollectionInfo } from '../../hooks/affine/use-delete-collection-info';
|
||||||
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
|
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
|
||||||
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
|
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 { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||||
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/use-shortcut-commands';
|
|
||||||
import type { AllWorkspace } from '../../shared';
|
import type { AllWorkspace } from '../../shared';
|
||||||
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
import { CollectionsList } from '../pure/workspace-slider-bar/collections';
|
||||||
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
import { AddCollectionButton } from '../pure/workspace-slider-bar/collections/add-collection-button';
|
||||||
@@ -169,7 +169,7 @@ export const RootAppSidebar = ({
|
|||||||
const closeUserWorkspaceList = useCallback(() => {
|
const closeUserWorkspaceList = useCallback(() => {
|
||||||
setOpenUserWorkspaceList(false);
|
setOpenUserWorkspaceList(false);
|
||||||
}, [setOpenUserWorkspaceList]);
|
}, [setOpenUserWorkspaceList]);
|
||||||
useRegisterBlocksuiteEditorCommands(router.back, router.forward);
|
useRegisterBrowserHistoryCommands(router.back, router.forward);
|
||||||
const userInfo = useDeleteCollectionInfo();
|
const userInfo = useDeleteCollectionInfo();
|
||||||
return (
|
return (
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSWRConfig } from 'swr';
|
|||||||
export function useMutateCloud() {
|
export function useMutateCloud() {
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
return useCallback(async () => {
|
return useCallback(async () => {
|
||||||
|
// todo: should not mutate all graphql cache
|
||||||
return mutate(key => {
|
return mutate(key => {
|
||||||
if (Array.isArray(key)) {
|
if (Array.isArray(key)) {
|
||||||
return key[0] === 'cloud';
|
return key[0] === 'cloud';
|
||||||
|
|||||||
@@ -1,25 +1,29 @@
|
|||||||
import { toast } from '@affine/component';
|
import { toast } from '@affine/component';
|
||||||
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons';
|
||||||
import type { Workspace } from '@blocksuite/store';
|
|
||||||
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
|
import { usePageMetaHelper } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||||
import {
|
import {
|
||||||
PreconditionStrategy,
|
PreconditionStrategy,
|
||||||
registerAffineCommand,
|
registerAffineCommand,
|
||||||
} from '@toeverything/infra/command';
|
} from '@toeverything/infra/command';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { useCallback, useEffect } from 'react';
|
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 { useBlockSuiteMetaHelper } from './use-block-suite-meta-helper';
|
||||||
import { useExportPage } from './use-export-page';
|
import { useExportPage } from './use-export-page';
|
||||||
import { useTrashModalHelper } from './use-trash-modal-helper';
|
import { useTrashModalHelper } from './use-trash-modal-helper';
|
||||||
|
|
||||||
export function useRegisterBlocksuiteEditorCommands(
|
export function useRegisterBlocksuiteEditorCommands(
|
||||||
blockSuiteWorkspace: Workspace,
|
|
||||||
pageId: string,
|
pageId: string,
|
||||||
mode: 'page' | 'edgeless'
|
mode: 'page' | 'edgeless'
|
||||||
) {
|
) {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
const [workspace] = useCurrentWorkspace();
|
||||||
|
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
|
||||||
const { getPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
const { getPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
|
||||||
const currentPage = blockSuiteWorkspace.getPage(pageId);
|
const currentPage = blockSuiteWorkspace.getPage(pageId);
|
||||||
assertExists(currentPage);
|
assertExists(currentPage);
|
||||||
@@ -28,6 +32,15 @@ export function useRegisterBlocksuiteEditorCommands(
|
|||||||
const favorite = pageMeta.favorite ?? false;
|
const favorite = pageMeta.favorite ?? false;
|
||||||
const trash = pageMeta.trash ?? false;
|
const trash = pageMeta.trash ?? false;
|
||||||
|
|
||||||
|
const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom);
|
||||||
|
|
||||||
|
const openHistoryModal = useCallback(() => {
|
||||||
|
setPageHistoryModalState(() => ({
|
||||||
|
pageId,
|
||||||
|
open: true,
|
||||||
|
}));
|
||||||
|
}, [pageId, setPageHistoryModalState]);
|
||||||
|
|
||||||
const { togglePageMode, toggleFavorite, restoreFromTrash } =
|
const { togglePageMode, toggleFavorite, restoreFromTrash } =
|
||||||
useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||||
const exportHandler = useExportPage(currentPage);
|
const exportHandler = useExportPage(currentPage);
|
||||||
@@ -40,12 +53,14 @@ export function useRegisterBlocksuiteEditorCommands(
|
|||||||
});
|
});
|
||||||
}, [pageId, pageMeta.title, setTrashModal]);
|
}, [pageId, pageMeta.title, setTrashModal]);
|
||||||
|
|
||||||
|
const isCloudWorkspace = workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubs: Array<() => void> = [];
|
const unsubs: Array<() => void> = [];
|
||||||
const preconditionStrategy = () =>
|
const preconditionStrategy = () =>
|
||||||
PreconditionStrategy.InPaperOrEdgeless && !trash;
|
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
|
// this is pretty hack and easy to break. need a better way to communicate with blocksuite editor
|
||||||
// unsubs.push(
|
// 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 () => {
|
return () => {
|
||||||
unsubs.forEach(unsub => unsub());
|
unsubs.forEach(unsub => unsub());
|
||||||
};
|
};
|
||||||
@@ -198,11 +227,12 @@ export function useRegisterBlocksuiteEditorCommands(
|
|||||||
onClickDelete,
|
onClickDelete,
|
||||||
exportHandler,
|
exportHandler,
|
||||||
pageId,
|
pageId,
|
||||||
pageMeta.title,
|
|
||||||
restoreFromTrash,
|
restoreFromTrash,
|
||||||
t,
|
t,
|
||||||
toggleFavorite,
|
toggleFavorite,
|
||||||
togglePageMode,
|
togglePageMode,
|
||||||
trash,
|
trash,
|
||||||
|
isCloudWorkspace,
|
||||||
|
openHistoryModal,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => {
|
|||||||
export const useServerFlavor = () => {
|
export const useServerFlavor = () => {
|
||||||
const { data: config, error } = useQuery(
|
const { data: config, error } = useQuery(
|
||||||
{ query: serverConfigQuery },
|
{ query: serverConfigQuery },
|
||||||
{ use: [errorHandler] }
|
{
|
||||||
|
use: [errorHandler],
|
||||||
|
revalidateOnFocus: false,
|
||||||
|
revalidateOnMount: false,
|
||||||
|
revalidateIfStale: false,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (error || !config) {
|
if (error || !config) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from '@toeverything/infra/command';
|
} from '@toeverything/infra/command';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export function useRegisterBlocksuiteEditorCommands(
|
export function useRegisterBrowserHistoryCommands(
|
||||||
back: () => unknown,
|
back: () => unknown,
|
||||||
forward: () => unknown
|
forward: () => unknown
|
||||||
) {
|
) {
|
||||||
@@ -24,6 +24,7 @@ import { setPageModeAtom } from '../../atoms';
|
|||||||
import { collectionsCRUDAtom } from '../../atoms/collections';
|
import { collectionsCRUDAtom } from '../../atoms/collections';
|
||||||
import { currentModeAtom } from '../../atoms/mode';
|
import { currentModeAtom } from '../../atoms/mode';
|
||||||
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
||||||
|
import { GlobalPageHistoryModal } from '../../components/affine/page-history-modal';
|
||||||
import { WorkspaceHeader } from '../../components/workspace-header';
|
import { WorkspaceHeader } from '../../components/workspace-header';
|
||||||
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
|
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/affine/use-register-blocksuite-editor-commands';
|
||||||
import { useCurrentSyncEngineStatus } from '../../hooks/current/use-current-sync-engine';
|
import { useCurrentSyncEngineStatus } from '../../hooks/current/use-current-sync-engine';
|
||||||
@@ -41,7 +42,7 @@ const DetailPageImpl = (): ReactElement => {
|
|||||||
const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom);
|
const { setTemporaryFilter } = useCollectionManager(collectionsCRUDAtom);
|
||||||
const mode = useAtomValue(currentModeAtom);
|
const mode = useAtomValue(currentModeAtom);
|
||||||
const setPageMode = useSetAtom(setPageModeAtom);
|
const setPageMode = useSetAtom(setPageModeAtom);
|
||||||
useRegisterBlocksuiteEditorCommands(blockSuiteWorkspace, currentPageId, mode);
|
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
|
||||||
const onLoad = useCallback(
|
const onLoad = useCallback(
|
||||||
(page: Page, editor: EditorContainer) => {
|
(page: Page, editor: EditorContainer) => {
|
||||||
try {
|
try {
|
||||||
@@ -101,6 +102,8 @@ const DetailPageImpl = (): ReactElement => {
|
|||||||
currentPageId={currentPageId}
|
currentPageId={currentPageId}
|
||||||
onLoadEditor={onLoad}
|
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 = {
|
export const getInvoicesCountQuery = {
|
||||||
id: 'getInvoicesCountQuery' as const,
|
id: 'getInvoicesCountQuery' as const,
|
||||||
operationName: 'getInvoicesCount',
|
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 = {
|
export const removeAvatarMutation = {
|
||||||
id: 'removeAvatarMutation' as const,
|
id: 'removeAvatarMutation' as const,
|
||||||
operationName: 'removeAvatar',
|
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 }>;
|
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 GetInvoicesCountQueryVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type GetInvoicesCountQuery = {
|
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 RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>;
|
||||||
|
|
||||||
export type RemoveAvatarMutation = {
|
export type RemoveAvatarMutation = {
|
||||||
@@ -729,6 +759,11 @@ export type Queries =
|
|||||||
variables: GetWorkspacesQueryVariables;
|
variables: GetWorkspacesQueryVariables;
|
||||||
response: GetWorkspacesQuery;
|
response: GetWorkspacesQuery;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'listHistoryQuery';
|
||||||
|
variables: ListHistoryQueryVariables;
|
||||||
|
response: ListHistoryQuery;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'getInvoicesCountQuery';
|
name: 'getInvoicesCountQuery';
|
||||||
variables: GetInvoicesCountQueryVariables;
|
variables: GetInvoicesCountQueryVariables;
|
||||||
@@ -816,6 +851,11 @@ export type Mutations =
|
|||||||
variables: PublishPageMutationVariables;
|
variables: PublishPageMutationVariables;
|
||||||
response: PublishPageMutation;
|
response: PublishPageMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'recoverDocMutation';
|
||||||
|
variables: RecoverDocMutationVariables;
|
||||||
|
response: RecoverDocMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'removeAvatarMutation';
|
name: 'removeAvatarMutation';
|
||||||
variables: RemoveAvatarMutationVariables;
|
variables: RemoveAvatarMutationVariables;
|
||||||
|
|||||||
@@ -518,6 +518,7 @@
|
|||||||
"com.affine.cmdk.affine.switch-state.on": "ON",
|
"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.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.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.cmdk.placeholder": "Type a command or search anything...",
|
||||||
"com.affine.collection-bar.action.tooltip.delete": "Delete",
|
"com.affine.collection-bar.action.tooltip.delete": "Delete",
|
||||||
"com.affine.collection-bar.action.tooltip.edit": "Edit",
|
"com.affine.collection-bar.action.tooltip.edit": "Edit",
|
||||||
@@ -972,5 +973,13 @@
|
|||||||
"system": "System",
|
"system": "System",
|
||||||
"upgradeBrowser": "Please upgrade to the latest version of Chrome for the best experience.",
|
"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 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": {
|
"exports": {
|
||||||
"./atom": "./src/atom.ts",
|
"./atom": "./src/atom.ts",
|
||||||
"./manager": "./src/manager/index.ts",
|
"./manager": "./src/manager/index.ts",
|
||||||
|
"./blob": "./src/blob/index.ts",
|
||||||
"./local/crud": "./src/local/crud.ts",
|
"./local/crud": "./src/local/crud.ts",
|
||||||
"./affine/*": "./src/affine/*.ts",
|
"./affine/*": "./src/affine/*.ts",
|
||||||
"./providers": "./src/providers/index.ts"
|
"./providers": "./src/providers/index.ts"
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import type {
|
|||||||
QueryOptions,
|
QueryOptions,
|
||||||
QueryResponse,
|
QueryResponse,
|
||||||
QueryVariables,
|
QueryVariables,
|
||||||
|
RecursiveMaybeFields,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { gqlFetcherFactory } from '@affine/graphql';
|
import { gqlFetcherFactory } from '@affine/graphql';
|
||||||
|
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||||
import type { GraphQLError } from 'graphql';
|
import type { GraphQLError } from 'graphql';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { Key, SWRConfiguration, SWRResponse } from 'swr';
|
import type { Key, SWRConfiguration, SWRResponse } from 'swr';
|
||||||
import useSWR from 'swr';
|
import useSWR, { useSWRConfig } from 'swr';
|
||||||
|
import useSWRInfinite from 'swr/infinite';
|
||||||
import type {
|
import type {
|
||||||
SWRMutationConfiguration,
|
SWRMutationConfiguration,
|
||||||
SWRMutationResponse,
|
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
|
* A useSWRMutation wrapper for sending graphql mutations
|
||||||
*
|
*
|
||||||
@@ -138,3 +198,32 @@ export function useMutation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const gql = fetcher;
|
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