Merge branch 'canary' into stable

This commit is contained in:
李华桥
2023-11-23 21:31:42 +08:00
357 changed files with 16759 additions and 12832 deletions

View File

@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "blobs" ADD COLUMN "deleted_at" TIMESTAMPTZ(6);
-- CreateTable
CREATE TABLE "snapshot_histories" (
"workspace_id" VARCHAR(36) NOT NULL,
"guid" VARCHAR(36) NOT NULL,
"timestamp" TIMESTAMPTZ(6) NOT NULL,
"blob" BYTEA NOT NULL,
"state" BYTEA,
"expired_at" TIMESTAMPTZ(6) NOT NULL,
CONSTRAINT "snapshot_histories_pkey" PRIMARY KEY ("workspace_id","guid","timestamp")
);

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.10.2",
"version": "0.10.3-canary.2",
"description": "Affine Node.js server",
"type": "module",
"bin": {
@@ -18,42 +18,43 @@
"predeploy": "yarn prisma migrate deploy && node --es-module-specifier-resolution node ./dist/data/app.js run"
},
"dependencies": {
"@apollo/server": "^4.9.4",
"@auth/prisma-adapter": "^1.0.3",
"@aws-sdk/client-s3": "^3.433.0",
"@apollo/server": "^4.9.5",
"@auth/prisma-adapter": "^1.0.7",
"@aws-sdk/client-s3": "^3.454.0",
"@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.17.0",
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
"@keyv/redis": "^2.8.0",
"@nestjs/apollo": "^12.0.9",
"@nestjs/common": "^10.2.7",
"@nestjs/core": "^10.2.7",
"@nestjs/event-emitter": "^2.0.2",
"@nestjs/graphql": "^12.0.9",
"@nestjs/platform-express": "^10.2.7",
"@nestjs/platform-socket.io": "^10.2.7",
"@nestjs/throttler": "^5.0.0",
"@nestjs/websockets": "^10.2.7",
"@nestjs/apollo": "^12.0.11",
"@nestjs/common": "^10.2.10",
"@nestjs/core": "^10.2.10",
"@nestjs/event-emitter": "^2.0.3",
"@nestjs/graphql": "^12.0.11",
"@nestjs/platform-express": "^10.2.10",
"@nestjs/platform-socket.io": "^10.2.10",
"@nestjs/schedule": "^4.0.0",
"@nestjs/throttler": "^5.0.1",
"@nestjs/websockets": "^10.2.10",
"@node-rs/argon2": "^1.5.2",
"@node-rs/crc32": "^1.7.2",
"@node-rs/jsonwebtoken": "^0.2.3",
"@opentelemetry/api": "^1.6.0",
"@opentelemetry/core": "^1.17.1",
"@opentelemetry/instrumentation": "^0.44.0",
"@opentelemetry/instrumentation-graphql": "^0.35.2",
"@opentelemetry/instrumentation-http": "^0.44.0",
"@opentelemetry/instrumentation-ioredis": "^0.35.2",
"@opentelemetry/instrumentation-nestjs-core": "^0.33.2",
"@opentelemetry/instrumentation-socket.io": "^0.34.2",
"@opentelemetry/sdk-metrics": "^1.17.1",
"@opentelemetry/sdk-node": "^0.44.0",
"@opentelemetry/sdk-trace-node": "^1.17.1",
"@prisma/client": "^5.4.2",
"@prisma/instrumentation": "^5.4.2",
"@opentelemetry/api": "^1.7.0",
"@opentelemetry/core": "^1.18.1",
"@opentelemetry/instrumentation": "^0.45.1",
"@opentelemetry/instrumentation-graphql": "^0.36.0",
"@opentelemetry/instrumentation-http": "^0.45.1",
"@opentelemetry/instrumentation-ioredis": "^0.35.3",
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
"@opentelemetry/instrumentation-socket.io": "^0.34.3",
"@opentelemetry/sdk-metrics": "^1.18.1",
"@opentelemetry/sdk-node": "^0.45.1",
"@opentelemetry/sdk-trace-node": "^1.18.1",
"@prisma/client": "^5.6.0",
"@prisma/instrumentation": "^5.6.0",
"@socket.io/redis-adapter": "^8.2.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"file-type": "^18.5.0",
"file-type": "^18.7.0",
"get-stream": "^8.0.1",
"graphql": "^16.8.1",
"graphql-type-json": "^0.3.2",
@@ -61,49 +62,49 @@
"ioredis": "^5.3.2",
"keyv": "^4.5.4",
"lodash-es": "^4.17.21",
"nanoid": "^5.0.1",
"nest-commander": "^3.12.0",
"nanoid": "^5.0.3",
"nest-commander": "^3.12.2",
"nestjs-throttler-storage-redis": "^0.4.1",
"next-auth": "^4.23.2",
"nodemailer": "^6.9.6",
"next-auth": "^4.24.5",
"nodemailer": "^6.9.7",
"on-headers": "^1.0.2",
"parse-duration": "^1.1.0",
"pretty-time": "^1.1.0",
"prisma": "^5.4.2",
"prisma": "^5.6.0",
"prom-client": "^15.0.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"semver": "^7.5.4",
"socket.io": "^4.7.2",
"stripe": "^14.1.0",
"stripe": "^14.5.0",
"ws": "^8.14.2",
"yjs": "^13.6.8"
"yjs": "^13.6.10"
},
"devDependencies": {
"@affine-test/kit": "workspace:*",
"@affine/storage": "workspace:*",
"@napi-rs/image": "^1.7.0",
"@nestjs/testing": "^10.2.7",
"@types/cookie-parser": "^1.4.4",
"@types/engine.io": "^3.1.8",
"@types/express": "^4.17.19",
"@types/graphql-upload": "^16.0.3",
"@nestjs/testing": "^10.2.10",
"@types/cookie-parser": "^1.4.6",
"@types/engine.io": "^3.1.10",
"@types/express": "^4.17.21",
"@types/graphql-upload": "^16.0.5",
"@types/keyv": "^4.2.0",
"@types/lodash-es": "^4.17.9",
"@types/node": "^18.18.5",
"@types/nodemailer": "^6.4.11",
"@types/on-headers": "^1.0.1",
"@types/pretty-time": "^1.1.3",
"@types/sinon": "^10.0.19",
"@types/supertest": "^2.0.14",
"@types/ws": "^8.5.7",
"@types/lodash-es": "^4.17.11",
"@types/node": "^20.9.3",
"@types/nodemailer": "^6.4.14",
"@types/on-headers": "^1.0.3",
"@types/pretty-time": "^1.1.5",
"@types/sinon": "^17.0.2",
"@types/supertest": "^2.0.16",
"@types/ws": "^8.5.10",
"ava": "^5.3.1",
"c8": "^8.0.1",
"nodemon": "^3.0.1",
"sinon": "^16.1.0",
"sinon": "^17.0.1",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
"typescript": "^5.3.2"
},
"ava": {
"extensions": {

View File

@@ -164,12 +164,14 @@ model VerificationToken {
}
model Blob {
id Int @id @default(autoincrement()) @db.Integer
hash String @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
blob Bytes @db.ByteA
id Int @id @default(autoincrement()) @db.Integer
hash String @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
blob Bytes @db.ByteA
length BigInt
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
// not for keeping, but for snapshot history
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6)
@@unique([workspaceId, hash])
@@map("blobs")
@@ -191,8 +193,8 @@ model OptimizedBlob {
// the latest snapshot of each doc that we've seen
// Snapshot + Updates are the latest state of the doc
model Snapshot {
id String @default(uuid()) @map("guid") @db.VarChar
workspaceId String @map("workspace_id") @db.VarChar
id String @default(uuid()) @map("guid") @db.VarChar
blob Bytes @db.ByteA
seq Int @default(0) @db.Integer
state Bytes? @db.ByteA
@@ -214,6 +216,18 @@ model Update {
@@map("updates")
}
model SnapshotHistory {
workspaceId String @map("workspace_id") @db.VarChar(36)
id String @map("guid") @db.VarChar(36)
timestamp DateTime @db.Timestamptz(6)
blob Bytes @db.ByteA
state Bytes? @db.ByteA
expiredAt DateTime @map("expired_at") @db.Timestamptz(6)
@@id([workspaceId, id, timestamp])
@@map("snapshot_histories")
}
model NewFeaturesWaitingList {
id String @id @default(uuid()) @db.VarChar
email String @unique

View File

@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { CacheModule } from './cache';
import { ConfigModule } from './config';
import { MetricsModule } from './metrics';
import { BusinessModules } from './modules';
@@ -10,17 +11,19 @@ import { SessionModule } from './session';
import { StorageModule } from './storage';
import { RateLimiterModule } from './throttler';
const BasicModules = [
PrismaModule,
ConfigModule.forRoot(),
CacheModule,
StorageModule.forRoot(),
MetricsModule,
SessionModule,
RateLimiterModule,
AuthModule,
];
@Module({
imports: [
PrismaModule,
ConfigModule.forRoot(),
StorageModule.forRoot(),
MetricsModule,
SessionModule,
RateLimiterModule,
AuthModule,
...BusinessModules,
],
imports: [...BasicModules, ...BusinessModules],
controllers: [AppController],
})
export class AppModule {}

View File

@@ -0,0 +1,330 @@
import Keyv from 'keyv';
export interface CacheSetOptions {
// in milliseconds
ttl?: number;
}
// extends if needed
export interface Cache {
// standard operation
get<T = unknown>(key: string): Promise<T | undefined>;
set<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions
): Promise<boolean>;
setnx<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions
): Promise<boolean>;
increase(key: string, count?: number): Promise<number>;
decrease(key: string, count?: number): Promise<number>;
delete(key: string): Promise<boolean>;
has(key: string): Promise<boolean>;
ttl(key: string): Promise<number>;
expire(key: string, ttl: number): Promise<boolean>;
// list operations
pushBack<T = unknown>(key: string, ...values: T[]): Promise<number>;
pushFront<T = unknown>(key: string, ...values: T[]): Promise<number>;
len(key: string): Promise<number>;
list<T = unknown>(key: string, start: number, end: number): Promise<T[]>;
popFront<T = unknown>(key: string, count?: number): Promise<T[]>;
popBack<T = unknown>(key: string, count?: number): Promise<T[]>;
// map operations
mapSet<T = unknown>(
map: string,
key: string,
value: T,
opts: CacheSetOptions
): Promise<boolean>;
mapIncrease(map: string, key: string, count?: number): Promise<number>;
mapDecrease(map: string, key: string, count?: number): Promise<number>;
mapGet<T = unknown>(map: string, key: string): Promise<T | undefined>;
mapDelete(map: string, key: string): Promise<boolean>;
mapKeys(map: string): Promise<string[]>;
mapRandomKey(map: string): Promise<string | undefined>;
mapLen(map: string): Promise<number>;
}
export class LocalCache implements Cache {
private readonly kv: Keyv;
constructor() {
this.kv = new Keyv();
}
// standard operation
async get<T = unknown>(key: string): Promise<T | undefined> {
return this.kv.get(key).catch(() => undefined);
}
async set<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
return this.kv
.set(key, value, opts.ttl)
.then(() => true)
.catch(() => false);
}
async setnx<T = unknown>(
key: string,
value: T,
opts?: CacheSetOptions | undefined
): Promise<boolean> {
if (!(await this.has(key))) {
return this.set(key, value, opts);
}
return false;
}
async increase(key: string, count: number = 1): Promise<number> {
const prev = (await this.get(key)) ?? 0;
if (typeof prev !== 'number') {
throw new Error(
`Expect a Number keyed by ${key}, but found ${typeof prev}`
);
}
const curr = prev + count;
return (await this.set(key, curr)) ? curr : prev;
}
async decrease(key: string, count: number = 1): Promise<number> {
return this.increase(key, -count);
}
async delete(key: string): Promise<boolean> {
return this.kv.delete(key).catch(() => false);
}
async has(key: string): Promise<boolean> {
return this.kv.has(key).catch(() => false);
}
async ttl(key: string): Promise<number> {
return this.kv
.get(key, { raw: true })
.then(raw => (raw?.expires ? raw.expires - Date.now() : Infinity))
.catch(() => 0);
}
async expire(key: string, ttl: number): Promise<boolean> {
const value = await this.kv.get(key);
return this.set(key, value, { ttl });
}
// list operations
private async getArray<T = unknown>(key: string) {
const raw = await this.kv.get(key, { raw: true });
if (raw && !Array.isArray(raw.value)) {
throw new Error(
`Expect an Array keyed by ${key}, but found ${raw.value}`
);
}
return raw as Keyv.DeserializedData<T[]>;
}
private async setArray<T = unknown>(
key: string,
value: T[],
opts: CacheSetOptions = {}
) {
return this.set(key, value, opts).then(() => value.length);
}
async pushBack<T = unknown>(key: string, ...values: T[]): Promise<number> {
let list: any[] = [];
let ttl: number | undefined = undefined;
const raw = await this.getArray(key);
if (raw) {
list = raw.value;
if (raw.expires) {
ttl = raw.expires - Date.now();
}
}
list = list.concat(values);
return this.setArray(key, list, { ttl });
}
async pushFront<T = unknown>(key: string, ...values: T[]): Promise<number> {
let list: any[] = [];
let ttl: number | undefined = undefined;
const raw = await this.getArray(key);
if (raw) {
list = raw.value;
if (raw.expires) {
ttl = raw.expires - Date.now();
}
}
list = values.concat(list);
return this.setArray(key, list, { ttl });
}
async len(key: string): Promise<number> {
return this.getArray(key).then(v => v?.value.length ?? 0);
}
/**
* list array elements with `[start, end]`
* the end indice is inclusive
*/
async list<T = unknown>(
key: string,
start: number,
end: number
): Promise<T[]> {
const raw = await this.getArray<T>(key);
if (raw?.value) {
start = (raw.value.length + start) % raw.value.length;
end = ((raw.value.length + end) % raw.value.length) + 1;
return raw.value.slice(start, end);
} else {
return [];
}
}
private async trim<T = unknown>(key: string, start: number, end: number) {
const raw = await this.getArray<T>(key);
if (raw) {
start = (raw.value.length + start) % raw.value.length;
// make negative end index work, and end indice is inclusive
end = ((raw.value.length + end) % raw.value.length) + 1;
const result = raw.value.splice(start, end);
await this.set(key, raw.value, {
ttl: raw.expires ? raw.expires - Date.now() : undefined,
});
return result;
}
return [];
}
async popFront<T = unknown>(key: string, count: number = 1) {
return this.trim<T>(key, 0, count - 1);
}
async popBack<T = unknown>(key: string, count: number = 1) {
return this.trim<T>(key, -count, count - 1);
}
// map operations
private async getMap<T = unknown>(map: string) {
const raw = await this.kv.get(map, { raw: true });
if (raw) {
if (typeof raw.value !== 'object') {
throw new Error(
`Expect an Object keyed by ${map}, but found ${typeof raw}`
);
}
if (Array.isArray(raw.value)) {
throw new Error(`Expect an Object keyed by ${map}, but found an Array`);
}
}
return raw as Keyv.DeserializedData<Record<string, T>>;
}
private async setMap<T = unknown>(
map: string,
value: Record<string, T>,
opts: CacheSetOptions = {}
) {
return this.kv.set(map, value, opts.ttl).then(() => true);
}
async mapGet<T = unknown>(map: string, key: string): Promise<T | undefined> {
const raw = await this.getMap<T>(map);
if (raw?.value) {
return raw.value[key];
}
return undefined;
}
async mapSet<T = unknown>(
map: string,
key: string,
value: T
): Promise<boolean> {
const raw = await this.getMap(map);
const data = raw?.value ?? {};
data[key] = value;
return this.setMap(map, data, {
ttl: raw?.expires ? raw.expires - Date.now() : undefined,
});
}
async mapDelete(map: string, key: string): Promise<boolean> {
const raw = await this.getMap(map);
if (raw?.value) {
delete raw.value[key];
return this.setMap(map, raw.value, {
ttl: raw.expires ? raw.expires - Date.now() : undefined,
});
}
return false;
}
async mapIncrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
const prev = (await this.mapGet(map, key)) ?? 0;
if (typeof prev !== 'number') {
throw new Error(
`Expect a Number keyed by ${key}, but found ${typeof prev}`
);
}
const curr = prev + count;
return (await this.mapSet(map, key, curr)) ? curr : prev;
}
async mapDecrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.mapIncrease(map, key, -count);
}
async mapKeys(map: string): Promise<string[]> {
const raw = await this.getMap(map);
if (raw) {
return Object.keys(raw.value);
}
return [];
}
async mapRandomKey(map: string): Promise<string | undefined> {
const keys = await this.mapKeys(map);
return keys[Math.floor(Math.random() * keys.length)];
}
async mapLen(map: string): Promise<number> {
const raw = await this.getMap(map);
return raw ? Object.keys(raw.value).length : 0;
}
}

View File

@@ -0,0 +1,24 @@
import { FactoryProvider, Global, Module } from '@nestjs/common';
import { Redis } from 'ioredis';
import { Config } from '../config';
import { LocalCache } from './cache';
import { RedisCache } from './redis';
const CacheProvider: FactoryProvider = {
provide: LocalCache,
useFactory: (config: Config) => {
return config.redis.enabled
? new RedisCache(new Redis(config.redis))
: new LocalCache();
},
inject: [Config],
};
@Global()
@Module({
providers: [CacheProvider],
exports: [CacheProvider],
})
export class CacheModule {}
export { LocalCache as Cache };

View File

@@ -0,0 +1,194 @@
import { Redis } from 'ioredis';
import { Cache, CacheSetOptions } from './cache';
export class RedisCache implements Cache {
constructor(private readonly redis: Redis) {}
// standard operation
async get<T = unknown>(key: string): Promise<T> {
return this.redis
.get(key)
.then(v => {
if (v) {
return JSON.parse(v);
}
return undefined;
})
.catch(() => undefined);
}
async set<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (opts.ttl) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl)
.then(() => true)
.catch(() => false);
}
return this.redis
.set(key, JSON.stringify(value))
.then(() => true)
.catch(() => false);
}
async increase(key: string, count: number = 1): Promise<number> {
return this.redis.incrby(key, count).catch(() => 0);
}
async decrease(key: string, count: number = 1): Promise<number> {
return this.redis.decrby(key, count).catch(() => 0);
}
async setnx<T = unknown>(
key: string,
value: T,
opts: CacheSetOptions = {}
): Promise<boolean> {
if (opts.ttl) {
return this.redis
.set(key, JSON.stringify(value), 'PX', opts.ttl, 'NX')
.then(v => !!v)
.catch(() => false);
}
return this.redis
.set(key, JSON.stringify(value), 'NX')
.then(v => !!v)
.catch(() => false);
}
async delete(key: string): Promise<boolean> {
return this.redis
.del(key)
.then(v => v > 0)
.catch(() => false);
}
async has(key: string): Promise<boolean> {
return this.redis
.exists(key)
.then(v => v > 0)
.catch(() => false);
}
async ttl(key: string): Promise<number> {
return this.redis.ttl(key).catch(() => 0);
}
async expire(key: string, ttl: number): Promise<boolean> {
return this.redis
.pexpire(key, ttl)
.then(v => v > 0)
.catch(() => false);
}
// list operations
async pushBack<T = unknown>(key: string, ...values: T[]): Promise<number> {
return this.redis
.rpush(key, ...values.map(v => JSON.stringify(v)))
.catch(() => 0);
}
async pushFront<T = unknown>(key: string, ...values: T[]): Promise<number> {
return this.redis
.lpush(key, ...values.map(v => JSON.stringify(v)))
.catch(() => 0);
}
async len(key: string): Promise<number> {
return this.redis.llen(key).catch(() => 0);
}
async list<T = unknown>(
key: string,
start: number,
end: number
): Promise<T[]> {
return this.redis
.lrange(key, start, end)
.then(data => data.map(v => JSON.parse(v)))
.catch(() => []);
}
async popFront<T = unknown>(key: string, count: number = 1): Promise<T[]> {
return this.redis
.lpop(key, count)
.then(data => (data ?? []).map(v => JSON.parse(v)))
.catch(() => []);
}
async popBack<T = unknown>(key: string, count: number = 1): Promise<T[]> {
return this.redis
.rpop(key, count)
.then(data => (data ?? []).map(v => JSON.parse(v)))
.catch(() => []);
}
// map operations
async mapSet<T = unknown>(
map: string,
key: string,
value: T
): Promise<boolean> {
return this.redis
.hset(map, key, JSON.stringify(value))
.then(v => v > 0)
.catch(() => false);
}
async mapIncrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.redis.hincrby(map, key, count);
}
async mapDecrease(
map: string,
key: string,
count: number = 1
): Promise<number> {
return this.redis.hincrby(map, key, -count);
}
async mapGet<T = unknown>(map: string, key: string): Promise<T | undefined> {
return this.redis
.hget(map, key)
.then(v => (v ? JSON.parse(v) : undefined))
.catch(() => undefined);
}
async mapDelete(map: string, key: string): Promise<boolean> {
return this.redis
.hdel(map, key)
.then(v => v > 0)
.catch(() => false);
}
async mapKeys(map: string): Promise<string[]> {
return this.redis.hkeys(map).catch(() => []);
}
async mapRandomKey(map: string): Promise<string | undefined> {
return this.redis
.hrandfield(map, 1)
.then(v =>
typeof v === 'string'
? v
: Array.isArray(v)
? (v[0] as string)
: undefined
)
.catch(() => undefined);
}
async mapLen(map: string): Promise<number> {
return this.redis.hlen(map).catch(() => 0);
}
}

