Compare commits

...

29 Commits

Author SHA1 Message Date
EYHN
a579cc7716 fix(core): better search result (#7819) 2024-08-09 10:37:27 +00:00
EYHN
b993ab04df fix(core): some doc missing in search result (#7818) 2024-08-09 10:37:24 +00:00
forehalo
eef9afd3ed chore: bump base version to 0.16.0 2024-08-09 18:30:07 +08:00
Cats Juice
06d5d9719c fix(core): wrong color of ai-subscribe button (#7816) 2024-08-09 09:44:09 +00:00
Cats Juice
f8e51112aa fix(core): sidebar renaming menu pos (#7798) 2024-08-09 17:06:55 +08:00
Cats Juice
e8d5692062 fix(core): sidebar unauthorized user avatar should center vertically (#7812) 2024-08-09 16:52:29 +08:00
EYHN
d2b0ee40a8 fix(core): disable blocksuite indexer (#7813) 2024-08-09 08:24:44 +00:00
EYHN
3ad5170b71 fix(core): hidden open in split view in browser (#7811) 2024-08-09 07:50:07 +00:00
pengx17
8209e84842 chore(electron): disable parallel execution of electron tests (#7789) 2024-08-09 07:33:16 +00:00
pengx17
fc19180451 fix(electron): missing collection name in tab header (#7807)
fix AF-1177
2024-08-09 07:02:38 +00:00
pengx17
009b5353b1 fix(electron): shell should import renderer css in dev (#7805) 2024-08-09 07:02:34 +00:00
EYHN
4beedaa22c fix(core): delete from folder not work (#7806) 2024-08-09 06:49:20 +00:00
CatsJuice
26fd9a4a1c feat(component): add autoFocusConfirmButton for confirm-modal (#7801)
close #5813

<div class='graphite__hidden'>
          <div>🎥 Video uploaded on Graphite:</div>
            <a href="https://app.graphite.dev/media/video/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4">
              <img src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4">
            </a>
          </div>
<video src="https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/aff35b76-9f73-4d15-b2cb-c25b03e2e2c3.mp4">CleanShot 2024-08-09 at 11.25.46.mp4</video>
2024-08-09 05:50:22 +00:00
EYHN
b2c00a2618 fix(core): typo in migration text (#7804) 2024-08-09 05:23:52 +00:00
JimmFly
85637156f6 chore: adjust i18n (#7800) 2024-08-09 04:10:18 +00:00
EYHN
c006f3f0af fix(core): reduce indexer performance impact (#7803) 2024-08-09 11:57:06 +08:00
EYHN
7efc87b6d3 chore(core): adjust migration text (#7802) 2024-08-09 11:50:52 +08:00
Tasnim Tantawi
450106ea54 feat(i18n): add Arabic (#7795) 2024-08-09 10:08:52 +08:00
EYHN
ffc12176c9 fix(electron): fix electron global state sync (#7793) 2024-08-08 12:02:10 +00:00
L-Sun
3d4fbcaebc fix(core): can not get chrome version in desktop mode in iOS (#7791) 2024-08-08 18:37:25 +08:00
pengx17
8db37e9bbf feat: cmd click support for journal sidebar (#7792)
fix AF-1214

The titles are also corrected:
![image.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/T2klNLEk0wxLh4NRDzhk/b7cd888f-b080-4800-a868-c37cbb0b9cbb.png)
2024-08-08 10:22:11 +00:00
pengx17
7fca13076a feat: mid click links to open in new tab (#7784)
fix AF-1200
2024-08-08 09:43:35 +00:00
EYHN
fd6e198295 chore: bump blocksuite (#7788) 2024-08-08 09:27:44 +00:00
pengx17
b71945c29f chore: tracking events for app tabs header (#7778)
fix AF-1194
2024-08-08 09:14:47 +00:00
EYHN
6ef5675be1 feat(core): better search result (#7787) 2024-08-08 08:56:55 +00:00
EYHN
c7aabd3a8d feat(core): highlight doc title in search result (#7786) 2024-08-08 08:56:51 +00:00
CatsJuice
03fd23de39 fix(core): cloud s subscription resume button's content is blank (#7783) 2024-08-08 08:43:05 +00:00
forehalo
f2eafc374c feat(server): authenticate user before ws connected (#7777) 2024-08-08 08:30:56 +00:00
EYHN
83244f0201 fix(core): trash doc in search result (#7785) 2024-08-08 08:17:48 +00:00
120 changed files with 2400 additions and 445 deletions

View File

@@ -247,7 +247,8 @@ const config = {
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)',
additionalHooks:
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget)',
},
],
},

View File

@@ -3,4 +3,4 @@ name: affine
description: AFFiNE cloud chart
type: application
version: 0.0.0
appVersion: "0.15.0"
appVersion: "0.16.0"

View File

@@ -3,7 +3,7 @@ name: graphql
description: AFFiNE GraphQL server
type: application
version: 0.0.0
appVersion: "0.15.0"
appVersion: "0.16.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -3,7 +3,7 @@ name: sync
description: AFFiNE Sync Server
type: application
version: 0.0.0
appVersion: "0.15.0"
appVersion: "0.16.0"
dependencies:
- name: gcloud-sql-proxy
version: 0.0.0

View File

@@ -19,5 +19,5 @@
],
"ext": "ts,md,json"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/monorepo",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"author": "toeverything",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/server-native",
"version": "0.15.0",
"version": "0.16.0",
"engines": {
"node": ">= 10.16.0 < 11 || >= 11.8.0"
},

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/server",
"private": true,
"version": "0.15.0",
"version": "0.16.0",
"description": "Affine Node.js server",
"type": "module",
"bin": {

View File

@@ -152,6 +152,8 @@ function buildAppModule() {
factor
// common fundamental modules
.use(...FunctionalityModules)
.useIf(config => config.flavor.sync, WebSocketModule)
// auth
.use(AuthModule)
@@ -159,7 +161,7 @@ function buildAppModule() {
.use(DocModule)
// sync server only
.useIf(config => config.flavor.sync, WebSocketModule, SyncModule)
.useIf(config => config.flavor.sync, SyncModule)
// graphql server only
.useIf(

View File

@@ -1,6 +1,6 @@
import type { ExecutionContext } from '@nestjs/common';
import { createParamDecorator } from '@nestjs/common';
import { User } from '@prisma/client';
import { User, UserSession } from '@prisma/client';
import { getRequestResponseFromContext } from '../../fundamentals';
@@ -53,3 +53,5 @@ export interface CurrentUser
hasPassword: boolean | null;
emailVerified: boolean;
}
export { type UserSession };

View File

@@ -1,15 +1,22 @@
import type {
CanActivate,
ExecutionContext,
FactoryProvider,
OnModuleInit,
} from '@nestjs/common';
import { Injectable, SetMetadata, UseGuards } from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';
import type { Request } from 'express';
import {
AuthenticationRequired,
Config,
getRequestResponseFromContext,
mapAnyError,
parseCookies,
} from '../../fundamentals';
import { WEBSOCKET_OPTIONS } from '../../fundamentals/websocket';
import { CurrentUser, UserSession } from './current-user';
import { AuthService, parseAuthUserSeqNum } from './service';
function extractTokenFromHeader(authorization: string) {
@@ -38,37 +45,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
async canActivate(context: ExecutionContext) {
const { req, res } = getRequestResponseFromContext(context);
// check cookie
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
const { user, expiresAt } = await this.auth.getUser(
sessionToken,
userSeq
);
if (res && user && expiresAt) {
await this.auth.refreshUserSessionIfNeeded(
req,
res,
sessionToken,
user.id,
expiresAt
);
}
if (user) {
req.sid = sessionToken;
req.user = user;
}
const userSession = await this.signIn(req);
if (res && userSession && userSession.session.expiresAt) {
await this.auth.refreshUserSessionIfNeeded(req, res, userSession.session);
}
// api is public
@@ -84,9 +63,44 @@ export class AuthGuard implements CanActivate, OnModuleInit {
if (!req.user) {
throw new AuthenticationRequired();
}
return true;
}
async signIn(
req: Request
): Promise<{ user: CurrentUser; session: UserSession } | null> {
if (req.user && req.session) {
return {
user: req.user,
session: req.session,
};
}
parseCookies(req);
let sessionToken: string | undefined =
req.cookies[AuthService.sessionCookieName];
if (!sessionToken && req.headers.authorization) {
sessionToken = extractTokenFromHeader(req.headers.authorization);
}
if (sessionToken) {
const userSeq = parseAuthUserSeqNum(
req.headers[AuthService.authUserSeqHeaderName]
);
const userSession = await this.auth.getUserSession(sessionToken, userSeq);
if (userSession) {
req.session = userSession.session;
req.user = userSession.user;
}
return userSession;
}
return null;
}
}
/**
@@ -111,3 +125,35 @@ export const Auth = () => {
// api is public accessible
export const Public = () => SetMetadata(PUBLIC_ENTRYPOINT_SYMBOL, true);
export const AuthWebsocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config, guard: AuthGuard) => {
return {
...config.websocket,
allowRequest: async (
req: any,
pass: (err: string | null | undefined, success: boolean) => void
) => {
if (!config.websocket.requireAuthentication) {
return pass(null, true);
}
try {
const authentication = await guard.signIn(req);
if (authentication) {
return pass(null, true);
} else {
return pass('unauthenticated', false);
}
} catch (e) {
const error = mapAnyError(e);
error.log('Websocket');
return pass('unauthenticated', false);
}
},
};
},
inject: [Config, AuthGuard],
};

View File

@@ -6,15 +6,21 @@ import { FeatureModule } from '../features';
import { QuotaModule } from '../quota';
import { UserModule } from '../user';
import { AuthController } from './controller';
import { AuthGuard } from './guard';
import { AuthGuard, AuthWebsocketOptionsProvider } from './guard';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
import { TokenService, TokenType } from './token';
@Module({
imports: [FeatureModule, UserModule, QuotaModule],
providers: [AuthService, AuthResolver, TokenService, AuthGuard],
exports: [AuthService, AuthGuard],
providers: [
AuthService,
AuthResolver,
TokenService,
AuthGuard,
AuthWebsocketOptionsProvider,
],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
controllers: [AuthController],
})
export class AuthModule {}

View File

@@ -1,6 +1,6 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import type { User } from '@prisma/client';
import type { User, UserSession } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import type { CookieOptions, Request, Response } from 'express';
import { assign, omit } from 'lodash-es';
@@ -121,27 +121,27 @@ export class AuthService implements OnApplicationBootstrap {
return sessionUser(user);
}
async getUser(
async getUserSession(
token: string,
seq = 0
): Promise<{ user: CurrentUser | null; expiresAt: Date | null }> {
): Promise<{ user: CurrentUser; session: UserSession } | null> {
const session = await this.getSession(token);
// no such session
if (!session) {
return { user: null, expiresAt: null };
return null;
}
const userSession = session.userSessions.at(seq);
// no such user session
if (!userSession) {
return { user: null, expiresAt: null };
return null;
}
// user session expired
if (userSession.expiresAt && userSession.expiresAt <= new Date()) {
return { user: null, expiresAt: null };
return null;
}
const user = await this.db.user.findUnique({
@@ -149,10 +149,10 @@ export class AuthService implements OnApplicationBootstrap {
});
if (!user) {
return { user: null, expiresAt: null };
return null;
}
return { user: sessionUser(user), expiresAt: userSession.expiresAt };
return { user: sessionUser(user), session: userSession };
}
async getUserList(token: string) {
@@ -251,12 +251,13 @@ export class AuthService implements OnApplicationBootstrap {
async refreshUserSessionIfNeeded(
_req: Request,
res: Response,
sessionId: string,
userId: string,
expiresAt: Date,
session: UserSession,
ttr = this.config.auth.session.ttr
): Promise<boolean> {
if (expiresAt && expiresAt.getTime() - Date.now() > ttr * 1000) {
if (
session.expiresAt &&
session.expiresAt.getTime() - Date.now() > ttr * 1000
) {
// no need to refresh
return false;
}
@@ -267,17 +268,14 @@ export class AuthService implements OnApplicationBootstrap {
await this.db.userSession.update({
where: {
sessionId_userId: {
sessionId,
userId,
},
id: session.id,
},
data: {
expiresAt: newExpiresAt,
},
});
res.cookie(AuthService.sessionCookieName, sessionId, {
res.cookie(AuthService.sessionCookieName, session.sessionId, {
expires: newExpiresAt,
...this.cookieOptions,
});

View File

@@ -50,12 +50,7 @@ function Awareness(workspaceId: string): `${string}:awareness` {
return `${workspaceId}:awareness`;
}
@WebSocketGateway({
cors: !AFFiNE.node.prod,
transports: ['websocket'],
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
maxHttpBufferSize: 1e8, // 100 MB
})
@WebSocketGateway()
export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
protected logger = new Logger(EventsGateway.name);
private connectionCount = 0;

View File

@@ -36,5 +36,6 @@ export {
getRequestFromHost,
getRequestResponseFromContext,
getRequestResponseFromHost,
parseCookies,
} from './utils/request';
export type * from './utils/types';

View File

@@ -2,8 +2,10 @@ import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseExceptionFilter } from '@nestjs/core';
import { GqlContextType } from '@nestjs/graphql';
import { ThrottlerException } from '@nestjs/throttler';
import { BaseWsExceptionFilter } from '@nestjs/websockets';
import { Response } from 'express';
import { of } from 'rxjs';
import { Socket } from 'socket.io';
import {
InternalServerError,
@@ -44,6 +46,20 @@ export class GlobalExceptionFilter extends BaseExceptionFilter {
}
}
export class GlobalWsExceptionFilter extends BaseWsExceptionFilter {
// @ts-expect-error satisfies the override
override handleError(client: Socket, exception: any): void {
const error = mapAnyError(exception);
error.log('Websocket');
metrics.socketio
.counter('unhandled_error')
.add(1, { status: error.status });
client.emit('error', {
error: toWebsocketError(error),
});
}
}
/**
* Only exists for websocket error body backward compatibility
*

View File

@@ -57,7 +57,7 @@ export class CloudThrottlerGuard extends ThrottlerGuard {
override getTracker(req: Request): Promise<string> {
return Promise.resolve(
// ↓ prefer session id if available
`throttler:${req.sid ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
`throttler:${req.session?.sessionId ?? req.get('CF-Connecting-IP') ?? req.get('CF-ray') ?? req.ip}`
// ^ throttler prefix make the key in store recognizable
);
}

View File

@@ -66,3 +66,29 @@ export function getRequestFromHost(host: ArgumentsHost) {
export function getRequestResponseFromContext(ctx: ExecutionContext) {
return getRequestResponseFromHost(ctx);
}
/**
* simple patch for request not protected by `cookie-parser`
* only take effect if `req.cookies` is not defined
*/
export function parseCookies(req: Request) {
if (req.cookies) {
return;
}
const cookieStr = req?.headers?.cookie ?? '';
req.cookies = cookieStr.split(';').reduce(
(cookies, cookie) => {
const [key, val] = cookie.split('=');
if (key) {
cookies[decodeURIComponent(key.trim())] = val
? decodeURIComponent(val.trim())
: val;
}
return cookies;
},
{} as Record<string, string>
);
}

View File

@@ -0,0 +1,20 @@
import { GatewayMetadata } from '@nestjs/websockets';
import { defineStartupConfig, ModuleConfig } from '../config';
declare module '../config' {
interface AppConfig {
websocket: ModuleConfig<
GatewayMetadata & {
requireAuthentication?: boolean;
}
>;
}
}
defineStartupConfig('websocket', {
// see: https://socket.io/docs/v4/server-options/#maxhttpbuffersize
transports: ['websocket'],
maxHttpBufferSize: 1e8, // 100 MB
requireAuthentication: true,
});

View File

@@ -1,17 +1,46 @@
import { Module, Provider } from '@nestjs/common';
import './config';
import {
FactoryProvider,
INestApplicationContext,
Module,
Provider,
} from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { Server } from 'socket.io';
import { Config } from '../config';
export const SocketIoAdapterImpl = Symbol('SocketIoAdapterImpl');
export class SocketIoAdapter extends IoAdapter {}
export class SocketIoAdapter extends IoAdapter {
constructor(protected readonly app: INestApplicationContext) {
super(app);
}
override createIOServer(port: number, options?: any): Server {
const config = this.app.get(WEBSOCKET_OPTIONS);
return super.createIOServer(port, { ...config, ...options });
}
}
const SocketIoAdapterImplProvider: Provider = {
provide: SocketIoAdapterImpl,
useValue: SocketIoAdapter,
};
export const WEBSOCKET_OPTIONS = Symbol('WEBSOCKET_OPTIONS');
export const websocketOptionsProvider: FactoryProvider = {
provide: WEBSOCKET_OPTIONS,
useFactory: (config: Config) => {
return config.websocket;
},
inject: [Config],
};
@Module({
providers: [SocketIoAdapterImplProvider],
exports: [SocketIoAdapterImplProvider],
providers: [SocketIoAdapterImplProvider, websocketOptionsProvider],
exports: [SocketIoAdapterImplProvider, websocketOptionsProvider],
})
export class WebSocketModule {}

View File

@@ -1,7 +1,7 @@
declare namespace Express {
interface Request {
user?: import('./core/auth/current-user').CurrentUser;
sid?: string;
session?: import('./core/auth/current-user').UserSession;
}
}

View File

@@ -18,7 +18,7 @@ export function createSockerIoAdapterImpl(
console.error(err);
});
const server = super.createIOServer(port, options) as Server;
const server = super.createIOServer(port, options);
server.adapter(createAdapter(pubClient, subClient));
return server;
}

View File

@@ -69,7 +69,7 @@ test('should be able to visit public api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer())
.get('/public')
@@ -100,7 +100,7 @@ test('should be able to visit private api if signed in', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
const res = await request(app.getHttpServer())
.get('/private')
@@ -114,26 +114,26 @@ test('should be able to parse session cookie', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer())
.get('/public')
.set('cookie', `${AuthService.sessionCookieName}=1`)
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
});
test('should be able to parse bearer token', async t => {
const { app, auth } = t.context;
// @ts-expect-error mock
auth.getUser.resolves({ user: { id: '1' } });
auth.getUserSession.resolves({ user: { id: '1' }, session: { id: '1' } });
await request(app.getHttpServer())
.get('/public')
.auth('1', { type: 'bearer' })
.expect(200);
t.deepEqual(auth.getUser.firstCall.args, ['1', 0]);
t.deepEqual(auth.getUserSession.firstCall.args, ['1', 0]);
});

View File

@@ -157,10 +157,10 @@ test('should be able to get user from session', async t => {
const session = await auth.createUserSession(u1);
const { user } = await auth.getUser(session.sessionId);
const userSession = await auth.getUserSession(session.sessionId);
t.not(user, null);
t.is(user!.id, u1.id);
t.not(userSession, null);
t.is(userSession!.user.id, u1.id);
});
test('should be able to sign out session', async t => {
@@ -203,19 +203,19 @@ test('should be able to signout multi accounts session', async t => {
t.not(signedOutSession, null);
const { user: signedU2 } = await auth.getUser(session.sessionId, 0);
const { user: noUser } = await auth.getUser(session.sessionId, 1);
const userSession1 = await auth.getUserSession(session.sessionId, 0);
const userSession2 = await auth.getUserSession(session.sessionId, 1);
t.is(noUser, null);
t.not(signedU2, null);
t.is(userSession2, null);
t.not(userSession1, null);
t.is(signedU2!.id, u2.id);
t.is(userSession1!.user.id, u2.id);
// sign out user at seq(0)
signedOutSession = await auth.signOut(session.sessionId);
t.is(signedOutSession, null);
const { user: noUser2 } = await auth.getUser(session.sessionId, 0);
t.is(noUser2, null);
const userSession3 = await auth.getUserSession(session.sessionId, 0);
t.is(userSession3, null);
});

View File

@@ -341,8 +341,10 @@ test('should throw if oauth account already connected', async t => {
},
});
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ user: { id: 'u2-id' } });
Sinon.stub(auth, 'getUserSession').resolves({
user: { id: 'u2-id' },
session: {},
} as any);
mockOAuthProvider(app, 'u2@affine.pro');
@@ -363,8 +365,10 @@ test('should throw if oauth account already connected', async t => {
test('should be able to connect oauth account', async t => {
const { app, u1, auth, db } = t.context;
// @ts-expect-error mock
Sinon.stub(auth, 'getUser').resolves({ user: { id: u1.id } });
Sinon.stub(auth, 'getUserSession').resolves({
user: { id: u1.id },
session: {},
} as any);
mockOAuthProvider(app, u1.email);

View File

@@ -9,5 +9,5 @@
"@types/debug": "^4.1.12",
"vitest": "1.6.0"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -3,8 +3,8 @@
"private": true,
"type": "module",
"devDependencies": {
"@blocksuite/global": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/store": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/global": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/store": "0.17.0-canary-202408080742-0cbcb73",
"react": "18.3.1",
"react-dom": "18.3.1",
"vitest": "1.6.0"
@@ -26,5 +26,5 @@
"lit": "^3.1.2",
"zod": "^3.22.4"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -1,5 +1,3 @@
import { assertExists } from '@blocksuite/global/utils';
export class UaHelper {
private readonly uaMap;
public isLinux = false;
@@ -12,8 +10,14 @@ export class UaHelper {
public isIOS = false;
getChromeVersion = (): number => {
const raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
assertExists(raw);
let raw = this.navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
if (!raw) {
raw = this.navigator.userAgent.match(/(CriOS)\/([0-9]+)/);
}
if (!raw) {
console.error('Cannot get chrome version');
return 0;
}
return parseInt(raw[2], 10);
};

View File

@@ -14,10 +14,10 @@
"@affine/debug": "workspace:*",
"@affine/env": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/blocks": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/global": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/presets": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/store": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/blocks": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/global": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/presets": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/store": "0.17.0-canary-202408080742-0cbcb73",
"@datastructures-js/binary-search-tree": "^5.3.2",
"foxact": "^0.2.33",
"fuse.js": "^7.0.0",
@@ -34,8 +34,8 @@
"devDependencies": {
"@affine-test/fixtures": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/block-std": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/presets": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/block-std": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/presets": "0.17.0-canary-202408080742-0cbcb73",
"@testing-library/react": "^16.0.0",
"async-call-rpc": "^6.4.0",
"fake-indexeddb": "^6.0.0",
@@ -73,5 +73,5 @@
"optional": true
}
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -34,6 +34,8 @@ export class Workspace extends Entity {
},
idGenerator: () => nanoid(),
schema: globalBlockSuiteSchema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
}
return this._docCollection;

View File

@@ -61,6 +61,8 @@ export class TestingWorkspaceLocalProvider
blobSources: {
main: blobStorage,
},
disableBacklinkIndex: true,
disableSearchIndex: true,
});
// apply initial state
@@ -95,6 +97,8 @@ export class TestingWorkspaceLocalProvider
const bs = new DocCollection({
id,
schema: globalBlockSuiteSchema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
applyUpdate(bs.doc, data);

View File

@@ -178,10 +178,13 @@ export class FullTextInvertedIndex implements InvertedIndex {
const queryTokens = new GeneralTokenizer().tokenize(term);
const matched = new Map<
number,
{
score: number[];
positions: Map<number, [number, number][]>;
}
Map<
number, // index
{
score: number;
ranges: [number, number][];
}
>
>();
for (const token of queryTokens) {
const key = InvertedIndexKey.forString(this.fieldKey, token.term);
@@ -250,27 +253,42 @@ export class FullTextInvertedIndex implements InvertedIndex {
maxScore === minScore
? score
: (score - minScore) / (maxScore - minScore);
const match = matched.get(nid) || {
score: [] as number[],
positions: new Map(),
const match =
matched.get(nid) ??
new Map<
number, // index
{
score: number;
ranges: [number, number][];
}
>();
const item = match.get(position.index) || {
score: 0,
ranges: [],
};
match.score.push(normalizedScore);
const ranges = match.positions.get(position.index) || [];
ranges.push(...position.ranges);
match.positions.set(position.index, ranges);
item.score += normalizedScore;
item.ranges.push(...position.ranges);
match.set(position.index, item);
matched.set(nid, match);
}
}
const match = new Match();
for (const [nid, { score, positions }] of matched) {
match.addScore(
nid,
score.reduce((acc, s) => acc + s, 0)
);
for (const [index, ranges] of positions) {
match.addHighlighter(nid, this.fieldKey, index, ranges);
for (const [nid, items] of matched) {
if (items.size === 0) {
break;
}
let highestScore = -1;
let highestIndex = -1;
let highestRanges: [number, number][] = [];
for (const [index, { score, ranges }] of items) {
if (score > highestScore) {
highestScore = score;
highestIndex = index;
highestRanges = ranges;
}
}
match.addScore(nid, highestScore);
match.addHighlighter(nid, this.fieldKey, highestIndex, highestRanges);
}
return match;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/admin",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"dependencies": {
"@affine/core": "workspace:*",

View File

@@ -78,12 +78,12 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@blocksuite/block-std": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/blocks": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/global": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/block-std": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/blocks": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/global": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/icons": "2.1.62",
"@blocksuite/presets": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/store": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/presets": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/store": "0.17.0-canary-202408080742-0cbcb73",
"@storybook/addon-actions": "^7.6.17",
"@storybook/addon-essentials": "^7.6.17",
"@storybook/addon-interactions": "^7.6.17",
@@ -110,5 +110,5 @@
"vite": "^5.2.8",
"vitest": "1.6.0"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -1,4 +1,4 @@
import type { KeyboardEvent } from 'react';
import type { KeyboardEvent, ReactElement } from 'react';
import { useCallback, useEffect, useState } from 'react';
import Input from '../../ui/input';
@@ -8,12 +8,16 @@ export const RenameModal = ({
onRename,
currentName,
open,
width = 220,
children,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onRename: (newName: string) => void;
currentName: string;
width?: string | number;
children?: ReactElement;
}) => {
const [value, setValue] = useState(currentName);
@@ -56,11 +60,11 @@ export const RenameModal = ({
onEnter={handleRename}
onKeyDown={onKeyDown}
data-testid="rename-modal-input"
style={{ width: 220, height: 34, borderRadius: 4 }}
style={{ width, height: 34, borderRadius: 4 }}
/>
}
>
<div></div>
{children ?? <div />}
</Menu>
);
};

View File

@@ -0,0 +1,33 @@
import { useLayoutEffect, useRef } from 'react';
export const useAutoFocus = <T extends HTMLElement = HTMLElement>(
autoFocus?: boolean
) => {
const ref = useRef<T | null>(null);
useLayoutEffect(() => {
if (ref.current && autoFocus) {
// to avoid clicking on something focusable(e.g MenuItem),
// then the input will not be focused
setTimeout(() => {
ref.current?.focus();
}, 0);
}
}, [autoFocus]);
return ref;
};
export const useAutoSelect = <T extends HTMLInputElement = HTMLInputElement>(
autoSelect?: boolean
) => {
const ref = useAutoFocus<T>(autoSelect);
useLayoutEffect(() => {
if (ref.current && autoSelect) {
ref.current?.select();
}
}, [autoSelect, ref]);
return ref;
};

View File

@@ -0,0 +1 @@
export { useAutoFocus, useAutoSelect } from './focus-and-select';

View File

@@ -7,6 +7,7 @@ import type {
} from 'react';
import { cloneElement, forwardRef, useCallback } from 'react';
import { useAutoFocus } from '../../hooks';
import { Loading } from '../loading';
import { Tooltip, type TooltipProps } from '../tooltip';
import * as styles from './button.css';
@@ -120,12 +121,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
tooltip,
tooltipShortcut,
tooltipOptions,
autoFocus,
onClick,
...otherProps
},
ref
upstreamRef
) => {
const ref = useAutoFocus<HTMLButtonElement>(autoFocus);
const handleClick = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
if (loading || disabled) return;
@@ -134,11 +138,22 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
[disabled, loading, onClick]
);
const buttonRef = (el: HTMLButtonElement | null) => {
ref.current = el;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(el);
} else {
upstreamRef.current = el;
}
}
};
return (
<Tooltip content={tooltip} shortcut={tooltipShortcut} {...tooltipOptions}>
<button
{...otherProps}
ref={ref}
ref={buttonRef}
className={clsx(styles.button, className)}
data-loading={loading || undefined}
data-block={block || undefined}

View File

@@ -8,14 +8,9 @@ import type {
KeyboardEventHandler,
ReactNode,
} from 'react';
import {
forwardRef,
useCallback,
useEffect,
useLayoutEffect,
useRef,
} from 'react';
import { forwardRef, useCallback, useEffect } from 'react';
import { useAutoFocus, useAutoSelect } from '../../hooks';
import { input, inputWrapper } from './style.css';
export type InputProps = {
@@ -55,30 +50,31 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
}: InputProps,
upstreamRef: ForwardedRef<HTMLInputElement>
) {
const inputRef = useRef<HTMLInputElement | null>(null);
useLayoutEffect(() => {
if (inputRef.current && (autoFocus || autoSelect)) {
// to avoid clicking on something focusable(e.g MenuItem),
// then the input will not be focused
setTimeout(() => {
inputRef.current?.focus();
}, 0);
if (autoSelect) {
inputRef.current?.select();
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
const selectRef = useAutoSelect<HTMLInputElement>(autoSelect);
const inputRef = (el: HTMLInputElement | null) => {
focusRef.current = el;
selectRef.current = el;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(el);
} else {
upstreamRef.current = el;
}
}
}, [autoFocus, autoSelect, upstreamRef]);
};
// use native blur event to get event after unmount
// don't use useLayoutEffect here, because the cleanup function will be called before unmount
useEffect(() => {
if (!onBlur) return;
inputRef.current?.addEventListener('blur', onBlur as any);
selectRef.current?.addEventListener('blur', onBlur as any);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
inputRef.current?.removeEventListener('blur', onBlur as any);
selectRef.current?.removeEventListener('blur', onBlur as any);
};
}, [onBlur]);
}, [onBlur, selectRef]);
return (
<div
@@ -105,16 +101,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
large: size === 'large',
'extra-large': size === 'extraLarge',
})}
ref={ref => {
inputRef.current = ref;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(ref);
} else {
upstreamRef.current = ref;
}
}
}}
ref={inputRef}
disabled={disabled}
style={inputStyle}
onChange={useCallback(

View File

@@ -17,6 +17,11 @@ export interface ConfirmModalProps extends ModalProps {
cancelText?: React.ReactNode;
cancelButtonOptions?: Omit<ButtonProps, 'children'>;
reverseFooter?: boolean;
/**
* Auto focus on confirm button when modal opened
* @default true
*/
autoFocusConfirm?: boolean;
}
export const ConfirmModal = ({
@@ -30,6 +35,7 @@ export const ConfirmModal = ({
onConfirm,
onCancel,
width = 480,
autoFocusConfirm = true,
...props
}: ConfirmModalProps) => {
const onConfirmClick = useCallback(() => {
@@ -73,6 +79,7 @@ export const ConfirmModal = ({
<Button
onClick={onConfirmClick}
data-testid="confirm-modal-confirm"
autoFocus={autoFocusConfirm}
{...confirmButtonOptions}
>
{confirmText}

View File

@@ -2,7 +2,7 @@
"name": "@affine/core",
"type": "module",
"private": true,
"version": "0.15.0",
"version": "0.16.0",
"exports": {
"./app": "./src/app.tsx",
"./router": "./src/router.tsx",
@@ -19,13 +19,13 @@
"@affine/graphql": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/templates": "workspace:*",
"@blocksuite/block-std": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/blocks": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/global": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/block-std": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/blocks": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/global": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/icons": "2.1.62",
"@blocksuite/inline": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/presets": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/store": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/inline": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/presets": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/store": "0.17.0-canary-202408080742-0cbcb73",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",

View File

@@ -213,7 +213,12 @@ export class AISlidesRenderer extends WithDisposable(LitElement) {
super.connectedCallback();
const schema = new Schema().register(AffineSchemas);
const collection = new DocCollection({ schema, id: 'SLIDES_PREVIEW' });
const collection = new DocCollection({
schema,
id: 'SLIDES_PREVIEW',
disableBacklinkIndex: true,
disableSearchIndex: true,
});
collection.meta.initialize();
collection.start();
const doc = collection.createDoc();

View File

@@ -187,7 +187,11 @@ export async function replaceFromMarkdown(
export async function markDownToDoc(host: EditorHost, answer: string) {
const schema = host.std.doc.collection.schema;
// Should not create a new doc in the original collection
const collection = new DocCollection({ schema });
const collection = new DocCollection({
schema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
collection.meta.initialize();
const job = new Job({
collection,

View File

@@ -109,6 +109,8 @@ const getOrCreateShellWorkspace = (workspaceId: string) => {
main: blobStorage,
},
schema: globalBlockSuiteSchema,
disableBacklinkIndex: true,
disableSearchIndex: true,
});
docCollectionMap.set(workspaceId, docCollection);
docCollection.doc.emit('sync', [true, docCollection.doc]);

View File

@@ -106,7 +106,6 @@ export function AffinePageReference({
e.preventDefault();
e.stopPropagation();
peekView.open(ref.current).catch(console.error);
return false; // means this click is handled
}
if (isInPeekView) {
peekView.close();

View File

@@ -60,7 +60,7 @@ export const AIPlan = () => {
) : (
<>
<AISubscribe
className={styles.learnAIButton}
className={styles.purchaseButton}
displayedFrequency="monthly"
/>
<a href="https://ai.affine.pro" target="_blank" rel="noreferrer">

View File

@@ -10,7 +10,6 @@ import { SubscriptionPlan, SubscriptionStatus } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { DoneIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import { nanoid } from 'nanoid';
@@ -424,15 +423,13 @@ const ResumeButton = () => {
return (
<ResumeAction open={open} onOpenChange={setOpen}>
<Button
className={styles.resumeAction}
onClick={handleClick}
style={assignInlineVars({
'--default-content': t['com.affine.payment.current-plan'](),
'--hover-content': t['com.affine.payment.resume-renewal'](),
})}
>
<span className={styles.resumeActionContent} />
<Button className={styles.resumeAction} onClick={handleClick}>
<span data-show-hover="true" className={clsx(styles.resumeContent)}>
{t['com.affine.payment.resume-renewal']()}
</span>
<span data-show-hover="false" className={clsx(styles.resumeContent)}>
{t['com.affine.payment.current-plan']()}
</span>
</Button>
</ResumeAction>
);

View File

@@ -179,14 +179,16 @@ export const planAction = style({
width: '100%',
});
export const resumeAction = style([planAction, {}]);
export const resumeActionContent = style({
':after': {
content: 'var(--default-content)',
},
export const resumeContent = style({
selectors: {
[`${resumeAction}:hover &:after`]: {
content: 'var(--hover-content)',
},
[`&[data-show-hover="true"], ${resumeAction}:hover &[data-show-hover="false"]`]:
{
display: 'none',
},
[`&[data-show-hover="false"], ${resumeAction}:hover &[data-show-hover="true"]`]:
{
display: 'block',
},
},
});
export const planBenefits = style({

View File

@@ -29,6 +29,7 @@ export function AddPageButton({
style={style}
className={clsx([styles.root, className])}
onClick={onClick}
onAuxClick={onClick}
>
<PlusIcon />
</IconButton>

View File

@@ -308,18 +308,20 @@ export const PageHeaderMenuButton = ({
{t['com.affine.workbench.tab.page-menu-open']()}
</MenuItem>
<MenuItem
preFix={
<MenuIcon>
<SplitViewIcon />
</MenuIcon>
}
data-testid="editor-option-menu-open-in-split-new"
onSelect={handleOpenInSplitView}
style={menuItemStyle}
>
{t['com.affine.workbench.split-view.page-menu-open']()}
</MenuItem>
{environment.isDesktop && (
<MenuItem
preFix={
<MenuIcon>
<SplitViewIcon />
</MenuIcon>
}
data-testid="editor-option-menu-open-in-split-new"
onSelect={handleOpenInSplitView}
style={menuItemStyle}
>
{t['com.affine.workbench.split-view.page-menu-open']()}
</MenuItem>
)}
<MenuSeparator />

View File

@@ -1,13 +1,14 @@
import { Checkbox, useDraggable } from '@affine/component';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { stopPropagation } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useMemo } from 'react';
import { selectionStateAtom, useAtom } from '../scoped-atoms';
import type { CollectionListItemProps, PageListItemProps } from '../types';
import { ColWrapper, stopPropagation } from '../utils';
import { ColWrapper } from '../utils';
import * as styles from './collection-list-item.css';
const ListTitleCell = ({

View File

@@ -35,6 +35,7 @@ export const CreateNewPagePopup = ({
desc={t['com.affine.write_with_a_blank_page']()}
right={<PageIcon width={20} height={20} />}
onClick={createNewPage}
onAuxClick={createNewPage}
data-testid="new-page-button-in-all-page"
/>
<BlockCard
@@ -42,6 +43,7 @@ export const CreateNewPagePopup = ({
desc={t['com.affine.draw_with_a_blank_whiteboard']()}
right={<EdgelessIcon width={20} height={20} />}
onClick={createNewEdgeless}
onAuxClick={createNewEdgeless}
data-testid="new-edgeless-button-in-all-page"
/>
{importFile ? (
@@ -117,6 +119,7 @@ export const NewPageButton = ({
<DropdownButton
size={size}
onClick={handleCreateNewPage}
onAuxClick={handleCreateNewPage}
onClickDropDown={useCallback(() => setOpen(open => !open), [])}
>
{children}

View File

@@ -10,6 +10,7 @@ import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { track } from '@affine/core/mixpanel';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { isNewTabTrigger } from '@affine/core/utils';
import type { Collection } from '@affine/env/filter';
import { useI18n } from '@affine/i18n';
import {
@@ -66,12 +67,9 @@ export const PageListHeader = () => {
size="small"
testId="new-page-button-trigger"
onCreateEdgeless={e =>
// todo: abstract this for ctrl check
createEdgeless(e?.metaKey || e?.ctrlKey ? 'new-tab' : true)
}
onCreatePage={e =>
createPage(e?.metaKey || e?.ctrlKey ? 'new-tab' : true)
createEdgeless(isNewTabTrigger(e) ? 'new-tab' : true)
}
onCreatePage={e => createPage(isNewTabTrigger(e) ? 'new-tab' : true)}
onImportFile={onImportFile}
>
<div className={styles.buttonText}>{t['New Page']()}</div>

View File

@@ -1,6 +1,7 @@
import { Checkbox, Tooltip, useDraggable } from '@affine/component';
import { TagService } from '@affine/core/modules/tag';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { stopPropagation } from '@affine/core/utils';
import { i18nTime } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import type { ForwardedRef, PropsWithChildren } from 'react';
@@ -15,7 +16,7 @@ import {
} from '../scoped-atoms';
import type { PageListItemProps } from '../types';
import { useAllDocDisplayProperties } from '../use-all-doc-display-properties';
import { ColWrapper, stopPropagation } from '../utils';
import { ColWrapper } from '../utils';
import * as styles from './page-list-item.css';
import { PageTags } from './page-tags';
@@ -310,18 +311,18 @@ const PageListItemWrapper = forwardRef(
if (!selectionState.selectable) {
return;
}
stopPropagation(e);
e.stopPropagation();
const currentIndex = pageIds.indexOf(pageId);
if (e.shiftKey) {
e.preventDefault();
if (!selectionState.selectionActive) {
setSelectionActive(true);
setAnchorIndex(currentIndex);
onClick?.();
return false;
} else {
handleShiftClick(currentIndex);
}
handleShiftClick(currentIndex);
return false;
} else {
setAnchorIndex(undefined);
setRangeIds([]);

View File

@@ -1,13 +1,13 @@
import { Menu } from '@affine/component';
import { useCatchEventCallback } from '@affine/core/hooks/use-catch-event-hook';
import type { Tag } from '@affine/core/modules/tag';
import { stopPropagation } from '@affine/core/utils';
import { CloseIcon, MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { LiveData, useLiveData } from '@toeverything/infra';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { useMemo } from 'react';
import { stopPropagation } from '../utils';
import * as styles from './page-tags.css';
export interface PageTagsProps {

View File

@@ -9,6 +9,7 @@ import {
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useCatchEventCallback } from '@affine/core/hooks/use-catch-event-hook';
import { track } from '@affine/core/mixpanel';
import { FavoriteService } from '@affine/core/modules/favorite';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
@@ -277,18 +278,30 @@ export const TrashOperationCell = ({
const t = useI18n();
const { openConfirmModal } = useConfirmModal();
const onConfirmPermanentlyDelete = useCallback(() => {
openConfirmModal({
title: `${t['com.affine.trashOperation.deletePermanently']()}?`,
description: t['com.affine.trashOperation.deleteDescription'](),
cancelText: t['Cancel'](),
confirmText: t['com.affine.trashOperation.delete'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: onPermanentlyDeletePage,
});
}, [onPermanentlyDeletePage, openConfirmModal, t]);
const onConfirmPermanentlyDelete = useCatchEventCallback(
e => {
e.preventDefault();
openConfirmModal({
title: `${t['com.affine.trashOperation.deletePermanently']()}?`,
description: t['com.affine.trashOperation.deleteDescription'](),
cancelText: t['Cancel'](),
confirmText: t['com.affine.trashOperation.delete'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: onPermanentlyDeletePage,
});
},
[onPermanentlyDeletePage, openConfirmModal, t]
);
const handleRestorePage = useCatchEventCallback(
e => {
e.preventDefault();
onRestorePage();
},
[onRestorePage]
);
return (
<ColWrapper flex={1}>
@@ -297,9 +310,7 @@ export const TrashOperationCell = ({
tooltipOptions={tooltipSideTop}
data-testid="restore-page-button"
style={{ marginRight: '12px' }}
onClick={() => {
onRestorePage();
}}
onClick={handleRestorePage}
size="20"
>
<ResetIcon />

View File

@@ -1,5 +1,6 @@
import type { CheckboxProps } from '@affine/component';
import { Checkbox } from '@affine/component';
import { useCatchEventCallback } from '@affine/core/hooks/use-catch-event-hook';
import { useI18n } from '@affine/i18n';
import { MultiSelectIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
@@ -19,7 +20,6 @@ import {
useAtomValue,
} from './scoped-atoms';
import type { HeaderColDef, ListItem } from './types';
import { stopPropagation } from './utils';
// the checkbox on the header has three states:
// when list selectable = true, the checkbox will be presented
@@ -28,23 +28,19 @@ import { stopPropagation } from './utils';
const ListHeaderCheckbox = () => {
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
const items = useAtomValue(itemsAtom);
const onActivateSelection: MouseEventHandler = useCallback(
e => {
stopPropagation(e);
setSelectionState(true);
},
[setSelectionState]
);
const onActivateSelection: MouseEventHandler = useCatchEventCallback(() => {
setSelectionState(true);
}, [setSelectionState]);
const handlers = useAtomValue(listHandlersAtom);
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
(e, checked) => {
stopPropagation(e);
handlers.onSelectedIdsChange?.(
checked ? (items ?? []).map(i => i.id) : []
);
},
[handlers, items]
);
const onChange: NonNullable<CheckboxProps['onChange']> =
useCatchEventCallback(
(_e, checked) => {
handlers.onSelectedIdsChange?.(
checked ? (items ?? []).map(i => i.id) : []
);
},
[handlers, items]
);
if (!selectionState.selectable) {
return null;

View File

@@ -1,13 +1,14 @@
import { Checkbox, useDraggable } from '@affine/component';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { stopPropagation } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import type { ForwardedRef, PropsWithChildren } from 'react';
import { forwardRef, useCallback, useMemo } from 'react';
import { selectionStateAtom, useAtom } from '../scoped-atoms';
import type { TagListItemProps } from '../types';
import { ColWrapper, stopPropagation } from '../utils';
import { ColWrapper } from '../utils';
import * as styles from './tag-list-item.css';
const TagListTitleCell = ({

View File

@@ -1,5 +1,4 @@
import clsx from 'clsx';
import type { BaseSyntheticEvent } from 'react';
import { forwardRef } from 'react';
import * as styles from './list.css';
@@ -58,14 +57,6 @@ export const betweenDaysAgo = (
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1);
};
export function stopPropagation(event: BaseSyntheticEvent) {
event.stopPropagation();
event.preventDefault();
}
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
event.stopPropagation();
}
// credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js
export function shallowEqual(objA: any, objB: any) {
if (Object.is(objA, objB)) {

View File

@@ -9,6 +9,7 @@ import {
} from '@affine/core/modules/explorer';
import { ExplorerTags } from '@affine/core/modules/explorer/views/sections/tags';
import { CMDKQuickSearchService } from '@affine/core/modules/quicksearch/services/cmdk';
import { isNewTabTrigger } from '@affine/core/utils';
import { events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
import { AllDocsIcon, SettingsIcon } from '@blocksuite/icons/rc';
@@ -85,9 +86,7 @@ export const RootAppSidebar = (): ReactElement => {
const onClickNewPage = useAsyncCallback(
async (e?: MouseEvent) => {
const page = pageHelper.createPage(
e?.ctrlKey || e?.metaKey ? 'new-tab' : true
);
const page = pageHelper.createPage(isNewTabTrigger(e) ? 'new-tab' : true);
page.load();
track.$.navigationPanel.$.createDoc();
},

View File

@@ -1,13 +1,15 @@
import { useCatchEventCallback } from '@affine/core/hooks/use-catch-event-hook';
import {
useJournalInfoHelper,
useJournalRouteHelper,
} from '@affine/core/hooks/use-journal';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { DocCollection } from '@affine/core/shared';
import { isNewTabTrigger } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { TodayIcon, TomorrowIcon, YesterdayIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { type MouseEvent, useCallback } from 'react';
import { type MouseEvent } from 'react';
import { MenuItem } from '../app-sidebar';
@@ -27,9 +29,9 @@ export const AppSidebarJournalButton = ({
location.pathname.split('/')[1]
);
const handleOpenToday = useCallback(
const handleOpenToday = useCatchEventCallback(
(e: MouseEvent) => {
openToday(e.ctrlKey || e.metaKey);
openToday(isNewTabTrigger(e));
},
[openToday]
);
@@ -48,6 +50,7 @@ export const AppSidebarJournalButton = ({
data-testid="slider-bar-journals-button"
active={isJournal}
onClick={handleOpenToday}
onAuxClick={handleOpenToday}
icon={<Icon />}
>
{t['com.affine.journal.app-sidebar-title']()}

View File

@@ -19,7 +19,12 @@ export const UnknownUserIcon = memo(
</defs>
</svg>`;
return <div dangerouslySetInnerHTML={{ __html: svgRaw }} />;
return (
<div
style={{ lineHeight: 0 }}
dangerouslySetInnerHTML={{ __html: svgRaw }}
/>
);
}
);
UnknownUserIcon.displayName = 'UnknownUserIcon';

View File

@@ -2,14 +2,17 @@ import { type DependencyList, type SyntheticEvent } from 'react';
import { useAsyncCallback } from './affine-async-hooks';
export const useCatchEventCallback = <E extends SyntheticEvent>(
cb: (e: E) => void | Promise<void>,
export const useCatchEventCallback = <
E extends SyntheticEvent,
Args extends any[],
>(
cb: (e: E, ...args: Args) => void | Promise<void>,
deps: DependencyList
) => {
return useAsyncCallback(
async (e: E) => {
async (e: E, ...args: Args) => {
e.stopPropagation();
await cb(e);
await cb(e, ...args);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
deps

View File

@@ -18,6 +18,7 @@ type NavigationEvents =
| 'openInSplitView'
| 'switchTab'
| 'switchSplitView'
| 'tabAction'
| 'navigate'
| 'goBack'
| 'goForward'
@@ -229,6 +230,9 @@ const PageEvents = {
storage: ['viewPlans'],
aiAction: ['viewPlans'],
},
appTabsHeader: {
$: ['tabAction'],
},
header: {
actions: [
'createDoc',
@@ -319,6 +323,24 @@ type PaymentEventArgs = {
recurring: string;
};
type TabActionControlType =
| 'click'
| 'dnd'
| 'midClick'
| 'xButton'
| 'contextMenu';
type TabActionType =
| 'pin'
| 'unpin'
| 'close'
| 'refresh'
| 'moveTab'
| 'openInSplitView'
| 'openInNewTab'
| 'switchSplitView'
| 'switchTab'
| 'separateTabs';
export type EventArgs = {
createWorkspace: { flavour: string };
oauth: { provider: string };
@@ -342,6 +364,11 @@ export type EventArgs = {
orderOrganizeItem: OrganizeItemArgs;
openInNewTab: { type: OrganizeItemType };
openInSplitView: { type: OrganizeItemType };
tabAction: {
type?: OrganizeItemType;
control: TabActionControlType;
action: TabActionType;
};
toggleFavorite: OrganizeItemArgs & { on: boolean };
createDoc: { mode?: 'edgeless' | 'page' };
switchPageMode: { mode: 'edgeless' | 'page' };

View File

@@ -14,6 +14,7 @@ import { appSidebarWidthAtom } from '@affine/core/components/app-sidebar/index.j
import { WindowsAppControls } from '@affine/core/components/pure/header/windows-app-controls';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useCatchEventCallback } from '@affine/core/hooks/use-catch-event-hook';
import { track } from '@affine/core/mixpanel';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { apis, events } from '@affine/electron-api';
import { useI18n } from '@affine/i18n';
@@ -82,20 +83,78 @@ const WorkbenchTab = ({
const activeViewIndex = workbench.activeViewIndex ?? 0;
const onContextMenu = useAsyncCallback(
async (viewIdx: number) => {
await tabsHeaderService.showContextMenu?.(workbench.id, viewIdx);
const action = await tabsHeaderService.showContextMenu?.(
workbench.id,
viewIdx
);
switch (action?.type) {
case 'open-in-split-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'openInSplitView',
});
break;
}
case 'separate-view': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'separateTabs',
});
break;
}
case 'pin-tab': {
if (action.payload.shouldPin) {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'pin',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'unpin',
});
}
break;
}
// fixme: when close tab the view may already be gc'ed
case 'close-tab': {
track.$.appTabsHeader.$.tabAction({
control: 'contextMenu',
action: 'close',
});
break;
}
default:
break;
}
},
[tabsHeaderService, workbench.id]
);
const onActivateView = useAsyncCallback(
async (viewIdx: number) => {
await tabsHeaderService.activateView?.(workbench.id, viewIdx);
if (tabActive) {
track.$.appTabsHeader.$.tabAction({
control: 'click',
action: 'switchSplitView',
});
} else {
track.$.appTabsHeader.$.tabAction({
control: 'click',
action: 'switchTab',
});
}
},
[tabsHeaderService, workbench.id]
[tabActive, tabsHeaderService, workbench.id]
);
const handleAuxClick: MouseEventHandler = useCatchEventCallback(
async e => {
if (e.button === 1) {
await tabsHeaderService.closeTab?.(workbench.id);
track.$.appTabsHeader.$.tabAction({
control: 'midClick',
action: 'close',
});
}
},
[tabsHeaderService, workbench.id]
@@ -103,6 +162,10 @@ const WorkbenchTab = ({
const handleCloseTab = useCatchEventCallback(async () => {
await tabsHeaderService.closeTab?.(workbench.id);
track.$.appTabsHeader.$.tabAction({
control: 'xButton',
action: 'close',
});
}, [tabsHeaderService, workbench.id]);
const { dropTargetRef, closestEdge } = useDropTarget<AffineDNDData>(
@@ -243,6 +306,10 @@ export const AppTabsHeader = ({
const onAddTab = useAsyncCallback(async () => {
await tabsHeaderService.onAddTab?.();
track.$.appTabsHeader.$.tabAction({
control: 'click',
action: 'openInNewTab',
});
}, [tabsHeaderService]);
const onToggleRightSidebar = useAsyncCallback(async () => {
@@ -268,6 +335,10 @@ export const AppTabsHeader = ({
if (targetId === data.source.data.from.tabId) {
return;
}
track.$.appTabsHeader.$.tabAction({
control: 'dnd',
action: 'moveTab',
});
return await tabsHeaderService.moveTab?.(
data.source.data.from.tabId,
targetId,
@@ -276,6 +347,11 @@ export const AppTabsHeader = ({
}
if (data.source.data.entity?.type === 'doc') {
track.$.appTabsHeader.$.tabAction({
control: 'dnd',
action: 'openInNewTab',
type: 'doc',
});
return await tabsHeaderService.onAddDocTab?.(
data.source.data.entity.id,
targetId,
@@ -284,6 +360,11 @@ export const AppTabsHeader = ({
}
if (data.source.data.entity?.type === 'tag') {
track.$.appTabsHeader.$.tabAction({
type: 'tag',
control: 'dnd',
action: 'openInNewTab',
});
return await tabsHeaderService.onAddTagTab?.(
data.source.data.entity.id,
targetId,
@@ -292,6 +373,11 @@ export const AppTabsHeader = ({
}
if (data.source.data.entity?.type === 'collection') {
track.$.appTabsHeader.$.tabAction({
type: 'collection',
control: 'dnd',
action: 'openInNewTab',
});
return await tabsHeaderService.onAddCollectionTab?.(
data.source.data.entity.id,
targetId,

View File

@@ -32,8 +32,15 @@ export class DocsIndexer extends Entity {
'jq:' + this.workspaceService.workspace.id
);
private readonly runner = new JobRunner(this.jobQueue, (jobs, signal) =>
this.execJob(jobs, signal)
private readonly runner = new JobRunner(
this.jobQueue,
(jobs, signal) => this.execJob(jobs, signal),
() =>
new Promise<void>(resolve =>
requestIdleCallback(() => resolve(), {
timeout: 200,
})
)
);
private readonly indexStorage = new IndexedDBIndexStorage(
@@ -173,7 +180,7 @@ export class DocsIndexer extends Entity {
}
);
for (const block of oldBlocks.nodes) {
docIndexWriter.delete(block.id);
blockIndexWriter.delete(block.id);
}
}
await blockIndexWriter.commit();

View File

@@ -79,7 +79,7 @@ async function crawlingDocData({
(
yRootDoc.getMap('meta').get('pages') as YArray<YMap<any>> | undefined
)?.forEach(page => {
if (page.get('id') === storageDocId) {
if (page.get('id') === docId) {
docExists = !(page.get('trash') ?? false);
}
});

View File

@@ -750,7 +750,7 @@ export const ExplorerFolderNodeFolder = ({
}
data-event-props="$.navigationPanel.organize.deleteOrganizeItem"
data-event-args-type={node.type$.value}
onClick={node.delete}
onClick={() => node.delete()}
>
{t['com.affine.rootAppSidebar.organize.delete-from-folder']()}
</MenuItem>

View File

@@ -15,6 +15,7 @@ import {
} from '@affine/core/modules/favorite';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { isNewTabTrigger } from '@affine/core/utils';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import { DocsService, useLiveData, useServices } from '@toeverything/infra';
@@ -81,7 +82,7 @@ export const ExplorerFavorites = () => {
favoriteService.favoriteList.indexAt('before')
);
workbenchService.workbench.openDoc(newDoc.id, {
at: e.ctrlKey || e.metaKey ? 'new-tab' : 'active',
at: isNewTabTrigger(e) ? 'new-tab' : 'active',
});
explorerSection.setCollapsed(false);
},
@@ -173,6 +174,7 @@ export const ExplorerFavorites = () => {
data-event-props="$.navigationPanel.favorites.createDoc"
data-event-args-control="addFavorite"
onClick={handleCreateNewFavoriteDoc}
onAuxClick={handleCreateNewFavoriteDoc}
size="16"
tooltip={t[
'com.affine.rootAppSidebar.explorer.fav-section-add-tooltip'

View File

@@ -66,8 +66,14 @@ export const ExplorerMigrationFavorites = () => {
const handleClickHelp = useCallback(() => {
openConfirmModal({
title: t['com.affine.rootAppSidebar.migration-data.help'](),
description:
t['com.affine.rootAppSidebar.migration-data.help.description'](),
description: (
<Trans
i18nKey="com.affine.rootAppSidebar.migration-data.help.description"
components={{
b: <b className={styles.descriptionHighlight} />,
}}
/>
),
confirmText: t['com.affine.rootAppSidebar.migration-data.help.confirm'](),
confirmButtonOptions: {
variant: 'primary',

View File

@@ -1,4 +1,3 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const container = style({
@@ -19,6 +18,5 @@ export const container = style({
});
export const descriptionHighlight = style({
color: cssVar('--affine-warning-color'),
fontWeight: 'normal',
fontWeight: 'bold',
});

View File

@@ -36,6 +36,14 @@ export const itemRoot = style({
},
},
});
export const itemRenameAnchor = style({
pointerEvents: 'none',
position: 'absolute',
left: 0,
top: -10,
width: 10,
height: 10,
});
export const itemContent = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -140,7 +148,6 @@ export const contentContainer = style({
export const draggingContainer = style({
background: cssVar('--affine-background-primary-color'),
boxShadow: cssVar('--affine-toolbar-shadow'),
width: '200px',
borderRadius: '6px',
});

View File

@@ -11,6 +11,7 @@ import {
useDropTarget,
} from '@affine/component';
import { RenameModal } from '@affine/component/rename-modal';
import { appSidebarWidthAtom } from '@affine/core/components/app-sidebar/index.jotai';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import type { AffineDNDData } from '@affine/core/types/dnd';
import { useI18n } from '@affine/i18n';
@@ -23,6 +24,7 @@ import * as Collapsible from '@radix-ui/react-collapsible';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import type { To } from 'history';
import { useAtomValue } from 'jotai';
import {
Fragment,
type RefAttributes,
@@ -111,6 +113,7 @@ export const ExplorerTreeNode = ({
// If no onClick or to is provided, clicking on the node will toggle the collapse state
const clickForCollapse = !onClick && !to && !disabled;
const [childCount, setChildCount] = useState(0);
const sidebarWidth = useAtomValue(appSidebarWidthAtom);
const [renaming, setRenaming] = useState(defaultRenaming);
const [lastInGroup, setLastInGroup] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
@@ -319,11 +322,14 @@ export const ExplorerTreeNode = ({
)}
{renameable && renaming && (
<RenameModal
open={renaming}
open
width={sidebarWidth - 32}
onOpenChange={setRenaming}
onRename={handleRename}
currentName={name ?? ''}
/>
>
<div className={styles.itemRenameAnchor} />
</RenameModal>
)}
<div className={styles.itemContent}>{name}</div>

View File

@@ -86,7 +86,10 @@ export class DocsQuickSearchSession
)
.map(([doc, docRecord]) => {
const { title, icon, updatedDate } =
this.docDisplayMetaService.getDocDisplayMeta(docRecord);
this.docDisplayMetaService.getDocDisplayMeta(
docRecord,
'title' in doc ? doc.title : undefined
);
return {
id: 'doc:' + docRecord.id,
source: 'docs',

View File

@@ -37,8 +37,9 @@ export class RecentDocsQuickSearchSession
const docRecords = this.recentDocsService.getRecentDocs();
return docRecords.map<QuickSearchItem<'recent-doc', { docId: string }>>(
docRecord => {
return docRecords
.filter(doc => !get(doc.trash$))
.map<QuickSearchItem<'recent-doc', { docId: string }>>(docRecord => {
const { title, icon } =
this.docDisplayMetaService.getDocDisplayMeta(docRecord);
@@ -54,8 +55,7 @@ export class RecentDocsQuickSearchSession
timestamp: docRecord.meta$.value.updatedDate,
payload: { docId: docRecord.id },
};
}
);
});
});
query(query: string) {

View File

@@ -10,7 +10,7 @@ export class DocDisplayMetaService extends Service {
super();
}
getDocDisplayMeta(docRecord: DocRecord) {
getDocDisplayMeta(docRecord: DocRecord, originalTitle?: string) {
const journalDateString = this.propertiesAdapter.getJournalPageDateString(
docRecord.id
);
@@ -22,7 +22,8 @@ export class DocDisplayMetaService extends Service {
const title = journalDateString
? i18nTime(journalDateString, { absolute: { accuracy: 'day' } })
: docRecord.meta$.value.title ||
: originalTitle ||
docRecord.meta$.value.title ||
({
key: 'Untitled',
} as const);

View File

@@ -1,5 +1,6 @@
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useCatchEventCallback } from '@affine/core/hooks/use-catch-event-hook';
import { isNewTabTrigger } from '@affine/core/utils';
import { useLiveData, useService } from '@toeverything/infra';
import { type To } from 'history';
import { forwardRef, type MouseEvent } from 'react';
@@ -11,7 +12,7 @@ export const WorkbenchLink = forwardRef<
React.PropsWithChildren<
{
to: To;
onClick?: (e: MouseEvent) => boolean | void; // return false to stop propagation
onClick?: (e: MouseEvent) => void;
} & React.HTMLProps<HTMLAnchorElement>
>
>(function WorkbenchLink({ to, onClick, ...other }, ref) {
@@ -23,26 +24,33 @@ export const WorkbenchLink = forwardRef<
(typeof to === 'string' ? to : `${to.pathname}${to.search}${to.hash}`);
const handleClick = useCatchEventCallback(
async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (onClick?.(event) === false) {
onClick?.(event);
if (event.defaultPrevented) {
return;
}
const at = (() => {
if (event.ctrlKey || event.metaKey) {
if (isNewTabTrigger(event)) {
return event.altKey && appSettings.enableMultiView
? 'tail'
: 'new-tab';
}
return 'active';
})();
workbench.open(to, { at });
event.preventDefault();
},
[appSettings.enableMultiView, onClick, to, workbench]
);
// eslint suspicious runtime error
// eslint-disable-next-line react/no-danger-with-children
return <a {...other} ref={ref} href={link} onClick={handleClick} />;
return (
<a
{...other}
ref={ref}
href={link}
onClick={handleClick}
onAuxClick={handleClick}
/>
);
});

View File

@@ -100,6 +100,8 @@ export class CloudWorkspaceFlavourProviderService
blobSources: {
main: blobStorage,
},
disableBacklinkIndex: true,
disableSearchIndex: true,
});
// apply initial state

View File

@@ -72,6 +72,8 @@ export class LocalWorkspaceFlavourProvider
idGenerator: () => nanoid(),
schema: globalBlockSuiteSchema,
blobSources: { main: blobStorage },
disableBacklinkIndex: true,
disableSearchIndex: true,
});
// apply initial state

View File

@@ -8,6 +8,7 @@ import { Header } from '@affine/core/components/pure/header';
import { WorkspaceModeFilterTab } from '@affine/core/components/pure/workspace-mode-filter-tab';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { track } from '@affine/core/mixpanel';
import { isNewTabTrigger } from '@affine/core/utils';
import type { Filter } from '@affine/env/filter';
import { PlusIcon } from '@blocksuite/icons/rc';
import { useService, WorkspaceService } from '@toeverything/infra';
@@ -60,10 +61,10 @@ export const AllPageHeader = ({
!showCreateNew && styles.headerCreateNewButtonHidden
)}
onCreateEdgeless={e =>
createEdgeless(e?.metaKey || e?.ctrlKey ? 'new-tab' : true)
createEdgeless(isNewTabTrigger(e) ? 'new-tab' : true)
}
onCreatePage={e =>
createPage(e?.metaKey || e?.ctrlKey ? 'new-tab' : true)
createPage(isNewTabTrigger(e) ? 'new-tab' : true)
}
onImportFile={onImportFile}
>

View File

@@ -121,11 +121,19 @@ export const Component = function CollectionPage() {
if (!collection) {
return null;
}
return isEmpty(collection) ? (
const inner = isEmpty(collection) ? (
<Placeholder collection={collection} />
) : (
<CollectionDetail collection={collection} />
);
return (
<>
<ViewIcon icon="collection" />
<ViewTitle title={collection.name} />
{inner}
</>
);
};
const Placeholder = ({ collection }: { collection: Collection }) => {
@@ -157,8 +165,6 @@ const Placeholder = ({ collection }: { collection: Collection }) => {
return (
<>
<ViewTitle title={collection.name} />
<ViewIcon icon="collection" />
<ViewHeader>
<div
style={{

View File

@@ -2,12 +2,13 @@ import type { DateCell } from '@affine/component';
import { DatePicker, IconButton, Menu, Scrollable } from '@affine/component';
import { MoveToTrash } from '@affine/core/components/page-list';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import {
useJournalHelper,
useJournalInfoHelper,
useJournalRouteHelper,
} from '@affine/core/hooks/use-journal';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import {
EdgelessIcon,
@@ -41,14 +42,18 @@ const CountDisplay = ({
}: { count: number; max?: number } & HTMLAttributes<HTMLSpanElement>) => {
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
};
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
interface PageItemProps
extends Omit<HTMLAttributes<HTMLAnchorElement>, 'onClick'> {
docRecord: DocRecord;
right?: ReactNode;
}
const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => {
const title = useLiveData(docRecord.title$);
const mode = useLiveData(docRecord.mode$);
const workspace = useService(WorkspaceService).workspace;
const title = useDocCollectionPageTitle(
workspace.docCollection,
docRecord.id
);
const { isJournal } = useJournalInfoHelper(
workspace.docCollection,
docRecord.id
@@ -60,8 +65,9 @@ const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => {
? EdgelessIcon
: PageIcon;
return (
<div
<WorkbenchLink
aria-label={title}
to={`/${docRecord.id}`}
className={clsx(className, styles.pageItem)}
{...attrs}
>
@@ -70,7 +76,7 @@ const PageItem = ({ docRecord, right, className, ...attrs }: PageItemProps) => {
</div>
<span className={styles.pageItemLabel}>{title}</span>
{right}
</div>
</WorkbenchLink>
);
};
@@ -177,14 +183,11 @@ const DailyCountEmptyFallback = ({ name }: { name: NavItemName }) => {
);
};
const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
const workspace = useService(WorkspaceService).workspace;
const nodeRef = useRef<HTMLDivElement>(null);
const t = useI18n();
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
const docRecords = useLiveData(useService(DocsService).list.docs$);
const navigateHelper = useNavigateHelper();
const getTodaysPages = useCallback(
(field: 'createDate' | 'updatedDate') => {
return sortPagesByDate(
@@ -267,9 +270,6 @@ const JournalDailyCountBlock = ({ date }: JournalBlockProps) => {
<div className={styles.dailyCountContent} ref={nodeRef}>
{renderList.map((pageRecord, index) => (
<PageItem
onClick={() =>
navigateHelper.openPage(workspace.id, pageRecord.id)
}
tabIndex={name === activeItem ? 0 : -1}
key={index}
docRecord={pageRecord}
@@ -297,7 +297,6 @@ const ConflictList = ({
className,
...attrs
}: ConflictListProps) => {
const navigateHelper = useNavigateHelper();
const workspace = useService(WorkspaceService).workspace;
const currentDoc = useService(DocService).doc;
const { setTrashModal } = useTrashModalHelper(workspace.docCollection);
@@ -335,7 +334,6 @@ const ConflictList = ({
</IconButton>
</Menu>
}
onClick={() => navigateHelper.openPage(workspace.id, docRecord.id)}
/>
);
})}

View File

@@ -0,0 +1,14 @@
import type { BaseSyntheticEvent } from 'react';
export function stopPropagation(event: BaseSyntheticEvent) {
event.stopPropagation();
}
export function stopEvent(event: BaseSyntheticEvent) {
event.stopPropagation();
event.preventDefault();
}
export function isNewTabTrigger(event?: React.MouseEvent) {
return event ? event.ctrlKey || event.metaKey || event.button === 1 : false;
}

View File

@@ -1,4 +1,5 @@
export * from './create-emotion-cache';
export * from './event';
export * from './fractional-indexing';
export * from './popup';
export * from './string2color';

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/electron-api",
"version": "0.15.0",
"version": "0.16.0",
"type": "module",
"private": true,
"main": "./src/index.ts",

View File

@@ -1,7 +1,7 @@
{
"name": "@affine/electron",
"private": true,
"version": "0.15.0",
"version": "0.16.0",
"author": "toeverything",
"repository": {
"url": "https://github.com/toeverything/AFFiNE",
@@ -29,10 +29,10 @@
"@affine/env": "workspace:*",
"@affine/i18n": "workspace:*",
"@affine/native": "workspace:*",
"@blocksuite/block-std": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/blocks": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/presets": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/store": "0.17.0-canary-202408071302-51ae473",
"@blocksuite/block-std": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/blocks": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/presets": "0.17.0-canary-202408080742-0cbcb73",
"@blocksuite/store": "0.17.0-canary-202408080742-0cbcb73",
"@electron-forge/cli": "^7.3.0",
"@electron-forge/core": "^7.3.0",
"@electron-forge/core-utils": "^7.3.0",

View File

@@ -2,6 +2,7 @@ import 'setimmediate';
import '@affine/component/theme/global.css';
import '@affine/component/theme/theme.css';
import '@affine/core/bootstrap/preload';
import '../global.css';
import { ThemeProvider } from '@affine/component/theme-provider';
import { configureAppTabsHeaderModule } from '@affine/core/modules/app-tabs-header';

View File

@@ -5,6 +5,7 @@ import {
addTab,
closeTab,
reloadView,
type TabAction,
WebContentViewsManager,
} from './tab-views';
@@ -15,6 +16,8 @@ export const showTabContextMenu = async (tabId: string, viewIndex: number) => {
return;
}
const { resolve, promise } = Promise.withResolvers<TabAction | null>();
const template: Parameters<typeof Menu.buildFromTemplate>[0] = [
tabMeta.pinned
? {
@@ -90,4 +93,22 @@ export const showTabContextMenu = async (tabId: string, viewIndex: number) => {
];
const menu = Menu.buildFromTemplate(template);
menu.popup();
// eslint-disable-next-line prefer-const
let unsub: (() => void) | undefined;
const subscription = WebContentViewsManager.instance.tabAction$.subscribe(
action => {
resolve(action);
unsub?.();
}
);
menu.on('menu-will-close', () => {
setTimeout(() => {
resolve(null);
unsub?.();
});
});
unsub = () => {
subscription.unsubscribe();
};
return promise;
};

View File

@@ -108,7 +108,7 @@ type OpenInSplitViewAction = {
};
};
type TabAction =
export type TabAction =
| AddTabAction
| CloseTabAction
| PinTabAction

View File

@@ -1,7 +1,10 @@
import { MemoryMemento } from '@toeverything/infra';
import { ipcRenderer } from 'electron';
import { AFFINE_API_CHANNEL_NAME } from '../shared/type';
import {
AFFINE_API_CHANNEL_NAME,
AFFINE_EVENT_CHANNEL_NAME,
} from '../shared/type';
const initialGlobalState = ipcRenderer.sendSync(
AFFINE_API_CHANNEL_NAME,
@@ -29,12 +32,14 @@ function createSharedStorageApi(
) {
const memory = new MemoryMemento();
memory.setAll(init);
ipcRenderer.on(`sharedStorage:${event}`, (_event, updates) => {
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
memory.del(key);
} else {
memory.set(key, value);
ipcRenderer.on(AFFINE_EVENT_CHANNEL_NAME, (_event, channel, updates) => {
if (channel === `sharedStorage:${event}`) {
for (const [key, value] of Object.entries(updates)) {
if (value === undefined) {
memory.del(key);
} else {
memory.set(key, value);
}
}
}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/graphql",
"version": "0.15.0",
"version": "0.16.0",
"description": "Autogenerated GraphQL client for affine.pro",
"license": "MIT",
"type": "module",

View File

@@ -33,5 +33,5 @@
"typescript": "^5.4.5",
"vitest": "1.6.0"
},
"version": "0.15.0"
"version": "0.16.0"
}

File diff suppressed because it is too large Load Diff

View File

@@ -1135,17 +1135,17 @@
"com.affine.rootAppSidebar.explorer.organize-section-add-tooltip": "New Folder",
"com.affine.rootAppSidebar.explorer.tag-add-tooltip": "New Doc",
"com.affine.rootAppSidebar.explorer.tag-section-add-tooltip": "New Tag",
"com.affine.rootAppSidebar.favorites": "Favourites",
"com.affine.rootAppSidebar.favorites": "Favorites",
"com.affine.rootAppSidebar.favorites.empty": "No Favorites",
"com.affine.rootAppSidebar.migration-data": "Migration data",
"com.affine.rootAppSidebar.migration-data.clean-all": "Delete All Old Data",
"com.affine.rootAppSidebar.migration-data.clean-all": "Empty the old favorites",
"com.affine.rootAppSidebar.migration-data.clean-all.cancel": "Cancel",
"com.affine.rootAppSidebar.migration-data.clean-all.confirm": "OK",
"com.affine.rootAppSidebar.migration-data.clean-all.description": "This action will empty and delete the entire discontinued Favorites section. Don't worry, <b>all your documents will not be affected - this only removes the old Favorites from the sidebar to place the new one</b>. In the meantime, please verify that you've moved all your frequently accessed documents to the spanking new personal Favorites section first.",
"com.affine.rootAppSidebar.migration-data.clean-all.description": "This action deletes the old Favorites section. <b>Your documents are safe</b>, ensure you've moved your frequently accessed documents to the new personal Favorites section.",
"com.affine.rootAppSidebar.migration-data.help": "The old \"Favorites\" will be replaced",
"com.affine.rootAppSidebar.migration-data.help.clean-all": "Delete all the old data",
"com.affine.rootAppSidebar.migration-data.help.clean-all": "Empty the old favorites",
"com.affine.rootAppSidebar.migration-data.help.confirm": "OK",
"com.affine.rootAppSidebar.migration-data.help.description": "Your documents are safe, yet you may need to re-pin your most-used ones. Previously, 'Favorites' were shared across the workspace. We've improved on this - now each person has a personal 'Favorites' section for their top documents, collections, and folders. We advise migrating your data.\nThe old 'Favorites' will disappear once emptied. Drag your items with ease from the old shared 'Favorites' into your new personal section, or opt to delete all old favorites by simply clicking 'Delete all the old data' now.",
"com.affine.rootAppSidebar.migration-data.help.description": "<b>Your documents are safe</b>, but you'll need to re-pin your most-used ones. \"Favorites\" are now personal. Move items from the old shared section to your new personal section or remove the old one by clicking \"Empty the old favorites\" now.",
"com.affine.rootAppSidebar.organize": "Organize",
"com.affine.rootAppSidebar.organize.delete": "Delete",
"com.affine.rootAppSidebar.organize.delete-from-folder": "Remove from folder",

View File

@@ -1,6 +1,7 @@
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
// Run `yarn run download-resources` to regenerate.
// If you need to update the code, please edit `i18n/src/scripts/download.ts` inside your project.
import ar from './ar.json';
import ca from './ca.json';
import da from './da.json';
import de from './de.json';
@@ -69,7 +70,7 @@ export const LOCALES = [
originalName: '简体中文',
flagEmoji: '🇨🇳',
base: false,
completeRate: 0.99,
completeRate: 0.974,
res: zh_Hans,
},
{
@@ -212,4 +213,14 @@ export const LOCALES = [
completeRate: 0.021,
res: ur,
},
{
id: 1000134005,
name: 'Arabic',
tag: 'ar',
originalName: 'العربية',
flagEmoji: '🇸🇦',
base: false,
completeRate: 0.974,
res: ar,
},
] as const;

View File

@@ -1139,14 +1139,14 @@
"com.affine.rootAppSidebar.favorites": "收藏夹",
"com.affine.rootAppSidebar.favorites.empty": "您可以将文档添加到您的收藏",
"com.affine.rootAppSidebar.migration-data": "迁移数据",
"com.affine.rootAppSidebar.migration-data.clean-all": "删除所有旧数据",
"com.affine.rootAppSidebar.migration-data.clean-all": "清空旧收藏夹",
"com.affine.rootAppSidebar.migration-data.clean-all.cancel": "取消",
"com.affine.rootAppSidebar.migration-data.clean-all.confirm": "好的",
"com.affine.rootAppSidebar.migration-data.clean-all.description": "此操作将清空并删除整个已停用的收藏夹部分。别担心,<b>您的所有文档都不会受到影响 - 这只会从侧边栏中删除旧的收藏夹并放置新的收藏夹</b>。与此同时,请先验证您是否已将所有经常访问的文档移动到新的个人收藏夹部分。",
"com.affine.rootAppSidebar.migration-data.help": "旧的“收藏夹”将被替换",
"com.affine.rootAppSidebar.migration-data.help.clean-all": "删除所有旧数据",
"com.affine.rootAppSidebar.migration-data.clean-all.description": "此操作将删除旧的收藏夹部分。<b>您的文档是安全的</b>,请确保您已将经常访问的文档移动到新的个人收藏夹。",
"com.affine.rootAppSidebar.migration-data.help": "“收藏夹”将被改为个人收藏夹",
"com.affine.rootAppSidebar.migration-data.help.clean-all": "清空旧收藏夹",
"com.affine.rootAppSidebar.migration-data.help.confirm": "好的",
"com.affine.rootAppSidebar.migration-data.help.description": "您的文档是安全的,但您可能需要重新固定最常用的文档。以前,“收藏夹”是在整个工作区共享的。我们对此进行了改进 - 现在每个人都有一个个人“收藏夹”部分,用于存放他们最常用的文档、精选和文件夹。我们建议您迁移数据。\n旧的“收藏”一旦清空就会消失。轻松地将您的项目从旧的共享“收藏夹”拖到新的个人部分,或者选择删除所有旧收藏夹,只需单击“删除所有旧数据”即可。",
"com.affine.rootAppSidebar.migration-data.help.description": "<b>您的文档是安全的</b>,但您需要重新收藏最常用的文档。“收藏夹”现在属于个人。现在将文档从旧收藏夹移动到新的个人收藏夹,或通过单击“清空旧收藏夹”删除旧收藏夹。",
"com.affine.rootAppSidebar.organize": "组织",
"com.affine.rootAppSidebar.organize.delete": "删除",
"com.affine.rootAppSidebar.organize.delete-from-folder": "从文件夹移出",
@@ -1243,7 +1243,7 @@
"com.affine.settings.translucent-style-description": "在侧边栏使用半透明效果。",
"com.affine.settings.workspace": "工作区",
"com.affine.settings.workspace.description": "您可以在此处自定义您的工作区。",
"com.affine.settings.workspace.experimental-features": "插件",
"com.affine.settings.workspace.experimental-features": "实验性功能",
"com.affine.settings.workspace.experimental-features.get-started": "开始使用",
"com.affine.settings.workspace.experimental-features.header.plugins": "实验性功能",
"com.affine.settings.workspace.experimental-features.prompt-disclaimer": "我已明确风险,并且愿意继续使用它。",

View File

@@ -57,5 +57,5 @@
"test": "ava",
"version": "napi version"
},
"version": "0.15.0"
"version": "0.16.0"
}

View File

@@ -2,7 +2,7 @@
"name": "@affine/templates",
"private": true,
"sideEffect": false,
"version": "0.15.0",
"version": "0.16.0",
"scripts": {
"postinstall": "node ./build-edgeless.mjs && node ./build-stickers.mjs"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@affine/web",
"version": "0.15.0",
"version": "0.16.0",
"description": "AFFiNE Desktop Web application",
"private": true,
"browser": "src/index.tsx",

View File

@@ -9,5 +9,5 @@
"@affine-test/kit": "workspace:*",
"@playwright/test": "=1.44.1"
},
"version": "0.15.0"
"version": "0.16.0"
}

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