mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-08 18:43:46 +00:00
Compare commits
29 Commits
v0.16.0-be
...
v0.16.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a579cc7716 | ||
|
|
b993ab04df | ||
|
|
eef9afd3ed | ||
|
|
06d5d9719c | ||
|
|
f8e51112aa | ||
|
|
e8d5692062 | ||
|
|
d2b0ee40a8 | ||
|
|
3ad5170b71 | ||
|
|
8209e84842 | ||
|
|
fc19180451 | ||
|
|
009b5353b1 | ||
|
|
4beedaa22c | ||
|
|
26fd9a4a1c | ||
|
|
b2c00a2618 | ||
|
|
85637156f6 | ||
|
|
c006f3f0af | ||
|
|
7efc87b6d3 | ||
|
|
450106ea54 | ||
|
|
ffc12176c9 | ||
|
|
3d4fbcaebc | ||
|
|
8db37e9bbf | ||
|
|
7fca13076a | ||
|
|
fd6e198295 | ||
|
|
b71945c29f | ||
|
|
6ef5675be1 | ||
|
|
c7aabd3a8d | ||
|
|
03fd23de39 | ||
|
|
f2eafc374c | ||
|
|
83244f0201 |
@@ -247,7 +247,8 @@ const config = {
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
{
|
||||
additionalHooks: '(useAsyncCallback|useDraggable|useDropTarget)',
|
||||
additionalHooks:
|
||||
'(useAsyncCallback|useCatchEventCallback|useDraggable|useDropTarget)',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
2
.github/helm/affine/Chart.yaml
vendored
2
.github/helm/affine/Chart.yaml
vendored
@@ -3,4 +3,4 @@ name: affine
|
||||
description: AFFiNE cloud chart
|
||||
type: application
|
||||
version: 0.0.0
|
||||
appVersion: "0.15.0"
|
||||
appVersion: "0.16.0"
|
||||
|
||||
@@ -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
|
||||
|
||||
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
2
.github/helm/affine/charts/sync/Chart.yaml
vendored
@@ -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
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
],
|
||||
"ext": "ts,md,json"
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -36,5 +36,6 @@ export {
|
||||
getRequestFromHost,
|
||||
getRequestResponseFromContext,
|
||||
getRequestResponseFromHost,
|
||||
parseCookies,
|
||||
} from './utils/request';
|
||||
export type * from './utils/types';
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
20
packages/backend/server/src/fundamentals/websocket/config.ts
Normal file
20
packages/backend/server/src/fundamentals/websocket/config.ts
Normal 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,
|
||||
});
|
||||
@@ -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 {}
|
||||
|
||||
2
packages/backend/server/src/global.d.ts
vendored
2
packages/backend/server/src/global.d.ts
vendored
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"@types/debug": "^4.1.12",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
6
packages/common/env/package.json
vendored
6
packages/common/env/package.json
vendored
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.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"
|
||||
}
|
||||
|
||||
12
packages/common/env/src/ua-helper.ts
vendored
12
packages/common/env/src/ua-helper.ts
vendored
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export class Workspace extends Entity {
|
||||
},
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
}
|
||||
return this._docCollection;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/admin",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@affine/core": "workspace:*",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
33
packages/frontend/component/src/hooks/focus-and-select.ts
Normal file
33
packages/frontend/component/src/hooks/focus-and-select.ts
Normal 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;
|
||||
};
|
||||
1
packages/frontend/component/src/hooks/index.ts
Normal file
1
packages/frontend/component/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useAutoFocus, useAutoSelect } from './focus-and-select';
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -29,6 +29,7 @@ export function AddPageButton({
|
||||
style={style}
|
||||
className={clsx([styles.root, className])}
|
||||
onClick={onClick}
|
||||
onAuxClick={onClick}
|
||||
>
|
||||
<PlusIcon />
|
||||
</IconButton>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = ({
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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']()}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,6 +100,8 @@ export class CloudWorkspaceFlavourProviderService
|
||||
blobSources: {
|
||||
main: blobStorage,
|
||||
},
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
// apply initial state
|
||||
|
||||
@@ -72,6 +72,8 @@ export class LocalWorkspaceFlavourProvider
|
||||
idGenerator: () => nanoid(),
|
||||
schema: globalBlockSuiteSchema,
|
||||
blobSources: { main: blobStorage },
|
||||
disableBacklinkIndex: true,
|
||||
disableSearchIndex: true,
|
||||
});
|
||||
|
||||
// apply initial state
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
14
packages/frontend/core/src/utils/event.ts
Normal file
14
packages/frontend/core/src/utils/event.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './create-emotion-cache';
|
||||
export * from './event';
|
||||
export * from './fractional-indexing';
|
||||
export * from './popup';
|
||||
export * from './string2color';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/electron-api",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -108,7 +108,7 @@ type OpenInSplitViewAction = {
|
||||
};
|
||||
};
|
||||
|
||||
type TabAction =
|
||||
export type TabAction =
|
||||
| AddTabAction
|
||||
| CloseTabAction
|
||||
| PinTabAction
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -33,5 +33,5 @@
|
||||
"typescript": "^5.4.5",
|
||||
"vitest": "1.6.0"
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
1444
packages/frontend/i18n/src/resources/ar.json
Normal file
1444
packages/frontend/i18n/src/resources/ar.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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": "我已明确风险,并且愿意继续使用它。",
|
||||
|
||||
@@ -57,5 +57,5 @@
|
||||
"test": "ava",
|
||||
"version": "napi version"
|
||||
},
|
||||
"version": "0.15.0"
|
||||
"version": "0.16.0"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user