View File

@@ -57,10 +57,10 @@ export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
return type === 'int'
? int(value)
: type === 'float'
? float(value)
: type === 'boolean'
? boolean(value)
: value;
? float(value)
: type === 'boolean'
? boolean(value)
: value;
}
/**
@@ -362,6 +362,14 @@ export interface AFFiNEConfig {
*/
experimentalMergeWithJwstCodec: boolean;
};
history: {
/**
* How long the buffer time of creating a new history snapshot when doc get updated.
*
* in {ms}
*/
interval: number;
};
};
payment: {

View File

@@ -209,6 +209,9 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
updatePollInterval: 3000,
experimentalMergeWithJwstCodec: false,
},
history: {
interval: 1000 * 60 * 10 /* 10 mins */,
},
},
payment: {
stripe: {

View File

@@ -10,3 +10,4 @@ import { Metrics } from './metrics';
controllers: [MetricsController],
})
export class MetricsModule {}
export { Metrics };

View File

@@ -25,4 +25,7 @@ export class Metrics implements OnModuleDestroy {
authCounter = metricsCreator.counter('auth');
authFailCounter = metricsCreator.counter('auth_fail', ['reason']);
docHistoryCounter = metricsCreator.counter('doc_history_created');
docRecoverCounter = metricsCreator.counter('doc_history_recovered');
}

View File

@@ -0,0 +1,241 @@
import { isDeepStrictEqual } from 'node:util';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Cron, CronExpression } from '@nestjs/schedule';
import type { Snapshot } from '@prisma/client';
import { Config } from '../../config';
import { Metrics } from '../../metrics';
import { PrismaService } from '../../prisma';
import { SubscriptionStatus } from '../payment/service';
import { Permission } from '../workspaces/types';
@Injectable()
export class DocHistoryManager {
private readonly logger = new Logger(DocHistoryManager.name);
constructor(
private readonly config: Config,
private readonly db: PrismaService,
private readonly metrics: Metrics
) {}
@OnEvent('doc:manager:snapshot:beforeUpdate')
async onDocUpdated(snapshot: Snapshot, forceCreate = false) {
const last = await this.last(snapshot.workspaceId, snapshot.id);
let shouldCreateHistory = false;
if (!last) {
// never created
shouldCreateHistory = true;
} else if (last.timestamp === snapshot.updatedAt) {
// no change
shouldCreateHistory = false;
} else if (
// force
forceCreate ||
// last history created before interval in configs
last.timestamp.getTime() <
snapshot.updatedAt.getTime() - this.config.doc.history.interval
) {
shouldCreateHistory = true;
}
if (shouldCreateHistory) {
// skip the history recording when no actual update on snapshot happended
if (last && isDeepStrictEqual(last.state, snapshot.state)) {
this.logger.debug(
`State matches, skip creating history record for ${snapshot.id} in workspace ${snapshot.workspaceId}`
);
return;
}
await this.db.snapshotHistory
.create({
select: {
timestamp: true,
},
data: {
workspaceId: snapshot.workspaceId,
id: snapshot.id,
timestamp: snapshot.updatedAt,
blob: snapshot.blob,
state: snapshot.state,
expiredAt: await this.getExpiredDateFromNow(snapshot.workspaceId),
},
})
.catch(() => {
// safe to ignore
// only happens when duplicated history record created in multi processes
});
this.metrics.docHistoryCounter(1, {});
this.logger.log(
`History created for ${snapshot.id} in workspace ${snapshot.workspaceId}.`
);
}
}
async list(
workspaceId: string,
id: string,
before: Date = new Date(),
take: number = 10
) {
return this.db.snapshotHistory.findMany({
select: {
timestamp: true,
},
where: {
workspaceId,
id,
timestamp: {
lte: before,
},
// only include the ones has not expired
expiredAt: {
gt: new Date(),
},
},
orderBy: {
timestamp: 'desc',
},
take,
});
}
async count(workspaceId: string, id: string) {
return this.db.snapshotHistory.count({
where: {
workspaceId,
id,
expiredAt: {
gt: new Date(),
},
},
});
}
async get(workspaceId: string, id: string, timestamp: Date) {
return this.db.snapshotHistory.findUnique({
where: {
workspaceId_id_timestamp: {
workspaceId,
id,
timestamp,
},
expiredAt: {
gt: new Date(),
},
},
});
}
async last(workspaceId: string, id: string) {
return this.db.snapshotHistory.findFirst({
where: {
workspaceId,
id,
},
select: {
timestamp: true,
state: true,
},
orderBy: {
timestamp: 'desc',
},
});
}
async recover(workspaceId: string, id: string, timestamp: Date) {
const history = await this.db.snapshotHistory.findUnique({
where: {
workspaceId_id_timestamp: {
workspaceId,
id,
timestamp,
},
},
});
if (!history) {
throw new Error('Given history not found');
}
const oldSnapshot = await this.db.snapshot.findUnique({
where: {
id_workspaceId: {
id,
workspaceId,
},
},
});
if (!oldSnapshot) {
// unreachable actually
throw new Error('Given Doc not found');
}
// save old snapshot as one history record
await this.onDocUpdated(oldSnapshot, true);
// WARN:
// we should never do the snapshot updating in recovering,
// which is not the solution in CRDT.
// let user revert in client and update the data in sync system
// `await this.db.snapshot.update();`
this.metrics.docRecoverCounter(1, {});
return history.timestamp;
}
/**
* @todo(@darkskygit) refactor with [Usage Control] system
*/
async getExpiredDateFromNow(workspaceId: string) {
const permission = await this.db.workspaceUserPermission.findFirst({
select: {
userId: true,
},
where: {
workspaceId,
type: Permission.Owner,
},
});
if (!permission) {
// unreachable actually
throw new Error('Workspace owner not found');
}
const sub = await this.db.userSubscription.findFirst({
select: {
id: true,
},
where: {
userId: permission.userId,
status: SubscriptionStatus.Active,
},
});
return new Date(
Date.now() +
1000 *
60 *
60 *
24 *
// 30 days for subscription user, 7 days for free user
(sub ? 30 : 7)
);
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT /* everyday at 12am */)
async cleanupExpiredHistory() {
await this.db.snapshotHistory.deleteMany({
where: {
expiredAt: {
lte: new Date(),
},
},
});
}
}

View File

