mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 17:22:18 +03:00
Merge branch 'master' into payment-system
This commit is contained in:
commit
95c1a44a0d
@ -16,7 +16,7 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: {{ .Chart.Name }}
|
- name: {{ .Chart.Name }}
|
||||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||||
command: ["yarn", "prisma", "migrate", "deploy"]
|
command: ["yarn", "predeploy"]
|
||||||
env:
|
env:
|
||||||
- name: NODE_ENV
|
- name: NODE_ENV
|
||||||
value: "{{ .Values.env }}"
|
value: "{{ .Values.env }}"
|
||||||
|
2
.github/workflows/workers.yml
vendored
2
.github/workflows/workers.yml
vendored
@ -15,7 +15,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Publish
|
- name: Publish
|
||||||
uses: cloudflare/wrangler-action@v3.3.1
|
uses: cloudflare/wrangler-action@v3.3.2
|
||||||
with:
|
with:
|
||||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "_data_migrations" (
|
||||||
|
"id" VARCHAR(36) NOT NULL,
|
||||||
|
"name" VARCHAR NOT NULL,
|
||||||
|
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finished_at" TIMESTAMPTZ(6),
|
||||||
|
|
||||||
|
CONSTRAINT "_data_migrations_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
@ -13,7 +13,9 @@
|
|||||||
"dev": "nodemon ./src/index.ts",
|
"dev": "nodemon ./src/index.ts",
|
||||||
"test": "ava --concurrency 1 --serial",
|
"test": "ava --concurrency 1 --serial",
|
||||||
"test:coverage": "c8 ava --concurrency 1 --serial",
|
"test:coverage": "c8 ava --concurrency 1 --serial",
|
||||||
"postinstall": "prisma generate"
|
"postinstall": "prisma generate",
|
||||||
|
"data-migration": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/data/app.ts",
|
||||||
|
"predeploy": "yarn prisma migrate deploy && node --es-module-specifier-resolution node ./dist/data/app.js run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/server": "^4.9.4",
|
"@apollo/server": "^4.9.4",
|
||||||
@ -60,6 +62,7 @@
|
|||||||
"keyv": "^4.5.4",
|
"keyv": "^4.5.4",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"nanoid": "^5.0.1",
|
"nanoid": "^5.0.1",
|
||||||
|
"nest-commander": "^3.12.0",
|
||||||
"nestjs-throttler-storage-redis": "^0.4.1",
|
"nestjs-throttler-storage-redis": "^0.4.1",
|
||||||
"next-auth": "^4.23.2",
|
"next-auth": "^4.23.2",
|
||||||
"nodemailer": "^6.9.6",
|
"nodemailer": "^6.9.6",
|
||||||
|
@ -231,3 +231,12 @@ model UserInvoice {
|
|||||||
|
|
||||||
@@map("user_invoices")
|
@@map("user_invoices")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model DataMigration {
|
||||||
|
id String @id @default(uuid()) @db.VarChar(36)
|
||||||
|
name String @db.VarChar
|
||||||
|
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(6)
|
||||||
|
finishedAt DateTime? @map("finished_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@map("_data_migrations")
|
||||||
|
}
|
||||||
|
18
packages/backend/server/src/data/app.ts
Normal file
18
packages/backend/server/src/data/app.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Logger, Module } from '@nestjs/common';
|
||||||
|
import { CommandFactory } from 'nest-commander';
|
||||||
|
|
||||||
|
import { PrismaModule } from '../prisma';
|
||||||
|
import { CreateCommand, NameQuestion } from './commands/create';
|
||||||
|
import { RevertCommand, RunCommand } from './commands/run';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [NameQuestion, CreateCommand, RunCommand, RevertCommand],
|
||||||
|
})
|
||||||
|
class AppModule {}
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
await CommandFactory.run(AppModule, new Logger());
|
||||||
|
}
|
||||||
|
|
||||||
|
await bootstrap();
|
73
packages/backend/server/src/data/commands/create.ts
Normal file
73
packages/backend/server/src/data/commands/create.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { camelCase, snakeCase, upperFirst } from 'lodash-es';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandRunner,
|
||||||
|
InquirerService,
|
||||||
|
Question,
|
||||||
|
QuestionSet,
|
||||||
|
} from 'nest-commander';
|
||||||
|
|
||||||
|
@QuestionSet({ name: 'name-questions' })
|
||||||
|
export class NameQuestion {
|
||||||
|
@Question({
|
||||||
|
name: 'name',
|
||||||
|
message: 'Name of the data migration script:',
|
||||||
|
})
|
||||||
|
parseName(val: string) {
|
||||||
|
return val.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command({
|
||||||
|
name: 'create',
|
||||||
|
arguments: '[name]',
|
||||||
|
description: 'create a data migration script',
|
||||||
|
})
|
||||||
|
export class CreateCommand extends CommandRunner {
|
||||||
|
logger = new Logger(CreateCommand.name);
|
||||||
|
constructor(private readonly inquirer: InquirerService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override async run(inputs: string[]): Promise<void> {
|
||||||
|
let name = inputs[0];
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
name = (
|
||||||
|
await this.inquirer.ask<{ name: string }>('name-questions', undefined)
|
||||||
|
).name;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const content = this.createScript(upperFirst(camelCase(name)) + timestamp);
|
||||||
|
const fileName = `${timestamp}-${snakeCase(name)}.ts`;
|
||||||
|
const filePath = join(
|
||||||
|
fileURLToPath(import.meta.url),
|
||||||
|
'../../migrations',
|
||||||
|
fileName
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Creating ${fileName}...`);
|
||||||
|
writeFileSync(filePath, content);
|
||||||
|
this.logger.log('Done');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createScript(name: string) {
|
||||||
|
const contents = ["import { PrismaService } from '../../prisma';", ''];
|
||||||
|
contents.push(`export class ${name} {`);
|
||||||
|
contents.push(' // do the migration');
|
||||||
|
contents.push(' static async up(db: PrismaService) {}');
|
||||||
|
contents.push('');
|
||||||
|
contents.push(' // revert the migration');
|
||||||
|
contents.push(' static async down(db: PrismaService) {}');
|
||||||
|
|
||||||
|
contents.push('}');
|
||||||
|
|
||||||
|
return contents.join('\n');
|
||||||
|
}
|
||||||
|
}
|
149
packages/backend/server/src/data/commands/run.ts
Normal file
149
packages/backend/server/src/data/commands/run.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Command, CommandRunner } from 'nest-commander';
|
||||||
|
|
||||||
|
import { PrismaService } from '../../prisma';
|
||||||
|
|
||||||
|
interface Migration {
|
||||||
|
file: string;
|
||||||
|
name: string;
|
||||||
|
up: (db: PrismaService) => Promise<void>;
|
||||||
|
down: (db: PrismaService) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectMigrations(): Promise<Migration[]> {
|
||||||
|
const folder = join(fileURLToPath(import.meta.url), '../../migrations');
|
||||||
|
|
||||||
|
const migrationFiles = readdirSync(folder)
|
||||||
|
.filter(desc => desc.endsWith('.ts') && desc !== 'index.ts')
|
||||||
|
.map(desc => join(folder, desc));
|
||||||
|
|
||||||
|
const migrations: Migration[] = await Promise.all(
|
||||||
|
migrationFiles.map(async file => {
|
||||||
|
return import(file).then(mod => {
|
||||||
|
const migration = mod[Object.keys(mod)[0]];
|
||||||
|
|
||||||
|
return {
|
||||||
|
file,
|
||||||
|
name: migration.name,
|
||||||
|
up: migration.up,
|
||||||
|
down: migration.down,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return migrations;
|
||||||
|
}
|
||||||
|
@Command({
|
||||||
|
name: 'run',
|
||||||
|
description: 'Run all pending data migrations',
|
||||||
|
})
|
||||||
|
export class RunCommand extends CommandRunner {
|
||||||
|
logger = new Logger(RunCommand.name);
|
||||||
|
constructor(private readonly db: PrismaService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override async run(): Promise<void> {
|
||||||
|
const migrations = await collectMigrations();
|
||||||
|
const done: Migration[] = [];
|
||||||
|
for (const migration of migrations) {
|
||||||
|
const exists = await this.db.dataMigration.count({
|
||||||
|
where: {
|
||||||
|
name: migration.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log(`Running ${migration.name}...`);
|
||||||
|
const record = await this.db.dataMigration.create({
|
||||||
|
data: {
|
||||||
|
name: migration.name,
|
||||||
|
startedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await migration.up(this.db);
|
||||||
|
await this.db.dataMigration.update({
|
||||||
|
where: {
|
||||||
|
id: record.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
finishedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
done.push(migration);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Failed to run data migration', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Done ${done.length} migrations`);
|
||||||
|
done.forEach(migration => {
|
||||||
|
this.logger.log(` ✔ ${migration.name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Command({
|
||||||
|
name: 'revert',
|
||||||
|
arguments: '[name]',
|
||||||
|
description: 'Revert one data migration with given name',
|
||||||
|
})
|
||||||
|
export class RevertCommand extends CommandRunner {
|
||||||
|
logger = new Logger(RevertCommand.name);
|
||||||
|
|
||||||
|
constructor(private readonly db: PrismaService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override async run(inputs: string[]): Promise<void> {
|
||||||
|
const name = inputs[0];
|
||||||
|
if (!name) {
|
||||||
|
throw new Error('A migration name is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrations = await collectMigrations();
|
||||||
|
|
||||||
|
const migration = migrations.find(m => m.name === name);
|
||||||
|
|
||||||
|
if (!migration) {
|
||||||
|
this.logger.error('Available migration names:');
|
||||||
|
migrations.forEach(m => {
|
||||||
|
this.logger.error(` - ${m.name}`);
|
||||||
|
});
|
||||||
|
throw new Error(`Unknown migration name: ${name}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.db.dataMigration.findFirst({
|
||||||
|
where: {
|
||||||
|
name: migration.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
throw new Error(`Migration ${name} has not been executed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.logger.log(`Reverting ${name}...`);
|
||||||
|
await migration.down(this.db);
|
||||||
|
this.logger.log('Done reverting');
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Failed to revert data migration ${name}`, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.dataMigration.delete({
|
||||||
|
where: {
|
||||||
|
id: record.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
|
||||||
|
|
||||||
|
import { PrismaService } from '../../prisma';
|
||||||
|
import { DocID } from '../../utils/doc';
|
||||||
|
|
||||||
|
export class Guid1698398506533 {
|
||||||
|
// do the migration
|
||||||
|
static async up(db: PrismaService) {
|
||||||
|
let turn = 0;
|
||||||
|
let lastTurnCount = 100;
|
||||||
|
while (lastTurnCount === 100) {
|
||||||
|
const docs = await db.snapshot.findMany({
|
||||||
|
select: {
|
||||||
|
workspaceId: true,
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
skip: turn * 100,
|
||||||
|
take: 100,
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
lastTurnCount = docs.length;
|
||||||
|
for (const doc of docs) {
|
||||||
|
const docId = new DocID(doc.id, doc.workspaceId);
|
||||||
|
|
||||||
|
// NOTE:
|
||||||
|
// `doc.id` could be 'space:xxx' or 'xxx'
|
||||||
|
// `docId.guid` is always 'xxx'
|
||||||
|
// what we want achieve is:
|
||||||
|
// if both 'space:xxx' and 'xxx' exist, merge 'space:xxx' to 'xxx' and delete it
|
||||||
|
// else just modify 'space:xxx' to 'xxx'
|
||||||
|
|
||||||
|
if (docId && !docId.isWorkspace && docId.guid !== doc.id) {
|
||||||
|
const existingUpdate = await db.snapshot.findFirst({
|
||||||
|
where: {
|
||||||
|
id: docId.guid,
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
blob: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// we have missing update with wrong id used before and need to be recovered
|
||||||
|
if (existingUpdate) {
|
||||||
|
const toBeMergeUpdate = await db.snapshot.findFirst({
|
||||||
|
// id 'space:xxx'
|
||||||
|
where: {
|
||||||
|
id: doc.id,
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
blob: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// no conflict
|
||||||
|
// actually unreachable path
|
||||||
|
if (!toBeMergeUpdate) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// recover
|
||||||
|
const yDoc = new Doc();
|
||||||
|
applyUpdate(yDoc, toBeMergeUpdate.blob);
|
||||||
|
applyUpdate(yDoc, existingUpdate.blob);
|
||||||
|
const update = encodeStateAsUpdate(yDoc);
|
||||||
|
|
||||||
|
await db.$transaction([
|
||||||
|
// we already have 'xxx', delete 'space:xxx'
|
||||||
|
db.snapshot.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: doc.id,
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.snapshot.update({
|
||||||
|
where: {
|
||||||
|
id_workspaceId: {
|
||||||
|
id: docId.guid,
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
blob: Buffer.from(update),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// there is no updates need to be merged
|
||||||
|
// just modify the id the required one
|
||||||
|
await db.snapshot.update({
|
||||||
|
where: {
|
||||||
|
id_workspaceId: {
|
||||||
|
id: doc.id,
|
||||||
|
workspaceId: doc.workspaceId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
id: docId.guid,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
turn++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// revert the migration
|
||||||
|
static async down() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
@ -71,7 +71,8 @@ app.use(serverTimingAndCache);
|
|||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
graphqlUploadExpress({
|
graphqlUploadExpress({
|
||||||
maxFileSize: 10 * 1024 * 1024,
|
// TODO: dynamic limit by quota
|
||||||
|
maxFileSize: 100 * 1024 * 1024,
|
||||||
maxFiles: 5,
|
maxFiles: 5,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -30,7 +30,7 @@ export class ExceptionLogger implements ExceptionFilter {
|
|||||||
new Error(
|
new Error(
|
||||||
`${requestId ? `requestId-${requestId}: ` : ''}${exception.message}${
|
`${requestId ? `requestId-${requestId}: ` : ''}${exception.message}${
|
||||||
shouldVerboseLog ? '\n' + exception.stack : ''
|
shouldVerboseLog ? '\n' + exception.stack : ''
|
||||||
}}`,
|
}`,
|
||||||
{ cause: exception }
|
{ cause: exception }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -135,12 +135,13 @@ export class AuthResolver {
|
|||||||
@Args('token') token: string,
|
@Args('token') token: string,
|
||||||
@Args('newPassword') newPassword: string
|
@Args('newPassword') newPassword: string
|
||||||
) {
|
) {
|
||||||
const id = await this.session.get(token);
|
// we only create user account after user sign in with email link
|
||||||
if (!id || id !== user.id) {
|
const email = await this.session.get(token);
|
||||||
|
if (!email || email !== user.email || !user.emailVerified) {
|
||||||
throw new ForbiddenException('Invalid token');
|
throw new ForbiddenException('Invalid token');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.auth.changePassword(id, newPassword);
|
await this.auth.changePassword(email, newPassword);
|
||||||
await this.session.delete(token);
|
await this.session.delete(token);
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
|
@ -233,10 +233,13 @@ export class AuthService {
|
|||||||
return Boolean(user.password);
|
return Boolean(user.password);
|
||||||
}
|
}
|
||||||
|
|
||||||
async changePassword(id: string, newPassword: string): Promise<User> {
|
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
id,
|
email,
|
||||||
|
emailVerified: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -248,7 +251,7 @@ export class AuthService {
|
|||||||
|
|
||||||
return this.prisma.user.update({
|
return this.prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id: user.id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
|
@ -2,7 +2,6 @@ import {
|
|||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
Logger,
|
Logger,
|
||||||
OnApplicationBootstrap,
|
|
||||||
OnModuleDestroy,
|
OnModuleDestroy,
|
||||||
OnModuleInit,
|
OnModuleInit,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -14,7 +13,6 @@ import { Config } from '../../config';
|
|||||||
import { Metrics } from '../../metrics/metrics';
|
import { Metrics } from '../../metrics/metrics';
|
||||||
import { PrismaService } from '../../prisma';
|
import { PrismaService } from '../../prisma';
|
||||||
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
|
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
|
||||||
import { DocID } from '../../utils/doc';
|
|
||||||
|
|
||||||
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||||
if (yBinary.equals(jwstBinary)) {
|
if (yBinary.equals(jwstBinary)) {
|
||||||
@ -44,9 +42,7 @@ const MAX_SEQ_NUM = 0x3fffffff; // u31
|
|||||||
* along side all the updates that have not been applies to that snapshot(timestamp).
|
* along side all the updates that have not been applies to that snapshot(timestamp).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocManager
|
export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||||
implements OnModuleInit, OnModuleDestroy, OnApplicationBootstrap
|
|
||||||
{
|
|
||||||
protected logger = new Logger(DocManager.name);
|
protected logger = new Logger(DocManager.name);
|
||||||
private job: NodeJS.Timeout | null = null;
|
private job: NodeJS.Timeout | null = null;
|
||||||
private seqMap = new Map<string, number>();
|
private seqMap = new Map<string, number>();
|
||||||
@ -60,12 +56,6 @@ export class DocManager
|
|||||||
protected readonly metrics: Metrics
|
protected readonly metrics: Metrics
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onApplicationBootstrap() {
|
|
||||||
if (!this.config.node.test) {
|
|
||||||
await this.refreshDocGuid();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
if (this.automation) {
|
if (this.automation) {
|
||||||
this.logger.log('Use Database');
|
this.logger.log('Use Database');
|
||||||
@ -421,56 +411,4 @@ export class DocManager
|
|||||||
return last + 1;
|
return last + 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* deal with old records that has wrong guid format
|
|
||||||
* correct guid with `${non-wsId}:${variant}:${subId}` to `${subId}`
|
|
||||||
*
|
|
||||||
* @TODO delete in next release
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
private async refreshDocGuid() {
|
|
||||||
let turn = 0;
|
|
||||||
let lastTurnCount = 100;
|
|
||||||
while (lastTurnCount === 100) {
|
|
||||||
const docs = await this.db.snapshot.findMany({
|
|
||||||
select: {
|
|
||||||
workspaceId: true,
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
skip: turn * 100,
|
|
||||||
take: 100,
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'asc',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
lastTurnCount = docs.length;
|
|
||||||
for (const doc of docs) {
|
|
||||||
const docId = new DocID(doc.id, doc.workspaceId);
|
|
||||||
|
|
||||||
if (docId && !docId.isWorkspace && docId.guid !== doc.id) {
|
|
||||||
await this.db.snapshot.deleteMany({
|
|
||||||
where: {
|
|
||||||
id: docId.guid,
|
|
||||||
workspaceId: doc.workspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.db.snapshot.update({
|
|
||||||
where: {
|
|
||||||
id_workspaceId: {
|
|
||||||
id: doc.id,
|
|
||||||
workspaceId: doc.workspaceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
id: docId.guid,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
turn++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
30
packages/common/env/src/filter.ts
vendored
30
packages/common/env/src/filter.ts
vendored
@ -14,7 +14,9 @@ export type LiteralValue =
|
|||||||
| number
|
| number
|
||||||
| string
|
| string
|
||||||
| boolean
|
| boolean
|
||||||
| { [K: string]: LiteralValue }
|
| {
|
||||||
|
[K: string]: LiteralValue;
|
||||||
|
}
|
||||||
| Array<LiteralValue>;
|
| Array<LiteralValue>;
|
||||||
|
|
||||||
export const refSchema: z.ZodType<Ref, z.ZodTypeDef> = z.object({
|
export const refSchema: z.ZodType<Ref, z.ZodTypeDef> = z.object({
|
||||||
@ -48,15 +50,31 @@ export type Filter = z.input<typeof filterSchema>;
|
|||||||
|
|
||||||
export const collectionSchema = z.object({
|
export const collectionSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
workspaceId: z.string(),
|
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
pinned: z.boolean().optional(),
|
mode: z.union([z.literal('page'), z.literal('rule')]),
|
||||||
filterList: z.array(filterSchema),
|
filterList: z.array(filterSchema),
|
||||||
allowList: z.array(z.string()).optional(),
|
allowList: z.array(z.string()),
|
||||||
excludeList: z.array(z.string()).optional(),
|
// page id list
|
||||||
|
pages: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
export const deletedCollectionSchema = z.object({
|
||||||
|
userId: z.string().optional(),
|
||||||
|
userName: z.string(),
|
||||||
|
collection: collectionSchema,
|
||||||
|
});
|
||||||
|
export type DeprecatedCollection = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
workspaceId: string;
|
||||||
|
filterList: z.infer<typeof filterSchema>[];
|
||||||
|
allowList?: string[];
|
||||||
|
};
|
||||||
export type Collection = z.input<typeof collectionSchema>;
|
export type Collection = z.input<typeof collectionSchema>;
|
||||||
|
export type DeleteCollectionInfo = {
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
} | null;
|
||||||
|
export type DeletedCollection = z.input<typeof deletedCollectionSchema>;
|
||||||
|
|
||||||
export const tagSchema = z.object({
|
export const tagSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
12
packages/common/env/src/workspace.ts
vendored
12
packages/common/env/src/workspace.ts
vendored
@ -8,10 +8,9 @@ import type {
|
|||||||
import type { PropsWithChildren, ReactNode } from 'react';
|
import type { PropsWithChildren, ReactNode } from 'react';
|
||||||
import type { DataSourceAdapter } from 'y-provider';
|
import type { DataSourceAdapter } from 'y-provider';
|
||||||
|
|
||||||
import type { Collection } from './filter.js';
|
|
||||||
|
|
||||||
export enum WorkspaceSubPath {
|
export enum WorkspaceSubPath {
|
||||||
ALL = 'all',
|
ALL = 'all',
|
||||||
|
Collection = 'collection',
|
||||||
SETTING = 'setting',
|
SETTING = 'setting',
|
||||||
TRASH = 'trash',
|
TRASH = 'trash',
|
||||||
SHARED = 'shared',
|
SHARED = 'shared',
|
||||||
@ -137,6 +136,7 @@ type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = {
|
|||||||
|
|
||||||
export type WorkspaceHeaderProps<Flavour extends keyof WorkspaceRegistry> =
|
export type WorkspaceHeaderProps<Flavour extends keyof WorkspaceRegistry> =
|
||||||
UIBaseProps<Flavour> & {
|
UIBaseProps<Flavour> & {
|
||||||
|
rightSlot?: ReactNode;
|
||||||
currentEntry:
|
currentEntry:
|
||||||
| {
|
| {
|
||||||
subPath: WorkspaceSubPath;
|
subPath: WorkspaceSubPath;
|
||||||
@ -167,20 +167,12 @@ type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
|
|||||||
onLoadEditor: (page: Page, editor: EditorContainer) => () => void;
|
onLoadEditor: (page: Page, editor: EditorContainer) => () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PageListProps<_Flavour extends keyof WorkspaceRegistry> = {
|
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
|
||||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
|
||||||
collection: Collection;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface FC<P> {
|
interface FC<P> {
|
||||||
(props: P): ReactNode;
|
(props: P): ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
|
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
|
||||||
Header: FC<WorkspaceHeaderProps<Flavour>>;
|
|
||||||
PageDetail: FC<PageDetailProps<Flavour>>;
|
PageDetail: FC<PageDetailProps<Flavour>>;
|
||||||
PageList: FC<PageListProps<Flavour>>;
|
|
||||||
NewSettingsDetail: FC<NewSettingProps<Flavour>>;
|
NewSettingsDetail: FC<NewSettingProps<Flavour>>;
|
||||||
Provider: FC<PropsWithChildren>;
|
Provider: FC<PropsWithChildren>;
|
||||||
LoginCard?: FC<object>;
|
LoginCard?: FC<object>;
|
||||||
|
@ -33,9 +33,11 @@
|
|||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.1.3",
|
||||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
|
"@radix-ui/react-toolbar": "^1.0.4",
|
||||||
"@toeverything/hooks": "workspace:*",
|
"@toeverything/hooks": "workspace:*",
|
||||||
"@toeverything/infra": "workspace:*",
|
"@toeverything/infra": "workspace:*",
|
||||||
"@toeverything/theme": "^0.7.20",
|
"@toeverything/theme": "^0.7.20",
|
||||||
@ -48,6 +50,7 @@
|
|||||||
"jotai": "^2.4.3",
|
"jotai": "^2.4.3",
|
||||||
"lit": "^2.8.0",
|
"lit": "^2.8.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"lottie-web": "^5.12.2",
|
"lottie-web": "^5.12.2",
|
||||||
"nanoid": "^5.0.1",
|
"nanoid": "^5.0.1",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren, useRef } from 'react';
|
||||||
|
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
import { useHasScrollTop } from './use-has-scroll-top';
|
import { useHasScrollTop } from './use-has-scroll-top';
|
||||||
@ -10,7 +10,8 @@ export function SidebarContainer({ children }: PropsWithChildren) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
||||||
const [hasScrollTop, ref] = useHasScrollTop();
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const hasScrollTop = useHasScrollTop(ref);
|
||||||
return (
|
return (
|
||||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
||||||
<div
|
<div
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { type RefObject, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export function useHasScrollTop() {
|
export function useHasScrollTop(ref: RefObject<HTMLElement> | null) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
const [hasScrollTop, setHasScrollTop] = useState(false);
|
const [hasScrollTop, setHasScrollTop] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) {
|
if (!ref?.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -13,8 +12,10 @@ export function useHasScrollTop() {
|
|||||||
|
|
||||||
function updateScrollTop() {
|
function updateScrollTop() {
|
||||||
if (container) {
|
if (container) {
|
||||||
|
setTimeout(() => {
|
||||||
const hasScrollTop = container.scrollTop > 0;
|
const hasScrollTop = container.scrollTop > 0;
|
||||||
setHasScrollTop(hasScrollTop);
|
setHasScrollTop(hasScrollTop);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -23,7 +24,7 @@ export function useHasScrollTop() {
|
|||||||
return () => {
|
return () => {
|
||||||
container.removeEventListener('scroll', updateScrollTop);
|
container.removeEventListener('scroll', updateScrollTop);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [ref]);
|
||||||
|
|
||||||
return [hasScrollTop, ref] as const;
|
return hasScrollTop;
|
||||||
}
|
}
|
||||||
|
@ -2,13 +2,14 @@ import { style } from '@vanilla-extract/css';
|
|||||||
|
|
||||||
export const sidebarSwitch = style({
|
export const sidebarSwitch = style({
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
width: 0,
|
display: 'none !important',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
transition: 'all .3s ease-in-out',
|
transition: 'all .3s ease-in-out',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-show=true]': {
|
'&[data-show=true]': {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
display: 'inline-flex !important',
|
||||||
width: '32px',
|
width: '32px',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
fontSize: '24px',
|
fontSize: '24px',
|
||||||
|
@ -6,25 +6,52 @@ import 'fake-indexeddb/auto';
|
|||||||
import type { Collection } from '@affine/env/filter';
|
import type { Collection } from '@affine/env/filter';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
import { atom } from 'jotai';
|
import { atom } from 'jotai';
|
||||||
|
import { atomWithObservable } from 'jotai/utils';
|
||||||
|
import { BehaviorSubject } from 'rxjs';
|
||||||
import { expect, test } from 'vitest';
|
import { expect, test } from 'vitest';
|
||||||
|
|
||||||
import { createDefaultFilter, vars } from '../filter/vars';
|
import { createDefaultFilter, vars } from '../filter/vars';
|
||||||
import {
|
import {
|
||||||
type CollectionsAtom,
|
type CollectionsCRUDAtom,
|
||||||
useCollectionManager,
|
useCollectionManager,
|
||||||
} from '../use-collection-manager';
|
} from '../use-collection-manager';
|
||||||
|
|
||||||
const defaultMeta = { tags: { options: [] } };
|
const defaultMeta = { tags: { options: [] } };
|
||||||
|
const collectionsSubject = new BehaviorSubject<Collection[]>([]);
|
||||||
const baseAtom = atom<Collection[]>([]);
|
const baseAtom = atomWithObservable<Collection[]>(
|
||||||
|
() => {
|
||||||
const mockAtom: CollectionsAtom = atom(
|
return collectionsSubject;
|
||||||
get => get(baseAtom),
|
},
|
||||||
async (_, set, update) => {
|
{
|
||||||
set(baseAtom, update);
|
initialValue: [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const mockAtom: CollectionsCRUDAtom = atom(get => {
|
||||||
|
return {
|
||||||
|
collections: get(baseAtom),
|
||||||
|
addCollection: async (...collections) => {
|
||||||
|
const prev = collectionsSubject.value;
|
||||||
|
collectionsSubject.next([...collections, ...prev]);
|
||||||
|
},
|
||||||
|
deleteCollection: async (...ids) => {
|
||||||
|
const prev = collectionsSubject.value;
|
||||||
|
collectionsSubject.next(prev.filter(v => !ids.includes(v.id)));
|
||||||
|
},
|
||||||
|
updateCollection: async (id, updater) => {
|
||||||
|
const prev = collectionsSubject.value;
|
||||||
|
collectionsSubject.next(
|
||||||
|
prev.map(v => {
|
||||||
|
if (v.id === id) {
|
||||||
|
return updater(v);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
test('useAllPageSetting', async () => {
|
test('useAllPageSetting', async () => {
|
||||||
const settingHook = renderHook(() => useCollectionManager(mockAtom));
|
const settingHook = renderHook(() => useCollectionManager(mockAtom));
|
||||||
const prevCollection = settingHook.result.current.currentCollection;
|
const prevCollection = settingHook.result.current.currentCollection;
|
||||||
@ -32,7 +59,6 @@ test('useAllPageSetting', async () => {
|
|||||||
await settingHook.result.current.updateCollection({
|
await settingHook.result.current.updateCollection({
|
||||||
...settingHook.result.current.currentCollection,
|
...settingHook.result.current.currentCollection,
|
||||||
filterList: [createDefaultFilter(vars[0], defaultMeta)],
|
filterList: [createDefaultFilter(vars[0], defaultMeta)],
|
||||||
workspaceId: 'test',
|
|
||||||
});
|
});
|
||||||
settingHook.rerender();
|
settingHook.rerender();
|
||||||
const nextCollection = settingHook.result.current.currentCollection;
|
const nextCollection = settingHook.result.current.currentCollection;
|
||||||
@ -40,8 +66,7 @@ test('useAllPageSetting', async () => {
|
|||||||
expect(nextCollection.filterList).toEqual([
|
expect(nextCollection.filterList).toEqual([
|
||||||
createDefaultFilter(vars[0], defaultMeta),
|
createDefaultFilter(vars[0], defaultMeta),
|
||||||
]);
|
]);
|
||||||
settingHook.result.current.backToAll();
|
await settingHook.result.current.createCollection({
|
||||||
await settingHook.result.current.saveCollection({
|
|
||||||
...settingHook.result.current.currentCollection,
|
...settingHook.result.current.currentCollection,
|
||||||
id: '1',
|
id: '1',
|
||||||
});
|
});
|
||||||
|
@ -1,321 +0,0 @@
|
|||||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
|
||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
|
|
||||||
import { useMediaQuery, useTheme } from '@mui/material';
|
|
||||||
import type React from 'react';
|
|
||||||
import { type CSSProperties, useMemo } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ScrollableContainer,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeadRow,
|
|
||||||
} from '../..';
|
|
||||||
import { TableBodyRow } from '../../ui/table';
|
|
||||||
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
|
||||||
import { AllPagesBody } from './all-pages-body';
|
|
||||||
import { NewPageButton } from './components/new-page-buttton';
|
|
||||||
import { TitleCell } from './components/title-cell';
|
|
||||||
import { AllPageListMobileView, TrashListMobileView } from './mobile';
|
|
||||||
import { TrashOperationCell } from './operation-cell';
|
|
||||||
import { StyledTableContainer } from './styles';
|
|
||||||
import type { ListData, PageListProps, TrashListData } from './type';
|
|
||||||
import type { CollectionsAtom } from './use-collection-manager';
|
|
||||||
import { useSorter } from './use-sorter';
|
|
||||||
import { formatDate, useIsSmallDevices } from './utils';
|
|
||||||
import { CollectionBar } from './view/collection-bar';
|
|
||||||
|
|
||||||
interface AllPagesHeadProps {
|
|
||||||
isPublicWorkspace: boolean;
|
|
||||||
sorter: ReturnType<typeof useSorter<ListData>>;
|
|
||||||
createNewPage: () => void;
|
|
||||||
createNewEdgeless: () => void;
|
|
||||||
importFile: () => void;
|
|
||||||
getPageInfo: GetPageInfoById;
|
|
||||||
propertiesMeta: PropertiesMeta;
|
|
||||||
collectionsAtom: CollectionsAtom;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AllPagesHead = ({
|
|
||||||
isPublicWorkspace,
|
|
||||||
sorter,
|
|
||||||
createNewPage,
|
|
||||||
createNewEdgeless,
|
|
||||||
importFile,
|
|
||||||
getPageInfo,
|
|
||||||
propertiesMeta,
|
|
||||||
collectionsAtom,
|
|
||||||
}: AllPagesHeadProps) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const titleList = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: 'title',
|
|
||||||
content: t['Title'](),
|
|
||||||
proportion: 0.5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tags',
|
|
||||||
content: t['Tags'](),
|
|
||||||
proportion: 0.2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'createDate',
|
|
||||||
content: t['Created'](),
|
|
||||||
proportion: 0.1,
|
|
||||||
tableCellStyle: {
|
|
||||||
width: '110px',
|
|
||||||
} satisfies CSSProperties,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'updatedDate',
|
|
||||||
content: t['Updated'](),
|
|
||||||
proportion: 0.1,
|
|
||||||
tableCellStyle: {
|
|
||||||
width: '110px',
|
|
||||||
} satisfies CSSProperties,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'unsortable_action',
|
|
||||||
content: (
|
|
||||||
<NewPageButton
|
|
||||||
createNewPage={createNewPage}
|
|
||||||
createNewEdgeless={createNewEdgeless}
|
|
||||||
importFile={importFile}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
showWhen: () => !isPublicWorkspace,
|
|
||||||
sortable: false,
|
|
||||||
tableCellStyle: {
|
|
||||||
width: '140px',
|
|
||||||
} satisfies CSSProperties,
|
|
||||||
styles: {
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
} satisfies CSSProperties,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[createNewEdgeless, createNewPage, importFile, isPublicWorkspace, t]
|
|
||||||
);
|
|
||||||
const tableItem = useMemo(
|
|
||||||
() =>
|
|
||||||
titleList
|
|
||||||
.filter(({ showWhen = () => true }) => showWhen())
|
|
||||||
.map(
|
|
||||||
({
|
|
||||||
key,
|
|
||||||
content,
|
|
||||||
proportion,
|
|
||||||
sortable = true,
|
|
||||||
styles,
|
|
||||||
tableCellStyle,
|
|
||||||
}) => (
|
|
||||||
<TableCell
|
|
||||||
key={key}
|
|
||||||
proportion={proportion}
|
|
||||||
active={sorter.key === key}
|
|
||||||
style={tableCellStyle}
|
|
||||||
onClick={
|
|
||||||
sortable
|
|
||||||
? () => sorter.shiftOrder(key as keyof ListData)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
...styles,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
{sorter.key === key &&
|
|
||||||
(sorter.order === 'asc' ? (
|
|
||||||
<ArrowUpBigIcon width={24} height={24} />
|
|
||||||
) : (
|
|
||||||
<ArrowDownBigIcon width={24} height={24} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[sorter, titleList]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<TableHead>
|
|
||||||
<TableHeadRow>{tableItem}</TableHeadRow>
|
|
||||||
<CollectionBar
|
|
||||||
columnsCount={titleList.length}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
propertiesMeta={propertiesMeta}
|
|
||||||
collectionsAtom={collectionsAtom}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PageList = ({
|
|
||||||
isPublicWorkspace = false,
|
|
||||||
collectionsAtom,
|
|
||||||
list,
|
|
||||||
onCreateNewPage,
|
|
||||||
onCreateNewEdgeless,
|
|
||||||
onImportFile,
|
|
||||||
fallback,
|
|
||||||
getPageInfo,
|
|
||||||
propertiesMeta,
|
|
||||||
}: PageListProps) => {
|
|
||||||
const sorter = useSorter<ListData>({
|
|
||||||
data: list,
|
|
||||||
key: DEFAULT_SORT_KEY,
|
|
||||||
order: 'desc',
|
|
||||||
});
|
|
||||||
const [hasScrollTop, ref] = useHasScrollTop();
|
|
||||||
const isSmallDevices = useIsSmallDevices();
|
|
||||||
if (isSmallDevices) {
|
|
||||||
return (
|
|
||||||
<ScrollableContainer inTableView>
|
|
||||||
<AllPageListMobileView
|
|
||||||
isPublicWorkspace={isPublicWorkspace}
|
|
||||||
createNewPage={onCreateNewPage}
|
|
||||||
createNewEdgeless={onCreateNewEdgeless}
|
|
||||||
importFile={onImportFile}
|
|
||||||
list={sorter.data}
|
|
||||||
/>
|
|
||||||
</ScrollableContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupKey =
|
|
||||||
sorter.key === 'createDate' || sorter.key === 'updatedDate'
|
|
||||||
? sorter.key
|
|
||||||
: // default sort
|
|
||||||
!sorter.key
|
|
||||||
? DEFAULT_SORT_KEY
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return sorter.data.length === 0 && fallback ? (
|
|
||||||
<StyledTableContainer>{fallback}</StyledTableContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollableContainer inTableView>
|
|
||||||
<StyledTableContainer ref={ref}>
|
|
||||||
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
|
|
||||||
<AllPagesHead
|
|
||||||
collectionsAtom={collectionsAtom}
|
|
||||||
propertiesMeta={propertiesMeta}
|
|
||||||
isPublicWorkspace={isPublicWorkspace}
|
|
||||||
sorter={sorter}
|
|
||||||
createNewPage={onCreateNewPage}
|
|
||||||
createNewEdgeless={onCreateNewEdgeless}
|
|
||||||
importFile={onImportFile}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
/>
|
|
||||||
<AllPagesBody
|
|
||||||
isPublicWorkspace={isPublicWorkspace}
|
|
||||||
groupKey={groupKey}
|
|
||||||
data={sorter.data}
|
|
||||||
/>
|
|
||||||
</Table>
|
|
||||||
</StyledTableContainer>
|
|
||||||
</ScrollableContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TrashListHead = () => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
return (
|
|
||||||
<TableHead>
|
|
||||||
<TableHeadRow>
|
|
||||||
<TableCell proportion={0.5}>{t['Title']()}</TableCell>
|
|
||||||
<TableCell proportion={0.2}>{t['Created']()}</TableCell>
|
|
||||||
<TableCell proportion={0.2}>{t['Moved to Trash']()}</TableCell>
|
|
||||||
<TableCell proportion={0.1}></TableCell>
|
|
||||||
</TableHeadRow>
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageListTrashViewProps {
|
|
||||||
list: TrashListData[];
|
|
||||||
fallback?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PageListTrashView = ({
|
|
||||||
list,
|
|
||||||
fallback,
|
|
||||||
}: PageListTrashViewProps) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
|
|
||||||
const theme = useTheme();
|
|
||||||
const [hasScrollTop, ref] = useHasScrollTop();
|
|
||||||
const isSmallDevices = useMediaQuery(theme.breakpoints.down('sm'));
|
|
||||||
if (isSmallDevices) {
|
|
||||||
const mobileList = list.map(({ pageId, icon, title, onClickPage }) => ({
|
|
||||||
title,
|
|
||||||
icon,
|
|
||||||
pageId,
|
|
||||||
onClickPage,
|
|
||||||
}));
|
|
||||||
return <TrashListMobileView list={mobileList} />;
|
|
||||||
}
|
|
||||||
const ListItems = list.map(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
pageId,
|
|
||||||
title,
|
|
||||||
preview,
|
|
||||||
icon,
|
|
||||||
createDate,
|
|
||||||
trashDate,
|
|
||||||
onClickPage,
|
|
||||||
onPermanentlyDeletePage,
|
|
||||||
onRestorePage,
|
|
||||||
},
|
|
||||||
index
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<TableBodyRow
|
|
||||||
data-testid={`page-list-item-${pageId}`}
|
|
||||||
key={`${pageId}-${index}`}
|
|
||||||
>
|
|
||||||
<TitleCell
|
|
||||||
icon={icon}
|
|
||||||
text={title || t['Untitled']()}
|
|
||||||
desc={preview}
|
|
||||||
onClick={onClickPage}
|
|
||||||
/>
|
|
||||||
<TableCell onClick={onClickPage}>{formatDate(createDate)}</TableCell>
|
|
||||||
<TableCell onClick={onClickPage}>
|
|
||||||
{trashDate ? formatDate(trashDate) : '--'}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
style={{ padding: 0 }}
|
|
||||||
data-testid={`more-actions-${pageId}`}
|
|
||||||
>
|
|
||||||
<TrashOperationCell
|
|
||||||
onPermanentlyDeletePage={onPermanentlyDeletePage}
|
|
||||||
onRestorePage={onRestorePage}
|
|
||||||
onOpenPage={onClickPage}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableBodyRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return list.length === 0 && fallback ? (
|
|
||||||
<StyledTableContainer>{fallback}</StyledTableContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollableContainer inTableView>
|
|
||||||
<StyledTableContainer ref={ref}>
|
|
||||||
<Table showBorder={hasScrollTop}>
|
|
||||||
<TrashListHead />
|
|
||||||
<TableBody>{ListItems}</TableBody>
|
|
||||||
</Table>
|
|
||||||
</StyledTableContainer>
|
|
||||||
</ScrollableContainer>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,188 +0,0 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
import { useDraggable } from '@dnd-kit/core';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
|
|
||||||
import { styled } from '../../styles';
|
|
||||||
import { TableBody, TableCell } from '../../ui/table';
|
|
||||||
import { FavoriteTag } from './components/favorite-tag';
|
|
||||||
import { Tags } from './components/tags';
|
|
||||||
import { TitleCell } from './components/title-cell';
|
|
||||||
import { OperationCell } from './operation-cell';
|
|
||||||
import { StyledTableBodyRow } from './styles';
|
|
||||||
import type { DateKey, DraggableTitleCellData, ListData } from './type';
|
|
||||||
import { useDateGroup } from './use-date-group';
|
|
||||||
import { formatDate, useIsSmallDevices } from './utils';
|
|
||||||
|
|
||||||
export const GroupRow = ({ children }: { children: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<StyledTableBodyRow>
|
|
||||||
<TableCell
|
|
||||||
style={{
|
|
||||||
color: 'var(--affine-text-secondary-color)',
|
|
||||||
fontSize: 'var(--affine-font-sm)',
|
|
||||||
background: 'initial',
|
|
||||||
cursor: 'default',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</TableCell>
|
|
||||||
</StyledTableBodyRow>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AllPagesBody = ({
|
|
||||||
isPublicWorkspace,
|
|
||||||
data,
|
|
||||||
groupKey,
|
|
||||||
}: {
|
|
||||||
isPublicWorkspace: boolean;
|
|
||||||
data: ListData[];
|
|
||||||
groupKey?: DateKey;
|
|
||||||
}) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const isSmallDevices = useIsSmallDevices();
|
|
||||||
const dataWithGroup = useDateGroup({ data, key: groupKey });
|
|
||||||
return (
|
|
||||||
<TableBody style={{ overflowY: 'auto', height: '100%' }}>
|
|
||||||
{dataWithGroup.map(
|
|
||||||
(
|
|
||||||
{
|
|
||||||
groupName,
|
|
||||||
pageId,
|
|
||||||
title,
|
|
||||||
preview,
|
|
||||||
tags,
|
|
||||||
icon,
|
|
||||||
isPublicPage,
|
|
||||||
favorite,
|
|
||||||
createDate,
|
|
||||||
updatedDate,
|
|
||||||
onClickPage,
|
|
||||||
bookmarkPage,
|
|
||||||
onOpenPageInNewTab,
|
|
||||||
removeToTrash,
|
|
||||||
onDisablePublicSharing,
|
|
||||||
},
|
|
||||||
index
|
|
||||||
) => {
|
|
||||||
const displayTitle = title || t['Untitled']();
|
|
||||||
return (
|
|
||||||
<Fragment key={pageId}>
|
|
||||||
{groupName &&
|
|
||||||
(index === 0 ||
|
|
||||||
dataWithGroup[index - 1].groupName !== groupName) && (
|
|
||||||
<GroupRow>{groupName}</GroupRow>
|
|
||||||
)}
|
|
||||||
<StyledTableBodyRow data-testid={`page-list-item-${pageId}`}>
|
|
||||||
<DraggableTitleCell
|
|
||||||
pageId={pageId}
|
|
||||||
draggableData={{
|
|
||||||
pageId,
|
|
||||||
pageTitle: displayTitle,
|
|
||||||
icon,
|
|
||||||
}}
|
|
||||||
icon={icon}
|
|
||||||
text={displayTitle}
|
|
||||||
desc={preview}
|
|
||||||
data-testid="title"
|
|
||||||
onClick={onClickPage}
|
|
||||||
/>
|
|
||||||
<TableCell
|
|
||||||
data-testid="tags"
|
|
||||||
hidden={isSmallDevices}
|
|
||||||
onClick={onClickPage}
|
|
||||||
style={{ fontSize: 'var(--affine-font-xs)' }}
|
|
||||||
>
|
|
||||||
<Tags value={tags}></Tags>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
data-testid="created-date"
|
|
||||||
ellipsis={true}
|
|
||||||
hidden={isSmallDevices}
|
|
||||||
onClick={onClickPage}
|
|
||||||
style={{ fontSize: 'var(--affine-font-xs)' }}
|
|
||||||
>
|
|
||||||
{formatDate(createDate)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell
|
|
||||||
data-testid="updated-date"
|
|
||||||
ellipsis={true}
|
|
||||||
hidden={isSmallDevices}
|
|
||||||
onClick={onClickPage}
|
|
||||||
style={{ fontSize: 'var(--affine-font-xs)' }}
|
|
||||||
>
|
|
||||||
{formatDate(updatedDate ?? createDate)}
|
|
||||||
</TableCell>
|
|
||||||
{!isPublicWorkspace && (
|
|
||||||
<TableCell
|
|
||||||
style={{
|
|
||||||
padding: 0,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '10px',
|
|
||||||
}}
|
|
||||||
data-testid={`more-actions-${pageId}`}
|
|
||||||
>
|
|
||||||
<FavoriteTag
|
|
||||||
className={favorite ? '' : 'favorite-button'}
|
|
||||||
onClick={bookmarkPage}
|
|
||||||
active={!!favorite}
|
|
||||||
/>
|
|
||||||
<OperationCell
|
|
||||||
favorite={favorite}
|
|
||||||
isPublic={isPublicPage}
|
|
||||||
onOpenPageInNewTab={onOpenPageInNewTab}
|
|
||||||
onToggleFavoritePage={bookmarkPage}
|
|
||||||
onRemoveToTrash={removeToTrash}
|
|
||||||
onDisablePublicSharing={onDisablePublicSharing}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</StyledTableBodyRow>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const FullSizeButton = styled('button')(() => ({
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'block',
|
|
||||||
}));
|
|
||||||
|
|
||||||
type DraggableTitleCellProps = {
|
|
||||||
pageId: string;
|
|
||||||
draggableData?: DraggableTitleCellData;
|
|
||||||
} & React.ComponentProps<typeof TitleCell>;
|
|
||||||
|
|
||||||
function DraggableTitleCell({
|
|
||||||
pageId,
|
|
||||||
draggableData,
|
|
||||||
...props
|
|
||||||
}: DraggableTitleCellProps) {
|
|
||||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
|
||||||
id: 'page-list-item-title-' + pageId,
|
|
||||||
data: draggableData,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TitleCell
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={{ opacity: isDragging ? 0.5 : 1 }}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{/* Use `button` for draggable element */}
|
|
||||||
{/* See https://docs.dndkit.com/api-documentation/draggable/usedraggable#role */}
|
|
||||||
{element => (
|
|
||||||
<FullSizeButton {...listeners} {...attributes}>
|
|
||||||
{element}
|
|
||||||
</FullSizeButton>
|
|
||||||
)}
|
|
||||||
</TitleCell>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const divider = style({
|
|
||||||
width: '0.5px',
|
|
||||||
height: '16px',
|
|
||||||
background: 'var(--affine-divider-color)',
|
|
||||||
// fix dropdown button click area
|
|
||||||
margin: '0 4px',
|
|
||||||
marginRight: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dropdownWrapper = style({
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingLeft: '4px',
|
|
||||||
paddingRight: '10px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dropdownIcon = style({
|
|
||||||
borderRadius: '4px',
|
|
||||||
selectors: {
|
|
||||||
[`${dropdownWrapper}:hover &`]: {
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const dropdownBtn = style({
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '0 10px',
|
|
||||||
// fix dropdown button click area
|
|
||||||
paddingRight: 0,
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
fontWeight: 600,
|
|
||||||
background: 'var(--affine-button-gray-color)',
|
|
||||||
boxShadow: 'var(--affine-float-button-shadow)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: 'var(--affine-font-sm)',
|
|
||||||
// width: '100%',
|
|
||||||
height: '32px',
|
|
||||||
userSelect: 'none',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
cursor: 'pointer',
|
|
||||||
selectors: {
|
|
||||||
'&:hover': {
|
|
||||||
background: 'var(--affine-hover-color-filled)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const menuContent = style({
|
|
||||||
backgroundColor: 'var(--affine-background-overlay-panel-color)',
|
|
||||||
});
|
|
@ -1,36 +0,0 @@
|
|||||||
import { ArrowDownSmallIcon } from '@blocksuite/icons';
|
|
||||||
import {
|
|
||||||
type ButtonHTMLAttributes,
|
|
||||||
forwardRef,
|
|
||||||
type MouseEventHandler,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
import * as styles from './dropdown.css';
|
|
||||||
|
|
||||||
type DropdownButtonProps = {
|
|
||||||
onClickDropDown?: MouseEventHandler<HTMLElement>;
|
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
export const DropdownButton = forwardRef<
|
|
||||||
HTMLButtonElement,
|
|
||||||
DropdownButtonProps
|
|
||||||
>(({ onClickDropDown, children, ...props }, ref) => {
|
|
||||||
const handleClickDropDown: MouseEventHandler<HTMLElement> = e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onClickDropDown?.(e);
|
|
||||||
};
|
|
||||||
return (
|
|
||||||
<button ref={ref} className={styles.dropdownBtn} {...props}>
|
|
||||||
<span>{children}</span>
|
|
||||||
<span className={styles.divider} />
|
|
||||||
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
|
|
||||||
<ArrowDownSmallIcon
|
|
||||||
className={styles.dropdownIcon}
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
DropdownButton.displayName = 'DropdownButton';
|
|
@ -21,6 +21,7 @@ export const FavoriteTag = forwardRef<
|
|||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
onClick?.(e);
|
onClick?.(e);
|
||||||
setPlayAnimation(!active);
|
setPlayAnimation(!active);
|
||||||
},
|
},
|
||||||
|
@ -0,0 +1,136 @@
|
|||||||
|
import * as Popover from '@radix-ui/react-popover';
|
||||||
|
import * as Toolbar from '@radix-ui/react-toolbar';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
type CSSProperties,
|
||||||
|
type MouseEventHandler,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import * as styles from './floating-toolbar.css';
|
||||||
|
|
||||||
|
interface FloatingToolbarProps {
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
open?: boolean;
|
||||||
|
// if dbclick outside of the panel, close the toolbar
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FloatingToolbarButtonProps {
|
||||||
|
icon: ReactNode;
|
||||||
|
onClick: MouseEventHandler;
|
||||||
|
type?: 'danger' | 'default';
|
||||||
|
label?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FloatingToolbarItemProps {}
|
||||||
|
|
||||||
|
export function FloatingToolbar({
|
||||||
|
children,
|
||||||
|
style,
|
||||||
|
className,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: PropsWithChildren<FloatingToolbarProps>) {
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const animatingRef = useRef(false);
|
||||||
|
|
||||||
|
// todo: move dbclick / esc to close to page list instead
|
||||||
|
useEffect(() => {
|
||||||
|
animatingRef.current = true;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
animatingRef.current = false;
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
// when dbclick outside of the panel or typing ESC, close the toolbar
|
||||||
|
const dbcHandler = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
!contentRef.current?.contains(e.target as Node) &&
|
||||||
|
!animatingRef.current
|
||||||
|
) {
|
||||||
|
// close the toolbar
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && !animatingRef.current) {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('dblclick', dbcHandler);
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('dblclick', dbcHandler);
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [onOpenChange, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover.Root open={open}>
|
||||||
|
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */}
|
||||||
|
<Popover.Anchor className={className} style={style} />
|
||||||
|
<Popover.Portal>
|
||||||
|
{/* always pop up on top for now */}
|
||||||
|
<Popover.Content side="top" className={styles.popoverContent}>
|
||||||
|
<Toolbar.Root ref={contentRef} className={clsx(styles.root)}>
|
||||||
|
{children}
|
||||||
|
</Toolbar.Root>
|
||||||
|
</Popover.Content>
|
||||||
|
</Popover.Portal>
|
||||||
|
</Popover.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// freestyle item that allows user to do anything
|
||||||
|
export function FloatingToolbarItem({
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<FloatingToolbarItemProps>) {
|
||||||
|
return <div className={styles.item}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// a typical button that has icon and label
|
||||||
|
export function FloatingToolbarButton({
|
||||||
|
icon,
|
||||||
|
type,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
label,
|
||||||
|
}: FloatingToolbarButtonProps) {
|
||||||
|
return (
|
||||||
|
<Toolbar.Button
|
||||||
|
onClick={onClick}
|
||||||
|
className={clsx(
|
||||||
|
styles.button,
|
||||||
|
type === 'danger' && styles.danger,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className={styles.buttonIcon}>{icon}</div>
|
||||||
|
{label}
|
||||||
|
</Toolbar.Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FloatingToolbarSeparator() {
|
||||||
|
return <Toolbar.Separator className={styles.separator} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
FloatingToolbar.Item = FloatingToolbarItem;
|
||||||
|
FloatingToolbar.Separator = FloatingToolbarSeparator;
|
||||||
|
FloatingToolbar.Button = FloatingToolbarButton;
|
@ -0,0 +1,93 @@
|
|||||||
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
const slideDownAndFade = keyframes({
|
||||||
|
'0%': {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'scale(0.95) translateY(20px)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(1) translateY(0)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const slideUpAndFade = keyframes({
|
||||||
|
'0%': {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'scale(1) translateY(0)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'scale(0.95) translateY(20px)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '4px',
|
||||||
|
border: '1px solid var(--affine-border-color)',
|
||||||
|
boxShadow: 'var(--affine-menu-shadow)',
|
||||||
|
gap: 4,
|
||||||
|
minWidth: 'max-content',
|
||||||
|
width: 'fit-content',
|
||||||
|
background: 'var(--affine-background-primary-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const popoverContent = style({
|
||||||
|
willChange: 'transform opacity',
|
||||||
|
selectors: {
|
||||||
|
'&[data-state="open"]': {
|
||||||
|
animation: `${slideDownAndFade} 0.2s ease-in-out`,
|
||||||
|
},
|
||||||
|
'&[data-state="closed"]': {
|
||||||
|
animation: `${slideUpAndFade} 0.2s ease-in-out`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const separator = style({
|
||||||
|
width: '1px',
|
||||||
|
height: '24px',
|
||||||
|
background: 'var(--affine-divider-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const item = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'inherit',
|
||||||
|
gap: 4,
|
||||||
|
height: '32px',
|
||||||
|
padding: '0 6px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const button = style([
|
||||||
|
item,
|
||||||
|
{
|
||||||
|
borderRadius: '8px',
|
||||||
|
':hover': {
|
||||||
|
background: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const danger = style({
|
||||||
|
color: 'inherit',
|
||||||
|
':hover': {
|
||||||
|
background: 'var(--affine-background-error-color)',
|
||||||
|
color: 'var(--affine-error-color)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buttonIcon = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 20,
|
||||||
|
color: 'var(--affine-icon-color)',
|
||||||
|
selectors: {
|
||||||
|
[`${danger}:hover &`]: {
|
||||||
|
color: 'var(--affine-error-color)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,5 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const menuContent = style({
|
||||||
|
backgroundColor: 'var(--affine-background-overlay-panel-color)',
|
||||||
|
});
|
@ -1,16 +1,17 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons';
|
import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons';
|
||||||
import { Menu } from '@toeverything/components/menu';
|
import { Menu } from '@toeverything/components/menu';
|
||||||
import { useCallback, useState } from 'react';
|
import { type PropsWithChildren, useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { DropdownButton } from '../../../ui/button';
|
||||||
import { BlockCard } from '../../card/block-card';
|
import { BlockCard } from '../../card/block-card';
|
||||||
import { DropdownButton } from './dropdown';
|
import { menuContent } from './new-page-button.css';
|
||||||
import { menuContent } from './dropdown.css';
|
|
||||||
|
|
||||||
type NewPageButtonProps = {
|
type NewPageButtonProps = {
|
||||||
createNewPage: () => void;
|
createNewPage: () => void;
|
||||||
createNewEdgeless: () => void;
|
createNewEdgeless: () => void;
|
||||||
importFile: () => void;
|
importFile: () => void;
|
||||||
|
size?: 'small' | 'default';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateNewPagePopup = ({
|
export const CreateNewPagePopup = ({
|
||||||
@ -58,8 +59,9 @@ export const NewPageButton = ({
|
|||||||
createNewPage,
|
createNewPage,
|
||||||
createNewEdgeless,
|
createNewEdgeless,
|
||||||
importFile,
|
importFile,
|
||||||
}: NewPageButtonProps) => {
|
size,
|
||||||
const t = useAFFiNEI18N();
|
children,
|
||||||
|
}: PropsWithChildren<NewPageButtonProps>) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
@ -92,13 +94,15 @@ export const NewPageButton = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DropdownButton
|
<DropdownButton
|
||||||
|
size={size}
|
||||||
|
data-testid="new-page-button"
|
||||||
onClick={useCallback(() => {
|
onClick={useCallback(() => {
|
||||||
createNewPage();
|
createNewPage();
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}, [createNewPage])}
|
}, [createNewPage])}
|
||||||
onClickDropDown={useCallback(() => setOpen(open => !open), [])}
|
onClickDropDown={useCallback(() => setOpen(open => !open), [])}
|
||||||
>
|
>
|
||||||
{t['New Page']()}
|
{children}
|
||||||
</DropdownButton>
|
</DropdownButton>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const tagList = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
gap: 10,
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const tagListFull = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 10,
|
|
||||||
maxWidth: 300,
|
|
||||||
padding: 10,
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const tag = style({
|
|
||||||
flexShrink: 0,
|
|
||||||
padding: '2px 10px',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 12,
|
|
||||||
lineHeight: '16px',
|
|
||||||
fontWeight: 400,
|
|
||||||
maxWidth: '100%',
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
});
|
|
@ -1,24 +0,0 @@
|
|||||||
import type { Tag } from '@affine/env/filter';
|
|
||||||
import { Menu } from '@toeverything/components/menu';
|
|
||||||
|
|
||||||
import * as styles from './tags.css';
|
|
||||||
|
|
||||||
// fixme: This component should use popover instead of menu
|
|
||||||
export const Tags = ({ value }: { value: Tag[] }) => {
|
|
||||||
const list = value.map(tag => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={tag.id}
|
|
||||||
className={styles.tag}
|
|
||||||
style={{ backgroundColor: tag.color }}
|
|
||||||
>
|
|
||||||
{tag.value}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<Menu items={<div className={styles.tagListFull}>{list}</div>}>
|
|
||||||
<div className={styles.tagList}>{list}</div>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,66 +0,0 @@
|
|||||||
import React, { useCallback } from 'react';
|
|
||||||
|
|
||||||
import type { TableCellProps } from '../../..';
|
|
||||||
import { Content, TableCell } from '../../..';
|
|
||||||
import {
|
|
||||||
StyledTitleContentWrapper,
|
|
||||||
StyledTitleLink,
|
|
||||||
StyledTitlePreview,
|
|
||||||
} from '../styles';
|
|
||||||
|
|
||||||
type TitleCellProps = {
|
|
||||||
icon: JSX.Element;
|
|
||||||
text: string;
|
|
||||||
desc?: React.ReactNode;
|
|
||||||
suffix?: JSX.Element;
|
|
||||||
/**
|
|
||||||
* Customize the children of the cell
|
|
||||||
* @param element
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
children?: (element: React.ReactElement) => React.ReactNode;
|
|
||||||
} & Omit<TableCellProps, 'children'>;
|
|
||||||
|
|
||||||
export const TitleCell = React.forwardRef<HTMLTableCellElement, TitleCellProps>(
|
|
||||||
({ icon, text, desc, suffix, children: render, ...props }, ref) => {
|
|
||||||
const renderChildren = useCallback(() => {
|
|
||||||
const childElement = (
|
|
||||||
<>
|
|
||||||
<StyledTitleLink>
|
|
||||||
{icon}
|
|
||||||
<StyledTitleContentWrapper>
|
|
||||||
<Content
|
|
||||||
ellipsis={true}
|
|
||||||
maxWidth="100%"
|
|
||||||
color="inherit"
|
|
||||||
fontSize="var(--affine-font-sm)"
|
|
||||||
weight="600"
|
|
||||||
lineHeight="18px"
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</Content>
|
|
||||||
{desc && (
|
|
||||||
<StyledTitlePreview
|
|
||||||
ellipsis={true}
|
|
||||||
color="var(--affine-text-secondary-color)"
|
|
||||||
>
|
|
||||||
{desc}
|
|
||||||
</StyledTitlePreview>
|
|
||||||
)}
|
|
||||||
</StyledTitleContentWrapper>
|
|
||||||
</StyledTitleLink>
|
|
||||||
{suffix}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return render ? render(childElement) : childElement;
|
|
||||||
}, [desc, icon, render, suffix, text]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableCell ref={ref} {...props}>
|
|
||||||
{renderChildren()}
|
|
||||||
</TableCell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
TitleCell.displayName = 'TitleCell';
|
|
@ -63,7 +63,7 @@ export const FilterList = ({
|
|||||||
>
|
>
|
||||||
{value.length === 0 ? (
|
{value.length === 0 ? (
|
||||||
<Button
|
<Button
|
||||||
icon={<PlusIcon />}
|
icon={<PlusIcon style={{ color: 'var(--affine-icon-color)' }} />}
|
||||||
iconPosition="end"
|
iconPosition="end"
|
||||||
style={{ fontSize: 'var(--affine-font-xs)', padding: '0 8px' }}
|
style={{ fontSize: 'var(--affine-font-xs)', padding: '0 8px' }}
|
||||||
>
|
>
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
export * from './all-page';
|
|
||||||
export * from './components/favorite-tag';
|
export * from './components/favorite-tag';
|
||||||
|
export * from './components/floating-toobar';
|
||||||
export * from './components/new-page-buttton';
|
export * from './components/new-page-buttton';
|
||||||
export * from './components/title-cell';
|
|
||||||
export * from './filter';
|
export * from './filter';
|
||||||
export * from './operation-cell';
|
export * from './operation-cell';
|
||||||
export * from './operation-menu-items';
|
export * from './operation-menu-items';
|
||||||
export * from './styles';
|
export * from './page-list';
|
||||||
export * from './type';
|
export * from './page-list-item';
|
||||||
|
export * from './page-tags';
|
||||||
|
export * from './types';
|
||||||
export * from './use-collection-manager';
|
export * from './use-collection-manager';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
export * from './view';
|
export * from './view';
|
||||||
|
@ -1,129 +0,0 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Content,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeadRow,
|
|
||||||
} from '../../..';
|
|
||||||
import { AllPagesBody } from './all-pages-body';
|
|
||||||
import { NewPageButton } from './components/new-page-buttton';
|
|
||||||
import {
|
|
||||||
StyledTableBodyRow,
|
|
||||||
StyledTableContainer,
|
|
||||||
StyledTitleLink,
|
|
||||||
} from './styles';
|
|
||||||
import type { ListData } from './type';
|
|
||||||
|
|
||||||
const MobileHead = ({
|
|
||||||
isPublicWorkspace,
|
|
||||||
createNewPage,
|
|
||||||
createNewEdgeless,
|
|
||||||
importFile,
|
|
||||||
}: {
|
|
||||||
isPublicWorkspace: boolean;
|
|
||||||
createNewPage: () => void;
|
|
||||||
createNewEdgeless: () => void;
|
|
||||||
importFile: () => void;
|
|
||||||
}) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
return (
|
|
||||||
<TableHead>
|
|
||||||
<TableHeadRow>
|
|
||||||
<TableCell proportion={0.8}>{t['Title']()}</TableCell>
|
|
||||||
{!isPublicWorkspace && (
|
|
||||||
<TableCell>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<NewPageButton
|
|
||||||
createNewPage={createNewPage}
|
|
||||||
createNewEdgeless={createNewEdgeless}
|
|
||||||
importFile={importFile}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableHeadRow>
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AllPageListMobileView = ({
|
|
||||||
list,
|
|
||||||
isPublicWorkspace,
|
|
||||||
createNewPage,
|
|
||||||
createNewEdgeless,
|
|
||||||
importFile,
|
|
||||||
}: {
|
|
||||||
isPublicWorkspace: boolean;
|
|
||||||
list: ListData[];
|
|
||||||
createNewPage: () => void;
|
|
||||||
createNewEdgeless: () => void;
|
|
||||||
importFile: () => void;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<StyledTableContainer>
|
|
||||||
<Table>
|
|
||||||
<MobileHead
|
|
||||||
isPublicWorkspace={isPublicWorkspace}
|
|
||||||
createNewPage={createNewPage}
|
|
||||||
createNewEdgeless={createNewEdgeless}
|
|
||||||
importFile={importFile}
|
|
||||||
/>
|
|
||||||
<AllPagesBody
|
|
||||||
isPublicWorkspace={isPublicWorkspace}
|
|
||||||
data={list}
|
|
||||||
// update groupKey after support sort by create date
|
|
||||||
groupKey="updatedDate"
|
|
||||||
/>
|
|
||||||
</Table>
|
|
||||||
</StyledTableContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO align to {@link AllPageListMobileView}
|
|
||||||
export const TrashListMobileView = ({
|
|
||||||
list,
|
|
||||||
}: {
|
|
||||||
list: {
|
|
||||||
pageId: string;
|
|
||||||
title: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
onClickPage: () => void;
|
|
||||||
}[];
|
|
||||||
}) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
|
|
||||||
const ListItems = list.map(({ pageId, title, icon, onClickPage }, index) => {
|
|
||||||
return (
|
|
||||||
<StyledTableBodyRow
|
|
||||||
data-testid={`page-list-item-${pageId}`}
|
|
||||||
key={`${pageId}-${index}`}
|
|
||||||
>
|
|
||||||
<TableCell onClick={onClickPage}>
|
|
||||||
<StyledTitleLink>
|
|
||||||
{icon}
|
|
||||||
<Content ellipsis={true} color="inherit">
|
|
||||||
{title || t['Untitled']()}
|
|
||||||
</Content>
|
|
||||||
</StyledTitleLink>
|
|
||||||
</TableCell>
|
|
||||||
</StyledTableBodyRow>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledTableContainer>
|
|
||||||
<Table>
|
|
||||||
<TableBody>{ListItems}</TableBody>
|
|
||||||
</Table>
|
|
||||||
</StyledTableContainer>
|
|
||||||
);
|
|
||||||
};
|
|
@ -12,14 +12,17 @@ import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu';
|
|||||||
import { ConfirmModal } from '@toeverything/components/modal';
|
import { ConfirmModal } from '@toeverything/components/modal';
|
||||||
import { Tooltip } from '@toeverything/components/tooltip';
|
import { Tooltip } from '@toeverything/components/tooltip';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { FlexWrapper } from '../../..';
|
import { FavoriteTag } from './components/favorite-tag';
|
||||||
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
|
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
|
||||||
|
import * as styles from './page-list.css';
|
||||||
|
import { ColWrapper, stopPropagationWithoutPrevent } from './utils';
|
||||||
|
|
||||||
export interface OperationCellProps {
|
export interface OperationCellProps {
|
||||||
favorite: boolean;
|
favorite: boolean;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
onOpenPageInNewTab: () => void;
|
link: string;
|
||||||
onToggleFavoritePage: () => void;
|
onToggleFavoritePage: () => void;
|
||||||
onRemoveToTrash: () => void;
|
onRemoveToTrash: () => void;
|
||||||
onDisablePublicSharing: () => void;
|
onDisablePublicSharing: () => void;
|
||||||
@ -28,14 +31,13 @@ export interface OperationCellProps {
|
|||||||
export const OperationCell = ({
|
export const OperationCell = ({
|
||||||
favorite,
|
favorite,
|
||||||
isPublic,
|
isPublic,
|
||||||
onOpenPageInNewTab,
|
link,
|
||||||
onToggleFavoritePage,
|
onToggleFavoritePage,
|
||||||
onRemoveToTrash,
|
onRemoveToTrash,
|
||||||
onDisablePublicSharing,
|
onDisablePublicSharing,
|
||||||
}: OperationCellProps) => {
|
}: OperationCellProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [openDisableShared, setOpenDisableShared] = useState(false);
|
const [openDisableShared, setOpenDisableShared] = useState(false);
|
||||||
|
|
||||||
const OperationMenu = (
|
const OperationMenu = (
|
||||||
<>
|
<>
|
||||||
{isPublic && (
|
{isPublic && (
|
||||||
@ -63,8 +65,14 @@ export const OperationCell = ({
|
|||||||
: t['com.affine.favoritePageOperation.add']()}
|
: t['com.affine.favoritePageOperation.add']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{!environment.isDesktop && (
|
{!environment.isDesktop && (
|
||||||
|
<Link
|
||||||
|
onClick={stopPropagationWithoutPrevent}
|
||||||
|
to={link}
|
||||||
|
target={'_blank'}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={onOpenPageInNewTab}
|
style={{ marginBottom: 4 }}
|
||||||
preFix={
|
preFix={
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<OpenInNewIcon />
|
<OpenInNewIcon />
|
||||||
@ -73,13 +81,22 @@ export const OperationCell = ({
|
|||||||
>
|
>
|
||||||
{t['com.affine.openPageOperation.newTab']()}
|
{t['com.affine.openPageOperation.newTab']()}
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
|
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FlexWrapper alignItems="center" justifyContent="center">
|
<ColWrapper
|
||||||
|
hideInSmallContainer
|
||||||
|
data-testid="page-list-item-favorite"
|
||||||
|
data-favorite={favorite ? true : undefined}
|
||||||
|
className={styles.favoriteCell}
|
||||||
|
>
|
||||||
|
<FavoriteTag onClick={onToggleFavoritePage} active={favorite} />
|
||||||
|
</ColWrapper>
|
||||||
|
<ColWrapper alignment="start">
|
||||||
<Menu
|
<Menu
|
||||||
items={OperationMenu}
|
items={OperationMenu}
|
||||||
contentOptions={{
|
contentOptions={{
|
||||||
@ -90,7 +107,7 @@ export const OperationCell = ({
|
|||||||
<MoreVerticalIcon />
|
<MoreVerticalIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Menu>
|
</Menu>
|
||||||
</FlexWrapper>
|
</ColWrapper>
|
||||||
<DisablePublicSharing.DisablePublicSharingModal
|
<DisablePublicSharing.DisablePublicSharingModal
|
||||||
onConfirm={onDisablePublicSharing}
|
onConfirm={onDisablePublicSharing}
|
||||||
open={openDisableShared}
|
open={openDisableShared}
|
||||||
@ -103,7 +120,6 @@ export const OperationCell = ({
|
|||||||
export interface TrashOperationCellProps {
|
export interface TrashOperationCellProps {
|
||||||
onPermanentlyDeletePage: () => void;
|
onPermanentlyDeletePage: () => void;
|
||||||
onRestorePage: () => void;
|
onRestorePage: () => void;
|
||||||
onOpenPage: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TrashOperationCell = ({
|
export const TrashOperationCell = ({
|
||||||
@ -113,9 +129,10 @@ export const TrashOperationCell = ({
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<FlexWrapper>
|
<ColWrapper flex={1}>
|
||||||
<Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top">
|
<Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top">
|
||||||
<IconButton
|
<IconButton
|
||||||
|
data-testid="restore-page-button"
|
||||||
style={{ marginRight: '12px' }}
|
style={{ marginRight: '12px' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRestorePage();
|
onRestorePage();
|
||||||
@ -130,6 +147,7 @@ export const TrashOperationCell = ({
|
|||||||
align="end"
|
align="end"
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
|
data-testid="delete-page-button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}}
|
}}
|
||||||
@ -152,6 +170,6 @@ export const TrashOperationCell = ({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FlexWrapper>
|
</ColWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { DeleteTemporarilyIcon } from '@blocksuite/icons';
|
import { DeleteIcon } from '@blocksuite/icons';
|
||||||
import {
|
import {
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
@ -17,7 +17,7 @@ export const MoveToTrash = (props: MenuItemProps) => {
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
preFix={
|
preFix={
|
||||||
<MenuIcon>
|
<MenuIcon>
|
||||||
<DeleteTemporarilyIcon />
|
<DeleteIcon />
|
||||||
</MenuIcon>
|
</MenuIcon>
|
||||||
}
|
}
|
||||||
type="danger"
|
type="danger"
|
||||||
@ -29,19 +29,29 @@ export const MoveToTrash = (props: MenuItemProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MoveToTrashConfirm = ({
|
const MoveToTrashConfirm = ({
|
||||||
title,
|
titles,
|
||||||
...confirmModalProps
|
...confirmModalProps
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
titles: string[];
|
||||||
} & ConfirmModalProps) => {
|
} & ConfirmModalProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
const multiple = titles.length > 1;
|
||||||
|
const title = multiple
|
||||||
|
? t['com.affine.moveToTrash.confirmModal.title.multiple']({
|
||||||
|
number: titles.length.toString(),
|
||||||
|
})
|
||||||
|
: t['com.affine.moveToTrash.confirmModal.title']();
|
||||||
|
const description = multiple
|
||||||
|
? t['com.affine.moveToTrash.confirmModal.description.multiple']({
|
||||||
|
number: titles.length.toString(),
|
||||||
|
})
|
||||||
|
: t['com.affine.moveToTrash.confirmModal.description']({
|
||||||
|
title: titles[0] || t['Untitled'](),
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
title={t['com.affine.moveToTrash.confirmModal.title']()}
|
title={title}
|
||||||
description={t['com.affine.moveToTrash.confirmModal.description']({
|
description={description}
|
||||||
title: title || 'Untitled',
|
|
||||||
})}
|
|
||||||
cancelText={t['com.affine.confirmModal.button.cancel']()}
|
cancelText={t['com.affine.confirmModal.button.cancel']()}
|
||||||
confirmButtonOptions={{
|
confirmButtonOptions={{
|
||||||
['data-testid' as string]: 'confirm-delete-page',
|
['data-testid' as string]: 'confirm-delete-page',
|
||||||
|
@ -0,0 +1,32 @@
|
|||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import type { Workspace } from '@blocksuite/store';
|
||||||
|
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
|
||||||
|
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
interface PagePreviewInnerProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PagePreviewInner = ({ workspace, pageId }: PagePreviewInnerProps) => {
|
||||||
|
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||||
|
assertExists(page);
|
||||||
|
const previewAtom = useBlockSuitePagePreview(page);
|
||||||
|
const preview = useAtomValue(previewAtom);
|
||||||
|
return preview ? preview : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PagePreviewProps {
|
||||||
|
workspace: Workspace;
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PagePreview = ({ workspace, pageId }: PagePreviewProps) => {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<PagePreviewInner workspace={workspace} pageId={pageId} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,133 @@
|
|||||||
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
});
|
||||||
|
|
||||||
|
const slideDown = keyframes({
|
||||||
|
'0%': {
|
||||||
|
opacity: 0,
|
||||||
|
height: '0px',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 1,
|
||||||
|
height: 'var(--radix-collapsible-content-height)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const slideUp = keyframes({
|
||||||
|
'0%': {
|
||||||
|
opacity: 1,
|
||||||
|
height: 'var(--radix-collapsible-content-height)',
|
||||||
|
},
|
||||||
|
'100%': {
|
||||||
|
opacity: 0,
|
||||||
|
height: '0px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collapsibleContent = style({
|
||||||
|
overflow: 'hidden',
|
||||||
|
selectors: {
|
||||||
|
'&[data-state="open"]': {
|
||||||
|
animation: `${slideDown} 0.3s ease-in-out`,
|
||||||
|
},
|
||||||
|
'&[data-state="closed"]': {
|
||||||
|
animation: `${slideUp} 0.3s ease-in-out`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collapsibleContentInner = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const header = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0px 16px 0px 6px',
|
||||||
|
gap: 4,
|
||||||
|
height: '28px',
|
||||||
|
':hover': {
|
||||||
|
background: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const spacer = style({
|
||||||
|
flex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerCollapseIcon = style({
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerLabel = style({
|
||||||
|
fontSize: 'var(--affine-font-sm)',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerCount = style({
|
||||||
|
fontSize: 'var(--affine-font-sm)',
|
||||||
|
color: 'var(--affine-text-disable-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectAllButton = style({
|
||||||
|
display: 'flex',
|
||||||
|
opacity: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: 'var(--affine-font-xs)',
|
||||||
|
height: '20px',
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: '0 8px',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': {
|
||||||
|
background: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
[`${header}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collapsedIcon = style({
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'transform 0.2s ease-in-out',
|
||||||
|
selectors: {
|
||||||
|
'&[data-collapsed="false"]': {
|
||||||
|
transform: 'rotate(90deg)',
|
||||||
|
},
|
||||||
|
[`${header}:hover &, &[data-collapsed="true"]`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collapsedIconContainer = style({
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: '2px',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
color: 'inherit',
|
||||||
|
selectors: {
|
||||||
|
'&[data-collapsed="true"]': {
|
||||||
|
transform: 'rotate(-90deg)',
|
||||||
|
},
|
||||||
|
'&[data-disabled="true"]': {
|
||||||
|
opacity: 0.3,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&:hover': {
|
||||||
|
background: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,276 @@
|
|||||||
|
import type { Tag } from '@affine/env/filter';
|
||||||
|
import { Trans } from '@affine/i18n';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
|
||||||
|
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||||
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { PagePreview } from './page-content-preview';
|
||||||
|
import * as styles from './page-group.css';
|
||||||
|
import { PageListItem } from './page-list-item';
|
||||||
|
import { pageListPropsAtom, selectionStateAtom } from './scoped-atoms';
|
||||||
|
import type {
|
||||||
|
PageGroupDefinition,
|
||||||
|
PageGroupProps,
|
||||||
|
PageListItemProps,
|
||||||
|
PageListProps,
|
||||||
|
} from './types';
|
||||||
|
import { type DateKey } from './types';
|
||||||
|
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||||
|
|
||||||
|
// todo: optimize date matchers
|
||||||
|
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||||
|
{
|
||||||
|
id: 'today',
|
||||||
|
label: <Trans i18nKey="com.affine.today" />,
|
||||||
|
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yesterday',
|
||||||
|
label: <Trans i18nKey="com.affine.yesterday" />,
|
||||||
|
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last7Days',
|
||||||
|
label: <Trans i18nKey="com.affine.last7Days" />,
|
||||||
|
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last30Days',
|
||||||
|
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||||
|
match: item =>
|
||||||
|
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moreThan30Days',
|
||||||
|
label: <Trans i18nKey="com.affine.moreThan30Days" />,
|
||||||
|
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const pageGroupDefinitions = {
|
||||||
|
createDate: getDateGroupDefinitions('createDate'),
|
||||||
|
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||||
|
// add more here later
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pagesToPageGroups(
|
||||||
|
pages: PageMeta[],
|
||||||
|
key?: DateKey
|
||||||
|
): PageGroupProps[] {
|
||||||
|
if (!key) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
items: pages,
|
||||||
|
allItems: pages,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// assume pages are already sorted, we will use the page order to determine the group order
|
||||||
|
const groupDefs = pageGroupDefinitions[key];
|
||||||
|
const groups: PageGroupProps[] = [];
|
||||||
|
|
||||||
|
for (const page of pages) {
|
||||||
|
// for a single page, there could be multiple groups that it belongs to
|
||||||
|
const matchedGroups = groupDefs.filter(def => def.match(page));
|
||||||
|
for (const groupDef of matchedGroups) {
|
||||||
|
const group = groups.find(g => g.id === groupDef.id);
|
||||||
|
if (group) {
|
||||||
|
group.items.push(page);
|
||||||
|
} else {
|
||||||
|
const label =
|
||||||
|
typeof groupDef.label === 'function'
|
||||||
|
? groupDef.label()
|
||||||
|
: groupDef.label;
|
||||||
|
groups.push({
|
||||||
|
id: groupDef.id,
|
||||||
|
label: label,
|
||||||
|
items: [page],
|
||||||
|
allItems: pages,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setCollapsed(v => !v);
|
||||||
|
}, []);
|
||||||
|
const selectionState = useAtomValue(selectionStateAtom);
|
||||||
|
const selectedItems = useMemo(() => {
|
||||||
|
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||||
|
return items.filter(item => selectedPageIds.includes(item.id));
|
||||||
|
}, [items, selectionState.selectedPageIds]);
|
||||||
|
const onSelectAll = useCallback(() => {
|
||||||
|
const nonCurrentGroupIds =
|
||||||
|
selectionState.selectedPageIds?.filter(
|
||||||
|
id => !items.map(item => item.id).includes(id)
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
selectionState.onSelectedPageIdsChange?.([
|
||||||
|
...nonCurrentGroupIds,
|
||||||
|
...items.map(item => item.id),
|
||||||
|
]);
|
||||||
|
}, [items, selectionState]);
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
return (
|
||||||
|
<Collapsible.Root
|
||||||
|
data-testid="page-list-group"
|
||||||
|
data-group-id={id}
|
||||||
|
open={!collapsed}
|
||||||
|
className={clsx(styles.root)}
|
||||||
|
>
|
||||||
|
{label ? (
|
||||||
|
<div data-testid="page-list-group-header" className={styles.header}>
|
||||||
|
<Collapsible.Trigger
|
||||||
|
role="button"
|
||||||
|
onClick={onExpandedClicked}
|
||||||
|
data-testid="page-list-group-header-collapsed-button"
|
||||||
|
className={styles.collapsedIconContainer}
|
||||||
|
>
|
||||||
|
<ToggleCollapseIcon
|
||||||
|
className={styles.collapsedIcon}
|
||||||
|
data-collapsed={collapsed !== false}
|
||||||
|
/>
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
<div className={styles.headerLabel}>{label}</div>
|
||||||
|
{selectionState.selectionActive ? (
|
||||||
|
<div className={styles.headerCount}>
|
||||||
|
{selectedItems.length}/{items.length}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className={styles.spacer} />
|
||||||
|
{selectionState.selectionActive ? (
|
||||||
|
<button className={styles.selectAllButton} onClick={onSelectAll}>
|
||||||
|
{t['com.affine.page.group-header.select-all']()}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<Collapsible.Content className={styles.collapsibleContent}>
|
||||||
|
<div className={styles.collapsibleContentInner}>
|
||||||
|
{items.map(item => (
|
||||||
|
<PageMetaListItemRenderer key={item.id} {...item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Collapsible.Content>
|
||||||
|
</Collapsible.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// todo: optimize how to render page meta list item
|
||||||
|
const requiredPropNames = [
|
||||||
|
'blockSuiteWorkspace',
|
||||||
|
'clickMode',
|
||||||
|
'isPreferredEdgeless',
|
||||||
|
'pageOperationsRenderer',
|
||||||
|
'selectedPageIds',
|
||||||
|
'onSelectedPageIdsChange',
|
||||||
|
'draggable',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
|
||||||
|
selectable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const listPropsAtom = selectAtom(
|
||||||
|
pageListPropsAtom,
|
||||||
|
props => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
requiredPropNames.map(name => [name, props[name]])
|
||||||
|
) as RequiredProps;
|
||||||
|
},
|
||||||
|
isEqual
|
||||||
|
);
|
||||||
|
|
||||||
|
const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
||||||
|
const props = useAtomValue(listPropsAtom);
|
||||||
|
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||||
|
return (
|
||||||
|
<PageListItem
|
||||||
|
{...pageMetaToPageItemProp(pageMeta, {
|
||||||
|
...props,
|
||||||
|
selectable: !!selectionActive,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function tagIdToTagOption(
|
||||||
|
tagId: string,
|
||||||
|
blockSuiteWorkspace: Workspace
|
||||||
|
): Tag | undefined {
|
||||||
|
return blockSuiteWorkspace.meta.properties.tags?.options.find(
|
||||||
|
opt => opt.id === tagId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageMetaToPageItemProp(
|
||||||
|
pageMeta: PageMeta,
|
||||||
|
props: RequiredProps
|
||||||
|
): PageListItemProps {
|
||||||
|
const toggleSelection = props.onSelectedPageIdsChange
|
||||||
|
? () => {
|
||||||
|
assertExists(props.selectedPageIds);
|
||||||
|
const prevSelected = props.selectedPageIds.includes(pageMeta.id);
|
||||||
|
const shouldAdd = !prevSelected;
|
||||||
|
const shouldRemove = prevSelected;
|
||||||
|
|
||||||
|
if (shouldAdd) {
|
||||||
|
props.onSelectedPageIdsChange?.([
|
||||||
|
...props.selectedPageIds,
|
||||||
|
pageMeta.id,
|
||||||
|
]);
|
||||||
|
} else if (shouldRemove) {
|
||||||
|
props.onSelectedPageIdsChange?.(
|
||||||
|
props.selectedPageIds.filter(id => id !== pageMeta.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const itemProps: PageListItemProps = {
|
||||||
|
pageId: pageMeta.id,
|
||||||
|
title: pageMeta.title,
|
||||||
|
preview: (
|
||||||
|
<PagePreview workspace={props.blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||||
|
),
|
||||||
|
createDate: new Date(pageMeta.createDate),
|
||||||
|
updatedDate: pageMeta.updatedDate
|
||||||
|
? new Date(pageMeta.updatedDate)
|
||||||
|
: undefined,
|
||||||
|
to:
|
||||||
|
props.clickMode === 'link'
|
||||||
|
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
||||||
|
: undefined,
|
||||||
|
onClick: props.clickMode === 'select' ? toggleSelection : undefined,
|
||||||
|
icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
|
||||||
|
<EdgelessIcon />
|
||||||
|
) : (
|
||||||
|
<PageIcon />
|
||||||
|
),
|
||||||
|
tags:
|
||||||
|
pageMeta.tags
|
||||||
|
?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace))
|
||||||
|
.filter((v): v is Tag => v != null) ?? [],
|
||||||
|
operations: props.pageOperationsRenderer?.(pageMeta),
|
||||||
|
selectable: props.selectable,
|
||||||
|
selected: props.selectedPageIds?.includes(pageMeta.id),
|
||||||
|
onSelectedChange: toggleSelection,
|
||||||
|
draggable: props.draggable,
|
||||||
|
isPublicPage: !!pageMeta.isPublic,
|
||||||
|
};
|
||||||
|
return itemProps;
|
||||||
|
}
|
@ -0,0 +1,175 @@
|
|||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
display: 'flex',
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
height: '54px', // 42 + 12
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'stretch',
|
||||||
|
transition: 'background-color 0.2s, opacity 0.2s',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
overflow: 'hidden',
|
||||||
|
cursor: 'default',
|
||||||
|
willChange: 'opacity',
|
||||||
|
selectors: {
|
||||||
|
'&[data-clickable=true]': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dragOverlay = style({
|
||||||
|
display: 'flex',
|
||||||
|
height: '54px', // 42 + 12
|
||||||
|
alignItems: 'center',
|
||||||
|
background: 'var(--affine-hover-color-filled)',
|
||||||
|
boxShadow: 'var(--affine-menu-shadow)',
|
||||||
|
borderRadius: 10,
|
||||||
|
zIndex: 1001,
|
||||||
|
cursor: 'pointer',
|
||||||
|
maxWidth: '360px',
|
||||||
|
transition: 'transform 0.2s',
|
||||||
|
willChange: 'transform',
|
||||||
|
selectors: {
|
||||||
|
'&[data-over=true]': {
|
||||||
|
transform: 'scale(0.8)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dndCell = style({
|
||||||
|
position: 'relative',
|
||||||
|
marginLeft: -8,
|
||||||
|
height: '100%',
|
||||||
|
outline: 'none',
|
||||||
|
paddingLeft: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-draggable=true] ${dndCell}:before`, {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
left: 0,
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
transition: 'height 0.2s, opacity 0.2s',
|
||||||
|
backgroundColor: 'var(--affine-placeholder-color)',
|
||||||
|
borderRadius: '2px',
|
||||||
|
opacity: 0,
|
||||||
|
willChange: 'height, opacity',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, {
|
||||||
|
height: 12,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, {
|
||||||
|
opacity: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, {
|
||||||
|
height: 32,
|
||||||
|
width: 2,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// todo: remove global style
|
||||||
|
globalStyle(`${root} > :first-child`, {
|
||||||
|
paddingLeft: '16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${root} > :last-child`, {
|
||||||
|
paddingRight: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const titleIconsWrapper = style({
|
||||||
|
padding: '0 5px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const selectionCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: 'var(--affine-font-h-3)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const titleCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
padding: '0 16px',
|
||||||
|
maxWidth: 'calc(100% - 64px)',
|
||||||
|
flex: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const titleCellMain = style({
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: 'var(--affine-font-sm)',
|
||||||
|
fontWeight: 600,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flex: 1,
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const titleCellPreview = style({
|
||||||
|
overflow: 'hidden',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
fontSize: 'var(--affine-font-xs)',
|
||||||
|
flex: 1,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
alignSelf: 'stretch',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 'var(--affine-font-h-3)',
|
||||||
|
color: 'var(--affine-icon-color)',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagsCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 'var(--affine-font-xs)',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
padding: '0 8px',
|
||||||
|
height: '60px',
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dateCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
fontSize: 'var(--affine-font-xs)',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
flexShrink: 0,
|
||||||
|
flexWrap: 'nowrap',
|
||||||
|
padding: '0 8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionsCellWrapper = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const operationsCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
columnGap: '6px',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
@ -0,0 +1,254 @@
|
|||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
|
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Checkbox } from '../../ui/checkbox';
|
||||||
|
import * as styles from './page-list-item.css';
|
||||||
|
import { PageTags } from './page-tags';
|
||||||
|
import type { DraggableTitleCellData, PageListItemProps } from './types';
|
||||||
|
import { ColWrapper, formatDate, stopPropagation } from './utils';
|
||||||
|
|
||||||
|
const PageListTitleCell = ({
|
||||||
|
title,
|
||||||
|
preview,
|
||||||
|
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
return (
|
||||||
|
<div data-testid="page-list-item-title" className={styles.titleCell}>
|
||||||
|
<div
|
||||||
|
data-testid="page-list-item-title-text"
|
||||||
|
className={styles.titleCellMain}
|
||||||
|
>
|
||||||
|
{title || t['Untitled']()}
|
||||||
|
</div>
|
||||||
|
{preview ? (
|
||||||
|
<div
|
||||||
|
data-testid="page-list-item-preview-text"
|
||||||
|
className={styles.titleCellPreview}
|
||||||
|
>
|
||||||
|
{preview}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
|
||||||
|
return (
|
||||||
|
<div data-testid="page-list-item-icon" className={styles.iconCell}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageSelectionCell = ({
|
||||||
|
selectable,
|
||||||
|
onSelectedChange,
|
||||||
|
selected,
|
||||||
|
}: Pick<PageListItemProps, 'selectable' | 'onSelectedChange' | 'selected'>) => {
|
||||||
|
const onSelectionChange = useCallback(
|
||||||
|
(_event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
return onSelectedChange?.();
|
||||||
|
},
|
||||||
|
[onSelectedChange]
|
||||||
|
);
|
||||||
|
if (!selectable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.selectionCell}>
|
||||||
|
<Checkbox
|
||||||
|
onClick={stopPropagation}
|
||||||
|
checked={!!selected}
|
||||||
|
onChange={onSelectionChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageTagsCell = ({ tags }: Pick<PageListItemProps, 'tags'>) => {
|
||||||
|
return (
|
||||||
|
<div data-testid="page-list-item-tags" className={styles.tagsCell}>
|
||||||
|
<PageTags
|
||||||
|
tags={tags}
|
||||||
|
hoverExpandDirection="left"
|
||||||
|
widthOnHover="300%"
|
||||||
|
maxItems={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageCreateDateCell = ({
|
||||||
|
createDate,
|
||||||
|
}: Pick<PageListItemProps, 'createDate'>) => {
|
||||||
|
return (
|
||||||
|
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
||||||
|
{formatDate(createDate)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageUpdatedDateCell = ({
|
||||||
|
updatedDate,
|
||||||
|
}: Pick<PageListItemProps, 'updatedDate'>) => {
|
||||||
|
return (
|
||||||
|
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
||||||
|
{updatedDate ? formatDate(updatedDate) : '-'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageListOperationsCell = ({
|
||||||
|
operations,
|
||||||
|
}: Pick<PageListItemProps, 'operations'>) => {
|
||||||
|
return operations ? (
|
||||||
|
<div onClick={stopPropagation} className={styles.operationsCell}>
|
||||||
|
{operations}
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageListItem = (props: PageListItemProps) => {
|
||||||
|
const pageTitleElement = useMemo(() => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.titleIconsWrapper}>
|
||||||
|
<PageSelectionCell
|
||||||
|
onSelectedChange={props.onSelectedChange}
|
||||||
|
selectable={props.selectable}
|
||||||
|
selected={props.selected}
|
||||||
|
/>
|
||||||
|
<PageListIconCell icon={props.icon} />
|
||||||
|
</div>
|
||||||
|
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
props.icon,
|
||||||
|
props.onSelectedChange,
|
||||||
|
props.preview,
|
||||||
|
props.selectable,
|
||||||
|
props.selected,
|
||||||
|
props.title,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||||
|
id: 'page-list-item-title-' + props.pageId,
|
||||||
|
data: {
|
||||||
|
pageId: props.pageId,
|
||||||
|
pageTitle: pageTitleElement,
|
||||||
|
} satisfies DraggableTitleCellData,
|
||||||
|
disabled: !props.draggable,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageListItemWrapper
|
||||||
|
onClick={props.onClick}
|
||||||
|
to={props.to}
|
||||||
|
pageId={props.pageId}
|
||||||
|
draggable={props.draggable}
|
||||||
|
isDragging={isDragging}
|
||||||
|
>
|
||||||
|
<ColWrapper flex={9}>
|
||||||
|
<ColWrapper
|
||||||
|
className={styles.dndCell}
|
||||||
|
flex={8}
|
||||||
|
ref={setNodeRef}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<div className={styles.titleIconsWrapper}>
|
||||||
|
<PageSelectionCell
|
||||||
|
onSelectedChange={props.onSelectedChange}
|
||||||
|
selectable={props.selectable}
|
||||||
|
selected={props.selected}
|
||||||
|
/>
|
||||||
|
<PageListIconCell icon={props.icon} />
|
||||||
|
</div>
|
||||||
|
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||||
|
</ColWrapper>
|
||||||
|
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
|
||||||
|
<PageTagsCell tags={props.tags} />
|
||||||
|
</ColWrapper>
|
||||||
|
</ColWrapper>
|
||||||
|
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||||
|
<PageCreateDateCell createDate={props.createDate} />
|
||||||
|
</ColWrapper>
|
||||||
|
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||||
|
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
||||||
|
</ColWrapper>
|
||||||
|
{props.operations ? (
|
||||||
|
<ColWrapper
|
||||||
|
className={styles.actionsCellWrapper}
|
||||||
|
flex={1}
|
||||||
|
alignment="end"
|
||||||
|
>
|
||||||
|
<PageListOperationsCell operations={props.operations} />
|
||||||
|
</ColWrapper>
|
||||||
|
) : null}
|
||||||
|
</PageListItemWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageListWrapperProps = PropsWithChildren<
|
||||||
|
Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & {
|
||||||
|
isDragging: boolean;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
function PageListItemWrapper({
|
||||||
|
to,
|
||||||
|
isDragging,
|
||||||
|
pageId,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
draggable,
|
||||||
|
}: PageListWrapperProps) {
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (onClick) {
|
||||||
|
stopPropagation(e);
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const commonProps = useMemo(
|
||||||
|
() => ({
|
||||||
|
'data-testid': 'page-list-item',
|
||||||
|
'data-page-id': pageId,
|
||||||
|
'data-draggable': draggable,
|
||||||
|
className: styles.root,
|
||||||
|
'data-clickable': !!onClick || !!to,
|
||||||
|
'data-dragging': isDragging,
|
||||||
|
onClick: handleClick,
|
||||||
|
}),
|
||||||
|
[pageId, draggable, isDragging, onClick, to, handleClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
return (
|
||||||
|
<Link {...commonProps} to={to}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return <div {...commonProps}>{children}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageListDragOverlay = ({
|
||||||
|
children,
|
||||||
|
over,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
over?: boolean;
|
||||||
|
}>) => {
|
||||||
|
return (
|
||||||
|
<div data-over={over} className={styles.dragOverlay}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,111 @@
|
|||||||
|
import { createContainer, globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
import * as itemStyles from './page-list-item.css';
|
||||||
|
|
||||||
|
export const listRootContainer = createContainer('list-root-container');
|
||||||
|
|
||||||
|
export const pageListScrollContainer = style({
|
||||||
|
overflowY: 'auto',
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
containerName: listRootContainer,
|
||||||
|
containerType: 'inline-size',
|
||||||
|
background: 'var(--affine-background-primary-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupsContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
rowGap: '16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const header = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '10px 6px 10px 16px',
|
||||||
|
position: 'sticky',
|
||||||
|
overflow: 'hidden',
|
||||||
|
zIndex: 1,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
background: 'var(--affine-background-primary-color)',
|
||||||
|
transition: 'box-shadow 0.2s ease-in-out',
|
||||||
|
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`[data-has-scroll-top=true] ${header}`, {
|
||||||
|
boxShadow: '0 1px var(--affine-border-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerCell = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
userSelect: 'none',
|
||||||
|
fontSize: 'var(--affine-font-xs)',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
selectors: {
|
||||||
|
'&[data-sorting], &:hover': {
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
},
|
||||||
|
'&[data-sortable]': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
'&:not(:last-child)': {
|
||||||
|
borderRight: '1px solid var(--affine-hover-color-filled)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
columnGap: '4px',
|
||||||
|
position: 'relative',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerTitleCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerTitleSelectionIconWrapper = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerCellSortIcon = style({
|
||||||
|
width: '14px',
|
||||||
|
height: '14px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const colWrapper = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hideInSmallContainer = style({
|
||||||
|
'@container': {
|
||||||
|
[`${listRootContainer} (max-width: 800px)`]: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const favoriteCell = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: 0,
|
||||||
|
selectors: {
|
||||||
|
[`&[data-favorite], &${itemStyles.root}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
@ -0,0 +1,301 @@
|
|||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { useHydrateAtoms } from 'jotai/utils';
|
||||||
|
import {
|
||||||
|
type ForwardedRef,
|
||||||
|
forwardRef,
|
||||||
|
type MouseEventHandler,
|
||||||
|
type PropsWithChildren,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
|
||||||
|
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||||
|
import { PageGroup } from './page-group';
|
||||||
|
import * as styles from './page-list.css';
|
||||||
|
import {
|
||||||
|
pageGroupsAtom,
|
||||||
|
pageListHandlersAtom,
|
||||||
|
pageListPropsAtom,
|
||||||
|
pagesAtom,
|
||||||
|
selectionStateAtom,
|
||||||
|
showOperationsAtom,
|
||||||
|
sorterAtom,
|
||||||
|
} from './scoped-atoms';
|
||||||
|
import type { PageListHandle, PageListProps } from './types';
|
||||||
|
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a list of pages, render a list of pages
|
||||||
|
*/
|
||||||
|
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
||||||
|
function PageListHandle(props, ref) {
|
||||||
|
return (
|
||||||
|
<Provider>
|
||||||
|
<PageListInner {...props} handleRef={ref} />
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const PageListInner = ({
|
||||||
|
handleRef,
|
||||||
|
...props
|
||||||
|
}: PageListProps & { handleRef: ForwardedRef<PageListHandle> }) => {
|
||||||
|
// push pageListProps to the atom so that downstream components can consume it
|
||||||
|
useHydrateAtoms([[pageListPropsAtom, props]], {
|
||||||
|
// note: by turning on dangerouslyForceHydrate, downstream component need to use selectAtom to consume the atom
|
||||||
|
// note2: not using it for now because it will cause some other issues
|
||||||
|
// dangerouslyForceHydrate: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
||||||
|
const setPageListSelectionState = useSetAtom(selectionStateAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPageListPropsAtom(props);
|
||||||
|
}, [props, setPageListPropsAtom]);
|
||||||
|
|
||||||
|
useImperativeHandle(
|
||||||
|
handleRef,
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
toggleSelectable: () => {
|
||||||
|
setPageListSelectionState(false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[setPageListSelectionState]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groups = useAtomValue(pageGroupsAtom);
|
||||||
|
const hideHeader = props.hideHeader;
|
||||||
|
return (
|
||||||
|
<div className={clsx(props.className, styles.root)}>
|
||||||
|
{!hideHeader ? <PageListHeader /> : null}
|
||||||
|
<div className={styles.groupsContainer}>
|
||||||
|
{groups.map(group => (
|
||||||
|
<PageGroup key={group.id} {...group} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeaderCellProps = ColWrapperProps & {
|
||||||
|
sortKey: keyof PageMeta;
|
||||||
|
sortable?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
||||||
|
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||||
|
const onClick: MouseEventHandler = useCallback(() => {
|
||||||
|
if (props.sortable && props.sortKey) {
|
||||||
|
setSorter({
|
||||||
|
newSortKey: props.sortKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [props.sortKey, props.sortable, setSorter]);
|
||||||
|
|
||||||
|
const sorting = sorter.key === props.sortKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ColWrapper
|
||||||
|
flex={props.flex}
|
||||||
|
alignment={props.alignment}
|
||||||
|
onClick={onClick}
|
||||||
|
className={styles.headerCell}
|
||||||
|
data-sortable={props.sortable ? true : undefined}
|
||||||
|
data-sorting={sorting ? true : undefined}
|
||||||
|
style={props.style}
|
||||||
|
hideInSmallContainer={props.hideInSmallContainer}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
{sorting ? (
|
||||||
|
<div className={styles.headerCellSortIcon}>
|
||||||
|
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</ColWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type HeaderColDef = {
|
||||||
|
key: string;
|
||||||
|
content: ReactNode;
|
||||||
|
flex: ColWrapperProps['flex'];
|
||||||
|
alignment?: ColWrapperProps['alignment'];
|
||||||
|
sortable?: boolean;
|
||||||
|
hideInSmallContainer?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// the checkbox on the header has three states:
|
||||||
|
// when list selectable = true, the checkbox will be presented
|
||||||
|
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
||||||
|
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
||||||
|
const PageListHeaderCheckbox = () => {
|
||||||
|
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||||
|
const pages = useAtomValue(pagesAtom);
|
||||||
|
const onActivateSelection: MouseEventHandler = useCallback(
|
||||||
|
e => {
|
||||||
|
stopPropagation(e);
|
||||||
|
setSelectionState(true);
|
||||||
|
},
|
||||||
|
[setSelectionState]
|
||||||
|
);
|
||||||
|
const handlers = useAtomValue(pageListHandlersAtom);
|
||||||
|
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||||
|
(e, checked) => {
|
||||||
|
stopPropagation(e);
|
||||||
|
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
||||||
|
},
|
||||||
|
[handlers, pages]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selectionState.selectable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.headerTitleSelectionIconWrapper}
|
||||||
|
onClick={onActivateSelection}
|
||||||
|
>
|
||||||
|
{!selectionState.selectionActive ? (
|
||||||
|
<MultiSelectIcon />
|
||||||
|
) : (
|
||||||
|
<Checkbox
|
||||||
|
checked={selectionState.selectedPageIds?.length === pages.length}
|
||||||
|
indeterminate={
|
||||||
|
selectionState.selectedPageIds &&
|
||||||
|
selectionState.selectedPageIds.length > 0 &&
|
||||||
|
selectionState.selectedPageIds.length < pages.length
|
||||||
|
}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PageListHeaderTitleCell = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
return (
|
||||||
|
<div className={styles.headerTitleCell}>
|
||||||
|
<PageListHeaderCheckbox />
|
||||||
|
{t['Title']()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageListHeader = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const showOperations = useAtomValue(showOperationsAtom);
|
||||||
|
const headerCols = useMemo(() => {
|
||||||
|
const cols: (HeaderColDef | boolean)[] = [
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
content: <PageListHeaderTitleCell />,
|
||||||
|
flex: 6,
|
||||||
|
alignment: 'start',
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
content: t['Tags'](),
|
||||||
|
flex: 3,
|
||||||
|
alignment: 'end',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createDate',
|
||||||
|
content: t['Created'](),
|
||||||
|
flex: 1,
|
||||||
|
sortable: true,
|
||||||
|
alignment: 'end',
|
||||||
|
hideInSmallContainer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'updatedDate',
|
||||||
|
content: t['Updated'](),
|
||||||
|
flex: 1,
|
||||||
|
sortable: true,
|
||||||
|
alignment: 'end',
|
||||||
|
hideInSmallContainer: true,
|
||||||
|
},
|
||||||
|
showOperations && {
|
||||||
|
key: 'actions',
|
||||||
|
content: '',
|
||||||
|
flex: 1,
|
||||||
|
alignment: 'end',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return cols.filter((def): def is HeaderColDef => !!def);
|
||||||
|
}, [t, showOperations]);
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.header)}>
|
||||||
|
{headerCols.map(col => {
|
||||||
|
return (
|
||||||
|
<PageListHeaderCell
|
||||||
|
flex={col.flex}
|
||||||
|
alignment={col.alignment}
|
||||||
|
key={col.key}
|
||||||
|
sortKey={col.key as keyof PageMeta}
|
||||||
|
sortable={col.sortable}
|
||||||
|
style={{ overflow: 'visible' }}
|
||||||
|
hideInSmallContainer={col.hideInSmallContainer}
|
||||||
|
>
|
||||||
|
{col.content}
|
||||||
|
</PageListHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PageListScrollContainerProps {
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PageListScrollContainer = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
PropsWithChildren<PageListScrollContainerProps>
|
||||||
|
>(({ className, children, style }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const hasScrollTop = useHasScrollTop(containerRef);
|
||||||
|
|
||||||
|
const setNodeRef = useCallback(
|
||||||
|
(r: HTMLDivElement) => {
|
||||||
|
if (ref) {
|
||||||
|
if (typeof ref === 'function') {
|
||||||
|
ref(r);
|
||||||
|
} else {
|
||||||
|
ref.current = r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
containerRef.current = r;
|
||||||
|
},
|
||||||
|
[ref]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
ref={setNodeRef}
|
||||||
|
data-has-scroll-top={hasScrollTop}
|
||||||
|
className={clsx(styles.pageListScrollContainer, className)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PageListScrollContainer.displayName = 'PageListScrollContainer';
|
@ -0,0 +1,138 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '32px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagsContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagsScrollContainer = style([
|
||||||
|
tagsContainer,
|
||||||
|
{
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100%',
|
||||||
|
gap: '8px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const tagsListContainer = style([
|
||||||
|
tagsContainer,
|
||||||
|
{
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '4px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const innerContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
columnGap: '8px',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
height: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
transition: 'all 0.2s 0.3s ease-in-out',
|
||||||
|
selectors: {
|
||||||
|
[`${root}:hover &`]: {
|
||||||
|
maxWidth: 'var(--hover-max-width)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// background with linear gradient hack
|
||||||
|
export const innerBackdrop = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
background:
|
||||||
|
'linear-gradient(90deg, transparent 0%, var(--affine-hover-color-filled) 40%)',
|
||||||
|
selectors: {
|
||||||
|
[`${root}:hover &`]: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 8px',
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagSticky = style([
|
||||||
|
tag,
|
||||||
|
{
|
||||||
|
fontSize: 'var(--affine-font-xs)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
columnGap: '4px',
|
||||||
|
border: '1px solid var(--affine-border-color)',
|
||||||
|
background: 'var(--affine-background-primary-color)',
|
||||||
|
maxWidth: '128px',
|
||||||
|
position: 'sticky',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
left: 0,
|
||||||
|
selectors: range(0, 20).reduce((selectors, i) => {
|
||||||
|
return {
|
||||||
|
...selectors,
|
||||||
|
[`&:nth-last-child(${i + 1})`]: {
|
||||||
|
right: `${i * 48}px`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, {}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const tagListItem = style([
|
||||||
|
tag,
|
||||||
|
{
|
||||||
|
fontSize: 'var(--affine-font-sm)',
|
||||||
|
padding: '4px 12px',
|
||||||
|
columnGap: '8px',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
height: '30px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const showMoreTag = style({
|
||||||
|
fontSize: 'var(--affine-font-h-5)',
|
||||||
|
right: 0,
|
||||||
|
position: 'sticky',
|
||||||
|
display: 'inline-flex',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagIndicator = style({
|
||||||
|
width: '8px',
|
||||||
|
height: '8px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagLabel = style({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
@ -0,0 +1,135 @@
|
|||||||
|
import type { Tag } from '@affine/env/filter';
|
||||||
|
import { MoreHorizontalIcon } from '@blocksuite/icons';
|
||||||
|
import { Menu } from '@toeverything/components/menu';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './page-tags.css';
|
||||||
|
import { stopPropagation } from './utils';
|
||||||
|
|
||||||
|
export interface PageTagsProps {
|
||||||
|
tags: Tag[];
|
||||||
|
maxItems?: number; // max number to show. if not specified, show all. if specified, show the first n items and add a "..." tag
|
||||||
|
widthOnHover?: number | string; // max width on hover
|
||||||
|
hoverExpandDirection?: 'left' | 'right'; // expansion direction on hover
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagItemProps {
|
||||||
|
tag: Tag;
|
||||||
|
idx: number;
|
||||||
|
mode: 'sticky' | 'list-item';
|
||||||
|
}
|
||||||
|
|
||||||
|
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
||||||
|
const tagColorMap = (color: string) => {
|
||||||
|
const mapping: Record<string, string> = {
|
||||||
|
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
|
||||||
|
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
|
||||||
|
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
|
||||||
|
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||||
|
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||||
|
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||||
|
};
|
||||||
|
return mapping[color] || color;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TagItem = ({ tag, idx, mode }: TagItemProps) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="page-tag"
|
||||||
|
className={mode === 'sticky' ? styles.tagSticky : styles.tagListItem}
|
||||||
|
data-idx={idx}
|
||||||
|
title={tag.value}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.tagIndicator}
|
||||||
|
style={{
|
||||||
|
backgroundColor: tagColorMap(tag.color),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={styles.tagLabel}>{tag.value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageTags = ({
|
||||||
|
tags,
|
||||||
|
widthOnHover,
|
||||||
|
maxItems,
|
||||||
|
hoverExpandDirection,
|
||||||
|
}: PageTagsProps) => {
|
||||||
|
const sanitizedWidthOnHover = widthOnHover
|
||||||
|
? typeof widthOnHover === 'string'
|
||||||
|
? widthOnHover
|
||||||
|
: `${widthOnHover}px`
|
||||||
|
: 'auto';
|
||||||
|
const tagsContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tagsContainerRef.current) {
|
||||||
|
const tagsContainer = tagsContainerRef.current;
|
||||||
|
const listener = () => {
|
||||||
|
// on mouseleave, reset scroll position to the hoverExpandDirection
|
||||||
|
tagsContainer.scrollTo({
|
||||||
|
left: hoverExpandDirection === 'left' ? Number.MAX_SAFE_INTEGER : 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
listener();
|
||||||
|
tagsContainerRef.current.addEventListener('mouseleave', listener);
|
||||||
|
return () => {
|
||||||
|
tagsContainer.removeEventListener('mouseleave', listener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}, [hoverExpandDirection]);
|
||||||
|
|
||||||
|
const tagsInPopover = useMemo(() => {
|
||||||
|
const lastTags = tags.slice(maxItems);
|
||||||
|
return (
|
||||||
|
<div className={styles.tagsListContainer}>
|
||||||
|
{lastTags.map((tag, idx) => (
|
||||||
|
<TagItem key={tag.id} tag={tag} idx={idx} mode="list-item" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [maxItems, tags]);
|
||||||
|
|
||||||
|
const tagsNormal = useMemo(() => {
|
||||||
|
const nTags = maxItems ? tags.slice(0, maxItems) : tags;
|
||||||
|
return nTags.map((tag, idx) => (
|
||||||
|
<TagItem key={tag.id} tag={tag} idx={idx} mode="sticky" />
|
||||||
|
));
|
||||||
|
}, [maxItems, tags]);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="page-tags"
|
||||||
|
className={styles.root}
|
||||||
|
style={{
|
||||||
|
// @ts-expect-error it's fine
|
||||||
|
'--hover-max-width': sanitizedWidthOnHover,
|
||||||
|
}}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
right: hoverExpandDirection === 'left' ? 0 : 'auto',
|
||||||
|
left: hoverExpandDirection === 'right' ? 0 : 'auto',
|
||||||
|
}}
|
||||||
|
className={clsx(styles.innerContainer)}
|
||||||
|
>
|
||||||
|
<div className={styles.innerBackdrop} />
|
||||||
|
<div className={styles.tagsScrollContainer} ref={tagsContainerRef}>
|
||||||
|
{tagsNormal}
|
||||||
|
</div>
|
||||||
|
{maxItems && tags.length > maxItems ? (
|
||||||
|
<Menu items={tagsInPopover}>
|
||||||
|
<div className={styles.showMoreTag}>
|
||||||
|
<MoreHorizontalIcon />
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,4 @@
|
|||||||
|
# <PageListTable />
|
||||||
|
|
||||||
|
A new implementation of the list table component for Page. Replace existing `PageList` component.
|
||||||
|
May rename to `PageList` later.
|
@ -0,0 +1,188 @@
|
|||||||
|
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { selectAtom } from 'jotai/utils';
|
||||||
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
|
import { pagesToPageGroups } from './page-group';
|
||||||
|
import type { PageListProps, PageMetaRecord } from './types';
|
||||||
|
|
||||||
|
// for ease of use in the component tree
|
||||||
|
// note: must use selectAtom to access this atom for efficiency
|
||||||
|
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
||||||
|
export const pageListPropsAtom = atom<PageListProps>();
|
||||||
|
|
||||||
|
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
||||||
|
const selectionActiveAtom = atom(false);
|
||||||
|
|
||||||
|
export const selectionStateAtom = atom(
|
||||||
|
get => {
|
||||||
|
const baseAtom = selectAtom(
|
||||||
|
pageListPropsAtom,
|
||||||
|
props => {
|
||||||
|
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
||||||
|
return {
|
||||||
|
selectable,
|
||||||
|
selectedPageIds,
|
||||||
|
onSelectedPageIdsChange,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isEqual
|
||||||
|
);
|
||||||
|
const baseState = get(baseAtom);
|
||||||
|
const selectionActive =
|
||||||
|
baseState.selectable === 'toggle'
|
||||||
|
? get(selectionActiveAtom)
|
||||||
|
: baseState.selectable;
|
||||||
|
return {
|
||||||
|
...baseState,
|
||||||
|
selectionActive,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(_get, set, active: boolean) => {
|
||||||
|
set(selectionActiveAtom, active);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// get handlers from pageListPropsAtom
|
||||||
|
export const pageListHandlersAtom = selectAtom(
|
||||||
|
pageListPropsAtom,
|
||||||
|
props => {
|
||||||
|
const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props;
|
||||||
|
|
||||||
|
return {
|
||||||
|
onSelectedPageIdsChange,
|
||||||
|
onDragStart,
|
||||||
|
onDragEnd,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
isEqual
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages);
|
||||||
|
|
||||||
|
export const showOperationsAtom = selectAtom(
|
||||||
|
pageListPropsAtom,
|
||||||
|
props => !!props.pageOperationsRenderer
|
||||||
|
);
|
||||||
|
|
||||||
|
type SortingContext<T extends string | number | symbol> = {
|
||||||
|
key: T;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
fallbackKey?: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SorterConfig<T extends Record<string, unknown> = Record<string, unknown>> =
|
||||||
|
{
|
||||||
|
key?: keyof T;
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
|
||||||
|
ctx,
|
||||||
|
a,
|
||||||
|
b
|
||||||
|
) => {
|
||||||
|
const val = (obj: PageMetaRecord) => {
|
||||||
|
let v = obj[ctx.key];
|
||||||
|
if (v === undefined && ctx.fallbackKey) {
|
||||||
|
v = obj[ctx.fallbackKey];
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
const valA = val(a);
|
||||||
|
const valB = val(b);
|
||||||
|
const revert = ctx.order === 'desc';
|
||||||
|
const revertSymbol = revert ? -1 : 1;
|
||||||
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||||
|
return valA.localeCompare(valB) * revertSymbol;
|
||||||
|
}
|
||||||
|
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||||
|
return (valA - valB) * revertSymbol;
|
||||||
|
}
|
||||||
|
if (valA instanceof Date && valB instanceof Date) {
|
||||||
|
return (valA.getTime() - valB.getTime()) * revertSymbol;
|
||||||
|
}
|
||||||
|
if (!valA) {
|
||||||
|
return -1 * revertSymbol;
|
||||||
|
}
|
||||||
|
if (!valB) {
|
||||||
|
return 1 * revertSymbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(valA) && Array.isArray(valB)) {
|
||||||
|
return (valA.length - valB.length) * revertSymbol;
|
||||||
|
}
|
||||||
|
console.warn(
|
||||||
|
'Unsupported sorting type! Please use custom sorting function.',
|
||||||
|
valA,
|
||||||
|
valB
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
|
||||||
|
key: DEFAULT_SORT_KEY,
|
||||||
|
order: 'desc',
|
||||||
|
sortingFn: defaultSortingFn,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sorterAtom = atom(
|
||||||
|
get => {
|
||||||
|
let pages = get(pagesAtom);
|
||||||
|
const sorterState = get(sorterStateAtom);
|
||||||
|
const sortCtx: SortingContext<keyof PageMetaRecord> | null = sorterState.key
|
||||||
|
? {
|
||||||
|
key: sorterState.key,
|
||||||
|
order: sorterState.order,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (sortCtx) {
|
||||||
|
if (sorterState.key === 'updatedDate') {
|
||||||
|
sortCtx.fallbackKey = 'createDate';
|
||||||
|
}
|
||||||
|
const compareFn = (a: PageMetaRecord, b: PageMetaRecord) =>
|
||||||
|
sorterState.sortingFn(sortCtx, a, b);
|
||||||
|
pages = [...pages].sort(compareFn);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pages,
|
||||||
|
...sortCtx,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
(_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => {
|
||||||
|
set(sorterStateAtom, sorterState => {
|
||||||
|
if (sorterState.key === newSortKey) {
|
||||||
|
return {
|
||||||
|
...sorterState,
|
||||||
|
order: sorterState.order === 'asc' ? 'desc' : 'asc',
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
key: newSortKey,
|
||||||
|
order: 'desc',
|
||||||
|
sortingFn: sorterState.sortingFn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const pageGroupsAtom = atom(get => {
|
||||||
|
let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy));
|
||||||
|
const sorter = get(sorterAtom);
|
||||||
|
|
||||||
|
if (groupBy === false) {
|
||||||
|
groupBy = undefined;
|
||||||
|
} else if (groupBy === undefined) {
|
||||||
|
groupBy =
|
||||||
|
sorter.key === 'createDate' || sorter.key === 'updatedDate'
|
||||||
|
? sorter.key
|
||||||
|
: // default sort
|
||||||
|
!sorter.key
|
||||||
|
? DEFAULT_SORT_KEY
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
return pagesToPageGroups(sorter.pages, groupBy);
|
||||||
|
});
|
@ -1,84 +0,0 @@
|
|||||||
import { displayFlex, styled } from '../../styles';
|
|
||||||
import { Content } from '../../ui/layout/content';
|
|
||||||
import { TableBodyRow } from '../../ui/table/table-row';
|
|
||||||
|
|
||||||
export const StyledTableContainer = styled('div')(({ theme }) => {
|
|
||||||
return {
|
|
||||||
height: '100%',
|
|
||||||
minHeight: '600px',
|
|
||||||
padding: '0 32px 180px 32px',
|
|
||||||
maxWidth: '100%',
|
|
||||||
[theme.breakpoints.down('sm')]: {
|
|
||||||
padding: '52px 0px',
|
|
||||||
'tr > td:first-of-type': {
|
|
||||||
borderTopLeftRadius: '0px',
|
|
||||||
borderBottomLeftRadius: '0px',
|
|
||||||
},
|
|
||||||
'tr > td:last-of-type': {
|
|
||||||
borderTopRightRadius: '0px',
|
|
||||||
borderBottomRightRadius: '0px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated
|
|
||||||
*/
|
|
||||||
export const StyledTitleWrapper = styled('div')(() => {
|
|
||||||
return {
|
|
||||||
...displayFlex('flex-start', 'center'),
|
|
||||||
a: {
|
|
||||||
color: 'inherit',
|
|
||||||
},
|
|
||||||
'a:visited': {
|
|
||||||
color: 'unset',
|
|
||||||
},
|
|
||||||
'a:hover': {
|
|
||||||
color: 'var(--affine-primary-color)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
export const StyledTitleLink = styled('div')(() => {
|
|
||||||
return {
|
|
||||||
...displayFlex('flex-start', 'center'),
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
'>svg': {
|
|
||||||
fontSize: '24px',
|
|
||||||
marginRight: '12px',
|
|
||||||
color: 'var(--affine-icon-color)',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StyledTitleContentWrapper = styled('div')(() => {
|
|
||||||
return {
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StyledTitlePreview = styled(Content)(() => {
|
|
||||||
return {
|
|
||||||
fontWeight: 400,
|
|
||||||
fontSize: 'var(--affine-font-xs)',
|
|
||||||
maxWidth: '100%',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const StyledTableBodyRow = styled(TableBodyRow)(() => {
|
|
||||||
return {
|
|
||||||
cursor: 'pointer',
|
|
||||||
'.favorite-button': {
|
|
||||||
visibility: 'hidden',
|
|
||||||
},
|
|
||||||
'&:hover': {
|
|
||||||
'.favorite-button': {
|
|
||||||
visibility: 'visible',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
@ -1,65 +0,0 @@
|
|||||||
import type { CollectionsAtom } from '@affine/component/page-list/use-collection-manager';
|
|
||||||
import type { Tag } from '@affine/env/filter';
|
|
||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
|
||||||
import type { ReactElement, ReactNode } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the keys of an object type whose values are of a given type
|
|
||||||
*
|
|
||||||
* See https://stackoverflow.com/questions/54520676/in-typescript-how-to-get-the-keys-of-an-object-type-whose-values-are-of-a-given
|
|
||||||
*/
|
|
||||||
export type KeysMatching<T, V> = {
|
|
||||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
|
||||||
}[keyof T];
|
|
||||||
|
|
||||||
export type ListData = {
|
|
||||||
pageId: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
title: string;
|
|
||||||
preview?: ReactNode;
|
|
||||||
tags: Tag[];
|
|
||||||
favorite: boolean;
|
|
||||||
createDate: Date;
|
|
||||||
updatedDate: Date;
|
|
||||||
isPublicPage: boolean;
|
|
||||||
onClickPage: () => void;
|
|
||||||
onOpenPageInNewTab: () => void;
|
|
||||||
bookmarkPage: () => void;
|
|
||||||
removeToTrash: () => void;
|
|
||||||
onDisablePublicSharing: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DateKey = KeysMatching<ListData, Date>;
|
|
||||||
|
|
||||||
export type TrashListData = {
|
|
||||||
pageId: string;
|
|
||||||
icon: JSX.Element;
|
|
||||||
title: string;
|
|
||||||
preview?: ReactNode;
|
|
||||||
createDate: Date;
|
|
||||||
// TODO remove optional after assert that trashDate is always set
|
|
||||||
trashDate?: Date;
|
|
||||||
onClickPage: () => void;
|
|
||||||
onRestorePage: () => void;
|
|
||||||
onPermanentlyDeletePage: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PageListProps = {
|
|
||||||
isPublicWorkspace?: boolean;
|
|
||||||
collectionsAtom: CollectionsAtom;
|
|
||||||
list: ListData[];
|
|
||||||
fallback?: ReactNode;
|
|
||||||
onCreateNewPage: () => void;
|
|
||||||
onCreateNewEdgeless: () => void;
|
|
||||||
onImportFile: () => void;
|
|
||||||
getPageInfo: GetPageInfoById;
|
|
||||||
propertiesMeta: PropertiesMeta;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type DraggableTitleCellData = {
|
|
||||||
pageId: string;
|
|
||||||
pageTitle: string;
|
|
||||||
pagePreview?: string;
|
|
||||||
icon: ReactElement;
|
|
||||||
};
|
|
@ -0,0 +1,87 @@
|
|||||||
|
import type { Tag } from '@affine/env/filter';
|
||||||
|
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { To } from 'react-router-dom';
|
||||||
|
|
||||||
|
// TODO: consider reducing the number of props here
|
||||||
|
// using type instead of interface to make it Record compatible
|
||||||
|
export type PageListItemProps = {
|
||||||
|
pageId: string;
|
||||||
|
icon: JSX.Element;
|
||||||
|
title: ReactNode; // using ReactNode to allow for rich content rendering
|
||||||
|
preview?: ReactNode; // using ReactNode to allow for rich content rendering
|
||||||
|
tags: Tag[];
|
||||||
|
createDate: Date;
|
||||||
|
updatedDate?: Date;
|
||||||
|
isPublicPage?: boolean;
|
||||||
|
to?: To; // whether or not to render this item as a Link
|
||||||
|
draggable?: boolean; // whether or not to allow dragging this item
|
||||||
|
selectable?: boolean; // show selection checkbox
|
||||||
|
selected?: boolean;
|
||||||
|
operations?: ReactNode; // operations to show on the right side of the item
|
||||||
|
onClick?: () => void;
|
||||||
|
onSelectedChange?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PageListHeaderProps {}
|
||||||
|
|
||||||
|
// todo: a temporary solution. may need to be refactored later
|
||||||
|
export type PagesGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later
|
||||||
|
|
||||||
|
// todo: a temporary solution. may need to be refactored later
|
||||||
|
export interface SortBy {
|
||||||
|
key: 'createDate' | 'updatedDate';
|
||||||
|
order: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DateKey = 'createDate' | 'updatedDate';
|
||||||
|
|
||||||
|
export interface PageListProps {
|
||||||
|
// required data:
|
||||||
|
pages: PageMeta[];
|
||||||
|
blockSuiteWorkspace: Workspace;
|
||||||
|
|
||||||
|
className?: string;
|
||||||
|
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||||
|
groupBy?: PagesGroupByType | false;
|
||||||
|
isPreferredEdgeless: (pageId: string) => boolean;
|
||||||
|
clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate
|
||||||
|
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
||||||
|
selectedPageIds?: string[]; // selected page ids
|
||||||
|
onSelectedPageIdsChange?: (selected: string[]) => void;
|
||||||
|
draggable?: boolean; // whether or not to allow dragging this page item
|
||||||
|
onDragStart?: (pageId: string) => void;
|
||||||
|
onDragEnd?: (pageId: string) => void;
|
||||||
|
// we also need the following to make sure the page list functions properly
|
||||||
|
// maybe we could also give a function to render PageListItem?
|
||||||
|
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageListHandle {
|
||||||
|
toggleSelectable: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageGroupDefinition {
|
||||||
|
id: string;
|
||||||
|
// using a function to render custom group header
|
||||||
|
label: (() => ReactNode) | ReactNode;
|
||||||
|
match: (item: PageMeta) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageGroupProps {
|
||||||
|
id: string;
|
||||||
|
label?: ReactNode; // if there is no label, it is a default group (without header)
|
||||||
|
items: PageMeta[];
|
||||||
|
allItems: PageMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type MakeRecord<T> = {
|
||||||
|
[P in keyof T]: T[P];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PageMetaRecord = MakeRecord<PageMeta>;
|
||||||
|
|
||||||
|
export type DraggableTitleCellData = {
|
||||||
|
pageId: string;
|
||||||
|
pageTitle: ReactNode;
|
||||||
|
};
|
@ -1,137 +1,124 @@
|
|||||||
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
|
import type {
|
||||||
import { useAtom } from 'jotai';
|
Collection,
|
||||||
import { atomWithReset, RESET } from 'jotai/utils';
|
DeleteCollectionInfo,
|
||||||
import type { WritableAtom } from 'jotai/vanilla';
|
Filter,
|
||||||
|
VariableMap,
|
||||||
|
} from '@affine/env/filter';
|
||||||
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
|
import { type Atom, useAtom, useAtomValue } from 'jotai';
|
||||||
|
import { atomWithReset } from 'jotai/utils';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { NIL } from 'uuid';
|
import { NIL } from 'uuid';
|
||||||
|
|
||||||
import { evalFilterList } from './filter';
|
import { evalFilterList } from './filter';
|
||||||
|
|
||||||
const defaultCollection = {
|
export const createEmptyCollection = (
|
||||||
id: NIL,
|
id: string,
|
||||||
name: 'All',
|
data?: Partial<Omit<Collection, 'id'>>
|
||||||
|
): Collection => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: '',
|
||||||
|
mode: 'page',
|
||||||
filterList: [],
|
filterList: [],
|
||||||
workspaceId: 'temporary',
|
pages: [],
|
||||||
|
allowList: [],
|
||||||
|
...data,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
const defaultCollection: Collection = createEmptyCollection(NIL, {
|
||||||
const collectionAtom = atomWithReset<{
|
name: 'All',
|
||||||
currentId: string;
|
mode: 'rule',
|
||||||
defaultCollection: Collection;
|
|
||||||
}>({
|
|
||||||
currentId: NIL,
|
|
||||||
defaultCollection: defaultCollection,
|
|
||||||
});
|
});
|
||||||
|
const defaultCollectionAtom = atomWithReset<Collection>(defaultCollection);
|
||||||
|
export const currentCollectionAtom = atomWithReset<string>(NIL);
|
||||||
|
|
||||||
export type CollectionsAtom = WritableAtom<
|
export type Updater<T> = (value: T) => T;
|
||||||
Collection[] | Promise<Collection[]>,
|
export type CollectionUpdater = Updater<Collection>;
|
||||||
[Collection[] | ((collection: Collection[]) => Collection[])],
|
export type CollectionsCRUD = {
|
||||||
Promise<void>
|
addCollection: (...collections: Collection[]) => Promise<void>;
|
||||||
>;
|
collections: Collection[];
|
||||||
|
updateCollection: (id: string, updater: CollectionUpdater) => Promise<void>;
|
||||||
|
deleteCollection: (
|
||||||
|
info: DeleteCollectionInfo,
|
||||||
|
...ids: string[]
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
export type CollectionsCRUDAtom = Atom<CollectionsCRUD>;
|
||||||
|
|
||||||
export const useSavedCollections = (collectionAtom: CollectionsAtom) => {
|
export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => {
|
||||||
const [savedCollections, setCollections] = useAtom(collectionAtom);
|
const [{ collections, addCollection, deleteCollection, updateCollection }] =
|
||||||
|
useAtom(collectionAtom);
|
||||||
const saveCollection = useCallback(
|
|
||||||
async (collection: Collection) => {
|
|
||||||
if (collection.id === NIL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await setCollections(old => [...old, collection]);
|
|
||||||
},
|
|
||||||
[setCollections]
|
|
||||||
);
|
|
||||||
const deleteCollection = useCallback(
|
|
||||||
async (id: string) => {
|
|
||||||
if (id === NIL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await setCollections(old => old.filter(v => v.id !== id));
|
|
||||||
},
|
|
||||||
[setCollections]
|
|
||||||
);
|
|
||||||
const addPage = useCallback(
|
const addPage = useCallback(
|
||||||
async (collectionId: string, pageId: string) => {
|
async (collectionId: string, pageId: string) => {
|
||||||
await setCollections(old => {
|
await updateCollection(collectionId, old => {
|
||||||
const collection = old.find(v => v.id === collectionId);
|
if (old.mode === 'page') {
|
||||||
if (!collection) {
|
return {
|
||||||
return old;
|
...old,
|
||||||
|
pages: [pageId, ...(old.pages ?? [])],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return [
|
return {
|
||||||
...old.filter(v => v.id !== collectionId),
|
...old,
|
||||||
{
|
allowList: [pageId, ...(old.allowList ?? [])],
|
||||||
...collection,
|
};
|
||||||
allowList: [pageId, ...(collection.allowList ?? [])],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setCollections]
|
[updateCollection]
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
savedCollections,
|
collections,
|
||||||
saveCollection,
|
addCollection,
|
||||||
|
updateCollection,
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
addPage,
|
addPage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useCollectionManager = (collectionsAtom: CollectionsAtom) => {
|
export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => {
|
||||||
const { savedCollections, saveCollection, deleteCollection, addPage } =
|
const {
|
||||||
useSavedCollections(collectionsAtom);
|
collections,
|
||||||
const [collectionData, setCollectionData] = useAtom(collectionAtom);
|
updateCollection,
|
||||||
|
addCollection,
|
||||||
const updateCollection = useCallback(
|
deleteCollection,
|
||||||
|
addPage,
|
||||||
|
} = useSavedCollections(collectionsAtom);
|
||||||
|
const currentCollectionId = useAtomValue(currentCollectionAtom);
|
||||||
|
const [defaultCollection, updateDefaultCollection] = useAtom(
|
||||||
|
defaultCollectionAtom
|
||||||
|
);
|
||||||
|
const update = useCallback(
|
||||||
async (collection: Collection) => {
|
async (collection: Collection) => {
|
||||||
if (collection.id === NIL) {
|
if (collection.id === NIL) {
|
||||||
setCollectionData({
|
updateDefaultCollection(collection);
|
||||||
...collectionData,
|
|
||||||
defaultCollection: collection,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await saveCollection(collection);
|
await updateCollection(collection.id, () => collection);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[collectionData, saveCollection, setCollectionData]
|
[updateDefaultCollection, updateCollection]
|
||||||
);
|
);
|
||||||
const selectCollection = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
setCollectionData({
|
|
||||||
...collectionData,
|
|
||||||
currentId: id,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[collectionData, setCollectionData]
|
|
||||||
);
|
|
||||||
const backToAll = useCallback(() => {
|
|
||||||
setCollectionData(RESET);
|
|
||||||
}, [setCollectionData]);
|
|
||||||
const setTemporaryFilter = useCallback(
|
const setTemporaryFilter = useCallback(
|
||||||
(filterList: Filter[]) => {
|
(filterList: Filter[]) => {
|
||||||
setCollectionData({
|
updateDefaultCollection({
|
||||||
currentId: NIL,
|
|
||||||
defaultCollection: {
|
|
||||||
...defaultCollection,
|
...defaultCollection,
|
||||||
filterList: filterList,
|
filterList: filterList,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setCollectionData]
|
[updateDefaultCollection, defaultCollection]
|
||||||
);
|
);
|
||||||
const currentCollection =
|
const currentCollection =
|
||||||
collectionData.currentId === NIL
|
currentCollectionId === NIL
|
||||||
? collectionData.defaultCollection
|
? defaultCollection
|
||||||
: savedCollections.find(v => v.id === collectionData.currentId) ??
|
: collections.find(v => v.id === currentCollectionId) ??
|
||||||
collectionData.defaultCollection;
|
defaultCollection;
|
||||||
return {
|
return {
|
||||||
currentCollection: currentCollection,
|
currentCollection: currentCollection,
|
||||||
savedCollections,
|
savedCollections: collections,
|
||||||
isDefault: currentCollection.id === NIL,
|
isDefault: currentCollectionId === NIL,
|
||||||
|
|
||||||
// actions
|
// actions
|
||||||
saveCollection,
|
createCollection: addCollection,
|
||||||
updateCollection,
|
updateCollection: update,
|
||||||
selectCollection,
|
|
||||||
backToAll,
|
|
||||||
deleteCollection,
|
deleteCollection,
|
||||||
addPage,
|
addPage,
|
||||||
setTemporaryFilter,
|
setTemporaryFilter,
|
||||||
@ -139,3 +126,25 @@ export const useCollectionManager = (collectionsAtom: CollectionsAtom) => {
|
|||||||
};
|
};
|
||||||
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
||||||
evalFilterList(filterList, varMap);
|
evalFilterList(filterList, varMap);
|
||||||
|
|
||||||
|
export const filterPage = (collection: Collection, page: PageMeta) => {
|
||||||
|
if (collection.mode === 'page') {
|
||||||
|
return collection.pages.includes(page.id);
|
||||||
|
}
|
||||||
|
return filterPageByRules(collection.filterList, collection.allowList, page);
|
||||||
|
};
|
||||||
|
export const filterPageByRules = (
|
||||||
|
rules: Filter[],
|
||||||
|
allowList: string[],
|
||||||
|
page: PageMeta
|
||||||
|
) => {
|
||||||
|
if (allowList?.includes(page.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return filterByFilterList(rules, {
|
||||||
|
'Is Favourited': !!page.favorite,
|
||||||
|
Created: page.createDate,
|
||||||
|
Updated: page.updatedDate ?? page.createDate,
|
||||||
|
Tags: page.tags,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
@ -1,65 +0,0 @@
|
|||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
|
|
||||||
import type { DateKey, ListData } from './type';
|
|
||||||
import {
|
|
||||||
isLastMonth,
|
|
||||||
isLastWeek,
|
|
||||||
isLastYear,
|
|
||||||
isToday,
|
|
||||||
isYesterday,
|
|
||||||
} from './utils';
|
|
||||||
|
|
||||||
export const useDateGroup = ({
|
|
||||||
data,
|
|
||||||
key,
|
|
||||||
}: {
|
|
||||||
data: ListData[];
|
|
||||||
key?: DateKey;
|
|
||||||
}) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
if (!key) {
|
|
||||||
return data.map(item => ({ ...item, groupName: '' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackGroup = {
|
|
||||||
id: 'earlier',
|
|
||||||
label: t['com.affine.earlier'](),
|
|
||||||
match: (_date: Date) => true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const groups = [
|
|
||||||
{
|
|
||||||
id: 'today',
|
|
||||||
label: t['com.affine.today'](),
|
|
||||||
match: (date: Date) => isToday(date),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'yesterday',
|
|
||||||
label: t['com.affine.yesterday'](),
|
|
||||||
match: (date: Date) => isYesterday(date) && !isToday(date),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'last7Days',
|
|
||||||
label: t['com.affine.last7Days'](),
|
|
||||||
match: (date: Date) => isLastWeek(date) && !isYesterday(date),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'last30Days',
|
|
||||||
label: t['com.affine.last30Days'](),
|
|
||||||
match: (date: Date) => isLastMonth(date) && !isLastWeek(date),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'currentYear',
|
|
||||||
label: t['com.affine.currentYear'](),
|
|
||||||
match: (date: Date) => isLastYear(date) && !isLastMonth(date),
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
return data.map(item => {
|
|
||||||
const group = groups.find(group => group.match(item[key])) ?? fallbackGroup;
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
groupName: group.label,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
@ -1,4 +1,12 @@
|
|||||||
import { useMediaQuery, useTheme } from '@mui/material';
|
import { useMediaQuery, useTheme } from '@mui/material';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import {
|
||||||
|
type BaseSyntheticEvent,
|
||||||
|
forwardRef,
|
||||||
|
type PropsWithChildren,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import * as styles from './page-list.css';
|
||||||
|
|
||||||
export const useIsSmallDevices = () => {
|
export const useIsSmallDevices = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@ -69,3 +77,71 @@ export const formatDate = (date: Date): string => {
|
|||||||
// MM-DD HH:mm
|
// MM-DD HH:mm
|
||||||
return `${month}-${day} ${hours}:${minutes}`;
|
return `${month}-${day} ${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ColWrapperProps = PropsWithChildren<{
|
||||||
|
flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||||
|
alignment?: 'start' | 'center' | 'end';
|
||||||
|
styles?: React.CSSProperties;
|
||||||
|
hideInSmallContainer?: boolean;
|
||||||
|
}> &
|
||||||
|
React.HTMLAttributes<Element>;
|
||||||
|
|
||||||
|
export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||||
|
function ColWrapper(
|
||||||
|
{
|
||||||
|
flex,
|
||||||
|
alignment,
|
||||||
|
hideInSmallContainer,
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: ColWrapperProps,
|
||||||
|
ref
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
data-testid="page-list-flex-wrapper"
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
flexGrow: flex,
|
||||||
|
flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto',
|
||||||
|
justifyContent: alignment,
|
||||||
|
}}
|
||||||
|
className={clsx(
|
||||||
|
className,
|
||||||
|
styles.colWrapper,
|
||||||
|
hideInSmallContainer ? styles.hideInSmallContainer : null
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const withinDaysAgo = (date: Date, days: number): boolean => {
|
||||||
|
const startDate = new Date();
|
||||||
|
const day = startDate.getDay();
|
||||||
|
const month = startDate.getMonth();
|
||||||
|
const year = startDate.getFullYear();
|
||||||
|
return new Date(year, month, day - days) <= date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const betweenDaysAgo = (
|
||||||
|
date: Date,
|
||||||
|
days0: number,
|
||||||
|
days1: number
|
||||||
|
): boolean => {
|
||||||
|
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function stopPropagation(event: BaseSyntheticEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
@ -0,0 +1,133 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
|
||||||
|
export const AffineShapeIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||||
|
<svg
|
||||||
|
width="200"
|
||||||
|
height="174"
|
||||||
|
viewBox="0 0 200 174"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<rect width="200" height="173.475" fill="white" />
|
||||||
|
<rect
|
||||||
|
x="51.7242"
|
||||||
|
y="38.4618"
|
||||||
|
width="96.5517"
|
||||||
|
height="96.5517"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M51.8341 86.7377L100 38.5717L148.166 86.7377L100 134.904L51.8341 86.7377Z"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M99.6055 38.1965C107.662 33.4757 117.043 30.7695 127.056 30.7695C157.087 30.7695 181.432 55.1147 181.432 85.1461C181.432 107.547 167.887 126.783 148.541 135.113"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M148.375 86.4724C153.096 94.5294 155.802 103.91 155.802 113.923C155.802 143.954 131.457 168.299 101.426 168.299C79.0252 168.299 59.7883 154.754 51.4585 135.408"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M100.395 135.113C92.3376 139.834 82.957 142.54 72.9444 142.54C42.913 142.54 18.5677 118.195 18.5677 88.1636C18.5677 65.7632 32.1126 46.5264 51.459 38.1965"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M51.4588 87.1319C46.7379 79.0749 44.0317 69.6944 44.0317 59.6818C44.0317 29.6504 68.377 5.3051 98.4084 5.30509C120.809 5.30509 140.046 18.85 148.375 38.1963"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M51.459 38.1965L148.541 135.279"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M148.541 38.1965L51.459 135.279"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M99.9995 38.1965V135.279"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M148.541 86.7376L51.4588 86.7376"
|
||||||
|
stroke="#D2D2D2"
|
||||||
|
strokeWidth="0.530504"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="148.276"
|
||||||
|
cy="38.4618"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="148.276"
|
||||||
|
cy="135.014"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="148.276"
|
||||||
|
cy="86.7377"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="51.7239"
|
||||||
|
cy="38.4618"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="51.7239"
|
||||||
|
cy="135.014"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="51.7239"
|
||||||
|
cy="86.7377"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="99.9998"
|
||||||
|
cy="38.4618"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
transform="rotate(-90 99.9998 38.4618)"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="99.9998"
|
||||||
|
cy="86.2071"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
transform="rotate(-90 99.9998 86.2071)"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="99.9998"
|
||||||
|
cy="135.014"
|
||||||
|
rx="3.97878"
|
||||||
|
ry="3.97878"
|
||||||
|
transform="rotate(-90 99.9998 135.014)"
|
||||||
|
fill="#5B5B5B"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
@ -1,7 +1,5 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
import { viewMenu } from './collection-list.css';
|
|
||||||
|
|
||||||
export const view = style({
|
export const view = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@ -9,7 +7,6 @@ export const view = style({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
paddingLeft: 16,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const option = style({
|
export const option = style({
|
||||||
@ -29,28 +26,3 @@ export const option = style({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const pin = style({
|
|
||||||
opacity: 1,
|
|
||||||
});
|
|
||||||
export const pinedIcon = style({
|
|
||||||
display: 'block',
|
|
||||||
selectors: {
|
|
||||||
[`${option}:hover &`]: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
[`${viewMenu}:hover &`]: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const pinIcon = style({
|
|
||||||
display: 'none',
|
|
||||||
selectors: {
|
|
||||||
[`${option}:hover &`]: {
|
|
||||||
display: 'block',
|
|
||||||
},
|
|
||||||
[`${viewMenu}:hover &`]: {
|
|
||||||
display: 'block',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
import type { DeleteCollectionInfo, PropertiesMeta } from '@affine/env/filter';
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { ViewLayersIcon } from '@blocksuite/icons';
|
import { ViewLayersIcon } from '@blocksuite/icons';
|
||||||
@ -8,22 +8,24 @@ import clsx from 'clsx';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type CollectionsAtom,
|
type CollectionsCRUDAtom,
|
||||||
useCollectionManager,
|
useCollectionManager,
|
||||||
} from '../use-collection-manager';
|
} from '../use-collection-manager';
|
||||||
import * as styles from './collection-bar.css';
|
import * as styles from './collection-bar.css';
|
||||||
import { EditCollectionModal } from './create-collection';
|
import { type AllPageListConfig, EditCollectionModal } from './edit-collection';
|
||||||
import { useActions } from './use-action';
|
import { useActions } from './use-action';
|
||||||
|
|
||||||
interface CollectionBarProps {
|
interface CollectionBarProps {
|
||||||
getPageInfo: GetPageInfoById;
|
getPageInfo: GetPageInfoById;
|
||||||
propertiesMeta: PropertiesMeta;
|
propertiesMeta: PropertiesMeta;
|
||||||
collectionsAtom: CollectionsAtom;
|
collectionsAtom: CollectionsCRUDAtom;
|
||||||
columnsCount: number;
|
backToAll: () => void;
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
info: DeleteCollectionInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CollectionBar = (props: CollectionBarProps) => {
|
export const CollectionBar = (props: CollectionBarProps) => {
|
||||||
const { getPageInfo, propertiesMeta, columnsCount, collectionsAtom } = props;
|
const { collectionsAtom } = props;
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const setting = useCollectionManager(collectionsAtom);
|
const setting = useCollectionManager(collectionsAtom);
|
||||||
const collection = setting.currentCollection;
|
const collection = setting.currentCollection;
|
||||||
@ -31,16 +33,23 @@ export const CollectionBar = (props: CollectionBarProps) => {
|
|||||||
const actions = useActions({
|
const actions = useActions({
|
||||||
collection,
|
collection,
|
||||||
setting,
|
setting,
|
||||||
|
info: props.info,
|
||||||
openEdit: () => setOpen(true),
|
openEdit: () => setOpen(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
return !setting.isDefault ? (
|
return !setting.isDefault ? (
|
||||||
<tr style={{ userSelect: 'none' }}>
|
<div
|
||||||
<td>
|
style={{
|
||||||
|
userSelect: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
<div className={styles.view}>
|
<div className={styles.view}>
|
||||||
<EditCollectionModal
|
<EditCollectionModal
|
||||||
propertiesMeta={propertiesMeta}
|
allPageListConfig={props.allPageListConfig}
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
init={collection}
|
init={collection}
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
@ -84,11 +93,8 @@ export const CollectionBar = (props: CollectionBarProps) => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
{Array.from({ length: columnsCount - 2 }).map((_, i) => (
|
<div
|
||||||
<td key={i}></td>
|
|
||||||
))}
|
|
||||||
<td
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'end',
|
justifyContent: 'end',
|
||||||
@ -96,11 +102,11 @@ export const CollectionBar = (props: CollectionBarProps) => {
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
style={{ border: 'none', position: 'static' }}
|
style={{ border: 'none', position: 'static' }}
|
||||||
onClick={() => setting.backToAll()}
|
onClick={props.backToAll}
|
||||||
>
|
>
|
||||||
{t['com.affine.collectionBar.backToAll']()}
|
{t['com.affine.collectionBar.backToAll']()}
|
||||||
</Button>
|
</Button>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const menuTitleStyle = style({
|
export const menuTitleStyle = style({
|
||||||
marginLeft: '12px',
|
marginLeft: '12px',
|
||||||
@ -14,30 +14,6 @@ export const menuDividerStyle = style({
|
|||||||
height: '1px',
|
height: '1px',
|
||||||
background: 'var(--affine-border-color)',
|
background: 'var(--affine-border-color)',
|
||||||
});
|
});
|
||||||
export const viewButton = style({
|
|
||||||
borderRadius: '8px',
|
|
||||||
height: '100%',
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: 'var(--affine-font-xs)',
|
|
||||||
background: 'var(--affine-white)',
|
|
||||||
['WebkitAppRegion' as string]: 'no-drag',
|
|
||||||
maxWidth: '150px',
|
|
||||||
color: 'var(--affine-text-secondary-color)',
|
|
||||||
border: '1px solid var(--affine-border-color)',
|
|
||||||
transition: 'margin-left 0.2s ease-in-out',
|
|
||||||
':hover': {
|
|
||||||
borderColor: 'var(--affine-border-color)',
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
},
|
|
||||||
marginRight: '20px',
|
|
||||||
});
|
|
||||||
globalStyle(`${viewButton} > span`, {
|
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
});
|
|
||||||
export const viewMenu = style({});
|
export const viewMenu = style({});
|
||||||
export const viewOption = style({
|
export const viewOption = style({
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
@ -57,171 +33,9 @@ export const viewOption = style({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const deleteOption = style({
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: '#FFEFE9',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const filterButton = style({
|
|
||||||
borderRadius: '8px',
|
|
||||||
height: '100%',
|
|
||||||
width: '100%',
|
|
||||||
marginRight: '20px',
|
|
||||||
padding: '4px 8px',
|
|
||||||
fontSize: 'var(--affine-font-xs)',
|
|
||||||
background: 'var(--affine-white)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
color: 'var(--affine-text-secondary-color)',
|
|
||||||
border: '1px solid var(--affine-border-color)',
|
|
||||||
['WebkitAppRegion' as string]: 'no-drag',
|
|
||||||
transition: 'margin-left 0.2s ease-in-out',
|
|
||||||
':hover': {
|
|
||||||
borderColor: 'var(--affine-border-color)',
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const filterButtonCollapse = style({
|
|
||||||
marginLeft: '20px',
|
|
||||||
});
|
|
||||||
export const viewDivider = style({
|
|
||||||
'::after': {
|
|
||||||
content: '""',
|
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
height: '1px',
|
|
||||||
background: 'var(--affine-border-color)',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
margin: '0 1px',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const saveButton = style({
|
|
||||||
marginTop: '4px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '8px 0',
|
|
||||||
':hover': {
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
border: '1px solid var(--affine-border-color)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const saveButtonContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
cursor: 'pointer',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
padding: '8px',
|
|
||||||
});
|
|
||||||
export const saveIcon = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
fontSize: 'var(--affine-font-sm)',
|
|
||||||
marginRight: '8px',
|
|
||||||
});
|
|
||||||
export const saveText = style({
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
fontSize: 'var(--affine-font-sm)',
|
|
||||||
});
|
|
||||||
export const cancelButton = style({
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
borderRadius: '8px',
|
|
||||||
':hover': {
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
border: '1px solid var(--affine-border-color)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const saveTitle = style({
|
|
||||||
fontSize: 'var(--affine-font-h-6)',
|
|
||||||
fontWeight: '600',
|
|
||||||
lineHeight: '24px',
|
|
||||||
paddingBottom: 20,
|
|
||||||
});
|
|
||||||
export const allowList = style({});
|
|
||||||
|
|
||||||
export const allowTitle = style({
|
|
||||||
fontSize: 12,
|
|
||||||
margin: '20px 0',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const allowListContent = style({
|
|
||||||
margin: '8px 0',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const excludeList = style({
|
|
||||||
backgroundColor: 'var(--affine-background-warning-color)',
|
|
||||||
padding: 18,
|
|
||||||
borderRadius: 8,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const excludeListContent = style({
|
|
||||||
margin: '8px 0',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const filterTitle = style({
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
marginBottom: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const excludeTitle = style({
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const excludeTip = style({
|
|
||||||
color: 'var(--affine-text-secondary-color)',
|
|
||||||
fontSize: 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const scrollContainer = style({
|
|
||||||
maxHeight: '70vh',
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
});
|
|
||||||
export const container = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
});
|
|
||||||
export const pageContainer = style({
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 600,
|
|
||||||
height: 32,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingLeft: 8,
|
|
||||||
paddingRight: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pageIcon = style({
|
|
||||||
marginRight: 20,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const pageTitle = style({
|
|
||||||
flex: 1,
|
|
||||||
});
|
|
||||||
export const deleteIcon = style({
|
|
||||||
marginLeft: 20,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: 4,
|
|
||||||
padding: 4,
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': {
|
|
||||||
color: 'var(--affine-error-color)',
|
|
||||||
backgroundColor: 'var(--affine-background-error-color)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const filterMenuTrigger = style({
|
export const filterMenuTrigger = style({
|
||||||
padding: '6px 8px',
|
padding: '6px 8px',
|
||||||
background: 'var(--affine-hover-color)',
|
':hover': {
|
||||||
|
backgroundColor: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
@ -1,114 +1,32 @@
|
|||||||
import type { Collection, Filter } from '@affine/env/filter';
|
import type {
|
||||||
|
Collection,
|
||||||
|
DeleteCollectionInfo,
|
||||||
|
Filter,
|
||||||
|
} from '@affine/env/filter';
|
||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
import type { PropertiesMeta } from '@affine/env/filter';
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { FilteredIcon, FolderIcon, ViewLayersIcon } from '@blocksuite/icons';
|
import { FilteredIcon } from '@blocksuite/icons';
|
||||||
import { Button } from '@toeverything/components/button';
|
import { Button } from '@toeverything/components/button';
|
||||||
import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu';
|
import { Menu } from '@toeverything/components/menu';
|
||||||
import { Tooltip } from '@toeverything/components/tooltip';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import type { MouseEvent } from 'react';
|
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
import { FlexWrapper } from '../../../ui/layout';
|
import { FlexWrapper } from '../../../ui/layout';
|
||||||
import { CreateFilterMenu } from '../filter/vars';
|
import { CreateFilterMenu } from '../filter/vars';
|
||||||
import type { useCollectionManager } from '../use-collection-manager';
|
import type { useCollectionManager } from '../use-collection-manager';
|
||||||
import * as styles from './collection-list.css';
|
import * as styles from './collection-list.css';
|
||||||
import { EditCollectionModal } from './create-collection';
|
import { CollectionOperations } from './collection-operations';
|
||||||
import { useActions } from './use-action';
|
import { type AllPageListConfig, EditCollectionModal } from './edit-collection';
|
||||||
|
|
||||||
const CollectionOption = ({
|
|
||||||
collection,
|
|
||||||
setting,
|
|
||||||
updateCollection,
|
|
||||||
}: {
|
|
||||||
collection: Collection;
|
|
||||||
setting: ReturnType<typeof useCollectionManager>;
|
|
||||||
updateCollection: (view: Collection) => void;
|
|
||||||
}) => {
|
|
||||||
const actions = useActions({
|
|
||||||
collection,
|
|
||||||
setting,
|
|
||||||
openEdit: updateCollection,
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectCollection = useCallback(
|
|
||||||
() => setting.selectCollection(collection.id),
|
|
||||||
[setting, collection.id]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
data-testid="collection-select-option"
|
|
||||||
preFix={
|
|
||||||
<MenuIcon>
|
|
||||||
<ViewLayersIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
}
|
|
||||||
onClick={selectCollection}
|
|
||||||
key={collection.id}
|
|
||||||
className={styles.viewMenu}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
content={collection.name}
|
|
||||||
side="right"
|
|
||||||
rootOptions={{
|
|
||||||
delayDuration: 1500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
maxWidth: '150px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collection.name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{actions.map((action, i) => {
|
|
||||||
const onClick = (e: MouseEvent<HTMLDivElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
action.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-testid={`collection-select-option-${action.name}`}
|
|
||||||
key={i}
|
|
||||||
onClick={onClick}
|
|
||||||
style={{ marginLeft: i === 0 ? 28 : undefined }}
|
|
||||||
className={clsx(styles.viewOption, action.className)}
|
|
||||||
>
|
|
||||||
{action.icon}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export const CollectionList = ({
|
export const CollectionList = ({
|
||||||
setting,
|
setting,
|
||||||
getPageInfo,
|
|
||||||
propertiesMeta,
|
propertiesMeta,
|
||||||
|
allPageListConfig,
|
||||||
|
userInfo,
|
||||||
}: {
|
}: {
|
||||||
setting: ReturnType<typeof useCollectionManager>;
|
setting: ReturnType<typeof useCollectionManager>;
|
||||||
getPageInfo: GetPageInfoById;
|
|
||||||
propertiesMeta: PropertiesMeta;
|
propertiesMeta: PropertiesMeta;
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
userInfo: DeleteCollectionInfo;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [collection, setCollection] = useState<Collection>();
|
const [collection, setCollection] = useState<Collection>();
|
||||||
@ -140,57 +58,8 @@ export const CollectionList = ({
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<FlexWrapper alignItems="center">
|
<FlexWrapper alignItems="center">
|
||||||
{setting.savedCollections.length > 0 && (
|
{setting.isDefault ? (
|
||||||
<Menu
|
<>
|
||||||
items={
|
|
||||||
<div style={{ minWidth: 150 }}>
|
|
||||||
<MenuItem
|
|
||||||
preFix={
|
|
||||||
<MenuIcon>
|
|
||||||
<FolderIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
}
|
|
||||||
onClick={setting.backToAll}
|
|
||||||
className={styles.viewMenu}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>All</div>
|
|
||||||
</div>
|
|
||||||
</MenuItem>
|
|
||||||
<div className={styles.menuTitleStyle}>Saved Collection</div>
|
|
||||||
<div className={styles.menuDividerStyle}></div>
|
|
||||||
{setting.savedCollections.map(view => (
|
|
||||||
<CollectionOption
|
|
||||||
key={view.id}
|
|
||||||
collection={view}
|
|
||||||
setting={setting}
|
|
||||||
updateCollection={setCollection}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
data-testid="collection-select"
|
|
||||||
style={{ marginRight: '20px' }}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
content={setting.currentCollection.name}
|
|
||||||
rootOptions={{
|
|
||||||
delayDuration: 1500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<>{setting.currentCollection.name}</>
|
|
||||||
</Tooltip>
|
|
||||||
</Button>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
<Menu
|
<Menu
|
||||||
items={
|
items={
|
||||||
<CreateFilterMenu
|
<CreateFilterMenu
|
||||||
@ -210,13 +79,30 @@ export const CollectionList = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</Menu>
|
</Menu>
|
||||||
<EditCollectionModal
|
<EditCollectionModal
|
||||||
propertiesMeta={propertiesMeta}
|
allPageListConfig={allPageListConfig}
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
init={collection}
|
init={collection}
|
||||||
open={!!collection}
|
open={!!collection}
|
||||||
onOpenChange={closeUpdateCollectionModal}
|
onOpenChange={closeUpdateCollectionModal}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<CollectionOperations
|
||||||
|
info={userInfo}
|
||||||
|
collection={setting.currentCollection}
|
||||||
|
config={allPageListConfig}
|
||||||
|
setting={setting}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
className={styles.filterMenuTrigger}
|
||||||
|
type="default"
|
||||||
|
icon={<FilteredIcon />}
|
||||||
|
data-testid="create-first-filter"
|
||||||
|
>
|
||||||
|
{t['com.affine.filter']()}
|
||||||
|
</Button>
|
||||||
|
</CollectionOperations>
|
||||||
|
)}
|
||||||
</FlexWrapper>
|
</FlexWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,10 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const divider = style({
|
||||||
|
marginTop: '2px',
|
||||||
|
marginBottom: '2px',
|
||||||
|
marginLeft: '12px',
|
||||||
|
marginRight: '8px',
|
||||||
|
height: '1px',
|
||||||
|
background: 'var(--affine-border-color)',
|
||||||
|
});
|
@ -0,0 +1,145 @@
|
|||||||
|
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { DeleteIcon, EditIcon, FilterIcon } from '@blocksuite/icons';
|
||||||
|
import {
|
||||||
|
Menu,
|
||||||
|
MenuIcon,
|
||||||
|
MenuItem,
|
||||||
|
type MenuItemProps,
|
||||||
|
} from '@toeverything/components/menu';
|
||||||
|
import {
|
||||||
|
type PropsWithChildren,
|
||||||
|
type ReactElement,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import type { useCollectionManager } from '../use-collection-manager';
|
||||||
|
import type { AllPageListConfig } from '.';
|
||||||
|
import * as styles from './collection-operations.css';
|
||||||
|
import {
|
||||||
|
useEditCollection,
|
||||||
|
useEditCollectionName,
|
||||||
|
} from './use-edit-collection';
|
||||||
|
|
||||||
|
export const CollectionOperations = ({
|
||||||
|
collection,
|
||||||
|
config,
|
||||||
|
setting,
|
||||||
|
info,
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<{
|
||||||
|
info: DeleteCollectionInfo;
|
||||||
|
collection: Collection;
|
||||||
|
config: AllPageListConfig;
|
||||||
|
setting: ReturnType<typeof useCollectionManager>;
|
||||||
|
}>) => {
|
||||||
|
const { open: openEditCollectionModal, node: editModal } =
|
||||||
|
useEditCollection(config);
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const { open: openEditCollectionNameModal, node: editNameModal } =
|
||||||
|
useEditCollectionName({
|
||||||
|
title: t['com.affine.editCollection.renameCollection'](),
|
||||||
|
});
|
||||||
|
const showEditName = useCallback(() => {
|
||||||
|
openEditCollectionNameModal(collection.name)
|
||||||
|
.then(name => {
|
||||||
|
return setting.updateCollection({ ...collection, name });
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}, [openEditCollectionNameModal, collection, setting]);
|
||||||
|
const showEdit = useCallback(() => {
|
||||||
|
openEditCollectionModal(collection)
|
||||||
|
.then(collection => {
|
||||||
|
return setting.updateCollection(collection);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}, [setting, collection, openEditCollectionModal]);
|
||||||
|
const actions = useMemo<
|
||||||
|
Array<
|
||||||
|
| {
|
||||||
|
icon: ReactElement;
|
||||||
|
name: string;
|
||||||
|
click: () => void;
|
||||||
|
type?: MenuItemProps['type'];
|
||||||
|
element?: undefined;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
element: ReactElement;
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<MenuIcon>
|
||||||
|
<EditIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
),
|
||||||
|
name: t['com.affine.collection.menu.rename'](),
|
||||||
|
click: showEditName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<MenuIcon>
|
||||||
|
<FilterIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
),
|
||||||
|
name: t['com.affine.collection.menu.edit'](),
|
||||||
|
click: showEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: <div key="divider" className={styles.divider}></div>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: (
|
||||||
|
<MenuIcon>
|
||||||
|
<DeleteIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
),
|
||||||
|
name: t['Delete'](),
|
||||||
|
click: () => {
|
||||||
|
setting.deleteCollection(info, collection.id).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
type: 'danger',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t, showEditName, showEdit, setting, info, collection.id]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{editModal}
|
||||||
|
{editNameModal}
|
||||||
|
<Menu
|
||||||
|
items={
|
||||||
|
<div style={{ minWidth: 150 }}>
|
||||||
|
{actions.map(action => {
|
||||||
|
if (action.element) {
|
||||||
|
return action.element;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<MenuItem
|
||||||
|
data-testid="collection-option"
|
||||||
|
key={action.name}
|
||||||
|
type={action.type}
|
||||||
|
preFix={action.icon}
|
||||||
|
onClick={action.click}
|
||||||
|
>
|
||||||
|
{action.name}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,28 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const footer = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
paddingTop: 20,
|
||||||
|
gap: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createTips = style({
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '20px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const label = style({
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: '22px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: '12px 0px 20px',
|
||||||
|
marginBottom: 8,
|
||||||
|
});
|
@ -1,45 +1,40 @@
|
|||||||
import type { Collection, Filter } from '@affine/env/filter';
|
|
||||||
import type { PropertiesMeta } from '@affine/env/filter';
|
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
import {
|
import {
|
||||||
EdgelessIcon,
|
createEmptyCollection,
|
||||||
PageIcon,
|
useEditCollectionName,
|
||||||
RemoveIcon,
|
} from '@affine/component/page-list';
|
||||||
SaveIcon,
|
import type { Collection } from '@affine/env/filter';
|
||||||
} from '@blocksuite/icons';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { SaveIcon } from '@blocksuite/icons';
|
||||||
import { Button } from '@toeverything/components/button';
|
import { Button } from '@toeverything/components/button';
|
||||||
import { Modal } from '@toeverything/components/modal';
|
import { Modal } from '@toeverything/components/modal';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { Input, ScrollableContainer } from '../../..';
|
import Input from '../../../ui/input';
|
||||||
import { FilterList } from '../filter';
|
import * as styles from './create-collection.css';
|
||||||
import * as styles from './collection-list.css';
|
|
||||||
|
|
||||||
interface EditCollectionModalProps {
|
export interface CreateCollectionModalProps {
|
||||||
init?: Collection;
|
|
||||||
title?: string;
|
title?: string;
|
||||||
|
onConfirmText?: string;
|
||||||
|
init: string;
|
||||||
|
onConfirm: (title: string) => Promise<void>;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
getPageInfo: GetPageInfoById;
|
showTips?: boolean;
|
||||||
propertiesMeta: PropertiesMeta;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
onConfirm: (view: Collection) => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditCollectionModal = ({
|
export const CreateCollectionModal = ({
|
||||||
init,
|
init,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
open,
|
open,
|
||||||
|
showTips,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
getPageInfo,
|
|
||||||
propertiesMeta,
|
|
||||||
title,
|
title,
|
||||||
}: EditCollectionModalProps) => {
|
}: CreateCollectionModalProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const onConfirmOnCollection = useCallback(
|
const onConfirmTitle = useCallback(
|
||||||
(view: Collection) => {
|
(title: string) => {
|
||||||
onConfirm(view)
|
onConfirm(title)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
})
|
})
|
||||||
@ -54,206 +49,73 @@ export const EditCollectionModal = ({
|
|||||||
}, [onOpenChange]);
|
}, [onOpenChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal open={open} title={title} onOpenChange={onOpenChange} width={480}>
|
||||||
open={open}
|
{init != null ? (
|
||||||
onOpenChange={onOpenChange}
|
<CreateCollection
|
||||||
width={600}
|
showTips={showTips}
|
||||||
contentOptions={{
|
|
||||||
style: { padding: '40px' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{init ? (
|
|
||||||
<EditCollection
|
|
||||||
propertiesMeta={propertiesMeta}
|
|
||||||
title={title}
|
|
||||||
onConfirmText={t['com.affine.editCollection.save']()}
|
onConfirmText={t['com.affine.editCollection.save']()}
|
||||||
init={init}
|
init={init}
|
||||||
getPageInfo={getPageInfo}
|
onConfirm={onConfirmTitle}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirmOnCollection}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PageProps {
|
export interface CreateCollectionProps {
|
||||||
id: string;
|
|
||||||
getPageInfo: GetPageInfoById;
|
|
||||||
onClick: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Page = ({ id, onClick, getPageInfo }: PageProps) => {
|
|
||||||
const page = getPageInfo(id);
|
|
||||||
const handleClick = useCallback(() => onClick(id), [id, onClick]);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{page ? (
|
|
||||||
<div className={styles.pageContainer}>
|
|
||||||
<div className={styles.pageIcon}>
|
|
||||||
{page.isEdgeless ? (
|
|
||||||
<EdgelessIcon style={{ width: 17.5, height: 17.5 }} />
|
|
||||||
) : (
|
|
||||||
<PageIcon style={{ width: 17.5, height: 17.5 }} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className={styles.pageTitle}>{page.title}</div>
|
|
||||||
<div onClick={handleClick} className={styles.deleteIcon}>
|
|
||||||
<RemoveIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EditCollectionProps {
|
|
||||||
title?: string;
|
|
||||||
onConfirmText?: string;
|
onConfirmText?: string;
|
||||||
init: Collection;
|
init: string;
|
||||||
getPageInfo: GetPageInfoById;
|
showTips?: boolean;
|
||||||
propertiesMeta: PropertiesMeta;
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: (collection: Collection) => void;
|
onConfirm: (title: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditCollection = ({
|
export const CreateCollection = ({
|
||||||
title,
|
|
||||||
init,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
onConfirmText,
|
onConfirmText,
|
||||||
getPageInfo,
|
init,
|
||||||
propertiesMeta,
|
showTips,
|
||||||
}: EditCollectionProps) => {
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: CreateCollectionProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [value, onChange] = useState<Collection>(init);
|
const [value, onChange] = useState(init);
|
||||||
const removeFromExcludeList = useCallback(
|
const isNameEmpty = useMemo(() => value.trim().length === 0, [value]);
|
||||||
(id: string) => {
|
const save = useCallback(() => {
|
||||||
onChange({
|
if (isNameEmpty) {
|
||||||
...value,
|
return;
|
||||||
excludeList: value.excludeList?.filter(v => v !== id),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[value]
|
|
||||||
);
|
|
||||||
const removeFromAllowList = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
onChange({
|
|
||||||
...value,
|
|
||||||
allowList: value.allowList?.filter(v => v !== id),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[value]
|
|
||||||
);
|
|
||||||
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
|
|
||||||
const onSaveCollection = useCallback(() => {
|
|
||||||
if (!isNameEmpty) {
|
|
||||||
onConfirm(value);
|
|
||||||
}
|
}
|
||||||
}, [value, isNameEmpty, onConfirm]);
|
onConfirm(value);
|
||||||
|
}, [onConfirm, value, isNameEmpty]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div>
|
||||||
style={{
|
<div className={styles.content}>
|
||||||
maxHeight: '90vh',
|
<div className={styles.label}>Name</div>
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.saveTitle}>
|
|
||||||
{title ?? t['com.affine.editCollection.updateCollection']()}
|
|
||||||
</div>
|
|
||||||
<ScrollableContainer
|
|
||||||
className={styles.scrollContainer}
|
|
||||||
viewPortClassName={styles.container}
|
|
||||||
>
|
|
||||||
{value.excludeList?.length ? (
|
|
||||||
<div className={styles.excludeList}>
|
|
||||||
<div className={styles.excludeTitle}>
|
|
||||||
Exclude from this collection
|
|
||||||
</div>
|
|
||||||
<div className={styles.excludeListContent}>
|
|
||||||
{value.excludeList.map(id => {
|
|
||||||
return (
|
|
||||||
<Page
|
|
||||||
id={id}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
key={id}
|
|
||||||
onClick={removeFromExcludeList}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className={styles.excludeTip}>
|
|
||||||
These pages will never appear in the current collection
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: 'var(--affine-hover-color)',
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 18,
|
|
||||||
marginTop: 20,
|
|
||||||
minHeight: '200px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={styles.filterTitle}>
|
|
||||||
{t['com.affine.editCollection.filters']()}
|
|
||||||
</div>
|
|
||||||
<FilterList
|
|
||||||
propertiesMeta={propertiesMeta}
|
|
||||||
value={value.filterList}
|
|
||||||
onChange={filterList => onChange({ ...value, filterList })}
|
|
||||||
/>
|
|
||||||
{value.allowList ? (
|
|
||||||
<div className={styles.allowList}>
|
|
||||||
<div className={styles.allowTitle}>With follow pages:</div>
|
|
||||||
<div className={styles.allowListContent}>
|
|
||||||
{value.allowList.map(id => {
|
|
||||||
return (
|
|
||||||
<Page
|
|
||||||
key={id}
|
|
||||||
id={id}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
onClick={removeFromAllowList}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: 20 }}>
|
|
||||||
<Input
|
<Input
|
||||||
size="large"
|
autoFocus
|
||||||
|
value={value}
|
||||||
data-testid="input-collection-title"
|
data-testid="input-collection-title"
|
||||||
placeholder={t['com.affine.editCollection.untitledCollection']()}
|
placeholder="Collection Name"
|
||||||
defaultValue={value.name}
|
onChange={useCallback((value: string) => onChange(value), [onChange])}
|
||||||
onChange={name => onChange({ ...value, name })}
|
onEnter={save}
|
||||||
onEnter={onSaveCollection}
|
></Input>
|
||||||
/>
|
{showTips ? (
|
||||||
|
<div className={styles.createTips}>
|
||||||
|
Collection is a smart folder where you can manually add pages or
|
||||||
|
automatically add pages through rules.
|
||||||
</div>
|
</div>
|
||||||
</ScrollableContainer>
|
) : null}
|
||||||
<div
|
</div>
|
||||||
style={{
|
<div className={styles.footer}>
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'flex-end',
|
|
||||||
marginTop: 40,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button size="large" onClick={onCancel}>
|
<Button size="large" onClick={onCancel}>
|
||||||
{t['com.affine.editCollection.button.cancel']()}
|
{t['com.affine.editCollection.button.cancel']()}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
style={{
|
|
||||||
marginLeft: 20,
|
|
||||||
}}
|
|
||||||
size="large"
|
size="large"
|
||||||
data-testid="save-collection"
|
data-testid="save-collection"
|
||||||
type="primary"
|
type="primary"
|
||||||
disabled={isNameEmpty}
|
disabled={isNameEmpty}
|
||||||
onClick={onSaveCollection}
|
onClick={save}
|
||||||
>
|
>
|
||||||
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
|
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
|
||||||
</Button>
|
</Button>
|
||||||
@ -262,33 +124,27 @@ export const EditCollection = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface SaveCollectionButtonProps {
|
interface SaveAsCollectionButtonProps {
|
||||||
getPageInfo: GetPageInfoById;
|
|
||||||
propertiesMeta: PropertiesMeta;
|
|
||||||
filterList: Filter[];
|
|
||||||
workspaceId: string;
|
|
||||||
onConfirm: (collection: Collection) => Promise<void>;
|
onConfirm: (collection: Collection) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SaveCollectionButton = ({
|
export const SaveAsCollectionButton = ({
|
||||||
onConfirm,
|
onConfirm,
|
||||||
getPageInfo,
|
}: SaveAsCollectionButtonProps) => {
|
||||||
propertiesMeta,
|
|
||||||
filterList,
|
|
||||||
workspaceId,
|
|
||||||
}: SaveCollectionButtonProps) => {
|
|
||||||
const [show, changeShow] = useState(false);
|
|
||||||
const [init, setInit] = useState<Collection>();
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
changeShow(true);
|
|
||||||
setInit({
|
|
||||||
id: nanoid(),
|
|
||||||
name: '',
|
|
||||||
filterList,
|
|
||||||
workspaceId,
|
|
||||||
});
|
|
||||||
}, [changeShow, workspaceId, filterList]);
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
const { open, node } = useEditCollectionName({
|
||||||
|
title: t['com.affine.editCollection.saveCollection'](),
|
||||||
|
showTips: true,
|
||||||
|
});
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
open('')
|
||||||
|
.then(name => {
|
||||||
|
return onConfirm(createEmptyCollection(nanoid(), { name }));
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}, [open, onConfirm]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@ -300,15 +156,7 @@ export const SaveCollectionButton = ({
|
|||||||
>
|
>
|
||||||
{t['com.affine.editCollection.saveCollection']()}
|
{t['com.affine.editCollection.saveCollection']()}
|
||||||
</Button>
|
</Button>
|
||||||
<EditCollectionModal
|
{node}
|
||||||
title={t['com.affine.editCollection.saveCollection']()}
|
|
||||||
propertiesMeta={propertiesMeta}
|
|
||||||
init={init}
|
|
||||||
onConfirm={onConfirm}
|
|
||||||
open={show}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
onOpenChange={changeShow}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,228 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const ellipsis = style({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pagesBottomLeft = style({
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pagesBottom = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderTop: '1px solid var(--affine-border-color)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '12px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pagesTabContent = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px 16px 8px 16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pagesTab = style({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pagesList = style({
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const bottomLeft = style({
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesBottom = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '20px 24px',
|
||||||
|
borderTop: '1px solid var(--affine-border-color)',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '12px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const includeListTitle = style({
|
||||||
|
marginTop: 8,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '22px',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
paddingLeft: 18,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesContainerRight = style({
|
||||||
|
flex: 2,
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderLeft: '1px solid var(--affine-border-color)',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
overflowY: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const includeAddButton = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
padding: '4px 8px',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: '22px',
|
||||||
|
width: 'max-content',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const includeItemTitle = style({ overflow: 'hidden', fontWeight: 600 });
|
||||||
|
|
||||||
|
export const includeItemContentIs = style({
|
||||||
|
padding: '0 8px',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const includeItemContent = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '20px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const includeItem = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 'max-content',
|
||||||
|
backgroundColor: 'var(--affine-background-primary-color)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
gap: 16,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: '1px solid var(--affine-border-color)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '4px 8px 4px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const includeTitle = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: '22px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesContainerLeftContentInclude = style({
|
||||||
|
overflow: 'hidden',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesContainerLeftContent = style({
|
||||||
|
padding: '12px 16px 16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesContainerLeftTab = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '16px 16px 8px 16px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesContainerLeft = style({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
overflow: 'hidden',
|
||||||
|
flex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const collectionEditContainer = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const confirmButton = style({
|
||||||
|
marginLeft: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resultPages = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const pageList = style({
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const previewCountTipsHighlight = style({
|
||||||
|
color: 'var(--affine-primary-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const previewCountTips = style({
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '20px',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
});
|
||||||
|
export const selectedCountTips = style({
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '20px',
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rulesTitleHighlight = style({
|
||||||
|
color: 'var(--affine-primary-color)',
|
||||||
|
fontStyle: 'italic',
|
||||||
|
fontWeight: 800,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tabButton = style({ height: 28 });
|
||||||
|
export const icon = style({
|
||||||
|
color: 'var(--affine-icon-color)',
|
||||||
|
});
|
||||||
|
export const button = style({
|
||||||
|
userSelect: 'none',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const bottomButton = style({
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const previewActive = style({
|
||||||
|
backgroundColor: 'var(--affine-hover-color-filled)',
|
||||||
|
});
|
||||||
|
export const rulesTitle = style({
|
||||||
|
padding: '20px 24px',
|
||||||
|
userSelect: 'none',
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: '24px',
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
borderBottom: '1px solid var(--affine-border-color)',
|
||||||
|
});
|
@ -0,0 +1,913 @@
|
|||||||
|
import {
|
||||||
|
AffineShapeIcon,
|
||||||
|
PageList,
|
||||||
|
PageListScrollContainer,
|
||||||
|
} from '@affine/component/page-list';
|
||||||
|
import type { Collection, Filter } from '@affine/env/filter';
|
||||||
|
import { Trans } from '@affine/i18n';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import {
|
||||||
|
CloseIcon,
|
||||||
|
EdgelessIcon,
|
||||||
|
FilterIcon,
|
||||||
|
PageIcon,
|
||||||
|
PlusIcon,
|
||||||
|
ToggleCollapseIcon,
|
||||||
|
} from '@blocksuite/icons';
|
||||||
|
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||||
|
import { Button } from '@toeverything/components/button';
|
||||||
|
import { Menu } from '@toeverything/components/menu';
|
||||||
|
import { Modal } from '@toeverything/components/modal';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { type MouseEvent, useEffect } from 'react';
|
||||||
|
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { RadioButton, RadioButtonGroup } from '../../..';
|
||||||
|
import { FilterList } from '../filter';
|
||||||
|
import { VariableSelect } from '../filter/vars';
|
||||||
|
import { filterPageByRules } from '../use-collection-manager';
|
||||||
|
import * as styles from './edit-collection.css';
|
||||||
|
|
||||||
|
export interface EditCollectionModalProps {
|
||||||
|
init?: Collection;
|
||||||
|
title?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: (view: Collection) => Promise<void>;
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditCollectionModal = ({
|
||||||
|
init,
|
||||||
|
onConfirm,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
allPageListConfig,
|
||||||
|
}: EditCollectionModalProps) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const onConfirmOnCollection = useCallback(
|
||||||
|
(view: Collection) => {
|
||||||
|
onConfirm(view)
|
||||||
|
.then(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onConfirm, onOpenChange]
|
||||||
|
);
|
||||||
|
const onCancel = useCallback(() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
}, [onOpenChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
withoutCloseButton
|
||||||
|
width="calc(100% - 64px)"
|
||||||
|
height="80%"
|
||||||
|
contentOptions={{
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
maxWidth: 944,
|
||||||
|
backgroundColor: 'var(--affine-white)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{init ? (
|
||||||
|
<EditCollection
|
||||||
|
title={title}
|
||||||
|
onConfirmText={t['com.affine.editCollection.save']()}
|
||||||
|
init={init}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onConfirm={onConfirmOnCollection}
|
||||||
|
allPageListConfig={allPageListConfig}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EditCollectionProps {
|
||||||
|
title?: string;
|
||||||
|
onConfirmText?: string;
|
||||||
|
init: Collection;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: (collection: Collection) => void;
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditCollection = ({
|
||||||
|
init,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
onConfirmText,
|
||||||
|
allPageListConfig,
|
||||||
|
}: EditCollectionProps) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const [value, onChange] = useState<Collection>(init);
|
||||||
|
const isNameEmpty = useMemo(() => value.name.trim().length === 0, [value]);
|
||||||
|
const onSaveCollection = useCallback(() => {
|
||||||
|
if (!isNameEmpty) {
|
||||||
|
onConfirm(value);
|
||||||
|
}
|
||||||
|
}, [value, isNameEmpty, onConfirm]);
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
filterList: init.filterList,
|
||||||
|
allowList: init.allowList,
|
||||||
|
});
|
||||||
|
}, [init.allowList, init.filterList, value]);
|
||||||
|
const buttons = useMemo(
|
||||||
|
() => (
|
||||||
|
<>
|
||||||
|
<Button size="large" onClick={onCancel}>
|
||||||
|
{t['com.affine.editCollection.button.cancel']()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={styles.confirmButton}
|
||||||
|
size="large"
|
||||||
|
data-testid="save-collection"
|
||||||
|
type="primary"
|
||||||
|
disabled={isNameEmpty}
|
||||||
|
onClick={onSaveCollection}
|
||||||
|
>
|
||||||
|
{onConfirmText ?? t['com.affine.editCollection.button.create']()}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[onCancel, t, isNameEmpty, onSaveCollection, onConfirmText]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.collectionEditContainer}>
|
||||||
|
{value.mode === 'page' ? (
|
||||||
|
<PagesMode
|
||||||
|
collection={value}
|
||||||
|
updateCollection={onChange}
|
||||||
|
buttons={buttons}
|
||||||
|
allPageListConfig={allPageListConfig}
|
||||||
|
></PagesMode>
|
||||||
|
) : (
|
||||||
|
<RulesMode
|
||||||
|
allPageListConfig={allPageListConfig}
|
||||||
|
collection={value}
|
||||||
|
reset={reset}
|
||||||
|
updateCollection={onChange}
|
||||||
|
buttons={buttons}
|
||||||
|
></RulesMode>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AllPageListConfig = {
|
||||||
|
allPages: PageMeta[];
|
||||||
|
workspace: Workspace;
|
||||||
|
isEdgeless: (id: string) => boolean;
|
||||||
|
getPage: (id: string) => PageMeta | undefined;
|
||||||
|
favoriteRender: (page: PageMeta) => ReactNode;
|
||||||
|
};
|
||||||
|
const RulesMode = ({
|
||||||
|
collection,
|
||||||
|
updateCollection,
|
||||||
|
reset,
|
||||||
|
buttons,
|
||||||
|
allPageListConfig,
|
||||||
|
}: {
|
||||||
|
collection: Collection;
|
||||||
|
updateCollection: (collection: Collection) => void;
|
||||||
|
reset: () => void;
|
||||||
|
buttons: ReactNode;
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
}) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const [showPreview, setShowPreview] = useState(true);
|
||||||
|
const allowListPages: PageMeta[] = [];
|
||||||
|
const rulesPages: PageMeta[] = [];
|
||||||
|
const [showTips, setShowTips] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setShowTips(!localStorage.getItem('hide-rules-mode-include-page-tips'));
|
||||||
|
}, []);
|
||||||
|
const hideTips = useCallback(() => {
|
||||||
|
setShowTips(false);
|
||||||
|
localStorage.setItem('hide-rules-mode-include-page-tips', 'true');
|
||||||
|
}, []);
|
||||||
|
allPageListConfig.allPages.forEach(v => {
|
||||||
|
if (v.trash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = filterPageByRules(
|
||||||
|
collection.filterList,
|
||||||
|
collection.allowList,
|
||||||
|
v
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
if (collection.allowList.includes(v.id)) {
|
||||||
|
allowListPages.push(v);
|
||||||
|
} else {
|
||||||
|
rulesPages.push(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { node: selectPageNode, open } = useSelectPage({ allPageListConfig });
|
||||||
|
const openSelectPage = useCallback(() => {
|
||||||
|
open(collection.allowList).then(
|
||||||
|
ids => {
|
||||||
|
updateCollection({
|
||||||
|
...collection,
|
||||||
|
allowList: ids,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
//do nothing
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}, [open, updateCollection, collection]);
|
||||||
|
const [expandInclude, setExpandInclude] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={clsx(styles.rulesTitle, styles.ellipsis)}>
|
||||||
|
<Trans
|
||||||
|
i18nKey="com.affine.editCollection.rules.tips"
|
||||||
|
values={{
|
||||||
|
highlight: t['com.affine.editCollection.rules.tips.highlight'](),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Pages that meet the rules will be added to the current collection{' '}
|
||||||
|
<span className={styles.rulesTitleHighlight}>highlight</span>.
|
||||||
|
</Trans>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rulesContainer}>
|
||||||
|
<div className={styles.rulesContainerLeft}>
|
||||||
|
<div className={styles.rulesContainerLeftTab}>
|
||||||
|
<RadioButtonGroup
|
||||||
|
width={158}
|
||||||
|
style={{ height: 32 }}
|
||||||
|
value={collection.mode}
|
||||||
|
onValueChange={useCallback(
|
||||||
|
(mode: 'page' | 'rule') => {
|
||||||
|
updateCollection({
|
||||||
|
...collection,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[collection, updateCollection]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioButton
|
||||||
|
spanStyle={styles.tabButton}
|
||||||
|
value="page"
|
||||||
|
data-testid="edit-collection-pages-button"
|
||||||
|
>
|
||||||
|
{t['com.affine.editCollection.pages']()}
|
||||||
|
</RadioButton>
|
||||||
|
<RadioButton
|
||||||
|
spanStyle={styles.tabButton}
|
||||||
|
value="rule"
|
||||||
|
data-testid="edit-collection-rules-button"
|
||||||
|
>
|
||||||
|
{t['com.affine.editCollection.rules']()}
|
||||||
|
</RadioButton>
|
||||||
|
</RadioButtonGroup>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rulesContainerLeftContent}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FilterList
|
||||||
|
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||||
|
value={collection.filterList}
|
||||||
|
onChange={useCallback(
|
||||||
|
filterList => updateCollection({ ...collection, filterList }),
|
||||||
|
[collection, updateCollection]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className={styles.rulesContainerLeftContentInclude}>
|
||||||
|
<div className={styles.includeTitle}>
|
||||||
|
<ToggleCollapseIcon
|
||||||
|
onClick={() => setExpandInclude(!expandInclude)}
|
||||||
|
className={styles.button}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
style={{
|
||||||
|
transform: expandInclude ? 'rotate(90deg)' : undefined,
|
||||||
|
}}
|
||||||
|
></ToggleCollapseIcon>
|
||||||
|
<div style={{ color: 'var(--affine-text-secondary-color)' }}>
|
||||||
|
include
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: expandInclude ? 'flex' : 'none',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '8px 16px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{collection.allowList.map(id => {
|
||||||
|
const page = allPageListConfig.allPages.find(
|
||||||
|
v => v.id === id
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div className={styles.includeItem} key={id}>
|
||||||
|
<div className={styles.includeItemContent}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
alignItems: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allPageListConfig.isEdgeless(id) ? (
|
||||||
|
<EdgelessIcon style={{ width: 16, height: 16 }} />
|
||||||
|
) : (
|
||||||
|
<PageIcon style={{ width: 16, height: 16 }} />
|
||||||
|
)}
|
||||||
|
Page
|
||||||
|
</div>
|
||||||
|
<div className={styles.includeItemContentIs}>is</div>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.includeItemTitle,
|
||||||
|
styles.ellipsis
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{page?.title || 'Untitled'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CloseIcon
|
||||||
|
className={styles.button}
|
||||||
|
onClick={() => {
|
||||||
|
updateCollection({
|
||||||
|
...collection,
|
||||||
|
allowList: collection.allowList.filter(
|
||||||
|
v => v !== id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
></CloseIcon>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div
|
||||||
|
onClick={openSelectPage}
|
||||||
|
className={clsx(styles.button, styles.includeAddButton)}
|
||||||
|
>
|
||||||
|
<PlusIcon></PlusIcon>
|
||||||
|
<div
|
||||||
|
style={{ color: 'var(--affine-text-secondary-color)' }}
|
||||||
|
>
|
||||||
|
Add include page
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showTips ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor:
|
||||||
|
'var(--affine-background-overlay-panel-color)',
|
||||||
|
padding: 10,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: '20px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginBottom: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--affine-text-secondary-color)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>HELP INFO</div>
|
||||||
|
<CloseIcon
|
||||||
|
color="var(--affine-icon-color)"
|
||||||
|
onClick={hideTips}
|
||||||
|
className={styles.button}
|
||||||
|
style={{ width: 16, height: 16 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: 10, fontWeight: 600 }}>
|
||||||
|
What is "Include"?
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
"Include" refers to manually adding pages rather
|
||||||
|
than automatically adding them through rule matching. You can
|
||||||
|
manually add pages through the "Add pages" option or
|
||||||
|
by dragging and dropping (coming soon).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PageListScrollContainer
|
||||||
|
className={styles.rulesContainerRight}
|
||||||
|
style={{
|
||||||
|
display: showPreview ? 'flex' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rulesPages.length > 0 ? (
|
||||||
|
<PageList
|
||||||
|
hideHeader
|
||||||
|
clickMode="select"
|
||||||
|
className={styles.resultPages}
|
||||||
|
pages={rulesPages}
|
||||||
|
groupBy={false}
|
||||||
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
|
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||||
|
></PageList>
|
||||||
|
) : null}
|
||||||
|
{allowListPages.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className={styles.includeListTitle}>include</div>
|
||||||
|
<PageList
|
||||||
|
hideHeader
|
||||||
|
clickMode="select"
|
||||||
|
className={styles.resultPages}
|
||||||
|
pages={allowListPages}
|
||||||
|
groupBy={false}
|
||||||
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
|
></PageList>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</PageListScrollContainer>
|
||||||
|
</div>
|
||||||
|
<div className={styles.rulesBottom}>
|
||||||
|
<div className={styles.bottomLeft}>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
styles.button,
|
||||||
|
styles.bottomButton,
|
||||||
|
showPreview && styles.previewActive
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setShowPreview(!showPreview);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.button, styles.bottomButton)}
|
||||||
|
onClick={reset}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</div>
|
||||||
|
<div className={styles.previewCountTips}>
|
||||||
|
After searching, there are currently{' '}
|
||||||
|
<span className={styles.previewCountTipsHighlight}>
|
||||||
|
{allowListPages.length + rulesPages.length}
|
||||||
|
</span>{' '}
|
||||||
|
pages.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center' }}>{buttons}</div>
|
||||||
|
</div>
|
||||||
|
{selectPageNode}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const PagesMode = ({
|
||||||
|
collection,
|
||||||
|
updateCollection,
|
||||||
|
buttons,
|
||||||
|
allPageListConfig,
|
||||||
|
}: {
|
||||||
|
collection: Collection;
|
||||||
|
updateCollection: (collection: Collection) => void;
|
||||||
|
buttons: ReactNode;
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
}) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const {
|
||||||
|
showFilter,
|
||||||
|
filters,
|
||||||
|
updateFilters,
|
||||||
|
clickFilter,
|
||||||
|
createFilter,
|
||||||
|
filteredList,
|
||||||
|
} = useFilter(allPageListConfig.allPages);
|
||||||
|
const { searchText, updateSearchText, searchedList } =
|
||||||
|
useSearch(filteredList);
|
||||||
|
const clearSelected = useCallback(() => {
|
||||||
|
updateCollection({
|
||||||
|
...collection,
|
||||||
|
pages: [],
|
||||||
|
});
|
||||||
|
}, [collection, updateCollection]);
|
||||||
|
const pageOperationsRenderer = useCallback(
|
||||||
|
(page: PageMeta) => allPageListConfig.favoriteRender(page),
|
||||||
|
[allPageListConfig]
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => updateSearchText(e.target.value)}
|
||||||
|
className={styles.rulesTitle}
|
||||||
|
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||||
|
></input>
|
||||||
|
<div className={styles.pagesList}>
|
||||||
|
<div className={styles.pagesTab}>
|
||||||
|
<div className={styles.pagesTabContent}>
|
||||||
|
<RadioButtonGroup
|
||||||
|
width={158}
|
||||||
|
style={{ height: 32 }}
|
||||||
|
value={collection.mode}
|
||||||
|
onValueChange={useCallback(
|
||||||
|
(mode: 'page' | 'rule') => {
|
||||||
|
updateCollection({
|
||||||
|
...collection,
|
||||||
|
mode,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[collection, updateCollection]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RadioButton
|
||||||
|
spanStyle={styles.tabButton}
|
||||||
|
value="page"
|
||||||
|
data-testid="edit-collection-pages-button"
|
||||||
|
>
|
||||||
|
{t['com.affine.editCollection.pages']()}
|
||||||
|
</RadioButton>
|
||||||
|
<RadioButton
|
||||||
|
spanStyle={styles.tabButton}
|
||||||
|
value="rule"
|
||||||
|
data-testid="edit-collection-rules-button"
|
||||||
|
>
|
||||||
|
{t['com.affine.editCollection.rules']()}
|
||||||
|
</RadioButton>
|
||||||
|
</RadioButtonGroup>
|
||||||
|
{!showFilter && filters.length === 0 ? (
|
||||||
|
<Menu
|
||||||
|
items={
|
||||||
|
<VariableSelect
|
||||||
|
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||||
|
selected={filters}
|
||||||
|
onSelect={createFilter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<FilterIcon
|
||||||
|
className={clsx(styles.icon, styles.button)}
|
||||||
|
onClick={clickFilter}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
></FilterIcon>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<FilterIcon
|
||||||
|
className={clsx(styles.icon, styles.button)}
|
||||||
|
onClick={clickFilter}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
></FilterIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showFilter ? (
|
||||||
|
<div style={{ padding: '12px 16px 16px' }}>
|
||||||
|
<FilterList
|
||||||
|
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||||
|
value={filters}
|
||||||
|
onChange={updateFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{searchedList.length ? (
|
||||||
|
<PageListScrollContainer>
|
||||||
|
<PageList
|
||||||
|
clickMode="select"
|
||||||
|
className={styles.pageList}
|
||||||
|
pages={searchedList}
|
||||||
|
groupBy={false}
|
||||||
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
|
selectable
|
||||||
|
onSelectedPageIdsChange={ids => {
|
||||||
|
updateCollection({
|
||||||
|
...collection,
|
||||||
|
pages: ids,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
pageOperationsRenderer={pageOperationsRenderer}
|
||||||
|
selectedPageIds={collection.pages}
|
||||||
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
|
></PageList>
|
||||||
|
</PageListScrollContainer>
|
||||||
|
) : (
|
||||||
|
<EmptyList search={searchText} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.pagesBottom}>
|
||||||
|
<div className={styles.pagesBottomLeft}>
|
||||||
|
<div className={styles.selectedCountTips}>
|
||||||
|
Selected
|
||||||
|
<span
|
||||||
|
style={{ marginLeft: 7 }}
|
||||||
|
className={styles.previewCountTipsHighlight}
|
||||||
|
>
|
||||||
|
{collection.pages.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.button, styles.bottomButton)}
|
||||||
|
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||||
|
onClick={clearSelected}
|
||||||
|
>
|
||||||
|
{t['com.affine.editCollection.pages.clear']()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>{buttons}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const SelectPage = ({
|
||||||
|
allPageListConfig,
|
||||||
|
init,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
init: string[];
|
||||||
|
onConfirm: (pageIds: string[]) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const [value, onChange] = useState(init);
|
||||||
|
const confirm = useCallback(() => {
|
||||||
|
onConfirm(value);
|
||||||
|
}, [value, onConfirm]);
|
||||||
|
const clearSelected = useCallback(() => {
|
||||||
|
onChange([]);
|
||||||
|
}, []);
|
||||||
|
const {
|
||||||
|
clickFilter,
|
||||||
|
createFilter,
|
||||||
|
filters,
|
||||||
|
showFilter,
|
||||||
|
updateFilters,
|
||||||
|
filteredList,
|
||||||
|
} = useFilter(allPageListConfig.allPages);
|
||||||
|
const { searchText, updateSearchText, searchedList } =
|
||||||
|
useSearch(filteredList);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<input
|
||||||
|
className={styles.rulesTitle}
|
||||||
|
value={searchText}
|
||||||
|
onChange={e => updateSearchText(e.target.value)}
|
||||||
|
placeholder={t['com.affine.editCollection.search.placeholder']()}
|
||||||
|
></input>
|
||||||
|
<div className={styles.pagesTab}>
|
||||||
|
<div className={styles.pagesTabContent}>
|
||||||
|
<div style={{ fontSize: 12, lineHeight: '20px', fontWeight: 600 }}>
|
||||||
|
Add include page
|
||||||
|
</div>
|
||||||
|
{!showFilter && filters.length === 0 ? (
|
||||||
|
<Menu
|
||||||
|
items={
|
||||||
|
<VariableSelect
|
||||||
|
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||||
|
selected={filters}
|
||||||
|
onSelect={createFilter}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<FilterIcon
|
||||||
|
className={clsx(styles.icon, styles.button)}
|
||||||
|
onClick={clickFilter}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
></FilterIcon>
|
||||||
|
</div>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<FilterIcon
|
||||||
|
className={clsx(styles.icon, styles.button)}
|
||||||
|
onClick={clickFilter}
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
></FilterIcon>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showFilter ? (
|
||||||
|
<div style={{ padding: '12px 16px 16px' }}>
|
||||||
|
<FilterList
|
||||||
|
propertiesMeta={allPageListConfig.workspace.meta.properties}
|
||||||
|
value={filters}
|
||||||
|
onChange={updateFilters}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{searchedList.length ? (
|
||||||
|
<PageListScrollContainer>
|
||||||
|
<PageList
|
||||||
|
clickMode="select"
|
||||||
|
className={styles.pageList}
|
||||||
|
pages={searchedList}
|
||||||
|
blockSuiteWorkspace={allPageListConfig.workspace}
|
||||||
|
selectable
|
||||||
|
onSelectedPageIdsChange={onChange}
|
||||||
|
selectedPageIds={value}
|
||||||
|
isPreferredEdgeless={allPageListConfig.isEdgeless}
|
||||||
|
pageOperationsRenderer={allPageListConfig.favoriteRender}
|
||||||
|
></PageList>
|
||||||
|
</PageListScrollContainer>
|
||||||
|
) : (
|
||||||
|
<EmptyList search={searchText} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.pagesBottom}>
|
||||||
|
<div className={styles.pagesBottomLeft}>
|
||||||
|
<div className={styles.selectedCountTips}>
|
||||||
|
Selected
|
||||||
|
<span
|
||||||
|
style={{ marginLeft: 7 }}
|
||||||
|
className={styles.previewCountTipsHighlight}
|
||||||
|
>
|
||||||
|
{value.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={clsx(styles.button, styles.bottomButton)}
|
||||||
|
style={{ fontSize: 12, lineHeight: '20px' }}
|
||||||
|
onClick={clearSelected}
|
||||||
|
>
|
||||||
|
{t['com.affine.editCollection.pages.clear']()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button size="large" onClick={onCancel}>
|
||||||
|
{t['com.affine.editCollection.button.cancel']()}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className={styles.confirmButton}
|
||||||
|
size="large"
|
||||||
|
data-testid="save-collection"
|
||||||
|
type="primary"
|
||||||
|
onClick={confirm}
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const useSelectPage = ({
|
||||||
|
allPageListConfig,
|
||||||
|
}: {
|
||||||
|
allPageListConfig: AllPageListConfig;
|
||||||
|
}) => {
|
||||||
|
const [value, onChange] = useState<{
|
||||||
|
init: string[];
|
||||||
|
onConfirm: (ids: string[]) => void;
|
||||||
|
}>();
|
||||||
|
const close = useCallback(() => {
|
||||||
|
onChange(undefined);
|
||||||
|
}, []);
|
||||||
|
return {
|
||||||
|
node: (
|
||||||
|
<Modal
|
||||||
|
open={!!value}
|
||||||
|
onOpenChange={close}
|
||||||
|
withoutCloseButton
|
||||||
|
width="calc(100% - 32px)"
|
||||||
|
height="80%"
|
||||||
|
overlayOptions={{ style: { backgroundColor: 'transparent' } }}
|
||||||
|
contentOptions={{
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
transform: 'translate(-50%,calc(-50% + 16px))',
|
||||||
|
maxWidth: 976,
|
||||||
|
backgroundColor: 'var(--affine-white)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<SelectPage
|
||||||
|
allPageListConfig={allPageListConfig}
|
||||||
|
init={value.init}
|
||||||
|
onConfirm={value.onConfirm}
|
||||||
|
onCancel={close}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
),
|
||||||
|
open: (init: string[]): Promise<string[]> =>
|
||||||
|
new Promise<string[]>(res => {
|
||||||
|
onChange({
|
||||||
|
init,
|
||||||
|
onConfirm: list => {
|
||||||
|
close();
|
||||||
|
res(list);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const useFilter = (list: PageMeta[]) => {
|
||||||
|
const [filters, changeFilters] = useState<Filter[]>([]);
|
||||||
|
const [showFilter, setShowFilter] = useState(false);
|
||||||
|
const clickFilter = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (showFilter || filters.length !== 0) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setShowFilter(!showFilter);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[filters.length, showFilter]
|
||||||
|
);
|
||||||
|
const onCreateFilter = useCallback(
|
||||||
|
(filter: Filter) => {
|
||||||
|
changeFilters([...filters, filter]);
|
||||||
|
setShowFilter(true);
|
||||||
|
},
|
||||||
|
[filters]
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
showFilter,
|
||||||
|
filters,
|
||||||
|
updateFilters: changeFilters,
|
||||||
|
clickFilter,
|
||||||
|
createFilter: onCreateFilter,
|
||||||
|
filteredList: list.filter(v => {
|
||||||
|
if (v.trash) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return filterPageByRules(filters, [], v);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const useSearch = (list: PageMeta[]) => {
|
||||||
|
const [value, onChange] = useState('');
|
||||||
|
return {
|
||||||
|
searchText: value,
|
||||||
|
updateSearchText: onChange,
|
||||||
|
searchedList: value
|
||||||
|
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
|
||||||
|
: list,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const EmptyList = ({ search }: { search?: string }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AffineShapeIcon />
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: '18px 0',
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: '28px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Empty
|
||||||
|
</div>
|
||||||
|
{search ? (
|
||||||
|
<div
|
||||||
|
className={styles.ellipsis}
|
||||||
|
style={{ maxWidth: 300, fontSize: 15, lineHeight: '24px' }}
|
||||||
|
>
|
||||||
|
No page titles contain{' '}
|
||||||
|
<span
|
||||||
|
style={{ fontWeight: 600, color: 'var(--affine-primary-color)' }}
|
||||||
|
>
|
||||||
|
{search}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,3 +1,7 @@
|
|||||||
|
export * from './affine-shape';
|
||||||
export * from './collection-bar';
|
export * from './collection-bar';
|
||||||
export * from './collection-list';
|
export * from './collection-list';
|
||||||
|
export * from './collection-operations';
|
||||||
export * from './create-collection';
|
export * from './create-collection';
|
||||||
|
export * from './edit-collection';
|
||||||
|
export * from './use-edit-collection';
|
||||||
|
@ -1,16 +1,9 @@
|
|||||||
import type { Collection } from '@affine/env/filter';
|
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import {
|
import { DeleteIcon, FilterIcon } from '@blocksuite/icons';
|
||||||
DeleteIcon,
|
|
||||||
FilterIcon,
|
|
||||||
PinedIcon,
|
|
||||||
PinIcon,
|
|
||||||
UnpinIcon,
|
|
||||||
} from '@blocksuite/icons';
|
|
||||||
import { type ReactNode, useMemo } from 'react';
|
import { type ReactNode, useMemo } from 'react';
|
||||||
|
|
||||||
import type { useCollectionManager } from '../use-collection-manager';
|
import type { useCollectionManager } from '../use-collection-manager';
|
||||||
import * as styles from './collection-bar.css';
|
|
||||||
|
|
||||||
interface CollectionBarAction {
|
interface CollectionBarAction {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
@ -24,7 +17,9 @@ export const useActions = ({
|
|||||||
collection,
|
collection,
|
||||||
setting,
|
setting,
|
||||||
openEdit,
|
openEdit,
|
||||||
|
info,
|
||||||
}: {
|
}: {
|
||||||
|
info: DeleteCollectionInfo;
|
||||||
collection: Collection;
|
collection: Collection;
|
||||||
setting: ReturnType<typeof useCollectionManager>;
|
setting: ReturnType<typeof useCollectionManager>;
|
||||||
openEdit: (open: Collection) => void;
|
openEdit: (open: Collection) => void;
|
||||||
@ -32,37 +27,6 @@ export const useActions = ({
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
return useMemo<CollectionBarAction[]>(() => {
|
return useMemo<CollectionBarAction[]>(() => {
|
||||||
return [
|
return [
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<>
|
|
||||||
{collection.pinned ? (
|
|
||||||
<PinedIcon className={styles.pinedIcon}></PinedIcon>
|
|
||||||
) : (
|
|
||||||
<PinIcon className={styles.pinedIcon}></PinIcon>
|
|
||||||
)}
|
|
||||||
{collection.pinned ? (
|
|
||||||
<UnpinIcon className={styles.pinIcon}></UnpinIcon>
|
|
||||||
) : (
|
|
||||||
<PinIcon className={styles.pinIcon}></PinIcon>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
name: 'pin',
|
|
||||||
tooltip: collection.pinned
|
|
||||||
? t['com.affine.collection-bar.action.tooltip.unpin']()
|
|
||||||
: t['com.affine.collection-bar.action.tooltip.pin'](),
|
|
||||||
className: styles.pin,
|
|
||||||
click: () => {
|
|
||||||
setting
|
|
||||||
.updateCollection({
|
|
||||||
...collection,
|
|
||||||
pinned: !collection.pinned,
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
icon: <FilterIcon />,
|
icon: <FilterIcon />,
|
||||||
name: 'edit',
|
name: 'edit',
|
||||||
@ -76,11 +40,11 @@ export const useActions = ({
|
|||||||
name: 'delete',
|
name: 'delete',
|
||||||
tooltip: t['com.affine.collection-bar.action.tooltip.delete'](),
|
tooltip: t['com.affine.collection-bar.action.tooltip.delete'](),
|
||||||
click: () => {
|
click: () => {
|
||||||
setting.deleteCollection(collection.id).catch(err => {
|
setting.deleteCollection(info, collection.id).catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [collection, t, setting, openEdit]);
|
}, [info, collection, t, setting, openEdit]);
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
type AllPageListConfig,
|
||||||
|
CreateCollectionModal,
|
||||||
|
EditCollectionModal,
|
||||||
|
} from '@affine/component/page-list';
|
||||||
|
import type { Collection } from '@affine/env/filter';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export const useEditCollection = (config: AllPageListConfig) => {
|
||||||
|
const [data, setData] = useState<{
|
||||||
|
collection: Collection;
|
||||||
|
onConfirm: (collection: Collection) => Promise<void>;
|
||||||
|
}>();
|
||||||
|
const close = useCallback(() => setData(undefined), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: data ? (
|
||||||
|
<EditCollectionModal
|
||||||
|
allPageListConfig={config}
|
||||||
|
init={data.collection}
|
||||||
|
open={!!data}
|
||||||
|
onOpenChange={close}
|
||||||
|
onConfirm={data.onConfirm}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
open: (collection: Collection): Promise<Collection> =>
|
||||||
|
new Promise<Collection>(res => {
|
||||||
|
setData({
|
||||||
|
collection,
|
||||||
|
onConfirm: async collection => {
|
||||||
|
res(collection);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditCollectionName = ({
|
||||||
|
title,
|
||||||
|
showTips,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
showTips?: boolean;
|
||||||
|
}) => {
|
||||||
|
const [data, setData] = useState<{
|
||||||
|
name: string;
|
||||||
|
onConfirm: (name: string) => Promise<void>;
|
||||||
|
}>();
|
||||||
|
const close = useCallback(() => setData(undefined), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: data ? (
|
||||||
|
<CreateCollectionModal
|
||||||
|
showTips={showTips}
|
||||||
|
title={title}
|
||||||
|
init={data.name}
|
||||||
|
open={!!data}
|
||||||
|
onOpenChange={close}
|
||||||
|
onConfirm={data.onConfirm}
|
||||||
|
/>
|
||||||
|
) : null,
|
||||||
|
open: (name: string): Promise<string> =>
|
||||||
|
new Promise<string>(res => {
|
||||||
|
setData({
|
||||||
|
name,
|
||||||
|
onConfirm: async collection => {
|
||||||
|
res(collection);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
@ -6,6 +6,8 @@ export * from './ui/checkbox';
|
|||||||
export * from './ui/empty';
|
export * from './ui/empty';
|
||||||
export * from './ui/input';
|
export * from './ui/input';
|
||||||
export * from './ui/layout';
|
export * from './ui/layout';
|
||||||
|
export * from './ui/lottie/collections-icon';
|
||||||
|
export * from './ui/lottie/delete-icon';
|
||||||
export * from './ui/menu';
|
export * from './ui/menu';
|
||||||
export * from './ui/mui';
|
export * from './ui/mui';
|
||||||
export * from './ui/popper';
|
export * from './ui/popper';
|
||||||
|
@ -289,8 +289,8 @@ affine-block-hub {
|
|||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
select,
|
select,
|
||||||
textarea,
|
textarea
|
||||||
[role='button'] {
|
/* [role='button'] */ {
|
||||||
-webkit-app-region: no-drag;
|
-webkit-app-region: no-drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,19 +8,25 @@ import {
|
|||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
type DropdownButtonProps = {
|
type DropdownButtonProps = {
|
||||||
|
size?: 'small' | 'default';
|
||||||
onClickDropDown?: MouseEventHandler<HTMLElement>;
|
onClickDropDown?: MouseEventHandler<HTMLElement>;
|
||||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
export const DropdownButton = forwardRef<
|
export const DropdownButton = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
DropdownButtonProps
|
DropdownButtonProps
|
||||||
>(({ onClickDropDown, children, ...props }, ref) => {
|
>(({ onClickDropDown, children, size = 'default', ...props }, ref) => {
|
||||||
const handleClickDropDown: MouseEventHandler<HTMLElement> = e => {
|
const handleClickDropDown: MouseEventHandler<HTMLElement> = e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClickDropDown?.(e);
|
onClickDropDown?.(e);
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<button ref={ref} className={styles.dropdownBtn} {...props}>
|
<button
|
||||||
|
ref={ref}
|
||||||
|
data-size={size}
|
||||||
|
className={styles.dropdownBtn}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<span>{children}</span>
|
<span>{children}</span>
|
||||||
<span className={styles.divider} />
|
<span className={styles.divider} />
|
||||||
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
|
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
|
@ -1,2 +1,2 @@
|
|||||||
export * from './dropdown';
|
export * from './dropdown-button';
|
||||||
export * from './radio';
|
export * from './radio';
|
||||||
|
@ -1,363 +0,0 @@
|
|||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const button = style({
|
|
||||||
display: 'inline-flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
userSelect: 'none',
|
|
||||||
touchAction: 'manipulation',
|
|
||||||
outline: '0',
|
|
||||||
border: '1px solid',
|
|
||||||
padding: '0 18px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: 'var(--affine-font-base)',
|
|
||||||
transition: 'all .3s',
|
|
||||||
['WebkitAppRegion' as string]: 'no-drag',
|
|
||||||
fontWeight: 600,
|
|
||||||
|
|
||||||
// changeable
|
|
||||||
height: '28px',
|
|
||||||
background: 'var(--affine-white)',
|
|
||||||
borderColor: 'var(--affine-border-color)',
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
|
|
||||||
selectors: {
|
|
||||||
'&.text-bold': {
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
'&:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
},
|
|
||||||
'&.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
color: 'var(--affine-disable-color)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
'&.loading': {
|
|
||||||
cursor: 'default',
|
|
||||||
color: 'var(--affine-disable-color)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
|
|
||||||
{
|
|
||||||
background: 'inherit',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.block': { display: 'flex', width: '100%' },
|
|
||||||
|
|
||||||
'&.circle': {
|
|
||||||
borderRadius: '50%',
|
|
||||||
},
|
|
||||||
'&.round': {
|
|
||||||
borderRadius: '14px',
|
|
||||||
},
|
|
||||||
// size
|
|
||||||
'&.large': {
|
|
||||||
height: '32px',
|
|
||||||
},
|
|
||||||
'&.round.large': {
|
|
||||||
borderRadius: '16px',
|
|
||||||
},
|
|
||||||
'&.extraLarge': {
|
|
||||||
height: '40px',
|
|
||||||
},
|
|
||||||
'&.round.extraLarge': {
|
|
||||||
borderRadius: '20px',
|
|
||||||
},
|
|
||||||
|
|
||||||
// type
|
|
||||||
'&.plain': {
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
borderColor: 'transparent',
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.primary': {
|
|
||||||
color: 'var(--affine-pure-white)',
|
|
||||||
background: 'var(--affine-primary-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
|
||||||
},
|
|
||||||
'&.primary:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
|
|
||||||
},
|
|
||||||
'&.primary.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.primary.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-primary-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.error': {
|
|
||||||
color: 'var(--affine-pure-white)',
|
|
||||||
background: 'var(--affine-error-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
|
||||||
},
|
|
||||||
'&.error:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
|
|
||||||
},
|
|
||||||
'&.error.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.error.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-error-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.warning': {
|
|
||||||
color: 'var(--affine-white)',
|
|
||||||
background: 'var(--affine-warning-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
|
||||||
},
|
|
||||||
'&.warning:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
|
|
||||||
},
|
|
||||||
'&.warning.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.warning.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-warning-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.success': {
|
|
||||||
color: 'var(--affine-pure-white)',
|
|
||||||
background: 'var(--affine-success-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
|
||||||
},
|
|
||||||
'&.success:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
|
|
||||||
},
|
|
||||||
'&.success.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.success.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-success-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.processing': {
|
|
||||||
color: 'var(--affine-pure-white)',
|
|
||||||
background: 'var(--affine-processing-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: 'var(--affine-button-inner-shadow)',
|
|
||||||
},
|
|
||||||
'&.processing:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
|
|
||||||
},
|
|
||||||
'&.processing.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.processing.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-processing-color)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
globalStyle(`${button} > span`, {
|
|
||||||
// flex: 1,
|
|
||||||
lineHeight: 1,
|
|
||||||
padding: '0 4px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const buttonIcon = style({
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'inline-flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
color: 'var(--affine-icon-color)',
|
|
||||||
fontSize: '16px',
|
|
||||||
width: '16px',
|
|
||||||
height: '16px',
|
|
||||||
selectors: {
|
|
||||||
'&.start': {
|
|
||||||
marginRight: '4px',
|
|
||||||
},
|
|
||||||
'&.end': {
|
|
||||||
marginLeft: '4px',
|
|
||||||
},
|
|
||||||
'&.large': {
|
|
||||||
fontSize: '20px',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
},
|
|
||||||
'&.extraLarge': {
|
|
||||||
fontSize: '20px',
|
|
||||||
width: '20px',
|
|
||||||
height: '20px',
|
|
||||||
},
|
|
||||||
'&.color-white': {
|
|
||||||
color: 'var(--affine-white)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const iconButton = style({
|
|
||||||
display: 'inline-flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
userSelect: 'none',
|
|
||||||
touchAction: 'manipulation',
|
|
||||||
outline: '0',
|
|
||||||
border: '1px solid',
|
|
||||||
borderRadius: '4px',
|
|
||||||
transition: 'all .3s',
|
|
||||||
['WebkitAppRegion' as string]: 'no-drag',
|
|
||||||
|
|
||||||
// changeable
|
|
||||||
width: '24px',
|
|
||||||
height: '24px',
|
|
||||||
fontSize: '20px',
|
|
||||||
color: 'var(--affine-text-primary-color)',
|
|
||||||
borderColor: 'var(--affine-border-color)',
|
|
||||||
selectors: {
|
|
||||||
'&.without-padding': {
|
|
||||||
margin: '-2px',
|
|
||||||
},
|
|
||||||
'&.active': {
|
|
||||||
color: 'var(--affine-primary-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-hover-color)',
|
|
||||||
},
|
|
||||||
'&.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
color: 'var(--affine-disable-color)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
'&.loading': {
|
|
||||||
cursor: 'default',
|
|
||||||
color: 'var(--affine-disable-color)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
|
|
||||||
{
|
|
||||||
background: 'inherit',
|
|
||||||
},
|
|
||||||
|
|
||||||
// size
|
|
||||||
'&.large': {
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
fontSize: '24px',
|
|
||||||
},
|
|
||||||
'&.large.without-padding': {
|
|
||||||
margin: '-4px',
|
|
||||||
},
|
|
||||||
'&.small': { width: '20px', height: '20px', fontSize: '16px' },
|
|
||||||
'&.extra-small': { width: '16px', height: '16px', fontSize: '12px' },
|
|
||||||
|
|
||||||
// type
|
|
||||||
'&.plain': {
|
|
||||||
color: 'var(--affine-icon-color)',
|
|
||||||
borderColor: 'transparent',
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
'&.plain.active': {
|
|
||||||
color: 'var(--affine-primary-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.primary': {
|
|
||||||
color: 'var(--affine-white)',
|
|
||||||
background: 'var(--affine-primary-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
|
||||||
},
|
|
||||||
'&.primary:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
|
|
||||||
},
|
|
||||||
'&.primary.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.primary.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-primary-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.error': {
|
|
||||||
color: 'var(--affine-white)',
|
|
||||||
background: 'var(--affine-error-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
|
||||||
},
|
|
||||||
'&.error:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
|
|
||||||
},
|
|
||||||
'&.error.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.error.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-error-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.warning': {
|
|
||||||
color: 'var(--affine-white)',
|
|
||||||
background: 'var(--affine-warning-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
|
||||||
},
|
|
||||||
'&.warning:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
|
|
||||||
},
|
|
||||||
'&.warning.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.warning.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-warning-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.success': {
|
|
||||||
color: 'var(--affine-white)',
|
|
||||||
background: 'var(--affine-success-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
|
||||||
},
|
|
||||||
'&.success:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
|
|
||||||
},
|
|
||||||
'&.success.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.success.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-success-color)',
|
|
||||||
},
|
|
||||||
|
|
||||||
'&.processing': {
|
|
||||||
color: 'var(--affine-white)',
|
|
||||||
background: 'var(--affine-processing-color)',
|
|
||||||
borderColor: 'var(--affine-black-10)',
|
|
||||||
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
|
||||||
},
|
|
||||||
'&.processing:not(.without-hover):hover': {
|
|
||||||
background:
|
|
||||||
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
|
|
||||||
},
|
|
||||||
'&.processing.disabled': {
|
|
||||||
opacity: '.4',
|
|
||||||
cursor: 'default',
|
|
||||||
},
|
|
||||||
'&.processing.disabled:not(.without-hover):hover': {
|
|
||||||
background: 'var(--affine-processing-color)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
@ -1,4 +1,4 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const dropdownBtn = style({
|
export const dropdownBtn = style({
|
||||||
display: 'inline-flex',
|
display: 'inline-flex',
|
||||||
@ -9,8 +9,8 @@ export const dropdownBtn = style({
|
|||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
color: 'var(--affine-text-primary-color)',
|
color: 'var(--affine-text-primary-color)',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
background: 'var(--affine-button-gray-color)',
|
background: 'var(--affine-background-primary-color)',
|
||||||
boxShadow: 'var(--affine-float-button-shadow)',
|
border: '1px solid var(--affine-border-color)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: 'var(--affine-font-sm)',
|
fontSize: 'var(--affine-font-sm)',
|
||||||
// width: '100%',
|
// width: '100%',
|
||||||
@ -22,6 +22,12 @@ export const dropdownBtn = style({
|
|||||||
'&:hover': {
|
'&:hover': {
|
||||||
background: 'var(--affine-hover-color-filled)',
|
background: 'var(--affine-hover-color-filled)',
|
||||||
},
|
},
|
||||||
|
'&[data-size=default]': {
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
'&[data-size=small]': {
|
||||||
|
height: 28,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -106,3 +112,365 @@ export const radioButtonGroup = style({
|
|||||||
// @ts-expect-error - fix electron drag
|
// @ts-expect-error - fix electron drag
|
||||||
WebkitAppRegion: 'no-drag',
|
WebkitAppRegion: 'no-drag',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const button = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
outline: '0',
|
||||||
|
border: '1px solid',
|
||||||
|
padding: '0 18px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: 'var(--affine-font-base)',
|
||||||
|
transition: 'all .3s',
|
||||||
|
['WebkitAppRegion' as string]: 'no-drag',
|
||||||
|
fontWeight: 600,
|
||||||
|
|
||||||
|
// changeable
|
||||||
|
height: '28px',
|
||||||
|
background: 'var(--affine-white)',
|
||||||
|
borderColor: 'var(--affine-border-color)',
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&.text-bold': {
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
'&:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
'&.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
color: 'var(--affine-disable-color)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&.loading': {
|
||||||
|
cursor: 'default',
|
||||||
|
color: 'var(--affine-disable-color)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
|
||||||
|
{
|
||||||
|
background: 'inherit',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.block': { display: 'flex', width: '100%' },
|
||||||
|
|
||||||
|
'&.circle': {
|
||||||
|
borderRadius: '50%',
|
||||||
|
},
|
||||||
|
'&.round': {
|
||||||
|
borderRadius: '14px',
|
||||||
|
},
|
||||||
|
// size
|
||||||
|
'&.large': {
|
||||||
|
height: '32px',
|
||||||
|
},
|
||||||
|
'&.round.large': {
|
||||||
|
borderRadius: '16px',
|
||||||
|
},
|
||||||
|
'&.extraLarge': {
|
||||||
|
height: '40px',
|
||||||
|
},
|
||||||
|
'&.round.extraLarge': {
|
||||||
|
borderRadius: '20px',
|
||||||
|
},
|
||||||
|
|
||||||
|
// type
|
||||||
|
'&.plain': {
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.primary': {
|
||||||
|
color: 'var(--affine-pure-white)',
|
||||||
|
background: 'var(--affine-primary-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||||
|
},
|
||||||
|
'&.primary:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
|
||||||
|
},
|
||||||
|
'&.primary.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.primary.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-primary-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.error': {
|
||||||
|
color: 'var(--affine-pure-white)',
|
||||||
|
background: 'var(--affine-error-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||||
|
},
|
||||||
|
'&.error:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
|
||||||
|
},
|
||||||
|
'&.error.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.error.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-error-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.warning': {
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
background: 'var(--affine-warning-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||||
|
},
|
||||||
|
'&.warning:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
|
||||||
|
},
|
||||||
|
'&.warning.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.warning.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-warning-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.success': {
|
||||||
|
color: 'var(--affine-pure-white)',
|
||||||
|
background: 'var(--affine-success-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||||
|
},
|
||||||
|
'&.success:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
|
||||||
|
},
|
||||||
|
'&.success.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.success.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-success-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.processing': {
|
||||||
|
color: 'var(--affine-pure-white)',
|
||||||
|
background: 'var(--affine-processing-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: 'var(--affine-button-inner-shadow)',
|
||||||
|
},
|
||||||
|
'&.processing:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
|
||||||
|
},
|
||||||
|
'&.processing.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.processing.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-processing-color)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${button} > span`, {
|
||||||
|
// flex: 1,
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '0 4px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buttonIcon = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'inline-flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
color: 'var(--affine-icon-color)',
|
||||||
|
fontSize: '16px',
|
||||||
|
width: '16px',
|
||||||
|
height: '16px',
|
||||||
|
selectors: {
|
||||||
|
'&.start': {
|
||||||
|
marginRight: '4px',
|
||||||
|
},
|
||||||
|
'&.end': {
|
||||||
|
marginLeft: '4px',
|
||||||
|
},
|
||||||
|
'&.large': {
|
||||||
|
fontSize: '20px',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
},
|
||||||
|
'&.extraLarge': {
|
||||||
|
fontSize: '20px',
|
||||||
|
width: '20px',
|
||||||
|
height: '20px',
|
||||||
|
},
|
||||||
|
'&.color-white': {
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iconButton = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'manipulation',
|
||||||
|
outline: '0',
|
||||||
|
border: '1px solid',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'all .3s',
|
||||||
|
['WebkitAppRegion' as string]: 'no-drag',
|
||||||
|
|
||||||
|
// changeable
|
||||||
|
width: '24px',
|
||||||
|
height: '24px',
|
||||||
|
fontSize: '20px',
|
||||||
|
color: 'var(--affine-text-primary-color)',
|
||||||
|
borderColor: 'var(--affine-border-color)',
|
||||||
|
selectors: {
|
||||||
|
'&.without-padding': {
|
||||||
|
margin: '-2px',
|
||||||
|
},
|
||||||
|
'&.active': {
|
||||||
|
color: 'var(--affine-primary-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-hover-color)',
|
||||||
|
},
|
||||||
|
'&.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
color: 'var(--affine-disable-color)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&.loading': {
|
||||||
|
cursor: 'default',
|
||||||
|
color: 'var(--affine-disable-color)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
'&.disabled:not(.without-hover):hover, &.loading:not(.without-hover):hover':
|
||||||
|
{
|
||||||
|
background: 'inherit',
|
||||||
|
},
|
||||||
|
|
||||||
|
// size
|
||||||
|
'&.large': {
|
||||||
|
width: '32px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '24px',
|
||||||
|
},
|
||||||
|
'&.large.without-padding': {
|
||||||
|
margin: '-4px',
|
||||||
|
},
|
||||||
|
'&.small': { width: '20px', height: '20px', fontSize: '16px' },
|
||||||
|
'&.extra-small': { width: '16px', height: '16px', fontSize: '12px' },
|
||||||
|
|
||||||
|
// type
|
||||||
|
'&.plain': {
|
||||||
|
color: 'var(--affine-icon-color)',
|
||||||
|
borderColor: 'transparent',
|
||||||
|
background: 'transparent',
|
||||||
|
},
|
||||||
|
'&.plain.active': {
|
||||||
|
color: 'var(--affine-primary-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.primary': {
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
background: 'var(--affine-primary-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||||
|
},
|
||||||
|
'&.primary:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-primary-color)',
|
||||||
|
},
|
||||||
|
'&.primary.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.primary.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-primary-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.error': {
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
background: 'var(--affine-error-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||||
|
},
|
||||||
|
'&.error:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-error-color)',
|
||||||
|
},
|
||||||
|
'&.error.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.error.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-error-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.warning': {
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
background: 'var(--affine-warning-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||||
|
},
|
||||||
|
'&.warning:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-warning-color)',
|
||||||
|
},
|
||||||
|
'&.warning.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.warning.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-warning-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.success': {
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
background: 'var(--affine-success-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||||
|
},
|
||||||
|
'&.success:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-success-color)',
|
||||||
|
},
|
||||||
|
'&.success.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.success.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-success-color)',
|
||||||
|
},
|
||||||
|
|
||||||
|
'&.processing': {
|
||||||
|
color: 'var(--affine-white)',
|
||||||
|
background: 'var(--affine-processing-color)',
|
||||||
|
borderColor: 'var(--affine-black-10)',
|
||||||
|
boxShadow: '0px 1px 2px 0px rgba(255, 255, 255, 0.25) inset',
|
||||||
|
},
|
||||||
|
'&.processing:not(.without-hover):hover': {
|
||||||
|
background:
|
||||||
|
'linear-gradient(0deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.04) 100%), var(--affine-processing-color)',
|
||||||
|
},
|
||||||
|
'&.processing.disabled': {
|
||||||
|
opacity: '.4',
|
||||||
|
cursor: 'default',
|
||||||
|
},
|
||||||
|
'&.processing.disabled:not(.without-hover):hover': {
|
||||||
|
background: 'var(--affine-processing-color)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -1,93 +0,0 @@
|
|||||||
import { displayInlineFlex, styled } from '../../styles';
|
|
||||||
import type { ButtonProps } from './interface';
|
|
||||||
import { getButtonColors, getSize } from './utils';
|
|
||||||
|
|
||||||
export const StyledButton = styled('button', {
|
|
||||||
shouldForwardProp: prop => {
|
|
||||||
return ![
|
|
||||||
'hoverBackground',
|
|
||||||
'shape',
|
|
||||||
'hoverColor',
|
|
||||||
'hoverStyle',
|
|
||||||
'type',
|
|
||||||
'bold',
|
|
||||||
'noBorder',
|
|
||||||
].includes(prop as string);
|
|
||||||
},
|
|
||||||
})<
|
|
||||||
Pick<
|
|
||||||
ButtonProps,
|
|
||||||
| 'size'
|
|
||||||
| 'disabled'
|
|
||||||
| 'hoverBackground'
|
|
||||||
| 'hoverColor'
|
|
||||||
| 'hoverStyle'
|
|
||||||
| 'shape'
|
|
||||||
| 'type'
|
|
||||||
| 'bold'
|
|
||||||
| 'noBorder'
|
|
||||||
>
|
|
||||||
>(({
|
|
||||||
theme,
|
|
||||||
size = 'default',
|
|
||||||
disabled,
|
|
||||||
hoverBackground,
|
|
||||||
hoverColor,
|
|
||||||
hoverStyle,
|
|
||||||
bold = false,
|
|
||||||
shape = 'default',
|
|
||||||
type = 'default',
|
|
||||||
noBorder = false,
|
|
||||||
}) => {
|
|
||||||
const { fontSize, borderRadius, padding, height } = getSize(size);
|
|
||||||
|
|
||||||
return {
|
|
||||||
height,
|
|
||||||
paddingLeft: padding,
|
|
||||||
paddingRight: padding,
|
|
||||||
border: noBorder ? 'none' : '1px solid',
|
|
||||||
WebkitAppRegion: 'no-drag',
|
|
||||||
...displayInlineFlex('center', 'center'),
|
|
||||||
gap: '8px',
|
|
||||||
position: 'relative',
|
|
||||||
// TODO: disabled color is not decided
|
|
||||||
...(disabled
|
|
||||||
? {
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
color: 'var(--affine-text-disable-color)',
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
// TODO: Implement circle shape
|
|
||||||
borderRadius: shape === 'default' ? borderRadius : height / 2,
|
|
||||||
fontSize,
|
|
||||||
fontWeight: bold ? '500' : '400',
|
|
||||||
'.affine-button-icon': {
|
|
||||||
color: 'var(--affine-icon-color)',
|
|
||||||
},
|
|
||||||
'.affine-button-icon__fixed': {
|
|
||||||
color: 'var(--affine-icon-color)',
|
|
||||||
},
|
|
||||||
'>span': {
|
|
||||||
width: 'max-content',
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
...getButtonColors(theme, type, disabled, {
|
|
||||||
hoverBackground,
|
|
||||||
hoverColor,
|
|
||||||
hoverStyle,
|
|
||||||
}),
|
|
||||||
|
|
||||||
// TODO: disabled hover should be implemented
|
|
||||||
//
|
|
||||||
// ':hover': {
|
|
||||||
// color: hoverColor ?? 'var(--affine-primary-color)',
|
|
||||||
// background: hoverBackground ?? 'var(--affine-hover-color)',
|
|
||||||
// '.affine-button-icon':{
|
|
||||||
//
|
|
||||||
// }
|
|
||||||
// ...(hoverStyle ?? {}),
|
|
||||||
// },
|
|
||||||
};
|
|
||||||
});
|
|
@ -1,38 +1,6 @@
|
|||||||
import type { Theme } from '@mui/material';
|
import type { Theme } from '@mui/material';
|
||||||
|
|
||||||
import type { ButtonProps } from './interface';
|
import type { ButtonProps } from './interface';
|
||||||
import { SIZE_DEFAULT, SIZE_MIDDLE, SIZE_SMALL } from './interface';
|
|
||||||
|
|
||||||
// TODO: Designer is not sure about the size, Now, is just use default size
|
|
||||||
export const SIZE_CONFIG = {
|
|
||||||
[SIZE_SMALL]: {
|
|
||||||
iconSize: 16,
|
|
||||||
fontSize: 'var(--affine-font-xs)',
|
|
||||||
borderRadius: 8,
|
|
||||||
height: 28,
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
[SIZE_MIDDLE]: {
|
|
||||||
iconSize: 20,
|
|
||||||
fontSize: 'var(--affine-font-sm)',
|
|
||||||
borderRadius: 8,
|
|
||||||
height: 32,
|
|
||||||
padding: 12,
|
|
||||||
},
|
|
||||||
[SIZE_DEFAULT]: {
|
|
||||||
iconSize: 24,
|
|
||||||
fontSize: 'var(--affine-font-base)',
|
|
||||||
height: 38,
|
|
||||||
padding: 24,
|
|
||||||
borderRadius: 8,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const getSize = (
|
|
||||||
size: typeof SIZE_SMALL | typeof SIZE_MIDDLE | typeof SIZE_DEFAULT
|
|
||||||
) => {
|
|
||||||
return SIZE_CONFIG[size];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getButtonColors = (
|
export const getButtonColors = (
|
||||||
_theme: Theme,
|
_theme: Theme,
|
||||||
|
@ -5,21 +5,26 @@ import { type HTMLAttributes, useCallback, useEffect, useRef } from 'react';
|
|||||||
import * as icons from './icons';
|
import * as icons from './icons';
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
|
|
||||||
type CheckboxProps = Omit<HTMLAttributes<HTMLInputElement>, 'onChange'> & {
|
export type CheckboxProps = Omit<
|
||||||
|
HTMLAttributes<HTMLInputElement>,
|
||||||
|
'onChange'
|
||||||
|
> & {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
onChange: (
|
onChange: (
|
||||||
event: React.ChangeEvent<HTMLInputElement>,
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
checked: boolean
|
checked: boolean
|
||||||
) => void;
|
) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
intermediate?: boolean;
|
indeterminate?: boolean;
|
||||||
|
animation?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Checkbox = ({
|
export const Checkbox = ({
|
||||||
checked,
|
checked,
|
||||||
onChange,
|
onChange,
|
||||||
intermediate,
|
indeterminate: indeterminate,
|
||||||
disabled,
|
disabled,
|
||||||
|
animation,
|
||||||
...otherProps
|
...otherProps
|
||||||
}: CheckboxProps) => {
|
}: CheckboxProps) => {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -28,23 +33,23 @@ export const Checkbox = ({
|
|||||||
const newChecked = event.target.checked;
|
const newChecked = event.target.checked;
|
||||||
onChange(event, newChecked);
|
onChange(event, newChecked);
|
||||||
const inputElement = inputRef.current;
|
const inputElement = inputRef.current;
|
||||||
if (newChecked && inputElement) {
|
if (newChecked && inputElement && animation) {
|
||||||
playCheckAnimation(inputElement.parentElement as Element).catch(
|
playCheckAnimation(inputElement.parentElement as Element).catch(
|
||||||
console.error
|
console.error
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onChange]
|
[onChange, animation]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
inputRef.current.indeterminate = !!intermediate;
|
inputRef.current.indeterminate = !!indeterminate;
|
||||||
}
|
}
|
||||||
}, [intermediate]);
|
}, [indeterminate]);
|
||||||
|
|
||||||
const icon = intermediate
|
const icon = indeterminate
|
||||||
? icons.intermediate
|
? icons.indeterminate
|
||||||
: checked
|
: checked
|
||||||
? icons.checked
|
? icons.checked
|
||||||
: icons.unchecked;
|
: icons.unchecked;
|
||||||
@ -83,18 +88,21 @@ export const playCheckAnimation = async (refElement: Element) => {
|
|||||||
await sparkingEl.animate(
|
await sparkingEl.animate(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
offset: 0.5,
|
|
||||||
boxShadow:
|
boxShadow:
|
||||||
'0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb',
|
'0 -18px 0 -8px #1e96eb, 16px -8px 0 -8px #1e96eb, 16px 8px 0 -8px #1e96eb, 0 18px 0 -8px #1e96eb, -16px 8px 0 -8px #1e96eb, -16px -8px 0 -8px #1e96eb',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
offset: 1,
|
|
||||||
boxShadow:
|
|
||||||
'0 -32px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
{ duration: 500, easing: 'ease', fill: 'forwards' }
|
{ duration: 240, easing: 'ease', fill: 'forwards' }
|
||||||
).finished;
|
).finished;
|
||||||
|
|
||||||
|
await sparkingEl.animate(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
boxShadow:
|
||||||
|
'0 -36px 0 -10px transparent, 32px -16px 0 -10px transparent, 32px 16px 0 -10px transparent, 0 36px 0 -10px transparent, -32px 16px 0 -10px transparent, -32px -16px 0 -10px transparent',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ duration: 360, easing: 'ease', fill: 'forwards' }
|
||||||
|
).finished;
|
||||||
sparkingEl.remove();
|
sparkingEl.remove();
|
||||||
};
|
};
|
||||||
|
@ -32,7 +32,7 @@ const checked = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const intermediate = (
|
const indeterminate = (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="1em"
|
width="1em"
|
||||||
@ -49,4 +49,4 @@ const intermediate = (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
export { checked, intermediate, unchecked };
|
export { checked, indeterminate, unchecked };
|
||||||
|
@ -0,0 +1,9 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
width: '1em',
|
||||||
|
height: '1em',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
840
packages/frontend/component/src/ui/lottie/collections-icon.json
Normal file
840
packages/frontend/component/src/ui/lottie/collections-icon.json
Normal file
@ -0,0 +1,840 @@
|
|||||||
|
{
|
||||||
|
"v": "5.12.1",
|
||||||
|
"fr": 120,
|
||||||
|
"ip": 0,
|
||||||
|
"op": 76,
|
||||||
|
"w": 300,
|
||||||
|
"h": 300,
|
||||||
|
"nm": "合成 1",
|
||||||
|
"ddd": 0,
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"id": "comp_0",
|
||||||
|
"nm": "预合成 1",
|
||||||
|
"fr": 120,
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 1,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "1",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.7,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.3,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 0,
|
||||||
|
"s": [154, 140, 0],
|
||||||
|
"to": [0, -1.667, 0],
|
||||||
|
"ti": [0, -15.5, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.833,
|
||||||
|
"y": 0.833
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.3,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 14.57,
|
||||||
|
"s": [154, 130, 0],
|
||||||
|
"to": [0, 15.5, 0],
|
||||||
|
"ti": [0, -17.167, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.5
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.167,
|
||||||
|
"y": 0.167
|
||||||
|
},
|
||||||
|
"t": 30,
|
||||||
|
"s": [154, 233, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.833,
|
||||||
|
"y": 0.833
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 171,
|
||||||
|
"s": [154, 233, 0],
|
||||||
|
"to": [0, -15.5, 0],
|
||||||
|
"ti": [0, 15.5, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": 193,
|
||||||
|
"s": [154, 140, 0]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2,
|
||||||
|
"x": "var $bm_rt;\nvar enable, amp, freq, decay, n, t, v;\ntry {\n $bm_rt = enable = effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0001');\n if (enable == 0) {\n $bm_rt = value;\n } else {\n amp = $bm_div(effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0003'), 5);\n freq = $bm_div(effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0004'), 10);\n decay = $bm_div(effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0005'), 3);\n n = 0, 0 < numKeys && (n = nearestKey(time).index, key(n).time > time && n--), t = 0 === n ? 0 : $bm_sub(time, key(n).time), $bm_rt = 0 < n ? (v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10))), $bm_sum(value, $bm_div($bm_mul($bm_mul($bm_div(v, 100), amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))))) : value;\n }\n} catch (err) {\n $bm_rt = value = value;\n}"
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 12, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [1246, 1246, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"ef": [
|
||||||
|
{
|
||||||
|
"ty": 5,
|
||||||
|
"nm": "Excite - 位置",
|
||||||
|
"np": 8,
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33",
|
||||||
|
"ix": 1,
|
||||||
|
"en": 1,
|
||||||
|
"ef": [
|
||||||
|
{
|
||||||
|
"ty": 7,
|
||||||
|
"nm": "Enable",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0001",
|
||||||
|
"ix": 1,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1,
|
||||||
|
"ix": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "Properties",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0002",
|
||||||
|
"ix": 2,
|
||||||
|
"v": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Overshoot",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0003",
|
||||||
|
"ix": 3,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 20,
|
||||||
|
"ix": 3,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Bounce",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0004",
|
||||||
|
"ix": 4,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 4,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Friction",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0005",
|
||||||
|
"ix": 5,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 5,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0006",
|
||||||
|
"ix": 6,
|
||||||
|
"v": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[7, 8],
|
||||||
|
[17, 8]
|
||||||
|
],
|
||||||
|
"c": false
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.5,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 2,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 2,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "2",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.7,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.3,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 4,
|
||||||
|
"s": [154, 140, 0],
|
||||||
|
"to": [0, -1.667, 0],
|
||||||
|
"ti": [0, -15.5, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.833,
|
||||||
|
"y": 0.833
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.3,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 18.57,
|
||||||
|
"s": [154, 130, 0],
|
||||||
|
"to": [0, 15.5, 0],
|
||||||
|
"ti": [0, -17.167, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0.5
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.167,
|
||||||
|
"y": 0.167
|
||||||
|
},
|
||||||
|
"t": 34,
|
||||||
|
"s": [154, 233, 0],
|
||||||
|
"to": [0, 0, 0],
|
||||||
|
"ti": [0, 0, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.833,
|
||||||
|
"y": 0.833
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.5,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 168,
|
||||||
|
"s": [154, 233, 0],
|
||||||
|
"to": [0, -15.5, 0],
|
||||||
|
"ti": [0, 15.5, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": 190,
|
||||||
|
"s": [154, 140, 0]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2,
|
||||||
|
"x": "var $bm_rt;\nvar enable, amp, freq, decay, n, t, v;\ntry {\n $bm_rt = enable = effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0001');\n if (enable == 0) {\n $bm_rt = value;\n } else {\n amp = $bm_div(effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0003'), 5);\n freq = $bm_div(effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0004'), 10);\n decay = $bm_div(effect('Excite - 位置')('Pseudo/BNCA2506f0b33-0005'), 3);\n n = 0, 0 < numKeys && (n = nearestKey(time).index, key(n).time > time && n--), t = 0 === n ? 0 : $bm_sub(time, key(n).time), $bm_rt = 0 < n ? (v = velocityAtTime($bm_sub(key(n).time, $bm_div(thisComp.frameDuration, 10))), $bm_sum(value, $bm_div($bm_mul($bm_mul($bm_div(v, 100), amp), Math.sin($bm_mul($bm_mul($bm_mul(freq, t), 2), Math.PI))), Math.exp($bm_mul(decay, t))))) : value;\n }\n} catch (err) {\n $bm_rt = value = value;\n}"
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 12, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [1246, 1246, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"ef": [
|
||||||
|
{
|
||||||
|
"ty": 5,
|
||||||
|
"nm": "Excite - 位置",
|
||||||
|
"np": 8,
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33",
|
||||||
|
"ix": 1,
|
||||||
|
"en": 1,
|
||||||
|
"ef": [
|
||||||
|
{
|
||||||
|
"ty": 7,
|
||||||
|
"nm": "Enable",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0001",
|
||||||
|
"ix": 1,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1,
|
||||||
|
"ix": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "Properties",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0002",
|
||||||
|
"ix": 2,
|
||||||
|
"v": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Overshoot",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0003",
|
||||||
|
"ix": 3,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 20,
|
||||||
|
"ix": 3,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Bounce",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0004",
|
||||||
|
"ix": 4,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 4,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Friction",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0005",
|
||||||
|
"ix": 5,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 5,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0006",
|
||||||
|
"ix": 6,
|
||||||
|
"v": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[9, 5],
|
||||||
|
[15, 5]
|
||||||
|
],
|
||||||
|
"c": false
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.5,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 2,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 1,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "“图层 4”轮廓",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [154, 140, 0],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 12, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [1246, 1246, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, -1.105],
|
||||||
|
[0, 0],
|
||||||
|
[1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 1.105],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 1.105],
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, -1.105]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[-5, -4.5],
|
||||||
|
[5, -4.5],
|
||||||
|
[7, -2.5],
|
||||||
|
[7, 2.5],
|
||||||
|
[5, 4.5],
|
||||||
|
[-5, 4.5],
|
||||||
|
[-7, 2.5],
|
||||||
|
[-7, -2.5]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.5,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 1,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 15.5],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 2,
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "预合成 1",
|
||||||
|
"refId": "comp_0",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [150, 150, 0],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [150, 150, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"hasMask": true,
|
||||||
|
"masksProperties": [
|
||||||
|
{
|
||||||
|
"inv": false,
|
||||||
|
"mode": "a",
|
||||||
|
"pt": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[291.703, 10],
|
||||||
|
[14, 10],
|
||||||
|
[14, 128.273],
|
||||||
|
[291.703, 128.273]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"x": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"nm": "蒙版 1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"w": 300,
|
||||||
|
"h": 300,
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"bm": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": [],
|
||||||
|
"props": {}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import Lottie, { type LottieRef } from 'lottie-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './collections-icon.css';
|
||||||
|
import animationData from './collections-icon.json';
|
||||||
|
|
||||||
|
export interface CollectionsIconProps {
|
||||||
|
closed: boolean; // eg, when collections icon is a "dragged over" state
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// animated collections icon that has two states: closed and opened
|
||||||
|
export const AnimatedCollectionsIcon = ({
|
||||||
|
closed,
|
||||||
|
className,
|
||||||
|
}: CollectionsIconProps) => {
|
||||||
|
const lottieRef: LottieRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lottieRef.current) {
|
||||||
|
const lottie = lottieRef.current;
|
||||||
|
if (closed) {
|
||||||
|
lottie.setDirection(1);
|
||||||
|
} else {
|
||||||
|
lottie.setDirection(-1);
|
||||||
|
}
|
||||||
|
lottie.play();
|
||||||
|
}
|
||||||
|
}, [closed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Lottie
|
||||||
|
className={clsx(styles.root, className)}
|
||||||
|
autoPlay={false}
|
||||||
|
loop={false}
|
||||||
|
lottieRef={lottieRef}
|
||||||
|
animationData={animationData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,9 @@
|
|||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const root = style({
|
||||||
|
width: '1em',
|
||||||
|
height: '1em',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
});
|
989
packages/frontend/component/src/ui/lottie/delete-icon.json
Normal file
989
packages/frontend/component/src/ui/lottie/delete-icon.json
Normal file
@ -0,0 +1,989 @@
|
|||||||
|
{
|
||||||
|
"v": "5.12.1",
|
||||||
|
"fr": 120,
|
||||||
|
"ip": 0,
|
||||||
|
"op": 41,
|
||||||
|
"w": 240,
|
||||||
|
"h": 240,
|
||||||
|
"nm": "Delete",
|
||||||
|
"ddd": 0,
|
||||||
|
"assets": [],
|
||||||
|
"layers": [
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 1,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "head",
|
||||||
|
"parent": 2,
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 5, 0],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 5, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[-1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, -1.105],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, -1.105],
|
||||||
|
[0, 0],
|
||||||
|
[1.105, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[-3, 0],
|
||||||
|
[-1, -2],
|
||||||
|
[1, -2],
|
||||||
|
[3, 0],
|
||||||
|
[3, 2],
|
||||||
|
[-3, 2]
|
||||||
|
],
|
||||||
|
"c": true
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.5,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 1,
|
||||||
|
"lj": 1,
|
||||||
|
"ml": 4,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 5],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 2,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "headline",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": [0.62],
|
||||||
|
"y": [1]
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": [0.001],
|
||||||
|
"y": [0]
|
||||||
|
},
|
||||||
|
"t": 17.891,
|
||||||
|
"s": [0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": 26,
|
||||||
|
"s": [-23]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.62,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.001,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 10,
|
||||||
|
"s": [119, 67, 0],
|
||||||
|
"to": [-2.167, -0.583, 0],
|
||||||
|
"ti": [5.25, 2.583, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": 0.62,
|
||||||
|
"y": 1
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": 0.001,
|
||||||
|
"y": 0
|
||||||
|
},
|
||||||
|
"t": 17.891,
|
||||||
|
"s": [106, 63.5, 0],
|
||||||
|
"to": [-5.25, -2.583, 0],
|
||||||
|
"ti": [3.083, 2, 0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": 25.78125,
|
||||||
|
"s": [87.5, 51.5, 0]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 7, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [1000, 1000, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[4, 7],
|
||||||
|
[20, 7]
|
||||||
|
],
|
||||||
|
"c": false
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.7,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 2,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 3,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "line2",
|
||||||
|
"parent": 5,
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [14, 14, 0],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [14, 14, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[14, 11],
|
||||||
|
[14, 17]
|
||||||
|
],
|
||||||
|
"c": false
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.5,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 2,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 4,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "line1",
|
||||||
|
"parent": 5,
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [10, 14, 0],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [10, 14, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[10, 11],
|
||||||
|
[10, 17]
|
||||||
|
],
|
||||||
|
"c": false
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.5,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 2,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ddd": 0,
|
||||||
|
"ind": 5,
|
||||||
|
"ty": 4,
|
||||||
|
"nm": "body",
|
||||||
|
"sr": 1,
|
||||||
|
"ks": {
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 11
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 1,
|
||||||
|
"k": [
|
||||||
|
{
|
||||||
|
"i": {
|
||||||
|
"x": [0.62],
|
||||||
|
"y": [1]
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"x": [0.001],
|
||||||
|
"y": [0]
|
||||||
|
},
|
||||||
|
"t": 10,
|
||||||
|
"s": [0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"t": 23.80859375,
|
||||||
|
"s": [-9]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ix": 10
|
||||||
|
},
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [49, 207, 0],
|
||||||
|
"ix": 2,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [5, 21, 0],
|
||||||
|
"ix": 1,
|
||||||
|
"l": 2
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [1000, 1000, 100],
|
||||||
|
"ix": 6,
|
||||||
|
"l": 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ao": 0,
|
||||||
|
"ef": [
|
||||||
|
{
|
||||||
|
"ty": 5,
|
||||||
|
"nm": "Excite - 位置",
|
||||||
|
"np": 8,
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33",
|
||||||
|
"ix": 1,
|
||||||
|
"en": 1,
|
||||||
|
"ef": [
|
||||||
|
{
|
||||||
|
"ty": 7,
|
||||||
|
"nm": "Enable",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0001",
|
||||||
|
"ix": 1,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1,
|
||||||
|
"ix": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "Properties",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0002",
|
||||||
|
"ix": 2,
|
||||||
|
"v": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Overshoot",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0003",
|
||||||
|
"ix": 3,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 20,
|
||||||
|
"ix": 3,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Bounce",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0004",
|
||||||
|
"ix": 4,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 4,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Friction",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0005",
|
||||||
|
"ix": 5,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 5,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0006",
|
||||||
|
"ix": 6,
|
||||||
|
"v": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 5,
|
||||||
|
"nm": "Excite - 旋转",
|
||||||
|
"np": 8,
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33",
|
||||||
|
"ix": 2,
|
||||||
|
"en": 1,
|
||||||
|
"ef": [
|
||||||
|
{
|
||||||
|
"ty": 7,
|
||||||
|
"nm": "Enable",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0001",
|
||||||
|
"ix": 1,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1,
|
||||||
|
"ix": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "Properties",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0002",
|
||||||
|
"ix": 2,
|
||||||
|
"v": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Overshoot",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0003",
|
||||||
|
"ix": 3,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 20,
|
||||||
|
"ix": 3,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Bounce",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0004",
|
||||||
|
"ix": 4,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 4,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 0,
|
||||||
|
"nm": "Friction",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0005",
|
||||||
|
"ix": 5,
|
||||||
|
"v": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 40,
|
||||||
|
"ix": 5,
|
||||||
|
"x": "var $bm_rt;\n$bm_rt = clamp(value, 0, 100);"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": 6,
|
||||||
|
"nm": "",
|
||||||
|
"mn": "Pseudo/BNCA2506f0b33-0006",
|
||||||
|
"ix": 6,
|
||||||
|
"v": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shapes": [
|
||||||
|
{
|
||||||
|
"ty": "gr",
|
||||||
|
"it": [
|
||||||
|
{
|
||||||
|
"ind": 0,
|
||||||
|
"ty": "sh",
|
||||||
|
"ix": 1,
|
||||||
|
"ks": {
|
||||||
|
"a": 0,
|
||||||
|
"k": {
|
||||||
|
"i": [
|
||||||
|
[0, 0],
|
||||||
|
[0, 0],
|
||||||
|
[-1.049, 0],
|
||||||
|
[0, 0],
|
||||||
|
[-0.075, 1.046],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"o": [
|
||||||
|
[0, 0],
|
||||||
|
[0.075, 1.046],
|
||||||
|
[0, 0],
|
||||||
|
[1.049, 0],
|
||||||
|
[0, 0],
|
||||||
|
[0, 0]
|
||||||
|
],
|
||||||
|
"v": [
|
||||||
|
[-7, -7],
|
||||||
|
[-6.133, 5.143],
|
||||||
|
[-4.138, 7],
|
||||||
|
[4.138, 7],
|
||||||
|
[6.133, 5.143],
|
||||||
|
[7, -7]
|
||||||
|
],
|
||||||
|
"c": false
|
||||||
|
},
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"nm": "路径 1",
|
||||||
|
"mn": "ADBE Vector Shape - Group",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "st",
|
||||||
|
"c": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"w": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 1.5,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"lc": 1,
|
||||||
|
"lj": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"nm": "描边 1",
|
||||||
|
"mn": "ADBE Vector Graphic - Stroke",
|
||||||
|
"hd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ty": "tr",
|
||||||
|
"p": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [12, 14],
|
||||||
|
"ix": 2
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [0, 0],
|
||||||
|
"ix": 1
|
||||||
|
},
|
||||||
|
"s": {
|
||||||
|
"a": 0,
|
||||||
|
"k": [100, 100],
|
||||||
|
"ix": 3
|
||||||
|
},
|
||||||
|
"r": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 6
|
||||||
|
},
|
||||||
|
"o": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 100,
|
||||||
|
"ix": 7
|
||||||
|
},
|
||||||
|
"sk": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 4
|
||||||
|
},
|
||||||
|
"sa": {
|
||||||
|
"a": 0,
|
||||||
|
"k": 0,
|
||||||
|
"ix": 5
|
||||||
|
},
|
||||||
|
"nm": "变换"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nm": "组 1",
|
||||||
|
"np": 2,
|
||||||
|
"cix": 2,
|
||||||
|
"bm": 0,
|
||||||
|
"ix": 1,
|
||||||
|
"mn": "ADBE Vector Group",
|
||||||
|
"hd": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ip": 0,
|
||||||
|
"op": 1200,
|
||||||
|
"st": 0,
|
||||||
|
"ct": 1,
|
||||||
|
"bm": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"markers": [],
|
||||||
|
"props": {}
|
||||||
|
}
|
38
packages/frontend/component/src/ui/lottie/delete-icon.tsx
Normal file
38
packages/frontend/component/src/ui/lottie/delete-icon.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
import Lottie, { type LottieRef } from 'lottie-react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './delete-icon.css';
|
||||||
|
import animationData from './delete-icon.json';
|
||||||
|
|
||||||
|
export interface DeleteIconProps {
|
||||||
|
closed: boolean; // eg, when delete icon is a "dragged over" state
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// animated delete icon that has two animation states
|
||||||
|
export const AnimatedDeleteIcon = ({ closed, className }: DeleteIconProps) => {
|
||||||
|
const lottieRef: LottieRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lottieRef.current) {
|
||||||
|
const lottie = lottieRef.current;
|
||||||
|
if (closed) {
|
||||||
|
lottie.setDirection(1);
|
||||||
|
} else {
|
||||||
|
lottie.setDirection(-1);
|
||||||
|
}
|
||||||
|
lottie.play();
|
||||||
|
}
|
||||||
|
}, [closed]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Lottie
|
||||||
|
className={clsx(styles.root, className)}
|
||||||
|
autoPlay={false}
|
||||||
|
loop={false}
|
||||||
|
lottieRef={lottieRef}
|
||||||
|
animationData={animationData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -1,6 +1,6 @@
|
|||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { type PropsWithChildren } from 'react';
|
import { type PropsWithChildren, useRef } from 'react';
|
||||||
|
|
||||||
import { useHasScrollTop } from '../../components/app-sidebar/sidebar-containers/use-has-scroll-top';
|
import { useHasScrollTop } from '../../components/app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
@ -23,7 +23,8 @@ export const ScrollableContainer = ({
|
|||||||
viewPortClassName,
|
viewPortClassName,
|
||||||
scrollBarClassName,
|
scrollBarClassName,
|
||||||
}: PropsWithChildren<ScrollableContainerProps>) => {
|
}: PropsWithChildren<ScrollableContainerProps>) => {
|
||||||
const [hasScrollTop, ref] = useHasScrollTop();
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const hasScrollTop = useHasScrollTop(ref);
|
||||||
return (
|
return (
|
||||||
<ScrollArea.Root
|
<ScrollArea.Root
|
||||||
style={_styles}
|
style={_styles}
|
||||||
|
@ -11,11 +11,9 @@ import { useCurrentUser } from '../../hooks/affine/use-current-user';
|
|||||||
import { useIsWorkspaceOwner } from '../../hooks/affine/use-is-workspace-owner';
|
import { useIsWorkspaceOwner } from '../../hooks/affine/use-is-workspace-owner';
|
||||||
import { useWorkspace } from '../../hooks/use-workspace';
|
import { useWorkspace } from '../../hooks/use-workspace';
|
||||||
import {
|
import {
|
||||||
BlockSuitePageList,
|
|
||||||
NewWorkspaceSettingDetail,
|
NewWorkspaceSettingDetail,
|
||||||
PageDetailEditor,
|
PageDetailEditor,
|
||||||
Provider,
|
Provider,
|
||||||
WorkspaceHeader,
|
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
|
|
||||||
const LoginCard = lazy(() =>
|
const LoginCard = lazy(() =>
|
||||||
@ -27,7 +25,6 @@ const LoginCard = lazy(() =>
|
|||||||
export const UI = {
|
export const UI = {
|
||||||
Provider,
|
Provider,
|
||||||
LoginCard,
|
LoginCard,
|
||||||
Header: WorkspaceHeader,
|
|
||||||
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
||||||
const workspace = useWorkspace(currentWorkspaceId);
|
const workspace = useWorkspace(currentWorkspaceId);
|
||||||
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
|
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
|
||||||
@ -61,16 +58,6 @@ export const UI = {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
|
||||||
return (
|
|
||||||
<BlockSuitePageList
|
|
||||||
listType="all"
|
|
||||||
collection={collection}
|
|
||||||
onOpenPage={onOpenPage}
|
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
NewSettingsDetail: ({
|
NewSettingsDetail: ({
|
||||||
currentWorkspaceId,
|
currentWorkspaceId,
|
||||||
onTransformWorkspace,
|
onTransformWorkspace,
|
||||||
|
@ -29,11 +29,9 @@ import { useCallback } from 'react';
|
|||||||
|
|
||||||
import { setPageModeAtom } from '../../atoms';
|
import { setPageModeAtom } from '../../atoms';
|
||||||
import {
|
import {
|
||||||
BlockSuitePageList,
|
|
||||||
NewWorkspaceSettingDetail,
|
NewWorkspaceSettingDetail,
|
||||||
PageDetailEditor,
|
PageDetailEditor,
|
||||||
Provider,
|
Provider,
|
||||||
WorkspaceHeader,
|
|
||||||
} from '../shared';
|
} from '../shared';
|
||||||
|
|
||||||
const logger = new DebugLogger('use-create-first-workspace');
|
const logger = new DebugLogger('use-create-first-workspace');
|
||||||
@ -85,7 +83,6 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
|
|||||||
},
|
},
|
||||||
CRUD,
|
CRUD,
|
||||||
UI: {
|
UI: {
|
||||||
Header: WorkspaceHeader,
|
|
||||||
Provider,
|
Provider,
|
||||||
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
||||||
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(currentWorkspaceId);
|
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(currentWorkspaceId);
|
||||||
@ -105,16 +102,6 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
|
||||||
return (
|
|
||||||
<BlockSuitePageList
|
|
||||||
listType="all"
|
|
||||||
collection={collection}
|
|
||||||
onOpenPage={onOpenPage}
|
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
NewSettingsDetail: ({
|
NewSettingsDetail: ({
|
||||||
currentWorkspaceId,
|
currentWorkspaceId,
|
||||||
onTransformWorkspace,
|
onTransformWorkspace,
|
||||||
|
@ -5,13 +5,10 @@ import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useWorkspace } from '../../hooks/use-workspace';
|
import { useWorkspace } from '../../hooks/use-workspace';
|
||||||
import { BlockSuitePageList, PageDetailEditor, Provider } from '../shared';
|
import { PageDetailEditor, Provider } from '../shared';
|
||||||
|
|
||||||
export const UI = {
|
export const UI = {
|
||||||
Provider,
|
Provider,
|
||||||
Header: () => {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
|
||||||
const workspace = useWorkspace(currentWorkspaceId);
|
const workspace = useWorkspace(currentWorkspaceId);
|
||||||
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
|
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
|
||||||
@ -29,16 +26,6 @@ export const UI = {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
|
|
||||||
return (
|
|
||||||
<BlockSuitePageList
|
|
||||||
listType="all"
|
|
||||||
collection={collection}
|
|
||||||
onOpenPage={onOpenPage}
|
|
||||||
blockSuiteWorkspace={blockSuiteWorkspace}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
NewSettingsDetail: () => {
|
NewSettingsDetail: () => {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
},
|
},
|
||||||
|
@ -14,22 +14,8 @@ export const NewWorkspaceSettingDetail = lazy(() =>
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const BlockSuitePageList = lazy(() =>
|
|
||||||
import('../components/blocksuite/block-suite-page-list').then(
|
|
||||||
({ BlockSuitePageList }) => ({
|
|
||||||
default: BlockSuitePageList,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const PageDetailEditor = lazy(() =>
|
export const PageDetailEditor = lazy(() =>
|
||||||
import('../components/page-detail-editor').then(({ PageDetailEditor }) => ({
|
import('../components/page-detail-editor').then(({ PageDetailEditor }) => ({
|
||||||
default: PageDetailEditor,
|
default: PageDetailEditor,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const WorkspaceHeader = lazy(() =>
|
|
||||||
import('../components/workspace-header').then(({ WorkspaceHeader }) => ({
|
|
||||||
default: WorkspaceHeader,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
210
packages/frontend/core/src/atoms/collections.ts
Normal file
210
packages/frontend/core/src/atoms/collections.ts
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import type { CollectionsCRUDAtom } from '@affine/component/page-list';
|
||||||
|
import type { Collection, DeprecatedCollection } from '@affine/env/filter';
|
||||||
|
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||||
|
import type { Workspace } from '@blocksuite/store';
|
||||||
|
import { currentWorkspaceAtom } from '@toeverything/infra/atom';
|
||||||
|
import { type DBSchema, openDB } from 'idb';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { atomWithObservable } from 'jotai/utils';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
|
||||||
|
import { getUserSetting } from '../utils/user-setting';
|
||||||
|
import { getWorkspaceSetting } from '../utils/workspace-setting';
|
||||||
|
import { sessionAtom } from './cloud-user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
export interface PageCollectionDBV1 extends DBSchema {
|
||||||
|
view: {
|
||||||
|
key: DeprecatedCollection['id'];
|
||||||
|
value: DeprecatedCollection;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
export interface StorageCRUD<Value> {
|
||||||
|
get: (key: string) => Promise<Value | null>;
|
||||||
|
set: (key: string, value: Value) => Promise<string>;
|
||||||
|
delete: (key: string) => Promise<void>;
|
||||||
|
list: () => Promise<string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
const collectionDBAtom = atom(
|
||||||
|
openDB<PageCollectionDBV1>('page-view', 1, {
|
||||||
|
upgrade(database) {
|
||||||
|
database.createObjectStore('view', {
|
||||||
|
keyPath: 'id',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
const localCollectionCRUDAtom = atom(get => ({
|
||||||
|
get: async (key: string) => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view').objectStore('view');
|
||||||
|
return (await t.get(key)) ?? null;
|
||||||
|
},
|
||||||
|
set: async (key: string, value: DeprecatedCollection) => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||||
|
await t.put(value);
|
||||||
|
return key;
|
||||||
|
},
|
||||||
|
delete: async (key: string) => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view', 'readwrite').objectStore('view');
|
||||||
|
await t.delete(key);
|
||||||
|
},
|
||||||
|
list: async () => {
|
||||||
|
const db = await get(collectionDBAtom);
|
||||||
|
const t = db.transaction('view').objectStore('view');
|
||||||
|
return t.getAllKeys();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
|
const getCollections = async (
|
||||||
|
storage: StorageCRUD<DeprecatedCollection>
|
||||||
|
): Promise<DeprecatedCollection[]> => {
|
||||||
|
return storage
|
||||||
|
.list()
|
||||||
|
.then(async keys => {
|
||||||
|
return await Promise.all(keys.map(key => storage.get(key))).then(v =>
|
||||||
|
v.filter((v): v is DeprecatedCollection => v !== null)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Failed to load collections', error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
type BaseCollectionsDataType = {
|
||||||
|
loading: boolean;
|
||||||
|
collections: Collection[];
|
||||||
|
};
|
||||||
|
export const pageCollectionBaseAtom =
|
||||||
|
atomWithObservable<BaseCollectionsDataType>(
|
||||||
|
get => {
|
||||||
|
const currentWorkspacePromise = get(currentWorkspaceAtom);
|
||||||
|
const session = get(sessionAtom);
|
||||||
|
const userId = session?.data?.user.id ?? null;
|
||||||
|
const migrateCollectionsFromIdbData = async (
|
||||||
|
workspace: Workspace
|
||||||
|
): Promise<Collection[]> => {
|
||||||
|
workspace.awarenessStore.awareness.emit('change log');
|
||||||
|
const localCRUD = get(localCollectionCRUDAtom);
|
||||||
|
const collections = await getCollections(localCRUD);
|
||||||
|
const result = collections.filter(v => v.workspaceId === workspace.id);
|
||||||
|
Promise.all(
|
||||||
|
result.map(collection => {
|
||||||
|
return localCRUD.delete(collection.id);
|
||||||
|
})
|
||||||
|
).catch(error => {
|
||||||
|
console.error('Failed to delete collections from indexeddb', error);
|
||||||
|
});
|
||||||
|
return result.map(v => {
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
mode: 'rule',
|
||||||
|
filterList: v.filterList,
|
||||||
|
allowList: v.allowList ?? [],
|
||||||
|
pages: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const migrateCollectionsFromUserData = async (
|
||||||
|
workspace: Workspace
|
||||||
|
): Promise<Collection[]> => {
|
||||||
|
if (userId == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const userSetting = getUserSetting(workspace, userId);
|
||||||
|
await userSetting.loaded;
|
||||||
|
const view = userSetting.view;
|
||||||
|
if (view) {
|
||||||
|
const collections: DeprecatedCollection[] = [...view.values()];
|
||||||
|
//delete collections
|
||||||
|
view.clear();
|
||||||
|
return collections.map(v => {
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
name: v.name,
|
||||||
|
mode: 'rule',
|
||||||
|
filterList: v.filterList,
|
||||||
|
allowList: v.allowList ?? [],
|
||||||
|
pages: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Observable<BaseCollectionsDataType>(subscriber => {
|
||||||
|
const group = new DisposableGroup();
|
||||||
|
currentWorkspacePromise.then(async currentWorkspace => {
|
||||||
|
const collectionsFromLocal =
|
||||||
|
await migrateCollectionsFromIdbData(currentWorkspace);
|
||||||
|
const collectionFromUserSetting =
|
||||||
|
await migrateCollectionsFromUserData(currentWorkspace);
|
||||||
|
const workspaceSetting = getWorkspaceSetting(currentWorkspace);
|
||||||
|
if (collectionsFromLocal.length || collectionFromUserSetting.length) {
|
||||||
|
// migrate collections
|
||||||
|
workspaceSetting.addCollection(
|
||||||
|
...collectionFromUserSetting,
|
||||||
|
...collectionsFromLocal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
subscriber.next({
|
||||||
|
loading: false,
|
||||||
|
collections: workspaceSetting.collections,
|
||||||
|
});
|
||||||
|
if (group.disposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fn = () => {
|
||||||
|
subscriber.next({
|
||||||
|
loading: false,
|
||||||
|
collections: workspaceSetting.collections,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
workspaceSetting.collectionsYArray.observe(fn);
|
||||||
|
group.add(() => {
|
||||||
|
workspaceSetting.collectionsYArray.unobserve(fn);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
group.dispose();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ initialValue: { loading: true, collections: [] } }
|
||||||
|
);
|
||||||
|
export const collectionsCRUDAtom: CollectionsCRUDAtom = atom(get => {
|
||||||
|
const workspacePromise = get(currentWorkspaceAtom);
|
||||||
|
return {
|
||||||
|
addCollection: async (...collections) => {
|
||||||
|
const workspace = await workspacePromise;
|
||||||
|
getWorkspaceSetting(workspace).addCollection(...collections);
|
||||||
|
},
|
||||||
|
collections: get(pageCollectionBaseAtom).collections,
|
||||||
|
updateCollection: async (id, updater) => {
|
||||||
|
const workspace = await workspacePromise;
|
||||||
|
getWorkspaceSetting(workspace).updateCollection(id, updater);
|
||||||
|
},
|
||||||
|
deleteCollection: async (info, ...ids) => {
|
||||||
|
const workspace = await workspacePromise;
|
||||||
|
getWorkspaceSetting(workspace).deleteCollection(info, ...ids);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
@ -2,12 +2,12 @@ import { atom } from 'jotai';
|
|||||||
|
|
||||||
export type TrashModal = {
|
export type TrashModal = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
pageId: string;
|
pageIds: string[];
|
||||||
pageTitle: string;
|
pageTitles: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const trashModalAtom = atom<TrashModal>({
|
export const trashModalAtom = atom<TrashModal>({
|
||||||
open: false,
|
open: false,
|
||||||
pageId: '',
|
pageIds: [],
|
||||||
pageTitle: '',
|
pageTitles: [],
|
||||||
});
|
});
|
||||||
|
@ -70,8 +70,8 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
|||||||
const handleOpenTrashModal = useCallback(() => {
|
const handleOpenTrashModal = useCallback(() => {
|
||||||
setTrashModal({
|
setTrashModal({
|
||||||
open: true,
|
open: true,
|
||||||
pageId,
|
pageIds: [pageId],
|
||||||
pageTitle: pageMeta.title,
|
pageTitles: [pageMeta.title],
|
||||||
});
|
});
|
||||||
}, [pageId, pageMeta.title, setTrashModal]);
|
}, [pageId, pageMeta.title, setTrashModal]);
|
||||||
|
|
||||||
|
@ -1,297 +0,0 @@
|
|||||||
import { Empty } from '@affine/component';
|
|
||||||
import type { ListData, TrashListData } from '@affine/component/page-list';
|
|
||||||
import { PageList, PageListTrashView } from '@affine/component/page-list';
|
|
||||||
import type { Collection } from '@affine/env/filter';
|
|
||||||
import { Trans } from '@affine/i18n';
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
|
||||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
|
||||||
import { type PageMeta, type Workspace } from '@blocksuite/store';
|
|
||||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
|
||||||
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
|
|
||||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
|
||||||
import { Suspense, useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { allPageModeSelectAtom } from '../../../atoms';
|
|
||||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
|
||||||
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
|
|
||||||
import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
|
|
||||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
|
||||||
import { toast } from '../../../utils';
|
|
||||||
import { filterPage } from '../../../utils/filter';
|
|
||||||
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
|
||||||
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
|
|
||||||
import { usePageHelper } from './utils';
|
|
||||||
|
|
||||||
export interface BlockSuitePageListProps {
|
|
||||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
|
||||||
listType: 'all' | 'trash' | 'shared' | 'public';
|
|
||||||
isPublic?: boolean;
|
|
||||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
|
||||||
collection?: Collection;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filter = {
|
|
||||||
all: (pageMeta: PageMeta) => !pageMeta.trash,
|
|
||||||
public: (pageMeta: PageMeta) => !pageMeta.trash,
|
|
||||||
trash: (pageMeta: PageMeta, allMetas: PageMeta[]) => {
|
|
||||||
const parentMeta = allMetas.find(m => m.subpageIds?.includes(pageMeta.id));
|
|
||||||
return !parentMeta?.trash && pageMeta.trash;
|
|
||||||
},
|
|
||||||
shared: (pageMeta: PageMeta) => pageMeta.isPublic && !pageMeta.trash,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PagePreviewInnerProps {
|
|
||||||
workspace: Workspace;
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PagePreviewInner = ({ workspace, pageId }: PagePreviewInnerProps) => {
|
|
||||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
|
||||||
assertExists(page);
|
|
||||||
const previewAtom = useBlockSuitePagePreview(page);
|
|
||||||
const preview = useAtomValue(previewAtom);
|
|
||||||
return preview;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PagePreviewProps {
|
|
||||||
workspace: Workspace;
|
|
||||||
pageId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PagePreview = ({ workspace, pageId }: PagePreviewProps) => {
|
|
||||||
return (
|
|
||||||
<Suspense>
|
|
||||||
<PagePreviewInner workspace={workspace} pageId={pageId} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PageListEmptyProps {
|
|
||||||
createPage?: ReturnType<typeof usePageHelper>['createPage'];
|
|
||||||
listType: BlockSuitePageListProps['listType'];
|
|
||||||
}
|
|
||||||
|
|
||||||
const PageListEmpty = (props: PageListEmptyProps) => {
|
|
||||||
const { listType, createPage } = props;
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
|
|
||||||
const onCreatePage = useCallback(() => {
|
|
||||||
createPage?.();
|
|
||||||
}, [createPage]);
|
|
||||||
|
|
||||||
const getEmptyDescription = () => {
|
|
||||||
if (listType === 'all') {
|
|
||||||
const createNewPageButton = (
|
|
||||||
<button className={emptyDescButton} onClick={onCreatePage}>
|
|
||||||
New Page
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
if (environment.isDesktop) {
|
|
||||||
const shortcut = environment.isMacOs ? '⌘ + N' : 'Ctrl + N';
|
|
||||||
return (
|
|
||||||
<Trans i18nKey="emptyAllPagesClient">
|
|
||||||
Click on the {createNewPageButton} button Or press
|
|
||||||
<kbd className={emptyDescKbd}>{{ shortcut } as any}</kbd> to create
|
|
||||||
your first page.
|
|
||||||
</Trans>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Trans i18nKey="emptyAllPages">
|
|
||||||
Click on the
|
|
||||||
{createNewPageButton}
|
|
||||||
button to create your first page.
|
|
||||||
</Trans>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (listType === 'trash') {
|
|
||||||
return t['emptyTrash']();
|
|
||||||
}
|
|
||||||
if (listType === 'shared') {
|
|
||||||
return t['emptySharedPages']();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={pageListEmptyStyle}>
|
|
||||||
<Empty
|
|
||||||
title={t['com.affine.emptyDesc']()}
|
|
||||||
description={getEmptyDescription()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BlockSuitePageList = ({
|
|
||||||
blockSuiteWorkspace,
|
|
||||||
onOpenPage,
|
|
||||||
listType,
|
|
||||||
isPublic = false,
|
|
||||||
collection,
|
|
||||||
}: BlockSuitePageListProps) => {
|
|
||||||
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
|
|
||||||
const {
|
|
||||||
toggleFavorite,
|
|
||||||
restoreFromTrash,
|
|
||||||
permanentlyDeletePage,
|
|
||||||
cancelPublicPage,
|
|
||||||
} = useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
|
||||||
const [filterMode] = useAtom(allPageModeSelectAtom);
|
|
||||||
const { createPage, createEdgeless, importFile, isPreferredEdgeless } =
|
|
||||||
usePageHelper(blockSuiteWorkspace);
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const getPageInfo = useGetPageInfoById(blockSuiteWorkspace);
|
|
||||||
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
|
||||||
|
|
||||||
const tagOptionMap = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.fromEntries(
|
|
||||||
(blockSuiteWorkspace.meta.properties.tags?.options ?? []).map(v => [
|
|
||||||
v.id,
|
|
||||||
v,
|
|
||||||
])
|
|
||||||
),
|
|
||||||
[blockSuiteWorkspace.meta.properties.tags?.options]
|
|
||||||
);
|
|
||||||
const list = useMemo(
|
|
||||||
() =>
|
|
||||||
pageMetas
|
|
||||||
.filter(pageMeta => {
|
|
||||||
if (filterMode === 'all') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (filterMode === 'edgeless') {
|
|
||||||
return isPreferredEdgeless(pageMeta.id);
|
|
||||||
}
|
|
||||||
if (filterMode === 'page') {
|
|
||||||
return !isPreferredEdgeless(pageMeta.id);
|
|
||||||
}
|
|
||||||
console.error('unknown filter mode', pageMeta, filterMode);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.filter(pageMeta => {
|
|
||||||
if (!filter[listType](pageMeta, pageMetas)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!collection) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return filterPage(collection, pageMeta);
|
|
||||||
}),
|
|
||||||
[pageMetas, filterMode, isPreferredEdgeless, listType, collection]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (listType === 'trash') {
|
|
||||||
const pageList: TrashListData[] = list.map(pageMeta => {
|
|
||||||
return {
|
|
||||||
icon: isPreferredEdgeless(pageMeta.id) ? (
|
|
||||||
<EdgelessIcon />
|
|
||||||
) : (
|
|
||||||
<PageIcon />
|
|
||||||
),
|
|
||||||
pageId: pageMeta.id,
|
|
||||||
title: pageMeta.title,
|
|
||||||
preview: (
|
|
||||||
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
|
|
||||||
),
|
|
||||||
createDate: new Date(pageMeta.createDate),
|
|
||||||
trashDate: pageMeta.trashDate
|
|
||||||
? new Date(pageMeta.trashDate)
|
|
||||||
: undefined,
|
|
||||||
onClickPage: () => onOpenPage(pageMeta.id),
|
|
||||||
onClickRestore: () => {
|
|
||||||
restoreFromTrash(pageMeta.id);
|
|
||||||
},
|
|
||||||
onRestorePage: () => {
|
|
||||||
restoreFromTrash(pageMeta.id);
|
|
||||||
toast(
|
|
||||||
t['com.affine.toastMessage.restored']({
|
|
||||||
title: pageMeta.title || 'Untitled',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onPermanentlyDeletePage: () => {
|
|
||||||
permanentlyDeletePage(pageMeta.id);
|
|
||||||
toast(t['com.affine.toastMessage.permanentlyDeleted']());
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<PageListTrashView
|
|
||||||
list={pageList}
|
|
||||||
fallback={<PageListEmpty listType={listType} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageList: ListData[] = list.map(pageMeta => {
|
|
||||||
const page = blockSuiteWorkspace.getPage(pageMeta.id);
|
|
||||||
return {
|
|
||||||
icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />,
|
|
||||||
pageId: pageMeta.id,
|
|
||||||
title: pageMeta.title,
|
|
||||||
preview: (
|
|
||||||
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
|
|
||||||
),
|
|
||||||
tags:
|
|
||||||
page?.meta.tags?.map(id => tagOptionMap[id]).filter(v => v != null) ??
|
|
||||||
[],
|
|
||||||
favorite: !!pageMeta.favorite,
|
|
||||||
isPublicPage: !!pageMeta.isPublic,
|
|
||||||
createDate: new Date(pageMeta.createDate),
|
|
||||||
updatedDate: new Date(pageMeta.updatedDate ?? pageMeta.createDate),
|
|
||||||
onClickPage: () => onOpenPage(pageMeta.id),
|
|
||||||
onOpenPageInNewTab: () => onOpenPage(pageMeta.id, true),
|
|
||||||
onClickRestore: () => {
|
|
||||||
restoreFromTrash(pageMeta.id);
|
|
||||||
},
|
|
||||||
removeToTrash: () =>
|
|
||||||
setTrashModal({
|
|
||||||
open: true,
|
|
||||||
pageId: pageMeta.id,
|
|
||||||
pageTitle: pageMeta.title,
|
|
||||||
}),
|
|
||||||
|
|
||||||
onRestorePage: () => {
|
|
||||||
restoreFromTrash(pageMeta.id);
|
|
||||||
toast(
|
|
||||||
t['com.affine.toastMessage.restored']({
|
|
||||||
title: pageMeta.title || 'Untitled',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
},
|
|
||||||
bookmarkPage: () => {
|
|
||||||
const status = pageMeta.favorite;
|
|
||||||
toggleFavorite(pageMeta.id);
|
|
||||||
toast(
|
|
||||||
status
|
|
||||||
? t['com.affine.toastMessage.removedFavorites']()
|
|
||||||
: t['com.affine.toastMessage.addedFavorites']()
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onDisablePublicSharing: () => {
|
|
||||||
cancelPublicPage(pageMeta.id);
|
|
||||||
toast('Successfully disabled', {
|
|
||||||
portal: document.body,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageList
|
|
||||||
collectionsAtom={currentCollectionsAtom}
|
|
||||||
propertiesMeta={blockSuiteWorkspace.meta.properties}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
onCreateNewPage={createPage}
|
|
||||||
onCreateNewEdgeless={createEdgeless}
|
|
||||||
onImportFile={importFile}
|
|
||||||
isPublicWorkspace={isPublic}
|
|
||||||
list={pageList}
|
|
||||||
fallback={<PageListEmpty createPage={createPage} listType={listType} />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,18 +1,7 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const filterContainerStyle = style({
|
export const filterContainerStyle = style({
|
||||||
padding: '12px',
|
padding: '0 16px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
'::after': {
|
|
||||||
content: '""',
|
|
||||||
display: 'block',
|
|
||||||
width: '100%',
|
|
||||||
height: '1px',
|
|
||||||
background: 'var(--affine-border-color)',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
margin: '0 1px',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
@ -25,17 +25,17 @@ import {
|
|||||||
} from '@toeverything/infra/command';
|
} from '@toeverything/infra/command';
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import groupBy from 'lodash/groupBy';
|
import groupBy from 'lodash/groupBy';
|
||||||
import { useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
openQuickSearchModalAtom,
|
openQuickSearchModalAtom,
|
||||||
pageSettingsAtom,
|
pageSettingsAtom,
|
||||||
recentPageIdsBaseAtom,
|
recentPageIdsBaseAtom,
|
||||||
} from '../../../atoms';
|
} from '../../../atoms';
|
||||||
|
import { collectionsCRUDAtom } from '../../../atoms/collections';
|
||||||
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
||||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||||
import { WorkspaceSubPath } from '../../../shared';
|
import { WorkspaceSubPath } from '../../../shared';
|
||||||
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
|
||||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||||
import type { CMDKCommand, CommandContext } from './types';
|
import type { CMDKCommand, CommandContext } from './types';
|
||||||
|
|
||||||
@ -295,7 +295,7 @@ export const collectionToCommand = (
|
|||||||
collection: Collection,
|
collection: Collection,
|
||||||
store: ReturnType<typeof getCurrentStore>,
|
store: ReturnType<typeof getCurrentStore>,
|
||||||
navigationHelper: ReturnType<typeof useNavigateHelper>,
|
navigationHelper: ReturnType<typeof useNavigateHelper>,
|
||||||
selectCollection: ReturnType<typeof useCollectionManager>['selectCollection'],
|
selectCollection: (id: string) => void,
|
||||||
t: ReturnType<typeof useAFFiNEI18N>
|
t: ReturnType<typeof useAFFiNEI18N>
|
||||||
): CMDKCommand => {
|
): CMDKCommand => {
|
||||||
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
|
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
|
||||||
@ -329,14 +329,18 @@ export const collectionToCommand = (
|
|||||||
|
|
||||||
export const useCollectionsCommands = () => {
|
export const useCollectionsCommands = () => {
|
||||||
// todo: considering collections for searching pages
|
// todo: considering collections for searching pages
|
||||||
const { savedCollections, selectCollection } = useCollectionManager(
|
const { savedCollections } = useCollectionManager(collectionsCRUDAtom);
|
||||||
currentCollectionsAtom
|
|
||||||
);
|
|
||||||
const store = getCurrentStore();
|
const store = getCurrentStore();
|
||||||
const query = useAtomValue(cmdkQueryAtom);
|
const query = useAtomValue(cmdkQueryAtom);
|
||||||
const navigationHelper = useNavigateHelper();
|
const navigationHelper = useNavigateHelper();
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
const [workspace] = useCurrentWorkspace();
|
||||||
|
const selectCollection = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
navigationHelper.jumpToCollection(workspace.id, id);
|
||||||
|
},
|
||||||
|
[navigationHelper, workspace.id]
|
||||||
|
);
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
let results: CMDKCommand[] = [];
|
let results: CMDKCommand[] = [];
|
||||||
if (query.trim() === '') {
|
if (query.trim() === '') {
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
|
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { type Atom, useAtomValue } from 'jotai';
|
import { type Atom, useAtomValue } from 'jotai';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { forwardRef, useRef } from 'react';
|
import { forwardRef, useRef } from 'react';
|
||||||
|
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
@ -14,17 +14,18 @@ import { TopTip } from './top-tip';
|
|||||||
import { WindowsAppControls } from './windows-app-controls';
|
import { WindowsAppControls } from './windows-app-controls';
|
||||||
|
|
||||||
interface HeaderPros {
|
interface HeaderPros {
|
||||||
left?: ReactElement;
|
left?: ReactNode;
|
||||||
right?: ReactElement;
|
right?: ReactNode;
|
||||||
center?: ReactElement;
|
center?: ReactNode;
|
||||||
mainContainerAtom: Atom<HTMLDivElement | null>;
|
mainContainerAtom: Atom<HTMLDivElement | null>;
|
||||||
|
bottomBorder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Header component is used to solve the following problems
|
// The Header component is used to solve the following problems
|
||||||
// 1. Manage layout issues independently of page or business logic
|
// 1. Manage layout issues independently of page or business logic
|
||||||
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
|
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
|
||||||
export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
|
export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
|
||||||
{ left, center, right, mainContainerAtom },
|
{ left, center, right, mainContainerAtom, bottomBorder },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
|
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
|
||||||
@ -51,7 +52,7 @@ export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
|
|||||||
<>
|
<>
|
||||||
<TopTip />
|
<TopTip />
|
||||||
<div
|
<div
|
||||||
className={style.header}
|
className={clsx(style.header, bottomBorder && style.bottomBorder)}
|
||||||
// data-has-warning={showWarning}
|
// data-has-warning={showWarning}
|
||||||
data-open={open}
|
data-open={open}
|
||||||
data-sidebar-floating={appSidebarFloating}
|
data-sidebar-floating={appSidebarFloating}
|
||||||
|
@ -8,7 +8,6 @@ export const header = style({
|
|||||||
padding: '0 16px',
|
padding: '0 16px',
|
||||||
minHeight: '52px',
|
minHeight: '52px',
|
||||||
background: 'var(--affine-background-primary-color)',
|
background: 'var(--affine-background-primary-color)',
|
||||||
borderBottom: '1px solid var(--affine-border-color)',
|
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-sidebar-floating="false"]': {
|
'&[data-sidebar-floating="false"]': {
|
||||||
@ -25,6 +24,10 @@ export const header = style({
|
|||||||
},
|
},
|
||||||
} as ComplexStyleRule);
|
} as ComplexStyleRule);
|
||||||
|
|
||||||
|
export const bottomBorder = style({
|
||||||
|
borderBottom: '1px solid var(--affine-border-color)',
|
||||||
|
});
|
||||||
|
|
||||||
export const headerItem = style({
|
export const headerItem = style({
|
||||||
minHeight: '32px',
|
minHeight: '32px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -1,41 +1,41 @@
|
|||||||
import {
|
import {
|
||||||
EditCollectionModal,
|
createEmptyCollection,
|
||||||
useCollectionManager,
|
useCollectionManager,
|
||||||
|
useEditCollectionName,
|
||||||
} from '@affine/component/page-list';
|
} from '@affine/component/page-list';
|
||||||
import type { Collection } from '@affine/env/filter';
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { PlusIcon } from '@blocksuite/icons';
|
import { PlusIcon } from '@blocksuite/icons';
|
||||||
import type { Workspace } from '@blocksuite/store';
|
|
||||||
import { IconButton } from '@toeverything/components/button';
|
import { IconButton } from '@toeverything/components/button';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
import { collectionsCRUDAtom } from '../../../../atoms/collections';
|
||||||
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
|
||||||
|
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
||||||
|
|
||||||
type AddCollectionButtonProps = {
|
export const AddCollectionButton = () => {
|
||||||
workspace: Workspace;
|
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||||
};
|
|
||||||
|
|
||||||
export const AddCollectionButton = ({
|
|
||||||
workspace,
|
|
||||||
}: AddCollectionButtonProps) => {
|
|
||||||
const getPageInfo = useGetPageInfoById(workspace);
|
|
||||||
const setting = useCollectionManager(currentCollectionsAtom);
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [show, showUpdateCollection] = useState(false);
|
const { node, open } = useEditCollectionName({
|
||||||
const [defaultCollection, setDefaultCollection] = useState<Collection>();
|
title: t['com.affine.editCollection.createCollection'](),
|
||||||
const handleClick = useCallback(() => {
|
showTips: true,
|
||||||
showUpdateCollection(true);
|
|
||||||
setDefaultCollection({
|
|
||||||
id: nanoid(),
|
|
||||||
name: '',
|
|
||||||
pinned: true,
|
|
||||||
filterList: [],
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
});
|
});
|
||||||
}, [showUpdateCollection, workspace.id]);
|
const navigateHelper = useNavigateHelper();
|
||||||
|
const [workspace] = useCurrentWorkspace();
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
open('')
|
||||||
|
.then(name => {
|
||||||
|
const id = nanoid();
|
||||||
|
return setting
|
||||||
|
.createCollection(createEmptyCollection(id, { name }))
|
||||||
|
.then(() => {
|
||||||
|
navigateHelper.jumpToCollection(workspace.id, id);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
}, [navigateHelper, open, setting, workspace.id]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IconButton
|
<IconButton
|
||||||
@ -45,16 +45,7 @@ export const AddCollectionButton = ({
|
|||||||
>
|
>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{node}
|
||||||
<EditCollectionModal
|
|
||||||
propertiesMeta={workspace.meta.properties}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
onConfirm={setting.saveCollection}
|
|
||||||
open={show}
|
|
||||||
onOpenChange={showUpdateCollection}
|
|
||||||
title={t['com.affine.editCollection.saveCollection']()}
|
|
||||||
init={defaultCollection}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,40 +1,29 @@
|
|||||||
import { MenuItem as CollectionItem } from '@affine/component/app-sidebar';
|
import { AnimatedCollectionsIcon } from '@affine/component';
|
||||||
import {
|
import {
|
||||||
EditCollectionModal,
|
MenuItem as SidebarMenuItem,
|
||||||
|
MenuLinkItem as SidebarMenuLinkItem,
|
||||||
|
} from '@affine/component/app-sidebar';
|
||||||
|
import {
|
||||||
|
CollectionOperations,
|
||||||
|
filterPage,
|
||||||
|
stopPropagation,
|
||||||
useCollectionManager,
|
useCollectionManager,
|
||||||
useSavedCollections,
|
useSavedCollections,
|
||||||
} from '@affine/component/page-list';
|
} from '@affine/component/page-list';
|
||||||
import type { Collection } from '@affine/env/filter';
|
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
|
||||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import {
|
import { InformationIcon, MoreHorizontalIcon } from '@blocksuite/icons';
|
||||||
DeleteIcon,
|
|
||||||
FilterIcon,
|
|
||||||
InformationIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
UnpinIcon,
|
|
||||||
ViewLayersIcon,
|
|
||||||
} from '@blocksuite/icons';
|
|
||||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import { IconButton } from '@toeverything/components/button';
|
import { IconButton } from '@toeverything/components/button';
|
||||||
import {
|
|
||||||
Menu,
|
|
||||||
MenuIcon,
|
|
||||||
MenuItem,
|
|
||||||
type MenuItemProps,
|
|
||||||
} from '@toeverything/components/menu';
|
|
||||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
import { useGetPageInfoById } from '../../../../hooks/use-get-page-info';
|
import { collectionsCRUDAtom } from '../../../../atoms/collections';
|
||||||
import { useNavigateHelper } from '../../../../hooks/use-navigate-helper';
|
import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config';
|
||||||
import { filterPage } from '../../../../utils/filter';
|
|
||||||
import { currentCollectionsAtom } from '../../../../utils/user-setting';
|
|
||||||
import type { CollectionsListProps } from '../index';
|
import type { CollectionsListProps } from '../index';
|
||||||
import { Page } from './page';
|
import { Page } from './page';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
@ -51,111 +40,20 @@ export const processCollectionsDrag = (e: DragEndEvent) => {
|
|||||||
e.over?.data.current?.addToCollection?.(e.active.data.current?.pageId);
|
e.over?.data.current?.addToCollection?.(e.active.data.current?.pageId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const CollectionOperations = ({
|
|
||||||
view,
|
|
||||||
showUpdateCollection,
|
|
||||||
setting,
|
|
||||||
}: {
|
|
||||||
view: Collection;
|
|
||||||
showUpdateCollection: () => void;
|
|
||||||
setting: ReturnType<typeof useCollectionManager>;
|
|
||||||
}) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const actions = useMemo<
|
|
||||||
Array<
|
|
||||||
| {
|
|
||||||
icon: ReactElement;
|
|
||||||
name: string;
|
|
||||||
click: () => void;
|
|
||||||
type?: MenuItemProps['type'];
|
|
||||||
element?: undefined;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
element: ReactElement;
|
|
||||||
}
|
|
||||||
>
|
|
||||||
>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<FilterIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['Edit Filter'](),
|
|
||||||
click: showUpdateCollection,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<UnpinIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['Unpin'](),
|
|
||||||
click: () => {
|
|
||||||
return setting.updateCollection({
|
|
||||||
...view,
|
|
||||||
pinned: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
element: <div key="divider" className={styles.menuDividerStyle}></div>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: (
|
|
||||||
<MenuIcon>
|
|
||||||
<DeleteIcon />
|
|
||||||
</MenuIcon>
|
|
||||||
),
|
|
||||||
name: t['Delete'](),
|
|
||||||
click: () => {
|
|
||||||
return setting.deleteCollection(view.id);
|
|
||||||
},
|
|
||||||
type: 'danger',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[setting, showUpdateCollection, t, view]
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div style={{ minWidth: 150 }}>
|
|
||||||
{actions.map(action => {
|
|
||||||
if (action.element) {
|
|
||||||
return action.element;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<MenuItem
|
|
||||||
data-testid="collection-option"
|
|
||||||
key={action.name}
|
|
||||||
type={action.type}
|
|
||||||
preFix={action.icon}
|
|
||||||
onClick={action.click}
|
|
||||||
>
|
|
||||||
{action.name}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const CollectionRenderer = ({
|
const CollectionRenderer = ({
|
||||||
collection,
|
collection,
|
||||||
pages,
|
pages,
|
||||||
workspace,
|
workspace,
|
||||||
getPageInfo,
|
info,
|
||||||
}: {
|
}: {
|
||||||
collection: Collection;
|
collection: Collection;
|
||||||
pages: PageMeta[];
|
pages: PageMeta[];
|
||||||
workspace: Workspace;
|
workspace: Workspace;
|
||||||
getPageInfo: GetPageInfoById;
|
info: DeleteCollectionInfo;
|
||||||
}) => {
|
}) => {
|
||||||
const [collapsed, setCollapsed] = useState(true);
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const setting = useCollectionManager(currentCollectionsAtom);
|
const setting = useCollectionManager(collectionsCRUDAtom);
|
||||||
const { jumpToSubPath } = useNavigateHelper();
|
|
||||||
const clickCollection = useCallback(() => {
|
|
||||||
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
|
||||||
setting.selectCollection(collection.id);
|
|
||||||
}, [jumpToSubPath, workspace.id, setting, collection.id]);
|
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: `${Collections_DROP_AREA_PREFIX}${collection.id}`,
|
id: `${Collections_DROP_AREA_PREFIX}${collection.id}`,
|
||||||
data: {
|
data: {
|
||||||
@ -166,19 +64,15 @@ const CollectionRenderer = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const config = useAllPageListConfig();
|
||||||
const allPagesMeta = useMemo(
|
const allPagesMeta = useMemo(
|
||||||
() => Object.fromEntries(pages.map(v => [v.id, v])),
|
() => Object.fromEntries(pages.map(v => [v.id, v])),
|
||||||
[pages]
|
[pages]
|
||||||
);
|
);
|
||||||
const [show, showUpdateCollection] = useState(false);
|
|
||||||
const allowList = useMemo(
|
const allowList = useMemo(
|
||||||
() => new Set(collection.allowList),
|
() => new Set(collection.allowList),
|
||||||
[collection.allowList]
|
[collection.allowList]
|
||||||
);
|
);
|
||||||
const excludeList = useMemo(
|
|
||||||
() => new Set(collection.excludeList),
|
|
||||||
[collection.excludeList]
|
|
||||||
);
|
|
||||||
const removeFromAllowList = useCallback(
|
const removeFromAllowList = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
return setting.updateCollection({
|
return setting.updateCollection({
|
||||||
@ -188,45 +82,29 @@ const CollectionRenderer = ({
|
|||||||
},
|
},
|
||||||
[collection, setting]
|
[collection, setting]
|
||||||
);
|
);
|
||||||
const addToExcludeList = useCallback(
|
|
||||||
(id: string) => {
|
|
||||||
return setting.updateCollection({
|
|
||||||
...collection,
|
|
||||||
excludeList: [id, ...(collection.excludeList ?? [])],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[collection, setting]
|
|
||||||
);
|
|
||||||
const pagesToRender = pages.filter(
|
const pagesToRender = pages.filter(
|
||||||
page => filterPage(collection, page) && !page.trash
|
page => filterPage(collection, page) && !page.trash
|
||||||
);
|
);
|
||||||
|
const location = useLocation();
|
||||||
|
const currentPath = location.pathname.split('?')[0];
|
||||||
|
const path = `/workspace/${workspace.id}/collection/${collection.id}`;
|
||||||
return (
|
return (
|
||||||
<Collapsible.Root open={!collapsed}>
|
<Collapsible.Root open={!collapsed}>
|
||||||
<EditCollectionModal
|
<SidebarMenuLinkItem
|
||||||
propertiesMeta={workspace.meta.properties}
|
|
||||||
getPageInfo={getPageInfo}
|
|
||||||
init={collection}
|
|
||||||
onConfirm={setting.saveCollection}
|
|
||||||
open={show}
|
|
||||||
onOpenChange={showUpdateCollection}
|
|
||||||
/>
|
|
||||||
<CollectionItem
|
|
||||||
data-testid="collection-item"
|
data-testid="collection-item"
|
||||||
data-type="collection-list-item"
|
data-type="collection-list-item"
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
onCollapsedChange={setCollapsed}
|
onCollapsedChange={setCollapsed}
|
||||||
active={isOver}
|
active={isOver || currentPath === path}
|
||||||
icon={<ViewLayersIcon />}
|
icon={<AnimatedCollectionsIcon closed={isOver} />}
|
||||||
|
to={path}
|
||||||
postfix={
|
postfix={
|
||||||
<Menu
|
<div onClick={stopPropagation}>
|
||||||
items={
|
|
||||||
<CollectionOperations
|
<CollectionOperations
|
||||||
view={collection}
|
info={info}
|
||||||
showUpdateCollection={() => showUpdateCollection(true)}
|
collection={collection}
|
||||||
setting={setting}
|
setting={setting}
|
||||||
/>
|
config={config}
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
data-testid="collection-options"
|
data-testid="collection-options"
|
||||||
@ -235,10 +113,10 @@ const CollectionRenderer = ({
|
|||||||
>
|
>
|
||||||
<MoreHorizontalIcon />
|
<MoreHorizontalIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Menu>
|
</CollectionOperations>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
collapsed={pagesToRender.length > 0 ? collapsed : undefined}
|
collapsed={pagesToRender.length > 0 ? collapsed : undefined}
|
||||||
onClick={clickCollection}
|
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -249,7 +127,7 @@ const CollectionRenderer = ({
|
|||||||
>
|
>
|
||||||
<div>{collection.name}</div>
|
<div>{collection.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</CollectionItem>
|
</SidebarMenuLinkItem>
|
||||||
<Collapsible.Content className={styles.collapsibleContent}>
|
<Collapsible.Content className={styles.collapsibleContent}>
|
||||||
<div style={{ marginLeft: 20, marginTop: -4 }}>
|
<div style={{ marginLeft: 20, marginTop: -4 }}>
|
||||||
{pagesToRender.map(page => {
|
{pagesToRender.map(page => {
|
||||||
@ -257,8 +135,6 @@ const CollectionRenderer = ({
|
|||||||
<Page
|
<Page
|
||||||
inAllowList={allowList.has(page.id)}
|
inAllowList={allowList.has(page.id)}
|
||||||
removeFromAllowList={removeFromAllowList}
|
removeFromAllowList={removeFromAllowList}
|
||||||
inExcludeList={excludeList.has(page.id)}
|
|
||||||
addToExcludeList={addToExcludeList}
|
|
||||||
allPageMeta={allPagesMeta}
|
allPageMeta={allPagesMeta}
|
||||||
page={page}
|
page={page}
|
||||||
key={page.id}
|
key={page.id}
|
||||||
@ -271,32 +147,27 @@ const CollectionRenderer = ({
|
|||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export const CollectionsList = ({ workspace }: CollectionsListProps) => {
|
export const CollectionsList = ({ workspace, info }: CollectionsListProps) => {
|
||||||
const metas = useBlockSuitePageMeta(workspace);
|
const metas = useBlockSuitePageMeta(workspace);
|
||||||
const { savedCollections } = useSavedCollections(currentCollectionsAtom);
|
const { collections } = useSavedCollections(collectionsCRUDAtom);
|
||||||
const getPageInfo = useGetPageInfoById(workspace);
|
|
||||||
const pinedCollections = useMemo(
|
|
||||||
() => savedCollections.filter(v => v.pinned),
|
|
||||||
[savedCollections]
|
|
||||||
);
|
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
if (pinedCollections.length === 0) {
|
if (collections.length === 0) {
|
||||||
return (
|
return (
|
||||||
<CollectionItem
|
<SidebarMenuItem
|
||||||
data-testid="slider-bar-collection-null-description"
|
data-testid="slider-bar-collection-null-description"
|
||||||
icon={<InformationIcon />}
|
icon={<InformationIcon />}
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
<span>{t['Create a collection']()}</span>
|
<span>{t['Create a collection']()}</span>
|
||||||
</CollectionItem>
|
</SidebarMenuItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div data-testid="collections" className={styles.wrapper}>
|
<div data-testid="collections" className={styles.wrapper}>
|
||||||
{pinedCollections.map(view => {
|
{collections.map(view => {
|
||||||
return (
|
return (
|
||||||
<CollectionRenderer
|
<CollectionRenderer
|
||||||
getPageInfo={getPageInfo}
|
info={info}
|
||||||
key={view.id}
|
key={view.id}
|
||||||
collection={view}
|
collection={view}
|
||||||
pages={metas}
|
pages={metas}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user