mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-11 20:08:37 +00:00
Compare commits
15 Commits
v0.10.0-ca
...
v0.10.0-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb0aaabe53 | ||
|
|
de33967a73 | ||
|
|
65321e39cc | ||
|
|
a3906bf92b | ||
|
|
0a88be7771 | ||
|
|
7068d5f38a | ||
|
|
bf17b4789b | ||
|
|
5e9efbffa3 | ||
|
|
7e516236f5 | ||
|
|
15024c6c8a | ||
|
|
6a93203d68 | ||
|
|
af9663d3e7 | ||
|
|
1d7e3dd570 | ||
|
|
6ef02fbc38 | ||
|
|
604c3da9fe |
@@ -43,7 +43,7 @@ Please follow the official guide at https://www.rust-lang.org/tools/install.
|
||||
|
||||
### Setup Node.js Environment
|
||||
|
||||
This setup requires modern yarn (currently `3.x`), run this if your yarn version is `1.x`
|
||||
This setup requires modern yarn (currently `4.x`), run this if your yarn version is `1.x`
|
||||
|
||||
Reference: [Yarn installation doc](https://yarnpkg.com/getting-started/install)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { Counter, Gauge, Summary } from 'prom-client';
|
||||
import { Counter, Gauge, register, Summary } from 'prom-client';
|
||||
|
||||
function getOr<T>(name: string, or: () => T): T {
|
||||
return (register.getSingleMetric(name) as T) || or();
|
||||
}
|
||||
|
||||
type LabelValues<T extends string> = Partial<Record<T, string | number>>;
|
||||
type MetricsCreator<T extends string> = (
|
||||
@@ -14,11 +18,15 @@ export const metricsCreatorGenerator = () => {
|
||||
name: string,
|
||||
labelNames?: T[]
|
||||
): MetricsCreator<T> => {
|
||||
const counter = new Counter({
|
||||
const counter = getOr(
|
||||
name,
|
||||
help: name,
|
||||
...(labelNames ? { labelNames } : {}),
|
||||
});
|
||||
() =>
|
||||
new Counter({
|
||||
name,
|
||||
help: name,
|
||||
...(labelNames ? { labelNames } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
return (value: number, labels: LabelValues<T>) => {
|
||||
counter.inc(labels, value);
|
||||
@@ -29,11 +37,15 @@ export const metricsCreatorGenerator = () => {
|
||||
name: string,
|
||||
labelNames?: T[]
|
||||
): MetricsCreator<T> => {
|
||||
const gauge = new Gauge({
|
||||
const gauge = getOr(
|
||||
name,
|
||||
help: name,
|
||||
...(labelNames ? { labelNames } : {}),
|
||||
});
|
||||
() =>
|
||||
new Gauge({
|
||||
name,
|
||||
help: name,
|
||||
...(labelNames ? { labelNames } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
return (value: number, labels: LabelValues<T>) => {
|
||||
gauge.set(labels, value);
|
||||
@@ -44,11 +56,15 @@ export const metricsCreatorGenerator = () => {
|
||||
name: string,
|
||||
labelNames?: T[]
|
||||
): TimerMetricsCreator<T> => {
|
||||
const summary = new Summary({
|
||||
const summary = getOr(
|
||||
name,
|
||||
help: name,
|
||||
...(labelNames ? { labelNames } : {}),
|
||||
});
|
||||
() =>
|
||||
new Summary({
|
||||
name,
|
||||
help: name,
|
||||
...(labelNames ? { labelNames } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
return (labels: LabelValues<T>) => {
|
||||
const now = process.hrtime();
|
||||
@@ -71,3 +87,68 @@ export const metricsCreatorGenerator = () => {
|
||||
};
|
||||
|
||||
export const metricsCreator = metricsCreatorGenerator();
|
||||
|
||||
export const CallTimer = (
|
||||
name: string,
|
||||
labels: Record<string, any> = {}
|
||||
): MethodDecorator => {
|
||||
const timer = metricsCreator.timer(name, Object.keys(labels));
|
||||
|
||||
// @ts-expect-error allow
|
||||
return (
|
||||
_target,
|
||||
_key,
|
||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
||||
) => {
|
||||
const originalMethod = desc.value;
|
||||
if (!originalMethod) {
|
||||
return desc;
|
||||
}
|
||||
|
||||
desc.value = function (...args: any[]) {
|
||||
const endTimer = timer(labels);
|
||||
let result: any;
|
||||
try {
|
||||
result = originalMethod.apply(this, args);
|
||||
} catch (e) {
|
||||
endTimer();
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.finally(endTimer);
|
||||
} else {
|
||||
endTimer();
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
return desc;
|
||||
};
|
||||
};
|
||||
|
||||
export const CallCounter = (
|
||||
name: string,
|
||||
labels: Record<string, any> = {}
|
||||
): MethodDecorator => {
|
||||
const count = metricsCreator.counter(name, Object.keys(labels));
|
||||
|
||||
// @ts-expect-error allow
|
||||
return (
|
||||
_target,
|
||||
_key,
|
||||
desc: TypedPropertyDescriptor<(...args: any[]) => any>
|
||||
) => {
|
||||
const originalMethod = desc.value;
|
||||
if (!originalMethod) {
|
||||
return desc;
|
||||
}
|
||||
|
||||
desc.value = function (...args: any[]) {
|
||||
count(1, labels);
|
||||
return originalMethod.apply(this, args);
|
||||
};
|
||||
|
||||
return desc;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { Snapshot, Update } from '@prisma/client';
|
||||
import { chunk } from 'lodash-es';
|
||||
import { defer, retry } from 'rxjs';
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
|
||||
@@ -89,10 +90,10 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
protected applyUpdates(guid: string, ...updates: Buffer[]): Doc {
|
||||
const doc = this.recoverDoc(...updates);
|
||||
this.metrics.jwstCodecMerge(1, {});
|
||||
|
||||
// test jwst codec
|
||||
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
|
||||
this.metrics.jwstCodecMerge(1, {});
|
||||
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
||||
let log = false;
|
||||
try {
|
||||
@@ -163,7 +164,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
/**
|
||||
* add update to manager for later processing.
|
||||
*/
|
||||
async push(workspaceId: string, guid: string, update: Buffer) {
|
||||
async push(
|
||||
workspaceId: string,
|
||||
guid: string,
|
||||
update: Buffer,
|
||||
retryTimes = 10
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
defer(async () => {
|
||||
const seq = await this.getUpdateSeq(workspaceId, guid);
|
||||
@@ -176,7 +182,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
});
|
||||
})
|
||||
.pipe(retry(MAX_SEQ_NUM)) // retry until seq num not conflict
|
||||
.pipe(retry(retryTimes)) // retry until seq num not conflict
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.logger.verbose(
|
||||
@@ -184,7 +190,54 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
error: reject,
|
||||
error: e => {
|
||||
this.logger.error('Failed to push updates', e);
|
||||
reject(new Error('Failed to push update'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async batchPush(
|
||||
workspaceId: string,
|
||||
guid: string,
|
||||
updates: Buffer[],
|
||||
retryTimes = 10
|
||||
) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
defer(async () => {
|
||||
const seq = await this.getUpdateSeq(workspaceId, guid, updates.length);
|
||||
let turn = 0;
|
||||
const batchCount = 10;
|
||||
for (const batch of chunk(updates, batchCount)) {
|
||||
await this.db.update.createMany({
|
||||
data: batch.map((update, i) => ({
|
||||
workspaceId,
|
||||
id: guid,
|
||||
// `seq` is the last seq num of the batch
|
||||
// example for 11 batched updates, start from seq num 20
|
||||
// seq for first update in the batch should be:
|
||||
// 31 - 11 + 0 * 10 + 0 + 1 = 21
|
||||
// ^ last seq num ^ updates.length ^ turn ^ batchCount ^i
|
||||
seq: seq - updates.length + turn * batchCount + i + 1,
|
||||
blob: update,
|
||||
})),
|
||||
});
|
||||
turn++;
|
||||
}
|
||||
})
|
||||
.pipe(retry(retryTimes)) // retry until seq num not conflict
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.logger.verbose(
|
||||
`pushed updates for workspace: ${workspaceId}, guid: ${guid}`
|
||||
);
|
||||
resolve();
|
||||
},
|
||||
error: e => {
|
||||
this.logger.error('Failed to push updates', e);
|
||||
reject(new Error('Failed to push update'));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -370,7 +423,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
return doc;
|
||||
}
|
||||
|
||||
private async getUpdateSeq(workspaceId: string, guid: string) {
|
||||
private async getUpdateSeq(workspaceId: string, guid: string, batch = 1) {
|
||||
try {
|
||||
const { seq } = await this.db.snapshot.update({
|
||||
select: {
|
||||
@@ -384,13 +437,13 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
},
|
||||
data: {
|
||||
seq: {
|
||||
increment: 1,
|
||||
increment: batch,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// reset
|
||||
if (seq === MAX_SEQ_NUM) {
|
||||
if (seq >= MAX_SEQ_NUM) {
|
||||
await this.db.snapshot.update({
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
@@ -406,9 +459,10 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
return seq;
|
||||
} catch {
|
||||
// not existing snapshot just count it from 1
|
||||
const last = this.seqMap.get(workspaceId + guid) ?? 0;
|
||||
this.seqMap.set(workspaceId + guid, last + 1);
|
||||
return last + 1;
|
||||
this.seqMap.set(workspaceId + guid, last + batch);
|
||||
return last + batch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,6 +270,7 @@ export class UserSubscriptionResolver {
|
||||
return this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: SubscriptionStatus.Active,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
81
packages/backend/server/src/modules/sync/events/error.ts
Normal file
81
packages/backend/server/src/modules/sync/events/error.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
enum EventErrorCode {
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
DOC_NOT_FOUND = 'DOC_NOT_FOUND',
|
||||
NOT_IN_WORKSPACE = 'NOT_IN_WORKSPACE',
|
||||
ACCESS_DENIED = 'ACCESS_DENIED',
|
||||
INTERNAL = 'INTERNAL',
|
||||
VERSION_REJECTED = 'VERSION_REJECTED',
|
||||
}
|
||||
|
||||
// Such errore are generally raised from the gateway handling to user,
|
||||
// the stack must be full of internal code,
|
||||
// so there is no need to inherit from `Error` class.
|
||||
export class EventError {
|
||||
constructor(
|
||||
public readonly code: EventErrorCode,
|
||||
public readonly message: string
|
||||
) {}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkspaceNotFoundError extends EventError {
|
||||
constructor(public readonly workspaceId: string) {
|
||||
super(
|
||||
EventErrorCode.WORKSPACE_NOT_FOUND,
|
||||
`You are trying to access an unknown workspace ${workspaceId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class DocNotFoundError extends EventError {
|
||||
constructor(
|
||||
public readonly workspaceId: string,
|
||||
public readonly docId: string
|
||||
) {
|
||||
super(
|
||||
EventErrorCode.DOC_NOT_FOUND,
|
||||
`You are trying to access an unknown doc ${docId} under workspace ${workspaceId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotInWorkspaceError extends EventError {
|
||||
constructor(public readonly workspaceId: string) {
|
||||
super(
|
||||
EventErrorCode.NOT_IN_WORKSPACE,
|
||||
`You should join in workspace ${workspaceId} before broadcasting messages.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class AccessDeniedError extends EventError {
|
||||
constructor(public readonly workspaceId: string) {
|
||||
super(
|
||||
EventErrorCode.ACCESS_DENIED,
|
||||
`You have no permission to access workspace ${workspaceId}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends EventError {
|
||||
constructor(public readonly error: Error) {
|
||||
super(EventErrorCode.INTERNAL, `Internal error happened: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export class VersionRejectedError extends EventError {
|
||||
constructor(public readonly version: number) {
|
||||
super(
|
||||
EventErrorCode.VERSION_REJECTED,
|
||||
// TODO: Too general error message,
|
||||
// need to be more specific when versioning system is implemented.
|
||||
`The version ${version} is rejected by server.`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { applyDecorators, Logger } from '@nestjs/common';
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
SubscribeMessage,
|
||||
SubscribeMessage as RawSubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
@@ -12,12 +12,40 @@ import { Server, Socket } from 'socket.io';
|
||||
import { encodeStateAsUpdate, encodeStateVector } from 'yjs';
|
||||
|
||||
import { Metrics } from '../../../metrics/metrics';
|
||||
import { CallCounter, CallTimer } from '../../../metrics/utils';
|
||||
import { DocID } from '../../../utils/doc';
|
||||
import { Auth, CurrentUser } from '../../auth';
|
||||
import { DocManager } from '../../doc';
|
||||
import { UserType } from '../../users';
|
||||
import { PermissionService } from '../../workspaces/permission';
|
||||
import { Permission } from '../../workspaces/types';
|
||||
import {
|
||||
AccessDeniedError,
|
||||
DocNotFoundError,
|
||||
EventError,
|
||||
InternalError,
|
||||
NotInWorkspaceError,
|
||||
WorkspaceNotFoundError,
|
||||
} from './error';
|
||||
|
||||
const SubscribeMessage = (event: string) =>
|
||||
applyDecorators(
|
||||
CallCounter('socket_io_counter', { event }),
|
||||
CallTimer('socket_io_timer', { event }),
|
||||
RawSubscribeMessage(event)
|
||||
);
|
||||
|
||||
type EventResponse<Data = any> =
|
||||
| {
|
||||
error: EventError;
|
||||
}
|
||||
| (Data extends never
|
||||
? {
|
||||
data?: never;
|
||||
}
|
||||
: {
|
||||
data: Data;
|
||||
});
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: process.env.NODE_ENV !== 'production',
|
||||
@@ -52,38 +80,50 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'client-handshake' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'client-handshake',
|
||||
});
|
||||
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
const canWrite = await this.permissions.tryCheck(
|
||||
workspaceId,
|
||||
user.id,
|
||||
Permission.Write
|
||||
);
|
||||
if (canWrite) await client.join(workspaceId);
|
||||
|
||||
endTimer();
|
||||
return canWrite;
|
||||
if (canWrite) {
|
||||
await client.join(workspaceId);
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-leave')
|
||||
async handleClientLeave(
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'client-leave' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'client-leave',
|
||||
});
|
||||
await client.leave(workspaceId);
|
||||
endTimer();
|
||||
): Promise<EventResponse> {
|
||||
if (client.rooms.has(workspaceId)) {
|
||||
await client.leave(workspaceId);
|
||||
return {};
|
||||
} else {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the old version of the `client-update` event without any data protocol.
|
||||
* It only exists for backwards compatibility to adapt older clients.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@SubscribeMessage('client-update')
|
||||
async handleClientUpdate(
|
||||
async handleClientUpdateV1(
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
@@ -96,31 +136,37 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
},
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'client-update' });
|
||||
const endTimer = this.metric.socketIOEventTimer({ event: 'client-update' });
|
||||
|
||||
if (!client.rooms.has(workspaceId)) {
|
||||
this.logger.verbose(
|
||||
`Client ${client.id} tried to push update to workspace ${workspaceId} without joining it first`
|
||||
);
|
||||
endTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
|
||||
client
|
||||
.to(docId.workspace)
|
||||
.emit('server-update', { workspaceId, guid, update });
|
||||
|
||||
const buf = Buffer.from(update, 'base64');
|
||||
// broadcast to all clients with newer version that only listen to `server-updates`
|
||||
client
|
||||
.to(docId.workspace)
|
||||
.emit('server-updates', { workspaceId, guid, updates: [update] });
|
||||
|
||||
const buf = Buffer.from(update, 'base64');
|
||||
await this.docManager.push(docId.workspace, docId.guid, buf);
|
||||
endTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the old version of the `doc-load` event without any data protocol.
|
||||
* It only exists for backwards compatibility to adapt older clients.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
@Auth()
|
||||
@SubscribeMessage('doc-load')
|
||||
async loadDoc(
|
||||
async loadDocV1(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody()
|
||||
@@ -134,12 +180,9 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
stateVector?: string;
|
||||
}
|
||||
): Promise<{ missing: string; state?: string } | false> {
|
||||
this.metric.socketIOEventCounter(1, { event: 'doc-load' });
|
||||
const endTimer = this.metric.socketIOEventTimer({ event: 'doc-load' });
|
||||
if (!client.rooms.has(workspaceId)) {
|
||||
const canRead = await this.permissions.tryCheck(workspaceId, user.id);
|
||||
if (!canRead) {
|
||||
endTimer();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -148,7 +191,6 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
||||
|
||||
if (!doc) {
|
||||
endTimer();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -160,53 +202,138 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
).toString('base64');
|
||||
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
|
||||
|
||||
endTimer();
|
||||
return {
|
||||
missing,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('client-update-v2')
|
||||
async handleClientUpdateV2(
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
guid,
|
||||
updates,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
updates: string[];
|
||||
},
|
||||
@ConnectedSocket() client: Socket
|
||||
): Promise<EventResponse<{ accepted: true }>> {
|
||||
if (!client.rooms.has(workspaceId)) {
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
client
|
||||
.to(docId.workspace)
|
||||
.emit('server-updates', { workspaceId, guid, updates });
|
||||
|
||||
const buffers = updates.map(update => Buffer.from(update, 'base64'));
|
||||
|
||||
await this.docManager.batchPush(docId.workspace, docId.guid, buffers);
|
||||
return {
|
||||
data: {
|
||||
accepted: true,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
error: new InternalError(e as Error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@SubscribeMessage('doc-load-v2')
|
||||
async loadDocV2(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@CurrentUser() user: UserType,
|
||||
@MessageBody()
|
||||
{
|
||||
workspaceId,
|
||||
guid,
|
||||
stateVector,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
stateVector?: string;
|
||||
}
|
||||
): Promise<EventResponse<{ missing: string; state?: string }>> {
|
||||
if (!client.rooms.has(workspaceId)) {
|
||||
const canRead = await this.permissions.tryCheck(workspaceId, user.id);
|
||||
if (!canRead) {
|
||||
return {
|
||||
error: new AccessDeniedError(workspaceId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const docId = new DocID(guid, workspaceId);
|
||||
const doc = await this.docManager.get(docId.workspace, docId.guid);
|
||||
|
||||
if (!doc) {
|
||||
return {
|
||||
error: docId.isWorkspace
|
||||
? new WorkspaceNotFoundError(workspaceId)
|
||||
: new DocNotFoundError(workspaceId, docId.guid),
|
||||
};
|
||||
}
|
||||
|
||||
const missing = Buffer.from(
|
||||
encodeStateAsUpdate(
|
||||
doc,
|
||||
stateVector ? Buffer.from(stateVector, 'base64') : undefined
|
||||
)
|
||||
).toString('base64');
|
||||
const state = Buffer.from(encodeStateVector(doc)).toString('base64');
|
||||
|
||||
return {
|
||||
data: {
|
||||
missing,
|
||||
state,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@SubscribeMessage('awareness-init')
|
||||
async handleInitAwareness(
|
||||
@MessageBody() workspaceId: string,
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'awareness-init' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'init-awareness',
|
||||
});
|
||||
): Promise<EventResponse<{ clientId: string }>> {
|
||||
if (client.rooms.has(workspaceId)) {
|
||||
client.to(workspaceId).emit('new-client-awareness-init');
|
||||
return {
|
||||
data: {
|
||||
clientId: client.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
this.logger.verbose(
|
||||
`Client ${client.id} tried to init awareness for workspace ${workspaceId} without joining it first`
|
||||
);
|
||||
return {
|
||||
error: new NotInWorkspaceError(workspaceId),
|
||||
};
|
||||
}
|
||||
endTimer();
|
||||
}
|
||||
|
||||
@SubscribeMessage('awareness-update')
|
||||
async handleHelpGatheringAwareness(
|
||||
@MessageBody() message: { workspaceId: string; awarenessUpdate: string },
|
||||
@ConnectedSocket() client: Socket
|
||||
) {
|
||||
this.metric.socketIOEventCounter(1, { event: 'awareness-update' });
|
||||
const endTimer = this.metric.socketIOEventTimer({
|
||||
event: 'awareness-update',
|
||||
});
|
||||
|
||||
): Promise<EventResponse> {
|
||||
if (client.rooms.has(message.workspaceId)) {
|
||||
client.to(message.workspaceId).emit('server-awareness-broadcast', {
|
||||
...message,
|
||||
});
|
||||
client
|
||||
.to(message.workspaceId)
|
||||
.emit('server-awareness-broadcast', message);
|
||||
return {};
|
||||
} else {
|
||||
this.logger.verbose(
|
||||
`Client ${client.id} tried to update awareness for workspace ${message.workspaceId} without joining it first`
|
||||
);
|
||||
return {
|
||||
error: new NotInWorkspaceError(message.workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
endTimer();
|
||||
return 'ack';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
export enum DocVariant {
|
||||
Workspace = 'workspace',
|
||||
Page = 'page',
|
||||
Space = 'space',
|
||||
Settings = 'settings',
|
||||
Unknown = 'unknown',
|
||||
|
||||
@@ -225,6 +225,31 @@ test('should have sequential update number', async t => {
|
||||
t.not(records.length, 0);
|
||||
});
|
||||
|
||||
test('should have correct sequential update number with batching push', async t => {
|
||||
const manager = m.get(DocManager);
|
||||
const doc = new YDoc();
|
||||
const text = doc.getText('content');
|
||||
const updates: Buffer[] = [];
|
||||
|
||||
doc.on('update', update => {
|
||||
updates.push(Buffer.from(update));
|
||||
});
|
||||
|
||||
text.insert(0, 'hello');
|
||||
text.insert(5, 'world');
|
||||
text.insert(5, ' ');
|
||||
|
||||
await manager.batchPush('2', '2', updates);
|
||||
|
||||
// [1,2,3]
|
||||
const records = await manager.getUpdates('2', '2');
|
||||
|
||||
t.deepEqual(
|
||||
records.map(({ seq }) => seq),
|
||||
[1, 2, 3]
|
||||
);
|
||||
});
|
||||
|
||||
test('should retry if seq num conflict', async t => {
|
||||
const manager = m.get(DocManager);
|
||||
|
||||
@@ -240,3 +265,19 @@ test('should retry if seq num conflict', async t => {
|
||||
|
||||
t.is(stub.callCount, 3);
|
||||
});
|
||||
|
||||
test('should throw if meet max retry times', async t => {
|
||||
const manager = m.get(DocManager);
|
||||
|
||||
// @ts-expect-error private method
|
||||
const stub = Sinon.stub(manager, 'getUpdateSeq');
|
||||
|
||||
stub.resolves(1);
|
||||
await t.notThrowsAsync(() => manager.push('1', '1', Buffer.from([0, 0])));
|
||||
|
||||
await t.throwsAsync(
|
||||
() => manager.push('1', '1', Buffer.from([0, 0]), 3 /* retry 3 times */),
|
||||
{ message: 'Failed to push update' }
|
||||
);
|
||||
t.is(stub.callCount, 5);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/storage",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"@types/debug": "^4.1.9",
|
||||
"vitest": "0.34.6"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
6
packages/common/env/package.json
vendored
6
packages/common/env/package.json
vendored
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"vitest": "0.34.6",
|
||||
@@ -27,5 +27,5 @@
|
||||
"dependencies": {
|
||||
"lit": "^2.8.0"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
3
packages/common/env/src/filter.ts
vendored
3
packages/common/env/src/filter.ts
vendored
@@ -51,11 +51,8 @@ export type Filter = z.input<typeof filterSchema>;
|
||||
export const collectionSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
mode: z.union([z.literal('page'), z.literal('rule')]),
|
||||
filterList: z.array(filterSchema),
|
||||
allowList: z.array(z.string()),
|
||||
// page id list
|
||||
pages: z.array(z.string()),
|
||||
});
|
||||
export const deletedCollectionSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
|
||||
@@ -55,9 +55,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/sdk": "workspace:*",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"jotai": "^2.4.3",
|
||||
"jotai-effect": "^0.1.0",
|
||||
"tinykeys": "^2.1.0",
|
||||
@@ -66,8 +66,8 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/editor": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"async-call-rpc": "^6.3.1",
|
||||
"electron": "link:../../frontend/electron/node_modules/electron",
|
||||
@@ -111,5 +111,5 @@
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/sdk",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
@@ -22,11 +22,11 @@
|
||||
"dist"
|
||||
],
|
||||
"dependencies": {
|
||||
"@blocksuite/block-std": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/block-std": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"jotai": "^2.4.3",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@toeverything/y-indexeddb",
|
||||
"type": "module",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"description": "IndexedDB database adapter for Yjs",
|
||||
"repository": "toeverything/AFFiNE",
|
||||
"author": "toeverything",
|
||||
@@ -37,8 +37,8 @@
|
||||
"y-provider": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"fake-indexeddb": "^5.0.0",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "3.6.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "y-provider",
|
||||
"type": "module",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"description": "Yjs provider protocol for multi document support",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
@@ -24,7 +24,7 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-dts": "3.6.0",
|
||||
"vitest": "0.34.6",
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"foxact": "^0.2.20",
|
||||
"jotai": "^2.4.3",
|
||||
"jotai-effect": "^0.2.2",
|
||||
"jotai-scope": "^0.4.0",
|
||||
"lit": "^2.8.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
@@ -62,16 +64,17 @@
|
||||
"react-is": "^18.2.0",
|
||||
"react-paginate": "^8.2.0",
|
||||
"react-router-dom": "^6.16.0",
|
||||
"react-virtuoso": "^4.6.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/icons": "2.1.35",
|
||||
"@blocksuite/lit": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@storybook/jest": "^0.2.3",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
@@ -87,5 +90,5 @@
|
||||
"vitest": "0.34.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export type EditorProps = {
|
||||
page: Page;
|
||||
mode: 'page' | 'edgeless';
|
||||
onInit: (page: Page, editor: Readonly<EditorContainer>) => void;
|
||||
onModeChange?: (mode: 'page' | 'edgeless') => void;
|
||||
setBlockHub?: (blockHub: BlockHub | null) => void;
|
||||
onLoad?: (page: Page, editor: EditorContainer) => () => void;
|
||||
style?: CSSProperties;
|
||||
@@ -36,7 +37,7 @@ declare global {
|
||||
}
|
||||
|
||||
const BlockSuiteEditorImpl = (props: EditorProps): ReactElement => {
|
||||
const { onLoad, page, mode, style } = props;
|
||||
const { onLoad, onModeChange, page, mode, style } = props;
|
||||
if (!page.loaded) {
|
||||
use(page.waitForLoaded());
|
||||
}
|
||||
@@ -59,17 +60,26 @@ const BlockSuiteEditorImpl = (props: EditorProps): ReactElement => {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.page && onLoad) {
|
||||
const disposes = [] as ((() => void) | undefined)[];
|
||||
disposes.push(onLoad?.(page, editor));
|
||||
return () => {
|
||||
disposes
|
||||
.filter((dispose): dispose is () => void => !!dispose)
|
||||
.forEach(dispose => dispose());
|
||||
};
|
||||
const disposes = [] as ((() => void) | undefined)[];
|
||||
|
||||
if (editor) {
|
||||
const dipose = editor.slots.pageModeSwitched.on(mode => {
|
||||
onModeChange?.(mode);
|
||||
});
|
||||
|
||||
disposes.push(() => dipose.dispose());
|
||||
}
|
||||
return;
|
||||
}, [editor, editor.page, page, onLoad]);
|
||||
|
||||
if (editor.page && onLoad) {
|
||||
disposes.push(onLoad?.(page, editor));
|
||||
}
|
||||
|
||||
return () => {
|
||||
disposes
|
||||
.filter((dispose): dispose is () => void => !!dispose)
|
||||
.forEach(dispose => dispose());
|
||||
};
|
||||
}, [editor, editor.page, page, onLoad, onModeChange]);
|
||||
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './floating-toolbar.css';
|
||||
@@ -16,8 +14,6 @@ interface FloatingToolbarProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
open?: boolean;
|
||||
// if dbclick outside of the panel, close the toolbar
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FloatingToolbarButtonProps {
|
||||
@@ -36,49 +32,7 @@ export function FloatingToolbar({
|
||||
style,
|
||||
className,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PropsWithChildren<FloatingToolbarProps>) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const animatingRef = useRef(false);
|
||||
|
||||
// todo: move dbclick / esc to close to page list instead
|
||||
useEffect(() => {
|
||||
animatingRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
animatingRef.current = false;
|
||||
}, 200);
|
||||
|
||||
if (open) {
|
||||
// when dbclick outside of the panel or typing ESC, close the toolbar
|
||||
const dbcHandler = (e: MouseEvent) => {
|
||||
if (
|
||||
!contentRef.current?.contains(e.target as Node) &&
|
||||
!animatingRef.current
|
||||
) {
|
||||
// close the toolbar
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !animatingRef.current) {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('dblclick', dbcHandler);
|
||||
document.addEventListener('keydown', escHandler);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('dblclick', dbcHandler);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open}>
|
||||
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */}
|
||||
@@ -86,9 +40,7 @@ export function FloatingToolbar({
|
||||
<Popover.Portal>
|
||||
{/* always pop up on top for now */}
|
||||
<Popover.Content side="top" className={styles.popoverContent}>
|
||||
<Toolbar.Root ref={contentRef} className={clsx(styles.root)}>
|
||||
{children}
|
||||
</Toolbar.Root>
|
||||
<Toolbar.Root className={clsx(styles.root)}>{children}</Toolbar.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from './types';
|
||||
export * from './use-collection-manager';
|
||||
export * from './utils';
|
||||
export * from './view';
|
||||
export * from './virtualized-page-list';
|
||||
|
||||
@@ -52,8 +52,9 @@ export const header = style({
|
||||
padding: '0px 16px 0px 6px',
|
||||
gap: 4,
|
||||
height: '28px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
},
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
@@ -6,14 +6,19 @@ import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { PagePreview } from './page-content-preview';
|
||||
import * as styles from './page-group.css';
|
||||
import { PageListItem } from './page-list-item';
|
||||
import { pageListPropsAtom, selectionStateAtom } from './scoped-atoms';
|
||||
import {
|
||||
pageGroupCollapseStateAtom,
|
||||
pageListPropsAtom,
|
||||
selectionStateAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import type {
|
||||
PageGroupDefinition,
|
||||
PageGroupProps,
|
||||
@@ -21,7 +26,7 @@ import type {
|
||||
PageListProps,
|
||||
} from './types';
|
||||
import { type DateKey } from './types';
|
||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||
import { betweenDaysAgo, shallowEqual, withinDaysAgo } from './utils';
|
||||
|
||||
// todo: optimize date matchers
|
||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||
@@ -57,6 +62,7 @@ const pageGroupDefinitions = {
|
||||
createDate: getDateGroupDefinitions('createDate'),
|
||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||
// add more here later
|
||||
// todo: some page group definitions maybe dynamic
|
||||
};
|
||||
|
||||
export function pagesToPageGroups(
|
||||
@@ -101,6 +107,78 @@ export function pagesToPageGroups(
|
||||
return groups;
|
||||
}
|
||||
|
||||
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
|
||||
const collapsed = collapseState[id];
|
||||
const onExpandedClicked: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCollapseState(v => ({ ...v, [id]: !v[id] }));
|
||||
},
|
||||
[id, setCollapseState]
|
||||
);
|
||||
|
||||
const selectionState = useAtomValue(selectionStateAtom);
|
||||
const selectedItems = useMemo(() => {
|
||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||
return items.filter(item => selectedPageIds.includes(item.id));
|
||||
}, [items, selectionState.selectedPageIds]);
|
||||
|
||||
const allSelected = useMemo(() => {
|
||||
return items.every(
|
||||
item => selectionState.selectedPageIds?.includes(item.id)
|
||||
);
|
||||
}, [items, selectionState.selectedPageIds]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const nonCurrentGroupIds =
|
||||
selectionState.selectedPageIds?.filter(
|
||||
id => !items.map(item => item.id).includes(id)
|
||||
) ?? [];
|
||||
|
||||
const newSelectedPageIds = allSelected
|
||||
? nonCurrentGroupIds
|
||||
: [...nonCurrentGroupIds, ...items.map(item => item.id)];
|
||||
|
||||
selectionState.onSelectedPageIdsChange?.(newSelectedPageIds);
|
||||
}, [items, selectionState, allSelected]);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return label ? (
|
||||
<div data-testid="page-list-group-header" className={styles.header}>
|
||||
<div
|
||||
role="button"
|
||||
onClick={onExpandedClicked}
|
||||
data-testid="page-list-group-header-collapsed-button"
|
||||
className={styles.collapsedIconContainer}
|
||||
>
|
||||
<ToggleCollapseIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={!!collapsed}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.headerLabel}>{label}</div>
|
||||
{selectionState.selectionActive ? (
|
||||
<div className={styles.headerCount}>
|
||||
{selectedItems.length}/{items.length}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer} />
|
||||
{selectionState.selectionActive ? (
|
||||
<button className={styles.selectAllButton} onClick={onSelectAll}>
|
||||
{t[
|
||||
allSelected
|
||||
? 'com.affine.page.group-header.clear'
|
||||
: 'com.affine.page.group-header.select-all'
|
||||
]()}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
||||
@@ -173,7 +251,7 @@ export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||
// todo: optimize how to render page meta list item
|
||||
const requiredPropNames = [
|
||||
'blockSuiteWorkspace',
|
||||
'clickMode',
|
||||
'rowAsLink',
|
||||
'isPreferredEdgeless',
|
||||
'pageOperationsRenderer',
|
||||
'selectedPageIds',
|
||||
@@ -185,13 +263,17 @@ type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
|
||||
selectable: boolean;
|
||||
};
|
||||
|
||||
const listPropsAtom = selectAtom(pageListPropsAtom, props => {
|
||||
return Object.fromEntries(
|
||||
requiredPropNames.map(name => [name, props[name]])
|
||||
) as RequiredProps;
|
||||
});
|
||||
const listPropsAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => {
|
||||
return Object.fromEntries(
|
||||
requiredPropNames.map(name => [name, props[name]])
|
||||
) as RequiredProps;
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
||||
export const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
||||
const props = useAtomValue(listPropsAtom);
|
||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||
return (
|
||||
@@ -247,10 +329,10 @@ function pageMetaToPageItemProp(
|
||||
? new Date(pageMeta.updatedDate)
|
||||
: undefined,
|
||||
to:
|
||||
props.clickMode === 'link'
|
||||
props.rowAsLink && !props.selectable
|
||||
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
||||
: undefined,
|
||||
onClick: props.clickMode === 'select' ? toggleSelection : undefined,
|
||||
onClick: props.selectable ? toggleSelection : undefined,
|
||||
icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
|
||||
<EdgelessIcon />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import {
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
|
||||
import * as styles from './page-list.css';
|
||||
import {
|
||||
pageListHandlersAtom,
|
||||
pageListPropsAtom,
|
||||
pagesAtom,
|
||||
selectionStateAtom,
|
||||
showOperationsAtom,
|
||||
sorterAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
||||
|
||||
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
||||
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||
const onClick: MouseEventHandler = useCallback(() => {
|
||||
if (props.sortable && props.sortKey) {
|
||||
setSorter({
|
||||
newSortKey: props.sortKey,
|
||||
});
|
||||
}
|
||||
}, [props.sortKey, props.sortable, setSorter]);
|
||||
|
||||
const sorting = sorter.key === props.sortKey;
|
||||
|
||||
return (
|
||||
<ColWrapper
|
||||
flex={props.flex}
|
||||
alignment={props.alignment}
|
||||
onClick={onClick}
|
||||
className={styles.headerCell}
|
||||
data-sortable={props.sortable ? true : undefined}
|
||||
data-sorting={sorting ? true : undefined}
|
||||
style={props.style}
|
||||
role="columnheader"
|
||||
hideInSmallContainer={props.hideInSmallContainer}
|
||||
>
|
||||
{props.children}
|
||||
{sorting ? (
|
||||
<div className={styles.headerCellSortIcon}>
|
||||
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||
</div>
|
||||
) : null}
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type HeaderColDef = {
|
||||
key: string;
|
||||
content: ReactNode;
|
||||
flex: ColWrapperProps['flex'];
|
||||
alignment?: ColWrapperProps['alignment'];
|
||||
sortable?: boolean;
|
||||
hideInSmallContainer?: boolean;
|
||||
};
|
||||
|
||||
type HeaderCellProps = ColWrapperProps & {
|
||||
sortKey: keyof PageMeta;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
// the checkbox on the header has three states:
|
||||
// when list selectable = true, the checkbox will be presented
|
||||
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
||||
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
||||
const PageListHeaderCheckbox = () => {
|
||||
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||
const pages = useAtomValue(pagesAtom);
|
||||
const onActivateSelection: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
stopPropagation(e);
|
||||
setSelectionState(true);
|
||||
},
|
||||
[setSelectionState]
|
||||
);
|
||||
const handlers = useAtomValue(pageListHandlersAtom);
|
||||
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||
(e, checked) => {
|
||||
stopPropagation(e);
|
||||
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
||||
},
|
||||
[handlers, pages]
|
||||
);
|
||||
|
||||
if (!selectionState.selectable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.headerTitleSelectionIconWrapper}
|
||||
onClick={onActivateSelection}
|
||||
>
|
||||
{!selectionState.selectionActive ? (
|
||||
<MultiSelectIcon />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectionState.selectedPageIds?.length === pages.length}
|
||||
indeterminate={
|
||||
selectionState.selectedPageIds &&
|
||||
selectionState.selectedPageIds.length > 0 &&
|
||||
selectionState.selectedPageIds.length < pages.length
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListHeaderTitleCell = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.headerTitleCell}>
|
||||
<PageListHeaderCheckbox />
|
||||
{t['Title']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const hideHeaderAtom = selectAtom(pageListPropsAtom, props => props.hideHeader);
|
||||
|
||||
// the table header for page list
|
||||
export const PageListTableHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const showOperations = useAtomValue(showOperationsAtom);
|
||||
const hideHeader = useAtomValue(hideHeaderAtom);
|
||||
const selectionState = useAtomValue(selectionStateAtom);
|
||||
const headerCols = useMemo(() => {
|
||||
const cols: (HeaderColDef | boolean)[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <PageListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: t['Tags'](),
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: t['Created'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: t['Updated'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
showOperations && {
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
||||
return cols.filter((def): def is HeaderColDef => !!def);
|
||||
}, [t, showOperations]);
|
||||
|
||||
if (hideHeader) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.tableHeader)}
|
||||
data-selectable={selectionState.selectable}
|
||||
data-selection-active={selectionState.selectionActive}
|
||||
>
|
||||
{headerCols.map(col => {
|
||||
return (
|
||||
<PageListHeaderCell
|
||||
flex={col.flex}
|
||||
alignment={col.alignment}
|
||||
key={col.key}
|
||||
sortKey={col.key as keyof PageMeta}
|
||||
sortable={col.sortable}
|
||||
style={{ overflow: 'visible' }}
|
||||
hideInSmallContainer={col.hideInSmallContainer}
|
||||
>
|
||||
{col.content}
|
||||
</PageListHeaderCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -84,7 +84,11 @@ const PageCreateDateCell = ({
|
||||
createDate,
|
||||
}: Pick<PageListItemProps, 'createDate'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
||||
<div
|
||||
data-testid="page-list-item-date"
|
||||
data-date-raw={createDate}
|
||||
className={styles.dateCell}
|
||||
>
|
||||
{formatDate(createDate)}
|
||||
</div>
|
||||
);
|
||||
@@ -94,7 +98,11 @@ const PageUpdatedDateCell = ({
|
||||
updatedDate,
|
||||
}: Pick<PageListItemProps, 'updatedDate'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
||||
<div
|
||||
data-testid="page-list-item-date"
|
||||
data-date-raw={updatedDate}
|
||||
className={styles.dateCell}
|
||||
>
|
||||
{updatedDate ? formatDate(updatedDate) : '-'}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,8 @@ import * as itemStyles from './page-list-item.css';
|
||||
export const listRootContainer = createContainer('list-root-container');
|
||||
|
||||
export const pageListScrollContainer = style({
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const root = style({
|
||||
@@ -23,7 +23,9 @@ export const groupsContainer = style({
|
||||
rowGap: '16px',
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
export const heading = style({});
|
||||
|
||||
export const tableHeader = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 6px 10px 16px',
|
||||
@@ -37,7 +39,7 @@ export const header = style({
|
||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||
});
|
||||
|
||||
globalStyle(`[data-has-scroll-top=true] ${header}`, {
|
||||
globalStyle(`[data-has-scroll-top=true] ${tableHeader}`, {
|
||||
boxShadow: '0 1px var(--affine-border-color)',
|
||||
});
|
||||
|
||||
@@ -73,13 +75,22 @@ export const headerTitleCell = style({
|
||||
export const headerTitleSelectionIconWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
fontSize: '16px',
|
||||
selectors: {
|
||||
[`${tableHeader}[data-selectable=toggle] &`]: {
|
||||
width: 32,
|
||||
},
|
||||
[`${tableHeader}[data-selection-active=true] &`]: {
|
||||
width: 24,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const headerCellSortIcon = style({
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
display: 'inline-flex',
|
||||
fontSize: 14,
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
|
||||
export const colWrapper = style({
|
||||
@@ -104,7 +115,7 @@ export const favoriteCell = style({
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`&[data-favorite], &${itemStyles.root}:hover &`]: {
|
||||
[`&[data-favorite], ${itemStyles.root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,86 +1,146 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useHydrateAtoms } from 'jotai/utils';
|
||||
import {
|
||||
type ForwardedRef,
|
||||
forwardRef,
|
||||
type MouseEventHandler,
|
||||
memo,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
|
||||
import { Scrollable } from '../../ui/scrollbar';
|
||||
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||
import { PageGroup } from './page-group';
|
||||
import { PageListTableHeader } from './page-header';
|
||||
import * as styles from './page-list.css';
|
||||
import {
|
||||
pageGroupsAtom,
|
||||
pageListHandlersAtom,
|
||||
pageListPropsAtom,
|
||||
pagesAtom,
|
||||
PageListProvider,
|
||||
selectionStateAtom,
|
||||
showOperationsAtom,
|
||||
sorterAtom,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom,
|
||||
} from './scoped-atoms';
|
||||
import type { PageListHandle, PageListProps } from './types';
|
||||
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
||||
|
||||
/**
|
||||
* Given a list of pages, render a list of pages
|
||||
*/
|
||||
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
||||
function PageListHandle(props, ref) {
|
||||
function PageList(props, ref) {
|
||||
return (
|
||||
<Provider>
|
||||
<PageListInner {...props} handleRef={ref} />
|
||||
</Provider>
|
||||
// push pageListProps to the atom so that downstream components can consume it
|
||||
// this makes sure pageListPropsAtom is always populated
|
||||
// @ts-expect-error fix type issues later
|
||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||
<PageListInner {...props} />
|
||||
</PageListInnerWrapper>
|
||||
</PageListProvider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const PageListInner = ({
|
||||
handleRef,
|
||||
...props
|
||||
}: PageListProps & { handleRef: ForwardedRef<PageListHandle> }) => {
|
||||
// push pageListProps to the atom so that downstream components can consume it
|
||||
useHydrateAtoms([[pageListPropsAtom, props]], {
|
||||
// note: by turning on dangerouslyForceHydrate, downstream component need to use selectAtom to consume the atom
|
||||
// note2: not using it for now because it will cause some other issues
|
||||
// dangerouslyForceHydrate: true,
|
||||
});
|
||||
|
||||
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
||||
const setPageListSelectionState = useSetAtom(selectionStateAtom);
|
||||
|
||||
// when pressing ESC or double clicking outside of the page list, close the selection mode
|
||||
// todo: use jotai-effect instead but it seems it does not work with jotai-scope?
|
||||
const usePageSelectionStateEffect = () => {
|
||||
const [selectionState, setSelectionActive] = useAtom(selectionStateAtom);
|
||||
useEffect(() => {
|
||||
setPageListPropsAtom(props);
|
||||
}, [props, setPageListPropsAtom]);
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => {
|
||||
return {
|
||||
toggleSelectable: () => {
|
||||
setPageListSelectionState(false);
|
||||
},
|
||||
if (
|
||||
selectionState.selectionActive &&
|
||||
selectionState.selectable === 'toggle'
|
||||
) {
|
||||
const startTime = Date.now();
|
||||
const dblClickHandler = (e: MouseEvent) => {
|
||||
if (Date.now() - startTime < 200) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
// skip if event target is inside of a button or input
|
||||
// or within a toolbar (like page list floating toolbar)
|
||||
if (
|
||||
target.tagName === 'BUTTON' ||
|
||||
target.tagName === 'INPUT' ||
|
||||
(e.target as HTMLElement).closest('button, input, [role="toolbar"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectionActive(false);
|
||||
};
|
||||
},
|
||||
[setPageListSelectionState]
|
||||
);
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (Date.now() - startTime < 200) {
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setSelectionActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('dblclick', dblClickHandler);
|
||||
document.addEventListener('keydown', escHandler);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dblclick', dblClickHandler);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [
|
||||
selectionState.selectable,
|
||||
selectionState.selectionActive,
|
||||
setSelectionActive,
|
||||
]);
|
||||
};
|
||||
|
||||
export const PageListInnerWrapper = memo(
|
||||
({
|
||||
handleRef,
|
||||
children,
|
||||
onSelectionActiveChange,
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
PageListProps & { handleRef: ForwardedRef<PageListHandle> }
|
||||
>) => {
|
||||
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
||||
const [selectionState, setPageListSelectionState] =
|
||||
useAtom(selectionStateAtom);
|
||||
usePageSelectionStateEffect();
|
||||
|
||||
useEffect(() => {
|
||||
setPageListPropsAtom(props);
|
||||
}, [props, setPageListPropsAtom]);
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionActiveChange?.(!!selectionState.selectionActive);
|
||||
}, [onSelectionActiveChange, selectionState.selectionActive]);
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => {
|
||||
return {
|
||||
toggleSelectable: () => {
|
||||
setPageListSelectionState(false);
|
||||
},
|
||||
};
|
||||
},
|
||||
[setPageListSelectionState]
|
||||
);
|
||||
return children;
|
||||
}
|
||||
);
|
||||
|
||||
PageListInnerWrapper.displayName = 'PageListInnerWrapper';
|
||||
|
||||
const PageListInner = (props: PageListProps) => {
|
||||
const groups = useAtomValue(pageGroupsAtom);
|
||||
const hideHeader = props.hideHeader;
|
||||
return (
|
||||
<div className={clsx(props.className, styles.root)}>
|
||||
{!hideHeader ? <PageListHeader /> : null}
|
||||
{!hideHeader ? <PageListTableHeader /> : null}
|
||||
<div className={styles.groupsContainer}>
|
||||
{groups.map(group => (
|
||||
<PageGroup key={group.id} {...group} />
|
||||
@@ -90,176 +150,6 @@ const PageListInner = ({
|
||||
);
|
||||
};
|
||||
|
||||
type HeaderCellProps = ColWrapperProps & {
|
||||
sortKey: keyof PageMeta;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
||||
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||
const onClick: MouseEventHandler = useCallback(() => {
|
||||
if (props.sortable && props.sortKey) {
|
||||
setSorter({
|
||||
newSortKey: props.sortKey,
|
||||
});
|
||||
}
|
||||
}, [props.sortKey, props.sortable, setSorter]);
|
||||
|
||||
const sorting = sorter.key === props.sortKey;
|
||||
|
||||
return (
|
||||
<ColWrapper
|
||||
flex={props.flex}
|
||||
alignment={props.alignment}
|
||||
onClick={onClick}
|
||||
className={styles.headerCell}
|
||||
data-sortable={props.sortable ? true : undefined}
|
||||
data-sorting={sorting ? true : undefined}
|
||||
style={props.style}
|
||||
hideInSmallContainer={props.hideInSmallContainer}
|
||||
>
|
||||
{props.children}
|
||||
{sorting ? (
|
||||
<div className={styles.headerCellSortIcon}>
|
||||
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||
</div>
|
||||
) : null}
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type HeaderColDef = {
|
||||
key: string;
|
||||
content: ReactNode;
|
||||
flex: ColWrapperProps['flex'];
|
||||
alignment?: ColWrapperProps['alignment'];
|
||||
sortable?: boolean;
|
||||
hideInSmallContainer?: boolean;
|
||||
};
|
||||
|
||||
// the checkbox on the header has three states:
|
||||
// when list selectable = true, the checkbox will be presented
|
||||
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
||||
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
||||
const PageListHeaderCheckbox = () => {
|
||||
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||
const pages = useAtomValue(pagesAtom);
|
||||
const onActivateSelection: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
stopPropagation(e);
|
||||
setSelectionState(true);
|
||||
},
|
||||
[setSelectionState]
|
||||
);
|
||||
const handlers = useAtomValue(pageListHandlersAtom);
|
||||
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||
(e, checked) => {
|
||||
stopPropagation(e);
|
||||
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
||||
},
|
||||
[handlers, pages]
|
||||
);
|
||||
|
||||
if (!selectionState.selectable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.headerTitleSelectionIconWrapper}
|
||||
onClick={onActivateSelection}
|
||||
>
|
||||
{!selectionState.selectionActive ? (
|
||||
<MultiSelectIcon />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectionState.selectedPageIds?.length === pages.length}
|
||||
indeterminate={
|
||||
selectionState.selectedPageIds &&
|
||||
selectionState.selectedPageIds.length > 0 &&
|
||||
selectionState.selectedPageIds.length < pages.length
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListHeaderTitleCell = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.headerTitleCell}>
|
||||
<PageListHeaderCheckbox />
|
||||
{t['Title']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageListHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const showOperations = useAtomValue(showOperationsAtom);
|
||||
const headerCols = useMemo(() => {
|
||||
const cols: (HeaderColDef | boolean)[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <PageListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: t['Tags'](),
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: t['Created'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: t['Updated'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
showOperations && {
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
||||
return cols.filter((def): def is HeaderColDef => !!def);
|
||||
}, [t, showOperations]);
|
||||
return (
|
||||
<div className={clsx(styles.header)}>
|
||||
{headerCols.map(col => {
|
||||
return (
|
||||
<PageListHeaderCell
|
||||
flex={col.flex}
|
||||
alignment={col.alignment}
|
||||
key={col.key}
|
||||
sortKey={col.key as keyof PageMeta}
|
||||
sortable={col.sortable}
|
||||
style={{ overflow: 'visible' }}
|
||||
hideInSmallContainer={col.hideInSmallContainer}
|
||||
>
|
||||
{col.content}
|
||||
</PageListHeaderCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageListScrollContainerProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
@@ -287,14 +177,14 @@ export const PageListScrollContainer = forwardRef<
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
<Scrollable.Root
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
className={clsx(styles.pageListScrollContainer, className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<Scrollable.Viewport ref={setNodeRef}>{children}</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ const tagColorMap = (color: string) => {
|
||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-gray)': 'var(--affine-palette-line-grey)',
|
||||
'var(--affine-tag-orange)': 'var(--affine-palette-line-orange)',
|
||||
'var(--affine-tag-purple)': 'var(--affine-palette-line-purple)',
|
||||
'var(--affine-tag-green)': 'var(--affine-palette-line-green)',
|
||||
};
|
||||
return mapping[color] || color;
|
||||
};
|
||||
@@ -109,7 +113,6 @@ export const PageTags = ({
|
||||
// @ts-expect-error it's fine
|
||||
'--hover-max-width': sanitizedWidthOnHover,
|
||||
}}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
@@ -123,7 +126,12 @@ export const PageTags = ({
|
||||
{tagsNormal}
|
||||
</div>
|
||||
{maxItems && tags.length > maxItems ? (
|
||||
<Menu items={tagsInPopover}>
|
||||
<Menu
|
||||
items={tagsInPopover}
|
||||
contentOptions={{
|
||||
onClick: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<div className={styles.showMoreTag}>
|
||||
<MoreHorizontalIcon />
|
||||
</div>
|
||||
|
||||
@@ -2,28 +2,40 @@ import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { createIsolation } from 'jotai-scope';
|
||||
|
||||
import { pagesToPageGroups } from './page-group';
|
||||
import type { PageListProps, PageMetaRecord } from './types';
|
||||
import type {
|
||||
PageListProps,
|
||||
PageMetaRecord,
|
||||
VirtualizedPageListProps,
|
||||
} from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
// for ease of use in the component tree
|
||||
// note: must use selectAtom to access this atom for efficiency
|
||||
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
||||
export const pageListPropsAtom = atom<PageListProps>();
|
||||
export const pageListPropsAtom = atom<
|
||||
PageListProps & Partial<VirtualizedPageListProps>
|
||||
>();
|
||||
|
||||
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
||||
const selectionActiveAtom = atom(false);
|
||||
|
||||
export const selectionStateAtom = atom(
|
||||
get => {
|
||||
const baseAtom = selectAtom(pageListPropsAtom, props => {
|
||||
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
||||
return {
|
||||
selectable,
|
||||
selectedPageIds,
|
||||
onSelectedPageIdsChange,
|
||||
};
|
||||
});
|
||||
const baseAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => {
|
||||
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
||||
return {
|
||||
selectable,
|
||||
selectedPageIds,
|
||||
onSelectedPageIdsChange,
|
||||
};
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
const baseState = get(baseAtom);
|
||||
const selectionActive =
|
||||
baseState.selectable === 'toggle'
|
||||
@@ -39,18 +51,27 @@ export const selectionStateAtom = atom(
|
||||
}
|
||||
);
|
||||
|
||||
// id -> isCollapsed
|
||||
// maybe reset on page on unmount?
|
||||
export const pageGroupCollapseStateAtom = atom<Record<string, boolean>>({});
|
||||
|
||||
// get handlers from pageListPropsAtom
|
||||
export const pageListHandlersAtom = selectAtom(pageListPropsAtom, props => {
|
||||
const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props;
|
||||
export const pageListHandlersAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => {
|
||||
const { onSelectedPageIdsChange } = props;
|
||||
return {
|
||||
onSelectedPageIdsChange,
|
||||
};
|
||||
},
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
return {
|
||||
onSelectedPageIdsChange,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
};
|
||||
});
|
||||
|
||||
export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages);
|
||||
export const pagesAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => props.pages,
|
||||
shallowEqual
|
||||
);
|
||||
|
||||
export const showOperationsAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
@@ -177,3 +198,10 @@ export const pageGroupsAtom = atom(get => {
|
||||
}
|
||||
return pagesToPageGroups(sorter.pages, groupBy);
|
||||
});
|
||||
|
||||
export const {
|
||||
Provider: PageListProvider,
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
useSetAtom,
|
||||
} = createIsolation();
|
||||
@@ -40,23 +40,27 @@ export interface PageListProps {
|
||||
// required data:
|
||||
pages: PageMeta[];
|
||||
blockSuiteWorkspace: Workspace;
|
||||
|
||||
className?: string;
|
||||
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||
groupBy?: PagesGroupByType | false;
|
||||
isPreferredEdgeless: (pageId: string) => boolean;
|
||||
clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate
|
||||
isPreferredEdgeless: (pageId: string) => boolean; // determines the icon used for each row
|
||||
rowAsLink?: boolean;
|
||||
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
||||
selectedPageIds?: string[]; // selected page ids
|
||||
onSelectedPageIdsChange?: (selected: string[]) => void;
|
||||
onSelectionActiveChange?: (active: boolean) => void;
|
||||
draggable?: boolean; // whether or not to allow dragging this page item
|
||||
onDragStart?: (pageId: string) => void;
|
||||
onDragEnd?: (pageId: string) => void;
|
||||
// we also need the following to make sure the page list functions properly
|
||||
// maybe we could also give a function to render PageListItem?
|
||||
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
|
||||
}
|
||||
|
||||
export interface VirtualizedPageListProps extends PageListProps {
|
||||
heading?: ReactNode; // the user provided heading part (non sticky, above the original header)
|
||||
atTopThreshold?: number; // the threshold to determine whether or not the user has scrolled to the top. default is 0
|
||||
atTopStateChange?: (atTop: boolean) => void; // called when the user scrolls to the top or not
|
||||
}
|
||||
|
||||
export interface PageListHandle {
|
||||
toggleSelectable: () => void;
|
||||
}
|
||||
|
||||
@@ -19,16 +19,13 @@ export const createEmptyCollection = (
|
||||
return {
|
||||
id,
|
||||
name: '',
|
||||
mode: 'page',
|
||||
filterList: [],
|
||||
pages: [],
|
||||
allowList: [],
|
||||
...data,
|
||||
};
|
||||
};
|
||||
const defaultCollection: Collection = createEmptyCollection(NIL, {
|
||||
name: 'All',
|
||||
mode: 'rule',
|
||||
});
|
||||
const defaultCollectionAtom = atomWithReset<Collection>(defaultCollection);
|
||||
export const currentCollectionAtom = atomWithReset<string>(NIL);
|
||||
@@ -52,12 +49,6 @@ export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => {
|
||||
const addPage = useCallback(
|
||||
async (collectionId: string, pageId: string) => {
|
||||
await updateCollection(collectionId, old => {
|
||||
if (old.mode === 'page') {
|
||||
return {
|
||||
...old,
|
||||
pages: [pageId, ...(old.pages ?? [])],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...old,
|
||||
allowList: [pageId, ...(old.allowList ?? [])],
|
||||
@@ -128,8 +119,8 @@ export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
||||
evalFilterList(filterList, varMap);
|
||||
|
||||
export const filterPage = (collection: Collection, page: PageMeta) => {
|
||||
if (collection.mode === 'page') {
|
||||
return collection.pages.includes(page.id);
|
||||
if (collection.filterList.length === 0) {
|
||||
return collection.allowList.includes(page.id);
|
||||
}
|
||||
return filterPageByRules(collection.filterList, collection.allowList, page);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// add dblclick & esc to document when page selection is active
|
||||
//
|
||||
export function usePageSelectionEvents() {}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type SorterConfig<
|
||||
T extends Record<string | number | symbol, unknown> = Record<
|
||||
string | number | symbol,
|
||||
unknown
|
||||
>,
|
||||
> = {
|
||||
data: T[];
|
||||
key: keyof T;
|
||||
order: 'asc' | 'desc' | 'none';
|
||||
sortingFn?: (
|
||||
ctx: {
|
||||
key: keyof T;
|
||||
order: 'asc' | 'desc';
|
||||
},
|
||||
a: T,
|
||||
b: T
|
||||
) => number;
|
||||
};
|
||||
|
||||
const defaultSortingFn: SorterConfig['sortingFn'] = (ctx, a, b) => {
|
||||
const valA = a[ctx.key];
|
||||
const valB = b[ctx.key];
|
||||
const revert = ctx.order === 'desc';
|
||||
const revertSymbol = revert ? -1 : 1;
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return valA.localeCompare(valB) * revertSymbol;
|
||||
}
|
||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
return valA - valB * revertSymbol;
|
||||
}
|
||||
if (valA instanceof Date && valB instanceof Date) {
|
||||
return (valA.getTime() - valB.getTime()) * revertSymbol;
|
||||
}
|
||||
if (!valA) {
|
||||
return -1 * revertSymbol;
|
||||
}
|
||||
if (!valB) {
|
||||
return 1 * revertSymbol;
|
||||
}
|
||||
|
||||
if (Array.isArray(valA) && Array.isArray(valB)) {
|
||||
return (valA.length - valB.length) * revertSymbol;
|
||||
}
|
||||
console.warn(
|
||||
'Unsupported sorting type! Please use custom sorting function.',
|
||||
valA,
|
||||
valB
|
||||
);
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const useSorter = <T extends Record<keyof any, unknown>>({
|
||||
data,
|
||||
sortingFn = defaultSortingFn,
|
||||
...defaultSorter
|
||||
}: SorterConfig<T> & { order: 'asc' | 'desc' }) => {
|
||||
const [sorter, setSorter] = useState<Omit<SorterConfig<T>, 'data'>>({
|
||||
...defaultSorter,
|
||||
// We should not show sorting icon at first time
|
||||
order: 'none',
|
||||
});
|
||||
const sortCtx =
|
||||
sorter.order === 'none'
|
||||
? {
|
||||
key: defaultSorter.key,
|
||||
order: defaultSorter.order,
|
||||
}
|
||||
: {
|
||||
key: sorter.key,
|
||||
order: sorter.order,
|
||||
};
|
||||
const compareFn = (a: T, b: T) => sortingFn(sortCtx, a, b);
|
||||
const sortedData = data.sort(compareFn);
|
||||
|
||||
const shiftOrder = (key?: keyof T) => {
|
||||
const orders = ['asc', 'desc', 'none'] as const;
|
||||
if (key && key !== sorter.key) {
|
||||
// Key changed
|
||||
setSorter({
|
||||
...sorter,
|
||||
key,
|
||||
order: orders[0],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSorter({
|
||||
...sorter,
|
||||
order: orders[(orders.indexOf(sorter.order) + 1) % orders.length],
|
||||
});
|
||||
};
|
||||
return {
|
||||
data: sortedData,
|
||||
order: sorter.order,
|
||||
key: sorter.order !== 'none' ? sorter.key : null,
|
||||
/**
|
||||
* @deprecated In most cases, we no necessary use `updateSorter` directly.
|
||||
*/
|
||||
updateSorter: (newVal: Partial<SorterConfig<T>>) =>
|
||||
setSorter({ ...sorter, ...newVal }),
|
||||
shiftOrder,
|
||||
resetSorter: () => setSorter(defaultSorter),
|
||||
};
|
||||
};
|
||||
@@ -124,10 +124,10 @@ export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||
|
||||
export const withinDaysAgo = (date: Date, days: number): boolean => {
|
||||
const startDate = new Date();
|
||||
const day = startDate.getDay();
|
||||
const day = startDate.getDate();
|
||||
const month = startDate.getMonth();
|
||||
const year = startDate.getFullYear();
|
||||
return new Date(year, month, day - days) <= date;
|
||||
return new Date(year, month, day - days + 1) <= date;
|
||||
};
|
||||
|
||||
export const betweenDaysAgo = (
|
||||
@@ -145,3 +145,38 @@ export function stopPropagation(event: BaseSyntheticEvent) {
|
||||
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
|
||||
export function shallowEqual(objA: any, objB: any) {
|
||||
if (Object.is(objA, objB)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof objA !== 'object' ||
|
||||
objA === null ||
|
||||
typeof objB !== 'object' ||
|
||||
objB === null
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (let i = 0; i < keysA.length; i++) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
|
||||
!Object.is(objA[keysA[i]], objB[keysA[i]])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
useCollectionManager,
|
||||
} from '../use-collection-manager';
|
||||
import * as styles from './collection-bar.css';
|
||||
import { type AllPageListConfig, EditCollectionModal } from './edit-collection';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
EditCollectionModal,
|
||||
} from './edit-collection/edit-collection';
|
||||
import { useActions } from './use-action';
|
||||
|
||||
interface CollectionBarProps {
|
||||
|
||||
@@ -15,7 +15,10 @@ import { CreateFilterMenu } from '../filter/vars';
|
||||
import type { useCollectionManager } from '../use-collection-manager';
|
||||
import * as styles from './collection-list.css';
|
||||
import { CollectionOperations } from './collection-operations';
|
||||
import { type AllPageListConfig, EditCollectionModal } from './edit-collection';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
EditCollectionModal,
|
||||
} from './edit-collection/edit-collection';
|
||||
|
||||
export const CollectionList = ({
|
||||
setting,
|
||||
|
||||
@@ -1,934 +0,0 @@
|
||||
import {
|
||||
AffineShapeIcon,
|
||||
PageList,
|
||||
PageListScrollContainer,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
CloseIcon,
|
||||
EdgelessIcon,
|
||||
FilterIcon,
|
||||
PageIcon,
|
||||
PlusIcon,
|
||||
ToggleCollapseIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import { Modal } from '@toeverything/components/modal';
|
||||
import clsx from 'clsx';
|
||||
import { type MouseEvent, useEffect } from 'react';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { RadioButton, RadioButtonGroup } from '../../..';
|
||||
import { FilterList } from '../filter';
|
||||
import { VariableSelect } from '../filter/vars';
|
||||
import { filterPageByRules } from '../use-collection-manager';
|
||||
import * as styles from './edit-collection.css';
|
||||
|
||||
export interface EditCollectionModalProps {
|
||||
init?: Collection;
|
||||
title?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (view: Collection) => Promise<void>;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}
|
||||
|
||||
export const EditCollectionModal = ({
|
||||
init,
|
||||
onConfirm,
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
allPageListConfig,
|
||||
}: EditCollectionModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const onConfirmOnCollection = useCallback(
|
||||
(view: Collection) => {
|
||||
onConfirm(view)
|
||||
.then(() => {
|
||||
onOpenChange(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
[onConfirm, onOpenChange]
|
||||
);
|
||||
const onCancel = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
withoutCloseButton
|
||||
width="calc(100% - 64px)"
|
||||
height="80%"
|
||||
contentOptions={{
|
||||
style: {
|
||||
padding: 0,
|
||||
maxWidth: 944,
|
||||
backgroundColor: 'var(--affine-white)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{init ? (
|
||||
<EditCollection
|
||||
title={title}
|
||||
onConfirmText={t['com.affine.editCollection.save']()}
|
||||
init={init}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirmOnCollection}
|
||||
allPageListConfig={allPageListConfig}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export interface EditCollectionProps {
|
||||
title?: string;
|
||||
onConfirmText?: string;
|
||||
init: Collection;
|
||||
onCancel: () => void;
|
||||
onConfirm: (collection: Collection) => void;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}
|
||||
|
||||
export const EditCollection = ({
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onConfirmText,
|
||||
allPageListConfig,
|
||||
}: EditCollectionProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, onChange] = useState<Collection>(init);
|
||||
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
|
||||
const onSaveCollection = useCallback(() => {
|
||||
if (!isNameEmpty) {
|
||||
onConfirm(value);
|
||||
}
|
||||
}, [value, isNameEmpty, onConfirm]);
|
||||
const reset = useCallback(() => {
|
||||
onChange({
|
||||
...value,
|
||||
filterList: init.filterList,
|
||||
allowList: init.allowList,
|
||||
});
|
||||
}, [init.allowList, init.filterList, value]);
|
||||
const buttons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Button size="large" onClick={onCancel}>
|
||||
{t['com.affine.editCollection.button.cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.confirmButton}
|
||||
size="large"
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
disabled={isNameEmpty}
|
||||
onClick={onSaveCollection}
|
||||
>
|
||||
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
[onCancel, t, isNameEmpty, onSaveCollection, onConfirmText]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.collectionEditContainer}>
|
||||
{value.mode === 'page' ? (
|
||||
<PagesMode
|
||||
collection={value}
|
||||
updateCollection={onChange}
|
||||
buttons={buttons}
|
||||
allPageListConfig={allPageListConfig}
|
||||
></PagesMode>
|
||||
) : (
|
||||
<RulesMode
|
||||
allPageListConfig={allPageListConfig}
|
||||
collection={value}
|
||||
reset={reset}
|
||||
updateCollection={onChange}
|
||||
buttons={buttons}
|
||||
></RulesMode>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type AllPageListConfig = {
|
||||
allPages: PageMeta[];
|
||||
workspace: Workspace;
|
||||
isEdgeless: (id: string) => boolean;
|
||||
getPage: (id: string) => PageMeta | undefined;
|
||||
favoriteRender: (page: PageMeta) => ReactNode;
|
||||
};
|
||||
const RulesMode = ({
|
||||
collection,
|
||||
updateCollection,
|
||||
reset,
|
||||
buttons,
|
||||
allPageListConfig,
|
||||
}: {
|
||||
collection: Collection;
|
||||
updateCollection: (collection: Collection) => void;
|
||||
reset: () => void;
|
||||
buttons: ReactNode;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [showPreview, setShowPreview] = useState(true);
|
||||
const allowListPages: PageMeta[] = [];
|
||||
const rulesPages: PageMeta[] = [];
|
||||
const [showTips, setShowTips] = useState(false);
|
||||
useEffect(() => {
|
||||
setShowTips(!localStorage.getItem('hide-rules-mode-include-page-tips'));
|
||||
}, []);
|
||||
const hideTips = useCallback(() => {
|
||||
setShowTips(false);
|
||||
localStorage.setItem('hide-rules-mode-include-page-tips', 'true');
|
||||
}, []);
|
||||
allPageListConfig.allPages.forEach(v => {
|
||||
if (v.trash) {
|
||||
return;
|
||||
}
|
||||
const result = filterPageByRules(
|
||||
collection.filterList,
|
||||
collection.allowList,
|
||||
v
|
||||
);
|
||||
if (result) {
|
||||
if (collection.allowList.includes(v.id)) {
|
||||
allowListPages.push(v);
|
||||
} else {
|
||||
rulesPages.push(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
const { node: selectPageNode, open } = useSelectPage({ allPageListConfig });
|
||||
const openSelectPage = useCallback(() => {
|
||||
open(collection.allowList).then(
|
||||
ids => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: ids,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
//do nothing
|
||||
}
|
||||
);
|
||||
}, [open, updateCollection, collection]);
|
||||
const [expandInclude, setExpandInclude] = useState(false);
|
||||
const count = allowListPages.length + rulesPages.length;
|
||||
return (
|
||||
<>
|
||||
{/*prevents modal autofocus to the first input*/}
|
||||
<input
|
||||
type="text"
|
||||
style={{ width: 0, height: 0 }}
|
||||
onFocus={e => requestAnimationFrame(() => e.target.blur())}
|
||||
/>
|
||||
<div className={clsx(styles.rulesTitle, styles.ellipsis)}>
|
||||
<Trans
|
||||
i18nKey="com.affine.editCollection.rules.tips"
|
||||
values={{
|
||||
highlight: t['com.affine.editCollection.rules.tips.highlight'](),
|
||||
}}
|
||||
>
|
||||
Pages that meet the rules will be added to the current collection{' '}
|
||||
<span className={styles.rulesTitleHighlight}>highlight</span>.
|
||||
</Trans>
|
||||
</div>
|
||||
<div className={styles.rulesContainer}>
|
||||
<div className={styles.rulesContainerLeft}>
|
||||
<div className={styles.rulesContainerLeftTab}>
|
||||
<RadioButtonGroup
|
||||
width={158}
|
||||
style={{ height: 32 }}
|
||||
value={collection.mode}
|
||||
onValueChange={useCallback(
|
||||
(mode: 'page' | 'rule') => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
mode,
|
||||
});
|
||||
},
|
||||
[collection, updateCollection]
|
||||
)}
|
||||
>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="page"
|
||||
data-testid="edit-collection-pages-button"
|
||||
>
|
||||
{t['com.affine.editCollection.pages']()}
|
||||
</RadioButton>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="rule"
|
||||
data-testid="edit-collection-rules-button"
|
||||
>
|
||||
{t['com.affine.editCollection.rules']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
</div>
|
||||
<div className={styles.rulesContainerLeftContent}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={collection.filterList}
|
||||
onChange={useCallback(
|
||||
filterList => updateCollection({ ...collection, filterList }),
|
||||
[collection, updateCollection]
|
||||
)}
|
||||
/>
|
||||
<div className={styles.rulesContainerLeftContentInclude}>
|
||||
<div className={styles.includeTitle}>
|
||||
<ToggleCollapseIcon
|
||||
onClick={() => setExpandInclude(!expandInclude)}
|
||||
className={styles.button}
|
||||
width={24}
|
||||
height={24}
|
||||
style={{
|
||||
transform: expandInclude ? 'rotate(90deg)' : undefined,
|
||||
}}
|
||||
></ToggleCollapseIcon>
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
include
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: expandInclude ? 'flex' : 'none',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px 16px',
|
||||
}}
|
||||
>
|
||||
{collection.allowList.map(id => {
|
||||
const page = allPageListConfig.allPages.find(
|
||||
v => v.id === id
|
||||
);
|
||||
return (
|
||||
<div className={styles.includeItem} key={id}>
|
||||
<div className={styles.includeItemContent}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{allPageListConfig.isEdgeless(id) ? (
|
||||
<EdgelessIcon style={{ width: 16, height: 16 }} />
|
||||
) : (
|
||||
<PageIcon style={{ width: 16, height: 16 }} />
|
||||
)}
|
||||
{t[
|
||||
'com.affine.editCollection.rules.include.page'
|
||||
]()}
|
||||
</div>
|
||||
<div className={styles.includeItemContentIs}>
|
||||
{t['com.affine.editCollection.rules.include.is']()}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.includeItemTitle,
|
||||
styles.ellipsis
|
||||
)}
|
||||
>
|
||||
{page?.title || t['Untitled']()}
|
||||
</div>
|
||||
</div>
|
||||
<CloseIcon
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: collection.allowList.filter(
|
||||
v => v !== id
|
||||
),
|
||||
});
|
||||
}}
|
||||
></CloseIcon>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
onClick={openSelectPage}
|
||||
className={clsx(styles.button, styles.includeAddButton)}
|
||||
>
|
||||
<PlusIcon></PlusIcon>
|
||||
<div
|
||||
style={{ color: 'var(--affine-text-secondary-color)' }}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.include.add']()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showTips ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor:
|
||||
'var(--affine-background-overlay-panel-color)',
|
||||
padding: 10,
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>{t['com.affine.collection.helpInfo']()}</div>
|
||||
<CloseIcon
|
||||
color="var(--affine-icon-color)"
|
||||
onClick={hideTips}
|
||||
className={styles.button}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>
|
||||
{t['com.affine.editCollection.rules.include.tipsTitle']()}
|
||||
</div>
|
||||
<div>{t['com.affine.editCollection.rules.include.tips']()}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<PageListScrollContainer
|
||||
className={styles.rulesContainerRight}
|
||||
style={{
|
||||
display: showPreview ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
{rulesPages.length > 0 ? (
|
||||
<PageList
|
||||
hideHeader
|
||||
clickMode="select"
|
||||
className={styles.resultPages}
|
||||
pages={rulesPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
) : null}
|
||||
{allowListPages.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.includeListTitle}>include</div>
|
||||
<PageList
|
||||
hideHeader
|
||||
clickMode="select"
|
||||
className={styles.resultPages}
|
||||
pages={allowListPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
></PageList>
|
||||
</div>
|
||||
) : null}
|
||||
</PageListScrollContainer>
|
||||
</div>
|
||||
<div className={styles.rulesBottom}>
|
||||
<div className={styles.bottomLeft}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.button,
|
||||
styles.bottomButton,
|
||||
showPreview && styles.previewActive
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowPreview(!showPreview);
|
||||
}}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.preview']()}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
onClick={reset}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.reset']()}
|
||||
</div>
|
||||
<div className={styles.previewCountTips}>
|
||||
<Trans
|
||||
i18nKey={
|
||||
count === 0
|
||||
? 'com.affine.editCollection.rules.countTips.zero'
|
||||
: count === 1
|
||||
? 'com.affine.editCollection.rules.countTips.one'
|
||||
: 'com.affine.editCollection.rules.countTips.more'
|
||||
}
|
||||
values={{ count: count }}
|
||||
>
|
||||
After searching, there are currently
|
||||
<span className={styles.previewCountTipsHighlight}>count</span>
|
||||
pages.
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>{buttons}</div>
|
||||
</div>
|
||||
{selectPageNode}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const PagesMode = ({
|
||||
collection,
|
||||
updateCollection,
|
||||
buttons,
|
||||
allPageListConfig,
|
||||
}: {
|
||||
collection: Collection;
|
||||
updateCollection: (collection: Collection) => void;
|
||||
buttons: ReactNode;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters,
|
||||
clickFilter,
|
||||
createFilter,
|
||||
filteredList,
|
||||
} = useFilter(allPageListConfig.allPages);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
const clearSelected = useCallback(() => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
pages: [],
|
||||
});
|
||||
}, [collection, updateCollection]);
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => allPageListConfig.favoriteRender(page),
|
||||
[allPageListConfig]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={e => updateSearchText(e.target.value)}
|
||||
className={styles.rulesTitle}
|
||||
style={{
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
}}
|
||||
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||
></input>
|
||||
<div className={styles.pagesList}>
|
||||
<div className={styles.pagesTab}>
|
||||
<div className={styles.pagesTabContent}>
|
||||
<RadioButtonGroup
|
||||
width={158}
|
||||
style={{ height: 32 }}
|
||||
value={collection.mode}
|
||||
onValueChange={useCallback(
|
||||
(mode: 'page' | 'rule') => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
mode,
|
||||
});
|
||||
},
|
||||
[collection, updateCollection]
|
||||
)}
|
||||
>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="page"
|
||||
data-testid="edit-collection-pages-button"
|
||||
>
|
||||
{t['com.affine.editCollection.pages']()}
|
||||
</RadioButton>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="rule"
|
||||
data-testid="edit-collection-rules-button"
|
||||
>
|
||||
{t['com.affine.editCollection.rules']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
{!showFilter && filters.length === 0 ? (
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
selected={filters}
|
||||
onSelect={createFilter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
) : (
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
)}
|
||||
</div>
|
||||
{showFilter ? (
|
||||
<div style={{ padding: '12px 16px 16px' }}>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<PageListScrollContainer>
|
||||
<PageList
|
||||
clickMode="select"
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
onSelectedPageIdsChange={ids => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
pages: ids,
|
||||
});
|
||||
}}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
selectedPageIds={collection.pages}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
></PageList>
|
||||
</PageListScrollContainer>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pagesBottom}>
|
||||
<div className={styles.pagesBottomLeft}>
|
||||
<div className={styles.selectedCountTips}>
|
||||
{t['com.affine.selectPage.selected']()}
|
||||
<span
|
||||
style={{ marginLeft: 7 }}
|
||||
className={styles.previewCountTipsHighlight}
|
||||
>
|
||||
{collection.pages.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
{t['com.affine.editCollection.pages.clear']()}
|
||||
</div>
|
||||
</div>
|
||||
<div>{buttons}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const SelectPage = ({
|
||||
allPageListConfig,
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
allPageListConfig: AllPageListConfig;
|
||||
init: string[];
|
||||
onConfirm: (pageIds: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, onChange] = useState(init);
|
||||
const confirm = useCallback(() => {
|
||||
onConfirm(value);
|
||||
}, [value, onConfirm]);
|
||||
const clearSelected = useCallback(() => {
|
||||
onChange([]);
|
||||
}, []);
|
||||
const {
|
||||
clickFilter,
|
||||
createFilter,
|
||||
filters,
|
||||
showFilter,
|
||||
updateFilters,
|
||||
filteredList,
|
||||
} = useFilter(allPageListConfig.allPages);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<input
|
||||
className={styles.rulesTitle}
|
||||
value={searchText}
|
||||
onChange={e => updateSearchText(e.target.value)}
|
||||
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||
></input>
|
||||
<div className={styles.pagesTab}>
|
||||
<div className={styles.pagesTabContent}>
|
||||
<div style={{ fontSize: 12, lineHeight: '20px', fontWeight: 600 }}>
|
||||
{t['com.affine.selectPage.title']()}
|
||||
</div>
|
||||
{!showFilter && filters.length === 0 ? (
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
selected={filters}
|
||||
onSelect={createFilter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
) : (
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
)}
|
||||
</div>
|
||||
{showFilter ? (
|
||||
<div style={{ padding: '12px 16px 16px' }}>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<PageListScrollContainer>
|
||||
<PageList
|
||||
clickMode="select"
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
onSelectedPageIdsChange={onChange}
|
||||
selectedPageIds={value}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
</PageListScrollContainer>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.pagesBottom}>
|
||||
<div className={styles.pagesBottomLeft}>
|
||||
<div className={styles.selectedCountTips}>
|
||||
{t['com.affine.selectPage.selected']()}
|
||||
<span
|
||||
style={{ marginLeft: 7 }}
|
||||
className={styles.previewCountTipsHighlight}
|
||||
>
|
||||
{value.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
{t['com.affine.editCollection.pages.clear']()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="large" onClick={onCancel}>
|
||||
{t['com.affine.editCollection.button.cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.confirmButton}
|
||||
size="large"
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
onClick={confirm}
|
||||
>
|
||||
{t['Confirm']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const useSelectPage = ({
|
||||
allPageListConfig,
|
||||
}: {
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const [value, onChange] = useState<{
|
||||
init: string[];
|
||||
onConfirm: (ids: string[]) => void;
|
||||
}>();
|
||||
const close = useCallback(() => {
|
||||
onChange(undefined);
|
||||
}, []);
|
||||
return {
|
||||
node: (
|
||||
<Modal
|
||||
open={!!value}
|
||||
onOpenChange={close}
|
||||
withoutCloseButton
|
||||
width="calc(100% - 32px)"
|
||||
height="80%"
|
||||
overlayOptions={{ style: { backgroundColor: 'transparent' } }}
|
||||
contentOptions={{
|
||||
style: {
|
||||
padding: 0,
|
||||
transform: 'translate(-50%,calc(-50% + 16px))',
|
||||
maxWidth: 976,
|
||||
backgroundColor: 'var(--affine-white)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<SelectPage
|
||||
allPageListConfig={allPageListConfig}
|
||||
init={value.init}
|
||||
onConfirm={value.onConfirm}
|
||||
onCancel={close}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
),
|
||||
open: (init: string[]): Promise<string[]> =>
|
||||
new Promise<string[]>(res => {
|
||||
onChange({
|
||||
init,
|
||||
onConfirm: list => {
|
||||
close();
|
||||
res(list);
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
const useFilter = (list: PageMeta[]) => {
|
||||
const [filters, changeFilters] = useState<Filter[]>([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const clickFilter = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (showFilter || filters.length !== 0) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowFilter(!showFilter);
|
||||
}
|
||||
},
|
||||
[filters.length, showFilter]
|
||||
);
|
||||
const onCreateFilter = useCallback(
|
||||
(filter: Filter) => {
|
||||
changeFilters([...filters, filter]);
|
||||
setShowFilter(true);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
return {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters: changeFilters,
|
||||
clickFilter,
|
||||
createFilter: onCreateFilter,
|
||||
filteredList: list.filter(v => {
|
||||
if (v.trash) {
|
||||
return false;
|
||||
}
|
||||
return filterPageByRules(filters, [], v);
|
||||
}),
|
||||
};
|
||||
};
|
||||
const useSearch = (list: PageMeta[]) => {
|
||||
const [value, onChange] = useState('');
|
||||
return {
|
||||
searchText: value,
|
||||
updateSearchText: onChange,
|
||||
searchedList: value
|
||||
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
|
||||
: list,
|
||||
};
|
||||
};
|
||||
const EmptyList = ({ search }: { search?: string }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<AffineShapeIcon />
|
||||
<div
|
||||
style={{
|
||||
margin: '18px 0',
|
||||
fontSize: 20,
|
||||
lineHeight: '28px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t['com.affine.selectPage.empty']()}
|
||||
</div>
|
||||
{search ? (
|
||||
<div
|
||||
className={styles.ellipsis}
|
||||
style={{ maxWidth: 300, fontSize: 15, lineHeight: '24px' }}
|
||||
>
|
||||
<Trans i18nKey="com.affine.selectPage.empty.tips" values={{ search }}>
|
||||
No page titles contain
|
||||
<span
|
||||
style={{ fontWeight: 600, color: 'var(--affine-primary-color)' }}
|
||||
>
|
||||
search
|
||||
</span>
|
||||
</Trans>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -59,12 +59,12 @@ export const rulesBottom = style({
|
||||
});
|
||||
|
||||
export const includeListTitle = style({
|
||||
marginTop: 8,
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
lineHeight: '22px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
paddingLeft: 18,
|
||||
padding: '4px 16px',
|
||||
borderTop: '1px solid var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const rulesContainerRight = style({
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { DialogContentProps } from '@radix-ui/react-dialog';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Modal } from '@toeverything/components/modal';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { RadioButton, RadioButtonGroup } from '../../../../index';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { PagesMode } from './pages-mode';
|
||||
import { RulesMode } from './rules-mode';
|
||||
|
||||
export type EditCollectionMode = 'page' | 'rule';
|
||||
|
||||
export interface EditCollectionModalProps {
|
||||
init?: Collection;
|
||||
title?: string;
|
||||
open: boolean;
|
||||
mode?: EditCollectionMode;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: (view: Collection) => Promise<void>;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}
|
||||
const contentOptions: DialogContentProps = {
|
||||
onPointerDownOutside: e => {
|
||||
e.preventDefault();
|
||||
},
|
||||
style: {
|
||||
padding: 0,
|
||||
maxWidth: 944,
|
||||
backgroundColor: 'var(--affine-white)',
|
||||
},
|
||||
};
|
||||
export const EditCollectionModal = ({
|
||||
init,
|
||||
onConfirm,
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
mode,
|
||||
allPageListConfig,
|
||||
}: EditCollectionModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const onConfirmOnCollection = useCallback(
|
||||
(view: Collection) => {
|
||||
onConfirm(view)
|
||||
.then(() => {
|
||||
onOpenChange(false);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
[onConfirm, onOpenChange]
|
||||
);
|
||||
const onCancel = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
withoutCloseButton
|
||||
width="calc(100% - 64px)"
|
||||
height="80%"
|
||||
contentOptions={contentOptions}
|
||||
>
|
||||
{init ? (
|
||||
<EditCollection
|
||||
title={title}
|
||||
onConfirmText={t['com.affine.editCollection.save']()}
|
||||
init={init}
|
||||
mode={mode}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirmOnCollection}
|
||||
allPageListConfig={allPageListConfig}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export interface EditCollectionProps {
|
||||
title?: string;
|
||||
onConfirmText?: string;
|
||||
init: Collection;
|
||||
mode?: EditCollectionMode;
|
||||
onCancel: () => void;
|
||||
onConfirm: (collection: Collection) => void;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}
|
||||
|
||||
export const EditCollection = ({
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onConfirmText,
|
||||
mode: initMode,
|
||||
allPageListConfig,
|
||||
}: EditCollectionProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, onChange] = useState<Collection>(init);
|
||||
const [mode, setMode] = useState<'page' | 'rule'>(
|
||||
initMode ?? (init.filterList.length === 0 ? 'page' : 'rule')
|
||||
);
|
||||
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
|
||||
const onSaveCollection = useCallback(() => {
|
||||
if (!isNameEmpty) {
|
||||
onConfirm(value);
|
||||
}
|
||||
}, [value, isNameEmpty, onConfirm]);
|
||||
const reset = useCallback(() => {
|
||||
onChange({
|
||||
...value,
|
||||
filterList: init.filterList,
|
||||
allowList: init.allowList,
|
||||
});
|
||||
}, [init.allowList, init.filterList, value]);
|
||||
const buttons = useMemo(
|
||||
() => (
|
||||
<>
|
||||
<Button size="large" onClick={onCancel}>
|
||||
{t['com.affine.editCollection.button.cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.confirmButton}
|
||||
size="large"
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
disabled={isNameEmpty}
|
||||
onClick={onSaveCollection}
|
||||
>
|
||||
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
[onCancel, t, isNameEmpty, onSaveCollection, onConfirmText]
|
||||
);
|
||||
const switchMode = useMemo(
|
||||
() => (
|
||||
<RadioButtonGroup
|
||||
width={158}
|
||||
style={{ height: 32 }}
|
||||
value={mode}
|
||||
onValueChange={(mode: 'page' | 'rule') => {
|
||||
setMode(mode);
|
||||
}}
|
||||
>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="page"
|
||||
data-testid="edit-collection-pages-button"
|
||||
>
|
||||
{t['com.affine.editCollection.pages']()}
|
||||
</RadioButton>
|
||||
<RadioButton
|
||||
spanStyle={styles.tabButton}
|
||||
value="rule"
|
||||
data-testid="edit-collection-rules-button"
|
||||
>
|
||||
{t['com.affine.editCollection.rules']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
),
|
||||
[mode, t]
|
||||
);
|
||||
return (
|
||||
<div className={styles.collectionEditContainer}>
|
||||
{mode === 'page' ? (
|
||||
<PagesMode
|
||||
collection={value}
|
||||
updateCollection={onChange}
|
||||
switchMode={switchMode}
|
||||
buttons={buttons}
|
||||
allPageListConfig={allPageListConfig}
|
||||
></PagesMode>
|
||||
) : (
|
||||
<RulesMode
|
||||
allPageListConfig={allPageListConfig}
|
||||
collection={value}
|
||||
switchMode={switchMode}
|
||||
reset={reset}
|
||||
updateCollection={onChange}
|
||||
buttons={buttons}
|
||||
></RulesMode>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type AllPageListConfig = {
|
||||
allPages: PageMeta[];
|
||||
workspace: Workspace;
|
||||
isEdgeless: (id: string) => boolean;
|
||||
getPage: (id: string) => PageMeta | undefined;
|
||||
favoriteRender: (page: PageMeta) => ReactNode;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
filterPageByRules,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { Modal } from '@toeverything/components/modal';
|
||||
import { type MouseEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { SelectPage } from './select-page';
|
||||
export const useSelectPage = ({
|
||||
allPageListConfig,
|
||||
}: {
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const [value, onChange] = useState<{
|
||||
init: string[];
|
||||
onConfirm: (ids: string[]) => void;
|
||||
}>();
|
||||
const close = useCallback(() => {
|
||||
onChange(undefined);
|
||||
}, []);
|
||||
return {
|
||||
node: (
|
||||
<Modal
|
||||
open={!!value}
|
||||
onOpenChange={close}
|
||||
withoutCloseButton
|
||||
width="calc(100% - 32px)"
|
||||
height="80%"
|
||||
overlayOptions={{ style: { backgroundColor: 'transparent' } }}
|
||||
contentOptions={{
|
||||
style: {
|
||||
padding: 0,
|
||||
transform: 'translate(-50%,calc(-50% + 16px))',
|
||||
maxWidth: 976,
|
||||
backgroundColor: 'var(--affine-white)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{value ? (
|
||||
<SelectPage
|
||||
allPageListConfig={allPageListConfig}
|
||||
init={value.init}
|
||||
onConfirm={value.onConfirm}
|
||||
onCancel={close}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
),
|
||||
open: (init: string[]): Promise<string[]> =>
|
||||
new Promise<string[]>(res => {
|
||||
onChange({
|
||||
init,
|
||||
onConfirm: list => {
|
||||
close();
|
||||
res(list);
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
export const useFilter = (list: PageMeta[]) => {
|
||||
const [filters, changeFilters] = useState<Filter[]>([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const clickFilter = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (showFilter || filters.length !== 0) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowFilter(!showFilter);
|
||||
}
|
||||
},
|
||||
[filters.length, showFilter]
|
||||
);
|
||||
const onCreateFilter = useCallback(
|
||||
(filter: Filter) => {
|
||||
changeFilters([...filters, filter]);
|
||||
setShowFilter(true);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
return {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters: changeFilters,
|
||||
clickFilter,
|
||||
createFilter: onCreateFilter,
|
||||
filteredList: list.filter(v => {
|
||||
if (v.trash) {
|
||||
return false;
|
||||
}
|
||||
return filterPageByRules(filters, [], v);
|
||||
}),
|
||||
};
|
||||
};
|
||||
export const useSearch = (list: PageMeta[]) => {
|
||||
const [value, onChange] = useState('');
|
||||
return {
|
||||
searchText: value,
|
||||
updateSearchText: onChange,
|
||||
searchedList: value
|
||||
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
|
||||
: list,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
FilterList,
|
||||
VirtualizedPageList,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import clsx from 'clsx';
|
||||
import { type ReactNode, useCallback } from 'react';
|
||||
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { useFilter, useSearch } from './hooks';
|
||||
import { EmptyList } from './select-page';
|
||||
|
||||
export const PagesMode = ({
|
||||
switchMode,
|
||||
collection,
|
||||
updateCollection,
|
||||
buttons,
|
||||
allPageListConfig,
|
||||
}: {
|
||||
collection: Collection;
|
||||
updateCollection: (collection: Collection) => void;
|
||||
buttons: ReactNode;
|
||||
switchMode: ReactNode;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters,
|
||||
clickFilter,
|
||||
createFilter,
|
||||
filteredList,
|
||||
} = useFilter(allPageListConfig.allPages);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
const clearSelected = useCallback(() => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: [],
|
||||
});
|
||||
}, [collection, updateCollection]);
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => allPageListConfig.favoriteRender(page),
|
||||
[allPageListConfig]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={e => updateSearchText(e.target.value)}
|
||||
className={styles.rulesTitle}
|
||||
style={{
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
}}
|
||||
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||
></input>
|
||||
<div className={styles.pagesList}>
|
||||
<div className={styles.pagesTab}>
|
||||
<div className={styles.pagesTabContent}>
|
||||
{switchMode}
|
||||
{!showFilter && filters.length === 0 ? (
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
selected={filters}
|
||||
onSelect={createFilter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
) : (
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
)}
|
||||
</div>
|
||||
{showFilter ? (
|
||||
<div style={{ padding: '12px 16px 16px' }}>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<VirtualizedPageList
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
onSelectedPageIdsChange={ids => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: ids,
|
||||
});
|
||||
}}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
selectedPageIds={collection.allowList}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
></VirtualizedPageList>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.pagesBottom}>
|
||||
<div className={styles.pagesBottomLeft}>
|
||||
<div className={styles.selectedCountTips}>
|
||||
{t['com.affine.selectPage.selected']()}
|
||||
<span
|
||||
style={{ marginLeft: 7 }}
|
||||
className={styles.previewCountTipsHighlight}
|
||||
>
|
||||
{collection.allowList.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
{t['com.affine.editCollection.pages.clear']()}
|
||||
</div>
|
||||
</div>
|
||||
<div>{buttons}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,368 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
CloseIcon,
|
||||
EdgelessIcon,
|
||||
PageIcon,
|
||||
PlusIcon,
|
||||
ToggleCollapseIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { type ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { FilterList } from '../../filter';
|
||||
import { PageList, PageListScrollContainer } from '../../page-list';
|
||||
import { filterPageByRules } from '../../use-collection-manager';
|
||||
import { AffineShapeIcon } from '../affine-shape';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { useSelectPage } from './hooks';
|
||||
|
||||
export const RulesMode = ({
|
||||
collection,
|
||||
updateCollection,
|
||||
reset,
|
||||
buttons,
|
||||
switchMode,
|
||||
allPageListConfig,
|
||||
}: {
|
||||
collection: Collection;
|
||||
updateCollection: (collection: Collection) => void;
|
||||
reset: () => void;
|
||||
buttons: ReactNode;
|
||||
switchMode: ReactNode;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [showPreview, setShowPreview] = useState(true);
|
||||
const allowListPages: PageMeta[] = [];
|
||||
const rulesPages: PageMeta[] = [];
|
||||
const [showTips, setShowTips] = useState(false);
|
||||
useEffect(() => {
|
||||
setShowTips(!localStorage.getItem('hide-rules-mode-include-page-tips'));
|
||||
}, []);
|
||||
const hideTips = useCallback(() => {
|
||||
setShowTips(false);
|
||||
localStorage.setItem('hide-rules-mode-include-page-tips', 'true');
|
||||
}, []);
|
||||
allPageListConfig.allPages.forEach(v => {
|
||||
if (v.trash) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
collection.filterList.length &&
|
||||
filterPageByRules(collection.filterList, [], v)
|
||||
) {
|
||||
rulesPages.push(v);
|
||||
}
|
||||
if (collection.allowList.includes(v.id)) {
|
||||
allowListPages.push(v);
|
||||
}
|
||||
});
|
||||
const { node: selectPageNode, open } = useSelectPage({ allPageListConfig });
|
||||
const openSelectPage = useCallback(() => {
|
||||
open(collection.allowList).then(
|
||||
ids => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: ids,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
//do nothing
|
||||
}
|
||||
);
|
||||
}, [open, updateCollection, collection]);
|
||||
const [expandInclude, setExpandInclude] = useState(true);
|
||||
return (
|
||||
<>
|
||||
{/*prevents modal autofocus to the first input*/}
|
||||
<input
|
||||
type="text"
|
||||
style={{ width: 0, height: 0 }}
|
||||
onFocus={e => requestAnimationFrame(() => e.target.blur())}
|
||||
/>
|
||||
<div className={clsx(styles.rulesTitle, styles.ellipsis)}>
|
||||
<Trans
|
||||
i18nKey="com.affine.editCollection.rules.tips"
|
||||
values={{
|
||||
highlight: t['com.affine.editCollection.rules.tips.highlight'](),
|
||||
}}
|
||||
>
|
||||
Pages that meet the rules will be added to the current collection{' '}
|
||||
<span className={styles.rulesTitleHighlight}>highlight</span>.
|
||||
</Trans>
|
||||
</div>
|
||||
<div className={styles.rulesContainer}>
|
||||
<div className={styles.rulesContainerLeft}>
|
||||
<div className={styles.rulesContainerLeftTab}>{switchMode}</div>
|
||||
<div className={styles.rulesContainerLeftContent}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={collection.filterList}
|
||||
onChange={useCallback(
|
||||
filterList => updateCollection({ ...collection, filterList }),
|
||||
[collection, updateCollection]
|
||||
)}
|
||||
/>
|
||||
<div className={styles.rulesContainerLeftContentInclude}>
|
||||
<div className={styles.includeTitle}>
|
||||
<ToggleCollapseIcon
|
||||
onClick={() => setExpandInclude(!expandInclude)}
|
||||
className={styles.button}
|
||||
width={24}
|
||||
height={24}
|
||||
style={{
|
||||
transform: expandInclude ? 'rotate(90deg)' : undefined,
|
||||
}}
|
||||
></ToggleCollapseIcon>
|
||||
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||
{t['com.affine.editCollection.rules.include.title']()}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: expandInclude ? 'flex' : 'none',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px 16px',
|
||||
}}
|
||||
>
|
||||
{collection.allowList.map(id => {
|
||||
const page = allPageListConfig.allPages.find(
|
||||
v => v.id === id
|
||||
);
|
||||
return (
|
||||
<div className={styles.includeItem} key={id}>
|
||||
<div className={styles.includeItemContent}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{allPageListConfig.isEdgeless(id) ? (
|
||||
<EdgelessIcon style={{ width: 16, height: 16 }} />
|
||||
) : (
|
||||
<PageIcon style={{ width: 16, height: 16 }} />
|
||||
)}
|
||||
{t[
|
||||
'com.affine.editCollection.rules.include.page'
|
||||
]()}
|
||||
</div>
|
||||
<div className={styles.includeItemContentIs}>
|
||||
{t['com.affine.editCollection.rules.include.is']()}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.includeItemTitle,
|
||||
styles.ellipsis
|
||||
)}
|
||||
>
|
||||
{page?.title || t['Untitled']()}
|
||||
</div>
|
||||
</div>
|
||||
<CloseIcon
|
||||
className={styles.button}
|
||||
onClick={() => {
|
||||
updateCollection({
|
||||
...collection,
|
||||
allowList: collection.allowList.filter(
|
||||
v => v !== id
|
||||
),
|
||||
});
|
||||
}}
|
||||
></CloseIcon>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
onClick={openSelectPage}
|
||||
className={clsx(styles.button, styles.includeAddButton)}
|
||||
>
|
||||
<PlusIcon></PlusIcon>
|
||||
<div
|
||||
style={{ color: 'var(--affine-text-secondary-color)' }}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.include.add']()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showTips ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor:
|
||||
'var(--affine-background-overlay-panel-color)',
|
||||
padding: 10,
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>{t['com.affine.collection.helpInfo']()}</div>
|
||||
<CloseIcon
|
||||
color="var(--affine-icon-color)"
|
||||
onClick={hideTips}
|
||||
className={styles.button}
|
||||
style={{ width: 16, height: 16 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: 10, fontWeight: 600 }}>
|
||||
{t['com.affine.editCollection.rules.include.tipsTitle']()}
|
||||
</div>
|
||||
<div>{t['com.affine.editCollection.rules.include.tips']()}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<PageListScrollContainer
|
||||
className={styles.rulesContainerRight}
|
||||
style={{
|
||||
display: showPreview ? 'flex' : 'none',
|
||||
}}
|
||||
>
|
||||
{rulesPages.length > 0 ? (
|
||||
<PageList
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
pages={rulesPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
) : (
|
||||
<RulesEmpty
|
||||
noRules={collection.filterList.length === 0}
|
||||
fullHeight={allowListPages.length === 0}
|
||||
/>
|
||||
)}
|
||||
{allowListPages.length > 0 ? (
|
||||
<div>
|
||||
<div className={styles.includeListTitle}>
|
||||
{t['com.affine.editCollection.rules.include.title']()}
|
||||
</div>
|
||||
<PageList
|
||||
hideHeader
|
||||
className={styles.resultPages}
|
||||
pages={allowListPages}
|
||||
groupBy={false}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
></PageList>
|
||||
</div>
|
||||
) : null}
|
||||
</PageListScrollContainer>
|
||||
</div>
|
||||
<div className={styles.rulesBottom}>
|
||||
<div className={styles.bottomLeft}>
|
||||
<div
|
||||
className={clsx(
|
||||
styles.button,
|
||||
styles.bottomButton,
|
||||
showPreview && styles.previewActive
|
||||
)}
|
||||
onClick={() => {
|
||||
setShowPreview(!showPreview);
|
||||
}}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.preview']()}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
onClick={reset}
|
||||
>
|
||||
{t['com.affine.editCollection.rules.reset']()}
|
||||
</div>
|
||||
<div className={styles.previewCountTips}>
|
||||
<Trans
|
||||
i18nKey="com.affine.editCollection.rules.countTips"
|
||||
values={{
|
||||
selectedCount: allowListPages.length,
|
||||
filteredCount: rulesPages.length,
|
||||
}}
|
||||
>
|
||||
Selected
|
||||
<span className={styles.previewCountTipsHighlight}>count</span>,
|
||||
filtered
|
||||
<span className={styles.previewCountTipsHighlight}>count</span>
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>{buttons}</div>
|
||||
</div>
|
||||
{selectPageNode}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const RulesEmpty = ({
|
||||
noRules,
|
||||
fullHeight,
|
||||
}: {
|
||||
noRules: boolean;
|
||||
fullHeight: boolean;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: fullHeight ? '100%' : '70%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 18,
|
||||
padding: '48px 0',
|
||||
}}
|
||||
>
|
||||
<AffineShapeIcon />
|
||||
<strong style={{ fontSize: 20, lineHeight: '28px' }}>
|
||||
{noRules
|
||||
? t['com.affine.editCollection.rules.empty.noRules']()
|
||||
: t['com.affine.editCollection.rules.empty.noResults']()}
|
||||
</strong>
|
||||
<div
|
||||
style={{
|
||||
width: '389px',
|
||||
textAlign: 'center',
|
||||
fontSize: 15,
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
>
|
||||
{noRules ? (
|
||||
<Trans i18nKey="com.affine.editCollection.rules.empty.noRules.tips">
|
||||
Please <strong>add rules</strong> to save this collection or switch
|
||||
to <strong>Pages</strong>, use manual selection mode
|
||||
</Trans>
|
||||
) : (
|
||||
t['com.affine.editCollection.rules.empty.noResults.tips']()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { VirtualizedPageList } from '../..';
|
||||
import { FilterList } from '../../filter';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { AffineShapeIcon } from '../affine-shape';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { useFilter, useSearch } from './hooks';
|
||||
export const SelectPage = ({
|
||||
allPageListConfig,
|
||||
init,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
allPageListConfig: AllPageListConfig;
|
||||
init: string[];
|
||||
onConfirm: (pageIds: string[]) => void;
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, onChange] = useState(init);
|
||||
const confirm = useCallback(() => {
|
||||
onConfirm(value);
|
||||
}, [value, onConfirm]);
|
||||
const clearSelected = useCallback(() => {
|
||||
onChange([]);
|
||||
}, []);
|
||||
const {
|
||||
clickFilter,
|
||||
createFilter,
|
||||
filters,
|
||||
showFilter,
|
||||
updateFilters,
|
||||
filteredList,
|
||||
} = useFilter(allPageListConfig.allPages);
|
||||
const { searchText, updateSearchText, searchedList } =
|
||||
useSearch(filteredList);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<input
|
||||
className={styles.rulesTitle}
|
||||
value={searchText}
|
||||
onChange={e => updateSearchText(e.target.value)}
|
||||
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||
></input>
|
||||
<div className={styles.pagesTab}>
|
||||
<div className={styles.pagesTabContent}>
|
||||
<div style={{ fontSize: 12, lineHeight: '20px', fontWeight: 600 }}>
|
||||
{t['com.affine.selectPage.title']()}
|
||||
</div>
|
||||
{!showFilter && filters.length === 0 ? (
|
||||
<Menu
|
||||
items={
|
||||
<VariableSelect
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
selected={filters}
|
||||
onSelect={createFilter}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
</div>
|
||||
</Menu>
|
||||
) : (
|
||||
<FilterIcon
|
||||
className={clsx(styles.icon, styles.button)}
|
||||
onClick={clickFilter}
|
||||
width={24}
|
||||
height={24}
|
||||
></FilterIcon>
|
||||
)}
|
||||
</div>
|
||||
{showFilter ? (
|
||||
<div style={{ padding: '12px 16px 16px' }}>
|
||||
<FilterList
|
||||
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||
value={filters}
|
||||
onChange={updateFilters}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{searchedList.length ? (
|
||||
<VirtualizedPageList
|
||||
className={styles.pageList}
|
||||
pages={searchedList}
|
||||
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||
selectable
|
||||
groupBy={false}
|
||||
onSelectedPageIdsChange={onChange}
|
||||
selectedPageIds={value}
|
||||
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||
/>
|
||||
) : (
|
||||
<EmptyList search={searchText} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.pagesBottom}>
|
||||
<div className={styles.pagesBottomLeft}>
|
||||
<div className={styles.selectedCountTips}>
|
||||
{t['com.affine.selectPage.selected']()}
|
||||
<span
|
||||
style={{ marginLeft: 7 }}
|
||||
className={styles.previewCountTipsHighlight}
|
||||
>
|
||||
{value.length}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(styles.button, styles.bottomButton)}
|
||||
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||
onClick={clearSelected}
|
||||
>
|
||||
{t['com.affine.editCollection.pages.clear']()}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button size="large" onClick={onCancel}>
|
||||
{t['com.affine.editCollection.button.cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.confirmButton}
|
||||
size="large"
|
||||
data-testid="save-collection"
|
||||
type="primary"
|
||||
onClick={confirm}
|
||||
>
|
||||
{t['Confirm']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const EmptyList = ({ search }: { search?: string }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<AffineShapeIcon />
|
||||
<div
|
||||
style={{
|
||||
margin: '18px 0',
|
||||
fontSize: 20,
|
||||
lineHeight: '28px',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{t['com.affine.selectPage.empty']()}
|
||||
</div>
|
||||
{search ? (
|
||||
<div
|
||||
className={styles.ellipsis}
|
||||
style={{ maxWidth: 300, fontSize: 15, lineHeight: '24px' }}
|
||||
>
|
||||
<Trans i18nKey="com.affine.selectPage.empty.tips" values={{ search }}>
|
||||
No page titles contain
|
||||
<span
|
||||
style={{ fontWeight: 600, color: 'var(--affine-primary-color)' }}
|
||||
>
|
||||
search
|
||||
</span>
|
||||
</Trans>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,5 +3,5 @@ export * from './collection-bar';
|
||||
export * from './collection-list';
|
||||
export * from './collection-operations';
|
||||
export * from './create-collection';
|
||||
export * from './edit-collection';
|
||||
export * from './edit-collection/edit-collection';
|
||||
export * from './use-edit-collection';
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type AllPageListConfig,
|
||||
CreateCollectionModal,
|
||||
EditCollectionModal,
|
||||
type EditCollectionMode,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useCallback, useState } from 'react';
|
||||
@@ -9,6 +10,7 @@ import { useCallback, useState } from 'react';
|
||||
export const useEditCollection = (config: AllPageListConfig) => {
|
||||
const [data, setData] = useState<{
|
||||
collection: Collection;
|
||||
mode?: 'page' | 'rule';
|
||||
onConfirm: (collection: Collection) => Promise<void>;
|
||||
}>();
|
||||
const close = useCallback(() => setData(undefined), []);
|
||||
@@ -19,14 +21,19 @@ export const useEditCollection = (config: AllPageListConfig) => {
|
||||
allPageListConfig={config}
|
||||
init={data.collection}
|
||||
open={!!data}
|
||||
mode={data.mode}
|
||||
onOpenChange={close}
|
||||
onConfirm={data.onConfirm}
|
||||
/>
|
||||
) : null,
|
||||
open: (collection: Collection): Promise<Collection> =>
|
||||
open: (
|
||||
collection: Collection,
|
||||
mode?: EditCollectionMode
|
||||
): Promise<Collection> =>
|
||||
new Promise<Collection>(res => {
|
||||
setData({
|
||||
collection,
|
||||
mode,
|
||||
onConfirm: async collection => {
|
||||
res(collection);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { Scrollable } from '../../ui/scrollbar';
|
||||
import { PageGroupHeader, PageMetaListItemRenderer } from './page-group';
|
||||
import { PageListTableHeader } from './page-header';
|
||||
import { PageListInnerWrapper } from './page-list';
|
||||
import * as styles from './page-list.css';
|
||||
import {
|
||||
pageGroupCollapseStateAtom,
|
||||
pageGroupsAtom,
|
||||
pageListPropsAtom,
|
||||
PageListProvider,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import type {
|
||||
PageGroupProps,
|
||||
PageListHandle,
|
||||
VirtualizedPageListProps,
|
||||
} from './types';
|
||||
|
||||
// we have three item types for rendering rows in Virtuoso
|
||||
type VirtuosoItemType =
|
||||
| 'sticky-header'
|
||||
| 'page-group-header'
|
||||
| 'page-item'
|
||||
| 'page-item-spacer';
|
||||
|
||||
interface BaseVirtuosoItem {
|
||||
type: VirtuosoItemType;
|
||||
}
|
||||
|
||||
interface VirtuosoItemStickyHeader extends BaseVirtuosoItem {
|
||||
type: 'sticky-header';
|
||||
}
|
||||
|
||||
interface VirtuosoItemPageItem extends BaseVirtuosoItem {
|
||||
type: 'page-item';
|
||||
data: PageMeta;
|
||||
}
|
||||
|
||||
interface VirtuosoItemPageGroupHeader extends BaseVirtuosoItem {
|
||||
type: 'page-group-header';
|
||||
data: PageGroupProps;
|
||||
}
|
||||
|
||||
interface VirtuosoPageItemSpacer extends BaseVirtuosoItem {
|
||||
type: 'page-item-spacer';
|
||||
data: {
|
||||
height: number;
|
||||
};
|
||||
}
|
||||
|
||||
type VirtuosoItem =
|
||||
| VirtuosoItemStickyHeader
|
||||
| VirtuosoItemPageItem
|
||||
| VirtuosoItemPageGroupHeader
|
||||
| VirtuosoPageItemSpacer;
|
||||
|
||||
/**
|
||||
* Given a list of pages, render a list of pages
|
||||
* Similar to normal PageList, but uses react-virtuoso to render the list (virtual rendering)
|
||||
*/
|
||||
export const VirtualizedPageList = forwardRef<
|
||||
PageListHandle,
|
||||
VirtualizedPageListProps
|
||||
>(function VirtualizedPageList(props, ref) {
|
||||
return (
|
||||
// push pageListProps to the atom so that downstream components can consume it
|
||||
// this makes sure pageListPropsAtom is always populated
|
||||
// @ts-expect-error fix type issues later
|
||||
<PageListProvider initialValues={[[pageListPropsAtom, props]]}>
|
||||
<PageListInnerWrapper {...props} handleRef={ref}>
|
||||
<PageListInner {...props} />
|
||||
</PageListInnerWrapper>
|
||||
</PageListProvider>
|
||||
);
|
||||
});
|
||||
|
||||
const headingAtom = selectAtom(pageListPropsAtom, props => props.heading);
|
||||
|
||||
const PageListHeading = () => {
|
||||
const heading = useAtomValue(headingAtom);
|
||||
return <div className={styles.heading}>{heading}</div>;
|
||||
};
|
||||
|
||||
const useVirtuosoItems = () => {
|
||||
const groups = useAtomValue(pageGroupsAtom);
|
||||
const groupCollapsedState = useAtomValue(pageGroupCollapseStateAtom);
|
||||
|
||||
return useMemo(() => {
|
||||
const items: VirtuosoItem[] = [];
|
||||
|
||||
// 1.
|
||||
// always put sticky header at the top
|
||||
// the visibility of sticky header is inside of PageListTableHeader
|
||||
items.push({
|
||||
type: 'sticky-header',
|
||||
});
|
||||
|
||||
// 2.
|
||||
// iterate groups and add page items
|
||||
for (const group of groups) {
|
||||
// skip empty group header since it will cause issue in virtuoso ("Zero-sized element")
|
||||
if (group.label) {
|
||||
items.push({
|
||||
type: 'page-group-header',
|
||||
data: group,
|
||||
});
|
||||
}
|
||||
// do not render items if the group is collapsed
|
||||
if (!groupCollapsedState[group.id]) {
|
||||
for (const item of group.items) {
|
||||
items.push({
|
||||
type: 'page-item',
|
||||
data: item,
|
||||
});
|
||||
// add a spacer between items (4px), unless it's the last item
|
||||
if (item !== group.items[group.items.length - 1]) {
|
||||
items.push({
|
||||
type: 'page-item-spacer',
|
||||
data: {
|
||||
height: 4,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add a spacer between groups (16px)
|
||||
items.push({
|
||||
type: 'page-item-spacer',
|
||||
data: {
|
||||
height: 16,
|
||||
},
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [groupCollapsedState, groups]);
|
||||
};
|
||||
|
||||
const itemContentRenderer = (_index: number, data: VirtuosoItem) => {
|
||||
switch (data.type) {
|
||||
case 'sticky-header':
|
||||
return <PageListTableHeader />;
|
||||
case 'page-group-header':
|
||||
return <PageGroupHeader {...data.data} />;
|
||||
case 'page-item':
|
||||
return <PageMetaListItemRenderer {...data.data} />;
|
||||
case 'page-item-spacer':
|
||||
return <div style={{ height: data.data.height }} />;
|
||||
}
|
||||
};
|
||||
|
||||
const Scroller = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
||||
>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport {...props} ref={ref}>
|
||||
{children}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
|
||||
Scroller.displayName = 'Scroller';
|
||||
|
||||
const PageListInner = ({
|
||||
atTopStateChange,
|
||||
atTopThreshold,
|
||||
...props
|
||||
}: VirtualizedPageListProps) => {
|
||||
const virtuosoItems = useVirtuosoItems();
|
||||
const [atTop, setAtTop] = useState(false);
|
||||
const handleAtTopStateChange = useCallback(
|
||||
(atTop: boolean) => {
|
||||
setAtTop(atTop);
|
||||
atTopStateChange?.(atTop);
|
||||
},
|
||||
[atTopStateChange]
|
||||
);
|
||||
const components = useMemo(() => {
|
||||
return {
|
||||
Header: props.heading ? PageListHeading : undefined,
|
||||
Scroller: Scroller,
|
||||
};
|
||||
}, [props.heading]);
|
||||
return (
|
||||
<Virtuoso<VirtuosoItem>
|
||||
data-has-scroll-top={!atTop}
|
||||
atTopThreshold={atTopThreshold ?? 0}
|
||||
atTopStateChange={handleAtTopStateChange}
|
||||
components={components}
|
||||
data={virtuosoItems}
|
||||
data-testid="virtualized-page-list"
|
||||
data-total-count={props.pages.length} // for testing, since we do not know the total count in test
|
||||
topItemCount={1} // sticky header
|
||||
totalCount={virtuosoItems.length}
|
||||
itemContent={itemContentRenderer}
|
||||
className={clsx(props.className, styles.root)}
|
||||
// todo: set a reasonable overscan value to avoid blank space?
|
||||
// overscan={100}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const settingHeader = style({
|
||||
height: '68px',
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
paddingBottom: '24px',
|
||||
marginBottom: '24px',
|
||||
});
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const Checkbox = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.root, disabled && styles.disabled)}
|
||||
role="checkbox"
|
||||
{...otherProps}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -30,8 +30,7 @@ export const scrollableViewport = style({
|
||||
});
|
||||
|
||||
globalStyle(`${scrollableViewport} > div`, {
|
||||
maxWidth: '100%',
|
||||
display: 'block !important',
|
||||
display: 'contents !important',
|
||||
});
|
||||
|
||||
export const scrollableContainer = style({
|
||||
@@ -44,7 +43,6 @@ export const scrollbar = style({
|
||||
flexDirection: 'column',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
marginRight: '4px',
|
||||
width: 'var(--scrollbar-width)',
|
||||
height: '100%',
|
||||
opacity: 1,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './scrollable';
|
||||
export * from './scrollbar';
|
||||
|
||||
64
packages/frontend/component/src/ui/scrollbar/scrollable.tsx
Normal file
64
packages/frontend/component/src/ui/scrollbar/scrollable.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, type RefAttributes } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const ScrollableRoot = forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollArea.ScrollAreaProps & RefAttributes<HTMLDivElement>
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<ScrollArea.Root
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clsx(className, styles.scrollableContainerRoot)}
|
||||
>
|
||||
{children}
|
||||
</ScrollArea.Root>
|
||||
);
|
||||
});
|
||||
|
||||
ScrollableRoot.displayName = 'ScrollableRoot';
|
||||
|
||||
export const ScrollableViewport = forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollArea.ScrollAreaViewportProps & RefAttributes<HTMLDivElement>
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<ScrollArea.Viewport
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clsx(className, styles.scrollableViewport)}
|
||||
>
|
||||
{children}
|
||||
</ScrollArea.Viewport>
|
||||
);
|
||||
});
|
||||
|
||||
ScrollableViewport.displayName = 'ScrollableViewport';
|
||||
|
||||
export const ScrollableScrollbar = forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollArea.ScrollAreaScrollbarProps & RefAttributes<HTMLDivElement>
|
||||
>(({ children, className, ...props }, ref) => {
|
||||
return (
|
||||
<ScrollArea.Scrollbar
|
||||
orientation="vertical"
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={clsx(className, styles.scrollbar)}
|
||||
>
|
||||
<ScrollArea.Thumb className={styles.scrollbarThumb} />
|
||||
{children}
|
||||
</ScrollArea.Scrollbar>
|
||||
);
|
||||
});
|
||||
|
||||
ScrollableScrollbar.displayName = 'ScrollableScrollbar';
|
||||
|
||||
export const Scrollable = {
|
||||
Root: ScrollableRoot,
|
||||
Viewport: ScrollableViewport,
|
||||
Scrollbar: ScrollableScrollbar,
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@affine/core",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"scripts": {
|
||||
"build": "yarn -T run build-core",
|
||||
"dev": "yarn -T run dev-core",
|
||||
@@ -24,13 +24,13 @@
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@affine/workspace": "workspace:*",
|
||||
"@blocksuite/block-std": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/block-std": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/icons": "2.1.35",
|
||||
"@blocksuite/lit": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^7.0.2",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
|
||||
@@ -116,10 +116,8 @@ export const pageCollectionBaseAtom =
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
mode: 'rule',
|
||||
filterList: v.filterList,
|
||||
allowList: v.allowList ?? [],
|
||||
pages: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -140,10 +138,8 @@ export const pageCollectionBaseAtom =
|
||||
return {
|
||||
id: v.id,
|
||||
name: v.name,
|
||||
mode: 'rule',
|
||||
filterList: v.filterList,
|
||||
allowList: v.allowList ?? [],
|
||||
pages: [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,11 +41,11 @@ export function registerAffineSettingsCommands({
|
||||
})
|
||||
);
|
||||
|
||||
// color schemes
|
||||
// color modes
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: 'affine:change-color-scheme-to-auto',
|
||||
label: `${t['com.affine.cmdk.affine.color-scheme.to']()} ${t[
|
||||
id: 'affine:change-color-mode-to-auto',
|
||||
label: `${t['com.affine.cmdk.affine.color-mode.to']()} ${t[
|
||||
'com.affine.themeSettings.system'
|
||||
]()}`,
|
||||
category: 'affine:settings',
|
||||
@@ -58,8 +58,8 @@ export function registerAffineSettingsCommands({
|
||||
);
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: 'affine:change-color-scheme-to-dark',
|
||||
label: `${t['com.affine.cmdk.affine.color-scheme.to']()} ${t[
|
||||
id: 'affine:change-color-mode-to-dark',
|
||||
label: `${t['com.affine.cmdk.affine.color-mode.to']()} ${t[
|
||||
'com.affine.themeSettings.dark'
|
||||
]()}`,
|
||||
category: 'affine:settings',
|
||||
@@ -73,8 +73,8 @@ export function registerAffineSettingsCommands({
|
||||
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: 'affine:change-color-scheme-to-light',
|
||||
label: `${t['com.affine.cmdk.affine.color-scheme.to']()} ${t[
|
||||
id: 'affine:change-color-mode-to-light',
|
||||
label: `${t['com.affine.cmdk.affine.color-mode.to']()} ${t[
|
||||
'com.affine.themeSettings.light'
|
||||
]()}`,
|
||||
category: 'affine:settings',
|
||||
|
||||
@@ -117,8 +117,7 @@ const Settings = () => {
|
||||
You are current on the {{ currentPlan }} plan. If you have any
|
||||
questions, please contact our
|
||||
<a
|
||||
href="#"
|
||||
target="_blank"
|
||||
href="mailto:support@toeverything.info"
|
||||
style={{ color: 'var(--affine-link-color)' }}
|
||||
>
|
||||
customer support
|
||||
@@ -139,7 +138,9 @@ const Settings = () => {
|
||||
>
|
||||
{Object.values(SubscriptionRecurring).map(recurring => (
|
||||
<RadioButton key={recurring} value={recurring}>
|
||||
{getRecurringLabel({ recurring, t })}
|
||||
<span className={styles.radioButtonText}>
|
||||
{getRecurringLabel({ recurring, t })}
|
||||
</span>
|
||||
{recurring === SubscriptionRecurring.Yearly && yearlyDiscount && (
|
||||
<span className={styles.radioButtonDiscount}>
|
||||
{t['com.affine.payment.discount-amount']({
|
||||
@@ -163,15 +164,17 @@ const Settings = () => {
|
||||
pushNotification({
|
||||
type: 'success',
|
||||
title: t['com.affine.payment.updated-notify-title'](),
|
||||
message: t['com.affine.payment.updated-notify-msg']({
|
||||
plan:
|
||||
detail.plan === SubscriptionPlan.Free
|
||||
? SubscriptionPlan.Free
|
||||
: getRecurringLabel({
|
||||
message:
|
||||
detail.plan === SubscriptionPlan.Free
|
||||
? t[
|
||||
'com.affine.payment.updated-notify-msg.cancel-subscription'
|
||||
]()
|
||||
: t['com.affine.payment.updated-notify-msg']({
|
||||
plan: getRecurringLabel({
|
||||
recurring: recurring as SubscriptionRecurring,
|
||||
t,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}}
|
||||
{...{ detail, subscription, recurring }}
|
||||
|
||||
@@ -92,7 +92,7 @@ export function getPlanDetail(t: ReturnType<typeof useAFFiNEI18N>) {
|
||||
t['com.affine.payment.benefit-2'](),
|
||||
t['com.affine.payment.benefit-3'](),
|
||||
t['com.affine.payment.benefit-4']({ capacity: '100GB' }),
|
||||
t['com.affine.payment.benefit-5']({ capacity: '500M' }),
|
||||
t['com.affine.payment.benefit-5']({ capacity: '100M' }),
|
||||
t['com.affine.payment.benefit-6']({ capacity: '10' }),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -12,6 +12,13 @@ export const radioButtonDiscount = style({
|
||||
color: 'var(--affine-primary-color)',
|
||||
fontWeight: 400,
|
||||
});
|
||||
export const radioButtonText = style({
|
||||
selectors: {
|
||||
'&:first-letter': {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const planCardsWrapper = style({
|
||||
paddingRight: 'calc(var(--setting-modal-gap-x) + 30px)',
|
||||
@@ -21,6 +28,7 @@ export const planCardsWrapper = style({
|
||||
});
|
||||
|
||||
export const planCard = style({
|
||||
backgroundColor: 'var(--affine-background-primary-color)',
|
||||
minHeight: '426px',
|
||||
minWidth: '258px',
|
||||
borderRadius: '16px',
|
||||
|
||||
@@ -145,7 +145,7 @@ export const SettingModal = ({
|
||||
className={style.suggestionLink}
|
||||
>
|
||||
<span className={style.suggestionLinkIcon}>
|
||||
<ContactWithUsIcon />
|
||||
<ContactWithUsIcon width="16" height="16" />
|
||||
</span>
|
||||
{t['com.affine.settings.suggestion']()}
|
||||
</a>
|
||||
|
||||
@@ -32,13 +32,16 @@ export const suggestionLink = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
alignItems: 'start',
|
||||
lineHeight: '22px',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const suggestionLinkIcon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
marginRight: '12px',
|
||||
display: 'flex',
|
||||
margin: '3px 0',
|
||||
});
|
||||
|
||||
export const footer = style({
|
||||
|
||||
@@ -51,11 +51,7 @@ export const EditorModeSwitch = ({
|
||||
return;
|
||||
}
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!environment.isServer && environment.isMacOs
|
||||
? e.key === 'ß'
|
||||
: e.key === 's' && e.altKey
|
||||
) {
|
||||
if (e.code === 'KeyS' && e.altKey) {
|
||||
e.preventDefault();
|
||||
togglePageMode(pageId);
|
||||
toast(
|
||||
|
||||
@@ -25,18 +25,25 @@ import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { pageSettingFamily } from '../atoms';
|
||||
import { fontStyleOptions } from '../atoms/settings';
|
||||
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
|
||||
import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper';
|
||||
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
|
||||
import { Bookmark } from './bookmark';
|
||||
import * as styles from './page-detail-editor.css';
|
||||
import { editorContainer, pluginContainer } from './page-detail-editor.css';
|
||||
import { TrashButtonGroup } from './pure/trash-button-group';
|
||||
|
||||
function useRouterHash() {
|
||||
return useLocation().hash.substring(1);
|
||||
}
|
||||
|
||||
export type OnLoadEditor = (page: Page, editor: EditorContainer) => () => void;
|
||||
|
||||
export interface PageDetailEditorProps {
|
||||
@@ -64,6 +71,10 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
const meta = useBlockSuitePageMeta(workspace).find(
|
||||
meta => meta.id === pageId
|
||||
);
|
||||
|
||||
const { switchToEdgelessMode, switchToPageMode } =
|
||||
useBlockSuiteMetaHelper(workspace);
|
||||
|
||||
const pageSettingAtom = pageSettingFamily(pageId);
|
||||
const pageSetting = useAtomValue(pageSettingAtom);
|
||||
const currentMode = pageSetting?.mode ?? 'page';
|
||||
@@ -80,6 +91,40 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
return fontStyle.value;
|
||||
}, [appSettings.fontStyle]);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const blockId = useRouterHash();
|
||||
const blockElement = useMemo(() => {
|
||||
if (!blockId || loading) {
|
||||
return null;
|
||||
}
|
||||
return document.querySelector(`[data-block-id="${blockId}"]`);
|
||||
}, [blockId, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blockElement) {
|
||||
setTimeout(
|
||||
() =>
|
||||
blockElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center',
|
||||
}),
|
||||
0
|
||||
);
|
||||
}
|
||||
}, [blockElement]);
|
||||
|
||||
const setEditorMode = useCallback(
|
||||
(mode: 'page' | 'edgeless') => {
|
||||
if (mode === 'edgeless') {
|
||||
switchToEdgelessMode(pageId);
|
||||
} else {
|
||||
switchToPageMode(pageId);
|
||||
}
|
||||
},
|
||||
[switchToEdgelessMode, switchToPageMode, pageId]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor
|
||||
@@ -94,6 +139,7 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
}
|
||||
mode={isPublic ? 'page' : currentMode}
|
||||
page={page}
|
||||
onModeChange={setEditorMode}
|
||||
onInit={useCallback(
|
||||
(page: Page, editor: Readonly<EditorContainer>) => {
|
||||
onInit(page, editor);
|
||||
@@ -138,6 +184,7 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
window.setTimeout(() => {
|
||||
disposes.forEach(dispose => dispose());
|
||||
});
|
||||
setLoading(false);
|
||||
};
|
||||
},
|
||||
[onLoad]
|
||||
|
||||
@@ -39,6 +39,11 @@ import { WorkspaceSubPath } from '../../../shared';
|
||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||
import type { CMDKCommand, CommandContext } from './types';
|
||||
|
||||
interface SearchResultsValue {
|
||||
space: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const cmdkQueryAtom = atom('');
|
||||
export const cmdkValueAtom = atom('');
|
||||
|
||||
@@ -153,7 +158,8 @@ export const pageToCommand = (
|
||||
label?: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
}
|
||||
},
|
||||
blockId?: string
|
||||
): CMDKCommand => {
|
||||
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
|
||||
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
|
||||
@@ -177,7 +183,14 @@ export const pageToCommand = (
|
||||
console.error('current workspace not found');
|
||||
return;
|
||||
}
|
||||
navigationHelper.jumpToPage(currentWorkspaceId, page.id);
|
||||
if (blockId) {
|
||||
return navigationHelper.jumpToPageBlock(
|
||||
currentWorkspaceId,
|
||||
page.id,
|
||||
blockId
|
||||
);
|
||||
}
|
||||
return navigationHelper.jumpToPage(currentWorkspaceId, page.id);
|
||||
},
|
||||
icon: pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />,
|
||||
timestamp: page.updatedDate,
|
||||
@@ -205,17 +218,23 @@ export const usePageCommands = () => {
|
||||
});
|
||||
} else {
|
||||
// queried pages that has matched contents
|
||||
const searchResults = Array.from(
|
||||
workspace.blockSuiteWorkspace.search({ query }).values()
|
||||
) as unknown as { space: string; content: string }[];
|
||||
// TODO: we shall have a debounce for global search here
|
||||
const searchResults = workspace.blockSuiteWorkspace.search({
|
||||
query,
|
||||
}) as unknown as Map<string, SearchResultsValue>;
|
||||
const resultValues = Array.from(searchResults.values());
|
||||
|
||||
const pageIds = searchResults.map(result => {
|
||||
const pageIds = resultValues.map(result => {
|
||||
if (result.space.startsWith('space:')) {
|
||||
return result.space.slice(6);
|
||||
} else {
|
||||
return result.space;
|
||||
}
|
||||
});
|
||||
const reverseMapping: Map<string, string> = new Map();
|
||||
searchResults.forEach((value, key) => {
|
||||
reverseMapping.set(value.space, key);
|
||||
});
|
||||
|
||||
results = pages.map(page => {
|
||||
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
|
||||
@@ -225,17 +244,20 @@ export const usePageCommands = () => {
|
||||
const label = {
|
||||
title: page.title || t['Untitled'](), // Used to ensure that a title exists
|
||||
subTitle:
|
||||
searchResults.find(result => result.space === page.id)?.content ||
|
||||
resultValues.find(result => result.space === page.id)?.content ||
|
||||
'',
|
||||
};
|
||||
|
||||
const blockId = reverseMapping.get(page.id);
|
||||
|
||||
const command = pageToCommand(
|
||||
category,
|
||||
page,
|
||||
store,
|
||||
navigationHelper,
|
||||
t,
|
||||
label
|
||||
label,
|
||||
blockId
|
||||
);
|
||||
|
||||
if (pageIds.includes(page.id)) {
|
||||
@@ -250,7 +272,9 @@ export const usePageCommands = () => {
|
||||
if (results.every(command => command.originalValue !== query)) {
|
||||
results.push({
|
||||
id: 'affine:pages:create-page',
|
||||
label: `${t['com.affine.cmdk.affine.create-new-page-as']()} ${query}`,
|
||||
label: t['com.affine.cmdk.affine.create-new-page-as']({
|
||||
keyWord: query,
|
||||
}),
|
||||
value: 'affine::create-page' + query, // hack to make the page always showing in the search result
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
@@ -263,9 +287,9 @@ export const usePageCommands = () => {
|
||||
|
||||
results.push({
|
||||
id: 'affine:pages:create-edgeless',
|
||||
label: `${t[
|
||||
'com.affine.cmdk.affine.create-new-edgeless-as'
|
||||
]()} ${query}`,
|
||||
label: t['com.affine.cmdk.affine.create-new-edgeless-as']({
|
||||
keyWord: query,
|
||||
}),
|
||||
value: 'affine::create-edgeless' + query, // hack to make the page always showing in the search result
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnimatedCollectionsIcon } from '@affine/component';
|
||||
import { AnimatedCollectionsIcon, toast } from '@affine/component';
|
||||
import {
|
||||
MenuItem as SidebarMenuItem,
|
||||
MenuLinkItem as SidebarMenuLinkItem,
|
||||
@@ -54,10 +54,17 @@ const CollectionRenderer = ({
|
||||
}) => {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const t = useAFFiNEI18N();
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `${Collections_DROP_AREA_PREFIX}${collection.id}`,
|
||||
data: {
|
||||
addToCollection: (id: string) => {
|
||||
if (collection.allowList.includes(id)) {
|
||||
toast(t['com.affine.collection.addPage.alreadyExists']());
|
||||
return;
|
||||
} else {
|
||||
toast(t['com.affine.collection.addPage.success']());
|
||||
}
|
||||
setting.addPage(collection.id, id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
@@ -24,6 +24,6 @@ export const workspaceType = style({
|
||||
});
|
||||
|
||||
export const scrollbar = style({
|
||||
transform: 'translateX(10px)',
|
||||
transform: 'translateX(8px)',
|
||||
width: '4px',
|
||||
});
|
||||
|
||||
@@ -36,10 +36,8 @@ const FilterContainer = ({ workspaceId }: { workspaceId: string }) => {
|
||||
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||
const saveToCollection = useCallback(
|
||||
async (collection: Collection) => {
|
||||
console.log(setting.currentCollection.filterList);
|
||||
await setting.createCollection({
|
||||
...collection,
|
||||
mode: 'rule',
|
||||
filterList: setting.currentCollection.filterList,
|
||||
});
|
||||
navigateHelper.jumpToCollection(workspaceId, collection.id);
|
||||
|
||||
@@ -29,6 +29,19 @@ export function useNavigateHelper() {
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const jumpToPageBlock = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
pageId: string,
|
||||
blockId: string,
|
||||
logic: RouteLogic = RouteLogic.PUSH
|
||||
) => {
|
||||
return navigate(`/workspace/${workspaceId}/${pageId}#${blockId}`, {
|
||||
replace: logic === RouteLogic.REPLACE,
|
||||
});
|
||||
},
|
||||
[navigate]
|
||||
);
|
||||
const jumpToCollection = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
@@ -122,6 +135,7 @@ export function useNavigateHelper() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
jumpToPage,
|
||||
jumpToPageBlock,
|
||||
jumpToPublicWorkspacePage,
|
||||
jumpToSubPath,
|
||||
jumpToIndex,
|
||||
@@ -132,14 +146,15 @@ export function useNavigateHelper() {
|
||||
jumpToCollection,
|
||||
}),
|
||||
[
|
||||
jumpTo404,
|
||||
jumpToExpired,
|
||||
jumpToIndex,
|
||||
jumpToPage,
|
||||
jumpToPageBlock,
|
||||
jumpToPublicWorkspacePage,
|
||||
jumpToSignIn,
|
||||
jumpToSubPath,
|
||||
jumpToIndex,
|
||||
jumpTo404,
|
||||
openPage,
|
||||
jumpToExpired,
|
||||
jumpToSignIn,
|
||||
jumpToCollection,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -15,6 +15,8 @@ export const scrollContainer = style({
|
||||
});
|
||||
|
||||
export const allPagesHeader = style({
|
||||
height: 100,
|
||||
alignItems: 'center',
|
||||
padding: '48px 16px 20px 24px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
@@ -23,7 +25,7 @@ export const allPagesHeader = style({
|
||||
});
|
||||
|
||||
export const allPagesHeaderTitle = style({
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
fontWeight: 500,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
display: 'flex',
|
||||
|
||||
@@ -4,10 +4,9 @@ import {
|
||||
FloatingToolbar,
|
||||
NewPageButton as PureNewPageButton,
|
||||
OperationCell,
|
||||
PageList,
|
||||
type PageListHandle,
|
||||
PageListScrollContainer,
|
||||
useCollectionManager,
|
||||
VirtualizedPageList,
|
||||
} from '@affine/component/page-list';
|
||||
import { WorkspaceFlavour, WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { Trans } from '@affine/i18n';
|
||||
@@ -27,7 +26,6 @@ import clsx from 'clsx';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -145,19 +143,12 @@ const usePageOperationsRenderer = () => {
|
||||
const PageListFloatingToolbar = ({
|
||||
selectedIds,
|
||||
onClose,
|
||||
open,
|
||||
}: {
|
||||
open: boolean;
|
||||
selectedIds: string[];
|
||||
onClose: () => void;
|
||||
}) => {
|
||||
const open = selectedIds.length > 0;
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
@@ -177,11 +168,7 @@ const PageListFloatingToolbar = ({
|
||||
}, [pageMetas, selectedIds, setTrashModal]);
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
className={styles.floatingToolbar}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<FloatingToolbar className={styles.floatingToolbar} open={open}>
|
||||
<FloatingToolbar.Item>
|
||||
<Trans
|
||||
i18nKey="com.affine.page.toolbar.selected"
|
||||
@@ -190,7 +177,7 @@ const PageListFloatingToolbar = ({
|
||||
<div className={styles.toolbarSelectedNumber}>
|
||||
{{ count: selectedIds.length } as any}
|
||||
</div>
|
||||
pages selected
|
||||
selected
|
||||
</Trans>
|
||||
</FloatingToolbar.Item>
|
||||
<FloatingToolbar.Button onClick={onClose} icon={<CloseIcon />} />
|
||||
@@ -245,9 +232,10 @@ export const AllPage = () => {
|
||||
);
|
||||
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
|
||||
const pageListRef = useRef<PageListHandle>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const deselectAllAndToggleSelect = useCallback(() => {
|
||||
setSelectedPageIds([]);
|
||||
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
|
||||
const hideFloatingToolbar = useCallback(() => {
|
||||
pageListRef.current?.toggleSelectable();
|
||||
}, []);
|
||||
|
||||
@@ -257,24 +245,7 @@ export const AllPage = () => {
|
||||
return selectedPageIds.filter(id => ids.includes(id));
|
||||
}, [filteredPageMetas, selectedPageIds]);
|
||||
|
||||
const [showHeaderCreateNewPage, setShowHeaderCreateNewPage] = useState(false);
|
||||
// when PageListScrollContainer scrolls above 40px, show the create new page button on header
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (container) {
|
||||
const handleScroll = () => {
|
||||
setTimeout(() => {
|
||||
const scrollTop = container.scrollTop ?? 0;
|
||||
setShowHeaderCreateNewPage(scrollTop > 40);
|
||||
});
|
||||
};
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, []);
|
||||
const [hideHeaderCreateNewPage, setHideHeaderCreateNewPage] = useState(true);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
@@ -289,7 +260,7 @@ export const AllPage = () => {
|
||||
size="small"
|
||||
className={clsx(
|
||||
styles.headerCreateNewButton,
|
||||
!showHeaderCreateNewPage && styles.headerCreateNewButtonHidden
|
||||
hideHeaderCreateNewPage && styles.headerCreateNewButtonHidden
|
||||
)}
|
||||
>
|
||||
<PlusIcon />
|
||||
@@ -297,37 +268,36 @@ export const AllPage = () => {
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<PageListScrollContainer
|
||||
ref={containerRef}
|
||||
className={styles.scrollContainer}
|
||||
>
|
||||
<PageListHeader />
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<>
|
||||
<PageList
|
||||
ref={pageListRef}
|
||||
selectable="toggle"
|
||||
draggable
|
||||
selectedPageIds={filteredSelectedPageIds}
|
||||
onSelectedPageIdsChange={setSelectedPageIds}
|
||||
pages={filteredPageMetas}
|
||||
clickMode="link"
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
<PageListFloatingToolbar
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onClose={deselectAllAndToggleSelect}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<>
|
||||
<VirtualizedPageList
|
||||
ref={pageListRef}
|
||||
selectable="toggle"
|
||||
draggable
|
||||
atTopThreshold={80}
|
||||
atTopStateChange={setHideHeaderCreateNewPage}
|
||||
onSelectionActiveChange={setShowFloatingToolbar}
|
||||
heading={<PageListHeader />}
|
||||
selectedPageIds={filteredSelectedPageIds}
|
||||
onSelectedPageIdsChange={setSelectedPageIds}
|
||||
pages={filteredPageMetas}
|
||||
rowAsLink
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
)}
|
||||
</PageListScrollContainer>
|
||||
<PageListFloatingToolbar
|
||||
open={showFloatingToolbar && filteredSelectedPageIds.length > 0}
|
||||
selectedIds={filteredSelectedPageIds}
|
||||
onClose={hideFloatingToolbar}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="all"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -93,10 +93,10 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
|
||||
const { updateCollection } = useCollectionManager(collectionsCRUDAtom);
|
||||
const { node, open } = useEditCollection(useAllPageListConfig());
|
||||
const openPageEdit = useCallback(() => {
|
||||
open({ ...collection, mode: 'page' }).then(updateCollection);
|
||||
open({ ...collection }, 'page').then(updateCollection);
|
||||
}, [open, collection, updateCollection]);
|
||||
const openRuleEdit = useCallback(() => {
|
||||
open({ ...collection, mode: 'rule' }).then(updateCollection);
|
||||
open({ ...collection }, 'rule').then(updateCollection);
|
||||
}, [collection, open, updateCollection]);
|
||||
const [showTips, setShowTips] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -277,9 +277,6 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
|
||||
|
||||
const isEmpty = (collection: Collection) => {
|
||||
return (
|
||||
(collection.mode === 'page' && collection.pages.length === 0) ||
|
||||
(collection.mode === 'rule' &&
|
||||
collection.allowList.length === 0 &&
|
||||
collection.filterList.length === 0)
|
||||
collection.allowList.length === 0 && collection.filterList.length === 0
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { filterPage, useCollectionManager } from '@affine/component/page-list';
|
||||
import {
|
||||
filterPage,
|
||||
filterPageByRules,
|
||||
useCollectionManager,
|
||||
} from '@affine/component/page-list';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
@@ -15,7 +19,8 @@ export const useFilteredPageMetas = (
|
||||
) => {
|
||||
const { isPreferredEdgeless } = usePageHelper(workspace);
|
||||
const pageMode = useAtomValue(allPageModeSelectAtom);
|
||||
const { currentCollection } = useCollectionManager(collectionsCRUDAtom);
|
||||
const { currentCollection, isDefault } =
|
||||
useCollectionManager(collectionsCRUDAtom);
|
||||
|
||||
const filteredPageMetas = useMemo(
|
||||
() =>
|
||||
@@ -43,9 +48,22 @@ export const useFilteredPageMetas = (
|
||||
if (!currentCollection) {
|
||||
return true;
|
||||
}
|
||||
return filterPage(currentCollection, pageMeta);
|
||||
return isDefault
|
||||
? filterPageByRules(
|
||||
currentCollection.filterList,
|
||||
currentCollection.allowList,
|
||||
pageMeta
|
||||
)
|
||||
: filterPage(currentCollection, pageMeta);
|
||||
}),
|
||||
[pageMetas, pageMode, isPreferredEdgeless, route, currentCollection]
|
||||
[
|
||||
currentCollection,
|
||||
isDefault,
|
||||
isPreferredEdgeless,
|
||||
pageMetas,
|
||||
pageMode,
|
||||
route,
|
||||
]
|
||||
);
|
||||
|
||||
return filteredPageMetas;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { toast } from '@affine/component';
|
||||
import {
|
||||
PageList,
|
||||
PageListScrollContainer,
|
||||
TrashOperationCell,
|
||||
VirtualizedPageList,
|
||||
} from '@affine/component/page-list';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@@ -61,30 +60,28 @@ export const TrashPage = () => {
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<WorkspaceHeader
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
subPath: WorkspaceSubPath.TRASH,
|
||||
}}
|
||||
/>
|
||||
<div className={styles.root}>
|
||||
<PageListScrollContainer className={styles.scrollContainer}>
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<PageList
|
||||
pages={filteredPageMetas}
|
||||
clickMode="link"
|
||||
groupBy={false}
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="trash"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</PageListScrollContainer>
|
||||
<WorkspaceHeader
|
||||
currentWorkspaceId={currentWorkspace.id}
|
||||
currentEntry={{
|
||||
subPath: WorkspaceSubPath.TRASH,
|
||||
}}
|
||||
/>
|
||||
{filteredPageMetas.length > 0 ? (
|
||||
<VirtualizedPageList
|
||||
pages={filteredPageMetas}
|
||||
rowAsLink
|
||||
groupBy={false}
|
||||
isPreferredEdgeless={isPreferredEdgeless}
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
pageOperationsRenderer={pageOperationsRenderer}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPageList
|
||||
type="trash"
|
||||
blockSuiteWorkspace={currentWorkspace.blockSuiteWorkspace}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -95,16 +95,11 @@ export class WorkspaceSetting {
|
||||
|
||||
deletePagesFromCollection(collection: Collection, idSet: Set<string>) {
|
||||
const newAllowList = collection.allowList.filter(id => !idSet.has(id));
|
||||
const newPages = collection.pages.filter(id => !idSet.has(id));
|
||||
if (
|
||||
newAllowList.length !== collection.allowList.length ||
|
||||
newPages.length !== collection.pages.length
|
||||
) {
|
||||
if (newAllowList.length !== collection.allowList.length) {
|
||||
this.updateCollection(collection.id, old => {
|
||||
return {
|
||||
...old,
|
||||
allowList: newAllowList,
|
||||
pages: newPages,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/electron",
|
||||
"private": true,
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"author": "toeverything",
|
||||
"repository": {
|
||||
"url": "https://github.com/toeverything/AFFiNE",
|
||||
@@ -32,10 +32,10 @@
|
||||
"@affine/sdk": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@affine/vue-hello-world-plugin": "workspace:*",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@electron-forge/cli": "^6.4.2",
|
||||
"@electron-forge/core": "^6.4.2",
|
||||
"@electron-forge/core-utils": "^6.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/graphql",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"description": "Autogenerated GraphQL client for affine.pro",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine/env": "workspace:*",
|
||||
"@blocksuite/block-std": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/block-std": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/lit": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"fake-indexeddb": "^5.0.0",
|
||||
@@ -62,5 +62,5 @@
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
"com.affine.aboutAFFiNE.version.title": "Version",
|
||||
"com.affine.appearanceSettings.clientBorder.description": "Customise the appearance of the client.",
|
||||
"com.affine.appearanceSettings.clientBorder.title": "Client Border Style",
|
||||
"com.affine.appearanceSettings.color.description": "Choose your colour scheme",
|
||||
"com.affine.appearanceSettings.color.title": "Colour Scheme",
|
||||
"com.affine.appearanceSettings.color.description": "Choose your colour mode",
|
||||
"com.affine.appearanceSettings.color.title": "Colour Mode",
|
||||
"com.affine.appearanceSettings.date.title": "Date",
|
||||
"com.affine.appearanceSettings.dateFormat.description": "Customise your date style.",
|
||||
"com.affine.appearanceSettings.dateFormat.title": "Date Format",
|
||||
@@ -394,8 +394,8 @@
|
||||
"com.affine.all-pages.header": "All Pages",
|
||||
"com.affine.collections.header": "Collections",
|
||||
"com.affine.page.group-header.select-all": "Select All",
|
||||
"com.affine.page.toolbar.selected_one": "<0>{{count}}</0> page selected",
|
||||
"com.affine.page.toolbar.selected_others": "<0>{{count}}</0> page(s) selected",
|
||||
"com.affine.page.group-header.clear": "Clear Selection",
|
||||
"com.affine.page.toolbar.selected": "<0>{{count}}</0> selected",
|
||||
"com.affine.collection.allCollections": "All Collections",
|
||||
"com.affine.collection.emptyCollection": "Empty Collection",
|
||||
"com.affine.collection.emptyCollectionDescription": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.",
|
||||
@@ -407,20 +407,25 @@
|
||||
"com.affine.editCollectionName.name": "Name",
|
||||
"com.affine.editCollectionName.name.placeholder": "Collection Name",
|
||||
"com.affine.editCollectionName.createTips": "Collection is a smart folder where you can manually add pages or automatically add pages through rules.",
|
||||
"com.affine.editCollection.rules.include.title": "Selected pages",
|
||||
"com.affine.editCollection.rules.include.page": "Page",
|
||||
"com.affine.editCollection.rules.include.is": "is",
|
||||
"com.affine.editCollection.rules.include.add": "Add include page",
|
||||
"com.affine.editCollection.rules.include.tipsTitle": "What is \"Include\"?",
|
||||
"com.affine.editCollection.rules.include.tips": "\"Include\" refers to manually adding pages rather than automatically adding them through rule matching. You can manually add pages through the \"Add pages\" option or by dragging and dropping (coming soon).",
|
||||
"com.affine.editCollection.rules.include.add": "Add selected page",
|
||||
"com.affine.editCollection.rules.include.tipsTitle": "What is \"Selected pages\"?",
|
||||
"com.affine.editCollection.rules.include.tips": "“Selected pages” refers to manually adding pages rather than automatically adding them through rule matching. You can manually add pages through the “Add selected pages” option or by dragging and dropping.",
|
||||
"com.affine.editCollection.rules.preview": "Preview",
|
||||
"com.affine.editCollection.rules.reset": "Reset",
|
||||
"com.affine.editCollection.rules.countTips.zero": "Showing <1>{{count}}</1> pages.",
|
||||
"com.affine.editCollection.rules.countTips.one": "Showing <1>{{count}}</1> page.",
|
||||
"com.affine.editCollection.rules.countTips.more": "Showing <1>{{count}}</1> pages.",
|
||||
"com.affine.editCollection.rules.countTips": "Selected <1>{{selectedCount}}</1>, filtered <3>{{filteredCount}}</3>",
|
||||
"com.affine.editCollection.rules.empty.noRules": "No Rules",
|
||||
"com.affine.editCollection.rules.empty.noRules.tips": "Please <1>add rules</1> to save this collection or switch to <3>Pages</3>, use manual selection mode",
|
||||
"com.affine.editCollection.rules.empty.noResults": "No Results",
|
||||
"com.affine.editCollection.rules.empty.noResults.tips": "No pages meet the filtering rules",
|
||||
"com.affine.selectPage.title": "Add include page",
|
||||
"com.affine.selectPage.selected": "Selected",
|
||||
"com.affine.selectPage.empty": "Empty",
|
||||
"com.affine.selectPage.empty.tips": "No page titles contain <1>{{search}}</1>",
|
||||
"com.affine.collection.addPage.alreadyExists": "Page already exists",
|
||||
"com.affine.collection.addPage.success": "Added successfully",
|
||||
"Confirm": "Confirm",
|
||||
"Connector": "Connector",
|
||||
"Continue with Google": "Continue with Google",
|
||||
@@ -633,9 +638,9 @@
|
||||
"com.affine.cmdk.affine.new-page": "New Page",
|
||||
"com.affine.cmdk.affine.new-edgeless-page": "New Edgeless",
|
||||
"com.affine.cmdk.affine.new-workspace": "New Workspace",
|
||||
"com.affine.cmdk.affine.create-new-page-as": "Create New Page as:",
|
||||
"com.affine.cmdk.affine.create-new-edgeless-as": "Create New Edgeless as:",
|
||||
"com.affine.cmdk.affine.color-scheme.to": "Change Colour Scheme to",
|
||||
"com.affine.cmdk.affine.create-new-page-as": "New \"{{keyWord}}\" Page",
|
||||
"com.affine.cmdk.affine.create-new-edgeless-as": "New \"{{keyWord}}\" Edgeless",
|
||||
"com.affine.cmdk.affine.color-mode.to": "Change Colour Mode to",
|
||||
"com.affine.cmdk.affine.left-sidebar.expand": "Expand Left Sidebar",
|
||||
"com.affine.cmdk.affine.left-sidebar.collapse": "Collapse Left Sidebar",
|
||||
"com.affine.cmdk.affine.navigation.goto-all-pages": "Go to All Pages",
|
||||
@@ -684,8 +689,8 @@
|
||||
"com.affine.auth.sign-out.confirm-modal.description": "After signing out, the Cloud Workspaces associated with this account will be removed from the current device, and signing in again will add them back.",
|
||||
"com.affine.auth.sign-out.confirm-modal.cancel": "Cancel",
|
||||
"com.affine.auth.sign-out.confirm-modal.confirm": "Sign Out",
|
||||
"com.affine.payment.recurring-yearly": "Annually",
|
||||
"com.affine.payment.recurring-monthly": "Monthly",
|
||||
"com.affine.payment.recurring-yearly": "yearly",
|
||||
"com.affine.payment.recurring-monthly": "monthly",
|
||||
"com.affine.payment.title": "Pricing Plans",
|
||||
"com.affine.payment.subtitle-not-signed-in": "This is the Pricing plans of AFFiNE Cloud. You can sign up or sign in to your account first.",
|
||||
"com.affine.payment.subtitle-active": "You are current on the {{currentPlan}} plan. If you have any questions, please contact our <3>customer support</3>.",
|
||||
@@ -701,11 +706,11 @@
|
||||
"com.affine.payment.change-to": "Change to {{to}} Billing",
|
||||
"com.affine.payment.resume": "Resume",
|
||||
"com.affine.payment.resume-renewal": "Resume Auto-renewal",
|
||||
"com.affine.payment.benefit-1": "Unlimited local workspace",
|
||||
"com.affine.payment.benefit-1": "Unlimited local workspaces",
|
||||
"com.affine.payment.benefit-2": "Unlimited login devices",
|
||||
"com.affine.payment.benefit-3": "Unlimited blocks",
|
||||
"com.affine.payment.benefit-4": "AFFiNE Cloud Storage {{capacity}}",
|
||||
"com.affine.payment.benefit-5": "The maximum file size is {{capacity}}",
|
||||
"com.affine.payment.benefit-4": "{{capacity}} of Cloud Storage",
|
||||
"com.affine.payment.benefit-5": "{{capacity}} of maximum file size",
|
||||
"com.affine.payment.benefit-6": "Number of members per Workspace ≤ {{capacity}}",
|
||||
"com.affine.payment.dynamic-benefit-1": "Best team workspace for collaboration and knowledge distilling.",
|
||||
"com.affine.payment.dynamic-benefit-2": "Focusing on what really matters with team project management and automation.",
|
||||
@@ -728,6 +733,7 @@
|
||||
"com.affine.payment.modal.change.confirm": "Change",
|
||||
"com.affine.payment.updated-notify-title": "Subscription updated",
|
||||
"com.affine.payment.updated-notify-msg": "You have changed your plan to {{plan}} billing.",
|
||||
"com.affine.payment.updated-notify-msg.cancel-subscription": "No further charges will be made starting from the next billing cycle.",
|
||||
"com.affine.storage.maximum-tips": "You have reached the maximum capacity limit for your current account",
|
||||
"com.affine.payment.tag-tooltips": "See all plans",
|
||||
"com.affine.payment.billing-setting.title": "Billing",
|
||||
|
||||
@@ -58,5 +58,5 @@
|
||||
"test": "ava",
|
||||
"version": "napi version"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"./v1/*.json": "./v1/*.json",
|
||||
"./preloading.json": "./preloading.json"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"js-base64": "^3.7.5",
|
||||
"ky": "^1.0.1",
|
||||
"lib0": "^0.2.87",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.1",
|
||||
"next-auth": "^4.23.2",
|
||||
"react": "18.2.0",
|
||||
@@ -46,5 +47,5 @@
|
||||
"vitest": "0.34.6",
|
||||
"ws": "^8.14.2"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
107
packages/frontend/workspace/src/affine/batch-sync-sender.ts
Normal file
107
packages/frontend/workspace/src/affine/batch-sync-sender.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
interface SyncUpdateSender {
|
||||
(
|
||||
guid: string,
|
||||
updates: Uint8Array[]
|
||||
): Promise<{
|
||||
accepted: boolean;
|
||||
retry: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* BatchSyncSender is simple wrapper with vanilla update sync with several advanced features:
|
||||
* - ACK mechanism, send updates sequentially with previous sync request correctly responds with ACK
|
||||
* - batching updates, when waiting for previous ACK, new updates will be buffered and sent in single sync request
|
||||
* - retryable, allow retry when previous sync request failed but with retry flag been set to true
|
||||
*/
|
||||
export class BatchSyncSender {
|
||||
private buffered: Uint8Array[] = [];
|
||||
private job: Promise<void> | null = null;
|
||||
private started = true;
|
||||
|
||||
constructor(
|
||||
private guid: string,
|
||||
private readonly rawSender: SyncUpdateSender
|
||||
) {}
|
||||
|
||||
send(update: Uint8Array) {
|
||||
this.buffered.push(update);
|
||||
this.next();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.started = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.started = true;
|
||||
this.next();
|
||||
}
|
||||
|
||||
private next() {
|
||||
if (!this.started || this.job || !this.buffered.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastIndex = Math.min(
|
||||
this.buffered.length - 1,
|
||||
99 /* max batch updates size */
|
||||
);
|
||||
const updates = this.buffered.slice(0, lastIndex + 1);
|
||||
|
||||
if (updates.length) {
|
||||
this.job = this.rawSender(this.guid, updates)
|
||||
.then(({ accepted, retry }) => {
|
||||
// remove pending updates if updates are accepted
|
||||
if (accepted) {
|
||||
this.buffered.splice(0, lastIndex + 1);
|
||||
}
|
||||
|
||||
// stop when previous sending failed and non-recoverable
|
||||
if (accepted || retry) {
|
||||
// avoid call stack overflow
|
||||
setTimeout(() => {
|
||||
this.next();
|
||||
}, 0);
|
||||
} else {
|
||||
this.stop();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
this.stop();
|
||||
})
|
||||
.finally(() => {
|
||||
this.job = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class MultipleBatchSyncSender {
|
||||
private senders: Record<string, BatchSyncSender> = {};
|
||||
|
||||
constructor(private readonly rawSender: SyncUpdateSender) {}
|
||||
|
||||
async send(guid: string, update: Uint8Array) {
|
||||
return this.getSender(guid).send(update);
|
||||
}
|
||||
|
||||
private getSender(guid: string) {
|
||||
let sender = this.senders[guid];
|
||||
if (!sender) {
|
||||
sender = new BatchSyncSender(guid, this.rawSender);
|
||||
this.senders[guid] = sender;
|
||||
}
|
||||
|
||||
return sender;
|
||||
}
|
||||
|
||||
start() {
|
||||
Object.values(this.senders).forEach(sender => sender.start());
|
||||
}
|
||||
|
||||
stop() {
|
||||
Object.values(this.senders).forEach(sender => sender.stop());
|
||||
}
|
||||
}
|
||||
@@ -59,14 +59,6 @@ export const CRUD: WorkspaceCRUD<WorkspaceFlavour.AFFINE_CLOUD> = {
|
||||
WorkspaceFlavour.AFFINE_CLOUD
|
||||
);
|
||||
|
||||
const datasource = createAffineDataSource(
|
||||
createWorkspace.id,
|
||||
newBlockSuiteWorkspace.doc,
|
||||
newBlockSuiteWorkspace.awarenessStore.awareness
|
||||
);
|
||||
|
||||
await syncDataSourceFromDoc(upstreamWorkspace.doc, datasource);
|
||||
|
||||
Y.applyUpdate(
|
||||
newBlockSuiteWorkspace.doc,
|
||||
Y.encodeStateAsUpdate(upstreamWorkspace.doc)
|
||||
@@ -85,6 +77,16 @@ export const CRUD: WorkspaceCRUD<WorkspaceFlavour.AFFINE_CLOUD> = {
|
||||
})
|
||||
);
|
||||
|
||||
const datasource = createAffineDataSource(
|
||||
createWorkspace.id,
|
||||
newBlockSuiteWorkspace.doc,
|
||||
newBlockSuiteWorkspace.awarenessStore.awareness
|
||||
);
|
||||
|
||||
const disconnect = datasource.onDocUpdate(() => {});
|
||||
await syncDataSourceFromDoc(upstreamWorkspace.doc, datasource);
|
||||
disconnect();
|
||||
|
||||
const provider = createIndexedDBProvider(
|
||||
newBlockSuiteWorkspace.doc,
|
||||
DEFAULT_DB_NAME
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from '@affine/graphql';
|
||||
import { gqlFetcherFactory } from '@affine/graphql';
|
||||
import type { GraphQLError } from 'graphql';
|
||||
import { useMemo } from 'react';
|
||||
import type { Key, SWRConfiguration, SWRResponse } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import type {
|
||||
@@ -70,10 +71,18 @@ export function useQuery<Query extends GraphQLQuery>(
|
||||
options: QueryOptions<Query>,
|
||||
config?: any
|
||||
) {
|
||||
const configWithSuspense: SWRConfiguration = useMemo(
|
||||
() => ({
|
||||
suspense: true,
|
||||
...config,
|
||||
}),
|
||||
[config]
|
||||
);
|
||||
|
||||
return useSWR(
|
||||
() => ['cloud', options.query.id, options.variables],
|
||||
() => fetcher(options),
|
||||
config
|
||||
configWithSuspense
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import type { DocDataSource } from 'y-provider';
|
||||
import type { Doc } from 'yjs';
|
||||
|
||||
import { MultipleBatchSyncSender } from './batch-sync-sender';
|
||||
import {
|
||||
type AwarenessChanges,
|
||||
base64ToUint8Array,
|
||||
@@ -41,8 +42,44 @@ export const createAffineDataSource = (
|
||||
console.warn('important!! please use doc.guid as roomName');
|
||||
}
|
||||
|
||||
logger.debug('createAffineDataSource', id, rootDoc.guid, awareness);
|
||||
logger.debug('createAffineDataSource', id, rootDoc.guid);
|
||||
const socket = getIoManager().socket('/');
|
||||
const syncSender = new MultipleBatchSyncSender(async (guid, updates) => {
|
||||
const payload = await Promise.all(
|
||||
updates.map(update => uint8ArrayToBase64(update))
|
||||
);
|
||||
|
||||
return new Promise(resolve => {
|
||||
socket.emit(
|
||||
'client-update-v2',
|
||||
{
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
updates: payload,
|
||||
},
|
||||
(response: {
|
||||
// TODO: reuse `EventError` with server
|
||||
error?: any;
|
||||
data: any;
|
||||
}) => {
|
||||
// TODO: raise error with different code to users
|
||||
if (response.error) {
|
||||
logger.error('client-update-v2 error', {
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
response,
|
||||
});
|
||||
}
|
||||
|
||||
resolve({
|
||||
accepted: !response.error,
|
||||
// TODO: reuse `EventError` with server
|
||||
retry: response.error?.code === 'INTERNAL',
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
get socket() {
|
||||
@@ -54,78 +91,93 @@ export const createAffineDataSource = (
|
||||
: undefined;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.debug('doc-load', {
|
||||
logger.debug('doc-load-v2', {
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
stateVector,
|
||||
});
|
||||
socket.emit(
|
||||
'doc-load',
|
||||
'doc-load-v2',
|
||||
{
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
stateVector,
|
||||
},
|
||||
(docState: Error | { missing: string; state: string } | null) => {
|
||||
(
|
||||
response: // TODO: reuse `EventError` with server
|
||||
{ error: any } | { data: { missing: string; state: string } }
|
||||
) => {
|
||||
logger.debug('doc-load callback', {
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
stateVector,
|
||||
docState,
|
||||
response,
|
||||
});
|
||||
if (docState instanceof Error) {
|
||||
reject(docState);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(
|
||||
docState
|
||||
? {
|
||||
missing: base64ToUint8Array(docState.missing),
|
||||
state: docState.state
|
||||
? base64ToUint8Array(docState.state)
|
||||
: undefined,
|
||||
}
|
||||
: false
|
||||
);
|
||||
if ('error' in response) {
|
||||
// TODO: result `EventError` with server
|
||||
if (response.error.code === 'DOC_NOT_FOUND') {
|
||||
resolve(false);
|
||||
} else {
|
||||
reject(new Error(response.error.message));
|
||||
}
|
||||
} else {
|
||||
resolve({
|
||||
missing: base64ToUint8Array(response.data.missing),
|
||||
state: response.data.state
|
||||
? base64ToUint8Array(response.data.state)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
sendDocUpdate: async (guid: string, update: Uint8Array) => {
|
||||
logger.debug('client-update', {
|
||||
logger.debug('client-update-v2', {
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
update,
|
||||
});
|
||||
socket.emit('client-update', {
|
||||
workspaceId: rootDoc.guid,
|
||||
guid,
|
||||
update: await uint8ArrayToBase64(update),
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
await syncSender.send(guid, update);
|
||||
},
|
||||
onDocUpdate: callback => {
|
||||
socket.on('connect', () => {
|
||||
socket.emit('client-handshake', rootDoc.guid);
|
||||
});
|
||||
const onUpdate = async (message: {
|
||||
workspaceId: string;
|
||||
guid: string;
|
||||
update: string;
|
||||
updates: string[];
|
||||
}) => {
|
||||
if (message.workspaceId === rootDoc.guid) {
|
||||
callback(message.guid, base64ToUint8Array(message.update));
|
||||
message.updates.forEach(update => {
|
||||
callback(message.guid, base64ToUint8Array(update));
|
||||
});
|
||||
}
|
||||
};
|
||||
socket.on('server-update', onUpdate);
|
||||
const destroyAwareness = setupAffineAwareness(socket, rootDoc, awareness);
|
||||
let destroyAwareness = () => {};
|
||||
socket.on('server-updates', onUpdate);
|
||||
socket.on('connect', () => {
|
||||
socket.emit(
|
||||
'client-handshake',
|
||||
rootDoc.guid,
|
||||
(response: { error?: any }) => {
|
||||
if (!response.error) {
|
||||
syncSender.start();
|
||||
destroyAwareness = setupAffineAwareness(
|
||||
socket,
|
||||
rootDoc,
|
||||
awareness
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
socket.connect();
|
||||
return () => {
|
||||
syncSender.stop();
|
||||
socket.emit('client-leave', rootDoc.guid);
|
||||
socket.off('server-update', onUpdate);
|
||||
socket.off('server-updates', onUpdate);
|
||||
destroyAwareness();
|
||||
socket.disconnect();
|
||||
};
|
||||
@@ -148,7 +200,6 @@ function setupAffineAwareness(
|
||||
if (workspaceId !== rootDoc.guid) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyAwarenessUpdate(
|
||||
awareness,
|
||||
base64ToUint8Array(awarenessUpdate),
|
||||
@@ -174,7 +225,7 @@ function setupAffineAwareness(
|
||||
awarenessUpdate: encodedUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
.catch(err => logger.error(err));
|
||||
};
|
||||
|
||||
const newClientAwarenessInitHandler = () => {
|
||||
@@ -188,7 +239,7 @@ function setupAffineAwareness(
|
||||
awarenessUpdate: encodedAwarenessUpdate,
|
||||
});
|
||||
})
|
||||
.catch(err => console.error(err));
|
||||
.catch(err => logger.error(err));
|
||||
};
|
||||
|
||||
const windowBeforeUnloadHandler = () => {
|
||||
@@ -199,12 +250,10 @@ function setupAffineAwareness(
|
||||
conn.on('new-client-awareness-init', newClientAwarenessInitHandler);
|
||||
awareness.on('update', awarenessUpdate);
|
||||
|
||||
conn.on('connect', () => {
|
||||
conn.emit('awareness-init', rootDoc.guid);
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', windowBeforeUnloadHandler);
|
||||
|
||||
conn.emit('awareness-init', rootDoc.guid);
|
||||
|
||||
return () => {
|
||||
awareness.off('update', awarenessUpdate);
|
||||
conn.off('server-awareness-broadcast', awarenessBroadcast);
|
||||
|
||||
@@ -38,13 +38,10 @@ const createAffineSocketIOProvider: DocProviderCreator = (
|
||||
const lazyProvider = createLazyProvider(doc, dataSource, {
|
||||
origin: 'affine-socket-io',
|
||||
});
|
||||
return {
|
||||
flavour: 'affine-socket-io',
|
||||
...lazyProvider,
|
||||
get status() {
|
||||
return lazyProvider.status;
|
||||
},
|
||||
};
|
||||
|
||||
Object.assign(lazyProvider, { flavour: 'affine-socket-io' });
|
||||
|
||||
return lazyProvider as unknown as AffineSocketIOProvider;
|
||||
};
|
||||
|
||||
const createIndexedDBBackgroundProvider: DocProviderCreator = (
|
||||
|
||||
@@ -38,5 +38,5 @@
|
||||
"react": "*",
|
||||
"react-dom": "*"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Hello world plugin",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"scripts": {
|
||||
"dev": "af dev",
|
||||
"build": "af build"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/image-preview-plugin",
|
||||
"type": "module",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"description": "Image preview plugin",
|
||||
"affinePlugin": {
|
||||
"release": true,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Outline plugin",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"scripts": {
|
||||
"dev": "af dev",
|
||||
"build": "af build"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Vue hello world plugin",
|
||||
"version": "0.10.0-canary.9",
|
||||
"version": "0.10.0-canary.10",
|
||||
"scripts": {
|
||||
"dev": "af dev",
|
||||
"build": "af build"
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@playwright/test": "^1.39.0"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"@types/fs-extra": "^11.0.2",
|
||||
"fs-extra": "^11.1.1"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"fs-extra": "^11.1.1",
|
||||
"playwright": "^1.39.0"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@blocksuite/block-std": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/block-std": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"express": "^4.18.2",
|
||||
"http-proxy-middleware": "^3.0.0-beta.1",
|
||||
"serve": "^14.2.1"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@blocksuite/block-std": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/block-std": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"express": "^4.18.2",
|
||||
"http-proxy-middleware": "^3.0.0-beta.1",
|
||||
"serve": "^14.2.1"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine-test/kit": "workspace:*",
|
||||
"@blocksuite/block-std": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/block-std": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/blocks": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231101080734-aa27dc89-nightly",
|
||||
"@playwright/test": "^1.39.0",
|
||||
"express": "^4.18.2",
|
||||
"http-proxy-middleware": "^3.0.0-beta.1",
|
||||
"serve": "^14.2.1"
|
||||
},
|
||||
"version": "0.10.0-canary.9"
|
||||
"version": "0.10.0-canary.10"
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
checkDatePicker,
|
||||
checkDatePickerMonth,
|
||||
checkFilterName,
|
||||
checkPagesCount,
|
||||
clickDatePicker,
|
||||
createFirstFilter,
|
||||
createPageWithTag,
|
||||
fillDatePicker,
|
||||
getPagesCount,
|
||||
selectDateFromDatePicker,
|
||||
selectMonthFromMonthPicker,
|
||||
selectTag,
|
||||
@@ -89,8 +89,7 @@ test('allow creation of filters by created time', async ({ page }) => {
|
||||
await clickNewPageButton(page);
|
||||
await clickSideBarAllPageButton(page);
|
||||
await waitForAllPagesLoad(page);
|
||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
||||
const pageCount = pages.length;
|
||||
const pageCount = await getPagesCount(page);
|
||||
expect(pageCount).not.toBe(0);
|
||||
await createFirstFilter(page, 'Created');
|
||||
await checkFilterName(page, 'after');
|
||||
@@ -98,11 +97,11 @@ test('allow creation of filters by created time', async ({ page }) => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
await checkDatePicker(page, yesterday);
|
||||
await checkPagesCount(page, 1);
|
||||
expect(await getPagesCount(page)).toBe(1);
|
||||
// change date
|
||||
const today = new Date();
|
||||
await fillDatePicker(page, today);
|
||||
await checkPagesCount(page, 0);
|
||||
expect(await getPagesCount(page)).toBe(0);
|
||||
// change filter
|
||||
await page.getByTestId('filter-name').click();
|
||||
await page.getByTestId('filler-tag-before').click();
|
||||
@@ -110,7 +109,7 @@ test('allow creation of filters by created time', async ({ page }) => {
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
await fillDatePicker(page, tomorrow);
|
||||
await checkDatePicker(page, tomorrow);
|
||||
await checkPagesCount(page, pageCount);
|
||||
expect(await getPagesCount(page)).toBe(pageCount);
|
||||
});
|
||||
|
||||
test('creation of filters by created time, then click date picker to modify the date', async ({
|
||||
@@ -121,8 +120,7 @@ test('creation of filters by created time, then click date picker to modify the
|
||||
await clickNewPageButton(page);
|
||||
await clickSideBarAllPageButton(page);
|
||||
await waitForAllPagesLoad(page);
|
||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
||||
const pageCount = pages.length;
|
||||
const pageCount = await getPagesCount(page);
|
||||
expect(pageCount).not.toBe(0);
|
||||
await createFirstFilter(page, 'Created');
|
||||
await checkFilterName(page, 'after');
|
||||
@@ -130,11 +128,11 @@ test('creation of filters by created time, then click date picker to modify the
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
await checkDatePicker(page, yesterday);
|
||||
await checkPagesCount(page, 1);
|
||||
expect(await getPagesCount(page)).toBe(1);
|
||||
// change date
|
||||
const today = new Date();
|
||||
await selectDateFromDatePicker(page, today);
|
||||
await checkPagesCount(page, 0);
|
||||
expect(await getPagesCount(page)).toBe(0);
|
||||
// change filter
|
||||
await page.locator('[data-testid="filter-name"]').click();
|
||||
await page.getByTestId('filler-tag-before').click();
|
||||
@@ -142,7 +140,7 @@ test('creation of filters by created time, then click date picker to modify the
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
await selectDateFromDatePicker(page, tomorrow);
|
||||
await checkDatePicker(page, tomorrow);
|
||||
await checkPagesCount(page, pageCount);
|
||||
expect(await getPagesCount(page)).toBe(pageCount);
|
||||
});
|
||||
|
||||
test('use monthpicker to modify the month of datepicker', async ({ page }) => {
|
||||
@@ -174,8 +172,7 @@ test('allow creation of filters by tags', async ({ page }) => {
|
||||
await waitForEditorLoad(page);
|
||||
await clickSideBarAllPageButton(page);
|
||||
await waitForAllPagesLoad(page);
|
||||
const pages = await page.locator('[data-testid="page-list-item"]').all();
|
||||
const pageCount = pages.length;
|
||||
const pageCount = await getPagesCount(page);
|
||||
expect(pageCount).not.toBe(0);
|
||||
await createFirstFilter(page, 'Tags');
|
||||
await checkFilterName(page, 'is not empty');
|
||||
@@ -188,12 +185,12 @@ test('allow creation of filters by tags', async ({ page }) => {
|
||||
await createPageWithTag(page, { title: 'Page B', tags: ['B'] });
|
||||
await clickSideBarAllPageButton(page);
|
||||
await checkFilterName(page, 'is not empty');
|
||||
await checkPagesCount(page, pagesWithTagsCount + 2);
|
||||
expect(await getPagesCount(page)).toBe(pagesWithTagsCount + 2);
|
||||
await changeFilter(page, 'contains all');
|
||||
await checkPagesCount(page, pageCount + 2);
|
||||
expect(await getPagesCount(page)).toBe(pageCount + 2);
|
||||
await selectTag(page, 'A');
|
||||
await checkPagesCount(page, 1);
|
||||
expect(await getPagesCount(page)).toBe(1);
|
||||
await changeFilter(page, 'does not contains all');
|
||||
await selectTag(page, 'B');
|
||||
await checkPagesCount(page, pageCount + 1);
|
||||
expect(await getPagesCount(page)).toBe(pageCount + 1);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test } from '@affine-test/kit/playwright';
|
||||
import { getPagesCount } from '@affine-test/kit/utils/filter';
|
||||
import { openHomePage } from '@affine-test/kit/utils/load-page';
|
||||
import { waitForEditorLoad } from '@affine-test/kit/utils/page-logic';
|
||||
import { clickSideBarAllPageButton } from '@affine-test/kit/utils/sidebar';
|
||||
@@ -48,13 +49,11 @@ test('create one workspace in the workspace list', async ({
|
||||
await page.keyboard.press('Escape');
|
||||
await clickSideBarAllPageButton(page);
|
||||
await page.waitForTimeout(2000);
|
||||
const pageList = page.locator('[data-testid=page-list-item]');
|
||||
const result = await pageList.count();
|
||||
const result = await getPagesCount(page);
|
||||
expect(result).toBe(13);
|
||||
await page.reload();
|
||||
await page.waitForTimeout(4000);
|
||||
const pageList1 = page.locator('[data-testid=page-list-item]');
|
||||
const result1 = await pageList1.count();
|
||||
const result1 = await getPagesCount(page);
|
||||
expect(result1).toBe(13);
|
||||
const currentWorkspace = await workspace.current();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user