@@ -1,7 +1,7 @@
import { DynamicModule } from '@nestjs/common';
import { DocHistoryManager } from './history';
import { DocManager } from './manager';
import { RedisDocManager } from './redis-manager';
export class DocModule {
/**
@@ -15,14 +15,10 @@ export class DocModule {
provide: 'DOC_MANAGER_AUTOMATION',
useValue: automation,
},
{
provide: DocManager,
useClass: globalThis.AFFiNE.redis.enabled
? RedisDocManager
: DocManager,
},
DocManager,
DocHistoryManager,
],
exports: [DocManager],
exports: [DocManager, DocHistoryManager],
};
}
@@ -39,4 +35,4 @@ export class DocModule {
}
}
export { DocManager };
export { DocHistoryManager, DocManager };

View File

@@ -5,6 +5,7 @@ import {
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Snapshot, Update } from '@prisma/client';
import { chunk } from 'lodash-es';
import { defer, retry } from 'rxjs';
@@ -16,6 +17,7 @@ import {
transact,
} from 'yjs';
import { Cache } from '../../cache';
import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma';
@@ -58,17 +60,19 @@ const MAX_SEQ_NUM = 0x3fffffff; // u31
*/
@Injectable()
export class DocManager implements OnModuleInit, OnModuleDestroy {
protected logger = new Logger(DocManager.name);
private logger = new Logger(DocManager.name);
private job: NodeJS.Timeout | null = null;
private seqMap = new Map<string, number>();
private busy = false;
constructor(
protected readonly db: PrismaService,
@Inject('DOC_MANAGER_AUTOMATION')
protected readonly automation: boolean,
protected readonly config: Config,
protected readonly metrics: Metrics
private readonly automation: boolean,
private readonly db: PrismaService,
private readonly config: Config,
private readonly metrics: Metrics,
private readonly cache: Cache,
private readonly event: EventEmitter2
) {}
onModuleInit() {
@@ -82,7 +86,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
this.destroy();
}
protected recoverDoc(...updates: Buffer[]): Promise<Doc> {
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
const doc = new Doc();
const chunks = chunk(updates, 10);
@@ -95,11 +99,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
try {
applyUpdate(doc, u);
} catch (e) {
this.logger.error(
`Failed to apply update: ${updates
.map(u => u.toString('hex'))
.join('\n')}`
);
this.logger.error('Failed to apply update', e);
}
});
});
@@ -117,14 +117,12 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
});
}
protected async applyUpdates(
guid: string,
...updates: Buffer[]
): Promise<Doc> {
private async applyUpdates(guid: string, ...updates: Buffer[]): Promise<Doc> {
const doc = await this.recoverDoc(...updates);
// test jwst codec
if (
this.config.affine.canary &&
this.config.doc.manager.experimentalMergeWithJwstCodec &&
updates.length < 100 /* avoid overloading */
) {
@@ -149,7 +147,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
this.logger.warn(`jwst apply update failed for ${guid}: ${e}`);
log = true;
} finally {
if (log) {
if (log && this.config.node.dev) {
this.logger.warn(
`Updates: ${updates.map(u => u.toString('hex')).join('\n')}`
);
@@ -223,8 +221,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
.pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({
next: () => {
this.logger.verbose(
`pushed update for workspace: ${workspaceId}, guid: ${guid}`
this.logger.debug(
`pushed 1 update for ${guid} in workspace ${workspaceId}`
);
resolve();
},
@@ -233,6 +231,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
reject(new Error('Failed to push update'));
},
});
}).then(() => {
return this.updateCachedUpdatesCount(workspaceId, guid, 1);
});
}
@@ -267,8 +267,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
.pipe(retry(retryTimes)) // retry until seq num not conflict
.subscribe({
next: () => {
this.logger.verbose(
`pushed updates for workspace: ${workspaceId}, guid: ${guid}`
this.logger.debug(
`pushed ${updates.length} updates for ${guid} in workspace ${workspaceId}`
);
resolve();
},
@@ -277,6 +277,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
reject(new Error('Failed to push update'));
},
});
}).then(() => {
return this.updateCachedUpdatesCount(workspaceId, guid, updates.length);
});
}
@@ -363,21 +365,22 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
/**
* apply pending updates to snapshot
*/
protected async autoSquash() {
private async autoSquash() {
// find the first update and batch process updates with same id
const first = await this.db.update.findFirst({
select: {
id: true,
workspaceId: true,
},
});
const candidate = await this.getAutoSquashCandidate();
// no pending updates
if (!first) {
if (!candidate) {
return;
}
const { id, workspaceId } = first;
const { id, workspaceId } = candidate;
// acquire lock
const ok = await this.lockUpdatesForAutoSquash(workspaceId, id);
if (!ok) {
return;
}
try {
await this._get(workspaceId, id);
@@ -386,14 +389,31 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
`Failed to apply updates for workspace: ${workspaceId}, guid: ${id}`
);
this.logger.error(e);
} finally {
await this.unlockUpdatesForAutoSquash(workspaceId, id);
}
}
protected async upsert(
private async getAutoSquashCandidate() {
const cache = await this.getAutoSquashCandidateFromCache();
if (cache) {
return cache;
}
return this.db.update.findFirst({
select: {
id: true,
workspaceId: true,
},
});
}
private async upsert(
workspaceId: string,
guid: string,
doc: Doc,
seq?: number
initialSeq?: number
) {
const blob = Buffer.from(encodeStateAsUpdate(doc));
const state = Buffer.from(encodeStateVector(doc));
@@ -417,7 +437,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
workspaceId,
blob,
state,
seq,
seq: initialSeq,
},
update: {
blob,
@@ -426,7 +446,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
});
}
protected async _get(
private async _get(
workspaceId: string,
guid: string
): Promise<{ doc: Doc } | { snapshot: Buffer } | null> {
@@ -446,22 +466,29 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
* Squash updates into a single update and save it as snapshot,
* and delete the updates records at the same time.
*/
protected async squash(updates: Update[], snapshot: Snapshot | null) {
private async squash(updates: Update[], snapshot: Snapshot | null) {
if (!updates.length) {
throw new Error('No updates to squash');
}
const first = updates[0];
const last = updates[updates.length - 1];
const { id, workspaceId } = first;
const doc = await this.applyUpdates(
first.id,
snapshot ? snapshot.blob : Buffer.from([0, 0]),
...updates.map(u => u.blob)
);
const { id, workspaceId } = first;
if (snapshot) {
this.event.emit('doc:manager:snapshot:beforeUpdate', snapshot);
}
await this.upsert(workspaceId, id, doc, last.seq);
this.logger.debug(
`Squashed ${updates.length} updates for ${id} in workspace ${workspaceId}`
);
await this.db.update.deleteMany({
where: {
id,
@@ -471,6 +498,8 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
},
},
});
await this.updateCachedUpdatesCount(workspaceId, id, -updates.length);
return doc;
}
@@ -496,6 +525,9 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
// reset
if (seq >= MAX_SEQ_NUM) {
await this.db.snapshot.update({
select: {
seq: true,
},
where: {
id_workspaceId: {
workspaceId,
@@ -516,4 +548,56 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
return last + batch;
}
}
private async updateCachedUpdatesCount(
workspaceId: string,
guid: string,
count: number
) {
const result = await this.cache.mapIncrease(
`doc:manager:updates`,
`${workspaceId}::${guid}`,
count
);
if (result <= 0) {
await this.cache.mapDelete(
`doc:manager:updates`,
`${workspaceId}::${guid}`
);
}
}
private async getAutoSquashCandidateFromCache() {
const key = await this.cache.mapRandomKey('doc:manager:updates');
if (key) {
const count = await this.cache.mapGet<number>('doc:manager:updates', key);
if (typeof count === 'number' && count > 0) {
const [workspaceId, id] = key.split('::');
return { id, workspaceId };
}
}
return null;
}
private async lockUpdatesForAutoSquash(workspaceId: string, guid: string) {
return this.cache.setnx(
`doc:manager:updates-lock:${workspaceId}::${guid}`,
1,
{
ttl: 60 * 1000,
}
);
}
private async unlockUpdatesForAutoSquash(workspaceId: string, guid: string) {
return this.cache
.delete(`doc:manager:updates-lock:${workspaceId}::${guid}`)
.catch(e => {
// safe, the lock will be expired when ttl ends
this.logger.error('Failed to release updates lock', e);
});
}
}

View File

@@ -1,129 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { Config } from '../../config';
import { Metrics } from '../../metrics/metrics';
import { PrismaService } from '../../prisma';
import { DocID } from '../../utils/doc';
import { DocManager } from './manager';
function makeKey(prefix: string) {
return (parts: TemplateStringsArray, ...args: any[]) => {
return parts.reduce((prev, curr, i) => {
return prev + curr + (args[i] || '');
}, prefix);
};
}
const pending = 'um_pending:';
const updates = makeKey('um_u:');
const lock = makeKey('um_l:');
const pushUpdateLua = `
redis.call('sadd', KEYS[1], ARGV[1])
redis.call('rpush', KEYS[2], ARGV[2])
`;
/**
* @deprecated unstable
*/
@Injectable()
export class RedisDocManager extends DocManager {
private readonly redis: Redis;
constructor(
protected override readonly db: PrismaService,
@Inject('DOC_MANAGER_AUTOMATION')
protected override readonly automation: boolean,
protected override readonly config: Config,
protected override readonly metrics: Metrics
) {
super(db, automation, config, metrics);
this.redis = new Redis(config.redis);
this.redis.defineCommand('pushDocUpdate', {
numberOfKeys: 2,
lua: pushUpdateLua,
});
}
override onModuleInit(): void {
if (this.automation) {
this.setup();
}
}
override async autoSquash(): Promise<void> {
// incase some update fallback to db
await super.autoSquash();
// consume rest updates in redis queue
const pendingDoc = await this.redis.spop(pending).catch(() => null); // safe
if (!pendingDoc) {
return;
}
const docId = new DocID(pendingDoc);
const updateKey = updates`${pendingDoc}`;
const lockKey = lock`${pendingDoc}`;
// acquire the lock
const lockResult = await this.redis
.set(
lockKey,
'1',
'EX',
// 10mins, incase progress exit in between lock require & release, which is a rare.
// if the lock is really hold more then 10mins, we should check the merge logic correctness
600,
'NX'
)
.catch(() => null); // safe;
if (!lockResult) {
// we failed to acquire the lock, put the pending doc back to queue.
await this.redis.sadd(pending, pendingDoc).catch(() => null); // safe
return;
}
try {
// fetch pending updates
const updates = await this.redis
.lrangeBuffer(updateKey, 0, -1)
.catch(() => []); // safe
if (!updates.length) {
return;
}
this.logger.verbose(
`applying ${updates.length} updates for workspace: ${docId}`
);
const snapshot = await this.getSnapshot(docId.workspace, docId.guid);
// merge
const doc = await (snapshot
? this.applyUpdates(docId.full, snapshot.blob, ...updates)
: this.applyUpdates(docId.full, ...updates));
// update snapshot
await this.upsert(docId.workspace, docId.guid, doc, snapshot?.seq);
// delete merged updates
await this.redis
.ltrim(updateKey, updates.length, -1)
// safe, fallback to mergeUpdates
.catch(e => {
this.logger.error(`Failed to remove merged updates from Redis: ${e}`);
});
} catch (e) {
this.logger.error(
`Failed to merge updates with snapshot for ${docId}: ${e}`
);
await this.redis.sadd(pending, docId.toString()).catch(() => null); // safe
} finally {
await this.redis.del(lockKey);
}
}
}

View File

@@ -1,5 +1,6 @@
import { DynamicModule, Type } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
import { GqlModule } from '../graphql.module';
import { AuthModule } from './auth';
@@ -11,7 +12,11 @@ import { WorkspaceModule } from './workspaces';
const { SERVER_FLAVOR } = process.env;
const BusinessModules: (Type | DynamicModule)[] = [];
const BusinessModules: (Type | DynamicModule)[] = [
EventEmitterModule.forRoot({
global: true,
}),
];
switch (SERVER_FLAVOR) {
case 'sync':
@@ -19,9 +24,7 @@ switch (SERVER_FLAVOR) {
break;
case 'graphql':
BusinessModules.push(
EventEmitterModule.forRoot({
global: true,
}),
ScheduleModule.forRoot(),
GqlModule,
WorkspaceModule,
UsersModule,
@@ -33,9 +36,7 @@ switch (SERVER_FLAVOR) {
case 'allinone':
default:
BusinessModules.push(
EventEmitterModule.forRoot({
global: true,
}),
ScheduleModule.forRoot(),
GqlModule,
WorkspaceModule,
UsersModule,

View File

@@ -12,12 +12,14 @@ import {
import type { Response } from 'express';
import format from 'pretty-time';
import { PrismaService } from '../../prisma';
import { StorageProvide } from '../../storage';
import { DocID } from '../../utils/doc';
import { Auth, CurrentUser, Publicable } from '../auth';
import { DocManager } from '../doc';
import { DocHistoryManager, DocManager } from '../doc';
import { UserType } from '../users';
import { PermissionService } from './permission';
import { PermissionService, PublicPageMode } from './permission';
import { Permission } from './types';
@Controller('/api/workspaces')
export class WorkspacesController {
@@ -26,7 +28,9 @@ export class WorkspacesController {
constructor(
@Inject(StorageProvide) private readonly storage: Storage,
private readonly permission: PermissionService,
private readonly docManager: DocManager
private readonly docManager: DocManager,
private readonly historyManager: DocHistoryManager,
private readonly prisma: PrismaService
) {}
// get workspace blob
@@ -82,8 +86,62 @@ export class WorkspacesController {
throw new NotFoundException('Doc not found');
}
if (!docId.isWorkspace) {
// fetch the publish page mode for publish page
const publishPage = await this.prisma.workspacePage.findUnique({
where: {
workspaceId_pageId: {
workspaceId: docId.workspace,
pageId: docId.guid,
},
},
});
const publishPageMode =
publishPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page';
res.setHeader('publish-mode', publishPageMode);
}
res.setHeader('content-type', 'application/octet-stream');
res.send(update);
this.logger.debug(`workspaces doc api: ${format(process.hrtime(start))}`);
}
@Get('/:id/docs/:guid/histories/:timestamp')
@Auth()
async history(
@CurrentUser() user: UserType,
@Param('id') ws: string,
@Param('guid') guid: string,
@Param('timestamp') timestamp: string,
@Res() res: Response
) {
const docId = new DocID(guid, ws);
let ts;
try {
ts = new Date(timestamp);
} catch (e) {
throw new Error('Invalid timestamp');
}
await this.permission.checkPagePermission(
docId.workspace,
docId.guid,
user.id,
Permission.Write
);
const history = await this.historyManager.get(
docId.workspace,
docId.guid,
ts
);
if (history) {
res.setHeader('content-type', 'application/octet-stream');
res.send(history.blob);
} else {
throw new NotFoundException('Doc history not found');
}
}
}

View File

@@ -0,0 +1,92 @@
import {
Args,
Field,
GraphQLISODateTime,
Int,
Mutation,
ObjectType,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import type { SnapshotHistory } from '@prisma/client';
import { DocID } from '../../utils/doc';
import { Auth, CurrentUser } from '../auth';
import { DocHistoryManager } from '../doc/history';
import { UserType } from '../users';
import { PermissionService } from './permission';
import { WorkspaceType } from './resolver';
import { Permission } from './types';
@ObjectType()
class DocHistoryType implements Partial<SnapshotHistory> {
@Field()
workspaceId!: string;
@Field()
id!: string;
@Field(() => GraphQLISODateTime)
timestamp!: Date;
}
@Resolver(() => WorkspaceType)
export class DocHistoryResolver {
constructor(
private readonly historyManager: DocHistoryManager,
private readonly permission: PermissionService
) {}
@ResolveField(() => [DocHistoryType])
async histories(
@Parent() workspace: WorkspaceType,
@Args('guid') guid: string,
@Args({ name: 'before', type: () => GraphQLISODateTime, nullable: true })
timestamp: Date = new Date(),
@Args({ name: 'take', type: () => Int, nullable: true })
take?: number
): Promise<DocHistoryType[]> {
const docId = new DocID(guid, workspace.id);
if (docId.isWorkspace) {
throw new Error('Invalid guid for listing doc histories.');
}
return this.historyManager
.list(workspace.id, docId.guid, timestamp, take)
.then(rows =>
rows.map(({ timestamp }) => {
return {
workspaceId: workspace.id,
id: docId.guid,
timestamp,
};
})
);
}
@Auth()
@Mutation(() => Date)
async recoverDoc(
@CurrentUser() user: UserType,
@Args('workspaceId') workspaceId: string,
@Args('guid') guid: string,
@Args({ name: 'timestamp', type: () => GraphQLISODateTime }) timestamp: Date
): Promise<Date> {
const docId = new DocID(guid, workspaceId);
if (docId.isWorkspace) {
throw new Error('Invalid guid for recovering doc from history.');
}
await this.permission.checkPagePermission(
docId.workspace,
docId.guid,
user.id,
Permission.Write
);
return this.historyManager.recover(docId.workspace, docId.guid, timestamp);
}
}

View File

@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { DocModule } from '../doc';
import { UsersService } from '../users';
import { WorkspacesController } from './controller';
import { DocHistoryResolver } from './history.resolver';
import { PermissionService } from './permission';
import { PagePermissionResolver, WorkspaceResolver } from './resolver';
@@ -14,6 +15,7 @@ import { PagePermissionResolver, WorkspaceResolver } from './resolver';
PermissionService,
UsersService,
PagePermissionResolver,
DocHistoryResolver,
],
exports: [PermissionService],
})

View File

@@ -244,18 +244,20 @@ export class PermissionService {
permission = Permission.Read
) {
// check whether page is public
const count = await this.prisma.workspacePage.count({
where: {
workspaceId: ws,
pageId: page,
public: true,
},
});
if (permission === Permission.Read) {
const count = await this.prisma.workspacePage.count({
where: {
workspaceId: ws,
pageId: page,
public: true,
},
});
// page is public
// accessible
if (count > 0) {
return true;
// page is public
// accessible
if (count > 0) {
return true;
}
}
if (user) {

View File

@@ -192,6 +192,7 @@ type WorkspaceType {
"""Public pages of a workspace"""
publicPages: [WorkspacePage!]!
histories(guid: String!, before: DateTime, take: Int): [DocHistoryType!]!
}
type InvitationWorkspaceType {
@@ -232,6 +233,12 @@ enum PublicPageMode {
Edgeless
}
type DocHistoryType {
workspaceId: String!
id: String!
timestamp: DateTime!
}
type Query {
"""Get is owner of workspace"""
isOwner(workspaceId: String!): Boolean!
@@ -288,6 +295,7 @@ type Mutation {
publishPage(workspaceId: String!, pageId: String!, mode: PublicPageMode = Page): WorkspacePage!
revokePage(workspaceId: String!, pageId: String!): Boolean! @deprecated(reason: "use revokePublicPage")
revokePublicPage(workspaceId: String!, pageId: String!): WorkspacePage!
recoverDoc(workspaceId: String!, guid: String!, timestamp: DateTime!): DateTime!
"""Upload user avatar"""
uploadAvatar(avatar: Upload!): UserType!

View File

@@ -1,12 +1,12 @@
export type DeepPartial<T> = T extends Array<infer U>
? DeepPartial<U>[]
: T extends ReadonlyArray<infer U>
? ReadonlyArray<DeepPartial<U>>
: T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;
? ReadonlyArray<DeepPartial<U>>
: T extends object
? {
[K in keyof T]?: DeepPartial<T[K]>;
}
: T;
type Join<Prefix, Suffixes> = Prefix extends string | number
? Suffixes extends string | number
@@ -32,11 +32,11 @@ export type LeafPaths<
> = Depth extends MaxDepth
? never
: T extends Record<string | number, any>
? {
[K in keyof T]-?: K extends string | number
? T[K] extends PrimitiveType
? K
: Join<K, LeafPaths<T[K], Path, MaxDepth, `${Depth}.`>>
: never;
}[keyof T]
: never;
? {
[K in keyof T]-?: K extends string | number
? T[K] extends PrimitiveType
? K
: Join<K, LeafPaths<T[K], Path, MaxDepth, `${Depth}.`>>
: never;
}[keyof T]
: never;

View File

@@ -0,0 +1,108 @@
import { Test, TestingModule } from '@nestjs/testing';
import test from 'ava';
import { Cache, CacheModule } from '../src/cache';
import { ConfigModule } from '../src/config';
let cache: Cache;
let module: TestingModule;
test.beforeEach(async () => {
module = await Test.createTestingModule({
imports: [ConfigModule.forRoot(), CacheModule],
}).compile();
const prefix = Math.random().toString(36).slice(2, 7);
cache = new Proxy(module.get(Cache), {
get(target, prop) {
// @ts-expect-error safe
const fn = target[prop];
if (typeof fn === 'function') {
// replase first parameter of fn with prefix
return (...args: any[]) =>
fn.call(target, `${prefix}:${args[0]}`, ...args.slice(1));
}
return fn;
},
});
});
test.afterEach(async () => {
await module.close();
});
test('should be able to set normal cache', async t => {
t.true(await cache.set('test', 1));
t.is(await cache.get<number>('test'), 1);
t.true(await cache.has('test'));
t.true(await cache.delete('test'));
t.is(await cache.get('test'), undefined);
t.true(await cache.set('test', { a: 1 }));
t.deepEqual(await cache.get('test'), { a: 1 });
});
test('should be able to set cache with non-exiting flag', async t => {
t.true(await cache.setnx('test', 1));
t.false(await cache.setnx('test', 2));
t.is(await cache.get('test'), 1);
});
test('should be able to set cache with ttl', async t => {
t.true(await cache.set('test', 1));
t.is(await cache.get('test'), 1);
t.true(await cache.expire('test', 1 * 1000));
const ttl = await cache.ttl('test');
t.true(ttl <= 1 * 1000);
t.true(ttl > 0);
});
test('should be able to incr/decr number cache', async t => {
t.true(await cache.set('test', 1));
t.is(await cache.increase('test'), 2);
t.is(await cache.increase('test'), 3);
t.is(await cache.decrease('test'), 2);
t.is(await cache.decrease('test'), 1);
// increase an nonexists number
t.is(await cache.increase('test2'), 1);
t.is(await cache.increase('test2'), 2);
});
test('should be able to manipulate list cache', async t => {
t.is(await cache.pushBack('test', 1), 1);
t.is(await cache.pushBack('test', 2, 3, 4), 4);
t.is(await cache.len('test'), 4);
t.deepEqual(await cache.list('test', 1, -1), [2, 3, 4]);
t.deepEqual(await cache.popFront('test', 2), [1, 2]);
t.deepEqual(await cache.popBack('test', 1), [4]);
t.is(await cache.pushBack('test2', { a: 1 }), 1);
t.deepEqual(await cache.popFront('test2', 1), [{ a: 1 }]);
});
test('should be able to manipulate map cache', async t => {
t.is(await cache.mapSet('test', 'a', 1), true);
t.is(await cache.mapSet('test', 'b', 2), true);
t.is(await cache.mapLen('test'), 2);
t.is(await cache.mapGet('test', 'a'), 1);
t.is(await cache.mapGet('test', 'b'), 2);
t.is(await cache.mapIncrease('test', 'a'), 2);
t.is(await cache.mapIncrease('test', 'a'), 3);
t.is(await cache.mapDecrease('test', 'b', 3), -1);
const keys = await cache.mapKeys('test');
t.deepEqual(keys, ['a', 'b']);
const randomKey = await cache.mapRandomKey('test');
t.truthy(randomKey);
t.true(keys.includes(randomKey!));
t.is(await cache.mapDelete('test', 'a'), true);
t.is(await cache.mapGet('test', 'a'), undefined);
});

View File

@@ -1,12 +1,14 @@
import { mock } from 'node:test';
import type { INestApplication } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { Test, TestingModule } from '@nestjs/testing';
import test from 'ava';
import { register } from 'prom-client';
import * as Sinon from 'sinon';
import { Doc as YDoc, encodeStateAsUpdate } from 'yjs';
import { CacheModule } from '../src/cache';
import { Config, ConfigModule } from '../src/config';
import { MetricsModule } from '../src/metrics';
import { DocManager, DocModule } from '../src/modules/doc';
@@ -18,6 +20,8 @@ const createModule = () => {
imports: [
PrismaModule,
MetricsModule,
CacheModule,
EventEmitterModule.forRoot(),
ConfigModule.forRoot(),
DocModule.forRoot(),
],

View File

@@ -0,0 +1,341 @@
import { INestApplication } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { Test, TestingModule } from '@nestjs/testing';
import type { Snapshot } from '@prisma/client';
import test from 'ava';
import * as Sinon from 'sinon';
import { ConfigModule } from '../src/config';
import { MetricsModule } from '../src/metrics';
import { DocHistoryManager } from '../src/modules/doc';
import { PrismaModule, PrismaService } from '../src/prisma';
import { flushDB } from './utils';
let app: INestApplication;
let m: TestingModule;
let manager: DocHistoryManager;
let db: PrismaService;
// cleanup database before each test
test.beforeEach(async () => {
await flushDB();
m = await Test.createTestingModule({
imports: [
PrismaModule,
MetricsModule,
ScheduleModule.forRoot(),
ConfigModule.forRoot(),
],
providers: [DocHistoryManager],
}).compile();
app = m.createNestApplication();
await app.init();
manager = m.get(DocHistoryManager);
Sinon.stub(manager, 'getExpiredDateFromNow').resolves(
new Date(Date.now() + 1000)
);
db = m.get(PrismaService);
});
test.afterEach(async () => {
await app.close();
await m.close();
Sinon.restore();
});
const snapshot: Snapshot = {
workspaceId: '1',
id: 'doc1',
blob: Buffer.from([0, 0]),
state: Buffer.from([0, 0]),
seq: 0,
updatedAt: new Date(),
createdAt: new Date(),
};
test('should create doc history if never created before', async t => {
Sinon.stub(manager, 'last').resolves(null);
const timestamp = new Date();
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.truthy(history);
t.is(history?.timestamp.getTime(), timestamp.getTime());
});
test('should not create history if timestamp equals to last record', async t => {
const timestamp = new Date();
Sinon.stub(manager, 'last').resolves({ timestamp, state: null });
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.falsy(history);
});
test('should not create history if state equals to last record', async t => {
const timestamp = new Date();
Sinon.stub(manager, 'last').resolves({
timestamp: new Date(timestamp.getTime() - 1),
state: snapshot.state,
});
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.falsy(history);
});
test('should not create history if time diff is less than interval config', async t => {
const timestamp = new Date();
Sinon.stub(manager, 'last').resolves({
timestamp: new Date(timestamp.getTime() - 1000),
state: Buffer.from([0, 1]),
});
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.falsy(history);
});
test('should create history if time diff is larger than interval config and state diff', async t => {
const timestamp = new Date();
Sinon.stub(manager, 'last').resolves({
timestamp: new Date(timestamp.getTime() - 1000 * 60 * 20),
state: Buffer.from([0, 1]),
});
await manager.onDocUpdated({
...snapshot,
updatedAt: timestamp,
});
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.truthy(history);
});
test('should create history with force flag even if time diff in small', async t => {
const timestamp = new Date();
Sinon.stub(manager, 'last').resolves({
timestamp: new Date(timestamp.getTime() - 1),
state: Buffer.from([0, 1]),
});
await manager.onDocUpdated(
{
...snapshot,
updatedAt: timestamp,
},
true
);
const history = await db.snapshotHistory.findFirst({
where: {
workspaceId: '1',
id: 'doc1',
},
});
t.truthy(history);
});
test('should correctly list all history records', async t => {
const timestamp = Date.now();
// insert expired data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
});
// insert available data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
const list = await manager.list(
snapshot.workspaceId,
snapshot.id,
new Date(timestamp + 20),
8
);
const count = await manager.count(snapshot.workspaceId, snapshot.id);
t.is(list.length, 8);
t.is(count, 10);
});
test('should be able to get history data', async t => {
const timestamp = new Date();
await manager.onDocUpdated(
{
...snapshot,
updatedAt: timestamp,
},
true
);
const history = await manager.get(
snapshot.workspaceId,
snapshot.id,
timestamp
);
t.truthy(history);
t.deepEqual(history?.blob, snapshot.blob);
});
test('should be able to get last history record', async t => {
const timestamp = Date.now();
// insert available data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
const history = await manager.last(snapshot.workspaceId, snapshot.id);
t.truthy(history);
t.is(history?.timestamp.getTime(), timestamp + 9);
});
test('should be able to recover from history', async t => {
await db.snapshot.create({
data: {
...snapshot,
blob: Buffer.from([1, 1]),
state: Buffer.from([1, 1]),
},
});
const history1Timestamp = snapshot.updatedAt.getTime() - 10;
await manager.onDocUpdated({
...snapshot,
updatedAt: new Date(history1Timestamp),
});
await manager.recover(
snapshot.workspaceId,
snapshot.id,
new Date(history1Timestamp)
);
const [history1, history2] = await db.snapshotHistory.findMany({
where: {
workspaceId: snapshot.workspaceId,
id: snapshot.id,
},
});
t.is(history1.timestamp.getTime(), history1Timestamp);
t.is(history2.timestamp.getTime(), snapshot.updatedAt.getTime());
// new history data force created with snapshot state before recovered
t.deepEqual(history2?.blob, Buffer.from([1, 1]));
t.deepEqual(history2?.state, Buffer.from([1, 1]));
});
test('should be able to cleanup expired history', async t => {
const timestamp = Date.now();
// insert expired data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp - 10 - i),
expiredAt: new Date(timestamp - 1),
})),
});
// insert available data
await db.snapshotHistory.createMany({
data: new Array(10).fill(0).map((_, i) => ({
workspaceId: snapshot.workspaceId,
id: snapshot.id,
blob: snapshot.blob,
state: snapshot.state,
timestamp: new Date(timestamp + i),
expiredAt: new Date(timestamp + 1000),
})),
});
let count = await db.snapshotHistory.count();
t.is(count, 20);
await manager.cleanupExpiredHistory();
count = await db.snapshotHistory.count();
t.is(count, 10);
const example = await db.snapshotHistory.findFirst();
t.truthy(example);
t.true(example!.expiredAt > new Date());
});

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/storage",
"version": "0.10.2",
"version": "0.10.3-canary.2",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
},
@@ -36,10 +36,10 @@
"version": "napi version"
},
"devDependencies": {
"@napi-rs/cli": "^2.16.3",
"@napi-rs/cli": "^2.16.5",
"lib0": "^0.2.87",
"nx": "^16.10.0",
"nx": "^17.1.3",
"nx-cloud": "^16.5.2",
"yjs": "^13.6.8"
"yjs": "^13.6.10"
}
}

View File

@@ -8,5 +8,5 @@
"react": "18.2.0",
"react-dom": "18.2.0"
},
"version": "0.10.2"
"version": "0.10.3-canary.2"
}

View File

@@ -496,8 +496,8 @@ const Command = React.forwardRef<HTMLDivElement, CommandProps>(
index + change < 0
? items[items.length - 1]
: index + change === items.length
? items[0]
: items[index + change];
? items[0]
: items[index + change];
}
if (newSelected)
@@ -666,10 +666,10 @@ const Item = React.forwardRef<HTMLDivElement, ItemProps>(
forceMount
? true
: context.filter() === false
? true
: !state.search
? true
: state.filtered.items.get(id) > 0
? true
: !state.search
? true
: state.filtered.items.get(id) > 0
);
React.useEffect(() => {
@@ -728,10 +728,10 @@ const Group = React.forwardRef<HTMLDivElement, GroupProps>(
forceMount
? true
: context.filter() === false
? true
: !state.search
? true
: state.filtered.groups.has(id)
? true
: !state.search
? true
: state.filtered.groups.has(id)
);
useLayoutEffect(() => {

View File

@@ -9,5 +9,5 @@
"@types/debug": "^4.1.9",
"vitest": "0.34.6"
},
"version": "0.10.2"
"version": "0.10.3-canary.2"
}

View File

@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/store": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/global": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/store": "0.0.0-20231122113751-6bf81eb3-nightly",
"react": "18.2.0",
"react-dom": "18.2.0",
"vitest": "0.34.6",
@@ -27,5 +27,5 @@
"dependencies": {
"lit": "^3.0.2"
},
"version": "0.10.2"
"version": "0.10.3-canary.2"
}

View File

@@ -55,34 +55,33 @@
},
"dependencies": {
"@affine/sdk": "workspace:*",
"@blocksuite/blocks": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/global": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/store": "0.0.0-20231116023037-31273bb7-nightly",
"jotai": "^2.4.3",
"jotai-effect": "^0.2.2",
"@blocksuite/blocks": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/global": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/store": "0.0.0-20231122113751-6bf81eb3-nightly",
"jotai": "^2.5.1",
"jotai-effect": "^0.2.3",
"tinykeys": "^2.1.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/editor": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/lit": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/editor": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/lit": "0.0.0-20231122113751-6bf81eb3-nightly",
"@testing-library/react": "^14.0.0",
"async-call-rpc": "^6.3.1",
"electron": "link:../../frontend/electron/node_modules/electron",
"nanoid": "^5.0.1",
"nanoid": "^5.0.3",
"react": "^18.2.0",
"rxjs": "^7.8.1",
"vite": "^4.4.11",
"vite-plugin-dts": "3.6.0",
"vitest": "0.34.6",
"yjs": "^13.6.8"
"yjs": "^13.6.10"
},
"peerDependencies": {
"@affine/templates": "*",
"@blocksuite/editor": "*",
"@blocksuite/lit": "*",
"async-call-rpc": "*",
"electron": "*",
"react": "*",
@@ -95,9 +94,6 @@
"@blocksuite/editor": {
"optional": true
},
"@blocksuite/lit": {
"optional": true
},
"async-call-rpc": {
"optional": true
},
@@ -111,5 +107,5 @@
"optional": true
}
},
"version": "0.10.2"
"version": "0.10.3-canary.2"
}

View File

@@ -1,750 +1,17 @@
import type { Page, PageMeta, Workspace } from '@blocksuite/store';
import { createIndexeddbStorage } from '@blocksuite/store';
import type { createStore, WritableAtom } from 'jotai/vanilla';
import type { Doc } from 'yjs';
import { Array as YArray, Doc as YDoc, Map as YMap, transact } from 'yjs';
export async function initEmptyPage(page: Page, title?: string) {
await page.waitForLoaded();
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(title ?? ''),
});
page.addBlock('affine:surface', {}, pageBlockId);
const noteBlockId = page.addBlock('affine:note', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, noteBlockId);
}
export async function buildEmptyBlockSuite(workspace: Workspace) {
const page = workspace.createPage();
await initEmptyPage(page);
workspace.setPageMeta(page.id, {
jumpOnce: true,
});
}
export async function buildShowcaseWorkspace(
workspace: Workspace,
options: {
schema: Schema;
atoms: {
pageMode: WritableAtom<
undefined,
[pageId: string, mode: 'page' | 'edgeless'],
void
>;
};
store: ReturnType<typeof createStore>;
}
) {
const prototypes = {
tags: {
options: [
{
id: 'icg1n5UdkP',
value: 'Travel',
color: 'var(--affine-tag-gray)',
},
{
id: 'Oe5dSe1DDJ',
value: 'Quick summary',
color: 'var(--affine-tag-green)',
},
{
id: 'g1L5dXKctL',
value: 'OKR',
color: 'var(--affine-tag-purple)',
},
{
id: 'q3mceOl_zi',
value: 'Streamline your workflow',
color: 'var(--affine-tag-teal)',
},
{
id: 'ze07JVwBu4',
value: 'Plan',
color: 'var(--affine-tag-teal)',
},
{
id: '8qcYPCTK0h',
value: 'Review',
color: 'var(--affine-tag-orange)',
},
{
id: 'wg-fBtd2eI',
value: 'Engage',
color: 'var(--affine-tag-pink)',
},
{
id: 'QYFD_HeQc-',
value: 'Create',
color: 'var(--affine-tag-blue)',
},
{
id: 'ZHBa2NtdSo',
value: 'Learn',
color: 'var(--affine-tag-yellow)',
},
],
},
};
workspace.meta.setProperties(prototypes);
const edgelessPage1 = nanoid();
const edgelessPage2 = nanoid();
const edgelessPage3 = nanoid();
const { store, atoms } = options;
[edgelessPage1, edgelessPage2, edgelessPage3].forEach(pageId => {
store.set(atoms.pageMode, pageId, 'edgeless');
});
const pageMetas = {
'9f6f3c04-cf32-470c-9648-479dc838f10e': {
createDate: 1691548231530,
tags: ['ZHBa2NtdSo', 'QYFD_HeQc-', 'wg-fBtd2eI'],
updatedDate: 1691676331623,
favorite: true,
jumpOnce: true,
},
'0773e198-5de0-45d4-a35e-de22ea72b96b': {
createDate: 1691548220794,
tags: [],
updatedDate: 1691676775642,
favorite: false,
},
'59b140eb-4449-488f-9eeb-42412dcc044e': {
createDate: 1691551731225,
tags: [],
updatedDate: 1691654611175,
favorite: false,
},
'7217fbe2-61db-4a91-93c6-ad5c800e5a43': {
createDate: 1691552082822,
tags: [],
updatedDate: 1691654606912,
favorite: false,
},
'6eb43ea8-8c11-456d-bb1d-5193937961ab': {
createDate: 1691552090989,
tags: [],
updatedDate: 1691646748171,
favorite: false,
},
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a': {
createDate: 1691564303138,
tags: [],
updatedDate: 1691646845195,
},
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238': {
createDate: 1691574743531,
tags: ['icg1n5UdkP'],
updatedDate: 1691647117761,
},
'22163830-8252-43fe-b62d-fd9bbeaa4caa': {
createDate: 1691574859042,
tags: [],
updatedDate: 1691648159371,
},
'b7a9e1bc-e205-44aa-8dad-7e328269d00b': {
createDate: 1691575011078,
tags: ['8qcYPCTK0h'],
updatedDate: 1691645074511,
favorite: false,
},
'646305d9-93e0-48df-bb92-d82944ceb5a3': {
createDate: 1691634722239,
tags: ['ze07JVwBu4'],
updatedDate: 1691647069662,
favorite: false,
},
'0350509d-8702-4797-b4d7-168f5e9359c7': {
createDate: 1691635388447,
tags: ['Oe5dSe1DDJ'],
updatedDate: 1691645873930,
},
'aa02af3c-5c5c-4856-b7ce-947ad17331f3': {
createDate: 1691636192263,
tags: ['q3mceOl_zi', 'g1L5dXKctL'],
updatedDate: 1691645102104,
},
'9d6e716e-a071-45a2-88ac-2f2f6eec0109': {
createDate: 1691574743531,
tags: ['icg1n5UdkP'],
updatedDate: 1691574743531,
},
} satisfies Record<string, Partial<PageMeta>>;
const data = [
[
'9f6f3c04-cf32-470c-9648-479dc838f10e',
import('@affine/templates/v1/getting-started.json'),
nanoid(),
],
[
'0773e198-5de0-45d4-a35e-de22ea72b96b',
import('@affine/templates/v1/preloading.json'),
edgelessPage1,
],
[
'59b140eb-4449-488f-9eeb-42412dcc044e',
import('@affine/templates/v1/template-galleries.json'),
nanoid(),
],
[
'7217fbe2-61db-4a91-93c6-ad5c800e5a43',
import('@affine/templates/v1/personal-home.json'),
nanoid(),
],
[
'6eb43ea8-8c11-456d-bb1d-5193937961ab',
import('@affine/templates/v1/working-home.json'),
nanoid(),
],
[
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a',
import('@affine/templates/v1/personal-project-management.json'),
nanoid(),
],
[
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238',
import('@affine/templates/v1/travel-plan.json'),
edgelessPage2,
],
[
'22163830-8252-43fe-b62d-fd9bbeaa4caa',
import('@affine/templates/v1/personal-knowledge-management.json'),
nanoid(),
],
[
'b7a9e1bc-e205-44aa-8dad-7e328269d00b',
import('@affine/templates/v1/annual-performance-review.json'),
nanoid(),
],
[
'646305d9-93e0-48df-bb92-d82944ceb5a3',
import('@affine/templates/v1/brief-event-planning.json'),
nanoid(),
],
[
'0350509d-8702-4797-b4d7-168f5e9359c7',
import('@affine/templates/v1/meeting-summary.json'),
nanoid(),
],
[
'aa02af3c-5c5c-4856-b7ce-947ad17331f3',
import('@affine/templates/v1/okr-template.json'),
nanoid(),
],
[
'9d6e716e-a071-45a2-88ac-2f2f6eec0109',
import('@affine/templates/v1/travel-note.json'),
edgelessPage3,
],
] as const;
const idMap = await Promise.all(data).then(async data => {
return data.reduce<Record<string, string>>(
(record, currentValue) => {
const [oldId, _, newId] = currentValue;
record[oldId] = newId;
return record;
},
{} as Record<string, string>
);
});
await Promise.all(
data.map(async ([id, promise, newId]) => {
const { default: template } = await promise;
let json = JSON.stringify(template);
Object.entries(idMap).forEach(([oldId, newId]) => {
json = json.replaceAll(oldId, newId);
});
json = JSON.parse(json);
await workspace
.importPageSnapshot(structuredClone(json), newId)
.catch(error => {
console.error('error importing page', id, error);
});
const page = workspace.getPage(newId);
assertExists(page);
await page.waitForLoaded();
workspace.schema.upgradePage(
0,
{
'affine:note': 1,
'affine:bookmark': 1,
'affine:database': 2,
'affine:divider': 1,
'affine:image': 1,
'affine:list': 1,
'affine:code': 1,
'affine:page': 2,
'affine:paragraph': 1,
'affine:surface': 3,
},
page.spaceDoc
);
})
);
Object.entries(pageMetas).forEach(([oldId, meta]) => {
const newId = idMap[oldId];
workspace.setPageMeta(newId, meta);
});
}
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
const migrationOrigin = 'affine-migration';
import { assertExists } from '@blocksuite/global/utils';
import type { Schema } from '@blocksuite/store';
import { nanoid } from 'nanoid';
type XYWH = [number, number, number, number];
function deserializeXYWH(xywh: string): XYWH {
return JSON.parse(xywh) as XYWH;
}
const getLatestVersions = (schema: Schema): Record<string, number> => {
return [...schema.flavourSchemaMap.entries()].reduce(
(record, [flavour, schema]) => {
record[flavour] = schema.version;
return record;
},
{} as Record<string, number>
);
};
function migrateDatabase(data: YMap<unknown>) {
data.delete('prop:mode');
data.set('prop:views', new YArray());
const columns = (data.get('prop:columns') as YArray<unknown>).toJSON() as {
id: string;
name: string;
hide: boolean;
type: string;
width: number;
selection?: unknown[];
}[];
const views = [
{
id: 'default',
name: 'Table',
columns: columns.map(col => ({
id: col.id,
width: col.width,
hide: col.hide,
})),
filter: { type: 'group', op: 'and', conditions: [] },
mode: 'table',
},
];
const cells = (data.get('prop:cells') as YMap<unknown>).toJSON() as Record<
string,
Record<
string,
{
id: string;
value: unknown;
}
>
>;
const convertColumn = (
id: string,
update: (cell: { id: string; value: unknown }) => void
) => {
Object.values(cells).forEach(row => {
if (row[id] != null) {
update(row[id]);
}
});
};
const newColumns = columns.map(v => {
let data: Record<string, unknown> = {};
if (v.type === 'select' || v.type === 'multi-select') {
data = { options: v.selection };
if (v.type === 'select') {
convertColumn(v.id, cell => {
if (Array.isArray(cell.value)) {
cell.value = cell.value[0]?.id;
}
});
} else {
convertColumn(v.id, cell => {
if (Array.isArray(cell.value)) {
cell.value = cell.value.map(v => v.id);
}
});
}
}
if (v.type === 'number') {
convertColumn(v.id, cell => {
if (typeof cell.value === 'string') {
cell.value = Number.parseFloat(cell.value.toString());
}
});
}
return {
id: v.id,
type: v.type,
name: v.name,
data,
};
});
data.set('prop:columns', newColumns);
data.set('prop:views', views);
data.set('prop:cells', cells);
}
function runBlockMigration(
flavour: string,
data: YMap<unknown>,
version: number
) {
if (flavour === 'affine:frame') {
data.set('sys:flavour', 'affine:note');
return;
}
if (flavour === 'affine:surface' && version <= 3) {
if (data.has('elements')) {
const elements = data.get('elements') as YMap<unknown>;
migrateSurface(elements);
data.set('prop:elements', elements.clone());
data.delete('elements');
} else {
data.set('prop:elements', new YMap());
}
}
if (flavour === 'affine:embed') {
data.set('sys:flavour', 'affine:image');
data.delete('prop:type');
}
if (flavour === 'affine:database' && version < 2) {
migrateDatabase(data);
}
}
function migrateSurface(data: YMap<unknown>) {
for (const [, value] of <IterableIterator<[string, YMap<unknown>]>>(
data.entries()
)) {
if (value.get('type') === 'connector') {
migrateSurfaceConnector(value);
}
}
}
function migrateSurfaceConnector(data: YMap<any>) {
let id = data.get('startElement')?.id;
const controllers = data.get('controllers');
const length = controllers.length;
const xywh = deserializeXYWH(data.get('xywh'));
if (id) {
data.set('source', { id });
} else {
data.set('source', {
position: [controllers[0].x + xywh[0], controllers[0].y + xywh[1]],
});
}
id = data.get('endElement')?.id;
if (id) {
data.set('target', { id });
} else {
data.set('target', {
position: [
controllers[length - 1].x + xywh[0],
controllers[length - 1].y + xywh[1],
],
});
}
const width = data.get('lineWidth') ?? 4;
data.set('strokeWidth', width);
const color = data.get('color');
data.set('stroke', color);
data.delete('startElement');
data.delete('endElement');
data.delete('controllers');
data.delete('lineWidth');
data.delete('color');
data.delete('xywh');
}
function updateBlockVersions(versions: YMap<number>) {
const frameVersion = versions.get('affine:frame');
if (frameVersion !== undefined) {
versions.set('affine:note', frameVersion);
versions.delete('affine:frame');
}
const embedVersion = versions.get('affine:embed');
if (embedVersion !== undefined) {
versions.set('affine:image', embedVersion);
versions.delete('affine:embed');
}
const databaseVersion = versions.get('affine:database');
if (databaseVersion !== undefined && databaseVersion < 2) {
versions.set('affine:database', 2);
}
}
function migrateMeta(
oldDoc: YDoc,
newDoc: YDoc,
idMap: Record<string, string>
) {
const originalMeta = oldDoc.getMap('space:meta');
const originalVersions = originalMeta.get('versions') as YMap<number>;
const originalPages = originalMeta.get('pages') as YArray<YMap<unknown>>;
const meta = newDoc.getMap('meta');
const pages = new YArray();
const blockVersions = originalVersions.clone();
meta.set('workspaceVersion', 1);
meta.set('blockVersions', blockVersions);
meta.set('pages', pages);
meta.set('name', originalMeta.get('name') as string);
updateBlockVersions(blockVersions);
const mapList = originalPages.map(page => {
const map = new YMap();
Array.from(page.entries())
.filter(([key]) => key !== 'subpageIds')
.forEach(([key, value]) => {
if (key === 'id') {
idMap[value] = nanoid();
map.set(key, idMap[value]);
} else {
map.set(key, value);
}
});
return map;
});
pages.push(mapList);
}
function migrateBlocks(
oldDoc: YDoc,
newDoc: YDoc,
idMap: Record<string, string>
) {
const spaces = newDoc.getMap('spaces');
const originalMeta = oldDoc.getMap('space:meta');
const originalVersions = originalMeta.get('versions') as YMap<number>;
const originalPages = originalMeta.get('pages') as YArray<YMap<unknown>>;
originalPages.forEach(page => {
const id = page.get('id') as string;
const newId = idMap[id];
const spaceId = id.startsWith('space:') ? id : `space:${id}`;
const originalBlocks = oldDoc.getMap(spaceId) as YMap<unknown>;
const subdoc = new YDoc();
spaces.set(newId, subdoc);
subdoc.guid = id;
const blocks = subdoc.getMap('blocks');
Array.from(originalBlocks.entries()).forEach(([key, value]) => {
const blockData = value.clone();
blocks.set(key, blockData);
const flavour = blockData.get('sys:flavour') as string;
const version = originalVersions.get(flavour);
if (version !== undefined) {
runBlockMigration(flavour, blockData, version);
}
});
});
}
export function migrateToSubdoc(oldDoc: YDoc): YDoc {
const needMigration =
Array.from(oldDoc.getMap('space:meta').keys()).length > 0;
if (!needMigration) {
return oldDoc;
}
const newDoc = new YDoc();
const idMap = {} as Record<string, string>;
migrateMeta(oldDoc, newDoc, idMap);
migrateBlocks(oldDoc, newDoc, idMap);
return newDoc;
}
export type UpgradeOptions = {
getCurrentRootDoc: () => Promise<YDoc>;
createWorkspace: () => Promise<Workspace>;
getSchema: () => Schema;
};
const upgradeV1ToV2 = async (options: UpgradeOptions) => {
const oldDoc = await options.getCurrentRootDoc();
const newDoc = migrateToSubdoc(oldDoc);
const newWorkspace = await options.createWorkspace();
applyUpdate(newWorkspace.doc, encodeStateAsUpdate(newDoc), migrationOrigin);
newDoc.getSubdocs().forEach(subdoc => {
newWorkspace.doc.getSubdocs().forEach(newDoc => {
if (subdoc.guid === newDoc.guid) {
applyUpdate(newDoc, encodeStateAsUpdate(subdoc), migrationOrigin);
}
});
});
return newWorkspace;
};
export * from './initialization';
export * from './migration/blob';
export { migratePages as forceUpgradePages } from './migration/blocksuite'; // campatible with electron
export * from './migration/fixing';
export { migrateToSubdoc } from './migration/subdoc';
export * from './migration/workspace';
/**
* Force upgrade block schema to the latest.
* Don't force to upgrade the pages without the check.
*
* Please note that this function will not upgrade the workspace version.
*
* @returns true if any schema is upgraded.
* @returns false if no schema is upgraded.
* @deprecated
* Use workspace meta data to determine the workspace version.
*/
export async function forceUpgradePages(
options: Omit<UpgradeOptions, 'createWorkspace'>
): Promise<boolean> {
const rootDoc = await options.getCurrentRootDoc();
guidCompatibilityFix(rootDoc);
const spaces = rootDoc.getMap('spaces') as YMap<any>;
const meta = rootDoc.getMap('meta') as YMap<unknown>;
const versions = meta.get('blockVersions') as YMap<number>;
const schema = options.getSchema();
const oldVersions = versions?.toJSON() ?? {};
spaces.forEach((space: Doc) => {
try {
schema.upgradePage(0, oldVersions, space);
} catch (e) {
console.error(`page ${space.guid} upgrade failed`, e);
}
});
const newVersions = getLatestVersions(schema);
meta.set('blockVersions', new YMap(Object.entries(newVersions)));
return Object.entries(oldVersions).some(
([flavour, version]) => newVersions[flavour] !== version
);
}
// database from 2 to 3
async function upgradeV2ToV3(options: UpgradeOptions): Promise<boolean> {
const rootDoc = await options.getCurrentRootDoc();
const spaces = rootDoc.getMap('spaces') as YMap<any>;
const meta = rootDoc.getMap('meta') as YMap<unknown>;
const versions = meta.get('blockVersions') as YMap<number>;
const schema = options.getSchema();
guidCompatibilityFix(rootDoc);
spaces.forEach((space: Doc) => {
schema.upgradePage(
0,
{
'affine:note': 1,
'affine:bookmark': 1,
'affine:database': 2,
'affine:divider': 1,
'affine:image': 1,
'affine:list': 1,
'affine:code': 1,
'affine:page': 2,
'affine:paragraph': 1,
'affine:surface': 3,
},
space
);
});
if ('affine:database' in versions) {
meta.set(
'blockVersions',
new YMap(Object.entries(getLatestVersions(schema)))
);
} else {
Object.entries(getLatestVersions(schema)).map(([flavour, version]) =>
versions.set(flavour, version)
);
}
return true;
}
// patch root doc's space guid compatibility issue
//
// in version 0.10, page id in spaces no longer has prefix "space:"
// The data flow for fetching a doc's updates is:
// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces`
// - when fetching the rows of this doc using the doc id === page id,
// it will return empty since there is no updates associated with the page id
export function guidCompatibilityFix(rootDoc: YDoc) {
let changed = false;
transact(rootDoc, () => {
const meta = rootDoc.getMap('meta') as YMap<unknown>;
const pages = meta.get('pages') as YArray<YMap<unknown>>;
pages?.forEach(page => {
const pageId = page.get('id') as string | undefined;
if (pageId?.includes(':')) {
// remove the prefix "space:" from page id
page.set('id', pageId.split(':').at(-1));
}
});
const spaces = rootDoc.getMap('spaces') as YMap<YDoc>;
spaces?.forEach((doc: YDoc, pageId: string) => {
if (pageId.includes(':')) {
const newPageId = pageId.split(':').at(-1) ?? pageId;
const newDoc = new YDoc();
// clone the original doc. yjs is not happy to use the same doc instance
applyUpdate(newDoc, encodeStateAsUpdate(doc));
newDoc.guid = doc.guid;
spaces.set(newPageId, newDoc);
// should remove the old doc, otherwise we will do it again in the next run
spaces.delete(pageId);
changed = true;
console.debug(
`fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}`
);
}
});
});
return changed;
}
export enum WorkspaceVersion {
// v1 is treated as undefined
SubDoc = 2,
DatabaseV3 = 3,
Surface = 4,
}
/**
* If returns false, it means no migration is needed.
* If returns true, it means migration is done.
* If returns Workspace, it means new workspace is created,
* and the old workspace should be deleted.
*/
export async function migrateWorkspace(
currentVersion: WorkspaceVersion | undefined,
options: UpgradeOptions
): Promise<Workspace | boolean> {
if (currentVersion === undefined) {
const workspace = await upgradeV1ToV2(options);
await upgradeV2ToV3({
...options,
getCurrentRootDoc: () => Promise.resolve(workspace.doc),
});
return workspace;
}
if (currentVersion === WorkspaceVersion.SubDoc) {
return upgradeV2ToV3(options);
} else if (currentVersion === WorkspaceVersion.DatabaseV3) {
// surface from 3 to 5
return forceUpgradePages(options);
} else {
return false;
}
}
export async function migrateLocalBlobStorage(from: string, to: string) {
const fromStorage = createIndexeddbStorage(from);
const toStorage = createIndexeddbStorage(to);
const keys = await fromStorage.crud.list();
for (const key of keys) {
const value = await fromStorage.crud.get(key);
if (!value) {
console.warn('cannot find blob:', key);
continue;
}
await toStorage.crud.set(key, value);
}
}

