Merge branch 'master' into payment-system

This commit is contained in:
DarkSky 2023-10-30 01:55:51 -05:00 committed by GitHub
commit 95c1a44a0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
148 changed files with 8869 additions and 3818 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {
const hasScrollTop = container.scrollTop > 0; setTimeout(() => {
setHasScrollTop(hasScrollTop); const hasScrollTop = container.scrollTop > 0;
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;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
import { style } from '@vanilla-extract/css';
export const menuContent = style({
backgroundColor: 'var(--affine-background-overlay-panel-color)',
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,23 +65,38 @@ export const OperationCell = ({
: t['com.affine.favoritePageOperation.add']()} : t['com.affine.favoritePageOperation.add']()}
</MenuItem> </MenuItem>
{!environment.isDesktop && ( {!environment.isDesktop && (
<MenuItem <Link
onClick={onOpenPageInNewTab} onClick={stopPropagationWithoutPrevent}
preFix={ to={link}
<MenuIcon> target={'_blank'}
<OpenInNewIcon /> rel="noopener noreferrer"
</MenuIcon>
}
> >
{t['com.affine.openPageOperation.newTab']()} <MenuItem
</MenuItem> style={{ marginBottom: 4 }}
preFix={
<MenuIcon>
<OpenInNewIcon />
</MenuIcon>
}
>
{t['com.affine.openPageOperation.newTab']()}
</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>
); );
}; };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
# <PageListTable />
A new implementation of the list table component for Page. Replace existing `PageList` component.
May rename to `PageList` later.

View File

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

View File

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

View File

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

View File

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

View File

@ -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'>>
filterList: [], ): Collection => {
workspaceId: 'temporary', return {
id,
name: '',
mode: 'page',
filterList: [],
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: { filterList: filterList,
...defaultCollection,
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,
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,83 +58,51 @@ export const CollectionList = ({
); );
return ( return (
<FlexWrapper alignItems="center"> <FlexWrapper alignItems="center">
{setting.savedCollections.length > 0 && ( {setting.isDefault ? (
<Menu <>
items={ <Menu
<div style={{ minWidth: 150 }}> items={
<MenuItem <CreateFilterMenu
preFix={ propertiesMeta={propertiesMeta}
<MenuIcon> value={setting.currentCollection.filterList}
<FolderIcon /> onChange={onChange}
</MenuIcon> />
} }
onClick={setting.backToAll} >
className={styles.viewMenu} <Button
> className={styles.filterMenuTrigger}
<div type="default"
style={{ icon={<FilteredIcon />}
display: 'flex', data-testid="create-first-filter"
alignItems: 'center', >
justifyContent: 'space-between', {t['com.affine.filter']()}
}} </Button>
> </Menu>
<div>All</div> <EditCollectionModal
</div> allPageListConfig={allPageListConfig}
</MenuItem> init={collection}
<div className={styles.menuTitleStyle}>Saved Collection</div> open={!!collection}
<div className={styles.menuDividerStyle}></div> onOpenChange={closeUpdateCollectionModal}
{setting.savedCollections.map(view => ( onConfirm={onConfirm}
<CollectionOption />
key={view.id} </>
collection={view} ) : (
setting={setting} <CollectionOperations
updateCollection={setCollection} info={userInfo}
/> collection={setting.currentCollection}
))} config={allPageListConfig}
</div> setting={setting}
}
> >
<Button <Button
data-testid="collection-select" className={styles.filterMenuTrigger}
style={{ marginRight: '20px' }} type="default"
icon={<FilteredIcon />}
data-testid="create-first-filter"
> >
<Tooltip {t['com.affine.filter']()}
content={setting.currentCollection.name}
rootOptions={{
delayDuration: 1500,
}}
>
<>{setting.currentCollection.name}</>
</Tooltip>
</Button> </Button>
</Menu> </CollectionOperations>
)} )}
<Menu
items={
<CreateFilterMenu
propertiesMeta={propertiesMeta}
value={setting.currentCollection.filterList}
onChange={onChange}
/>
}
>
<Button
className={styles.filterMenuTrigger}
type="default"
icon={<FilteredIcon />}
data-testid="create-first-filter"
>
{t['com.affine.filter']()}
</Button>
</Menu>
<EditCollectionModal
propertiesMeta={propertiesMeta}
getPageInfo={getPageInfo}
init={collection}
open={!!collection}
onOpenChange={closeUpdateCollectionModal}
onConfirm={onConfirm}
/>
</FlexWrapper> </FlexWrapper>
); );
}; };

View File

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

View File

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

View File

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

View File

@ -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', <Input
flexDirection: 'column', autoFocus
}} value={value}
> data-testid="input-collection-title"
<div className={styles.saveTitle}> placeholder="Collection Name"
{title ?? t['com.affine.editCollection.updateCollection']()} onChange={useCallback((value: string) => onChange(value), [onChange])}
</div> onEnter={save}
<ScrollableContainer ></Input>
className={styles.scrollContainer} {showTips ? (
viewPortClassName={styles.container} <div className={styles.createTips}>
> Collection is a smart folder where you can manually add pages or
{value.excludeList?.length ? ( automatically add pages through rules.
<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> </div>
) : null} ) : null}
<div </div>
style={{ <div className={styles.footer}>
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
size="large"
data-testid="input-collection-title"
placeholder={t['com.affine.editCollection.untitledCollection']()}
defaultValue={value.name}
onChange={name => onChange({ ...value, name })}
onEnter={onSaveCollection}
/>
</div>
</ScrollableContainer>
<div
style={{
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}
/>
</> </>
); );
}; };

View File

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

View File

@ -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 &quot;Include&quot;
</div>
<div>
&quot;Include&quot; refers to manually adding pages rather
than automatically adding them through rule matching. You can
manually add pages through the &quot;Add pages&quot; 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,2 @@
export * from './dropdown'; export * from './dropdown-button';
export * from './radio'; export * from './radio';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const root = style({
width: '1em',
height: '1em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});

View 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": {}
}

View File

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

View File

@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const root = style({
width: '1em',
height: '1em',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});

View 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": {}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'](),
showTips: true,
});
const navigateHelper = useNavigateHelper();
const [workspace] = useCurrentWorkspace();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
showUpdateCollection(true); open('')
setDefaultCollection({ .then(name => {
id: nanoid(), const id = nanoid();
name: '', return setting
pinned: true, .createCollection(createEmptyCollection(id, { name }))
filterList: [], .then(() => {
workspaceId: workspace.id, navigateHelper.jumpToCollection(workspace.id, id);
}); });
}, [showUpdateCollection, workspace.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}
/>
</> </>
); );
}; };

View File

@ -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,57 +82,41 @@ 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 info={info}
view={collection} collection={collection}
showUpdateCollection={() => showUpdateCollection(true)} setting={setting}
setting={setting} config={config}
/>
}
>
<IconButton
data-testid="collection-options"
type="plain"
withoutHoverStyle
> >
<MoreHorizontalIcon /> <IconButton
</IconButton> data-testid="collection-options"
</Menu> type="plain"
withoutHoverStyle
>
<MoreHorizontalIcon />
</IconButton>
</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