mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
Merge branch 'canary' into stable
This commit is contained in:
@@ -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")
|
||||
);
|
||||
@@ -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"
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
330
packages/backend/server/src/cache/cache.ts
vendored
Normal file
330
packages/backend/server/src/cache/cache.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
24
packages/backend/server/src/cache/index.ts
vendored
Normal file
24
packages/backend/server/src/cache/index.ts
vendored
Normal 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 };
|
||||
194
packages/backend/server/src/cache/redis.ts
vendored
Normal file
194
packages/backend/server/src/cache/redis.ts
vendored
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -209,6 +209,9 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
updatePollInterval: 3000,
|
||||
experimentalMergeWithJwstCodec: false,
|
||||
},
|
||||
history: {
|
||||
interval: 1000 * 60 * 10 /* 10 mins */,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
stripe: {
|
||||
|
||||
@@ -10,3 +10,4 @@ import { Metrics } from './metrics';
|
||||
controllers: [MetricsController],
|
||||
})
|
||||
export class MetricsModule {}
|
||||
export { Metrics };
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
241
packages/backend/server/src/modules/doc/history.ts
Normal file
241
packages/backend/server/src/modules/doc/history.ts
Normal 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(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -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;
|
||||
|
||||
108
packages/backend/server/tests/cache.spec.ts
Normal file
108
packages/backend/server/tests/cache.spec.ts
Normal 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);
|
||||
});
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
341
packages/backend/server/tests/history.spec.ts
Normal file
341
packages/backend/server/tests/history.spec.ts
Normal 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());
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"version": "0.10.2"
|
||||
"version": "0.10.3-canary.2"
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"@types/debug": "^4.1.9",
|
||||
"vitest": "0.34.6"
|
||||
},
|
||||
"version": "0.10.2"
|
||||
"version": "0.10.3-canary.2"
|
||||
}
|
||||
|
||||
6
packages/common/env/package.json
vendored
6
packages/common/env/package.json
vendored
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.0.0-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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
291
packages/common/infra/src/blocksuite/initialization/index.ts
Normal file
291
packages/common/infra/src/blocksuite/initialization/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
15
packages/common/infra/src/blocksuite/migration/blob.ts
Normal file
15
packages/common/infra/src/blocksuite/migration/blob.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
packages/common/infra/src/blocksuite/migration/blocksuite.ts
Normal file
36
packages/common/infra/src/blocksuite/migration/blocksuite.ts
Normal 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
|
||||
);
|
||||
}
|
||||
45
packages/common/infra/src/blocksuite/migration/fixing.ts
Normal file
45
packages/common/infra/src/blocksuite/migration/fixing.ts
Normal 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;
|
||||
}
|
||||
282
packages/common/infra/src/blocksuite/migration/subdoc.ts
Normal file
282
packages/common/infra/src/blocksuite/migration/subdoc.ts
Normal 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;
|
||||
};
|
||||
77
packages/common/infra/src/blocksuite/migration/workspace.ts
Normal file
77
packages/common/infra/src/blocksuite/migration/workspace.ts
Normal 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;
|
||||
}
|
||||
@@ -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<
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from './browser-warning';
|
||||
export * from './download-client';
|
||||
export * from './local-demo-tips';
|
||||
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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)[] = [];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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]': {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
}, {}),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './disable-public-link';
|
||||
export * from './share-menu';
|
||||
export * from './styles';
|
||||
@@ -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',
|
||||
};
|
||||
});
|
||||
@@ -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: {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from './helper';
|
||||
export * from './mui-theme';
|
||||
export * from './mui-theme-provider';
|
||||
export * from './styled';
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { alpha, css, keyframes, styled } from '@mui/material/styles';
|
||||
|
||||
export { alpha, css, keyframes, styled };
|
||||
@@ -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);
|
||||
3
packages/frontend/component/src/styles/styled.tsx
Normal file
3
packages/frontend/component/src/styles/styled.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export { styled };
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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?: {
|
||||
|
||||
@@ -51,8 +51,8 @@ export const Checkbox = ({
|
||||
const icon = indeterminate
|
||||
? icons.indeterminate
|
||||
: checked
|
||||
? icons.checked
|
||||
: icons.unchecked;
|
||||
? icons.checked
|
||||
: icons.unchecked;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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)',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './interface';
|
||||
export * from './popper';
|
||||
export * from './pure-popper';
|
||||
@@ -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'>;
|
||||
@@ -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)'),
|
||||
};
|
||||
});
|
||||
@@ -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)',
|
||||
};
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import { styled } from '../../styles';
|
||||
|
||||
export const PopperWrapper = styled('div')(() => {
|
||||
return {
|
||||
position: 'relative',
|
||||
};
|
||||
});
|
||||
@@ -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;
|
||||
94
packages/frontend/component/src/ui/skeleton/index.css.ts
Normal file
94
packages/frontend/component/src/ui/skeleton/index.css.ts
Normal 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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
2
packages/frontend/component/src/ui/skeleton/index.ts
Normal file
2
packages/frontend/component/src/ui/skeleton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './skeleton';
|
||||
export * from './types';
|
||||
49
packages/frontend/component/src/ui/skeleton/skeleton.tsx
Normal file
49
packages/frontend/component/src/ui/skeleton/skeleton.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
33
packages/frontend/component/src/ui/skeleton/types.ts
Normal file
33
packages/frontend/component/src/ui/skeleton/types.ts
Normal 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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './tree-node';
|
||||
export * from './tree-view';
|
||||
export * from './types';
|
||||
@@ -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
Reference in New Issue
Block a user