View File

@@ -0,0 +1,291 @@
import { assertExists } from '@blocksuite/global/utils';
import type { Page, PageMeta, Workspace } from '@blocksuite/store';
import type { createStore, WritableAtom } from 'jotai/vanilla';
import { nanoid } from 'nanoid';
import { migratePages } from '../migration/blocksuite';
export async function initEmptyPage(page: Page, title?: string) {
await page.load(() => {
const pageBlockId = page.addBlock('affine:page', {
title: new page.Text(title ?? ''),
});
page.addBlock('affine:surface', {}, pageBlockId);
const noteBlockId = page.addBlock('affine:note', {}, pageBlockId);
page.addBlock('affine:paragraph', {}, noteBlockId);
});
}
/**
* FIXME: Use exported json data to instead of building data.
*/
export async function buildShowcaseWorkspace(
workspace: Workspace,
options: {
atoms: {
pageMode: WritableAtom<
undefined,
[pageId: string, mode: 'page' | 'edgeless'],
void
>;
};
store: ReturnType<typeof createStore>;
}
) {
const prototypes = {
tags: {
options: [
{
id: 'icg1n5UdkP',
value: 'Travel',
color: 'var(--affine-tag-gray)',
},
{
id: 'Oe5dSe1DDJ',
value: 'Quick summary',
color: 'var(--affine-tag-green)',
},
{
id: 'g1L5dXKctL',
value: 'OKR',
color: 'var(--affine-tag-purple)',
},
{
id: 'q3mceOl_zi',
value: 'Streamline your workflow',
color: 'var(--affine-tag-teal)',
},
{
id: 'ze07JVwBu4',
value: 'Plan',
color: 'var(--affine-tag-teal)',
},
{
id: '8qcYPCTK0h',
value: 'Review',
color: 'var(--affine-tag-orange)',
},
{
id: 'wg-fBtd2eI',
value: 'Engage',
color: 'var(--affine-tag-pink)',
},
{
id: 'QYFD_HeQc-',
value: 'Create',
color: 'var(--affine-tag-blue)',
},
{
id: 'ZHBa2NtdSo',
value: 'Learn',
color: 'var(--affine-tag-yellow)',
},
],
},
};
workspace.meta.setProperties(prototypes);
const edgelessPage1 = nanoid();
const edgelessPage2 = nanoid();
const edgelessPage3 = nanoid();
const { store, atoms } = options;
[edgelessPage1, edgelessPage2, edgelessPage3].forEach(pageId => {
store.set(atoms.pageMode, pageId, 'edgeless');
});
const pageMetas = {
'9f6f3c04-cf32-470c-9648-479dc838f10e': {
createDate: 1691548231530,
tags: ['ZHBa2NtdSo', 'QYFD_HeQc-', 'wg-fBtd2eI'],
updatedDate: 1691676331623,
favorite: true,
jumpOnce: true,
},
'0773e198-5de0-45d4-a35e-de22ea72b96b': {
createDate: 1691548220794,
tags: [],
updatedDate: 1691676775642,
favorite: false,
},
'59b140eb-4449-488f-9eeb-42412dcc044e': {
createDate: 1691551731225,
tags: [],
updatedDate: 1691654611175,
favorite: false,
},
'7217fbe2-61db-4a91-93c6-ad5c800e5a43': {
createDate: 1691552082822,
tags: [],
updatedDate: 1691654606912,
favorite: false,
},
'6eb43ea8-8c11-456d-bb1d-5193937961ab': {
createDate: 1691552090989,
tags: [],
updatedDate: 1691646748171,
favorite: false,
},
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a': {
createDate: 1691564303138,
tags: [],
updatedDate: 1691646845195,
},
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238': {
createDate: 1691574743531,
tags: ['icg1n5UdkP'],
updatedDate: 1691647117761,
},
'22163830-8252-43fe-b62d-fd9bbeaa4caa': {
createDate: 1691574859042,
tags: [],
updatedDate: 1691648159371,
},
'b7a9e1bc-e205-44aa-8dad-7e328269d00b': {
createDate: 1691575011078,
tags: ['8qcYPCTK0h'],
updatedDate: 1691645074511,
favorite: false,
},
'646305d9-93e0-48df-bb92-d82944ceb5a3': {
createDate: 1691634722239,
tags: ['ze07JVwBu4'],
updatedDate: 1691647069662,
favorite: false,
},
'0350509d-8702-4797-b4d7-168f5e9359c7': {
createDate: 1691635388447,
tags: ['Oe5dSe1DDJ'],
updatedDate: 1691645873930,
},
'aa02af3c-5c5c-4856-b7ce-947ad17331f3': {
createDate: 1691636192263,
tags: ['q3mceOl_zi', 'g1L5dXKctL'],
updatedDate: 1691645102104,
},
'9d6e716e-a071-45a2-88ac-2f2f6eec0109': {
createDate: 1691574743531,
tags: ['icg1n5UdkP'],
updatedDate: 1691574743531,
},
} satisfies Record<string, Partial<PageMeta>>;
const data = [
[
'9f6f3c04-cf32-470c-9648-479dc838f10e',
import('@affine/templates/v1/getting-started.json'),
nanoid(),
],
[
'0773e198-5de0-45d4-a35e-de22ea72b96b',
import('@affine/templates/v1/preloading.json'),
edgelessPage1,
],
[
'59b140eb-4449-488f-9eeb-42412dcc044e',
import('@affine/templates/v1/template-galleries.json'),
nanoid(),
],
[
'7217fbe2-61db-4a91-93c6-ad5c800e5a43',
import('@affine/templates/v1/personal-home.json'),
nanoid(),
],
[
'6eb43ea8-8c11-456d-bb1d-5193937961ab',
import('@affine/templates/v1/working-home.json'),
nanoid(),
],
[
'3ddc8a4f-62c7-4fd4-8064-9ed9f61e437a',
import('@affine/templates/v1/personal-project-management.json'),
nanoid(),
],
[
'512b1cb3-d22d-4b20-a7aa-58e2afcb1238',
import('@affine/templates/v1/travel-plan.json'),
edgelessPage2,
],
[
'22163830-8252-43fe-b62d-fd9bbeaa4caa',
import('@affine/templates/v1/personal-knowledge-management.json'),
nanoid(),
],
[
'b7a9e1bc-e205-44aa-8dad-7e328269d00b',
import('@affine/templates/v1/annual-performance-review.json'),
nanoid(),
],
[
'646305d9-93e0-48df-bb92-d82944ceb5a3',
import('@affine/templates/v1/brief-event-planning.json'),
nanoid(),
],
[
'0350509d-8702-4797-b4d7-168f5e9359c7',
import('@affine/templates/v1/meeting-summary.json'),
nanoid(),
],
[
'aa02af3c-5c5c-4856-b7ce-947ad17331f3',
import('@affine/templates/v1/okr-template.json'),
nanoid(),
],
[
'9d6e716e-a071-45a2-88ac-2f2f6eec0109',
import('@affine/templates/v1/travel-note.json'),
edgelessPage3,
],
] as const;
const idMap = await Promise.all(data).then(async data => {
return data.reduce<Record<string, string>>(
(record, currentValue) => {
const [oldId, _, newId] = currentValue;
record[oldId] = newId;
return record;
},
{} as Record<string, string>
);
});
await Promise.all(
data.map(async ([id, promise, newId]) => {
const { default: template } = await promise;
let json = JSON.stringify(template);
Object.entries(idMap).forEach(([oldId, newId]) => {
json = json.replaceAll(oldId, newId);
});
json = JSON.parse(json);
await workspace
.importPageSnapshot(structuredClone(json), newId)
.catch(error => {
console.error('error importing page', id, error);
});
const page = workspace.getPage(newId);
assertExists(page);
await page.load();
workspace.schema.upgradePage(
0,
{
'affine:note': 1,
'affine:bookmark': 1,
'affine:database': 2,
'affine:divider': 1,
'affine:image': 1,
'affine:list': 1,
'affine:code': 1,
'affine:page': 2,
'affine:paragraph': 1,
'affine:surface': 3,
},
page.spaceDoc
);
// The showcase building will create multiple pages once, and may skip the version writing.
// https://github.com/toeverything/blocksuite/blob/master/packages/store/src/workspace/page.ts#L662
if (!workspace.meta.blockVersions) {
await migratePages(workspace.doc, workspace.schema);
}
})
);
Object.entries(pageMetas).forEach(([oldId, meta]) => {
const newId = idMap[oldId];
workspace.setPageMeta(newId, meta);
});
}

