Compare commits

..

15 Commits

Author SHA1 Message Date
Joooye_34
fb0aaabe53 v0.10.0-canary.10 2023-11-02 23:40:34 +08:00
Cats Juice
de33967a73 fix(component): flex setting-modal header & footer (#4818) 2023-11-02 15:11:20 +00:00
Peng Xiao
65321e39cc refactor(component): virtual rendering page list (#4775)
Co-authored-by: Joooye_34 <Joooye1991@gmail.com>
2023-11-02 14:21:01 +00:00
liuyi
a3906bf92b fix(server): do not return subscription if not active (#4820) 2023-11-02 14:17:26 +00:00
JimmFly
0a88be7771 feat(core): add jump to block for cmdk (#4802) 2023-11-02 11:49:49 +00:00
3720
7068d5f38a feat(core): remove mode and pages field from Collection (#4817) 2023-11-02 10:47:43 +00:00
LongYinan
bf17b4789b Merge pull request #4819 from toeverything/61/hotfix
fix(server): remove awareness states cache
2023-11-02 18:31:19 +08:00
forehalo
5e9efbffa3 fix(server): page variant may exist 2023-11-02 18:25:30 +08:00
forehalo
7e516236f5 fix(workspace): remove awareness states cache 2023-11-02 18:24:00 +08:00
Hongtao Lye
15024c6c8a chore: bump blocksuite (#4801) 2023-11-02 09:12:23 +00:00
liuyi
6a93203d68 feat(server): sync data with ack (#4791) 2023-11-02 09:05:28 +00:00
JimmFly
af9663d3e7 fix(core): adjust payment related text (#4797) 2023-11-02 08:22:54 +00:00
EYHN
1d7e3dd570 fix(core): currentUser undefined when all workspaces deleted (#4812) 2023-11-02 07:20:08 +00:00
EYHN
6ef02fbc38 fix(core): fix block suite edit mode switch shortcut (#4813) 2023-11-02 07:17:44 +00:00
EYHN
604c3da9fe docs: change yarn version in BUILDING.md (#4811) 2023-11-02 03:56:02 +00:00
115 changed files with 3616 additions and 1943 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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;
};
};

View File

@@ -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;
}
}
}

View File

@@ -270,6 +270,7 @@ export class UserSubscriptionResolver {
return this.db.userSubscription.findUnique({
where: {
userId: user.id,
status: SubscriptionStatus.Active,
},
});
}

View 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.`
);
}
}

View File

@@ -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';
}
}

View File

@@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql';
export enum DocVariant {
Workspace = 'workspace',
Page = 'page',
Space = 'space',
Settings = 'settings',
Unknown = 'unknown',

View File

@@ -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);
});

View File

@@ -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"
},

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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(),

View File

@@ -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"
}

View File

@@ -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"
},

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -11,3 +11,4 @@ export * from './types';
export * from './use-collection-manager';
export * from './utils';
export * from './view';
export * from './virtualized-page-list';

View File

@@ -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',
});

View File

@@ -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 />
) : (

View File

@@ -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>
);
};

View File

@@ -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>
);

View File

@@ -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,
},
},

View File

@@ -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>
);
});

View File

@@ -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>

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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);
};

View File

@@ -0,0 +1,3 @@
// add dblclick & esc to document when page selection is active
//
export function usePageSelectionEvents() {}

View File

@@ -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),
};
};

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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>
);
};

View File

@@ -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({

View File

@@ -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;
};

View File

@@ -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,
};
};

View File

@@ -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>
</>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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';

View File

@@ -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);
},

View File

@@ -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}
/>
);
};

View File

@@ -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',
});

View File

@@ -57,6 +57,7 @@ export const Checkbox = ({
return (
<div
className={clsx(styles.root, disabled && styles.disabled)}
role="checkbox"
{...otherProps}
>
{icon}

View File

@@ -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,

View File

@@ -1 +1,2 @@
export * from './scrollable';
export * from './scrollbar';

View 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,
};

View File

@@ -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",

View File

@@ -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: [],
};
});
}

View File

@@ -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',

View File

@@ -117,8 +117,7 @@ const Settings = () => {
You are current on the {{ currentPlan }} plan. If you have any
questions, please contact our&nbsp;
<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 }}

View File

@@ -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' }),
],
},

View File

@@ -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',

View File

@@ -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>

View File

@@ -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({

View File

@@ -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(

View File

@@ -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]

View File

@@ -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 () => {

View File

@@ -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);
});

View File

@@ -24,6 +24,6 @@ export const workspaceType = style({
});
export const scrollbar = style({
transform: 'translateX(10px)',
transform: 'translateX(8px)',
width: '4px',
});

View File

@@ -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);

View File

@@ -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,
]
);

View File

@@ -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',

View File

@@ -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>
);
};

View File

@@ -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
);
};

View File

@@ -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;

View File

@@ -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>
</>
);

View File

@@ -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,
};
});
}

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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",

View File

@@ -58,5 +58,5 @@
"test": "ava",
"version": "napi version"
},
"version": "0.10.0-canary.9"
"version": "0.10.0-canary.10"
}

View File

@@ -7,5 +7,5 @@
"./v1/*.json": "./v1/*.json",
"./preloading.json": "./preloading.json"
},
"version": "0.10.0-canary.9"
"version": "0.10.0-canary.10"
}

View File

@@ -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"
}

View 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());
}
}

View File

@@ -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

View File

@@ -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
);
}

View File

@@ -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);

View File

@@ -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 = (

View File

@@ -38,5 +38,5 @@
"react": "*",
"react-dom": "*"
},
"version": "0.10.0-canary.9"
"version": "0.10.0-canary.10"
}

View File

@@ -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"

View File

@@ -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,

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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);
});

View File

@@ -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