mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 18:02:47 +08:00
Compare commits
137 Commits
v0.26.3-be
...
v0.12.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df17001284 | ||
|
|
e400abf1f4 | ||
|
|
640aa00148 | ||
|
|
5ae8f029f7 | ||
|
|
a26e0b3ec9 | ||
|
|
f492b6711b | ||
|
|
81aae61394 | ||
|
|
e08f58beea | ||
|
|
4560819f76 | ||
|
|
193c197a54 | ||
|
|
449c0a38a7 | ||
|
|
8d141e5a81 | ||
|
|
e04911315f | ||
|
|
75d58679b6 | ||
|
|
2e6386e4cf | ||
|
|
f345a61df0 | ||
|
|
a6420fcd76 | ||
|
|
fec406f7e8 | ||
|
|
769398591b | ||
|
|
e01569fff7 | ||
|
|
6bde2de783 | ||
|
|
3513ced6cb | ||
|
|
8dc9addc40 | ||
|
|
9d9f89ef2e | ||
|
|
6cfe5d4566 | ||
|
|
6032b432f8 | ||
|
|
5823787ded | ||
|
|
b3f272ba70 | ||
|
|
a5df5a7c8a | ||
|
|
90de90403a | ||
|
|
4d4e4fc4e2 | ||
|
|
aa73e532d3 | ||
|
|
31faa93c71 | ||
|
|
def60f4c61 | ||
|
|
d15ec0ff77 | ||
|
|
d2acd0385a | ||
|
|
1effb2f25f | ||
|
|
9189d26332 | ||
|
|
79a8be7799 | ||
|
|
1a643cc70c | ||
|
|
9321be3ff5 | ||
|
|
24dc3f95ff | ||
|
|
4257b5f3a4 | ||
|
|
ea17e86032 | ||
|
|
48cd8999bd | ||
|
|
cdf1d9002e | ||
|
|
79b39f14d2 | ||
|
|
619420cfd1 | ||
|
|
739e914b5f | ||
|
|
5e9739eb3a | ||
|
|
0a89b7f528 | ||
|
|
0a0ee37ac2 | ||
|
|
a143379161 | ||
|
|
8e7dedfe82 | ||
|
|
d25a8547d0 | ||
|
|
4d16229fea | ||
|
|
99371be7e8 | ||
|
|
34ed8dd7a5 | ||
|
|
39b7b671b1 | ||
|
|
207b56d5af | ||
|
|
9e94e7195b | ||
|
|
de951c8779 | ||
|
|
fd37026ca5 | ||
|
|
4fd5812a89 | ||
|
|
d01e987ecc | ||
|
|
d87c218c0b | ||
|
|
a5bf5cc244 | ||
|
|
16bcd6e76b | ||
|
|
2e2ace8472 | ||
|
|
37cff8fe8d | ||
|
|
70ab3b4916 | ||
|
|
f42ba54578 | ||
|
|
a67c8181fc | ||
|
|
613efbded9 | ||
|
|
549419d102 | ||
|
|
21c42f8771 | ||
|
|
9012adda7a | ||
|
|
fb442e9055 | ||
|
|
a231474dd2 | ||
|
|
833b42000b | ||
|
|
7690c48710 | ||
|
|
579828a700 | ||
|
|
746db2ccfc | ||
|
|
eff344a9c1 | ||
|
|
c89ebab596 | ||
|
|
62f4421b7c | ||
|
|
42383dbd29 | ||
|
|
120e7397ba | ||
|
|
24123ad01c | ||
|
|
ad50320391 | ||
|
|
eb21a60dda | ||
|
|
c0e3be2d40 | ||
|
|
09d3b72358 | ||
|
|
246e16c6c0 | ||
|
|
dc279d062b | ||
|
|
47d5f9e1c2 | ||
|
|
a226eb8d5f | ||
|
|
908c4e1a6f | ||
|
|
1d0bcc80a0 | ||
|
|
50010bd824 | ||
|
|
c0ede1326d | ||
|
|
89197bacef | ||
|
|
f97d323ab5 | ||
|
|
2acb219dcc | ||
|
|
992ed89a89 | ||
|
|
d272d7922d | ||
|
|
c1cd1713b9 | ||
|
|
b20e91bee0 | ||
|
|
9a4e5ec8c3 | ||
|
|
2019838ae7 | ||
|
|
30ff25f400 | ||
|
|
e766208c18 | ||
|
|
8742f28148 | ||
|
|
cd291bb60e | ||
|
|
62c0efcfd1 | ||
|
|
87248b3337 | ||
|
|
00c940f7df | ||
|
|
931b459fbd | ||
|
|
51e71f4a0a | ||
|
|
9b631f2328 | ||
|
|
01f481a9b6 | ||
|
|
0177ab5c87 | ||
|
|
4db35d341c | ||
|
|
3c4a803c97 | ||
|
|
05154dc7ca | ||
|
|
c90b477f60 | ||
|
|
6f18ddbe85 | ||
|
|
dde779a71d | ||
|
|
bd9f66fbc7 | ||
|
|
92f1f40bfa | ||
|
|
48dc1049b3 | ||
|
|
9add530370 | ||
|
|
b77460d871 | ||
|
|
42db41776b | ||
|
|
075439c74f | ||
|
|
fc6c553ece | ||
|
|
59cb3d5df1 |
@@ -12,3 +12,4 @@ static
|
|||||||
web-static
|
web-static
|
||||||
public
|
public
|
||||||
packages/frontend/i18n/src/i18n-generated.ts
|
packages/frontend/i18n/src/i18n-generated.ts
|
||||||
|
packages/frontend/templates/edgeless-templates.gen.ts
|
||||||
|
|||||||
2
.github/deployment/front/Dockerfile
vendored
2
.github/deployment/front/Dockerfile
vendored
@@ -1,6 +1,6 @@
|
|||||||
FROM openresty/openresty:1.21.4.3-0-buster
|
FROM openresty/openresty:1.21.4.3-0-buster
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./packages/frontend/core/dist/index.html ./dist/index.html
|
COPY ./packages/frontend/core/dist ./dist
|
||||||
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
||||||
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
|
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
|
||||||
|
|
||||||
|
|||||||
4
.github/renovate.json
vendored
4
.github/renovate.json
vendored
@@ -47,11 +47,11 @@
|
|||||||
"groupName": "electron-forge"
|
"groupName": "electron-forge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "blocksuite-nightly",
|
"groupName": "blocksuite-canary",
|
||||||
"matchPackagePatterns": ["^@blocksuite"],
|
"matchPackagePatterns": ["^@blocksuite"],
|
||||||
"excludePackageNames": ["@blocksuite/icons"],
|
"excludePackageNames": ["@blocksuite/icons"],
|
||||||
"rangeStrategy": "replace",
|
"rangeStrategy": "replace",
|
||||||
"followTag": "nightly"
|
"followTag": "canary"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "all non-major dependencies",
|
"groupName": "all non-major dependencies",
|
||||||
|
|||||||
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p builds
|
mkdir -p builds
|
||||||
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
|
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
|
||||||
mv packages/frontend/electron/out/*/make/AppImage/x64/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,3 +79,6 @@ lib
|
|||||||
affine.db
|
affine.db
|
||||||
apps/web/next-routes.conf
|
apps/web/next-routes.conf
|
||||||
.nx
|
.nx
|
||||||
|
|
||||||
|
packages/frontend/templates/edgeless
|
||||||
|
packages/frontend/core/public/static/templates
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ packages/frontend/i18n/src/i18n-generated.ts
|
|||||||
packages/frontend/graphql/src/graphql/index.ts
|
packages/frontend/graphql/src/graphql/index.ts
|
||||||
tests/affine-legacy/**/static
|
tests/affine-legacy/**/static
|
||||||
.yarnrc.yml
|
.yarnrc.yml
|
||||||
|
packages/frontend/templates/edgeless-templates.gen.ts
|
||||||
packages/frontend/templates/templates.gen.ts
|
packages/frontend/templates/templates.gen.ts
|
||||||
packages/frontend/templates/onboarding
|
packages/frontend/templates/onboarding
|
||||||
|
|
||||||
|
|||||||
1000
Cargo.lock
generated
1000
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -76,7 +76,7 @@
|
|||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"@vitest/coverage-istanbul": "1.1.3",
|
"@vitest/coverage-istanbul": "1.1.3",
|
||||||
"@vitest/ui": "1.1.3",
|
"@vitest/ui": "1.1.3",
|
||||||
"electron": "^28.1.4",
|
"electron": "^28.2.1",
|
||||||
"eslint": "^8.54.0",
|
"eslint": "^8.54.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-i": "^2.29.0",
|
"eslint-plugin-i": "^2.29.0",
|
||||||
|
|||||||
@@ -40,21 +40,21 @@
|
|||||||
"@node-rs/crc32": "^1.7.2",
|
"@node-rs/crc32": "^1.7.2",
|
||||||
"@node-rs/jsonwebtoken": "^0.3.0",
|
"@node-rs/jsonwebtoken": "^0.3.0",
|
||||||
"@opentelemetry/api": "^1.7.0",
|
"@opentelemetry/api": "^1.7.0",
|
||||||
"@opentelemetry/core": "^1.20.0",
|
"@opentelemetry/core": "^1.21.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.47.0",
|
"@opentelemetry/exporter-prometheus": "^0.48.0",
|
||||||
"@opentelemetry/exporter-zipkin": "^1.20.0",
|
"@opentelemetry/exporter-zipkin": "^1.21.0",
|
||||||
"@opentelemetry/host-metrics": "^0.34.0",
|
"@opentelemetry/host-metrics": "^0.35.0",
|
||||||
"@opentelemetry/instrumentation": "^0.47.0",
|
"@opentelemetry/instrumentation": "^0.48.0",
|
||||||
"@opentelemetry/instrumentation-graphql": "^0.36.0",
|
"@opentelemetry/instrumentation-graphql": "^0.37.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.47.0",
|
"@opentelemetry/instrumentation-http": "^0.48.0",
|
||||||
"@opentelemetry/instrumentation-ioredis": "^0.36.0",
|
"@opentelemetry/instrumentation-ioredis": "^0.37.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
|
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
||||||
"@opentelemetry/instrumentation-socket.io": "^0.35.0",
|
"@opentelemetry/instrumentation-socket.io": "^0.36.0",
|
||||||
"@opentelemetry/resources": "^1.20.0",
|
"@opentelemetry/resources": "^1.21.0",
|
||||||
"@opentelemetry/sdk-metrics": "^1.20.0",
|
"@opentelemetry/sdk-metrics": "^1.21.0",
|
||||||
"@opentelemetry/sdk-node": "^0.47.0",
|
"@opentelemetry/sdk-node": "^0.48.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^1.20.0",
|
"@opentelemetry/sdk-trace-node": "^1.21.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.20.0",
|
"@opentelemetry/semantic-conventions": "^1.21.0",
|
||||||
"@prisma/client": "^5.7.1",
|
"@prisma/client": "^5.7.1",
|
||||||
"@prisma/instrumentation": "^5.7.1",
|
"@prisma/instrumentation": "^5.7.1",
|
||||||
"@socket.io/redis-adapter": "^8.2.1",
|
"@socket.io/redis-adapter": "^8.2.1",
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ model Snapshot {
|
|||||||
seq Int @default(0) @db.Integer
|
seq Int @default(0) @db.Integer
|
||||||
state Bytes? @db.ByteA
|
state Bytes? @db.ByteA
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
// the `updated_at` field will not record the time of record changed,
|
||||||
|
// but the created time of last seen update that has been merged into snapshot.
|
||||||
|
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@id([id, workspaceId])
|
@@id([id, workspaceId])
|
||||||
@@map("snapshots")
|
@@map("snapshots")
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
prismaAdapter.createVerificationToken = async data => {
|
||||||
|
await session.set(
|
||||||
|
`${data.identifier}:${data.token}`,
|
||||||
|
Date.now() + session.sessionTtl
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaAdapter.useVerificationToken = async ({ identifier, token }) => {
|
||||||
|
const expires = await session.get(`${identifier}:${token}`);
|
||||||
|
if (expires) {
|
||||||
|
return { identifier, token, expires: new Date(expires) };
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const nextAuthOptions: NextAuthOptions = {
|
const nextAuthOptions: NextAuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
// @ts-expect-error esm interop issue
|
// @ts-expect-error esm interop issue
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { chunk } from 'lodash-es';
|
|||||||
import { defer, retry } from 'rxjs';
|
import { defer, retry } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
decodeStateVector,
|
|
||||||
Doc,
|
Doc,
|
||||||
encodeStateAsUpdate,
|
encodeStateAsUpdate,
|
||||||
encodeStateVector,
|
encodeStateVector,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Cache,
|
Cache,
|
||||||
|
CallTimer,
|
||||||
Config,
|
Config,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
type EventPayload,
|
type EventPayload,
|
||||||
@@ -45,36 +45,6 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
|||||||
return compare(yBinary, yBinary2, true);
|
return compare(yBinary, yBinary2, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect whether rhs state is newer than lhs state.
|
|
||||||
*
|
|
||||||
* How could we tell a state is newer:
|
|
||||||
*
|
|
||||||
* i. if the state vector size is larger, it's newer
|
|
||||||
* ii. if the state vector size is same, compare each client's state
|
|
||||||
*/
|
|
||||||
function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
|
|
||||||
const lhsVector = decodeStateVector(lhs);
|
|
||||||
const rhsVector = decodeStateVector(rhs);
|
|
||||||
|
|
||||||
if (lhsVector.size < rhsVector.size) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [client, state] of lhsVector) {
|
|
||||||
const rstate = rhsVector.get(client);
|
|
||||||
if (!rstate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state < rstate) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEmptyBuffer(buf: Buffer): boolean {
|
export function isEmptyBuffer(buf: Buffer): boolean {
|
||||||
return (
|
return (
|
||||||
buf.length === 0 ||
|
buf.length === 0 ||
|
||||||
@@ -119,6 +89,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.destroy();
|
this.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CallTimer('doc', 'yjs_recover_updates_to_doc')
|
||||||
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
||||||
const doc = new Doc();
|
const doc = new Doc();
|
||||||
const chunks = chunk(updates, 10);
|
const chunks = chunk(updates, 10);
|
||||||
@@ -382,7 +353,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
const updates = await this.getUpdates(workspaceId, guid);
|
const updates = await this.getUpdates(workspaceId, guid);
|
||||||
|
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
const doc = await this.squash(updates, snapshot);
|
const doc = await this.squash(snapshot, updates);
|
||||||
return Buffer.from(encodeStateVector(doc));
|
return Buffer.from(encodeStateVector(doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,80 +434,92 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns whether the snapshot is updated to the latest, `undefined` means the doc to be upserted is outdated.
|
||||||
|
*/
|
||||||
|
@CallTimer('doc', 'upsert')
|
||||||
private async upsert(
|
private async upsert(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
guid: string,
|
guid: string,
|
||||||
doc: Doc,
|
doc: Doc,
|
||||||
// we always delay the snapshot update to avoid db overload,
|
// we always delay the snapshot update to avoid db overload,
|
||||||
// so the value of `updatedAt` will not be accurate to user's real action time
|
// so the value of auto updated `updatedAt` by db will never be accurate to user's real action time
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
initialSeq?: number
|
seq: number
|
||||||
) {
|
) {
|
||||||
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
|
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
||||||
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
|
||||||
|
|
||||||
if (isEmptyBuffer(blob)) {
|
if (isEmptyBuffer(blob)) {
|
||||||
return false;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = Buffer.from(encodeStateVector(doc));
|
||||||
|
|
||||||
|
// CONCERNS:
|
||||||
|
// i. Because we save the real user's last seen action time as `updatedAt`,
|
||||||
|
// it's possible to simply compare the `updatedAt` to determine if the snapshot is older than the one we are going to save.
|
||||||
|
//
|
||||||
|
// ii. Prisma doesn't support `upsert` with additional `where` condition along side unique constraint.
|
||||||
|
// In our case, we need to manually check the `updatedAt` to avoid overriding the newer snapshot.
|
||||||
|
// where: { id_workspaceId: {}, updatedAt: { lt: updatedAt } }
|
||||||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
//
|
||||||
|
// iii. Only set the seq number when creating the snapshot.
|
||||||
|
// For updating scenario, the seq number will be updated when updates pushed to db.
|
||||||
|
try {
|
||||||
|
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
|
||||||
|
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "state", "seq", "created_at", "updated_at")
|
||||||
|
VALUES (${workspaceId}, ${guid}, ${blob}, ${state}, ${seq}, DEFAULT, ${updatedAt})
|
||||||
|
ON CONFLICT ("workspace_id", "guid")
|
||||||
|
DO UPDATE SET "blob" = ${blob}, "state" = ${state}, "updated_at" = ${updatedAt}, "seq" = ${seq}
|
||||||
|
WHERE "snapshots"."workspace_id" = ${workspaceId} AND "snapshots"."guid" = ${guid} AND "snapshots"."updated_at" <= ${updatedAt}
|
||||||
|
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
|
||||||
|
`;
|
||||||
|
|
||||||
|
// const result = await this.db.snapshot.upsert({
|
||||||
|
// select: {
|
||||||
|
// updatedAt: true,
|
||||||
|
// seq: true,
|
||||||
|
// },
|
||||||
|
// where: {
|
||||||
|
// id_workspaceId: {
|
||||||
|
// workspaceId,
|
||||||
|
// id: guid,
|
||||||
|
// },
|
||||||
|
// ⬇️ NOT SUPPORTED BY PRISMA YET
|
||||||
|
// updatedAt: {
|
||||||
|
// lt: updatedAt,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// update: {
|
||||||
|
// blob,
|
||||||
|
// state,
|
||||||
|
// updatedAt,
|
||||||
|
// },
|
||||||
|
// create: {
|
||||||
|
// workspaceId,
|
||||||
|
// id: guid,
|
||||||
|
// blob,
|
||||||
|
// state,
|
||||||
|
// updatedAt,
|
||||||
|
// seq,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if the condition `snapshot.updatedAt > updatedAt` is true, by which means the snapshot has already been updated by other process,
|
||||||
|
// the updates has been applied to current `doc` must have been seen by the other process as well.
|
||||||
|
// The `updatedSnapshot` will be `undefined` in this case.
|
||||||
|
const updatedSnapshot = result.at(0);
|
||||||
|
|
||||||
|
if (!updatedSnapshot) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Buffer.from(encodeStateVector(doc));
|
return true;
|
||||||
|
} catch (e) {
|
||||||
return await this.db.$transaction(async db => {
|
this.logger.error('Failed to upsert snapshot', e);
|
||||||
const snapshot = await db.snapshot.findUnique({
|
return false;
|
||||||
where: {
|
}
|
||||||
id_workspaceId: {
|
|
||||||
id: guid,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// update
|
|
||||||
if (snapshot) {
|
|
||||||
// only update if state is newer
|
|
||||||
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
|
|
||||||
await db.snapshot.update({
|
|
||||||
select: {
|
|
||||||
seq: true,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
id_workspaceId: {
|
|
||||||
workspaceId,
|
|
||||||
id: guid,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
blob,
|
|
||||||
state,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// create
|
|
||||||
await db.snapshot.create({
|
|
||||||
select: {
|
|
||||||
seq: true,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
id: guid,
|
|
||||||
workspaceId,
|
|
||||||
blob,
|
|
||||||
state,
|
|
||||||
seq: initialSeq,
|
|
||||||
createdAt: updatedAt,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _get(
|
private async _get(
|
||||||
@@ -548,7 +531,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
return {
|
return {
|
||||||
doc: await this.squash(updates, snapshot),
|
doc: await this.squash(snapshot, updates),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,17 +542,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Squash updates into a single update and save it as snapshot,
|
* Squash updates into a single update and save it as snapshot,
|
||||||
* and delete the updates records at the same time.
|
* and delete the updates records at the same time.
|
||||||
*/
|
*/
|
||||||
private async squash(updates: Update[], snapshot: Snapshot | null) {
|
@CallTimer('doc', 'squash')
|
||||||
|
private async squash(snapshot: Snapshot | null, updates: Update[]) {
|
||||||
if (!updates.length) {
|
if (!updates.length) {
|
||||||
throw new Error('No updates to squash');
|
throw new Error('No updates to squash');
|
||||||
}
|
}
|
||||||
const first = updates[0];
|
|
||||||
const last = updates[updates.length - 1];
|
|
||||||
|
|
||||||
const { id, workspaceId } = first;
|
const last = updates[updates.length - 1];
|
||||||
|
const { id, workspaceId } = last;
|
||||||
|
|
||||||
const doc = await this.applyUpdates(
|
const doc = await this.applyUpdates(
|
||||||
first.id,
|
id,
|
||||||
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
||||||
...updates.map(u => u.blob)
|
...updates.map(u => u.blob)
|
||||||
);
|
);
|
||||||
@@ -600,19 +583,24 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// always delete updates
|
// we will keep the updates only if the upsert failed on unknown reason
|
||||||
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
// `done === undefined` means the updates is outdated(have already been merged by other process), safe to be deleted
|
||||||
const { count } = await this.db.update.deleteMany({
|
// `done === true` means the upsert is successful, safe to be deleted
|
||||||
where: {
|
if (done !== false) {
|
||||||
id,
|
// always delete updates
|
||||||
workspaceId,
|
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
||||||
seq: {
|
const { count } = await this.db.update.deleteMany({
|
||||||
in: updates.map(u => u.seq),
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
seq: {
|
||||||
|
in: updates.map(u => u.seq),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||||
|
}
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -761,18 +749,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async lockSnapshotForUpsert<T>(
|
|
||||||
workspaceId: string,
|
|
||||||
guid: string,
|
|
||||||
job: () => Promise<T>
|
|
||||||
) {
|
|
||||||
return this.doWithLock(
|
|
||||||
'doc:manager:snapshot',
|
|
||||||
`${workspaceId}::${guid}`,
|
|
||||||
job
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async reportUpdatesQueueCount() {
|
async reportUpdatesQueueCount() {
|
||||||
metrics.doc
|
metrics.doc
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export class WorkspaceResolver {
|
|||||||
id: workspace.id,
|
id: workspace.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
blob: buffer,
|
blob: buffer,
|
||||||
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { FeatureType } from '../../core/features';
|
||||||
|
import { upsertLatestFeatureVersion } from './utils/user-features';
|
||||||
|
|
||||||
|
export class RefreshUnlimitedWorkspaceFeature1708321519830 {
|
||||||
|
// do the migration
|
||||||
|
static async up(db: PrismaClient) {
|
||||||
|
// add unlimited workspace feature
|
||||||
|
await upsertLatestFeatureVersion(db, FeatureType.UnlimitedWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// revert the migration
|
||||||
|
static async down(_db: PrismaClient) {}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
|
|||||||
import {
|
import {
|
||||||
CommonFeature,
|
CommonFeature,
|
||||||
FeatureKind,
|
FeatureKind,
|
||||||
|
Features,
|
||||||
FeatureType,
|
FeatureType,
|
||||||
} from '../../../core/features';
|
} from '../../../core/features';
|
||||||
|
|
||||||
@@ -33,6 +34,16 @@ export async function upsertFeature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upsertLatestFeatureVersion(
|
||||||
|
db: PrismaClient,
|
||||||
|
type: FeatureType
|
||||||
|
) {
|
||||||
|
const feature = Features.filter(f => f.feature === type);
|
||||||
|
feature.sort((a, b) => b.version - a.version);
|
||||||
|
const latestFeature = feature[0];
|
||||||
|
await upsertFeature(db, latestFeature);
|
||||||
|
}
|
||||||
|
|
||||||
export async function migrateNewFeatureTable(prisma: PrismaClient) {
|
export async function migrateNewFeatureTable(prisma: PrismaClient) {
|
||||||
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
||||||
for (const oldUser of waitingList) {
|
for (const oldUser of waitingList) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SessionCache } from '../cache';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionService {
|
export class SessionService {
|
||||||
private readonly prefix = 'session:';
|
private readonly prefix = 'session:';
|
||||||
private readonly sessionTtl = 30 * 60 * 1000; // 30 min
|
public readonly sessionTtl = 30 * 60 * 1000; // 30 min
|
||||||
|
|
||||||
constructor(private readonly cache: SessionCache) {}
|
constructor(private readonly cache: SessionCache) {}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ try {
|
|||||||
: require('../../../storage.node');
|
: require('../../../storage.node');
|
||||||
}
|
}
|
||||||
|
|
||||||
export { storageModule as OctoBaseStorageModule };
|
|
||||||
|
|
||||||
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
||||||
|
|
||||||
export const verifyChallengeResponse = async (
|
export const verifyChallengeResponse = async (
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Args,
|
Args,
|
||||||
Context,
|
Context,
|
||||||
Field,
|
Field,
|
||||||
|
InputType,
|
||||||
Int,
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
@@ -125,6 +126,31 @@ class UserInvoiceType implements Partial<UserInvoice> {
|
|||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class CreateCheckoutSessionInput {
|
||||||
|
@Field(() => SubscriptionRecurring, {
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: SubscriptionRecurring.Yearly,
|
||||||
|
})
|
||||||
|
recurring!: SubscriptionRecurring;
|
||||||
|
|
||||||
|
@Field(() => SubscriptionPlan, {
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: SubscriptionPlan.Pro,
|
||||||
|
})
|
||||||
|
plan!: SubscriptionPlan;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
coupon!: string | null;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
successCallbackLink!: string | null;
|
||||||
|
|
||||||
|
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
|
||||||
|
@Field(() => String)
|
||||||
|
idempotencyKey!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Auth()
|
@Auth()
|
||||||
@Resolver(() => UserSubscriptionType)
|
@Resolver(() => UserSubscriptionType)
|
||||||
export class SubscriptionResolver {
|
export class SubscriptionResolver {
|
||||||
@@ -182,7 +208,11 @@ export class SubscriptionResolver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Mutation(() => String, {
|
@Mutation(() => String, {
|
||||||
|
deprecationReason: 'use `createCheckoutSession` instead',
|
||||||
description: 'Create a subscription checkout link of stripe',
|
description: 'Create a subscription checkout link of stripe',
|
||||||
})
|
})
|
||||||
async checkout(
|
async checkout(
|
||||||
@@ -193,6 +223,7 @@ export class SubscriptionResolver {
|
|||||||
) {
|
) {
|
||||||
const session = await this.service.createCheckoutSession({
|
const session = await this.service.createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring,
|
recurring,
|
||||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
@@ -210,6 +241,36 @@ export class SubscriptionResolver {
|
|||||||
return session.url;
|
return session.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String, {
|
||||||
|
description: 'Create a subscription checkout link of stripe',
|
||||||
|
})
|
||||||
|
async createCheckoutSession(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Args({ name: 'input', type: () => CreateCheckoutSessionInput })
|
||||||
|
input: CreateCheckoutSessionInput
|
||||||
|
) {
|
||||||
|
const session = await this.service.createCheckoutSession({
|
||||||
|
user,
|
||||||
|
plan: input.plan,
|
||||||
|
recurring: input.recurring,
|
||||||
|
promotionCode: input.coupon,
|
||||||
|
redirectUrl:
|
||||||
|
input.successCallbackLink ?? `${this.config.baseUrl}/upgrade-success`,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
throw new GraphQLError('Failed to create checkout session', {
|
||||||
|
extensions: {
|
||||||
|
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||||
|
code: HttpStatus.BAD_GATEWAY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.url;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => String, {
|
@Mutation(() => String, {
|
||||||
description: 'Create a stripe customer portal to manage payment methods',
|
description: 'Create a stripe customer portal to manage payment methods',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -69,13 +69,15 @@ export class SubscriptionService {
|
|||||||
async createCheckoutSession({
|
async createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
recurring,
|
recurring,
|
||||||
|
plan,
|
||||||
|
promotionCode,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
plan = SubscriptionPlan.Pro,
|
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
plan?: SubscriptionPlan;
|
|
||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
promotionCode?: string | null;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -95,7 +97,28 @@ export class SubscriptionService {
|
|||||||
`${idempotencyKey}-getOrCreateCustomer`,
|
`${idempotencyKey}-getOrCreateCustomer`,
|
||||||
user
|
user
|
||||||
);
|
);
|
||||||
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
|
|
||||||
|
let discount: { coupon?: string; promotion_code?: string } | undefined;
|
||||||
|
|
||||||
|
if (promotionCode) {
|
||||||
|
const code = await this.getAvailablePromotionCode(
|
||||||
|
promotionCode,
|
||||||
|
customer.stripeCustomerId
|
||||||
|
);
|
||||||
|
if (code) {
|
||||||
|
discount ??= {};
|
||||||
|
discount.promotion_code = code;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const coupon = await this.getAvailableCoupon(
|
||||||
|
user,
|
||||||
|
CouponType.EarlyAccess
|
||||||
|
);
|
||||||
|
if (coupon) {
|
||||||
|
discount ??= {};
|
||||||
|
discount.coupon = coupon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await this.stripe.checkout.sessions.create(
|
return await this.stripe.checkout.sessions.create(
|
||||||
{
|
{
|
||||||
@@ -108,13 +131,11 @@ export class SubscriptionService {
|
|||||||
tax_id_collection: {
|
tax_id_collection: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
...(coupon
|
...(discount
|
||||||
? {
|
? {
|
||||||
discounts: [{ coupon }],
|
discounts: [discount],
|
||||||
}
|
}
|
||||||
: {
|
: { allow_promotion_codes: true }),
|
||||||
allow_promotion_codes: true,
|
|
||||||
}),
|
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
success_url: redirectUrl,
|
success_url: redirectUrl,
|
||||||
customer: customer.stripeCustomerId,
|
customer: customer.stripeCustomerId,
|
||||||
@@ -643,4 +664,33 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getAvailablePromotionCode(
|
||||||
|
userFacingPromotionCode: string,
|
||||||
|
customer?: string
|
||||||
|
) {
|
||||||
|
const list = await this.stripe.promotionCodes.list({
|
||||||
|
code: userFacingPromotionCode,
|
||||||
|
active: true,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = list.data[0];
|
||||||
|
if (!code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let available = false;
|
||||||
|
|
||||||
|
if (code.customer) {
|
||||||
|
available =
|
||||||
|
typeof code.customer === 'string'
|
||||||
|
? code.customer === customer
|
||||||
|
: code.customer.id === customer;
|
||||||
|
} else {
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return available ? code.id : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||||
# ------------------------------------------------------
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
input CreateCheckoutSessionInput {
|
||||||
|
coupon: String
|
||||||
|
idempotencyKey: String!
|
||||||
|
plan: SubscriptionPlan = Pro
|
||||||
|
recurring: SubscriptionRecurring = Yearly
|
||||||
|
successCallbackLink: String
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
||||||
"""
|
"""
|
||||||
@@ -107,7 +115,10 @@ type Mutation {
|
|||||||
changePassword(newPassword: String!, token: String!): UserType!
|
changePassword(newPassword: String!, token: String!): UserType!
|
||||||
|
|
||||||
"""Create a subscription checkout link of stripe"""
|
"""Create a subscription checkout link of stripe"""
|
||||||
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String!
|
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! @deprecated(reason: "use `createCheckoutSession` instead")
|
||||||
|
|
||||||
|
"""Create a subscription checkout link of stripe"""
|
||||||
|
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
|
||||||
|
|
||||||
"""Create a stripe customer portal to manage payment methods"""
|
"""Create a stripe customer portal to manage payment methods"""
|
||||||
createCustomerPortal: String!
|
createCustomerPortal: String!
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { TestingModule } from '@nestjs/testing';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import test from 'ava';
|
import test from 'ava';
|
||||||
import * as Sinon from 'sinon';
|
import * as Sinon from 'sinon';
|
||||||
import {
|
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||||
applyUpdate,
|
|
||||||
decodeStateVector,
|
|
||||||
Doc as YDoc,
|
|
||||||
encodeStateAsUpdate,
|
|
||||||
} from 'yjs';
|
|
||||||
|
|
||||||
import { DocManager, DocModule } from '../src/core/doc';
|
import { DocManager, DocModule } from '../src/core/doc';
|
||||||
import { QuotaModule } from '../src/core/quota';
|
import { QuotaModule } from '../src/core/quota';
|
||||||
@@ -277,72 +272,120 @@ test('should throw if meet max retry times', async t => {
|
|||||||
t.is(stub.callCount, 5);
|
t.is(stub.callCount, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not update snapshot if state is outdated', async t => {
|
test('should be able to insert the snapshot if it is new created', async t => {
|
||||||
const db = m.get(PrismaClient);
|
|
||||||
const manager = m.get(DocManager);
|
const manager = m.get(DocManager);
|
||||||
|
|
||||||
await db.snapshot.create({
|
|
||||||
data: {
|
|
||||||
id: '2',
|
|
||||||
workspaceId: '2',
|
|
||||||
blob: Buffer.from([0, 0]),
|
|
||||||
seq: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const doc = new YDoc();
|
const doc = new YDoc();
|
||||||
const text = doc.getText('content');
|
const text = doc.getText('content');
|
||||||
const updates: Buffer[] = [];
|
|
||||||
|
|
||||||
doc.on('update', update => {
|
|
||||||
updates.push(Buffer.from(update));
|
|
||||||
});
|
|
||||||
|
|
||||||
text.insert(0, 'hello');
|
text.insert(0, 'hello');
|
||||||
text.insert(5, 'world');
|
const update = encodeStateAsUpdate(doc);
|
||||||
text.insert(5, ' ');
|
|
||||||
|
|
||||||
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
await manager.push('1', '1', Buffer.from(update));
|
||||||
|
|
||||||
const updateWith3Records = await manager.getUpdates('2', '2');
|
const updates = await manager.getUpdates('1', '1');
|
||||||
text.insert(11, '!');
|
t.is(updates.length, 1);
|
||||||
await manager.push('2', '2', updates[3]);
|
|
||||||
const updateWith4Records = await manager.getUpdates('2', '2');
|
|
||||||
|
|
||||||
// Simulation:
|
|
||||||
// Node A get 3 updates and squash them at time 1, will finish at time 10
|
|
||||||
// Node B get 4 updates and squash them at time 3, will finish at time 8
|
|
||||||
// Node B finish the squash first, and update the snapshot
|
|
||||||
// Node A finish the squash later, and update the snapshot to an outdated state
|
|
||||||
// Time: ---------------------->
|
|
||||||
// A: ^get ^upsert
|
|
||||||
// B: ^get ^upsert
|
|
||||||
//
|
|
||||||
// We should avoid such situation
|
|
||||||
// @ts-expect-error private
|
// @ts-expect-error private
|
||||||
await manager.squash(updateWith4Records, null);
|
const snapshot = await manager.squash(null, updates);
|
||||||
// @ts-expect-error private
|
|
||||||
await manager.squash(updateWith3Records, null);
|
|
||||||
|
|
||||||
const result = await db.snapshot.findUnique({
|
t.truthy(snapshot);
|
||||||
|
t.is(snapshot.getText('content').toString(), 'hello');
|
||||||
|
|
||||||
|
const restUpdates = await manager.getUpdates('1', '1');
|
||||||
|
|
||||||
|
t.is(restUpdates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to merge updates into snapshot', async t => {
|
||||||
|
const manager = m.get(DocManager);
|
||||||
|
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
|
text.insert(0, 'hello');
|
||||||
|
text.insert(5, 'world');
|
||||||
|
text.insert(5, ' ');
|
||||||
|
text.insert(11, '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await manager.batchPush('1', '1', updates.slice(0, 2));
|
||||||
|
// do the merge
|
||||||
|
const doc = (await manager.get('1', '1'))!;
|
||||||
|
|
||||||
|
t.is(doc.getText('content').toString(), 'helloworld');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await manager.batchPush('1', '1', updates.slice(2));
|
||||||
|
const doc = (await manager.get('1', '1'))!;
|
||||||
|
|
||||||
|
t.is(doc.getText('content').toString(), 'hello world!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const restUpdates = await manager.getUpdates('1', '1');
|
||||||
|
|
||||||
|
t.is(restUpdates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not update snapshot if doc is outdated', async t => {
|
||||||
|
const manager = m.get(DocManager);
|
||||||
|
const db = m.get(PrismaClient);
|
||||||
|
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
|
text.insert(0, 'hello');
|
||||||
|
text.insert(5, 'world');
|
||||||
|
text.insert(5, ' ');
|
||||||
|
text.insert(11, '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.batchPush('2', '1', updates.slice(0, 2)); // 'helloworld'
|
||||||
|
// merge updates into snapshot
|
||||||
|
await manager.get('2', '1');
|
||||||
|
// fake the snapshot is a lot newer
|
||||||
|
await db.snapshot.update({
|
||||||
where: {
|
where: {
|
||||||
id_workspaceId: {
|
id_workspaceId: {
|
||||||
id: '2',
|
|
||||||
workspaceId: '2',
|
workspaceId: '2',
|
||||||
|
id: '1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
data: {
|
||||||
|
updatedAt: new Date(Date.now() + 10000),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
{
|
||||||
t.fail('snapshot not found');
|
const snapshot = await manager.getSnapshot('2', '1');
|
||||||
return;
|
await manager.batchPush('2', '1', updates.slice(2)); // 'hello world!'
|
||||||
|
const updateRecords = await manager.getUpdates('2', '1');
|
||||||
|
|
||||||
|
// @ts-expect-error private
|
||||||
|
const doc = await manager.squash(snapshot, updateRecords);
|
||||||
|
|
||||||
|
// all updated will merged into doc not matter it's timestamp is outdated or not,
|
||||||
|
// but the snapshot record will not be updated
|
||||||
|
t.is(doc.getText('content').toString(), 'hello world!');
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = decodeStateVector(result.state!);
|
{
|
||||||
t.is(state.get(doc.clientID), 12);
|
const doc = new YDoc();
|
||||||
|
applyUpdate(doc, (await manager.getSnapshot('2', '1'))!.blob);
|
||||||
|
// the snapshot will not get touched if the new doc's timestamp is outdated
|
||||||
|
t.is(doc.getText('content').toString(), 'helloworld');
|
||||||
|
|
||||||
const d = new YDoc();
|
// the updates are known as outdated, so they will be deleted
|
||||||
applyUpdate(d, result.blob!);
|
t.is((await manager.getUpdates('2', '1')).length, 0);
|
||||||
|
}
|
||||||
const dtext = d.getText('content');
|
|
||||||
t.is(dtext.toString(), 'hello world!');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ crate-type = ["cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
jwst-codec = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
|
|
||||||
jwst-core = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
|
|
||||||
jwst-storage = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
|
|
||||||
napi = { version = "2", default-features = false, features = [
|
napi = { version = "2", default-features = false, features = [
|
||||||
"napi5",
|
"napi5",
|
||||||
"async",
|
"async",
|
||||||
@@ -18,6 +15,7 @@ napi = { version = "2", default-features = false, features = [
|
|||||||
napi-derive = { version = "2", features = ["type-def"] }
|
napi-derive = { version = "2", features = ["type-def"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
sha3 = "0.10"
|
sha3 = "0.10"
|
||||||
|
y-octo = { git = "https://github.com/y-crdt/y-octo.git", branch = "main" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
|
|||||||
22
packages/backend/storage/index.d.ts
vendored
22
packages/backend/storage/index.d.ts
vendored
@@ -1,28 +1,6 @@
|
|||||||
/* auto-generated by NAPI-RS */
|
/* auto-generated by NAPI-RS */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
export class Storage {
|
|
||||||
/** Create a storage instance and establish connection to persist store. */
|
|
||||||
static connect(database: string, debugOnlyAutoMigrate?: boolean | undefined | null): Promise<Storage>
|
|
||||||
/** List all blobs in a workspace. */
|
|
||||||
listBlobs(workspaceId?: string | undefined | null): Promise<Array<string>>
|
|
||||||
/** Fetch a workspace blob. */
|
|
||||||
getBlob(workspaceId: string, name: string): Promise<Blob | null>
|
|
||||||
/** Upload a blob into workspace storage. */
|
|
||||||
uploadBlob(workspaceId: string, blob: Buffer): Promise<string>
|
|
||||||
/** Delete a blob from workspace storage. */
|
|
||||||
deleteBlob(workspaceId: string, hash: string): Promise<boolean>
|
|
||||||
/** Workspace size taken by blobs. */
|
|
||||||
blobsSize(workspaces: Array<string>): Promise<number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Blob {
|
|
||||||
contentType: string
|
|
||||||
lastModified: string
|
|
||||||
size: number
|
|
||||||
data: Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
||||||
* result binary.
|
* result binary.
|
||||||
|
|||||||
@@ -2,16 +2,10 @@
|
|||||||
|
|
||||||
pub mod hashcash;
|
pub mod hashcash;
|
||||||
|
|
||||||
use std::{
|
use std::fmt::{Debug, Display};
|
||||||
collections::HashMap,
|
|
||||||
fmt::{Debug, Display},
|
|
||||||
path::PathBuf,
|
|
||||||
};
|
|
||||||
|
|
||||||
use jwst_codec::Doc;
|
|
||||||
use jwst_core::BlobStorage;
|
|
||||||
use jwst_storage::{BlobStorageType, JwstStorage, JwstStorageError};
|
|
||||||
use napi::{bindgen_prelude::*, Error, Result, Status};
|
use napi::{bindgen_prelude::*, Error, Result, Status};
|
||||||
|
use y_octo::Doc;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate napi_derive;
|
extern crate napi_derive;
|
||||||
@@ -35,132 +29,13 @@ macro_rules! map_err {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! napi_wrap {
|
|
||||||
($( ($name: ident, $target: ident) ),*) => {
|
|
||||||
$(
|
|
||||||
#[napi]
|
|
||||||
pub struct $name($target);
|
|
||||||
|
|
||||||
impl std::ops::Deref for $name {
|
|
||||||
type Target = $target;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<$target> for $name {
|
|
||||||
fn from(t: $target) -> Self {
|
|
||||||
Self(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)*
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
napi_wrap!((Storage, JwstStorage));
|
|
||||||
|
|
||||||
#[napi(object)]
|
|
||||||
pub struct Blob {
|
|
||||||
pub content_type: String,
|
|
||||||
pub last_modified: String,
|
|
||||||
pub size: i64,
|
|
||||||
pub data: Buffer,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[napi]
|
|
||||||
impl Storage {
|
|
||||||
/// Create a storage instance and establish connection to persist store.
|
|
||||||
#[napi]
|
|
||||||
pub async fn connect(database: String, debug_only_auto_migrate: Option<bool>) -> Result<Storage> {
|
|
||||||
let inner = match if cfg!(debug_assertions) && debug_only_auto_migrate.unwrap_or(false) {
|
|
||||||
JwstStorage::new_with_migration(&database, BlobStorageType::DB).await
|
|
||||||
} else {
|
|
||||||
JwstStorage::new(&database, BlobStorageType::DB).await
|
|
||||||
} {
|
|
||||||
Ok(storage) => storage,
|
|
||||||
Err(JwstStorageError::Db(e)) => {
|
|
||||||
return Err(Error::new(
|
|
||||||
Status::GenericFailure,
|
|
||||||
format!("failed to connect to database: {}", e),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(Error::new(Status::GenericFailure, e.to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(inner.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all blobs in a workspace.
|
|
||||||
#[napi]
|
|
||||||
pub async fn list_blobs(&self, workspace_id: Option<String>) -> Result<Vec<String>> {
|
|
||||||
map_err!(self.blobs().list_blobs(workspace_id).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a workspace blob.
|
|
||||||
#[napi]
|
|
||||||
pub async fn get_blob(&self, workspace_id: String, name: String) -> Result<Option<Blob>> {
|
|
||||||
let (id, params) = {
|
|
||||||
let path = PathBuf::from(name.clone());
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|s| s.to_str().map(|s| s.to_string()));
|
|
||||||
let id = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str().map(|s| s.to_string()))
|
|
||||||
.unwrap_or(name);
|
|
||||||
|
|
||||||
(id, ext.map(|ext| HashMap::from([("format".into(), ext)])))
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(meta) = self
|
|
||||||
.blobs()
|
|
||||||
.get_metadata(Some(workspace_id.clone()), id.clone(), params.clone())
|
|
||||||
.await
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(file) = self.blobs().get_blob(Some(workspace_id), id, params).await else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(Blob {
|
|
||||||
content_type: meta.content_type,
|
|
||||||
last_modified: format!("{:?}", meta.last_modified),
|
|
||||||
size: meta.size,
|
|
||||||
data: file.into(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload a blob into workspace storage.
|
|
||||||
#[napi]
|
|
||||||
pub async fn upload_blob(&self, workspace_id: String, blob: Buffer) -> Result<String> {
|
|
||||||
// TODO: can optimize, avoid copy
|
|
||||||
let blob = blob.as_ref().to_vec();
|
|
||||||
map_err!(self.blobs().put_blob(Some(workspace_id), blob).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a blob from workspace storage.
|
|
||||||
#[napi]
|
|
||||||
pub async fn delete_blob(&self, workspace_id: String, hash: String) -> Result<bool> {
|
|
||||||
map_err!(self.blobs().delete_blob(Some(workspace_id), hash).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Workspace size taken by blobs.
|
|
||||||
#[napi]
|
|
||||||
pub async fn blobs_size(&self, workspaces: Vec<String>) -> Result<i64> {
|
|
||||||
map_err!(self.blobs().get_blobs_size(workspaces).await)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
/// Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
||||||
/// result binary.
|
/// result binary.
|
||||||
#[napi(catch_unwind)]
|
#[napi(catch_unwind)]
|
||||||
pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
|
pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
|
||||||
let mut doc = Doc::default();
|
let mut doc = Doc::default();
|
||||||
for update in updates {
|
for update in updates {
|
||||||
map_err!(doc.apply_update_from_binary(update.as_ref().to_vec()))?;
|
map_err!(doc.apply_update_from_binary_v1(update.as_ref()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let buf = map_err!(doc.encode_update_v1())?;
|
let buf = map_err!(doc.encode_update_v1())?;
|
||||||
|
|||||||
4
packages/common/env/package.json
vendored
4
packages/common/env/package.json
vendored
@@ -3,8 +3,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"vitest": "1.1.3"
|
"vitest": "1.1.3"
|
||||||
|
|||||||
2
packages/common/env/src/global.ts
vendored
2
packages/common/env/src/global.ts
vendored
@@ -7,6 +7,7 @@ import { isDesktop, isServer } from './constant.js';
|
|||||||
import { UaHelper } from './ua-helper.js';
|
import { UaHelper } from './ua-helper.js';
|
||||||
|
|
||||||
export const blockSuiteFeatureFlags = z.object({
|
export const blockSuiteFeatureFlags = z.object({
|
||||||
|
enable_synced_doc_block: z.boolean(),
|
||||||
enable_expand_database_block: z.boolean(),
|
enable_expand_database_block: z.boolean(),
|
||||||
enable_bultin_ledits: z.boolean(),
|
enable_bultin_ledits: z.boolean(),
|
||||||
});
|
});
|
||||||
@@ -15,6 +16,7 @@ export const runtimeFlagsSchema = z.object({
|
|||||||
enableTestProperties: z.boolean(),
|
enableTestProperties: z.boolean(),
|
||||||
enableBroadcastChannelProvider: z.boolean(),
|
enableBroadcastChannelProvider: z.boolean(),
|
||||||
enableDebugPage: z.boolean(),
|
enableDebugPage: z.boolean(),
|
||||||
|
githubUrl: z.string(),
|
||||||
changelogUrl: z.string(),
|
changelogUrl: z.string(),
|
||||||
downloadUrl: z.string(),
|
downloadUrl: z.string(),
|
||||||
// see: tools/workers
|
// see: tools/workers
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
"@affine/debug": "workspace:*",
|
"@affine/debug": "workspace:*",
|
||||||
"@affine/env": "workspace:*",
|
"@affine/env": "workspace:*",
|
||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"jotai": "^2.5.1",
|
"jotai": "^2.5.1",
|
||||||
"jotai-effect": "^0.2.3",
|
"jotai-effect": "^0.2.3",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.3",
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-test/fixtures": "workspace:*",
|
"@affine-test/fixtures": "workspace:*",
|
||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"async-call-rpc": "^6.3.1",
|
"async-call-rpc": "^6.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { Map as YMap } from 'yjs';
|
|||||||
import { getLatestVersions } from '../migration/blocksuite';
|
import { getLatestVersions } from '../migration/blocksuite';
|
||||||
import { replaceIdMiddleware } from './middleware';
|
import { replaceIdMiddleware } from './middleware';
|
||||||
|
|
||||||
export async function initEmptyPage(page: Page, title?: string) {
|
export function initEmptyPage(page: Page, title?: string) {
|
||||||
await page.load(() => {
|
page.load(() => {
|
||||||
const pageBlockId = page.addBlock('affine:page', {
|
const pageBlockId = page.addBlock('affine:page', {
|
||||||
title: new page.Text(title ?? ''),
|
title: new page.Text(title ?? ''),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import { setupEditorFlags } from '@affine/env/global';
|
||||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
import { assertEquals } from '@blocksuite/global/utils';
|
import { assertEquals } from '@blocksuite/global/utils';
|
||||||
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||||
@@ -164,6 +165,8 @@ export class WorkspaceManager {
|
|||||||
// apply compatibility fix
|
// apply compatibility fix
|
||||||
fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc);
|
fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc);
|
||||||
|
|
||||||
|
setupEditorFlags(workspace.blockSuiteWorkspace);
|
||||||
|
|
||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,14 +32,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.3",
|
||||||
"y-provider": "workspace:*"
|
"y-provider": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"fake-indexeddb": "^5.0.0",
|
"fake-indexeddb": "^5.0.0",
|
||||||
"vite": "^5.0.6",
|
"vite": "^5.0.6",
|
||||||
"vite-plugin-dts": "3.7.0",
|
"vite-plugin-dts": "3.7.0",
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('indexeddb provider', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||||
page.addBlock('affine:paragraph', {}, frameId);
|
page.addBlock('affine:paragraph', {}, frameId);
|
||||||
@@ -129,7 +129,7 @@ describe('indexeddb provider', () => {
|
|||||||
| WorkspacePersist
|
| WorkspacePersist
|
||||||
| undefined;
|
| undefined;
|
||||||
assertExists(data);
|
assertExists(data);
|
||||||
await testWorkspace.getPage('page0')?.waitForLoaded();
|
testWorkspace.getPage('page0')?.waitForLoaded();
|
||||||
data.updates.forEach(({ update }) => {
|
data.updates.forEach(({ update }) => {
|
||||||
Workspace.Y.applyUpdate(subPage, update);
|
Workspace.Y.applyUpdate(subPage, update);
|
||||||
});
|
});
|
||||||
@@ -148,7 +148,7 @@ describe('indexeddb provider', () => {
|
|||||||
expect(provider.connected).toBe(false);
|
expect(provider.connected).toBe(false);
|
||||||
{
|
{
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||||
page.addBlock('affine:paragraph', {}, frameId);
|
page.addBlock('affine:paragraph', {}, frameId);
|
||||||
@@ -203,7 +203,7 @@ describe('indexeddb provider', () => {
|
|||||||
provider.connect();
|
provider.connect();
|
||||||
{
|
{
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||||
for (let i = 0; i < 99; i++) {
|
for (let i = 0; i < 99; i++) {
|
||||||
@@ -369,14 +369,14 @@ describe('subDoc', () => {
|
|||||||
const page0 = workspace.createPage({
|
const page0 = workspace.createPage({
|
||||||
id: 'page0',
|
id: 'page0',
|
||||||
});
|
});
|
||||||
await page0.waitForLoaded();
|
page0.waitForLoaded();
|
||||||
const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0);
|
const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0);
|
||||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||||
provider.connect();
|
provider.connect();
|
||||||
const page1 = workspace.createPage({
|
const page1 = workspace.createPage({
|
||||||
id: 'page1',
|
id: 'page1',
|
||||||
});
|
});
|
||||||
await page1.waitForLoaded();
|
page1.waitForLoaded();
|
||||||
const { paragraphBlockId: paragraphBlockIdPage2 } = initEmptyPage(page1);
|
const { paragraphBlockId: paragraphBlockIdPage2 } = initEmptyPage(page1);
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
provider.disconnect();
|
provider.disconnect();
|
||||||
@@ -390,14 +390,14 @@ describe('subDoc', () => {
|
|||||||
provider.connect();
|
provider.connect();
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
const page0 = newWorkspace.getPage('page0') as Page;
|
const page0 = newWorkspace.getPage('page0') as Page;
|
||||||
await page0.waitForLoaded();
|
page0.waitForLoaded();
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
{
|
{
|
||||||
const block = page0.getBlockById(paragraphBlockIdPage1);
|
const block = page0.getBlockById(paragraphBlockIdPage1);
|
||||||
assertExists(block);
|
assertExists(block);
|
||||||
}
|
}
|
||||||
const page1 = newWorkspace.getPage('page1') as Page;
|
const page1 = newWorkspace.getPage('page1') as Page;
|
||||||
await page1.waitForLoaded();
|
page1.waitForLoaded();
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
{
|
{
|
||||||
const block = page1.getBlockById(paragraphBlockIdPage2);
|
const block = page1.getBlockById(paragraphBlockIdPage2);
|
||||||
@@ -410,7 +410,7 @@ describe('subDoc', () => {
|
|||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
test('download binary', async () => {
|
test('download binary', async () => {
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
initEmptyPage(page);
|
initEmptyPage(page);
|
||||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||||
provider.connect();
|
provider.connect();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"vite": "^5.0.6",
|
"vite": "^5.0.6",
|
||||||
"vite-plugin-dts": "3.7.0",
|
"vite-plugin-dts": "3.7.0",
|
||||||
"vitest": "1.1.3",
|
"vitest": "1.1.3",
|
||||||
|
|||||||
@@ -73,12 +73,12 @@
|
|||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/icons": "2.1.44",
|
"@blocksuite/icons": "2.1.44",
|
||||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@storybook/addon-actions": "^7.5.3",
|
"@storybook/addon-actions": "^7.5.3",
|
||||||
"@storybook/addon-essentials": "^7.5.3",
|
"@storybook/addon-essentials": "^7.5.3",
|
||||||
"@storybook/addon-interactions": "^7.5.3",
|
"@storybook/addon-interactions": "^7.5.3",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,195 +0,0 @@
|
|||||||
import { keyframes, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const modalStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
|
||||||
borderRadius: '16px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const titleContainerStyle = style({
|
|
||||||
width: 'calc(100% - 72px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
height: '60px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const titleStyle = style({
|
|
||||||
fontSize: 'var(--affine-font-h6)',
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: '12px',
|
|
||||||
position: 'absolute',
|
|
||||||
marginBottom: '12px',
|
|
||||||
});
|
|
||||||
const slideToLeft = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(-300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const slideToRight = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const slideFormLeft = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const slideFormRight = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(-300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const formSlideToLeftStyle = style({
|
|
||||||
animation: `${slideFormLeft} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
export const formSlideToRightStyle = style({
|
|
||||||
animation: `${slideFormRight} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
export const slideToLeftStyle = style({
|
|
||||||
animation: `${slideToLeft} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
export const slideToRightStyle = style({
|
|
||||||
animation: `${slideToRight} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const containerStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
});
|
|
||||||
export const videoContainerStyle = style({
|
|
||||||
height: '300px',
|
|
||||||
width: 'calc(100% - 72px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const videoSlideStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
});
|
|
||||||
export const videoStyle = style({
|
|
||||||
position: 'absolute',
|
|
||||||
objectFit: 'fill',
|
|
||||||
height: '300px',
|
|
||||||
border: '1px solid var(--affine-border-color)',
|
|
||||||
transition: 'opacity 0.5s ease-in-out',
|
|
||||||
});
|
|
||||||
const fadeIn = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(300px)',
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const videoActiveStyle = style({
|
|
||||||
animation: `${fadeIn} 0.5s ease-in-out forwards`,
|
|
||||||
opacity: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const arrowStyle = style({
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
wordWrap: 'break-word',
|
|
||||||
width: '36px',
|
|
||||||
fontSize: '32px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '240px',
|
|
||||||
flexGrow: 0.2,
|
|
||||||
cursor: 'pointer',
|
|
||||||
});
|
|
||||||
export const descriptionContainerStyle = style({
|
|
||||||
width: 'calc(100% - 112px)',
|
|
||||||
height: '100px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const descriptionStyle = style({
|
|
||||||
marginTop: '15px',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
fontSize: 'var(--affine-font-sm)',
|
|
||||||
lineHeight: '18px',
|
|
||||||
position: 'absolute',
|
|
||||||
});
|
|
||||||
export const tabStyle = style({
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
content: '""',
|
|
||||||
margin: '40px 10px 40px 0',
|
|
||||||
transition: 'all 0.15s ease-in-out',
|
|
||||||
position: 'relative',
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'::after': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '20px',
|
|
||||||
left: '0',
|
|
||||||
width: '100%',
|
|
||||||
height: '2px',
|
|
||||||
background: 'var(--affine-text-primary-color)',
|
|
||||||
transition: 'all 0.15s ease-in-out',
|
|
||||||
opacity: 0.2,
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const tabActiveStyle = style({
|
|
||||||
'::after': {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const tabContainerStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
marginTop: '20px',
|
|
||||||
position: 'relative',
|
|
||||||
height: '2px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
});
|
|
||||||
export const buttonDisableStyle = style({
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
color: 'var(--affine-text-disable-color)',
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './tour-modal';
|
|
||||||
Binary file not shown.
@@ -1,160 +0,0 @@
|
|||||||
/// <reference types="../../type.d.ts" />
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Modal, type ModalProps } from '../../ui/modal';
|
|
||||||
import editingVideo from './editingVideo.mp4';
|
|
||||||
import {
|
|
||||||
arrowStyle,
|
|
||||||
buttonDisableStyle,
|
|
||||||
containerStyle,
|
|
||||||
descriptionContainerStyle,
|
|
||||||
descriptionStyle,
|
|
||||||
formSlideToLeftStyle,
|
|
||||||
formSlideToRightStyle,
|
|
||||||
modalStyle,
|
|
||||||
slideToLeftStyle,
|
|
||||||
slideToRightStyle,
|
|
||||||
tabActiveStyle,
|
|
||||||
tabContainerStyle,
|
|
||||||
tabStyle,
|
|
||||||
titleContainerStyle,
|
|
||||||
titleStyle,
|
|
||||||
videoContainerStyle,
|
|
||||||
videoSlideStyle,
|
|
||||||
videoStyle,
|
|
||||||
} from './index.css';
|
|
||||||
import switchVideo from './switchVideo.mp4';
|
|
||||||
|
|
||||||
export const TourModal = (props: ModalProps) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const [step, setStep] = useState(-1);
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
width={545}
|
|
||||||
contentOptions={{
|
|
||||||
['data-testid' as string]: 'onboarding-modal',
|
|
||||||
style: {
|
|
||||||
minHeight: '480px',
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
overlayOptions={{
|
|
||||||
style: {
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButtonOptions={{
|
|
||||||
// @ts-expect-error - fix upstream type
|
|
||||||
'data-testid': 'onboarding-modal-close-button',
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={modalStyle}>
|
|
||||||
<div className={titleContainerStyle}>
|
|
||||||
{step !== -1 && (
|
|
||||||
<div
|
|
||||||
className={clsx(titleStyle, {
|
|
||||||
[slideToRightStyle]: step === 0,
|
|
||||||
[formSlideToLeftStyle]: step === 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.title2']()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={clsx(titleStyle, {
|
|
||||||
[slideToLeftStyle]: step === 1,
|
|
||||||
[formSlideToRightStyle]: step === 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.title1']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={containerStyle}>
|
|
||||||
<div
|
|
||||||
className={clsx(arrowStyle, { [buttonDisableStyle]: step !== 1 })}
|
|
||||||
onClick={() => step === 1 && setStep(0)}
|
|
||||||
data-testid="onboarding-modal-pre-button"
|
|
||||||
>
|
|
||||||
<ArrowLeftSmallIcon />
|
|
||||||
</div>
|
|
||||||
<div className={videoContainerStyle}>
|
|
||||||
<div className={videoSlideStyle}>
|
|
||||||
{step !== -1 && (
|
|
||||||
<video
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
className={clsx(videoStyle, {
|
|
||||||
[slideToRightStyle]: step === 0,
|
|
||||||
[formSlideToLeftStyle]: step === 1,
|
|
||||||
})}
|
|
||||||
data-testid="onboarding-modal-editing-video"
|
|
||||||
>
|
|
||||||
<source src={editingVideo} type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
)}
|
|
||||||
<video
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
className={clsx(videoStyle, {
|
|
||||||
[slideToLeftStyle]: step === 1,
|
|
||||||
[formSlideToRightStyle]: step === 0,
|
|
||||||
})}
|
|
||||||
data-testid="onboarding-modal-switch-video"
|
|
||||||
>
|
|
||||||
<source src={switchVideo} type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(arrowStyle, { [buttonDisableStyle]: step === 1 })}
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
data-testid="onboarding-modal-next-button"
|
|
||||||
>
|
|
||||||
<ArrowRightSmallIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className={tabContainerStyle}>
|
|
||||||
<li
|
|
||||||
className={clsx(tabStyle, {
|
|
||||||
[tabActiveStyle]: step !== 1,
|
|
||||||
})}
|
|
||||||
onClick={() => setStep(0)}
|
|
||||||
></li>
|
|
||||||
<li
|
|
||||||
className={clsx(tabStyle, { [tabActiveStyle]: step === 1 })}
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
></li>
|
|
||||||
</ul>
|
|
||||||
<div className={descriptionContainerStyle}>
|
|
||||||
{step !== -1 && (
|
|
||||||
<div
|
|
||||||
className={clsx(descriptionStyle, {
|
|
||||||
[slideToRightStyle]: step === 0,
|
|
||||||
[formSlideToLeftStyle]: step === 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.videoDescription2']()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={clsx(descriptionStyle, {
|
|
||||||
[slideToLeftStyle]: step === 1,
|
|
||||||
[formSlideToRightStyle]: step === 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.videoDescription1']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TourModal;
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './confirm-modal';
|
export * from './confirm-modal';
|
||||||
export * from './modal';
|
export * from './modal';
|
||||||
|
export * from './overlay-modal';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '../button';
|
|||||||
import { Input, type InputProps } from '../input';
|
import { Input, type InputProps } from '../input';
|
||||||
import { ConfirmModal, type ConfirmModalProps } from './confirm-modal';
|
import { ConfirmModal, type ConfirmModalProps } from './confirm-modal';
|
||||||
import { Modal, type ModalProps } from './modal';
|
import { Modal, type ModalProps } from './modal';
|
||||||
|
import { OverlayModal, type OverlayModalProps } from './overlay-modal';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'UI/Modal',
|
title: 'UI/Modal',
|
||||||
@@ -65,5 +66,38 @@ const ConfirmModalTemplate: StoryFn<ConfirmModalProps> = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OverlayModalTemplate: StoryFn<OverlayModalProps> = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>Open Overlay Modal</Button>
|
||||||
|
<OverlayModal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title="Modal Title"
|
||||||
|
description="Modal description"
|
||||||
|
confirmButtonOptions={{
|
||||||
|
type: 'primary',
|
||||||
|
}}
|
||||||
|
topImage={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '400px',
|
||||||
|
height: '300px',
|
||||||
|
background: '#66ccff',
|
||||||
|
opacity: 0.1,
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Confirm: StoryFn<ModalProps> =
|
export const Confirm: StoryFn<ModalProps> =
|
||||||
ConfirmModalTemplate.bind(undefined);
|
ConfirmModalTemplate.bind(undefined);
|
||||||
|
|
||||||
|
export const Overlay: StoryFn<ModalProps> =
|
||||||
|
OverlayModalTemplate.bind(undefined);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const title = style({
|
||||||
|
padding: '20px 24px 8px 24px',
|
||||||
|
fontSize: cssVar('fontH6'),
|
||||||
|
fontFamily: cssVar('fontFamily'),
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: '26px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
padding: '0px 24px 8px',
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
lineHeight: '24px',
|
||||||
|
fontWeight: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const footer = style({
|
||||||
|
padding: '20px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '20px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gotItBtn = style({
|
||||||
|
fontWeight: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buttonText = style({
|
||||||
|
color: cssVar('pureWhite'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
':visited': {
|
||||||
|
color: cssVar('pureWhite'),
|
||||||
|
},
|
||||||
|
});
|
||||||
102
packages/frontend/component/src/ui/modal/overlay-modal.tsx
Normal file
102
packages/frontend/component/src/ui/modal/overlay-modal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { DialogTrigger } from '@radix-ui/react-dialog';
|
||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Button, type ButtonProps } from '../button';
|
||||||
|
import { Modal, type ModalProps } from './modal';
|
||||||
|
import * as styles from './overlay-modal.css';
|
||||||
|
|
||||||
|
const defaultContentOptions: ModalProps['contentOptions'] = {
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: cssVar('menuShadow'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const defaultOverlayOptions: ModalProps['overlayOptions'] = {
|
||||||
|
style: {
|
||||||
|
background: cssVar('white80'),
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OverlayModalProps extends ModalProps {
|
||||||
|
to?: string;
|
||||||
|
external?: boolean;
|
||||||
|
topImage?: React.ReactNode;
|
||||||
|
confirmText?: string;
|
||||||
|
confirmButtonOptions?: ButtonProps;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
cancelText?: string;
|
||||||
|
cancelButtonOptions?: ButtonProps;
|
||||||
|
withoutCancelButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverlayModal = memo(function OverlayModal({
|
||||||
|
open,
|
||||||
|
topImage,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onConfirm,
|
||||||
|
to,
|
||||||
|
external,
|
||||||
|
confirmButtonOptions,
|
||||||
|
cancelButtonOptions,
|
||||||
|
withoutCancelButton,
|
||||||
|
contentOptions = defaultContentOptions,
|
||||||
|
overlayOptions = defaultOverlayOptions,
|
||||||
|
// FIXME: we need i18n
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
width = 400,
|
||||||
|
}: OverlayModalProps) {
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
onConfirm?.();
|
||||||
|
}, [onOpenChange, onConfirm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
contentOptions={contentOptions}
|
||||||
|
overlayOptions={overlayOptions}
|
||||||
|
open={open}
|
||||||
|
width={width}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
withoutCloseButton
|
||||||
|
>
|
||||||
|
{topImage}
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
<div className={styles.content}>{description}</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
{!withoutCancelButton ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button {...cancelButtonOptions}>{cancelText}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{to ? (
|
||||||
|
external ? (
|
||||||
|
//FIXME: we need a more standardized way to implement this link with other click events
|
||||||
|
<a href={to} target="_blank" rel="noreferrer">
|
||||||
|
<Button onClick={handleConfirm} {...confirmButtonOptions}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link to={to}>
|
||||||
|
<Button onClick={handleConfirm} {...confirmButtonOptions}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleConfirm} {...confirmButtonOptions}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ const require = createRequire(import.meta.url);
|
|||||||
const packageJson = require('../package.json');
|
const packageJson = require('../package.json');
|
||||||
|
|
||||||
const editorFlags: BlockSuiteFeatureFlags = {
|
const editorFlags: BlockSuiteFeatureFlags = {
|
||||||
|
enable_synced_doc_block: false,
|
||||||
enable_expand_database_block: false,
|
enable_expand_database_block: false,
|
||||||
enable_bultin_ledits: false,
|
enable_bultin_ledits: false,
|
||||||
};
|
};
|
||||||
@@ -16,6 +17,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableTestProperties: false,
|
enableTestProperties: false,
|
||||||
enableBroadcastChannelProvider: true,
|
enableBroadcastChannelProvider: true,
|
||||||
enableDebugPage: true,
|
enableDebugPage: true,
|
||||||
|
githubUrl: 'https://github.com/toeverything/AFFiNE',
|
||||||
changelogUrl: 'https://affine.pro/what-is-new',
|
changelogUrl: 'https://affine.pro/what-is-new',
|
||||||
downloadUrl: 'https://affine.pro/download',
|
downloadUrl: 'https://affine.pro/download',
|
||||||
imageProxyUrl: '/api/worker/image-proxy',
|
imageProxyUrl: '/api/worker/image-proxy',
|
||||||
@@ -57,6 +59,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableTestProperties: true,
|
enableTestProperties: true,
|
||||||
enableBroadcastChannelProvider: true,
|
enableBroadcastChannelProvider: true,
|
||||||
enableDebugPage: true,
|
enableDebugPage: true,
|
||||||
|
githubUrl: 'https://github.com/toeverything/AFFiNE',
|
||||||
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
||||||
downloadUrl: 'https://affine.pro/download',
|
downloadUrl: 'https://affine.pro/download',
|
||||||
imageProxyUrl: '/api/worker/image-proxy',
|
imageProxyUrl: '/api/worker/image-proxy',
|
||||||
|
|||||||
@@ -26,14 +26,14 @@
|
|||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@affine/workspace": "workspace:*",
|
"@affine/workspace": "workspace:*",
|
||||||
"@affine/workspace-impl": "workspace:*",
|
"@affine/workspace-impl": "workspace:*",
|
||||||
"@blocksuite/block-std": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/block-std": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/icons": "2.1.44",
|
"@blocksuite/icons": "2.1.44",
|
||||||
"@blocksuite/inline": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/inline": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@dnd-kit/core": "^6.0.8",
|
"@dnd-kit/core": "^6.0.8",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@emotion/cache": "^11.11.0",
|
"@emotion/cache": "^11.11.0",
|
||||||
|
|||||||
BIN
packages/frontend/core/public/static/githubStar.mp4
Normal file
BIN
packages/frontend/core/public/static/githubStar.mp4
Normal file
Binary file not shown.
BIN
packages/frontend/core/public/static/newIssue.mp4
Normal file
BIN
packages/frontend/core/public/static/newIssue.mp4
Normal file
Binary file not shown.
@@ -55,21 +55,6 @@ export const guideChangeLogAtom = atom<
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
export const guideOnboardingAtom = atom<
|
|
||||||
Guide['onBoarding'],
|
|
||||||
[open: boolean],
|
|
||||||
void
|
|
||||||
>(
|
|
||||||
get => {
|
|
||||||
return get(guidePrimitiveAtom).onBoarding;
|
|
||||||
},
|
|
||||||
(_, set, open) => {
|
|
||||||
set(guidePrimitiveAtom, tips => ({
|
|
||||||
...tips,
|
|
||||||
onBoarding: open,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const guideDownloadClientTipAtom = atom<
|
export const guideDownloadClientTipAtom = atom<
|
||||||
Guide['downloadClientTip'],
|
Guide['downloadClientTip'],
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import type { SettingProps } from '../components/affine/setting-modal';
|
|||||||
export const openWorkspacesModalAtom = atom(false);
|
export const openWorkspacesModalAtom = atom(false);
|
||||||
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
||||||
export const openQuickSearchModalAtom = atom(false);
|
export const openQuickSearchModalAtom = atom(false);
|
||||||
export const openOnboardingModalAtom = atom(false);
|
|
||||||
export const openSignOutModalAtom = atom(false);
|
export const openSignOutModalAtom = atom(false);
|
||||||
export const openPaymentDisableAtom = atom(false);
|
export const openPaymentDisableAtom = atom(false);
|
||||||
export const openQuotaModalAtom = atom(false);
|
export const openQuotaModalAtom = atom(false);
|
||||||
|
export const openStarAFFiNEModalAtom = atom(false);
|
||||||
|
export const openIssueFeedbackModalAtom = atom(false);
|
||||||
|
|
||||||
export type SettingAtom = Pick<
|
export type SettingAtom = Pick<
|
||||||
SettingProps,
|
SettingProps,
|
||||||
|
|||||||
4
packages/frontend/core/src/atoms/sync-engine-status.ts
Normal file
4
packages/frontend/core/src/atoms/sync-engine-status.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { SyncEngineStatus } from '@affine/workspace';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const syncEngineStatusAtom = atom<SyncEngineStatus | null>(null);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { builtInTemplates } from '@affine/templates/edgeless';
|
||||||
|
import {
|
||||||
|
EdgelessTemplatePanel,
|
||||||
|
type TemplateManager,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
|
||||||
|
EdgelessTemplatePanel.templates.extend(builtInTemplates as TemplateManager);
|
||||||
@@ -33,7 +33,7 @@ export async function createFirstAppData() {
|
|||||||
workspace.setPageMeta(page.id, {
|
workspace.setPageMeta(page.id, {
|
||||||
jumpOnce: true,
|
jumpOnce: true,
|
||||||
});
|
});
|
||||||
await initEmptyPage(page);
|
initEmptyPage(page);
|
||||||
}
|
}
|
||||||
logger.debug('create first workspace');
|
logger.debug('create first workspace');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import './register-blocksuite-components';
|
import './register-blocksuite-components';
|
||||||
|
import './edgeless-template';
|
||||||
|
|
||||||
import { setupGlobal } from '@affine/env/global';
|
import { setupGlobal } from '@affine/env/global';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { ContactWithUsIcon, NewIcon, UserGuideIcon } from '@blocksuite/icons';
|
import { ContactWithUsIcon, NewIcon } from '@blocksuite/icons';
|
||||||
import { registerAffineCommand } from '@toeverything/infra/command';
|
import { registerAffineCommand } from '@toeverything/infra/command';
|
||||||
import type { createStore } from 'jotai';
|
import type { createStore } from 'jotai';
|
||||||
|
|
||||||
import { openOnboardingModalAtom, openSettingModalAtom } from '../atoms';
|
import { openSettingModalAtom } from '../atoms';
|
||||||
|
|
||||||
export function registerAffineHelpCommands({
|
export function registerAffineHelpCommands({
|
||||||
t,
|
t,
|
||||||
@@ -39,18 +39,6 @@ export function registerAffineHelpCommands({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
unsubs.push(
|
|
||||||
registerAffineCommand({
|
|
||||||
id: 'affine:help-getting-started',
|
|
||||||
category: 'affine:help',
|
|
||||||
icon: <UserGuideIcon />,
|
|
||||||
label: t['com.affine.cmdk.affine.getting-started'](),
|
|
||||||
preconditionStrategy: () => environment.isDesktop,
|
|
||||||
run() {
|
|
||||||
store.set(openOnboardingModalAtom, true);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubs.forEach(unsub => unsub());
|
unsubs.forEach(unsub => unsub());
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ export function registerAffineNavigationCommands({
|
|||||||
category: 'affine:navigation',
|
category: 'affine:navigation',
|
||||||
icon: <ArrowRightBigIcon />,
|
icon: <ArrowRightBigIcon />,
|
||||||
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
|
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
|
||||||
|
keyBinding: '$mod+,',
|
||||||
run() {
|
run() {
|
||||||
store.set(openSettingModalAtom, {
|
store.set(openSettingModalAtom, s => ({
|
||||||
activeTab: 'appearance',
|
activeTab: 'appearance',
|
||||||
open: true,
|
open: !s.open,
|
||||||
});
|
}));
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { AuthPanelProps } from './index';
|
|||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
||||||
import { Captcha, useCaptcha } from './use-captcha';
|
import { Captcha, useCaptcha } from './use-captcha';
|
||||||
|
import { useSubscriptionSearch } from './use-subscription';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
@@ -34,6 +35,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
const [verifyToken, challenge] = useCaptcha();
|
const [verifyToken, challenge] = useCaptcha();
|
||||||
|
const subscriptionData = useSubscriptionSearch();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isMutating: isSigningIn,
|
isMutating: isSigningIn,
|
||||||
@@ -81,7 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
if (verifyToken) {
|
if (verifyToken) {
|
||||||
if (user) {
|
if (user) {
|
||||||
// provider password sign-in if user has by default
|
// provider password sign-in if user has by default
|
||||||
if (user.hasPassword) {
|
// If with payment, onl support email sign in to avoid redirect to affine app
|
||||||
|
if (user.hasPassword && !subscriptionData) {
|
||||||
setAuthState('signInWithPassword');
|
setAuthState('signInWithPassword');
|
||||||
} else {
|
} else {
|
||||||
const res = await signIn(email, verifyToken, challenge);
|
const res = await signIn(email, verifyToken, challenge);
|
||||||
@@ -101,6 +104,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
subscriptionData,
|
||||||
challenge,
|
challenge,
|
||||||
email,
|
email,
|
||||||
setAuthEmail,
|
setAuthEmail,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Button } from '@affine/component/ui/button';
|
|||||||
import { Loading } from '@affine/component/ui/loading';
|
import { Loading } from '@affine/component/ui/loading';
|
||||||
import { AffineShapeIcon } from '@affine/core/components/page-list';
|
import { AffineShapeIcon } from '@affine/core/components/page-list';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import type { SubscriptionRecurring } from '@affine/graphql';
|
import type { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
changePasswordMutation,
|
changePasswordMutation,
|
||||||
checkoutMutation,
|
createCheckoutSessionMutation,
|
||||||
subscriptionQuery,
|
subscriptionQuery,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -30,18 +30,25 @@ const usePaymentRedirect = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recurring = searchData.recurring as SubscriptionRecurring;
|
const recurring = searchData.recurring as SubscriptionRecurring;
|
||||||
|
const plan = searchData.plan as SubscriptionPlan;
|
||||||
|
const coupon = searchData.coupon;
|
||||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||||
const { trigger: checkoutSubscription } = useMutation({
|
const { trigger: checkoutSubscription } = useMutation({
|
||||||
mutation: checkoutMutation,
|
mutation: createCheckoutSessionMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useAsyncCallback(async () => {
|
return useAsyncCallback(async () => {
|
||||||
const { checkout } = await checkoutSubscription({
|
const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({
|
||||||
recurring,
|
input: {
|
||||||
idempotencyKey,
|
recurring,
|
||||||
|
plan,
|
||||||
|
coupon,
|
||||||
|
idempotencyKey,
|
||||||
|
successCallbackLink: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
window.open(checkout, '_self', 'norefferer');
|
window.open(checkoutUrl, '_self', 'norefferer');
|
||||||
}, [recurring, idempotencyKey, checkoutSubscription]);
|
}, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CenterLoading = () => {
|
const CenterLoading = () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
enum SubscriptionKey {
|
enum SubscriptionKey {
|
||||||
Recurring = 'subscription_recurring',
|
Recurring = 'subscription_recurring',
|
||||||
Plan = 'subscription_plan',
|
Plan = 'subscription_plan',
|
||||||
|
Coupon = 'coupon',
|
||||||
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
|
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
|
||||||
Token = 'token', // When signup, there should have a token to set password
|
Token = 'token', // When signup, there should have a token to set password
|
||||||
}
|
}
|
||||||
@@ -22,11 +23,13 @@ export function useSubscriptionSearch() {
|
|||||||
|
|
||||||
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
||||||
const plan = searchParams.get(SubscriptionKey.Plan);
|
const plan = searchParams.get(SubscriptionKey.Plan);
|
||||||
|
const coupon = searchParams.get(SubscriptionKey.Coupon);
|
||||||
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
||||||
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
||||||
return {
|
return {
|
||||||
recurring,
|
recurring,
|
||||||
plan,
|
plan,
|
||||||
|
coupon,
|
||||||
withSignUp,
|
withSignUp,
|
||||||
passwordToken,
|
passwordToken,
|
||||||
getRedirectUrl(signUp?: boolean) {
|
getRedirectUrl(signUp?: boolean) {
|
||||||
@@ -35,6 +38,10 @@ export function useSubscriptionSearch() {
|
|||||||
[SubscriptionKey.Plan, plan ?? ''],
|
[SubscriptionKey.Plan, plan ?? ''],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (coupon) {
|
||||||
|
paymentParams.set(SubscriptionKey.Coupon, coupon);
|
||||||
|
}
|
||||||
|
|
||||||
if (signUp) {
|
if (signUp) {
|
||||||
paymentParams.set(SubscriptionKey.SignUp, '1');
|
paymentParams.set(SubscriptionKey.SignUp, '1');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const CreateWorkspaceModal = ({
|
|||||||
workspace.setPageMeta(page.id, {
|
workspace.setPageMeta(page.id, {
|
||||||
jumpOnce: true,
|
jumpOnce: true,
|
||||||
});
|
});
|
||||||
await initEmptyPage(page);
|
initEmptyPage(page);
|
||||||
}
|
}
|
||||||
logger.debug('create first workspace');
|
logger.debug('create first workspace');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { OverlayModal } from '@affine/component';
|
||||||
|
import { openIssueFeedbackModalAtom } from '@affine/core/atoms';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
|
||||||
|
export const IssueFeedbackModal = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const [open, setOpen] = useAtom(openIssueFeedbackModalAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayModal
|
||||||
|
open={open}
|
||||||
|
topImage={
|
||||||
|
<video
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
src={'/static/newIssue.mp4'}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={t['com.affine.issue-feedback.title']()}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
description={t['com.affine.issue-feedback.description']()}
|
||||||
|
cancelText={t['com.affine.issue-feedback.cancel']()}
|
||||||
|
to={`${runtimeConfig.githubUrl}/issues/new/choose`}
|
||||||
|
confirmText={t['com.affine.issue-feedback.confirm']()}
|
||||||
|
confirmButtonOptions={{
|
||||||
|
type: 'primary',
|
||||||
|
}}
|
||||||
|
external
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { TourModal } from '@affine/component/tour-modal';
|
|
||||||
import { useAtom } from 'jotai';
|
|
||||||
import { memo, useCallback } from 'react';
|
|
||||||
|
|
||||||
import { openOnboardingModalAtom } from '../../atoms';
|
|
||||||
import { guideOnboardingAtom } from '../../atoms/guide';
|
|
||||||
|
|
||||||
export const OnboardingModal = memo(function OnboardingModal() {
|
|
||||||
const [open, setOpen] = useAtom(openOnboardingModalAtom);
|
|
||||||
const [guideOpen, setShowOnboarding] = useAtom(guideOnboardingAtom);
|
|
||||||
const onOpenChange = useCallback(
|
|
||||||
(open: boolean) => {
|
|
||||||
if (open) return;
|
|
||||||
setShowOnboarding(false);
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
[setOpen, setShowOnboarding]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TourModal open={!open ? guideOpen : open} onOpenChange={onOpenChange} />
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -17,11 +17,11 @@ const paperLocations = {
|
|||||||
},
|
},
|
||||||
'1': {
|
'1': {
|
||||||
x: -240,
|
x: -240,
|
||||||
y: -100,
|
y: -30,
|
||||||
},
|
},
|
||||||
'2': {
|
'2': {
|
||||||
x: 240,
|
x: 240,
|
||||||
y: -100,
|
y: -35,
|
||||||
},
|
},
|
||||||
'3': {
|
'3': {
|
||||||
x: -480,
|
x: -480,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Button, Modal, type ModalProps } from '@affine/component';
|
import { OverlayModal } from '@affine/component';
|
||||||
|
import type { ModalProps } from '@affine/component/ui/modal';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useAppConfigStorage } from '../../../hooks/use-app-config-storage';
|
import { useAppConfigStorage } from '../../../hooks/use-app-config-storage';
|
||||||
import Thumb from './assets/thumb';
|
import Thumb from './assets/thumb';
|
||||||
import * as styles from './workspace-guide-modal.css';
|
|
||||||
|
|
||||||
const contentOptions: ModalProps['contentOptions'] = {
|
|
||||||
style: { padding: 0, overflow: 'hidden' },
|
|
||||||
};
|
|
||||||
const overlayOptions: ModalProps['overlayOptions'] = {
|
const overlayOptions: ModalProps['overlayOptions'] = {
|
||||||
style: {
|
style: {
|
||||||
background:
|
background:
|
||||||
@@ -36,7 +33,6 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const gotIt = useCallback(() => {
|
const gotIt = useCallback(() => {
|
||||||
setOpen(false);
|
|
||||||
setDismiss(true);
|
setDismiss(true);
|
||||||
}, [setDismiss]);
|
}, [setDismiss]);
|
||||||
|
|
||||||
@@ -47,28 +43,23 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<OverlayModal
|
||||||
withoutCloseButton
|
|
||||||
contentOptions={contentOptions}
|
|
||||||
overlayOptions={overlayOptions}
|
|
||||||
open={open}
|
open={open}
|
||||||
width={400}
|
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
>
|
topImage={<Thumb />}
|
||||||
<Thumb />
|
title={t['com.affine.onboarding.workspace-guide.title']()}
|
||||||
<div className={styles.title}>
|
description={t['com.affine.onboarding.workspace-guide.content']()}
|
||||||
{t['com.affine.onboarding.workspace-guide.title']()}
|
onConfirm={gotIt}
|
||||||
</div>
|
overlayOptions={overlayOptions}
|
||||||
<div className={styles.content}>
|
withoutCancelButton
|
||||||
{t['com.affine.onboarding.workspace-guide.content']()}
|
confirmButtonOptions={{
|
||||||
</div>
|
style: {
|
||||||
<div className={styles.footer}>
|
fontWeight: 500,
|
||||||
<Button type="primary" size="large" onClick={gotIt}>
|
},
|
||||||
<span className={styles.gotItBtn}>
|
type: 'primary',
|
||||||
{t['com.affine.onboarding.workspace-guide.got-it']()}
|
size: 'large',
|
||||||
</span>
|
}}
|
||||||
</Button>
|
confirmText={t['com.affine.onboarding.workspace-guide.got-it']()}
|
||||||
</div>
|
/>
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ const getOrCreateShellWorkspace = (workspaceId: string) => {
|
|||||||
const blobStorage = createAffineCloudBlobStorage(workspaceId);
|
const blobStorage = createAffineCloudBlobStorage(workspaceId);
|
||||||
workspace = new Workspace({
|
workspace = new Workspace({
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
providerCreators: [],
|
|
||||||
blobStorages: [
|
blobStorages: [
|
||||||
() => ({
|
() => ({
|
||||||
crud: blobStorage,
|
crud: blobStorage,
|
||||||
@@ -162,12 +161,10 @@ export const useSnapshotPage = (
|
|||||||
});
|
});
|
||||||
page.awarenessStore.setReadonly(page, true);
|
page.awarenessStore.setReadonly(page, true);
|
||||||
const spaceDoc = page.spaceDoc;
|
const spaceDoc = page.spaceDoc;
|
||||||
page
|
page.load(() => {
|
||||||
.load(() => {
|
applyUpdate(spaceDoc, new Uint8Array(snapshot));
|
||||||
applyUpdate(spaceDoc, new Uint8Array(snapshot));
|
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
|
||||||
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
|
}); // must load before applyUpdate
|
||||||
})
|
|
||||||
.catch(console.error); // must load before applyUpdate
|
|
||||||
}
|
}
|
||||||
return page ?? undefined;
|
return page ?? undefined;
|
||||||
}, [pageDocId, snapshot, ts, workspace]);
|
}, [pageDocId, snapshot, ts, workspace]);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
SubscriptionMutator,
|
SubscriptionMutator,
|
||||||
} from '@affine/core/hooks/use-subscription';
|
} from '@affine/core/hooks/use-subscription';
|
||||||
import {
|
import {
|
||||||
checkoutMutation,
|
createCheckoutSessionMutation,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -359,7 +359,7 @@ const Upgrade = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const { isMutating, trigger } = useMutation({
|
const { isMutating, trigger } = useMutation({
|
||||||
mutation: checkoutMutation,
|
mutation: createCheckoutSessionMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTabRef = useRef<Window | null>(null);
|
const newTabRef = useRef<Window | null>(null);
|
||||||
@@ -383,13 +383,21 @@ const Upgrade = ({
|
|||||||
newTabRef.current.focus();
|
newTabRef.current.focus();
|
||||||
} else {
|
} else {
|
||||||
await trigger(
|
await trigger(
|
||||||
{ recurring, idempotencyKey },
|
{
|
||||||
|
input: {
|
||||||
|
recurring,
|
||||||
|
idempotencyKey,
|
||||||
|
plan: SubscriptionPlan.Pro, // Only support prod plan now.
|
||||||
|
coupon: null,
|
||||||
|
successCallbackLink: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
// FIXME: safari prevents from opening new tab by window api
|
// FIXME: safari prevents from opening new tab by window api
|
||||||
// TODO(@xp): what if electron?
|
// TODO(@xp): what if electron?
|
||||||
const newTab = window.open(
|
const newTab = window.open(
|
||||||
data.checkout,
|
data.createCheckoutSession,
|
||||||
'_blank',
|
'_blank',
|
||||||
'noopener noreferrer'
|
'noopener noreferrer'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
|
import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
|
||||||
import { Modal, type ModalProps } from '@affine/component/ui/modal';
|
import { Modal, type ModalProps } from '@affine/component/ui/modal';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import {
|
||||||
|
openIssueFeedbackModalAtom,
|
||||||
|
openStarAFFiNEModalAtom,
|
||||||
|
} from '@affine/core/atoms';
|
||||||
|
import { Trans } from '@affine/i18n';
|
||||||
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
|
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
|
||||||
import { ContactWithUsIcon } from '@blocksuite/icons';
|
import { ContactWithUsIcon } from '@blocksuite/icons';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
|
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
@@ -37,7 +42,6 @@ export const SettingModal = ({
|
|||||||
onSettingClick,
|
onSettingClick,
|
||||||
...modalProps
|
...modalProps
|
||||||
}: SettingProps) => {
|
}: SettingProps) => {
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
|
|
||||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -79,6 +83,16 @@ export const SettingModal = ({
|
|||||||
},
|
},
|
||||||
[onSettingClick]
|
[onSettingClick]
|
||||||
);
|
);
|
||||||
|
const setOpenIssueFeedbackModal = useSetAtom(openIssueFeedbackModalAtom);
|
||||||
|
const setOpenStarAFFiNEModal = useSetAtom(openStarAFFiNEModalAtom);
|
||||||
|
|
||||||
|
const handleOpenIssueFeedbackModal = useCallback(() => {
|
||||||
|
setOpenIssueFeedbackModal(true);
|
||||||
|
}, [setOpenIssueFeedbackModal]);
|
||||||
|
|
||||||
|
const handleOpenStarAFFiNEModal = useCallback(() => {
|
||||||
|
setOpenStarAFFiNEModal(true);
|
||||||
|
}, [setOpenStarAFFiNEModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -126,17 +140,24 @@ export const SettingModal = ({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.footer}>
|
<div className={style.footer}>
|
||||||
<a
|
<ContactWithUsIcon fontSize={16} />
|
||||||
href="https://community.affine.pro/home"
|
<Trans
|
||||||
target="_blank"
|
i18nKey={'com.affine.settings.suggestion-2'}
|
||||||
rel="noreferrer"
|
components={{
|
||||||
className={style.suggestionLink}
|
1: (
|
||||||
>
|
<span
|
||||||
<span className={style.suggestionLinkIcon}>
|
className={style.link}
|
||||||
<ContactWithUsIcon width="16" height="16" />
|
onClick={handleOpenStarAFFiNEModal}
|
||||||
</span>
|
/>
|
||||||
{t['com.affine.settings.suggestion']()}
|
),
|
||||||
</a>
|
2: (
|
||||||
|
<span
|
||||||
|
className={style.link}
|
||||||
|
onClick={handleOpenIssueFeedbackModal}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const wrapper = style({
|
export const wrapper = style({
|
||||||
@@ -50,4 +51,12 @@ export const footer = style({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingBottom: '20px',
|
paddingBottom: '20px',
|
||||||
|
gap: '4px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const link = style({
|
||||||
|
color: cssVar('linkColor'),
|
||||||
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
|
|||||||
import { SettingRow } from '@affine/component/setting-components';
|
import { SettingRow } from '@affine/component/setting-components';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
|
import { useSystemOnline } from '@affine/core/hooks/use-system-online';
|
||||||
import { apis } from '@affine/electron-api';
|
import { apis } from '@affine/electron-api';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import type { Workspace, WorkspaceMetadata } from '@affine/workspace';
|
import type { Workspace, WorkspaceMetadata } from '@affine/workspace';
|
||||||
@@ -20,6 +21,7 @@ export const ExportPanel = ({
|
|||||||
const workspaceId = workspaceMetadata.id;
|
const workspaceId = workspaceMetadata.id;
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const isOnline = useSystemOnline();
|
||||||
|
|
||||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
const onExport = useAsyncCallback(async () => {
|
const onExport = useAsyncCallback(async () => {
|
||||||
@@ -28,8 +30,11 @@ export const ExportPanel = ({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await workspace.engine.sync.waitForSynced();
|
if (isOnline) {
|
||||||
await workspace.engine.blob.sync();
|
await workspace.engine.sync.waitForSynced();
|
||||||
|
await workspace.engine.blob.sync();
|
||||||
|
}
|
||||||
|
|
||||||
const result = await apis?.dialog.saveDBFileAs(workspaceId);
|
const result = await apis?.dialog.saveDBFileAs(workspaceId);
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
@@ -48,7 +53,7 @@ export const ExportPanel = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [pushNotification, saving, t, workspace, workspaceId]);
|
}, [isOnline, pushNotification, saving, t, workspace, workspaceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { OverlayModal } from '@affine/component';
|
||||||
|
import { openStarAFFiNEModalAtom } from '@affine/core/atoms';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
|
||||||
|
export const StarAFFiNEModal = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const [open, setOpen] = useAtom(openStarAFFiNEModalAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayModal
|
||||||
|
open={open}
|
||||||
|
topImage={
|
||||||
|
<video
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
src={'/static/gitHubStar.mp4'}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={t['com.affine.star-affine.title']()}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
description={t['com.affine.star-affine.description']()}
|
||||||
|
cancelText={t['com.affine.star-affine.cancel']()}
|
||||||
|
to={runtimeConfig.githubUrl}
|
||||||
|
confirmButtonOptions={{
|
||||||
|
type: 'primary',
|
||||||
|
}}
|
||||||
|
confirmText={t['com.affine.star-affine.confirm']()}
|
||||||
|
external
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -48,7 +48,7 @@ interface BlocksuiteEditorContainerProps {
|
|||||||
// mimic the interface of the webcomponent and expose slots & host
|
// mimic the interface of the webcomponent and expose slots & host
|
||||||
type BlocksuiteEditorContainerRef = Pick<
|
type BlocksuiteEditorContainerRef = Pick<
|
||||||
(typeof AffineEditorContainer)['prototype'],
|
(typeof AffineEditorContainer)['prototype'],
|
||||||
'mode' | 'page' | 'model' | 'slots' | 'host'
|
'mode' | 'page' | 'slots' | 'host'
|
||||||
> &
|
> &
|
||||||
HTMLDivElement;
|
HTMLDivElement;
|
||||||
|
|
||||||
|
|||||||
@@ -37,24 +37,14 @@ export type EditorProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: Defined async cache to support suspense, instead of reflect symbol to provider persistent error cache.
|
|
||||||
*/
|
|
||||||
const PAGE_LOAD_KEY = Symbol('PAGE_LOAD');
|
|
||||||
const PAGE_ROOT_KEY = Symbol('PAGE_ROOT');
|
|
||||||
|
|
||||||
function usePageRoot(page: Page) {
|
function usePageRoot(page: Page) {
|
||||||
let load$ = Reflect.get(page, PAGE_LOAD_KEY);
|
if (!page.ready) {
|
||||||
if (!load$) {
|
page.load();
|
||||||
load$ = page.load();
|
|
||||||
Reflect.set(page, PAGE_LOAD_KEY, load$);
|
|
||||||
}
|
}
|
||||||
use(load$);
|
|
||||||
|
|
||||||
if (!page.root) {
|
if (!page.root) {
|
||||||
let root$: Promise<void> | undefined = Reflect.get(page, PAGE_ROOT_KEY);
|
use(
|
||||||
if (!root$) {
|
new Promise<void>((resolve, reject) => {
|
||||||
root$ = new Promise((resolve, reject) => {
|
|
||||||
const disposable = page.slots.rootAdded.once(() => {
|
const disposable = page.slots.rootAdded.once(() => {
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
@@ -62,10 +52,8 @@ function usePageRoot(page: Page) {
|
|||||||
disposable.dispose();
|
disposable.dispose();
|
||||||
reject(new NoPageRootError(page));
|
reject(new NoPageRootError(page));
|
||||||
}, 20 * 1000);
|
}, 20 * 1000);
|
||||||
});
|
})
|
||||||
Reflect.set(page, PAGE_ROOT_KEY, root$);
|
);
|
||||||
}
|
|
||||||
use(root$);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return page.root;
|
return page.root;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { BlockSpec } from '@blocksuite/block-std';
|
import type { BlockSpec } from '@blocksuite/block-std';
|
||||||
import type { ParagraphService } from '@blocksuite/blocks';
|
import type { PageService, ParagraphService } from '@blocksuite/blocks';
|
||||||
import {
|
import {
|
||||||
AttachmentService,
|
AttachmentService,
|
||||||
|
CanvasTextFonts,
|
||||||
DocEditorBlockSpecs,
|
DocEditorBlockSpecs,
|
||||||
|
DocPageService,
|
||||||
EdgelessEditorBlockSpecs,
|
EdgelessEditorBlockSpecs,
|
||||||
|
EdgelessPageService,
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import { html, unsafeStatic } from 'lit/static-html.js';
|
import { html, unsafeStatic } from 'lit/static-html.js';
|
||||||
@@ -17,6 +20,31 @@ class CustomAttachmentService extends AttachmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customLoadFonts(service: PageService): void {
|
||||||
|
const officialDomains = new Set(['affine.pro', 'affine.fail']);
|
||||||
|
if (!officialDomains.has(window.location.host)) {
|
||||||
|
const fonts = CanvasTextFonts.map(font => ({
|
||||||
|
...font,
|
||||||
|
// self-hosted fonts are served from /assets
|
||||||
|
url: '/assets' + new URL(font.url).pathname.split('/').pop(),
|
||||||
|
}));
|
||||||
|
service.fontLoader.load(fonts);
|
||||||
|
} else {
|
||||||
|
service.fontLoader.load(CanvasTextFonts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomDocPageService extends DocPageService {
|
||||||
|
override loadFonts(): void {
|
||||||
|
customLoadFonts(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class CustomEdgelessPageService extends EdgelessPageService {
|
||||||
|
override loadFonts(): void {
|
||||||
|
customLoadFonts(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type AffineReference = HTMLElementTagNameMap['affine-reference'];
|
type AffineReference = HTMLElementTagNameMap['affine-reference'];
|
||||||
type PageReferenceRenderer = (reference: AffineReference) => React.ReactElement;
|
type PageReferenceRenderer = (reference: AffineReference) => React.ReactElement;
|
||||||
|
|
||||||
@@ -76,6 +104,12 @@ export const docModeSpecs = DocEditorBlockSpecs.map(spec => {
|
|||||||
service: CustomAttachmentService,
|
service: CustomAttachmentService,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.schema.model.flavour === 'affine:page') {
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
service: CustomDocPageService,
|
||||||
|
};
|
||||||
|
}
|
||||||
return spec;
|
return spec;
|
||||||
});
|
});
|
||||||
export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
||||||
@@ -85,5 +119,11 @@ export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
|||||||
service: CustomAttachmentService,
|
service: CustomAttachmentService,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.schema.model.flavour === 'affine:page') {
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
service: CustomEdgelessPageService,
|
||||||
|
};
|
||||||
|
}
|
||||||
return spec;
|
return spec;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,9 +27,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
|||||||
const createPageAndOpen = useCallback(
|
const createPageAndOpen = useCallback(
|
||||||
(mode?: 'page' | 'edgeless') => {
|
(mode?: 'page' | 'edgeless') => {
|
||||||
const page = createPage();
|
const page = createPage();
|
||||||
initEmptyPage(page).catch(error => {
|
initEmptyPage(page);
|
||||||
toast(`Failed to initialize Page: ${error.message}`);
|
|
||||||
});
|
|
||||||
setPageMode(page.id, mode || 'page');
|
setPageMode(page.id, mode || 'page');
|
||||||
openPage(blockSuiteWorkspace.id, page.id);
|
openPage(blockSuiteWorkspace.id, page.id);
|
||||||
return page;
|
return page;
|
||||||
@@ -66,10 +64,10 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
|||||||
const createLinkedPageAndOpen = useAsyncCallback(
|
const createLinkedPageAndOpen = useAsyncCallback(
|
||||||
async (pageId: string) => {
|
async (pageId: string) => {
|
||||||
const page = createPageAndOpen();
|
const page = createPageAndOpen();
|
||||||
await page.load();
|
page.load();
|
||||||
const parentPage = blockSuiteWorkspace.getPage(pageId);
|
const parentPage = blockSuiteWorkspace.getPage(pageId);
|
||||||
if (parentPage) {
|
if (parentPage) {
|
||||||
await parentPage.load();
|
parentPage.load();
|
||||||
const text = parentPage.Text.fromDelta([
|
const text = parentPage.Text.fromDelta([
|
||||||
{
|
{
|
||||||
insert: ' ',
|
insert: ' ',
|
||||||
|
|||||||
@@ -96,11 +96,15 @@ export const useZoomControls = ({
|
|||||||
[dragEndImpl]
|
[dragEndImpl]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(
|
||||||
if (isDragging) {
|
(evt: MouseEvent) => {
|
||||||
dragEndImpl();
|
evt.preventDefault();
|
||||||
}
|
if (isDragging) {
|
||||||
}, [isDragging, dragEndImpl]);
|
dragEndImpl();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDragging, dragEndImpl]
|
||||||
|
);
|
||||||
|
|
||||||
const checkZoomSize = useCallback(() => {
|
const checkZoomSize = useCallback(() => {
|
||||||
const { current: zoomArea } = zoomRef;
|
const { current: zoomArea } = zoomRef;
|
||||||
@@ -183,15 +187,17 @@ export const useZoomControls = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = (event: WheelEvent) => {
|
const handleScroll = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
const { deltaY } = event;
|
const { deltaY } = event;
|
||||||
if (deltaY > 0) {
|
if (deltaY > 0) {
|
||||||
zoomOut();
|
zoomOut();
|
||||||
} else if (deltaY < 0) {
|
} else if (deltaY < 0 && currentScale < 2) {
|
||||||
zoomIn();
|
zoomIn();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResize = () => {
|
const handleResize = (event: UIEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
checkZoomSize();
|
checkZoomSize();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -206,7 +212,7 @@ export const useZoomControls = ({
|
|||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
};
|
};
|
||||||
}, [zoomIn, zoomOut, checkZoomSize, handleMouseUp]);
|
}, [zoomIn, zoomOut, checkZoomSize, handleMouseUp, currentScale]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zoomIn,
|
zoomIn,
|
||||||
|
|||||||
@@ -86,8 +86,14 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
localStorage.setItem('last_page_id', page.id);
|
localStorage.setItem('last_page_id', page.id);
|
||||||
|
|
||||||
if (onLoad) {
|
if (onLoad) {
|
||||||
disposableGroup.add(onLoad(page, editor));
|
// Invoke onLoad once the editor has been mounted to the DOM.
|
||||||
|
editor.updateComplete
|
||||||
|
.then(() => {
|
||||||
|
disposableGroup.add(onLoad(page, editor));
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ beforeEach(async () => {
|
|||||||
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
|
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
|
||||||
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema });
|
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema });
|
||||||
const initPage = async (page: Page) => {
|
const initPage = async (page: Page) => {
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
assertExists(page);
|
assertExists(page);
|
||||||
const pageBlockId = page.addBlock('affine:page', {
|
const pageBlockId = page.addBlock('affine:page', {
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ const usePageOperationsRenderer = () => {
|
|||||||
const { setTrashModal } = useTrashModalHelper(
|
const { setTrashModal } = useTrashModalHelper(
|
||||||
currentWorkspace.blockSuiteWorkspace
|
currentWorkspace.blockSuiteWorkspace
|
||||||
);
|
);
|
||||||
const { toggleFavorite } = useBlockSuiteMetaHelper(
|
const { toggleFavorite, duplicate } = useBlockSuiteMetaHelper(
|
||||||
currentWorkspace.blockSuiteWorkspace
|
currentWorkspace.blockSuiteWorkspace
|
||||||
);
|
);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
|
||||||
const pageOperationsRenderer = useCallback(
|
const pageOperationsRenderer = useCallback(
|
||||||
(page: PageMeta) => {
|
(page: PageMeta) => {
|
||||||
const onDisablePublicSharing = () => {
|
const onDisablePublicSharing = () => {
|
||||||
@@ -42,12 +43,16 @@ const usePageOperationsRenderer = () => {
|
|||||||
portal: document.body,
|
portal: document.body,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageOperationCell
|
<PageOperationCell
|
||||||
favorite={!!page.favorite}
|
favorite={!!page.favorite}
|
||||||
isPublic={!!page.isPublic}
|
isPublic={!!page.isPublic}
|
||||||
onDisablePublicSharing={onDisablePublicSharing}
|
onDisablePublicSharing={onDisablePublicSharing}
|
||||||
link={`/workspace/${currentWorkspace.id}/${page.id}`}
|
link={`/workspace/${currentWorkspace.id}/${page.id}`}
|
||||||
|
onDuplicate={() => {
|
||||||
|
duplicate(page.id, false);
|
||||||
|
}}
|
||||||
onRemoveToTrash={() =>
|
onRemoveToTrash={() =>
|
||||||
setTrashModal({
|
setTrashModal({
|
||||||
open: true,
|
open: true,
|
||||||
@@ -67,7 +72,7 @@ const usePageOperationsRenderer = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
|
[currentWorkspace.id, setTrashModal, t, toggleFavorite, duplicate]
|
||||||
);
|
);
|
||||||
|
|
||||||
return pageOperationsRenderer;
|
return pageOperationsRenderer;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|||||||
import {
|
import {
|
||||||
DeleteIcon,
|
DeleteIcon,
|
||||||
DeletePermanentlyIcon,
|
DeletePermanentlyIcon,
|
||||||
|
DuplicateIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
FavoritedIcon,
|
FavoritedIcon,
|
||||||
FavoriteIcon,
|
FavoriteIcon,
|
||||||
@@ -39,6 +40,7 @@ export interface PageOperationCellProps {
|
|||||||
link: string;
|
link: string;
|
||||||
onToggleFavoritePage: () => void;
|
onToggleFavoritePage: () => void;
|
||||||
onRemoveToTrash: () => void;
|
onRemoveToTrash: () => void;
|
||||||
|
onDuplicate: () => void;
|
||||||
onDisablePublicSharing: () => void;
|
onDisablePublicSharing: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,7 @@ export const PageOperationCell = ({
|
|||||||
link,
|
link,
|
||||||
onToggleFavoritePage,
|
onToggleFavoritePage,
|
||||||
onRemoveToTrash,
|
onRemoveToTrash,
|
||||||
|
onDuplicate,
|
||||||
onDisablePublicSharing,
|
onDisablePublicSharing,
|
||||||
}: PageOperationCellProps) => {
|
}: PageOperationCellProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
@@ -98,6 +101,18 @@ export const PageOperationCell = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
preFix={
|
||||||
|
<MenuIcon>
|
||||||
|
<DuplicateIcon />
|
||||||
|
</MenuIcon>
|
||||||
|
}
|
||||||
|
onSelect={onDuplicate}
|
||||||
|
>
|
||||||
|
{t['com.affine.header.option.duplicate']()}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
|
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
|
||||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||||
import type { Page, Workspace } from '@blocksuite/store';
|
import type { Page, Workspace } from '@blocksuite/store';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const logger = new DebugLogger('use-block-suite-workspace-page');
|
|
||||||
|
|
||||||
export function useBlockSuiteWorkspacePage(
|
export function useBlockSuiteWorkspacePage(
|
||||||
blockSuiteWorkspace: Workspace,
|
blockSuiteWorkspace: Workspace,
|
||||||
pageId: string | null
|
pageId: string | null
|
||||||
@@ -36,11 +33,15 @@ export function useBlockSuiteWorkspacePage(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page && !page.loaded) {
|
if (page && !page.loaded) {
|
||||||
page.load().catch(err => {
|
page.load();
|
||||||
logger.error('Failed to load page', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (page?.id !== pageId) {
|
||||||
|
setPage(pageId ? blockSuiteWorkspace.getPage(pageId) : null);
|
||||||
|
}
|
||||||
|
}, [blockSuiteWorkspace, page?.id, pageId]);
|
||||||
|
|
||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ const safeCurrentPageAtom = atom<Promise<Page | undefined>>(async get => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!page.loaded) {
|
if (!page.loaded) {
|
||||||
await page.waitForLoaded();
|
page.load();
|
||||||
}
|
}
|
||||||
return page;
|
return page;
|
||||||
});
|
});
|
||||||
@@ -310,7 +310,7 @@ export const usePageCommands = () => {
|
|||||||
category: 'affine:creation',
|
category: 'affine:creation',
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const page = pageHelper.createPage();
|
const page = pageHelper.createPage();
|
||||||
await page.waitForLoaded();
|
page.load();
|
||||||
pageMetaHelper.setPageTitle(page.id, query);
|
pageMetaHelper.setPageTitle(page.id, query);
|
||||||
},
|
},
|
||||||
icon: <PageIcon />,
|
icon: <PageIcon />,
|
||||||
@@ -325,7 +325,7 @@ export const usePageCommands = () => {
|
|||||||
category: 'affine:creation',
|
category: 'affine:creation',
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const page = pageHelper.createEdgeless();
|
const page = pageHelper.createEdgeless();
|
||||||
await page.waitForLoaded();
|
page.load();
|
||||||
pageMetaHelper.setPageTitle(page.id, query);
|
pageMetaHelper.setPageTitle(page.id, query);
|
||||||
},
|
},
|
||||||
icon: <EdgelessIcon />,
|
icon: <EdgelessIcon />,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const root = style({});
|
export const root = style({});
|
||||||
@@ -7,19 +8,16 @@ export const commandsContainer = style({
|
|||||||
padding: '8px 6px 18px 6px',
|
padding: '8px 6px 18px 6px',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const searchInput = style({
|
export const searchInputContainer = style({
|
||||||
height: 66,
|
height: 66,
|
||||||
color: 'var(--affine-text-primary-color)',
|
padding: '18px 16px',
|
||||||
fontSize: 'var(--affine-font-h-5)',
|
|
||||||
padding: '21px 24px',
|
|
||||||
marginBottom: '8px',
|
marginBottom: '8px',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
borderBottom: '1px solid var(--affine-border-color)',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 12,
|
||||||
|
borderBottom: `1px solid ${cssVar('borderColor')}`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|
||||||
'::placeholder': {
|
|
||||||
color: 'var(--affine-text-secondary-color)',
|
|
||||||
},
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&.inEditor': {
|
'&.inEditor': {
|
||||||
paddingTop: '12px',
|
paddingTop: '12px',
|
||||||
@@ -28,6 +26,15 @@ export const searchInput = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const searchInput = style({
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
fontSize: cssVar('fontH5'),
|
||||||
|
width: '100%',
|
||||||
|
'::placeholder': {
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const pageTitleWrapper = style({
|
export const pageTitleWrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -95,8 +102,9 @@ export const keybindingFragment = style({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
color: 'var(--affine-text-secondary-color)',
|
color: 'var(--affine-text-secondary-color)',
|
||||||
backgroundColor: 'var(--affine-background-tertiary-color)',
|
backgroundColor: 'var(--affine-background-tertiary-color)',
|
||||||
width: 24,
|
minWidth: 24,
|
||||||
height: 20,
|
height: 20,
|
||||||
|
textTransform: 'uppercase',
|
||||||
});
|
});
|
||||||
|
|
||||||
globalStyle(`${root} [cmdk-root]`, {
|
globalStyle(`${root} [cmdk-root]`, {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { Loading } from '@affine/component/ui/loading';
|
||||||
import { formatDate } from '@affine/core/components/page-list';
|
import { formatDate } from '@affine/core/components/page-list';
|
||||||
|
import { useSyncEngineStatus } from '@affine/core/hooks/affine/use-sync-engine-status';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { SyncEngineStep } from '@affine/workspace';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import type { CommandCategory } from '@toeverything/infra/command';
|
import type { CommandCategory } from '@toeverything/infra/command';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
@@ -187,7 +190,7 @@ export const CMDKContainer = ({
|
|||||||
const [value, setValue] = useAtom(cmdkValueAtom);
|
const [value, setValue] = useAtom(cmdkValueAtom);
|
||||||
const isInEditor = pageMeta !== undefined;
|
const isInEditor = pageMeta !== undefined;
|
||||||
const [opening, setOpening] = useState(open);
|
const [opening, setOpening] = useState(open);
|
||||||
|
const { syncEngineStatus, progress } = useSyncEngineStatus();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// fix list height animation on openning
|
// fix list height animation on openning
|
||||||
@@ -224,16 +227,29 @@ export const CMDKContainer = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Command.Input
|
<div
|
||||||
placeholder={t['com.affine.cmdk.placeholder']()}
|
className={clsx(className, styles.searchInputContainer, {
|
||||||
ref={inputRef}
|
|
||||||
{...rest}
|
|
||||||
value={query}
|
|
||||||
onValueChange={onQueryChange}
|
|
||||||
className={clsx(className, styles.searchInput, {
|
|
||||||
inEditor: isInEditor,
|
inEditor: isInEditor,
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
|
{!syncEngineStatus ||
|
||||||
|
syncEngineStatus.step === SyncEngineStep.Syncing ? (
|
||||||
|
<Loading
|
||||||
|
size={24}
|
||||||
|
progress={progress ? Math.max(progress, 0.2) : undefined}
|
||||||
|
speed={progress ? 0 : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Command.Input
|
||||||
|
placeholder={t['com.affine.cmdk.placeholder']()}
|
||||||
|
ref={inputRef}
|
||||||
|
{...rest}
|
||||||
|
value={query}
|
||||||
|
onValueChange={onQueryChange}
|
||||||
|
className={clsx(className, styles.searchInput)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Command.List data-opening={opening ? true : undefined}>
|
<Command.List data-opening={opening ? true : undefined}>
|
||||||
{children}
|
{children}
|
||||||
</Command.List>
|
</Command.List>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Tooltip } from '@affine/component/ui/tooltip';
|
import { Tooltip } from '@affine/component/ui/tooltip';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { CloseIcon, NewIcon, UserGuideIcon } from '@blocksuite/icons';
|
import { CloseIcon, NewIcon } from '@blocksuite/icons';
|
||||||
import { useSetAtom } from 'jotai/react';
|
import { useSetAtom } from 'jotai/react';
|
||||||
import { useAtomValue } from 'jotai/react';
|
import { useAtomValue } from 'jotai/react';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { openOnboardingModalAtom, openSettingModalAtom } from '../../../atoms';
|
import { openSettingModalAtom } from '../../../atoms';
|
||||||
import { currentModeAtom } from '../../../atoms/mode';
|
import { currentModeAtom } from '../../../atoms/mode';
|
||||||
import type { SettingProps } from '../../affine/setting-modal';
|
import type { SettingProps } from '../../affine/setting-modal';
|
||||||
import { ContactIcon, HelpIcon, KeyboardIcon } from './icons';
|
import { ContactIcon, HelpIcon, KeyboardIcon } from './icons';
|
||||||
@@ -22,14 +22,14 @@ const DEFAULT_SHOW_LIST: IslandItemNames[] = [
|
|||||||
'contact',
|
'contact',
|
||||||
'shortcuts',
|
'shortcuts',
|
||||||
];
|
];
|
||||||
const DESKTOP_SHOW_LIST: IslandItemNames[] = [...DEFAULT_SHOW_LIST, 'guide'];
|
|
||||||
type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts' | 'guide';
|
const DESKTOP_SHOW_LIST: IslandItemNames[] = [...DEFAULT_SHOW_LIST];
|
||||||
|
type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts';
|
||||||
|
|
||||||
const showList = environment.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST;
|
const showList = environment.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST;
|
||||||
|
|
||||||
export const HelpIsland = () => {
|
export const HelpIsland = () => {
|
||||||
const mode = useAtomValue(currentModeAtom);
|
const mode = useAtomValue(currentModeAtom);
|
||||||
const setOpenOnboarding = useSetAtom(openOnboardingModalAtom);
|
|
||||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||||
const [spread, setShowSpread] = useState(false);
|
const [spread, setShowSpread] = useState(false);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
@@ -102,22 +102,6 @@ export const HelpIsland = () => {
|
|||||||
</StyledIconWrapper>
|
</StyledIconWrapper>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{showList.includes('guide') && (
|
|
||||||
<Tooltip
|
|
||||||
content={t['com.affine.helpIsland.gettingStarted']()}
|
|
||||||
side="left"
|
|
||||||
>
|
|
||||||
<StyledIconWrapper
|
|
||||||
data-testid="easy-guide"
|
|
||||||
onClick={() => {
|
|
||||||
setShowSpread(false);
|
|
||||||
setOpenOnboarding(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<UserGuideIcon />
|
|
||||||
</StyledIconWrapper>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</StyledAnimateWrapper>
|
</StyledAnimateWrapper>
|
||||||
|
|
||||||
{spread ? (
|
{spread ? (
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export const emptyCollectionMessage = style({
|
|||||||
fontSize: 'var(--affine-font-sm)',
|
fontSize: 'var(--affine-font-sm)',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--affine-black-30)',
|
color: 'var(--affine-black-30)',
|
||||||
|
userSelect: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const emptyCollectionNewButton = style({
|
export const emptyCollectionNewButton = style({
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const AddFavouriteButton = ({
|
|||||||
createLinkedPage(pageId);
|
createLinkedPage(pageId);
|
||||||
} else {
|
} else {
|
||||||
const page = createPage();
|
const page = createPage();
|
||||||
await page.load();
|
page.load();
|
||||||
setPageMeta(page.id, { favorite: true });
|
setPageMeta(page.id, { favorite: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -171,4 +171,5 @@ export const emptyFavouritesMessage = style({
|
|||||||
fontSize: 'var(--affine-font-sm)',
|
fontSize: 'var(--affine-font-sm)',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--affine-black-30)',
|
color: 'var(--affine-black-30)',
|
||||||
|
userSelect: 'none',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import { Loading } from '@affine/component/ui/loading';
|
|||||||
import { Tooltip } from '@affine/component/ui/tooltip';
|
import { Tooltip } from '@affine/component/ui/tooltip';
|
||||||
import { openSettingModalAtom } from '@affine/core/atoms';
|
import { openSettingModalAtom } from '@affine/core/atoms';
|
||||||
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
|
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
|
||||||
|
import { useSyncEngineStatus } from '@affine/core/hooks/affine/use-sync-engine-status';
|
||||||
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
|
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
|
||||||
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
|
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
|
||||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||||
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
||||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace';
|
import { SyncEngineStep } from '@affine/workspace';
|
||||||
import {
|
import {
|
||||||
CloudWorkspaceIcon,
|
CloudWorkspaceIcon,
|
||||||
InformationFillDuotoneIcon,
|
InformationFillDuotoneIcon,
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
UnsyncIcon,
|
UnsyncIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { debounce, mean } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
type HTMLAttributes,
|
type HTMLAttributes,
|
||||||
@@ -93,8 +94,8 @@ const useSyncEngineSyncProgress = () => {
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const isOnline = useSystemOnline();
|
const isOnline = useSystemOnline();
|
||||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
const [syncEngineStatus, setSyncEngineStatus] =
|
const { syncEngineStatus, setSyncEngineStatus, progress } =
|
||||||
useState<SyncEngineStatus | null>(null);
|
useSyncEngineStatus();
|
||||||
const [isOverCapacity, setIsOverCapacity] = useState(false);
|
const [isOverCapacity, setIsOverCapacity] = useState(false);
|
||||||
|
|
||||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||||
@@ -155,25 +156,14 @@ const useSyncEngineSyncProgress = () => {
|
|||||||
disposable?.dispose();
|
disposable?.dispose();
|
||||||
disposableOverCapacity?.dispose();
|
disposableOverCapacity?.dispose();
|
||||||
};
|
};
|
||||||
}, [currentWorkspace, isOwner, jumpToPricePlan, pushNotification, t]);
|
}, [
|
||||||
|
currentWorkspace,
|
||||||
const progress = useMemo(() => {
|
isOwner,
|
||||||
if (!syncEngineStatus?.remotes || syncEngineStatus?.remotes.length === 0) {
|
jumpToPricePlan,
|
||||||
return null;
|
pushNotification,
|
||||||
}
|
setSyncEngineStatus,
|
||||||
return mean(
|
t,
|
||||||
syncEngineStatus.remotes.map(peer => {
|
]);
|
||||||
if (!peer) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
const totalTask =
|
|
||||||
peer.totalDocs + peer.pendingPullUpdates + peer.pendingPushUpdates;
|
|
||||||
const doneTask = peer.loadedDocs;
|
|
||||||
|
|
||||||
return doneTask / totalTask;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [syncEngineStatus?.remotes]);
|
|
||||||
|
|
||||||
const content = useMemo(() => {
|
const content = useMemo(() => {
|
||||||
// TODO: add i18n
|
// TODO: add i18n
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export const RootAppSidebar = ({
|
|||||||
|
|
||||||
const onClickNewPage = useAsyncCallback(async () => {
|
const onClickNewPage = useAsyncCallback(async () => {
|
||||||
const page = createPage();
|
const page = createPage();
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
openPage(page.id);
|
openPage(page.id);
|
||||||
}, [createPage, openPage]);
|
}, [createPage, openPage]);
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
|||||||
import { Schema, Workspace } from '@blocksuite/store';
|
import { Schema, Workspace } from '@blocksuite/store';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
||||||
import { beforeEach, describe, expect, test } from 'vitest';
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { useBlockSuitePageMeta } from '../use-block-suite-page-meta';
|
import { useBlockSuitePageMeta } from '../use-block-suite-page-meta';
|
||||||
import { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper';
|
import { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper';
|
||||||
@@ -17,18 +17,26 @@ let blockSuiteWorkspace: Workspace;
|
|||||||
const schema = new Schema();
|
const schema = new Schema();
|
||||||
schema.register(AffineSchemas).register(__unstableSchemas);
|
schema.register(AffineSchemas).register(__unstableSchemas);
|
||||||
|
|
||||||
|
// todo: this module has some side-effects that will break the tests
|
||||||
|
vi.mock('@affine/workspace-impl', () => ({
|
||||||
|
default: {},
|
||||||
|
}));
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
blockSuiteWorkspace = new Workspace({
|
blockSuiteWorkspace = new Workspace({
|
||||||
id: 'test',
|
id: 'test',
|
||||||
schema,
|
schema,
|
||||||
});
|
});
|
||||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
|
||||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
blockSuiteWorkspace.doc.emit('sync', []);
|
||||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
|
||||||
|
initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
||||||
|
initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
||||||
|
initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useBlockSuiteWorkspaceHelper', () => {
|
describe('useBlockSuiteWorkspaceHelper', () => {
|
||||||
test('should create page', () => {
|
test('should create page', async () => {
|
||||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||||
const helperHook = renderHook(() =>
|
const helperHook = renderHook(() =>
|
||||||
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
|
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
|
||||||
@@ -36,6 +44,7 @@ describe('useBlockSuiteWorkspaceHelper', () => {
|
|||||||
const pageMetaHook = renderHook(() =>
|
const pageMetaHook = renderHook(() =>
|
||||||
useBlockSuitePageMeta(blockSuiteWorkspace)
|
useBlockSuitePageMeta(blockSuiteWorkspace)
|
||||||
);
|
);
|
||||||
|
await new Promise(resolve => setTimeout(resolve));
|
||||||
expect(pageMetaHook.result.current.length).toBe(3);
|
expect(pageMetaHook.result.current.length).toBe(3);
|
||||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||||
const page = helperHook.result.current.createPage('page4');
|
const page = helperHook.result.current.createPage('page4');
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ beforeEach(async () => {
|
|||||||
blockSuiteWorkspace.doc.emit('sync', []);
|
blockSuiteWorkspace.doc.emit('sync', []);
|
||||||
|
|
||||||
const initPage = async (page: Page) => {
|
const initPage = async (page: Page) => {
|
||||||
await page.waitForLoaded();
|
page.load();
|
||||||
expect(page).not.toBeNull();
|
expect(page).not.toBeNull();
|
||||||
assertExists(page);
|
assertExists(page);
|
||||||
const pageBlockId = page.addBlock('affine:page', {
|
const pageBlockId = page.addBlock('affine:page', {
|
||||||
|
|||||||
@@ -147,12 +147,12 @@ export function useBlockSuiteMetaHelper(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const duplicate = useAsyncCallback(
|
const duplicate = useAsyncCallback(
|
||||||
async (pageId: string) => {
|
async (pageId: string, openPageAfterDuplication: boolean = true) => {
|
||||||
const currentPageMeta = getPageMeta(pageId);
|
const currentPageMeta = getPageMeta(pageId);
|
||||||
const newPage = createPage();
|
const newPage = createPage();
|
||||||
const currentPage = blockSuiteWorkspace.getPage(pageId);
|
const currentPage = blockSuiteWorkspace.getPage(pageId);
|
||||||
|
|
||||||
await newPage.waitForLoaded();
|
newPage.waitForLoaded();
|
||||||
if (!currentPageMeta || !currentPage) {
|
if (!currentPageMeta || !currentPage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -164,9 +164,18 @@ export function useBlockSuiteMetaHelper(
|
|||||||
tags: currentPageMeta.tags,
|
tags: currentPageMeta.tags,
|
||||||
favorite: currentPageMeta.favorite,
|
favorite: currentPageMeta.favorite,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lastDigitRegex = /\((\d+)\)$/;
|
||||||
|
const match = currentPageMeta.title.match(lastDigitRegex);
|
||||||
|
const newNumber = match ? parseInt(match[1], 10) + 1 : 1;
|
||||||
|
|
||||||
|
const newPageTitle =
|
||||||
|
currentPageMeta.title.replace(lastDigitRegex, '') + `(${newNumber})`;
|
||||||
|
|
||||||
setPageMode(newPage.id, currentMode);
|
setPageMode(newPage.id, currentMode);
|
||||||
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
|
setPageTitle(newPage.id, newPageTitle);
|
||||||
openPage(blockSuiteWorkspace.id, newPage.id);
|
|
||||||
|
openPageAfterDuplication && openPage(blockSuiteWorkspace.id, newPage.id);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
blockSuiteWorkspace,
|
blockSuiteWorkspace,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { syncEngineStatusAtom } from '@affine/core/atoms/sync-engine-status';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { mean } from 'lodash-es';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export function useSyncEngineStatus() {
|
||||||
|
const [syncEngineStatus, setSyncEngineStatus] = useAtom(syncEngineStatusAtom);
|
||||||
|
|
||||||
|
const progress = useMemo(() => {
|
||||||
|
if (!syncEngineStatus?.remotes || syncEngineStatus?.remotes.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return mean(
|
||||||
|
syncEngineStatus.remotes.map(peer => {
|
||||||
|
if (!peer) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const totalTask =
|
||||||
|
peer.totalDocs + peer.pendingPullUpdates + peer.pendingPushUpdates;
|
||||||
|
const doneTask = peer.loadedDocs;
|
||||||
|
|
||||||
|
return doneTask / totalTask;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [syncEngineStatus?.remotes]);
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
syncEngineStatus,
|
||||||
|
setSyncEngineStatus,
|
||||||
|
progress,
|
||||||
|
}),
|
||||||
|
[progress, setSyncEngineStatus, syncEngineStatus]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,9 +5,11 @@ import type { Atom } from 'jotai';
|
|||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useJournalHelper } from './use-journal';
|
||||||
|
|
||||||
const weakMap = new WeakMap<Workspace, Atom<PageMeta[]>>();
|
const weakMap = new WeakMap<Workspace, Atom<PageMeta[]>>();
|
||||||
|
|
||||||
export function useBlockSuitePageMeta(
|
export function useAllBlockSuitePageMeta(
|
||||||
blockSuiteWorkspace: Workspace
|
blockSuiteWorkspace: Workspace
|
||||||
): PageMeta[] {
|
): PageMeta[] {
|
||||||
if (!weakMap.has(blockSuiteWorkspace)) {
|
if (!weakMap.has(blockSuiteWorkspace)) {
|
||||||
@@ -26,6 +28,18 @@ export function useBlockSuitePageMeta(
|
|||||||
return useAtomValue(weakMap.get(blockSuiteWorkspace) as Atom<PageMeta[]>);
|
return useAtomValue(weakMap.get(blockSuiteWorkspace) as Atom<PageMeta[]>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useBlockSuitePageMeta(blocksuiteWorkspace: Workspace) {
|
||||||
|
const pageMetas = useAllBlockSuitePageMeta(blocksuiteWorkspace);
|
||||||
|
const { isPageJournal } = useJournalHelper(blocksuiteWorkspace);
|
||||||
|
return useMemo(
|
||||||
|
() =>
|
||||||
|
pageMetas.filter(
|
||||||
|
pageMeta => !isPageJournal(pageMeta.id) || !!pageMeta.updatedDate
|
||||||
|
),
|
||||||
|
[isPageJournal, pageMetas]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function usePageMetaHelper(blockSuiteWorkspace: Workspace) {
|
export function usePageMetaHelper(blockSuiteWorkspace: Workspace) {
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
|
||||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||||
import type { Page, Workspace } from '@blocksuite/store';
|
import type { Page, Workspace } from '@blocksuite/store';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const logger = new DebugLogger('use-block-suite-workspace-page');
|
|
||||||
|
|
||||||
export function useBlockSuiteWorkspacePage(
|
export function useBlockSuiteWorkspacePage(
|
||||||
blockSuiteWorkspace: Workspace,
|
blockSuiteWorkspace: Workspace,
|
||||||
pageId: string | null
|
pageId: string | null
|
||||||
@@ -36,9 +33,7 @@ export function useBlockSuiteWorkspacePage(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page && !page.loaded) {
|
if (page && !page.loaded) {
|
||||||
page.load().catch(err => {
|
page.load();
|
||||||
logger.error('Failed to load page', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [page]);
|
}, [page]);
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,11 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
|
|||||||
(maybeDate: MaybeDate) => {
|
(maybeDate: MaybeDate) => {
|
||||||
const title = dayjs(maybeDate).format(JOURNAL_DATE_FORMAT);
|
const title = dayjs(maybeDate).format(JOURNAL_DATE_FORMAT);
|
||||||
const page = bsWorkspaceHelper.createPage();
|
const page = bsWorkspaceHelper.createPage();
|
||||||
initEmptyPage(page, title).catch(err =>
|
// set created date to match the journal date
|
||||||
console.error('Failed to load journal page', err)
|
page.workspace.setPageMeta(page.id, {
|
||||||
);
|
createDate: dayjs(maybeDate).toDate().getTime(),
|
||||||
|
});
|
||||||
|
initEmptyPage(page, title);
|
||||||
adapter.setJournalPageDateString(page.id, title);
|
adapter.setJournalPageDateString(page.id, title);
|
||||||
return page;
|
return page;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const Component = () => {
|
|||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
const list = useAtomValue(workspaceListAtom);
|
const list = useAtomValue(workspaceListAtom);
|
||||||
|
|
||||||
const { openPage } = useNavigateHelper();
|
const { openPage } = useNavigateHelper();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
|
|||||||
|
|
||||||
if (urlToOpen && lastOpened !== urlToOpen && autoOpen) {
|
if (urlToOpen && lastOpened !== urlToOpen && autoOpen) {
|
||||||
lastOpened = urlToOpen;
|
lastOpened = urlToOpen;
|
||||||
open(urlToOpen, '_blank');
|
location.href = urlToOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!urlToOpen) {
|
if (!urlToOpen) {
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
||||||
import { ResizePanel } from '@affine/component/resize-panel';
|
import { ResizePanel } from '@affine/component/resize-panel';
|
||||||
|
import { pageSettingFamily, setPageModeAtom } from '@affine/core/atoms';
|
||||||
|
import { collectionsCRUDAtom } from '@affine/core/atoms/collections';
|
||||||
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||||
import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status';
|
import { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status';
|
||||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||||
import { WorkspaceSubPath } from '@affine/core/shared';
|
|
||||||
import { globalBlockSuiteSchema, SyncEngineStep } from '@affine/workspace';
|
import { globalBlockSuiteSchema, SyncEngineStep } from '@affine/workspace';
|
||||||
import {
|
import {
|
||||||
BookmarkService,
|
BookmarkService,
|
||||||
customImageProxyMiddleware,
|
customImageProxyMiddleware,
|
||||||
|
EmbedGithubService,
|
||||||
|
EmbedLoomService,
|
||||||
|
EmbedYoutubeService,
|
||||||
ImageService,
|
ImageService,
|
||||||
|
type PageService,
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||||
import type { Page, Workspace } from '@blocksuite/store';
|
import type { Page, Workspace } from '@blocksuite/store';
|
||||||
import { appSettingAtom } from '@toeverything/infra/atom';
|
import { appSettingAtom } from '@toeverything/infra';
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import {
|
import {
|
||||||
memo,
|
memo,
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
@@ -25,8 +30,6 @@ import {
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import type { Map as YMap } from 'yjs';
|
import type { Map as YMap } from 'yjs';
|
||||||
|
|
||||||
import { setPageModeAtom } from '../../../atoms';
|
|
||||||
import { collectionsCRUDAtom } from '../../../atoms/collections';
|
|
||||||
import { currentModeAtom, currentPageIdAtom } from '../../../atoms/mode';
|
import { currentModeAtom, currentPageIdAtom } from '../../../atoms/mode';
|
||||||
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
|
||||||
import { HubIsland } from '../../../components/affine/hub-island';
|
import { HubIsland } from '../../../components/affine/hub-island';
|
||||||
@@ -42,7 +45,7 @@ import { TopTip } from '../../../components/top-tip';
|
|||||||
import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands';
|
import { useRegisterBlocksuiteEditorCommands } from '../../../hooks/affine/use-register-blocksuite-editor-commands';
|
||||||
import { usePageDocumentTitle } from '../../../hooks/use-global-state';
|
import { usePageDocumentTitle } from '../../../hooks/use-global-state';
|
||||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||||
import { performanceRenderLogger } from '../../../shared';
|
import { performanceRenderLogger, WorkspaceSubPath } from '../../../shared';
|
||||||
import { PageNotFound } from '../../404';
|
import { PageNotFound } from '../../404';
|
||||||
import * as styles from './detail-page.css';
|
import * as styles from './detail-page.css';
|
||||||
import { DetailPageHeader, RightSidebarHeader } from './detail-page-header';
|
import { DetailPageHeader, RightSidebarHeader } from './detail-page-header';
|
||||||
@@ -121,6 +124,7 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) {
|
|||||||
const setPageMode = useSetAtom(setPageModeAtom);
|
const setPageMode = useSetAtom(setPageModeAtom);
|
||||||
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
|
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
|
||||||
usePageDocumentTitle(pageMeta);
|
usePageDocumentTitle(pageMeta);
|
||||||
|
const rootStore = useStore();
|
||||||
|
|
||||||
const onLoad = useCallback(
|
const onLoad = useCallback(
|
||||||
(page: Page, editor: AffineEditorContainer) => {
|
(page: Page, editor: AffineEditorContainer) => {
|
||||||
@@ -144,11 +148,35 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) {
|
|||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
ImageService.setImageProxyURL(runtimeConfig.imageProxyUrl);
|
// blocksuite editor host
|
||||||
BookmarkService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
|
const editorHost = editor.host;
|
||||||
editor.host?.std.clipboard.use(
|
|
||||||
|
// provide image proxy endpoint to blocksuite
|
||||||
|
editorHost.std.clipboard.use(
|
||||||
customImageProxyMiddleware(runtimeConfig.imageProxyUrl)
|
customImageProxyMiddleware(runtimeConfig.imageProxyUrl)
|
||||||
);
|
);
|
||||||
|
ImageService.setImageProxyURL(runtimeConfig.imageProxyUrl);
|
||||||
|
|
||||||
|
// provide link preview endpoint to blocksuite
|
||||||
|
BookmarkService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
|
||||||
|
EmbedGithubService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
|
||||||
|
EmbedYoutubeService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
|
||||||
|
EmbedLoomService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
|
||||||
|
|
||||||
|
// provide page mode and updated date to blocksuite
|
||||||
|
const pageService = editorHost.std.spec.getService(
|
||||||
|
'affine:page'
|
||||||
|
) as PageService;
|
||||||
|
pageService.getPageMode = (pageId: string) =>
|
||||||
|
rootStore.get(pageSettingFamily(pageId)).mode;
|
||||||
|
pageService.getPageUpdatedAt = (pageId: string) => {
|
||||||
|
const linkedPage = page.workspace.getPage(pageId);
|
||||||
|
if (!linkedPage) return new Date();
|
||||||
|
|
||||||
|
const updatedDate = linkedPage.meta.updatedDate;
|
||||||
|
const createDate = linkedPage.meta.createDate;
|
||||||
|
return updatedDate ? new Date(updatedDate) : new Date(createDate);
|
||||||
|
};
|
||||||
|
|
||||||
setPageMode(currentPageId, mode);
|
setPageMode(currentPageId, mode);
|
||||||
// fixme: it seems pageLinkClicked is not triggered sometimes?
|
// fixme: it seems pageLinkClicked is not triggered sometimes?
|
||||||
@@ -173,6 +201,7 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) {
|
|||||||
openPage,
|
openPage,
|
||||||
setPageMode,
|
setPageMode,
|
||||||
setTemporaryFilter,
|
setTemporaryFilter,
|
||||||
|
rootStore,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { MoveToTrash } from '@affine/core/components/page-list';
|
import { MoveToTrash } from '@affine/core/components/page-list';
|
||||||
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
|
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
|
||||||
|
import { useBlockSuitePageMeta } from '@affine/core/hooks/use-block-suite-page-meta';
|
||||||
import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
|
import { useBlockSuiteWorkspacePageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
|
||||||
import {
|
import {
|
||||||
useJournalHelper,
|
useJournalHelper,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
useJournalRouteHelper,
|
useJournalRouteHelper,
|
||||||
} from '@affine/core/hooks/use-journal';
|
} from '@affine/core/hooks/use-journal';
|
||||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||||
|
import type { BlockSuiteWorkspace } from '@affine/core/shared';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import {
|
import {
|
||||||
EdgelessIcon,
|
EdgelessIcon,
|
||||||
@@ -20,7 +22,7 @@ import {
|
|||||||
PageIcon,
|
PageIcon,
|
||||||
TodayIcon,
|
TodayIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import type { Page } from '@blocksuite/store';
|
import type { Page, PageMeta } from '@blocksuite/store';
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -41,21 +43,28 @@ const CountDisplay = ({
|
|||||||
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
||||||
};
|
};
|
||||||
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
|
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
page: Page;
|
pageMeta: PageMeta;
|
||||||
|
workspace: BlockSuiteWorkspace;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
}
|
}
|
||||||
const PageItem = ({ page, right, className, ...attrs }: PageItemProps) => {
|
const PageItem = ({
|
||||||
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
|
pageMeta,
|
||||||
const title = useBlockSuiteWorkspacePageTitle(page.workspace, page.id);
|
workspace,
|
||||||
|
right,
|
||||||
|
className,
|
||||||
|
...attrs
|
||||||
|
}: PageItemProps) => {
|
||||||
|
const { isJournal } = useJournalInfoHelper(workspace, pageMeta.id);
|
||||||
|
const title = useBlockSuiteWorkspacePageTitle(workspace, pageMeta.id);
|
||||||
|
|
||||||
const Icon = isJournal
|
const Icon = isJournal
|
||||||
? TodayIcon
|
? TodayIcon
|
||||||
: page.meta.mode === 'edgeless'
|
: pageMeta.mode === 'edgeless'
|
||||||
? EdgelessIcon
|
? EdgelessIcon
|
||||||
: PageIcon;
|
: PageIcon;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
aria-label={page.meta.title}
|
aria-label={pageMeta.title}
|
||||||
className={clsx(className, styles.pageItem)}
|
className={clsx(className, styles.pageItem)}
|
||||||
{...attrs}
|
{...attrs}
|
||||||
>
|
>
|
||||||
@@ -114,15 +123,12 @@ const EditorJournalPanel = (props: EditorExtensionProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sortPagesByDate = (
|
const sortPagesByDate = (
|
||||||
pages: Page[],
|
pages: PageMeta[],
|
||||||
field: 'updatedDate' | 'createDate',
|
field: 'updatedDate' | 'createDate',
|
||||||
order: 'asc' | 'desc' = 'desc'
|
order: 'asc' | 'desc' = 'desc'
|
||||||
) => {
|
) => {
|
||||||
return [...pages].sort((a, b) => {
|
return [...pages].sort((a, b) => {
|
||||||
return (
|
return (order === 'asc' ? 1 : -1) * dayjs(b[field]).diff(dayjs(a[field]));
|
||||||
(order === 'asc' ? 1 : -1) *
|
|
||||||
dayjs(b.meta[field]).diff(dayjs(a.meta[field]))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -141,21 +147,21 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
|
|||||||
const nodeRef = useRef<HTMLDivElement>(null);
|
const nodeRef = useRef<HTMLDivElement>(null);
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
||||||
|
const pageMetas = useBlockSuitePageMeta(workspace);
|
||||||
|
|
||||||
const navigateHelper = useNavigateHelper();
|
const navigateHelper = useNavigateHelper();
|
||||||
|
|
||||||
const getTodaysPages = useCallback(
|
const getTodaysPages = useCallback(
|
||||||
(field: 'createDate' | 'updatedDate') => {
|
(field: 'createDate' | 'updatedDate') => {
|
||||||
const pages: Page[] = [];
|
return sortPagesByDate(
|
||||||
Array.from(workspace.pages.values()).forEach(page => {
|
pageMetas.filter(pageMeta => {
|
||||||
if (page.meta.trash) return;
|
if (pageMeta.trash) return false;
|
||||||
if (page.meta[field] && dayjs(page.meta[field]).isSame(date, 'day')) {
|
return pageMeta[field] && dayjs(pageMeta[field]).isSame(date, 'day');
|
||||||
pages.push(page);
|
}),
|
||||||
}
|
field
|
||||||
});
|
);
|
||||||
return sortPagesByDate(pages, field);
|
|
||||||
},
|
},
|
||||||
[date, workspace.pages]
|
[date, pageMetas]
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdToday = useMemo(
|
const createdToday = useMemo(
|
||||||
@@ -224,14 +230,15 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
|
|||||||
<Scrollable.Scrollbar />
|
<Scrollable.Scrollbar />
|
||||||
<Scrollable.Viewport>
|
<Scrollable.Viewport>
|
||||||
<div className={styles.dailyCountContent} ref={nodeRef}>
|
<div className={styles.dailyCountContent} ref={nodeRef}>
|
||||||
{renderList.map((page, index) => (
|
{renderList.map((pageMeta, index) => (
|
||||||
<PageItem
|
<PageItem
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigateHelper.openPage(workspace.id, page.id)
|
navigateHelper.openPage(workspace.id, pageMeta.id)
|
||||||
}
|
}
|
||||||
tabIndex={name === activeItem ? 0 : -1}
|
tabIndex={name === activeItem ? 0 : -1}
|
||||||
key={index}
|
key={index}
|
||||||
page={page}
|
pageMeta={pageMeta}
|
||||||
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -282,7 +289,8 @@ const ConflictList = ({
|
|||||||
<PageItem
|
<PageItem
|
||||||
aria-label={page.meta.title}
|
aria-label={page.meta.title}
|
||||||
aria-selected={isCurrent}
|
aria-selected={isCurrent}
|
||||||
page={page}
|
pageMeta={page.meta}
|
||||||
|
workspace={workspace}
|
||||||
key={page.id}
|
key={page.id}
|
||||||
right={
|
right={
|
||||||
<Menu
|
<Menu
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import {
|
|||||||
} from '@affine/core/modules/workspace';
|
} from '@affine/core/modules/workspace';
|
||||||
import { type Workspace } from '@affine/workspace';
|
import { type Workspace } from '@affine/workspace';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { type ReactElement, Suspense, useEffect, useMemo } from 'react';
|
import {
|
||||||
|
type ReactElement,
|
||||||
|
Suspense,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
import { Outlet, useParams } from 'react-router-dom';
|
import { Outlet, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
||||||
@@ -67,12 +73,34 @@ export const Component = (): ReactElement => {
|
|||||||
localStorage.setItem('last_workspace_id', workspace.id);
|
localStorage.setItem('last_workspace_id', workspace.id);
|
||||||
}, [setCurrentWorkspace, meta, workspaceManager, workspace]);
|
}, [setCurrentWorkspace, meta, workspaceManager, workspace]);
|
||||||
|
|
||||||
|
const [workspaceIsLoading, setWorkspaceIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// hotfix: avoid doing operation, before workspace is loaded
|
||||||
|
useEffect(() => {
|
||||||
|
if (!workspace) {
|
||||||
|
setWorkspaceIsLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const metaYMap = workspace.blockSuiteWorkspace.doc.getMap('meta');
|
||||||
|
|
||||||
|
const handleYMapChanged = () => {
|
||||||
|
setWorkspaceIsLoading(metaYMap.size === 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
handleYMapChanged();
|
||||||
|
|
||||||
|
metaYMap.observe(handleYMapChanged);
|
||||||
|
return () => {
|
||||||
|
metaYMap.unobserve(handleYMapChanged);
|
||||||
|
};
|
||||||
|
}, [workspace]);
|
||||||
|
|
||||||
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
|
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
|
||||||
if (listLoading === false && meta === undefined) {
|
if (listLoading === false && meta === undefined) {
|
||||||
return <PageNotFound />;
|
return <PageNotFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace || workspaceIsLoading) {
|
||||||
return <WorkspaceFallback key="workspaceLoading" />;
|
return <WorkspaceFallback key="workspaceLoading" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const TrashPage = () => {
|
|||||||
permanentlyDeletePage(page.id);
|
permanentlyDeletePage(page.id);
|
||||||
toast(t['com.affine.toastMessage.permanentlyDeleted']());
|
toast(t['com.affine.toastMessage.permanentlyDeleted']());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TrashOperationCell
|
<TrashOperationCell
|
||||||
onPermanentlyDeletePage={onPermanentlyDeletePage}
|
onPermanentlyDeletePage={onPermanentlyDeletePage}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user