View File

@@ -0,0 +1,15 @@
import { createIndexeddbStorage } from '@blocksuite/store';
export async function migrateLocalBlobStorage(from: string, to: string) {
const fromStorage = createIndexeddbStorage(from);
const toStorage = createIndexeddbStorage(to);
const keys = await fromStorage.crud.list();
for (const key of keys) {
const value = await fromStorage.crud.get(key);
if (!value) {
console.warn('cannot find blob:', key);
continue;
}
await toStorage.crud.set(key, value);
}
}

View File

@@ -0,0 +1,36 @@
import type { Schema } from '@blocksuite/store';
import type { Doc as YDoc } from 'yjs';
import { Map as YMap } from 'yjs';
const getLatestVersions = (schema: Schema): Record<string, number> => {
return [...schema.flavourSchemaMap.entries()].reduce(
(record, [flavour, schema]) => {
record[flavour] = schema.version;
return record;
},
{} as Record<string, number>
);
};
export async function migratePages(
rootDoc: YDoc,
schema: Schema
): Promise<boolean> {
const spaces = rootDoc.getMap('spaces') as YMap<any>;
const meta = rootDoc.getMap('meta') as YMap<unknown>;
const versions = meta.get('blockVersions') as YMap<number>;
const oldVersions = versions?.toJSON() ?? {};
spaces.forEach((space: YDoc) => {
try {
schema.upgradePage(0, oldVersions, space);
} catch (e) {
console.error(`page ${space.guid} upgrade failed`, e);
}
});
const newVersions = getLatestVersions(schema);
meta.set('blockVersions', new YMap(Object.entries(newVersions)));
return Object.entries(oldVersions).some(
([flavour, version]) => newVersions[flavour] !== version
);
}

View File

@@ -0,0 +1,45 @@
import type { Array as YArray, Map as YMap } from 'yjs';
import { Doc as YDoc, transact } from 'yjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
// patch root doc's space guid compatibility issue
//
// in version 0.10, page id in spaces no longer has prefix "space:"
// The data flow for fetching a doc's updates is:
// - page id in `meta.pages` -> find `${page-id}` in `doc.spaces` -> `doc` -> `doc.guid`
// if `doc` is not found in `doc.spaces`, a new doc will be created and its `doc.guid` is the same with its pageId
// - because of guid logic change, the doc that previously prefixed with "space:" will not be found in `doc.spaces`
// - when fetching the rows of this doc using the doc id === page id,
// it will return empty since there is no updates associated with the page id
export function guidCompatibilityFix(rootDoc: YDoc) {
let changed = false;
transact(rootDoc, () => {
const meta = rootDoc.getMap('meta') as YMap<unknown>;
const pages = meta.get('pages') as YArray<YMap<unknown>>;
pages?.forEach(page => {
const pageId = page.get('id') as string | undefined;
if (pageId?.includes(':')) {
// remove the prefix "space:" from page id
page.set('id', pageId.split(':').at(-1));
}
});
const spaces = rootDoc.getMap('spaces') as YMap<YDoc>;
spaces?.forEach((doc: YDoc, pageId: string) => {
if (pageId.includes(':')) {
const newPageId = pageId.split(':').at(-1) ?? pageId;
const newDoc = new YDoc();
// clone the original doc. yjs is not happy to use the same doc instance
applyUpdate(newDoc, encodeStateAsUpdate(doc));
newDoc.guid = doc.guid;
spaces.set(newPageId, newDoc);
// should remove the old doc, otherwise we will do it again in the next run
spaces.delete(pageId);
changed = true;
console.debug(
`fixed space id ${pageId} -> ${newPageId}, doc id: ${doc.guid}`
);
}
});
});
return changed;
}

View File

@@ -0,0 +1,282 @@
import type { Workspace } from '@blocksuite/store';
import { nanoid } from 'nanoid';
import { Array as YArray, Doc as YDoc, Map as YMap } from 'yjs';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
const migrationOrigin = 'affine-migration';
type XYWH = [number, number, number, number];
function deserializeXYWH(xywh: string): XYWH {
return JSON.parse(xywh) as XYWH;
}
function migrateDatabase(data: YMap<unknown>) {
data.delete('prop:mode');
data.set('prop:views', new YArray());
const columns = (data.get('prop:columns') as YArray<unknown>).toJSON() as {
id: string;
name: string;
hide: boolean;
type: string;
width: number;
selection?: unknown[];
}[];
const views = [
{
id: 'default',
name: 'Table',
columns: columns.map(col => ({
id: col.id,
width: col.width,
hide: col.hide,
})),
filter: { type: 'group', op: 'and', conditions: [] },
mode: 'table',
},
];
const cells = (data.get('prop:cells') as YMap<unknown>).toJSON() as Record<
string,
Record<
string,
{
id: string;
value: unknown;
}
>
>;
const convertColumn = (
id: string,
update: (cell: { id: string; value: unknown }) => void
) => {
Object.values(cells).forEach(row => {
if (row[id] != null) {
update(row[id]);
}
});
};
const newColumns = columns.map(v => {
let data: Record<string, unknown> = {};
if (v.type === 'select' || v.type === 'multi-select') {
data = { options: v.selection };
if (v.type === 'select') {
convertColumn(v.id, cell => {
if (Array.isArray(cell.value)) {
cell.value = cell.value[0]?.id;
}
});
} else {
convertColumn(v.id, cell => {
if (Array.isArray(cell.value)) {
cell.value = cell.value.map(v => v.id);
}
});
}
}
if (v.type === 'number') {
convertColumn(v.id, cell => {
if (typeof cell.value === 'string') {
cell.value = Number.parseFloat(cell.value.toString());
}
});
}
return {
id: v.id,
type: v.type,
name: v.name,
data,
};
});
data.set('prop:columns', newColumns);
data.set('prop:views', views);
data.set('prop:cells', cells);
}
function runBlockMigration(
flavour: string,
data: YMap<unknown>,
version: number
) {
if (flavour === 'affine:frame') {
data.set('sys:flavour', 'affine:note');
return;
}
if (flavour === 'affine:surface' && version <= 3) {
if (data.has('elements')) {
const elements = data.get('elements') as YMap<unknown>;
migrateSurface(elements);
data.set('prop:elements', elements.clone());
data.delete('elements');
} else {
data.set('prop:elements', new YMap());
}
}
if (flavour === 'affine:embed') {
data.set('sys:flavour', 'affine:image');
data.delete('prop:type');
}
if (flavour === 'affine:database' && version < 2) {
migrateDatabase(data);
}
}
function migrateSurface(data: YMap<unknown>) {
for (const [, value] of <IterableIterator<[string, YMap<unknown>]>>(
data.entries()
)) {
if (value.get('type') === 'connector') {
migrateSurfaceConnector(value);
}
}
}
function migrateSurfaceConnector(data: YMap<any>) {
let id = data.get('startElement')?.id;
const controllers = data.get('controllers');
const length = controllers.length;
const xywh = deserializeXYWH(data.get('xywh'));
if (id) {
data.set('source', { id });
} else {
data.set('source', {
position: [controllers[0].x + xywh[0], controllers[0].y + xywh[1]],
});
}
id = data.get('endElement')?.id;
if (id) {
data.set('target', { id });
} else {
data.set('target', {
position: [
controllers[length - 1].x + xywh[0],
controllers[length - 1].y + xywh[1],
],
});
}
const width = data.get('lineWidth') ?? 4;
data.set('strokeWidth', width);
const color = data.get('color');
data.set('stroke', color);
data.delete('startElement');
data.delete('endElement');
data.delete('controllers');
data.delete('lineWidth');
data.delete('color');
data.delete('xywh');
}
function updateBlockVersions(versions: YMap<number>) {
const frameVersion = versions.get('affine:frame');
if (frameVersion !== undefined) {
versions.set('affine:note', frameVersion);
versions.delete('affine:frame');
}
const embedVersion = versions.get('affine:embed');
if (embedVersion !== undefined) {
versions.set('affine:image', embedVersion);
versions.delete('affine:embed');
}
const databaseVersion = versions.get('affine:database');
if (databaseVersion !== undefined && databaseVersion < 2) {
versions.set('affine:database', 2);
}
}
function migrateMeta(
oldDoc: YDoc,
newDoc: YDoc,
idMap: Record<string, string>
) {
const originalMeta = oldDoc.getMap('space:meta');
const originalVersions = originalMeta.get('versions') as YMap<number>;
const originalPages = originalMeta.get('pages') as YArray<YMap<string>>;
const meta = newDoc.getMap('meta');
const pages = new YArray();
const blockVersions = originalVersions.clone();
meta.set('workspaceVersion', 1);
meta.set('blockVersions', blockVersions);
meta.set('pages', pages);
meta.set('name', originalMeta.get('name') as string);
updateBlockVersions(blockVersions);
const mapList = originalPages.map(page => {
const map = new YMap();
Array.from(page.entries())
.filter(([key]) => key !== 'subpageIds')
.forEach(([key, value]) => {
if (key === 'id') {
idMap[value] = nanoid();
map.set(key, idMap[value]);
} else {
map.set(key, value);
}
});
return map;
});
pages.push(mapList);
}
function migrateBlocks(
oldDoc: YDoc,
newDoc: YDoc,
idMap: Record<string, string>
) {
const spaces = newDoc.getMap('spaces');
const originalMeta = oldDoc.getMap('space:meta');
const originalVersions = originalMeta.get('versions') as YMap<number>;
const originalPages = originalMeta.get('pages') as YArray<YMap<unknown>>;
originalPages.forEach(page => {
const id = page.get('id') as string;
const newId = idMap[id];
const spaceId = id.startsWith('space:') ? id : `space:${id}`;
const originalBlocks = oldDoc.getMap(spaceId) as YMap<unknown>;
const subdoc = new YDoc();
spaces.set(newId, subdoc);
subdoc.guid = id;
const blocks = subdoc.getMap('blocks');
Array.from(originalBlocks.entries()).forEach(([key, value]) => {
// @ts-expect-error clone method exists
const blockData = value.clone();
blocks.set(key, blockData);
const flavour = blockData.get('sys:flavour') as string;
const version = originalVersions.get(flavour);
if (version !== undefined) {
runBlockMigration(flavour, blockData, version);
}
});
});
}
export function migrateToSubdoc(oldDoc: YDoc): YDoc {
const needMigration =
Array.from(oldDoc.getMap('space:meta').keys()).length > 0;
if (!needMigration) {
return oldDoc;
}
const newDoc = new YDoc();
const idMap = {} as Record<string, string>;
migrateMeta(oldDoc, newDoc, idMap);
migrateBlocks(oldDoc, newDoc, idMap);
return newDoc;
}
export const upgradeV1ToV2 = async (
oldDoc: YDoc,
createWorkspace: () => Promise<Workspace>
) => {
const newDoc = migrateToSubdoc(oldDoc);
const newWorkspace = await createWorkspace();
applyUpdate(newWorkspace.doc, encodeStateAsUpdate(newDoc), migrationOrigin);
newDoc.getSubdocs().forEach(subdoc => {
newWorkspace.doc.getSubdocs().forEach(newDoc => {
if (subdoc.guid === newDoc.guid) {
applyUpdate(newDoc, encodeStateAsUpdate(subdoc), migrationOrigin);
}
});
});
return newWorkspace;
};

View File

@@ -0,0 +1,77 @@
import type { Workspace } from '@blocksuite/store';
import type { Schema } from '@blocksuite/store';
import type { Doc as YDoc } from 'yjs';
import { migratePages } from './blocksuite';
import { upgradeV1ToV2 } from './subdoc';
interface MigrationOptions {
doc: YDoc;
schema: Schema;
createWorkspace: () => Promise<Workspace>;
}
function createMigrationQueue(options: MigrationOptions) {
return [
async (doc: YDoc) => {
const newWorkspace = await upgradeV1ToV2(doc, options.createWorkspace);
return newWorkspace.doc;
},
async (doc: YDoc) => {
await migratePages(doc, options.schema);
return doc;
},
];
}
/**
* For split migrate function from MigrationQueue.
*/
export enum MigrationPoint {
SubDoc = 1,
BlockVersion = 2,
}
export async function migrateWorkspace(
point: MigrationPoint,
options: MigrationOptions
) {
const migrationQueue = createMigrationQueue(options);
const migrationFns = migrationQueue.slice(point - 1);
let doc = options.doc;
for (const migrate of migrationFns) {
doc = await migrate(doc);
}
return doc;
}
export function checkWorkspaceCompatibility(
workspace: Workspace
): MigrationPoint | null {
const workspaceDocJSON = workspace.doc.toJSON();
const spaceMetaObj = workspaceDocJSON['space:meta'];
const docKeys = Object.keys(workspaceDocJSON);
const haveSpaceMeta = !!spaceMetaObj && Object.keys(spaceMetaObj).length > 0;
const haveLegacySpace = docKeys.some(key => key.startsWith('space:'));
if (haveSpaceMeta || haveLegacySpace) {
return MigrationPoint.SubDoc;
}
// Sometimes, blocksuite will not write blockVersions to meta.
// Just fix it when user open the workspace.
const blockVersions = workspace.meta.blockVersions;
if (!blockVersions) {
return MigrationPoint.BlockVersion;
}
// From v2, we depend on blocksuite to check and migrate data.
for (const [flavour, version] of Object.entries(blockVersions)) {
const schema = workspace.schema.flavourSchemaMap.get(flavour);
if (schema?.version !== version) {
return MigrationPoint.BlockVersion;
}
}
return null;
}

View File

@@ -200,6 +200,7 @@ export type WorkspaceHandlers = {
list: () => Promise<[workspaceId: string, meta: WorkspaceMeta][]>;
delete: (id: string) => Promise<void>;
getMeta: (id: string) => Promise<WorkspaceMeta>;
clone: (id: string, newId: string) => Promise<void>;
};
export type UnwrapManagerHandlerToServerSide<

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/sdk",
"version": "0.10.2",
"version": "0.10.3-canary.2",
"type": "module",
"scripts": {
"build": "vite build",
@@ -22,12 +22,12 @@
"dist"
],
"dependencies": {
"@blocksuite/block-std": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/blocks": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/editor": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/global": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/store": "0.0.0-20231116023037-31273bb7-nightly",
"jotai": "^2.4.3",
"@blocksuite/block-std": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/blocks": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/editor": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/global": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/store": "0.0.0-20231122113751-6bf81eb3-nightly",
"jotai": "^2.5.1",
"zod": "^3.22.4"
},
"devDependencies": {

View File

@@ -35,4 +35,4 @@ downloadBinary(yDoc.guid).then(blob => {
## LICENSE
[MIT](https://github.com/toeverything/AFFiNE/blob/master/LICENSE-MIT)
[MIT](https://github.com/toeverything/AFFiNE/blob/canary/LICENSE-MIT)

View File

@@ -1,7 +1,7 @@
{
"name": "@toeverything/y-indexeddb",
"type": "module",
"version": "0.10.2",
"version": "0.10.3-canary.2",
"description": "IndexedDB database adapter for Yjs",
"repository": "toeverything/AFFiNE",
"author": "toeverything",
@@ -33,18 +33,18 @@
},
"dependencies": {
"idb": "^7.1.1",
"nanoid": "^5.0.1",
"nanoid": "^5.0.3",
"y-provider": "workspace:*"
},
"devDependencies": {
"@blocksuite/blocks": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/store": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/blocks": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/store": "0.0.0-20231122113751-6bf81eb3-nightly",
"fake-indexeddb": "^5.0.0",
"vite": "^4.4.11",
"vite-plugin-dts": "3.6.0",
"vitest": "0.34.6",
"y-indexeddb": "^9.0.11",
"yjs": "^13.6.8"
"yjs": "^13.6.10"
},
"peerDependencies": {
"yjs": "^13"

View File

@@ -1,7 +1,7 @@
{
"name": "y-provider",
"type": "module",
"version": "0.10.2",
"version": "0.10.3-canary.2",
"description": "Yjs provider protocol for multi document support",
"exports": {
".": "./src/index.ts"
@@ -24,11 +24,11 @@
"build": "vite build"
},
"devDependencies": {
"@blocksuite/store": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/store": "0.0.0-20231122113751-6bf81eb3-nightly",
"vite": "^4.4.11",
"vite-plugin-dts": "3.6.0",
"vitest": "0.34.6",
"yjs": "^13.6.8"
"yjs": "^13.6.10"
},
"peerDependencies": {
"yjs": "^13"

View File

@@ -12,7 +12,6 @@
"@blocksuite/editor": "*",
"@blocksuite/global": "*",
"@blocksuite/icons": "2.1.34",
"@blocksuite/lit": "*",
"@blocksuite/store": "*"
},
"dependencies": {
@@ -22,14 +21,11 @@
"@affine/workspace": "workspace:*",
"@dnd-kit/core": "^6.0.8",
"@dnd-kit/modifiers": "^6.0.1",
"@dnd-kit/sortable": "^7.0.2",
"@dnd-kit/sortable": "^8.0.0",
"@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.1",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/base": "5.0.0-beta.19",
"@mui/icons-material": "^5.14.14",
"@mui/material": "^5.14.14",
"@popperjs/core": "^2.11.8",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-collapsible": "^1.0.3",
@@ -47,15 +43,14 @@
"clsx": "^2.0.0",
"dayjs": "^1.11.10",
"foxact": "^0.2.20",
"jotai": "^2.4.3",
"jotai-effect": "^0.2.2",
"jotai-scope": "^0.4.0",
"jotai": "^2.5.1",
"jotai-effect": "^0.2.3",
"jotai-scope": "^0.4.1",
"lit": "^3.0.2",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"lottie-react": "^2.4.0",
"lottie-web": "^5.12.2",
"nanoid": "^5.0.1",
"nanoid": "^5.0.3",
"next-themes": "^0.2.1",
"react": "18.2.0",
"react-datepicker": "^4.20.0",
@@ -69,12 +64,12 @@
"uuid": "^9.0.1"
},
"devDependencies": {
"@blocksuite/blocks": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/editor": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/global": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/icons": "2.1.35",
"@blocksuite/lit": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/store": "0.0.0-20231116023037-31273bb7-nightly",
"@blocksuite/blocks": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/editor": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/global": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/icons": "2.1.36",
"@blocksuite/lit": "0.0.0-20231122113751-6bf81eb3-nightly",
"@blocksuite/store": "0.0.0-20231122113751-6bf81eb3-nightly",
"@storybook/jest": "^0.2.3",
"@storybook/testing-library": "^0.2.2",
"@testing-library/react": "^14.0.0",
@@ -85,10 +80,10 @@
"@types/react-dom": "^18.2.13",
"@vanilla-extract/css": "^1.13.0",
"fake-indexeddb": "^5.0.0",
"typescript": "^5.2.2",
"typescript": "^5.3.2",
"vite": "^4.4.11",
"vitest": "0.34.6",
"yjs": "^13.6.8"
"yjs": "^13.6.10"
},
"version": "0.10.2"
"version": "0.10.3-canary.2"
}

View File

@@ -1,47 +0,0 @@
import { Trans } from '@affine/i18n';
import { CloseIcon, Logo1Icon } from '@blocksuite/icons';
import {
downloadCloseButtonStyle,
downloadMessageStyle,
downloadTipContainerStyle,
downloadTipIconStyle,
downloadTipStyle,
linkStyle,
} from './index.css';
export const DownloadTips = ({ onClose }: { onClose: () => void }) => {
return (
<div
className={downloadTipContainerStyle}
data-testid="download-client-tip"
>
<div className={downloadTipStyle}>
<Logo1Icon className={downloadTipIconStyle} />
<div className={downloadMessageStyle}>
<Trans i18nKey="com.affine.banner.content">
This demo is limited.
<a
className={linkStyle}
href="https://affine.pro/download"
target="_blank"
rel="noreferrer"
>
Download the AFFiNE Client
</a>
for the latest features and Performance.
</Trans>
</div>
</div>
<div
className={downloadCloseButtonStyle}
onClick={onClose}
data-testid="download-client-tip-close-button"
>
<CloseIcon className={downloadTipIconStyle} />
</div>
</div>
);
};
export default DownloadTips;

View File

@@ -1,13 +1,4 @@
import { keyframes, style } from '@vanilla-extract/css';
const slideDown = keyframes({
'0%': {
height: '0px',
},
'100%': {
height: '44px',
},
});
import { style } from '@vanilla-extract/css';
export const browserWarningStyle = style({
backgroundColor: 'var(--affine-background-warning-color)',
@@ -36,52 +27,31 @@ export const closeIconStyle = style({
position: 'relative',
zIndex: 1,
});
export const downloadTipContainerStyle = style({
backgroundColor: 'var(--affine-primary-color)',
color: 'var(--affine-white)',
export const tipsContainer = style({
backgroundColor: 'var(--affine-background-error-color)',
color: 'var(--affine-error-color)',
width: '100%',
height: '44px',
fontSize: 'var(--affine-font-base)',
fontSize: 'var(--affine-font-sm)',
fontWeight: '700',
display: 'flex',
justifyContent: 'center',
justifyContent: 'space-between',
alignItems: 'center',
position: 'relative',
animation: `${slideDown} .3s ease-in-out forwards`,
padding: '12px 16px',
position: 'sticky',
gap: '16px',
containerType: 'inline-size',
});
export const downloadTipStyle = style({
export const tipsMessage = style({
color: 'var(--affine-error-color)',
flexGrow: 1,
flexShrink: 1,
});
export const tipsRightItem = style({
display: 'flex',
justifyContent: 'center',
flexShrink: 0,
justifyContent: 'space-between',
alignItems: 'center',
});
export const downloadTipIconStyle = style({
color: 'var(--affine-white)',
width: '24px',
height: '24px',
fontSize: '24px',
position: 'relative',
zIndex: 1,
});
export const downloadCloseButtonStyle = style({
color: 'var(--affine-white)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
position: 'absolute',
right: '24px',
});
export const downloadMessageStyle = style({
color: 'var(--affine-white)',
marginLeft: '8px',
});
export const linkStyle = style({
color: 'var(--affine-white)',
textDecoration: 'underline',
':hover': {
textDecoration: 'underline',
},
':visited': {
color: 'var(--affine-white)',
textDecoration: 'underline',
},
gap: '16px',
});

View File

@@ -1,2 +1,2 @@
export * from './browser-warning';
export * from './download-client';
export * from './local-demo-tips';

View File

@@ -0,0 +1,54 @@
import { CloseIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useCallback } from 'react';
import * as styles from './index.css';
type LocalDemoTipsProps = {
isLoggedIn: boolean;
onLogin: () => void;
onEnableCloud: () => void;
onClose: () => void;
};
export const LocalDemoTips = ({
onClose,
isLoggedIn,
onLogin,
onEnableCloud,
}: LocalDemoTipsProps) => {
const content = isLoggedIn
? 'This is a local demo workspace, and the data is stored locally. We recommend enabling AFFiNE Cloud.'
: 'This is a local demo workspace, and the data is stored locally in the browser. We recommend Enabling AFFiNE Cloud or downloading the client for a better experience.';
const buttonLabel = isLoggedIn
? 'Enable AFFiNE Cloud'
: 'Sign in with AFFiNE Cloud';
const handleClick = useCallback(() => {
if (isLoggedIn) {
return onEnableCloud();
}
return onLogin();
}, [isLoggedIn, onEnableCloud, onLogin]);
return (
<div className={styles.tipsContainer} data-testid="local-demo-tips">
<div className={styles.tipsMessage}>{content}</div>
<div className={styles.tipsRightItem}>
<div>
<Button onClick={handleClick}>{buttonLabel}</Button>
</div>
<IconButton
onClick={onClose}
data-testid="local-demo-tips-close-button"
>
<CloseIcon />
</IconButton>
</div>
</div>
);
};
export default LocalDemoTips;

View File

@@ -0,0 +1,22 @@
import { style } from '@vanilla-extract/css';
export {
closeIcon,
ellipsisTextOverflow,
halo,
icon,
particles,
root,
} from '../app-updater-button/index.css';
export const rootPadding = style({
padding: '0 24px',
});
export const label = style({
display: 'flex',
alignItems: 'center',
width: '100%',
height: '100%',
fontSize: 'var(--affine-font-sm)',
whiteSpace: 'nowrap',
});

View File

@@ -0,0 +1,53 @@
import { CloseIcon, DownloadIcon } from '@blocksuite/icons';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import * as styles from './index.css';
// Although it is called an input, it is actually a button.
export function AppDownloadButton({
className,
style,
}: {
className?: string;
style?: React.CSSProperties;
}) {
const [show, setShow] = useState(true);
const handleClose = useCallback(() => {
setShow(false);
}, []);
// TODO: unify this type of literal value.
const handleClick = useCallback(() => {
const url = `https://affine.pro/download?channel=stable`;
open(url, '_blank');
}, []);
if (!show) {
return null;
}
return (
<button
style={style}
className={clsx([styles.root, styles.rootPadding, className])}
onClick={handleClick}
>
<div className={clsx([styles.label])}>
<DownloadIcon className={styles.icon} />
<span className={styles.ellipsisTextOverflow}>Download App</span>
</div>
<div
className={styles.closeIcon}
onClick={e => {
e.stopPropagation();
handleClose();
}}
>
<CloseIcon />
</div>
<div className={styles.particles} aria-hidden="true"></div>
<span className={styles.halo} aria-hidden="true"></span>
</button>
);
}

View File

@@ -1,11 +1,11 @@
import { Skeleton } from '@mui/material';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useAtom, useAtomValue } from 'jotai';
import debounce from 'lodash/debounce';
import { debounce } from 'lodash-es';
import type { PropsWithChildren, ReactElement } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Skeleton } from '../../ui/skeleton';
import { fallbackHeaderStyle, fallbackStyle } from './fallback.css';
import {
floatingMaxWidth,
@@ -159,6 +159,7 @@ export const AppSidebarFallback = (): ReactElement | null => {
};
export * from './add-page-button';
export * from './app-download-button';
export * from './app-updater-button';
export * from './category-divider';
export * from './index.css';

View File

@@ -1,7 +1,6 @@
import { EditorContainer } from '@blocksuite/editor';
import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store';
import { Skeleton } from '@mui/material';
import clsx from 'clsx';
import { use } from 'foxact/use';
import type { CSSProperties, ReactElement } from 'react';
@@ -17,11 +16,12 @@ import {
import type { FallbackProps } from 'react-error-boundary';
import { ErrorBoundary } from 'react-error-boundary';
import { Skeleton } from '../../ui/skeleton';
import {
blockSuiteEditorHeaderStyle,
blockSuiteEditorStyle,
} from './index.css';
import { getPresets } from './preset';
import { editorPresets } from './preset';
interface BlockElement extends Element {
path: string[];
@@ -104,12 +104,10 @@ const BlockSuiteEditorImpl = ({
if (editor.page !== page) {
editor.page = page;
editor.pagePreset = editorPresets.pageModePreset;
editor.edgelessPreset = editorPresets.edgelessModePreset;
}
const presets = getPresets();
editor.pagePreset = presets.pageModePreset;
editor.edgelessPreset = presets.edgelessModePreset;
useLayoutEffect(() => {
if (editor) {
const disposes: (() => void)[] = [];

View File

@@ -17,7 +17,7 @@ class CustomAttachmentService extends AttachmentService {
}
}
export function getPresets() {
function getPresets() {
const pageModePreset = PagePreset.map(preset => {
if (preset.schema.model.flavour === 'affine:attachment') {
return {
@@ -42,3 +42,5 @@ export function getPresets() {
edgelessModePreset,
};
}
export const editorPresets = getPresets();

View File

@@ -2,7 +2,6 @@ import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import { CollaborationIcon, SettingsIcon } from '@blocksuite/icons';
import { Skeleton } from '@mui/material';
import { Avatar } from '@toeverything/components/avatar';
import { Divider } from '@toeverything/components/divider';
import { Tooltip } from '@toeverything/components/tooltip';
@@ -12,6 +11,7 @@ import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/wor
import { useAtomValue } from 'jotai/react';
import { useCallback } from 'react';
import { Skeleton } from '../../../ui/skeleton';
import {
StyledCard,
StyledIconContainer,

View File

@@ -1,13 +0,0 @@
import { Skeleton } from '@mui/material';
import { memo } from 'react';
export const ListSkeleton = memo(function ListItemSkeleton() {
return (
<>
<Skeleton animation="wave" height={40} />
<Skeleton animation="wave" height={40} />
<Skeleton animation="wave" height={40} />
<Skeleton animation="wave" height={40} />
</>
);
});

View File

@@ -60,9 +60,9 @@ export const notificationStyle = style({
opacity: 1,
height: 'var(--front-toast-height)',
vars: {
'--scale': 'var(--toasts-before)* 0.05 + 1',
'--scale': 'calc(1 - var(--toasts-before)* 0.05)',
'--y':
'translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(calc(-1 * var(--scale)))',
'translateY(calc(var(--lift-amount) * var(--toasts-before))) scale(var(--scale))',
},
},
'&[data-mounted=true][data-expanded=true]': {

View File

@@ -6,7 +6,7 @@ import {
type MenuItemProps,
} from '@toeverything/components/menu';
import { PublicLinkDisableModal } from '../../share-menu';
import { PublicLinkDisableModal } from '../../disable-public-link';
export const DisablePublicSharing = (props: MenuItemProps) => {
const t = useAFFiNEI18N();

View File

@@ -64,14 +64,6 @@ export const innerBackdrop = style({
},
});
const range = (start: number, end: number) => {
const result = [];
for (let i = start; i < end; i++) {
result.push(i);
}
return result;
};
export const tag = style({
height: '20px',
display: 'flex',
@@ -94,14 +86,6 @@ export const tagSticky = style([
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
left: 0,
selectors: range(0, 20).reduce((selectors, i) => {
return {
...selectors,
[`&:nth-last-child(${i + 1})`]: {
right: `${i * 48}px`,
},
};
}, {}),
},
]);

View File

@@ -18,6 +18,7 @@ interface TagItemProps {
tag: Tag;
idx: number;
mode: 'sticky' | 'list-item';
style?: React.CSSProperties;
}
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
@@ -37,13 +38,14 @@ const tagColorMap = (color: string) => {
return mapping[color] || color;
};
const TagItem = ({ tag, idx, mode }: TagItemProps) => {
const TagItem = ({ tag, idx, mode, style }: TagItemProps) => {
return (
<div
data-testid="page-tag"
className={mode === 'sticky' ? styles.tagSticky : styles.tagListItem}
data-idx={idx}
title={tag.value}
style={style}
>
<div
className={styles.tagIndicator}
@@ -101,8 +103,30 @@ export const PageTags = ({
const tagsNormal = useMemo(() => {
const nTags = maxItems ? tags.slice(0, maxItems) : tags;
// sort tags by length
nTags.sort((a, b) => a.value.length - b.value.length);
const tagRightCharLength = nTags.reduceRight<number[]>(
(acc, tag) => {
const curr = acc[0] + Math.min(tag.value.length, 10);
return [curr, ...acc];
},
[0]
);
tagRightCharLength.shift();
return nTags.map((tag, idx) => (
<TagItem key={tag.id} tag={tag} idx={idx} mode="sticky" />
<TagItem
key={tag.id}
tag={tag}
idx={idx}
mode="sticky"
style={{
right: `calc(${tagRightCharLength[idx]}em)`,
}}
/>
));
}, [maxItems, tags]);
return (

View File

@@ -192,9 +192,9 @@ export const pageGroupsAtom = atom(get => {
sorter.key === 'createDate' || sorter.key === 'updatedDate'
? sorter.key
: // default sort
!sorter.key
? DEFAULT_SORT_KEY
: undefined;
!sorter.key
? DEFAULT_SORT_KEY
: undefined;
}
return pagesToPageGroups(sorter.pages, groupBy);
});

View File

@@ -1,4 +1,3 @@
import { useMediaQuery, useTheme } from '@mui/material';
import clsx from 'clsx';
import {
type BaseSyntheticEvent,
@@ -8,12 +7,6 @@ import {
import * as styles from './page-list.css';
export const useIsSmallDevices = () => {
const theme = useTheme();
const isSmallDevices = useMediaQuery(theme.breakpoints.down(900));
return isSmallDevices;
};
export function isToday(date: Date): boolean {
const today = new Date();
return (

View File

@@ -92,10 +92,10 @@ export const RulesMode = ({
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>
components={{
2: <span className={styles.rulesTitleHighlight} />,
}}
/>
</div>
<div className={styles.rulesContainer}>
<div className={styles.rulesContainerLeft}>

View File

@@ -1,5 +1,4 @@
import { Skeleton } from '@mui/material';
import { Skeleton } from '../../ui/skeleton';
import { SettingHeader } from './setting-header';
import { SettingRow } from './setting-row';
import { SettingWrapper } from './wrapper';

View File

@@ -1,12 +1,11 @@
import { Skeleton } from '@mui/material';
import { FlexWrapper } from '../../ui/layout';
import { Skeleton } from '../../ui/skeleton';
export const WorkspaceListItemSkeleton = () => {
return (
<FlexWrapper
alignItems="center"
style={{ padding: '0 8px', height: 30, marginBottom: 4 }}
style={{ padding: '0 24px', height: 30, marginBottom: 4 }}
>
<Skeleton
variant="circular"
@@ -14,7 +13,12 @@ export const WorkspaceListItemSkeleton = () => {
height={14}
style={{ marginRight: 10 }}
/>
<Skeleton variant="rectangular" height={16} style={{ flexGrow: 1 }} />
<Skeleton
variant="rectangular"
height={16}
width={0}
style={{ flexGrow: 1 }}
/>
</FlexWrapper>
);
};

View File

@@ -1,3 +0,0 @@
export * from './disable-public-link';
export * from './share-menu';
export * from './styles';

View File

@@ -1,91 +0,0 @@
import { Button } from '@toeverything/components/button';
import { displayFlex, styled } from '../..';
export const TabItem = styled('li')<{ isActive?: boolean }>(({ isActive }) => {
{
return {
...displayFlex('center', 'center'),
flex: '1',
height: '30px',
color: 'var(--affine-text-primary-color)',
opacity: isActive ? 1 : 0.2,
fontWeight: '500',
fontSize: 'var(--affine-font-base)',
lineHeight: 'var(--affine-line-height)',
cursor: 'pointer',
transition: 'all 0.15s ease',
padding: '0 10px',
marginBottom: '4px',
borderRadius: '4px',
position: 'relative',
':hover': {
background: 'var(--affine-hover-color)',
opacity: 1,
color: isActive
? 'var(--affine-t/ext-primary-color)'
: 'var(--affine-text-secondary-color)',
svg: {
fill: isActive
? 'var(--affine-text-primary-color)'
: 'var(--affine-text-secondary-color)',
},
},
svg: {
fontSize: '20px',
marginRight: '12px',
},
':after': {
content: '""',
position: 'absolute',
bottom: '-6px',
left: '0',
width: '100%',
height: '2px',
background: 'var(--affine-text-primary-color)',
opacity: 0.2,
},
};
}
});
export const StyledIndicator = styled('div')(() => {
return {
height: '2px',
background: 'var(--affine-text-primary-color)',
position: 'absolute',
left: '0',
transition: 'left .3s, width .3s',
};
});
export const StyledInput = styled('input')(() => {
return {
padding: '4px 8px',
height: '28px',
color: 'var(--affine-placeholder-color)',
border: `1px solid ${'var(--affine-placeholder-color)'}`,
cursor: 'default',
overflow: 'hidden',
userSelect: 'text',
borderRadius: '4px',
flexGrow: 1,
marginRight: '10px',
};
});
export const StyledDisableButton = styled(Button)(() => {
return {
color: '#FF631F',
height: '32px',
border: 'none',
marginTop: '16px',
borderRadius: '8px',
padding: '0',
};
});
export const StyledLinkSpan = styled('span')(() => {
return {
marginLeft: '4px',
color: 'var(--affine-primary-color)',
fontWeight: '500',
cursor: 'pointer',
};
});

View File

@@ -2,8 +2,6 @@ import { lightCssVariables } from '@toeverything/theme';
import type { ComplexStyleRule } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
import { breakpoints } from '../../styles/mui-theme';
export const appStyle = style({
width: '100%',
position: 'relative',
@@ -134,10 +132,10 @@ export const toolStyle = style({
flexDirection: 'column',
gap: '12px',
'@media': {
[breakpoints.down('md', true)]: {
'screen and (max-width: 960px)': {
right: 'calc((100vw - 640px) * 3 / 19 + 14px)',
},
[breakpoints.down('sm', true)]: {
'screen and (max-width: 640px)': {
right: '5px',
bottom: '5px',
},
@@ -149,10 +147,10 @@ export const toolStyle = style({
'&[data-in-trash-page="true"]': {
bottom: '70px',
'@media': {
[breakpoints.down('md', true)]: {
'screen and (max-width: 960px)': {
bottom: '80px',
},
[breakpoints.down('sm', true)]: {
'screen and (max-width: 640px)': {
bottom: '85px',
},
print: {

View File

@@ -1,6 +1,4 @@
export * from './components/list-skeleton';
export * from './styles';
export * from './ui/breadcrumbs';
export * from './ui/button';
export * from './ui/checkbox';
export * from './ui/empty';
@@ -8,12 +6,8 @@ export * from './ui/input';
export * from './ui/layout';
export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/menu';
export * from './ui/mui';
export * from './ui/popper';
export * from './ui/scrollbar';
export * from './ui/shared/container';
export * from './ui/skeleton';
export * from './ui/switch';
export * from './ui/table';
export * from './ui/toast';
export * from './ui/tree-view';

View File

@@ -1,3 +1,2 @@
export * from './helper';
export * from './mui-theme';
export * from './mui-theme-provider';
export * from './styled';

View File

@@ -1,3 +0,0 @@
import { alpha, css, keyframes, styled } from '@mui/material/styles';
export { alpha, css, keyframes, styled };

View File

@@ -1,86 +0,0 @@
import type {
Breakpoint,
BreakpointsOptions,
ThemeOptions,
} from '@mui/material';
export const muiThemes = {
breakpoints: {
values: {
xs: 0,
sm: 640,
md: 960,
lg: 1280,
xl: 1920,
},
},
} satisfies ThemeOptions;
// Ported from mui
// See https://github.com/mui/material-ui/blob/eba90da5359ff9c58b02800dfe468dc6c0b95bd2/packages/mui-system/src/createTheme/createBreakpoints.js
// License under MIT
function createBreakpoints(breakpoints: BreakpointsOptions): Readonly<
Omit<BreakpointsOptions, 'up' | 'down'> & {
up: (key: Breakpoint | number, pure?: boolean) => string;
down: (key: Breakpoint | number, pure?: boolean) => string;
}
> {
const {
// The breakpoint **start** at this value.
// For instance with the first breakpoint xs: [xs, sm).
values = {
xs: 0, // phone
sm: 600, // tablet
md: 900, // small laptop
lg: 1200, // desktop
xl: 1536, // large screen
},
unit = 'px',
step = 5,
...other
} = breakpoints;
const keys = Object.keys(values) as ['xs', 'sm', 'md', 'lg', 'xl'];
function up(key: Breakpoint | number, pure = false) {
const value = typeof key === 'number' ? key : values[key];
const original = `(min-width:${value}${unit})`;
if (pure) {
return original;
}
return `@media ${original}`;
}
function down(key: Breakpoint | number, pure = false) {
const value = typeof key === 'number' ? key : values[key];
const original = `(max-width:${value - step / 100}${unit})`;
if (pure) {
return original;
}
return `@media ${original}`;
}
return {
keys,
values,
up,
down,
unit,
// between,
// only,
// not,
...other,
};
}
/**
* @example
* ```ts
* export const iconButtonStyle = style({
* [breakpoints.up('sm')]: {
* padding: '6px'
* },
* });
* ```
*/
export const breakpoints = createBreakpoints(muiThemes.breakpoints);

View File

@@ -0,0 +1,3 @@
import styled from '@emotion/styled';
export { styled };

View File

@@ -1,14 +0,0 @@
import type { BreadcrumbsProps } from '@mui/material/Breadcrumbs';
import MuiBreadcrumbs from '@mui/material/Breadcrumbs';
import type { ComponentType } from 'react';
import { styled } from '../../styles';
const StyledMuiBreadcrumbs = styled(MuiBreadcrumbs)(() => {
return {
color: 'var(--affine-text-primary-color)',
};
});
export const Breadcrumbs: ComponentType<BreadcrumbsProps> =
StyledMuiBreadcrumbs;

View File

@@ -1,60 +0,0 @@
import { styled } from '../../styles';
import type { ButtonProps } from './interface';
import { getButtonColors } from './utils';
export const LoadingContainer = styled('div')<Pick<ButtonProps, 'type'>>(({
theme,
type = 'default',
}) => {
const { color } = getButtonColors(theme, type, false);
return `
margin: 0px auto;
width: 38px;
text-align: center;
.load {
width: 8px;
height: 8px;
background-color: ${color};
border-radius: 100%;
display: inline-block;
-webkit-animation: bouncedelay 1.4s infinite ease-in-out;
animation: bouncedelay 1.4s infinite ease-in-out;
/* Prevent first frame from flickering when animation starts */
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.load1 {
-webkit-animation-delay: -0.32s;
animation-delay: -0.32s;
}
.load2 {
-webkit-animation-delay: -0.16s;
animation-delay: -0.16s;
}
@-webkit-keyframes bouncedelay {
0%, 80%, 100% { -webkit-transform: scale(0) }
40% { -webkit-transform: scale(1.0) }
}
@keyframes bouncedelay {
0%, 80%, 100% {
transform: scale(0);
-webkit-transform: scale(0);
} 40% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}
`;
});
export const Loading = ({ type }: Pick<ButtonProps, 'type'>) => {
return (
<LoadingContainer type={type} className="load-container">
<div className="load load1"></div>
<div className="load load2"></div>
<div className="load"></div>
</LoadingContainer>
);
};

View File

@@ -1,9 +1,6 @@
import type { Theme } from '@mui/material';
import type { ButtonProps } from './interface';
export const getButtonColors = (
_theme: Theme,
type: ButtonProps['type'],
disabled: boolean,
extend?: {

View File

@@ -51,8 +51,8 @@ export const Checkbox = ({
const icon = indeterminate
? icons.indeterminate
: checked
? icons.checked
: icons.unchecked;
? icons.checked
: icons.unchecked;
return (
<div

View File

@@ -1,6 +0,0 @@
/**
* @deprecated
* Use @toeverything/components/menu instead, this component only used in bookmark plugin, since it support set anchor as Range
*/
export * from './menu-item';
export * from './pure-menu';

View File

@@ -1,44 +0,0 @@
import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react';
import { forwardRef } from 'react';
import {
StyledContent,
StyledEndIconWrapper,
StyledMenuItem,
StyledStartIconWrapper,
} from './styles';
export type IconMenuProps = PropsWithChildren<{
icon?: ReactElement;
endIcon?: ReactElement;
iconSize?: number;
disabled?: boolean;
active?: boolean;
disableHover?: boolean;
userFocused?: boolean;
gap?: string;
fontSize?: string;
}> &
HTMLAttributes<HTMLButtonElement>;
export const MenuItem = forwardRef<HTMLButtonElement, IconMenuProps>(
({ endIcon, icon, children, gap, fontSize, iconSize, ...props }, ref) => {
return (
<StyledMenuItem ref={ref} {...props}>
{icon && (
<StyledStartIconWrapper iconSize={iconSize} gap={gap}>
{icon}
</StyledStartIconWrapper>
)}
<StyledContent fontSize={fontSize}>{children}</StyledContent>
{endIcon && (
<StyledEndIconWrapper iconSize={iconSize} gap={gap}>
{endIcon}
</StyledEndIconWrapper>
)}
</StyledMenuItem>
);
}
);
MenuItem.displayName = 'MenuItem';
export default MenuItem;

View File

@@ -1,24 +0,0 @@
import type { CSSProperties } from 'react';
import type { PurePopperProps } from '../popper';
import { PurePopper } from '../popper';
import { StyledMenuWrapper } from './styles';
export type PureMenuProps = PurePopperProps & {
width?: CSSProperties['width'];
height?: CSSProperties['height'];
};
export const PureMenu = ({
children,
placement,
width,
...otherProps
}: PureMenuProps) => {
return (
<PurePopper placement={placement} {...otherProps}>
<StyledMenuWrapper width={width} placement={placement}>
{children}
</StyledMenuWrapper>
</PurePopper>
);
};

View File

@@ -1,115 +0,0 @@
import type { CSSProperties } from 'react';
import { displayFlex, styled, textEllipsis } from '../../styles';
import StyledPopperContainer from '../shared/container';
export const StyledMenuWrapper = styled(StyledPopperContainer, {
shouldForwardProp: propName =>
!['width', 'height'].includes(propName as string),
})<{
width?: CSSProperties['width'];
height?: CSSProperties['height'];
}>(({ width, height }) => {
return {
width,
height,
minWidth: '200px',
background: 'var(--affine-white)',
padding: '8px 4px',
fontSize: '14px',
backgroundColor: 'var(--affine-white)',
boxShadow: 'var(--affine-menu-shadow)',
userSelect: 'none',
};
});
export const StyledStartIconWrapper = styled('div')<{
gap?: CSSProperties['gap'];
iconSize?: CSSProperties['fontSize'];
}>(({ gap, iconSize }) => {
return {
display: 'flex',
marginRight: gap ? gap : '12px',
fontSize: iconSize ? iconSize : '20px',
color: 'var(--affine-icon-color)',
};
});
export const StyledEndIconWrapper = styled('div')<{
gap?: CSSProperties['gap'];
iconSize?: CSSProperties['fontSize'];
}>(({ gap, iconSize }) => {
return {
display: 'flex',
marginLeft: gap ? gap : '12px',
fontSize: iconSize ? iconSize : '20px',
color: 'var(--affine-icon-color)',
};
});
export const StyledContent = styled('div')<{
fontSize?: CSSProperties['fontSize'];
}>(({ fontSize }) => {
return {
textAlign: 'left',
flexGrow: 1,
fontSize: fontSize ? fontSize : 'var(--affine-font-base)',
...textEllipsis(1),
};
});
export const StyledMenuItem = styled('button')<{
isDir?: boolean;
disabled?: boolean;
active?: boolean;
disableHover?: boolean;
userFocused?: boolean;
}>(({
isDir = false,
disabled = false,
active = false,
disableHover = false,
userFocused = false,
}) => {
return {
width: '100%',
borderRadius: '5px',
padding: '0 14px',
fontSize: 'var(--affine-font-sm)',
height: '32px',
...displayFlex('flex-start', 'center'),
cursor: isDir ? 'pointer' : '',
position: 'relative',
backgroundColor: 'transparent',
color: disabled
? 'var(--affine-text-disable-color)'
: 'var(--affine-text-primary-color)',
svg: {
color: disabled
? 'var(--affine-text-disable-color)'
: 'var(--affine-icon-color)',
},
...(disabled
? {
cursor: 'not-allowed',
pointerEvents: 'none',
}
: {}),
':hover':
disabled || disableHover
? {}
: {
backgroundColor: 'var(--affine-hover-color)',
},
...(userFocused && !disabled
? {
backgroundColor: 'var(--affine-hover-color)',
}
: {}),
...(active && !disabled
? {
backgroundColor: 'var(--affine-hover-color)',
}
: {}),
};
});

View File

@@ -1,19 +0,0 @@
import { ClickAwayListener as MuiClickAwayListener } from '@mui/base/ClickAwayListener';
import MuiAvatar from '@mui/material/Avatar';
import MuiBreadcrumbs from '@mui/material/Breadcrumbs';
import MuiCollapse from '@mui/material/Collapse';
import MuiFade from '@mui/material/Fade';
import MuiGrow from '@mui/material/Grow';
import MuiSkeleton from '@mui/material/Skeleton';
import MuiSlide from '@mui/material/Slide';
export {
MuiAvatar,
MuiBreadcrumbs,
MuiClickAwayListener,
MuiCollapse,
MuiFade,
MuiGrow,
MuiSkeleton,
MuiSlide,
};

View File

@@ -1,3 +0,0 @@
export * from './interface';
export * from './popper';
export * from './pure-popper';

View File

@@ -1,64 +0,0 @@
import {
type PopperPlacementType,
type PopperProps as PopperUnstyledProps,
} from '@mui/base/Popper';
import type { CSSProperties, ReactElement, ReactNode, Ref } from 'react';
export type VirtualElement = {
getBoundingClientRect: () => ClientRect | DOMRect;
contextElement?: Element;
};
export type PopperHandler = {
setVisible: (visible: boolean) => void;
};
export type PopperArrowProps = {
placement?: PopperPlacementType;
};
export type PopperProps = {
// Popover content
content?: ReactNode;
// Popover trigger
children: ReactElement;
// Whether the default is implicit
defaultVisible?: boolean;
// Used to manually control the visibility of the Popover
visible?: boolean;
// TODO: support focus
trigger?: 'hover' | 'click' | 'focus' | ('click' | 'hover' | 'focus')[];
// How long does it take for the mouse to display the Popover, in milliseconds
pointerEnterDelay?: number;
// How long does it take to hide the Popover after the mouse moves out, in milliseconds
pointerLeaveDelay?: number;
// Callback fired when the component closed or open
onVisibleChange?: (visible: boolean) => void;
// Popover container style
popoverStyle?: CSSProperties;
// Popover container class name
popoverClassName?: string;
// Anchor class name
anchorClassName?: string;
// Popover z-index
zIndex?: number;
offset?: [number, number];
showArrow?: boolean;
popperHandlerRef?: Ref<PopperHandler>;
onClickAway?: () => void;
triggerContainerStyle?: CSSProperties;
} & Omit<PopperUnstyledProps, 'open' | 'content'>;

View File

@@ -1,97 +0,0 @@
import type { CSSProperties } from 'react';
import { forwardRef } from 'react';
import { styled } from '../../styles';
import type { PopperArrowProps } from './interface';
export const PopperArrow = forwardRef<HTMLElement, PopperArrowProps>(
function PopperArrow({ placement }, ref) {
return <StyledArrow placement={placement} ref={ref} />;
}
);
const getArrowStyle = (
placement: PopperArrowProps['placement'] = 'bottom',
backgroundColor: CSSProperties['backgroundColor']
) => {
if (placement.indexOf('bottom') === 0) {
return {
top: 0,
left: 0,
marginTop: '-0.9em',
width: '3em',
height: '1em',
'&::before': {
borderWidth: '0 1em 1em 1em',
borderColor: `transparent transparent ${backgroundColor} transparent`,
},
};
}
if (placement.indexOf('top') === 0) {
return {
bottom: 0,
left: 0,
marginBottom: '-0.9em',
width: '3em',
height: '1em',
'&::before': {
borderWidth: '1em 1em 0 1em',
borderColor: `${backgroundColor} transparent transparent transparent`,
},
};
}
if (placement.indexOf('left') === 0) {
return {
right: 0,
marginRight: '-0.9em',
height: '3em',
width: '1em',
'&::before': {
borderWidth: '1em 0 1em 1em',
borderColor: `transparent transparent transparent ${backgroundColor}`,
},
};
}
if (placement.indexOf('right') === 0) {
return {
left: 0,
marginLeft: '-0.9em',
height: '3em',
width: '1em',
'&::before': {
borderWidth: '1em 1em 1em 0',
borderColor: `transparent ${backgroundColor} transparent transparent`,
},
};
}
return {
display: 'none',
};
};
const StyledArrow = styled('span')<{
placement?: PopperArrowProps['placement'];
}>(({ placement }) => {
return {
position: 'absolute',
fontSize: '7px',
width: '3em',
'::before': {
content: '""',
margin: 'auto',
display: 'block',
width: 0,
height: 0,
borderStyle: 'solid',
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
},
...getArrowStyle(placement, 'var(--affine-tooltip)'),
};
});

View File

@@ -1,300 +0,0 @@
import { ClickAwayListener } from '@mui/base/ClickAwayListener';
import { Popper as PopperUnstyled } from '@mui/base/Popper';
import Grow from '@mui/material/Grow';
import type { CSSProperties, PointerEvent } from 'react';
import {
cloneElement,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { styled } from '../../styles';
import type { PopperProps, VirtualElement } from './interface';
export const Popper = ({
children,
content,
anchorEl: propsAnchorEl,
placement = 'top-start',
defaultVisible = false,
visible: propsVisible,
trigger = 'hover',
pointerEnterDelay = 500,
pointerLeaveDelay = 100,
onVisibleChange,
popoverStyle,
popoverClassName,
anchorClassName,
zIndex,
offset = [0, 5],
showArrow = false,
popperHandlerRef,
onClick,
onClickAway,
onPointerEnter,
onPointerLeave,
triggerContainerStyle = {},
...popperProps
}: PopperProps) => {
const [anchorEl, setAnchorEl] = useState<VirtualElement>();
const [visible, setVisible] = useState(defaultVisible);
//const [arrowRef, setArrowRef] = useState<HTMLElement>();
const arrowRef = null;
const pointerLeaveTimer = useRef<number>();
const pointerEnterTimer = useRef<number>();
const visibleControlledByParent = typeof propsVisible !== 'undefined';
const isAnchorCustom = typeof propsAnchorEl !== 'undefined';
const hasHoverTrigger = useMemo(() => {
return (
trigger === 'hover' ||
(Array.isArray(trigger) && trigger.includes('hover'))
);
}, [trigger]);
const hasClickTrigger = useMemo(() => {
return (
trigger === 'click' ||
(Array.isArray(trigger) && trigger.includes('click'))
);
}, [trigger]);
const onPointerEnterHandler = (e: PointerEvent<HTMLDivElement>) => {
onPointerEnter?.(e);
if (!hasHoverTrigger || visibleControlledByParent) {
return;
}
window.clearTimeout(pointerLeaveTimer.current);
pointerEnterTimer.current = window.window.setTimeout(() => {
setVisible(true);
}, pointerEnterDelay);
};
const onPointerLeaveHandler = (e: PointerEvent<HTMLDivElement>) => {
onPointerLeave?.(e);
if (!hasHoverTrigger || visibleControlledByParent) {
return;
}
window.clearTimeout(pointerEnterTimer.current);
pointerLeaveTimer.current = window.window.setTimeout(() => {
setVisible(false);
}, pointerLeaveDelay);
};
useEffect(() => {
onVisibleChange?.(visible);
}, [visible, onVisibleChange]);
useImperativeHandle(popperHandlerRef, () => {
return {
setVisible: (visible: boolean) => {
!visibleControlledByParent && setVisible(visible);
},
};
});
const mergedClass = [anchorClassName, children.props.className]
.filter(Boolean)
.join(' ');
return (
<ClickAwayListener
onClickAway={() => {
if (visibleControlledByParent) {
onClickAway?.();
} else {
setVisible(false);
}
}}
>
<Container style={triggerContainerStyle}>
{cloneElement(children, {
ref: (dom: HTMLDivElement) => setAnchorEl(dom),
onClick: (e: MouseEvent) => {
children.props.onClick?.(e);
if (!hasClickTrigger || visibleControlledByParent) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
onClick?.(e);
return;
}
setVisible(!visible);
},
onPointerEnter: onPointerEnterHandler,
onPointerLeave: onPointerLeaveHandler,
...(mergedClass
? {
className: mergedClass,
}
: {}),
})}
{content && (
<BasicStyledPopper
open={visibleControlledByParent ? propsVisible : visible}
zIndex={zIndex}
anchorEl={isAnchorCustom ? propsAnchorEl : anchorEl}
placement={placement}
transition
modifiers={[
{
name: 'offset',
options: {
offset,
},
},
{
name: 'arrow',
enabled: showArrow,
options: {
element: arrowRef,
},
},
]}
{...popperProps}
>
{({ TransitionProps }) => (
<Grow {...TransitionProps}>
<div
onPointerEnter={onPointerEnterHandler}
onPointerLeave={onPointerLeaveHandler}
style={popoverStyle}
className={popoverClassName}
onClick={() => {
if (hasClickTrigger && !visibleControlledByParent) {
setVisible(false);
}
}}
>
{showArrow ? (
placement.indexOf('bottom') === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="6"
viewBox="0 0 11 6"
fill="none"
>
<path
d="M6.38889 0.45C5.94444 -0.15 5.05555 -0.150001 4.61111 0.449999L0.499999 6L10.5 6L6.38889 0.45Z"
style={{ fill: 'var(--affine-tooltip)' }}
/>
</svg>
{content}
</div>
) : placement.indexOf('top') === 0 ? (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{content}
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="6"
viewBox="0 0 11 6"
fill="none"
>
<path
d="M4.61111 5.55C5.05556 6.15 5.94445 6.15 6.38889 5.55L10.5 -4.76837e-07H0.5L4.61111 5.55Z"
style={{ fill: 'var(--affine-tooltip)' }}
/>
</svg>
</div>
) : placement.indexOf('left') === 0 ? (
<>
{content}
<svg
xmlns="http://www.w3.org/2000/svg"
width="6"
height="10"
viewBox="0 0 6 10"
fill="none"
>
<path
d="M5.55 5.88889C6.15 5.44444 6.15 4.55555 5.55 4.11111L-4.76837e-07 0L-4.76837e-07 10L5.55 5.88889Z"
style={{ fill: 'var(--affine-tooltip)' }}
/>
</svg>
</>
) : placement.indexOf('right') === 0 ? (
<>
<svg
xmlns="http://www.w3.org/2000/svg"
width="6"
height="10"
viewBox="0 0 6 10"
style={{ fill: 'var(--affine-tooltip)' }}
>
<path
d="M0.45 4.11111C-0.15 4.55556 -0.15 5.44445 0.45 5.88889L6 10V0L0.45 4.11111Z"
style={{ fill: 'var(--affine-tooltip)' }}
/>
</svg>
{content}
</>
) : (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{content}
<svg
xmlns="http://www.w3.org/2000/svg"
width="11"
height="6"
viewBox="0 0 11 6"
fill="none"
>
<path
d="M4.61111 5.55C5.05556 6.15 5.94445 6.15 6.38889 5.55L10.5 -4.76837e-07H0.5L4.61111 5.55Z"
style={{ fill: 'var(--affine-tooltip)' }}
/>
</svg>
</div>
)
) : (
content
)}
</div>
</Grow>
)}
</BasicStyledPopper>
)}
</Container>
</ClickAwayListener>
);
};
// The children of ClickAwayListener must be a DOM Node to judge whether the click is outside, use node.contains
const Container = styled('div')({
display: 'contents',
});
export const BasicStyledPopper = styled(PopperUnstyled, {
shouldForwardProp: (propName: string) =>
!['zIndex'].some(name => name === propName),
})<{
zIndex?: CSSProperties['zIndex'];
}>(({ zIndex }) => {
return {
zIndex: zIndex ?? 'var(--affine-z-index-popover)',
};
});

View File

@@ -1,66 +0,0 @@
import type { PopperProps as PopperUnstyledProps } from '@mui/base/Popper';
import Grow from '@mui/material/Grow';
import type { CSSProperties, PropsWithChildren } from 'react';
import { useState } from 'react';
import { PopperArrow } from './popover-arrow';
import { BasicStyledPopper } from './popper';
import { PopperWrapper } from './styles';
export type PurePopperProps = {
zIndex?: CSSProperties['zIndex'];
offset?: [number, number];
showArrow?: boolean;
} & PopperUnstyledProps &
PropsWithChildren;
export const PurePopper = (props: PurePopperProps) => {
const {
children,
zIndex,
offset,
showArrow = false,
modifiers = [],
placement,
...otherProps
} = props;
const [arrowRef, setArrowRef] = useState<HTMLElement | null>();
return (
<BasicStyledPopper
zIndex={zIndex}
transition
modifiers={[
{
name: 'offset',
options: {
offset,
},
},
{
name: 'arrow',
enabled: showArrow,
options: {
element: arrowRef,
},
},
...modifiers,
]}
placement={placement}
{...otherProps}
>
{({ TransitionProps }) => (
<Grow {...TransitionProps}>
<PopperWrapper>
{showArrow && (
<PopperArrow placement={placement} ref={setArrowRef} />
)}
{children}
</PopperWrapper>
</Grow>
)}
</BasicStyledPopper>
);
};

View File

@@ -1,7 +0,0 @@
import { styled } from '../../styles';
export const PopperWrapper = styled('div')(() => {
return {
position: 'relative',
};
});

View File

@@ -1,57 +0,0 @@
import type { PopperPlacementType } from '@mui/material';
import { styled } from '../../styles';
export type PopperDirection =
| 'none'
| 'left-top'
| 'left-bottom'
| 'right-top'
| 'right-bottom';
const getBorderRadius = (direction: PopperDirection, radius = '0') => {
const map: Record<PopperDirection, string> = {
none: `${radius}`,
'left-top': `0 ${radius} ${radius} ${radius}`,
'left-bottom': `${radius} ${radius} ${radius} 0`,
'right-top': `${radius} 0 ${radius} ${radius}`,
'right-bottom': `${radius} ${radius} 0 ${radius}`,
};
return map[direction];
};
export const placementToContainerDirection: Record<
PopperPlacementType,
PopperDirection
> = {
top: 'none',
'top-start': 'left-bottom',
'top-end': 'right-bottom',
right: 'none',
'right-start': 'left-top',
'right-end': 'left-bottom',
bottom: 'none',
'bottom-start': 'none',
'bottom-end': 'none',
left: 'none',
'left-start': 'right-top',
'left-end': 'right-bottom',
auto: 'none',
'auto-start': 'none',
'auto-end': 'none',
};
export const StyledPopperContainer = styled('div')<{
placement?: PopperPlacementType;
}>(({ placement = 'top' }) => {
const direction = placementToContainerDirection[placement];
const borderRadius = getBorderRadius(
direction,
'var(--affine-popover-radius)'
);
return {
borderRadius,
};
});
export default StyledPopperContainer;

View File

@@ -0,0 +1,94 @@
import { keyframes, style } from '@vanilla-extract/css';
import type { PickStringFromUnion, SkeletonProps } from './types';
// variables
const bg = 'var(--affine-placeholder-color)';
const highlight = 'rgba(255, 255, 255, 0.4)';
const defaultHeight = '32px';
const pulseKeyframes = keyframes({
'0%': { opacity: 1 },
'50%': { opacity: 0.5 },
'100%': { opacity: 1 },
});
const waveKeyframes = keyframes({
'0%': { transform: 'translateX(-100%)' },
'50%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(100%)' },
});
export const root = style({
display: 'block',
width: '100%',
height: defaultHeight,
flexShrink: 0,
/**
* paint background in ::before,
* so that we can use opacity to control the color
**/
position: 'relative',
'::before': {
content: '',
position: 'absolute',
borderRadius: 'inherit',
inset: 0,
opacity: 0.3,
backgroundColor: bg,
},
});
export const variant: Record<string, string> = {
circular: style({
width: defaultHeight,
borderRadius: '50%',
}),
rectangular: style({
borderRadius: '0px',
}),
rounded: style({
borderRadius: '8px',
}),
text: style({
borderRadius: '4px',
height: '1.2em',
marginTop: '0.2em',
marginBottom: '0.2em',
}),
};
export const animation: Record<
PickStringFromUnion<SkeletonProps['animation']>,
string
> = {
pulse: style({
animation: `${pulseKeyframes} 2s ease-in-out 0.5s infinite`,
}),
wave: style({
position: 'relative',
overflow: 'hidden',
/* Fix bug in Safari https://bugs.webkit.org/show_bug.cgi?id=68196 */
WebkitMaskImage: '-webkit-radial-gradient(white, black)',
'::after': {
animation: `${waveKeyframes} 2s linear 0.5s infinite`,
background: `linear-gradient(
90deg,
transparent,
${highlight},
transparent
)`,
content: '',
position: 'absolute',
transform:
'translateX(-100%)' /* Avoid flash during server-side hydration */,
bottom: 0,
left: 0,
right: 0,
top: 0,
},
}),
};

View File

@@ -0,0 +1,2 @@
export * from './skeleton';
export * from './types';

View File

@@ -0,0 +1,49 @@
import clsx from 'clsx';
import * as styles from './index.css';
import type { SkeletonProps } from './types';
function getSize(size: number | string) {
return typeof size === 'number' || /^\d+$/.test(size) ? `${size}px` : size;
}
/**
*
* @returns
*/
export const Skeleton = ({
animation = 'pulse',
variant = 'text',
children,
width: _width,
height: _height,
style: _style,
className: _className,
...props
}: SkeletonProps) => {
const width = _width !== undefined ? getSize(_width) : undefined;
const height = _height !== undefined ? getSize(_height) : undefined;
const style = {
width,
height,
...(_style || {}),
};
return (
<div
className={clsx(
_className,
styles.root,
styles.variant[variant],
animation && styles.animation[animation]
)}
style={style}
{...props}
>
{children}
</div>
);
};

View File

@@ -0,0 +1,33 @@
import type { HTMLAttributes, PropsWithChildren } from 'react';
export interface SkeletonProps
extends PropsWithChildren,
HTMLAttributes<HTMLElement> {
/**
* The animation. If `false` the animation effect is disabled.
*/
animation?: 'pulse' | 'wave' | false;
/**
* The type of content that will be rendered.
* @default `'text'`
*/
variant?: 'circular' | 'rectangular' | 'rounded' | 'text' | string;
/**
* Width of the skeleton. Useful when the skeleton is inside an inline element with no width of its own.
*/
width?: number | string;
/**
* Height of the skeleton. Useful when you don't want to adapt the skeleton to a text element but for instance a card.
*/
height?: number | string;
/**
* Wrapper component. If not provided, the default element is a div.
*/
wrapper?: string;
}
export type PickStringFromUnion<T> = T extends string ? T : never;

View File

@@ -1,10 +1,3 @@
// import Table from '@mui/material/Table';
// import TableBody from '@mui/material/TableBody';
// import TableCell from '@mui/material/TableCell';
// import TableHead from '@mui/material/TableHead';
// import TableRow from '@mui/material/TableRow';
//
export * from './interface';
export * from './table';
export * from './table-body';

View File

@@ -30,15 +30,15 @@ const htmlToElement = <T extends ChildNode>(html: string | TemplateResult) => {
const createToastContainer = (portal?: HTMLElement) => {
portal = portal || document.body;
const styles = css`
position: absolute;
width: 100%;
position: fixed;
z-index: 9999;
top: 16px;
left: 16px;
right: 16px;
bottom: 78px;
left: 50%;
transform: translateX(-50%);
pointer-events: none;
display: flex;
flex-direction: column-reverse;
flex-direction: column;
align-items: center;
`;
const template = html`<div
@@ -55,6 +55,65 @@ export type ToastOptions = {
portal?: HTMLElement;
};
const animateToastOut = (toastElement: HTMLDivElement) => {
toastElement.style.opacity = '0';
setTimeout(() => toastElement.remove(), 300); // Match transition duration
};
const createAndShowNewToast = (
message: string,
duration: number,
portal?: HTMLElement
) => {
if (!ToastContainer || (portal && !portal.contains(ToastContainer))) {
ToastContainer = createToastContainer(portal);
}
const toastStyles = css`
position: absolute;
bottom: 0;
max-width: 480px;
text-align: center;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
padding: 10px 16px;
margin: 0;
color: var(--affine-white);
background: var(--affine-tooltip);
box-shadow: var(--affine-float-button-shadow);
border-radius: 8px;
opacity: 0;
transform: translateY(100%);
transition:
transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1),
opacity 0.3s ease;
`;
const toastTemplate = html`<div
style="${toastStyles}"
data-testid="affine-toast"
>
${message}
</div>`;
const toastElement = htmlToElement<HTMLDivElement>(toastTemplate);
// message is not trusted
toastElement.textContent = message;
ToastContainer.appendChild(toastElement);
logger.debug(`toast with message: "${message}"`);
window.dispatchEvent(
new CustomEvent('affine-toast:emit', { detail: message })
);
setTimeout(() => {
toastElement.style.opacity = '1';
toastElement.style.transform = 'translateY(0)';
}, 100);
setTimeout(() => {
animateToastOut(toastElement);
}, duration);
};
/**
* @example
* ```ts
@@ -63,80 +122,21 @@ export type ToastOptions = {
*/
export const toast = (
message: string,
{ duration = 2500, portal }: ToastOptions = {
duration: 2500,
}
{ duration = 3000, portal }: ToastOptions = {}
) => {
if (!ToastContainer || (portal && !portal.contains(ToastContainer))) {
ToastContainer = createToastContainer(portal);
if (ToastContainer && ToastContainer.children.length >= 2) {
// If there are already two toasts, remove the oldest one immediately
const oldestToast = ToastContainer.children[0] as HTMLDivElement;
oldestToast.remove();
}
const styles = css`
max-width: 480px;
text-align: center;
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
padding: 6px 12px;
margin: 10px 0 0 0;
color: var(--affine-white);
background: var(--affine-tooltip);
box-shadow: var(--affine-float-button-shadow);
border-radius: 10px;
transition: all 230ms cubic-bezier(0.21, 1.02, 0.73, 1);
opacity: 0;
`;
// If there is one toast already, start its disappearing animation
if (ToastContainer && ToastContainer.children.length === 1) {
const currentToast = ToastContainer.children[0] as HTMLDivElement;
animateToastOut(currentToast);
}
const template = html`<div
style="${styles}"
data-testid="affine-toast"
></div>`;
const element = htmlToElement<HTMLDivElement>(template);
// message is not trusted
element.textContent = message;
ToastContainer.appendChild(element);
logger.debug(`toast with message: "${message}"`);
window.dispatchEvent(
new CustomEvent('affine-toast:emit', { detail: message })
);
const fadeIn = [
{
opacity: 0,
},
{ opacity: 1 },
];
const options = {
duration: 230,
easing: 'cubic-bezier(0.21, 1.02, 0.73, 1)',
fill: 'forwards' as const,
} satisfies KeyframeAnimationOptions;
element.animate(fadeIn, options);
setTimeout(() => {
const animation = element.animate(
// fade out
fadeIn.reverse(),
options
);
animation.finished
.then(() => {
element.style.maxHeight = '0';
element.style.margin = '0';
element.style.padding = '0';
// wait for transition
// ToastContainer = null;
element.addEventListener('transitionend', () => {
element.remove();
});
})
.catch(err => {
console.error(err);
});
}, duration);
return element;
createAndShowNewToast(message, duration, portal);
};
export default toast;

View File

@@ -1,32 +0,0 @@
import { useState } from 'react';
import type { TreeNodeProps } from '../types';
export const useCollapsed = ({
initialCollapsedIds = [],
disableCollapse = false,
}: {
disableCollapse?: boolean;
initialCollapsedIds?: string[];
}) => {
// TODO: should record collapsedIds in localStorage
const [collapsedIds, setCollapsedIds] =
useState<string[]>(initialCollapsedIds);
const setCollapsed: TreeNodeProps['setCollapsed'] = (id, collapsed) => {
if (disableCollapse) {
return;
}
if (collapsed) {
setCollapsedIds(ids => [...ids, id]);
} else {
setCollapsedIds(ids => ids.filter(i => i !== id));
}
};
return {
collapsedIds,
setCollapsed,
};
};
export default useCollapsed;

View File

@@ -1,63 +0,0 @@
import { useEffect, useState } from 'react';
import type { TreeViewProps } from '../types';
import { flattenIds } from '../utils';
export const useSelectWithKeyboard = <RenderProps>({
data,
enableKeyboardSelection,
onSelect,
}: Pick<
TreeViewProps<RenderProps>,
'data' | 'enableKeyboardSelection' | 'onSelect'
>) => {
const [selectedId, setSelectedId] = useState<string>();
// TODO: should record collapsedIds in localStorage
useEffect(() => {
if (!enableKeyboardSelection) {
return;
}
const flattenedIds = flattenIds<RenderProps>(data);
const handleDirectionKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') {
return;
}
if (selectedId === undefined) {
setSelectedId(flattenedIds[0]);
return;
}
let selectedIndex = flattenedIds.indexOf(selectedId);
if (e.key === 'ArrowDown') {
selectedIndex < flattenedIds.length - 1 && selectedIndex++;
}
if (e.key === 'ArrowUp') {
selectedIndex > 0 && selectedIndex--;
}
setSelectedId(flattenedIds[selectedIndex]);
};
const handleEnterKeyDown = (e: KeyboardEvent) => {
if (e.key !== 'Enter') {
return;
}
selectedId && onSelect?.(selectedId);
};
document.addEventListener('keydown', handleDirectionKeyDown);
document.addEventListener('keydown', handleEnterKeyDown);
return () => {
document.removeEventListener('keydown', handleDirectionKeyDown);
document.removeEventListener('keydown', handleEnterKeyDown);
};
}, [data, enableKeyboardSelection, onSelect, selectedId]);
return {
selectedId,
};
};
export default useSelectWithKeyboard;

View File

@@ -1,3 +0,0 @@
export * from './tree-node';
export * from './tree-view';
export * from './types';

View File

@@ -1,44 +0,0 @@
import MuiCollapse from '@mui/material/Collapse';
import { lightTheme } from '@toeverything/theme';
import type { CSSProperties } from 'react';
import { alpha, styled } from '../../styles';
export const StyledCollapse = styled(MuiCollapse)<{
indent?: CSSProperties['paddingLeft'];
}>(({ indent = 12 }) => {
return {
paddingLeft: indent,
};
});
export const StyledTreeNodeWrapper = styled('div')(() => {
return {
position: 'relative',
};
});
export const StyledTreeNodeContainer = styled('div')<{ isDragging?: boolean }>(
({ isDragging = false }) => {
return {
background: isDragging ? 'var(--affine-hover-color)' : '',
};
}
);
export const StyledNodeLine = styled('div')<{
isOver: boolean;
isTop?: boolean;
}>(({ isOver, isTop = false }) => {
return {
position: 'absolute',
left: '0',
...(isTop ? { top: '-1px' } : { bottom: '-1px' }),
width: '100%',
paddingTop: '2x',
borderTop: '2px solid',
borderColor: isOver ? 'var(--affine-primary-color)' : 'transparent',
boxShadow: isOver
? `0px 0px 8px ${alpha(lightTheme.primaryColor, 0.35)}`
: 'none',
zIndex: 1,
};
});

Some files were not shown because too many files have changed in this diff Show More