mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-04 16:44:56 +00:00
Compare commits
137 Commits
06-18-feat
...
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
|
||||
public
|
||||
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
|
||||
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/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": "blocksuite-nightly",
|
||||
"groupName": "blocksuite-canary",
|
||||
"matchPackagePatterns": ["^@blocksuite"],
|
||||
"excludePackageNames": ["@blocksuite/icons"],
|
||||
"rangeStrategy": "replace",
|
||||
"followTag": "nightly"
|
||||
"followTag": "canary"
|
||||
},
|
||||
{
|
||||
"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: |
|
||||
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/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
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,3 +79,6 @@ lib
|
||||
affine.db
|
||||
apps/web/next-routes.conf
|
||||
.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
|
||||
tests/affine-legacy/**/static
|
||||
.yarnrc.yml
|
||||
packages/frontend/templates/edgeless-templates.gen.ts
|
||||
packages/frontend/templates/templates.gen.ts
|
||||
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",
|
||||
"@vitest/coverage-istanbul": "1.1.3",
|
||||
"@vitest/ui": "1.1.3",
|
||||
"electron": "^28.1.4",
|
||||
"electron": "^28.2.1",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-i": "^2.29.0",
|
||||
|
||||
@@ -40,21 +40,21 @@
|
||||
"@node-rs/crc32": "^1.7.2",
|
||||
"@node-rs/jsonwebtoken": "^0.3.0",
|
||||
"@opentelemetry/api": "^1.7.0",
|
||||
"@opentelemetry/core": "^1.20.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.47.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.20.0",
|
||||
"@opentelemetry/host-metrics": "^0.34.0",
|
||||
"@opentelemetry/instrumentation": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.47.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.36.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.35.0",
|
||||
"@opentelemetry/resources": "^1.20.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.20.0",
|
||||
"@opentelemetry/sdk-node": "^0.47.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.20.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.20.0",
|
||||
"@opentelemetry/core": "^1.21.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.48.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.21.0",
|
||||
"@opentelemetry/host-metrics": "^0.35.0",
|
||||
"@opentelemetry/instrumentation": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-graphql": "^0.37.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.37.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.36.0",
|
||||
"@opentelemetry/resources": "^1.21.0",
|
||||
"@opentelemetry/sdk-metrics": "^1.21.0",
|
||||
"@opentelemetry/sdk-node": "^0.48.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.21.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.21.0",
|
||||
"@prisma/client": "^5.7.1",
|
||||
"@prisma/instrumentation": "^5.7.1",
|
||||
"@socket.io/redis-adapter": "^8.2.1",
|
||||
|
||||
@@ -265,7 +265,9 @@ model Snapshot {
|
||||
seq Int @default(0) @db.Integer
|
||||
state Bytes? @db.ByteA
|
||||
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])
|
||||
@@map("snapshots")
|
||||
|
||||
@@ -96,6 +96,24 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
||||
}
|
||||
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 = {
|
||||
providers: [
|
||||
// @ts-expect-error esm interop issue
|
||||
|
||||
@@ -10,7 +10,6 @@ import { chunk } from 'lodash-es';
|
||||
import { defer, retry } from 'rxjs';
|
||||
import {
|
||||
applyUpdate,
|
||||
decodeStateVector,
|
||||
Doc,
|
||||
encodeStateAsUpdate,
|
||||
encodeStateVector,
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
|
||||
import {
|
||||
Cache,
|
||||
CallTimer,
|
||||
Config,
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
@@ -45,36 +45,6 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||
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 {
|
||||
return (
|
||||
buf.length === 0 ||
|
||||
@@ -119,6 +89,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
@CallTimer('doc', 'yjs_recover_updates_to_doc')
|
||||
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
||||
const doc = new Doc();
|
||||
const chunks = chunk(updates, 10);
|
||||
@@ -382,7 +353,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
const updates = await this.getUpdates(workspaceId, guid);
|
||||
|
||||
if (updates.length) {
|
||||
const doc = await this.squash(updates, snapshot);
|
||||
const doc = await this.squash(snapshot, updates);
|
||||
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(
|
||||
workspaceId: string,
|
||||
guid: string,
|
||||
doc: Doc,
|
||||
// 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,
|
||||
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)) {
|
||||
return false;
|
||||
if (isEmptyBuffer(blob)) {
|
||||
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 await this.db.$transaction(async db => {
|
||||
const snapshot = await db.snapshot.findUnique({
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
return true;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to upsert snapshot', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async _get(
|
||||
@@ -548,7 +531,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
if (updates.length) {
|
||||
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,
|
||||
* 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) {
|
||||
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(
|
||||
first.id,
|
||||
id,
|
||||
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
||||
...updates.map(u => u.blob)
|
||||
);
|
||||
@@ -600,19 +583,24 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
}
|
||||
|
||||
// always delete updates
|
||||
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
||||
const { count } = await this.db.update.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
seq: {
|
||||
in: updates.map(u => u.seq),
|
||||
// we will keep the updates only if the upsert failed on unknown reason
|
||||
// `done === undefined` means the updates is outdated(have already been merged by other process), safe to be deleted
|
||||
// `done === true` means the upsert is successful, safe to be deleted
|
||||
if (done !== false) {
|
||||
// always delete updates
|
||||
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
||||
const { count } = await this.db.update.deleteMany({
|
||||
where: {
|
||||
id,
|
||||
workspaceId,
|
||||
seq: {
|
||||
in: updates.map(u => u.seq),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||
}
|
||||
|
||||
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)
|
||||
async reportUpdatesQueueCount() {
|
||||
metrics.doc
|
||||
|
||||
@@ -277,6 +277,7 @@ export class WorkspaceResolver {
|
||||
id: workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
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 {
|
||||
CommonFeature,
|
||||
FeatureKind,
|
||||
Features,
|
||||
FeatureType,
|
||||
} 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) {
|
||||
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
||||
for (const oldUser of waitingList) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SessionCache } from '../cache';
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
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) {}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ try {
|
||||
: require('../../../storage.node');
|
||||
}
|
||||
|
||||
export { storageModule as OctoBaseStorageModule };
|
||||
|
||||
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
||||
|
||||
export const verifyChallengeResponse = async (
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Args,
|
||||
Context,
|
||||
Field,
|
||||
InputType,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
@@ -125,6 +126,31 @@ class UserInvoiceType implements Partial<UserInvoice> {
|
||||
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()
|
||||
@Resolver(() => UserSubscriptionType)
|
||||
export class SubscriptionResolver {
|
||||
@@ -182,7 +208,11 @@ export class SubscriptionResolver {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@Mutation(() => String, {
|
||||
deprecationReason: 'use `createCheckoutSession` instead',
|
||||
description: 'Create a subscription checkout link of stripe',
|
||||
})
|
||||
async checkout(
|
||||
@@ -193,6 +223,7 @@ export class SubscriptionResolver {
|
||||
) {
|
||||
const session = await this.service.createCheckoutSession({
|
||||
user,
|
||||
plan: SubscriptionPlan.Pro,
|
||||
recurring,
|
||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||
idempotencyKey,
|
||||
@@ -210,6 +241,36 @@ export class SubscriptionResolver {
|
||||
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, {
|
||||
description: 'Create a stripe customer portal to manage payment methods',
|
||||
})
|
||||
|
||||
@@ -69,13 +69,15 @@ export class SubscriptionService {
|
||||
async createCheckoutSession({
|
||||
user,
|
||||
recurring,
|
||||
plan,
|
||||
promotionCode,
|
||||
redirectUrl,
|
||||
idempotencyKey,
|
||||
plan = SubscriptionPlan.Pro,
|
||||
}: {
|
||||
user: User;
|
||||
plan?: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
plan: SubscriptionPlan;
|
||||
promotionCode?: string | null;
|
||||
redirectUrl: string;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
@@ -95,7 +97,28 @@ export class SubscriptionService {
|
||||
`${idempotencyKey}-getOrCreateCustomer`,
|
||||
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(
|
||||
{
|
||||
@@ -108,13 +131,11 @@ export class SubscriptionService {
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
...(coupon
|
||||
...(discount
|
||||
? {
|
||||
discounts: [{ coupon }],
|
||||
discounts: [discount],
|
||||
}
|
||||
: {
|
||||
allow_promotion_codes: true,
|
||||
}),
|
||||
: { allow_promotion_codes: true }),
|
||||
mode: 'subscription',
|
||||
success_url: redirectUrl,
|
||||
customer: customer.stripeCustomerId,
|
||||
@@ -643,4 +664,33 @@ export class SubscriptionService {
|
||||
|
||||
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)
|
||||
# ------------------------------------------------------
|
||||
|
||||
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.
|
||||
"""
|
||||
@@ -107,7 +115,10 @@ type Mutation {
|
||||
changePassword(newPassword: String!, token: String!): UserType!
|
||||
|
||||
"""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"""
|
||||
createCustomerPortal: String!
|
||||
|
||||
@@ -4,12 +4,7 @@ import { TestingModule } from '@nestjs/testing';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import test from 'ava';
|
||||
import * as Sinon from 'sinon';
|
||||
import {
|
||||
applyUpdate,
|
||||
decodeStateVector,
|
||||
Doc as YDoc,
|
||||
encodeStateAsUpdate,
|
||||
} from 'yjs';
|
||||
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { DocManager, DocModule } from '../src/core/doc';
|
||||
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);
|
||||
});
|
||||
|
||||
test('should not update snapshot if state is outdated', async t => {
|
||||
const db = m.get(PrismaClient);
|
||||
test('should be able to insert the snapshot if it is new created', async t => {
|
||||
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 text = doc.getText('content');
|
||||
const updates: Buffer[] = [];
|
||||
|
||||
doc.on('update', update => {
|
||||
updates.push(Buffer.from(update));
|
||||
});
|
||||
|
||||
text.insert(0, 'hello');
|
||||
text.insert(5, 'world');
|
||||
text.insert(5, ' ');
|
||||
const update = encodeStateAsUpdate(doc);
|
||||
|
||||
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');
|
||||
text.insert(11, '!');
|
||||
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
|
||||
const updates = await manager.getUpdates('1', '1');
|
||||
t.is(updates.length, 1);
|
||||
// @ts-expect-error private
|
||||
await manager.squash(updateWith4Records, null);
|
||||
// @ts-expect-error private
|
||||
await manager.squash(updateWith3Records, null);
|
||||
const snapshot = await manager.squash(null, updates);
|
||||
|
||||
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: {
|
||||
id_workspaceId: {
|
||||
id: '2',
|
||||
workspaceId: '2',
|
||||
id: '1',
|
||||
},
|
||||
},
|
||||
data: {
|
||||
updatedAt: new Date(Date.now() + 10000),
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
t.fail('snapshot not found');
|
||||
return;
|
||||
{
|
||||
const snapshot = await manager.getSnapshot('2', '1');
|
||||
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();
|
||||
applyUpdate(d, result.blob!);
|
||||
|
||||
const dtext = d.getText('content');
|
||||
t.is(dtext.toString(), 'hello world!');
|
||||
// the updates are known as outdated, so they will be deleted
|
||||
t.is((await manager.getUpdates('2', '1')).length, 0);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -8,9 +8,6 @@ crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
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 = [
|
||||
"napi5",
|
||||
"async",
|
||||
@@ -18,6 +15,7 @@ napi = { version = "2", default-features = false, features = [
|
||||
napi-derive = { version = "2", features = ["type-def"] }
|
||||
rand = "0.8"
|
||||
sha3 = "0.10"
|
||||
y-octo = { git = "https://github.com/y-crdt/y-octo.git", branch = "main" }
|
||||
|
||||
[dev-dependencies]
|
||||
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 */
|
||||
/* 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
|
||||
* result binary.
|
||||
|
||||
@@ -2,16 +2,10 @@
|
||||
|
||||
pub mod hashcash;
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt::{Debug, Display},
|
||||
path::PathBuf,
|
||||
};
|
||||
use std::fmt::{Debug, Display};
|
||||
|
||||
use jwst_codec::Doc;
|
||||
use jwst_core::BlobStorage;
|
||||
use jwst_storage::{BlobStorageType, JwstStorage, JwstStorageError};
|
||||
use napi::{bindgen_prelude::*, Error, Result, Status};
|
||||
use y_octo::Doc;
|
||||
|
||||
#[macro_use]
|
||||
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
|
||||
/// result binary.
|
||||
#[napi(catch_unwind)]
|
||||
pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
|
||||
let mut doc = Doc::default();
|
||||
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())?;
|
||||
|
||||
4
packages/common/env/package.json
vendored
4
packages/common/env/package.json
vendored
@@ -3,8 +3,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"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';
|
||||
|
||||
export const blockSuiteFeatureFlags = z.object({
|
||||
enable_synced_doc_block: z.boolean(),
|
||||
enable_expand_database_block: z.boolean(),
|
||||
enable_bultin_ledits: z.boolean(),
|
||||
});
|
||||
@@ -15,6 +16,7 @@ export const runtimeFlagsSchema = z.object({
|
||||
enableTestProperties: z.boolean(),
|
||||
enableBroadcastChannelProvider: z.boolean(),
|
||||
enableDebugPage: z.boolean(),
|
||||
githubUrl: z.string(),
|
||||
changelogUrl: z.string(),
|
||||
downloadUrl: z.string(),
|
||||
// see: tools/workers
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
"@affine/debug": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||
"jotai": "^2.5.1",
|
||||
"jotai-effect": "^0.2.3",
|
||||
"nanoid": "^5.0.3",
|
||||
@@ -26,8 +26,8 @@
|
||||
"devDependencies": {
|
||||
"@affine-test/fixtures": "workspace:*",
|
||||
"@affine/templates": "workspace:*",
|
||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||
"async-call-rpc": "^6.3.1",
|
||||
"react": "^18.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -13,8 +13,8 @@ import { Map as YMap } from 'yjs';
|
||||
import { getLatestVersions } from '../migration/blocksuite';
|
||||
import { replaceIdMiddleware } from './middleware';
|
||||
|
||||
export async function initEmptyPage(page: Page, title?: string) {
|
||||
await page.load(() => {
|
||||
export function initEmptyPage(page: Page, title?: string) {
|
||||
page.load(() => {
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
title: new page.Text(title ?? ''),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { setupEditorFlags } from '@affine/env/global';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { assertEquals } from '@blocksuite/global/utils';
|
||||
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||
@@ -164,6 +165,8 @@ export class WorkspaceManager {
|
||||
// apply compatibility fix
|
||||
fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc);
|
||||
|
||||
setupEditorFlags(workspace.blockSuiteWorkspace);
|
||||
|
||||
return workspace;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,14 +32,14 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||
"idb": "^8.0.0",
|
||||
"nanoid": "^5.0.3",
|
||||
"y-provider": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||
"fake-indexeddb": "^5.0.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "3.7.0",
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('indexeddb provider', () => {
|
||||
],
|
||||
});
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
page.waitForLoaded();
|
||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
@@ -129,7 +129,7 @@ describe('indexeddb provider', () => {
|
||||
| WorkspacePersist
|
||||
| undefined;
|
||||
assertExists(data);
|
||||
await testWorkspace.getPage('page0')?.waitForLoaded();
|
||||
testWorkspace.getPage('page0')?.waitForLoaded();
|
||||
data.updates.forEach(({ update }) => {
|
||||
Workspace.Y.applyUpdate(subPage, update);
|
||||
});
|
||||
@@ -148,7 +148,7 @@ describe('indexeddb provider', () => {
|
||||
expect(provider.connected).toBe(false);
|
||||
{
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
page.waitForLoaded();
|
||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
page.addBlock('affine:paragraph', {}, frameId);
|
||||
@@ -203,7 +203,7 @@ describe('indexeddb provider', () => {
|
||||
provider.connect();
|
||||
{
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
page.waitForLoaded();
|
||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||
for (let i = 0; i < 99; i++) {
|
||||
@@ -369,14 +369,14 @@ describe('subDoc', () => {
|
||||
const page0 = workspace.createPage({
|
||||
id: 'page0',
|
||||
});
|
||||
await page0.waitForLoaded();
|
||||
page0.waitForLoaded();
|
||||
const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0);
|
||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||
provider.connect();
|
||||
const page1 = workspace.createPage({
|
||||
id: 'page1',
|
||||
});
|
||||
await page1.waitForLoaded();
|
||||
page1.waitForLoaded();
|
||||
const { paragraphBlockId: paragraphBlockIdPage2 } = initEmptyPage(page1);
|
||||
await setTimeout(200);
|
||||
provider.disconnect();
|
||||
@@ -390,14 +390,14 @@ describe('subDoc', () => {
|
||||
provider.connect();
|
||||
await setTimeout(200);
|
||||
const page0 = newWorkspace.getPage('page0') as Page;
|
||||
await page0.waitForLoaded();
|
||||
page0.waitForLoaded();
|
||||
await setTimeout(200);
|
||||
{
|
||||
const block = page0.getBlockById(paragraphBlockIdPage1);
|
||||
assertExists(block);
|
||||
}
|
||||
const page1 = newWorkspace.getPage('page1') as Page;
|
||||
await page1.waitForLoaded();
|
||||
page1.waitForLoaded();
|
||||
await setTimeout(200);
|
||||
{
|
||||
const block = page1.getBlockById(paragraphBlockIdPage2);
|
||||
@@ -410,7 +410,7 @@ describe('subDoc', () => {
|
||||
describe('utils', () => {
|
||||
test('download binary', async () => {
|
||||
const page = workspace.createPage({ id: 'page0' });
|
||||
await page.waitForLoaded();
|
||||
page.waitForLoaded();
|
||||
initEmptyPage(page);
|
||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||
provider.connect();
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"build": "vite build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "3.7.0",
|
||||
"vitest": "1.1.3",
|
||||
|
||||
@@ -73,12 +73,12 @@
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/icons": "2.1.44",
|
||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@storybook/addon-actions": "^7.5.3",
|
||||
"@storybook/addon-essentials": "^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 './modal';
|
||||
export * from './overlay-modal';
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '../button';
|
||||
import { Input, type InputProps } from '../input';
|
||||
import { ConfirmModal, type ConfirmModalProps } from './confirm-modal';
|
||||
import { Modal, type ModalProps } from './modal';
|
||||
import { OverlayModal, type OverlayModalProps } from './overlay-modal';
|
||||
|
||||
export default {
|
||||
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> =
|
||||
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 editorFlags: BlockSuiteFeatureFlags = {
|
||||
enable_synced_doc_block: false,
|
||||
enable_expand_database_block: false,
|
||||
enable_bultin_ledits: false,
|
||||
};
|
||||
@@ -16,6 +17,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
enableTestProperties: false,
|
||||
enableBroadcastChannelProvider: true,
|
||||
enableDebugPage: true,
|
||||
githubUrl: 'https://github.com/toeverything/AFFiNE',
|
||||
changelogUrl: 'https://affine.pro/what-is-new',
|
||||
downloadUrl: 'https://affine.pro/download',
|
||||
imageProxyUrl: '/api/worker/image-proxy',
|
||||
@@ -57,6 +59,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
enableTestProperties: true,
|
||||
enableBroadcastChannelProvider: true,
|
||||
enableDebugPage: true,
|
||||
githubUrl: 'https://github.com/toeverything/AFFiNE',
|
||||
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
||||
downloadUrl: 'https://affine.pro/download',
|
||||
imageProxyUrl: '/api/worker/image-proxy',
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
"@affine/templates": "workspace:*",
|
||||
"@affine/workspace": "workspace:*",
|
||||
"@affine/workspace-impl": "workspace:*",
|
||||
"@blocksuite/block-std": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/block-std": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/icons": "2.1.44",
|
||||
"@blocksuite/inline": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
||||
"@blocksuite/inline": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||
"@dnd-kit/core": "^6.0.8",
|
||||
"@dnd-kit/sortable": "^8.0.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<
|
||||
Guide['downloadClientTip'],
|
||||
|
||||
@@ -10,10 +10,11 @@ import type { SettingProps } from '../components/affine/setting-modal';
|
||||
export const openWorkspacesModalAtom = atom(false);
|
||||
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
||||
export const openQuickSearchModalAtom = atom(false);
|
||||
export const openOnboardingModalAtom = atom(false);
|
||||
export const openSignOutModalAtom = atom(false);
|
||||
export const openPaymentDisableAtom = atom(false);
|
||||
export const openQuotaModalAtom = atom(false);
|
||||
export const openStarAFFiNEModalAtom = atom(false);
|
||||
export const openIssueFeedbackModalAtom = atom(false);
|
||||
|
||||
export type SettingAtom = Pick<
|
||||
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, {
|
||||
jumpOnce: true,
|
||||
});
|
||||
await initEmptyPage(page);
|
||||
initEmptyPage(page);
|
||||
}
|
||||
logger.debug('create first workspace');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import './register-blocksuite-components';
|
||||
import './edgeless-template';
|
||||
|
||||
import { setupGlobal } from '@affine/env/global';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 type { createStore } from 'jotai';
|
||||
|
||||
import { openOnboardingModalAtom, openSettingModalAtom } from '../atoms';
|
||||
import { openSettingModalAtom } from '../atoms';
|
||||
|
||||
export function registerAffineHelpCommands({
|
||||
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 () => {
|
||||
unsubs.forEach(unsub => unsub());
|
||||
|
||||
@@ -83,11 +83,12 @@ export function registerAffineNavigationCommands({
|
||||
category: 'affine:navigation',
|
||||
icon: <ArrowRightBigIcon />,
|
||||
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
|
||||
keyBinding: '$mod+,',
|
||||
run() {
|
||||
store.set(openSettingModalAtom, {
|
||||
store.set(openSettingModalAtom, s => ({
|
||||
activeTab: 'appearance',
|
||||
open: true,
|
||||
});
|
||||
open: !s.open,
|
||||
}));
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
||||
import { Captcha, useCaptcha } from './use-captcha';
|
||||
import { useSubscriptionSearch } from './use-subscription';
|
||||
|
||||
function validateEmail(email: string) {
|
||||
return emailRegex.test(email);
|
||||
@@ -34,6 +35,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
const [verifyToken, challenge] = useCaptcha();
|
||||
const subscriptionData = useSubscriptionSearch();
|
||||
|
||||
const {
|
||||
isMutating: isSigningIn,
|
||||
@@ -81,7 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
if (verifyToken) {
|
||||
if (user) {
|
||||
// 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');
|
||||
} else {
|
||||
const res = await signIn(email, verifyToken, challenge);
|
||||
@@ -101,6 +104,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
||||
}
|
||||
}
|
||||
}, [
|
||||
subscriptionData,
|
||||
challenge,
|
||||
email,
|
||||
setAuthEmail,
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Button } from '@affine/component/ui/button';
|
||||
import { Loading } from '@affine/component/ui/loading';
|
||||
import { AffineShapeIcon } from '@affine/core/components/page-list';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import type { SubscriptionRecurring } from '@affine/graphql';
|
||||
import type { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||
import {
|
||||
changePasswordMutation,
|
||||
checkoutMutation,
|
||||
createCheckoutSessionMutation,
|
||||
subscriptionQuery,
|
||||
} from '@affine/graphql';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@@ -30,18 +30,25 @@ const usePaymentRedirect = () => {
|
||||
}
|
||||
|
||||
const recurring = searchData.recurring as SubscriptionRecurring;
|
||||
const plan = searchData.plan as SubscriptionPlan;
|
||||
const coupon = searchData.coupon;
|
||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||
const { trigger: checkoutSubscription } = useMutation({
|
||||
mutation: checkoutMutation,
|
||||
mutation: createCheckoutSessionMutation,
|
||||
});
|
||||
|
||||
return useAsyncCallback(async () => {
|
||||
const { checkout } = await checkoutSubscription({
|
||||
recurring,
|
||||
idempotencyKey,
|
||||
const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({
|
||||
input: {
|
||||
recurring,
|
||||
plan,
|
||||
coupon,
|
||||
idempotencyKey,
|
||||
successCallbackLink: null,
|
||||
},
|
||||
});
|
||||
window.open(checkout, '_self', 'norefferer');
|
||||
}, [recurring, idempotencyKey, checkoutSubscription]);
|
||||
window.open(checkoutUrl, '_self', 'norefferer');
|
||||
}, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]);
|
||||
};
|
||||
|
||||
const CenterLoading = () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
|
||||
enum SubscriptionKey {
|
||||
Recurring = 'subscription_recurring',
|
||||
Plan = 'subscription_plan',
|
||||
Coupon = 'coupon',
|
||||
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
|
||||
}
|
||||
@@ -22,11 +23,13 @@ export function useSubscriptionSearch() {
|
||||
|
||||
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
||||
const plan = searchParams.get(SubscriptionKey.Plan);
|
||||
const coupon = searchParams.get(SubscriptionKey.Coupon);
|
||||
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
||||
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
||||
return {
|
||||
recurring,
|
||||
plan,
|
||||
coupon,
|
||||
withSignUp,
|
||||
passwordToken,
|
||||
getRedirectUrl(signUp?: boolean) {
|
||||
@@ -35,6 +38,10 @@ export function useSubscriptionSearch() {
|
||||
[SubscriptionKey.Plan, plan ?? ''],
|
||||
]);
|
||||
|
||||
if (coupon) {
|
||||
paymentParams.set(SubscriptionKey.Coupon, coupon);
|
||||
}
|
||||
|
||||
if (signUp) {
|
||||
paymentParams.set(SubscriptionKey.SignUp, '1');
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ export const CreateWorkspaceModal = ({
|
||||
workspace.setPageMeta(page.id, {
|
||||
jumpOnce: true,
|
||||
});
|
||||
await initEmptyPage(page);
|
||||
initEmptyPage(page);
|
||||
}
|
||||
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': {
|
||||
x: -240,
|
||||
y: -100,
|
||||
y: -30,
|
||||
},
|
||||
'2': {
|
||||
x: 240,
|
||||
y: -100,
|
||||
y: -35,
|
||||
},
|
||||
'3': {
|
||||
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 { memo, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useAppConfigStorage } from '../../../hooks/use-app-config-storage';
|
||||
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'] = {
|
||||
style: {
|
||||
background:
|
||||
@@ -36,7 +33,6 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
|
||||
}, [open]);
|
||||
|
||||
const gotIt = useCallback(() => {
|
||||
setOpen(false);
|
||||
setDismiss(true);
|
||||
}, [setDismiss]);
|
||||
|
||||
@@ -47,28 +43,23 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
withoutCloseButton
|
||||
contentOptions={contentOptions}
|
||||
overlayOptions={overlayOptions}
|
||||
<OverlayModal
|
||||
open={open}
|
||||
width={400}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Thumb />
|
||||
<div className={styles.title}>
|
||||
{t['com.affine.onboarding.workspace-guide.title']()}
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{t['com.affine.onboarding.workspace-guide.content']()}
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<Button type="primary" size="large" onClick={gotIt}>
|
||||
<span className={styles.gotItBtn}>
|
||||
{t['com.affine.onboarding.workspace-guide.got-it']()}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
topImage={<Thumb />}
|
||||
title={t['com.affine.onboarding.workspace-guide.title']()}
|
||||
description={t['com.affine.onboarding.workspace-guide.content']()}
|
||||
onConfirm={gotIt}
|
||||
overlayOptions={overlayOptions}
|
||||
withoutCancelButton
|
||||
confirmButtonOptions={{
|
||||
style: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
type: 'primary',
|
||||
size: 'large',
|
||||
}}
|
||||
confirmText={t['com.affine.onboarding.workspace-guide.got-it']()}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -111,7 +111,6 @@ const getOrCreateShellWorkspace = (workspaceId: string) => {
|
||||
const blobStorage = createAffineCloudBlobStorage(workspaceId);
|
||||
workspace = new Workspace({
|
||||
id: workspaceId,
|
||||
providerCreators: [],
|
||||
blobStorages: [
|
||||
() => ({
|
||||
crud: blobStorage,
|
||||
@@ -162,12 +161,10 @@ export const useSnapshotPage = (
|
||||
});
|
||||
page.awarenessStore.setReadonly(page, true);
|
||||
const spaceDoc = page.spaceDoc;
|
||||
page
|
||||
.load(() => {
|
||||
applyUpdate(spaceDoc, new Uint8Array(snapshot));
|
||||
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
|
||||
})
|
||||
.catch(console.error); // must load before applyUpdate
|
||||
page.load(() => {
|
||||
applyUpdate(spaceDoc, new Uint8Array(snapshot));
|
||||
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
|
||||
}); // must load before applyUpdate
|
||||
}
|
||||
return page ?? undefined;
|
||||
}, [pageDocId, snapshot, ts, workspace]);
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
SubscriptionMutator,
|
||||
} from '@affine/core/hooks/use-subscription';
|
||||
import {
|
||||
checkoutMutation,
|
||||
createCheckoutSessionMutation,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
@@ -359,7 +359,7 @@ const Upgrade = ({
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { isMutating, trigger } = useMutation({
|
||||
mutation: checkoutMutation,
|
||||
mutation: createCheckoutSessionMutation,
|
||||
});
|
||||
|
||||
const newTabRef = useRef<Window | null>(null);
|
||||
@@ -383,13 +383,21 @@ const Upgrade = ({
|
||||
newTabRef.current.focus();
|
||||
} else {
|
||||
await trigger(
|
||||
{ recurring, idempotencyKey },
|
||||
{
|
||||
input: {
|
||||
recurring,
|
||||
idempotencyKey,
|
||||
plan: SubscriptionPlan.Pro, // Only support prod plan now.
|
||||
coupon: null,
|
||||
successCallbackLink: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: data => {
|
||||
// FIXME: safari prevents from opening new tab by window api
|
||||
// TODO(@xp): what if electron?
|
||||
const newTab = window.open(
|
||||
data.checkout,
|
||||
data.createCheckoutSession,
|
||||
'_blank',
|
||||
'noopener noreferrer'
|
||||
);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
|
||||
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 { ContactWithUsIcon } from '@blocksuite/icons';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
|
||||
|
||||
@@ -37,7 +42,6 @@ export const SettingModal = ({
|
||||
onSettingClick,
|
||||
...modalProps
|
||||
}: SettingProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||
@@ -79,6 +83,16 @@ export const SettingModal = ({
|
||||
},
|
||||
[onSettingClick]
|
||||
);
|
||||
const setOpenIssueFeedbackModal = useSetAtom(openIssueFeedbackModalAtom);
|
||||
const setOpenStarAFFiNEModal = useSetAtom(openStarAFFiNEModalAtom);
|
||||
|
||||
const handleOpenIssueFeedbackModal = useCallback(() => {
|
||||
setOpenIssueFeedbackModal(true);
|
||||
}, [setOpenIssueFeedbackModal]);
|
||||
|
||||
const handleOpenStarAFFiNEModal = useCallback(() => {
|
||||
setOpenStarAFFiNEModal(true);
|
||||
}, [setOpenStarAFFiNEModal]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -126,17 +140,24 @@ export const SettingModal = ({
|
||||
</Suspense>
|
||||
</div>
|
||||
<div className={style.footer}>
|
||||
<a
|
||||
href="https://community.affine.pro/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={style.suggestionLink}
|
||||
>
|
||||
<span className={style.suggestionLinkIcon}>
|
||||
<ContactWithUsIcon width="16" height="16" />
|
||||
</span>
|
||||
{t['com.affine.settings.suggestion']()}
|
||||
</a>
|
||||
<ContactWithUsIcon fontSize={16} />
|
||||
<Trans
|
||||
i18nKey={'com.affine.settings.suggestion-2'}
|
||||
components={{
|
||||
1: (
|
||||
<span
|
||||
className={style.link}
|
||||
onClick={handleOpenStarAFFiNEModal}
|
||||
/>
|
||||
),
|
||||
2: (
|
||||
<span
|
||||
className={style.link}
|
||||
onClick={handleOpenIssueFeedbackModal}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const wrapper = style({
|
||||
@@ -50,4 +51,12 @@ export const footer = style({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
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 { Button } from '@affine/component/ui/button';
|
||||
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 { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { Workspace, WorkspaceMetadata } from '@affine/workspace';
|
||||
@@ -20,6 +21,7 @@ export const ExportPanel = ({
|
||||
const workspaceId = workspaceMetadata.id;
|
||||
const t = useAFFiNEI18N();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const isOnline = useSystemOnline();
|
||||
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const onExport = useAsyncCallback(async () => {
|
||||
@@ -28,8 +30,11 @@ export const ExportPanel = ({
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await workspace.engine.sync.waitForSynced();
|
||||
await workspace.engine.blob.sync();
|
||||
if (isOnline) {
|
||||
await workspace.engine.sync.waitForSynced();
|
||||
await workspace.engine.blob.sync();
|
||||
}
|
||||
|
||||
const result = await apis?.dialog.saveDBFileAs(workspaceId);
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
@@ -48,7 +53,7 @@ export const ExportPanel = ({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [pushNotification, saving, t, workspace, workspaceId]);
|
||||
}, [isOnline, pushNotification, saving, t, workspace, workspaceId]);
|
||||
|
||||
return (
|
||||
<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
|
||||
type BlocksuiteEditorContainerRef = Pick<
|
||||
(typeof AffineEditorContainer)['prototype'],
|
||||
'mode' | 'page' | 'model' | 'slots' | 'host'
|
||||
'mode' | 'page' | 'slots' | 'host'
|
||||
> &
|
||||
HTMLDivElement;
|
||||
|
||||
|
||||
@@ -37,24 +37,14 @@ export type EditorProps = {
|
||||
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) {
|
||||
let load$ = Reflect.get(page, PAGE_LOAD_KEY);
|
||||
if (!load$) {
|
||||
load$ = page.load();
|
||||
Reflect.set(page, PAGE_LOAD_KEY, load$);
|
||||
if (!page.ready) {
|
||||
page.load();
|
||||
}
|
||||
use(load$);
|
||||
|
||||
if (!page.root) {
|
||||
let root$: Promise<void> | undefined = Reflect.get(page, PAGE_ROOT_KEY);
|
||||
if (!root$) {
|
||||
root$ = new Promise((resolve, reject) => {
|
||||
use(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const disposable = page.slots.rootAdded.once(() => {
|
||||
resolve();
|
||||
});
|
||||
@@ -62,10 +52,8 @@ function usePageRoot(page: Page) {
|
||||
disposable.dispose();
|
||||
reject(new NoPageRootError(page));
|
||||
}, 20 * 1000);
|
||||
});
|
||||
Reflect.set(page, PAGE_ROOT_KEY, root$);
|
||||
}
|
||||
use(root$);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return page.root;
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { BlockSpec } from '@blocksuite/block-std';
|
||||
import type { ParagraphService } from '@blocksuite/blocks';
|
||||
import type { PageService, ParagraphService } from '@blocksuite/blocks';
|
||||
import {
|
||||
AttachmentService,
|
||||
CanvasTextFonts,
|
||||
DocEditorBlockSpecs,
|
||||
DocPageService,
|
||||
EdgelessEditorBlockSpecs,
|
||||
EdgelessPageService,
|
||||
} from '@blocksuite/blocks';
|
||||
import bytes from 'bytes';
|
||||
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 PageReferenceRenderer = (reference: AffineReference) => React.ReactElement;
|
||||
|
||||
@@ -76,6 +104,12 @@ export const docModeSpecs = DocEditorBlockSpecs.map(spec => {
|
||||
service: CustomAttachmentService,
|
||||
};
|
||||
}
|
||||
if (spec.schema.model.flavour === 'affine:page') {
|
||||
return {
|
||||
...spec,
|
||||
service: CustomDocPageService,
|
||||
};
|
||||
}
|
||||
return spec;
|
||||
});
|
||||
export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
||||
@@ -85,5 +119,11 @@ export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
||||
service: CustomAttachmentService,
|
||||
};
|
||||
}
|
||||
if (spec.schema.model.flavour === 'affine:page') {
|
||||
return {
|
||||
...spec,
|
||||
service: CustomEdgelessPageService,
|
||||
};
|
||||
}
|
||||
return spec;
|
||||
});
|
||||
|
||||
@@ -27,9 +27,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
||||
const createPageAndOpen = useCallback(
|
||||
(mode?: 'page' | 'edgeless') => {
|
||||
const page = createPage();
|
||||
initEmptyPage(page).catch(error => {
|
||||
toast(`Failed to initialize Page: ${error.message}`);
|
||||
});
|
||||
initEmptyPage(page);
|
||||
setPageMode(page.id, mode || 'page');
|
||||
openPage(blockSuiteWorkspace.id, page.id);
|
||||
return page;
|
||||
@@ -66,10 +64,10 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
||||
const createLinkedPageAndOpen = useAsyncCallback(
|
||||
async (pageId: string) => {
|
||||
const page = createPageAndOpen();
|
||||
await page.load();
|
||||
page.load();
|
||||
const parentPage = blockSuiteWorkspace.getPage(pageId);
|
||||
if (parentPage) {
|
||||
await parentPage.load();
|
||||
parentPage.load();
|
||||
const text = parentPage.Text.fromDelta([
|
||||
{
|
||||
insert: ' ',
|
||||
|
||||
@@ -96,11 +96,15 @@ export const useZoomControls = ({
|
||||
[dragEndImpl]
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging) {
|
||||
dragEndImpl();
|
||||
}
|
||||
}, [isDragging, dragEndImpl]);
|
||||
const handleMouseUp = useCallback(
|
||||
(evt: MouseEvent) => {
|
||||
evt.preventDefault();
|
||||
if (isDragging) {
|
||||
dragEndImpl();
|
||||
}
|
||||
},
|
||||
[isDragging, dragEndImpl]
|
||||
);
|
||||
|
||||
const checkZoomSize = useCallback(() => {
|
||||
const { current: zoomArea } = zoomRef;
|
||||
@@ -183,15 +187,17 @@ export const useZoomControls = ({
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const { deltaY } = event;
|
||||
if (deltaY > 0) {
|
||||
zoomOut();
|
||||
} else if (deltaY < 0) {
|
||||
} else if (deltaY < 0 && currentScale < 2) {
|
||||
zoomIn();
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
const handleResize = (event: UIEvent) => {
|
||||
event.preventDefault();
|
||||
checkZoomSize();
|
||||
};
|
||||
|
||||
@@ -206,7 +212,7 @@ export const useZoomControls = ({
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [zoomIn, zoomOut, checkZoomSize, handleMouseUp]);
|
||||
}, [zoomIn, zoomOut, checkZoomSize, handleMouseUp, currentScale]);
|
||||
|
||||
return {
|
||||
zoomIn,
|
||||
|
||||
@@ -86,8 +86,14 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
|
||||
})
|
||||
);
|
||||
localStorage.setItem('last_page_id', page.id);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeEach(async () => {
|
||||
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
|
||||
blockSuiteWorkspace = new BlockSuiteWorkspace({ id: 'test', schema });
|
||||
const initPage = async (page: Page) => {
|
||||
await page.waitForLoaded();
|
||||
page.waitForLoaded();
|
||||
expect(page).not.toBeNull();
|
||||
assertExists(page);
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
|
||||
@@ -31,10 +31,11 @@ const usePageOperationsRenderer = () => {
|
||||
const { setTrashModal } = useTrashModalHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const { toggleFavorite } = useBlockSuiteMetaHelper(
|
||||
const { toggleFavorite, duplicate } = useBlockSuiteMetaHelper(
|
||||
currentWorkspace.blockSuiteWorkspace
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const pageOperationsRenderer = useCallback(
|
||||
(page: PageMeta) => {
|
||||
const onDisablePublicSharing = () => {
|
||||
@@ -42,12 +43,16 @@ const usePageOperationsRenderer = () => {
|
||||
portal: document.body,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<PageOperationCell
|
||||
favorite={!!page.favorite}
|
||||
isPublic={!!page.isPublic}
|
||||
onDisablePublicSharing={onDisablePublicSharing}
|
||||
link={`/workspace/${currentWorkspace.id}/${page.id}`}
|
||||
onDuplicate={() => {
|
||||
duplicate(page.id, false);
|
||||
}}
|
||||
onRemoveToTrash={() =>
|
||||
setTrashModal({
|
||||
open: true,
|
||||
@@ -67,7 +72,7 @@ const usePageOperationsRenderer = () => {
|
||||
/>
|
||||
);
|
||||
},
|
||||
[currentWorkspace.id, setTrashModal, t, toggleFavorite]
|
||||
[currentWorkspace.id, setTrashModal, t, toggleFavorite, duplicate]
|
||||
);
|
||||
|
||||
return pageOperationsRenderer;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
DeleteIcon,
|
||||
DeletePermanentlyIcon,
|
||||
DuplicateIcon,
|
||||
EditIcon,
|
||||
FavoritedIcon,
|
||||
FavoriteIcon,
|
||||
@@ -39,6 +40,7 @@ export interface PageOperationCellProps {
|
||||
link: string;
|
||||
onToggleFavoritePage: () => void;
|
||||
onRemoveToTrash: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDisablePublicSharing: () => void;
|
||||
}
|
||||
|
||||
@@ -48,6 +50,7 @@ export const PageOperationCell = ({
|
||||
link,
|
||||
onToggleFavoritePage,
|
||||
onRemoveToTrash,
|
||||
onDuplicate,
|
||||
onDisablePublicSharing,
|
||||
}: PageOperationCellProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
@@ -98,6 +101,18 @@ export const PageOperationCell = ({
|
||||
</MenuItem>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<DuplicateIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
onSelect={onDuplicate}
|
||||
>
|
||||
{t['com.affine.header.option.duplicate']()}
|
||||
</MenuItem>
|
||||
|
||||
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const logger = new DebugLogger('use-block-suite-workspace-page');
|
||||
|
||||
export function useBlockSuiteWorkspacePage(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string | null
|
||||
@@ -36,11 +33,15 @@ export function useBlockSuiteWorkspacePage(
|
||||
|
||||
useEffect(() => {
|
||||
if (page && !page.loaded) {
|
||||
page.load().catch(err => {
|
||||
logger.error('Failed to load page', err);
|
||||
});
|
||||
page.load();
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (page?.id !== pageId) {
|
||||
setPage(pageId ? blockSuiteWorkspace.getPage(pageId) : null);
|
||||
}
|
||||
}, [blockSuiteWorkspace, page?.id, pageId]);
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ const safeCurrentPageAtom = atom<Promise<Page | undefined>>(async get => {
|
||||
}
|
||||
|
||||
if (!page.loaded) {
|
||||
await page.waitForLoaded();
|
||||
page.load();
|
||||
}
|
||||
return page;
|
||||
});
|
||||
@@ -310,7 +310,7 @@ export const usePageCommands = () => {
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const page = pageHelper.createPage();
|
||||
await page.waitForLoaded();
|
||||
page.load();
|
||||
pageMetaHelper.setPageTitle(page.id, query);
|
||||
},
|
||||
icon: <PageIcon />,
|
||||
@@ -325,7 +325,7 @@ export const usePageCommands = () => {
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const page = pageHelper.createEdgeless();
|
||||
await page.waitForLoaded();
|
||||
page.load();
|
||||
pageMetaHelper.setPageTitle(page.id, query);
|
||||
},
|
||||
icon: <EdgelessIcon />,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({});
|
||||
@@ -7,19 +8,16 @@ export const commandsContainer = style({
|
||||
padding: '8px 6px 18px 6px',
|
||||
});
|
||||
|
||||
export const searchInput = style({
|
||||
export const searchInputContainer = style({
|
||||
height: 66,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
padding: '21px 24px',
|
||||
padding: '18px 16px',
|
||||
marginBottom: '8px',
|
||||
width: '100%',
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderBottom: `1px solid ${cssVar('borderColor')}`,
|
||||
flexShrink: 0,
|
||||
|
||||
'::placeholder': {
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
},
|
||||
selectors: {
|
||||
'&.inEditor': {
|
||||
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({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -95,8 +102,9 @@ export const keybindingFragment = style({
|
||||
borderRadius: 4,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
backgroundColor: 'var(--affine-background-tertiary-color)',
|
||||
width: 24,
|
||||
minWidth: 24,
|
||||
height: 20,
|
||||
textTransform: 'uppercase',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-root]`, {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Loading } from '@affine/component/ui/loading';
|
||||
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 { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SyncEngineStep } from '@affine/workspace';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import type { CommandCategory } from '@toeverything/infra/command';
|
||||
import clsx from 'clsx';
|
||||
@@ -187,7 +190,7 @@ export const CMDKContainer = ({
|
||||
const [value, setValue] = useAtom(cmdkValueAtom);
|
||||
const isInEditor = pageMeta !== undefined;
|
||||
const [opening, setOpening] = useState(open);
|
||||
|
||||
const { syncEngineStatus, progress } = useSyncEngineStatus();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// fix list height animation on openning
|
||||
@@ -224,16 +227,29 @@ export const CMDKContainer = ({
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Command.Input
|
||||
placeholder={t['com.affine.cmdk.placeholder']()}
|
||||
ref={inputRef}
|
||||
{...rest}
|
||||
value={query}
|
||||
onValueChange={onQueryChange}
|
||||
className={clsx(className, styles.searchInput, {
|
||||
<div
|
||||
className={clsx(className, styles.searchInputContainer, {
|
||||
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}>
|
||||
{children}
|
||||
</Command.List>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Tooltip } from '@affine/component/ui/tooltip';
|
||||
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 { useAtomValue } from 'jotai/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { openOnboardingModalAtom, openSettingModalAtom } from '../../../atoms';
|
||||
import { openSettingModalAtom } from '../../../atoms';
|
||||
import { currentModeAtom } from '../../../atoms/mode';
|
||||
import type { SettingProps } from '../../affine/setting-modal';
|
||||
import { ContactIcon, HelpIcon, KeyboardIcon } from './icons';
|
||||
@@ -22,14 +22,14 @@ const DEFAULT_SHOW_LIST: IslandItemNames[] = [
|
||||
'contact',
|
||||
'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;
|
||||
|
||||
export const HelpIsland = () => {
|
||||
const mode = useAtomValue(currentModeAtom);
|
||||
const setOpenOnboarding = useSetAtom(openOnboardingModalAtom);
|
||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
const [spread, setShowSpread] = useState(false);
|
||||
const t = useAFFiNEI18N();
|
||||
@@ -102,22 +102,6 @@ export const HelpIsland = () => {
|
||||
</StyledIconWrapper>
|
||||
</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>
|
||||
|
||||
{spread ? (
|
||||
|
||||
@@ -153,6 +153,7 @@ export const emptyCollectionMessage = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
textAlign: 'center',
|
||||
color: 'var(--affine-black-30)',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const emptyCollectionNewButton = style({
|
||||
|
||||
@@ -25,7 +25,7 @@ export const AddFavouriteButton = ({
|
||||
createLinkedPage(pageId);
|
||||
} else {
|
||||
const page = createPage();
|
||||
await page.load();
|
||||
page.load();
|
||||
setPageMeta(page.id, { favorite: true });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -171,4 +171,5 @@ export const emptyFavouritesMessage = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
textAlign: 'center',
|
||||
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 { openSettingModalAtom } from '@affine/core/atoms';
|
||||
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 { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { type SyncEngineStatus, SyncEngineStep } from '@affine/workspace';
|
||||
import { SyncEngineStep } from '@affine/workspace';
|
||||
import {
|
||||
CloudWorkspaceIcon,
|
||||
InformationFillDuotoneIcon,
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
UnsyncIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { debounce, mean } from 'lodash-es';
|
||||
import { debounce } from 'lodash-es';
|
||||
import {
|
||||
forwardRef,
|
||||
type HTMLAttributes,
|
||||
@@ -93,8 +94,8 @@ const useSyncEngineSyncProgress = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const isOnline = useSystemOnline();
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const [syncEngineStatus, setSyncEngineStatus] =
|
||||
useState<SyncEngineStatus | null>(null);
|
||||
const { syncEngineStatus, setSyncEngineStatus, progress } =
|
||||
useSyncEngineStatus();
|
||||
const [isOverCapacity, setIsOverCapacity] = useState(false);
|
||||
|
||||
const currentWorkspace = useAtomValue(waitForCurrentWorkspaceAtom);
|
||||
@@ -155,25 +156,14 @@ const useSyncEngineSyncProgress = () => {
|
||||
disposable?.dispose();
|
||||
disposableOverCapacity?.dispose();
|
||||
};
|
||||
}, [currentWorkspace, isOwner, jumpToPricePlan, pushNotification, t]);
|
||||
|
||||
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]);
|
||||
}, [
|
||||
currentWorkspace,
|
||||
isOwner,
|
||||
jumpToPricePlan,
|
||||
pushNotification,
|
||||
setSyncEngineStatus,
|
||||
t,
|
||||
]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
// TODO: add i18n
|
||||
|
||||
@@ -115,7 +115,7 @@ export const RootAppSidebar = ({
|
||||
|
||||
const onClickNewPage = useAsyncCallback(async () => {
|
||||
const page = createPage();
|
||||
await page.waitForLoaded();
|
||||
page.waitForLoaded();
|
||||
openPage(page.id);
|
||||
}, [createPage, openPage]);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { __unstableSchemas, AffineSchemas } from '@blocksuite/blocks/models';
|
||||
import { Schema, Workspace } from '@blocksuite/store';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
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 { useBlockSuiteWorkspaceHelper } from '../use-block-suite-workspace-helper';
|
||||
@@ -17,18 +17,26 @@ let blockSuiteWorkspace: Workspace;
|
||||
const schema = new Schema();
|
||||
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 () => {
|
||||
blockSuiteWorkspace = new Workspace({
|
||||
id: 'test',
|
||||
schema,
|
||||
});
|
||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
||||
await initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
||||
|
||||
blockSuiteWorkspace.doc.emit('sync', []);
|
||||
|
||||
initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page0' }));
|
||||
initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page1' }));
|
||||
initEmptyPage(blockSuiteWorkspace.createPage({ id: 'page2' }));
|
||||
});
|
||||
|
||||
describe('useBlockSuiteWorkspaceHelper', () => {
|
||||
test('should create page', () => {
|
||||
test('should create page', async () => {
|
||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||
const helperHook = renderHook(() =>
|
||||
useBlockSuiteWorkspaceHelper(blockSuiteWorkspace)
|
||||
@@ -36,6 +44,7 @@ describe('useBlockSuiteWorkspaceHelper', () => {
|
||||
const pageMetaHook = renderHook(() =>
|
||||
useBlockSuitePageMeta(blockSuiteWorkspace)
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve));
|
||||
expect(pageMetaHook.result.current.length).toBe(3);
|
||||
expect(blockSuiteWorkspace.meta.pageMetas.length).toBe(3);
|
||||
const page = helperHook.result.current.createPage('page4');
|
||||
|
||||
@@ -54,7 +54,7 @@ beforeEach(async () => {
|
||||
blockSuiteWorkspace.doc.emit('sync', []);
|
||||
|
||||
const initPage = async (page: Page) => {
|
||||
await page.waitForLoaded();
|
||||
page.load();
|
||||
expect(page).not.toBeNull();
|
||||
assertExists(page);
|
||||
const pageBlockId = page.addBlock('affine:page', {
|
||||
|
||||
@@ -147,12 +147,12 @@ export function useBlockSuiteMetaHelper(
|
||||
);
|
||||
|
||||
const duplicate = useAsyncCallback(
|
||||
async (pageId: string) => {
|
||||
async (pageId: string, openPageAfterDuplication: boolean = true) => {
|
||||
const currentPageMeta = getPageMeta(pageId);
|
||||
const newPage = createPage();
|
||||
const currentPage = blockSuiteWorkspace.getPage(pageId);
|
||||
|
||||
await newPage.waitForLoaded();
|
||||
newPage.waitForLoaded();
|
||||
if (!currentPageMeta || !currentPage) {
|
||||
return;
|
||||
}
|
||||
@@ -164,9 +164,18 @@ export function useBlockSuiteMetaHelper(
|
||||
tags: currentPageMeta.tags,
|
||||
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);
|
||||
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
|
||||
openPage(blockSuiteWorkspace.id, newPage.id);
|
||||
setPageTitle(newPage.id, newPageTitle);
|
||||
|
||||
openPageAfterDuplication && openPage(blockSuiteWorkspace.id, newPage.id);
|
||||
},
|
||||
[
|
||||
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 { useMemo } from 'react';
|
||||
|
||||
import { useJournalHelper } from './use-journal';
|
||||
|
||||
const weakMap = new WeakMap<Workspace, Atom<PageMeta[]>>();
|
||||
|
||||
export function useBlockSuitePageMeta(
|
||||
export function useAllBlockSuitePageMeta(
|
||||
blockSuiteWorkspace: Workspace
|
||||
): PageMeta[] {
|
||||
if (!weakMap.has(blockSuiteWorkspace)) {
|
||||
@@ -26,6 +28,18 @@ export function useBlockSuitePageMeta(
|
||||
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) {
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const logger = new DebugLogger('use-block-suite-workspace-page');
|
||||
|
||||
export function useBlockSuiteWorkspacePage(
|
||||
blockSuiteWorkspace: Workspace,
|
||||
pageId: string | null
|
||||
@@ -36,9 +33,7 @@ export function useBlockSuiteWorkspacePage(
|
||||
|
||||
useEffect(() => {
|
||||
if (page && !page.loaded) {
|
||||
page.load().catch(err => {
|
||||
logger.error('Failed to load page', err);
|
||||
});
|
||||
page.load();
|
||||
}
|
||||
}, [page]);
|
||||
|
||||
|
||||
@@ -33,9 +33,11 @@ export const useJournalHelper = (workspace: BlockSuiteWorkspace) => {
|
||||
(maybeDate: MaybeDate) => {
|
||||
const title = dayjs(maybeDate).format(JOURNAL_DATE_FORMAT);
|
||||
const page = bsWorkspaceHelper.createPage();
|
||||
initEmptyPage(page, title).catch(err =>
|
||||
console.error('Failed to load journal page', err)
|
||||
);
|
||||
// set created date to match the journal date
|
||||
page.workspace.setPageMeta(page.id, {
|
||||
createDate: dayjs(maybeDate).toDate().getTime(),
|
||||
});
|
||||
initEmptyPage(page, title);
|
||||
adapter.setJournalPageDateString(page.id, title);
|
||||
return page;
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ export const Component = () => {
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const list = useAtomValue(workspaceListAtom);
|
||||
|
||||
const { openPage } = useNavigateHelper();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
|
||||
@@ -74,7 +74,7 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
|
||||
|
||||
if (urlToOpen && lastOpened !== urlToOpen && autoOpen) {
|
||||
lastOpened = urlToOpen;
|
||||
open(urlToOpen, '_blank');
|
||||
location.href = urlToOpen;
|
||||
}
|
||||
|
||||
if (!urlToOpen) {
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
||||
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 { useWorkspaceStatus } from '@affine/core/hooks/use-workspace-status';
|
||||
import { waitForCurrentWorkspaceAtom } from '@affine/core/modules/workspace';
|
||||
import { WorkspaceSubPath } from '@affine/core/shared';
|
||||
import { globalBlockSuiteSchema, SyncEngineStep } from '@affine/workspace';
|
||||
import {
|
||||
BookmarkService,
|
||||
customImageProxyMiddleware,
|
||||
EmbedGithubService,
|
||||
EmbedLoomService,
|
||||
EmbedYoutubeService,
|
||||
ImageService,
|
||||
type PageService,
|
||||
} from '@blocksuite/blocks';
|
||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { appSettingAtom } from '@toeverything/infra/atom';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { appSettingAtom } from '@toeverything/infra';
|
||||
import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||
import {
|
||||
memo,
|
||||
type ReactElement,
|
||||
@@ -25,8 +30,6 @@ import {
|
||||
import { useParams } from 'react-router-dom';
|
||||
import type { Map as YMap } from 'yjs';
|
||||
|
||||
import { setPageModeAtom } from '../../../atoms';
|
||||
import { collectionsCRUDAtom } from '../../../atoms/collections';
|
||||
import { currentModeAtom, currentPageIdAtom } from '../../../atoms/mode';
|
||||
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
|
||||
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 { usePageDocumentTitle } from '../../../hooks/use-global-state';
|
||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||
import { performanceRenderLogger } from '../../../shared';
|
||||
import { performanceRenderLogger, WorkspaceSubPath } from '../../../shared';
|
||||
import { PageNotFound } from '../../404';
|
||||
import * as styles from './detail-page.css';
|
||||
import { DetailPageHeader, RightSidebarHeader } from './detail-page-header';
|
||||
@@ -121,6 +124,7 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) {
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
useRegisterBlocksuiteEditorCommands(currentPageId, mode);
|
||||
usePageDocumentTitle(pageMeta);
|
||||
const rootStore = useStore();
|
||||
|
||||
const onLoad = useCallback(
|
||||
(page: Page, editor: AffineEditorContainer) => {
|
||||
@@ -144,11 +148,35 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
ImageService.setImageProxyURL(runtimeConfig.imageProxyUrl);
|
||||
BookmarkService.setLinkPreviewEndpoint(runtimeConfig.linkPreviewUrl);
|
||||
editor.host?.std.clipboard.use(
|
||||
// blocksuite editor host
|
||||
const editorHost = editor.host;
|
||||
|
||||
// provide image proxy endpoint to blocksuite
|
||||
editorHost.std.clipboard.use(
|
||||
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);
|
||||
// fixme: it seems pageLinkClicked is not triggered sometimes?
|
||||
@@ -173,6 +201,7 @@ const DetailPageImpl = memo(function DetailPageImpl({ page }: { page: Page }) {
|
||||
openPage,
|
||||
setPageMode,
|
||||
setTemporaryFilter,
|
||||
rootStore,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@affine/component';
|
||||
import { MoveToTrash } from '@affine/core/components/page-list';
|
||||
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 {
|
||||
useJournalHelper,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
useJournalRouteHelper,
|
||||
} from '@affine/core/hooks/use-journal';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import type { BlockSuiteWorkspace } from '@affine/core/shared';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
EdgelessIcon,
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
PageIcon,
|
||||
TodayIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import type { Page, PageMeta } from '@blocksuite/store';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import clsx from 'clsx';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -41,21 +43,28 @@ const CountDisplay = ({
|
||||
return <span {...attrs}>{count > max ? `${max}+` : count}</span>;
|
||||
};
|
||||
interface PageItemProps extends HTMLAttributes<HTMLDivElement> {
|
||||
page: Page;
|
||||
pageMeta: PageMeta;
|
||||
workspace: BlockSuiteWorkspace;
|
||||
right?: ReactNode;
|
||||
}
|
||||
const PageItem = ({ page, right, className, ...attrs }: PageItemProps) => {
|
||||
const { isJournal } = useJournalInfoHelper(page.workspace, page.id);
|
||||
const title = useBlockSuiteWorkspacePageTitle(page.workspace, page.id);
|
||||
const PageItem = ({
|
||||
pageMeta,
|
||||
workspace,
|
||||
right,
|
||||
className,
|
||||
...attrs
|
||||
}: PageItemProps) => {
|
||||
const { isJournal } = useJournalInfoHelper(workspace, pageMeta.id);
|
||||
const title = useBlockSuiteWorkspacePageTitle(workspace, pageMeta.id);
|
||||
|
||||
const Icon = isJournal
|
||||
? TodayIcon
|
||||
: page.meta.mode === 'edgeless'
|
||||
: pageMeta.mode === 'edgeless'
|
||||
? EdgelessIcon
|
||||
: PageIcon;
|
||||
return (
|
||||
<div
|
||||
aria-label={page.meta.title}
|
||||
aria-label={pageMeta.title}
|
||||
className={clsx(className, styles.pageItem)}
|
||||
{...attrs}
|
||||
>
|
||||
@@ -114,15 +123,12 @@ const EditorJournalPanel = (props: EditorExtensionProps) => {
|
||||
};
|
||||
|
||||
const sortPagesByDate = (
|
||||
pages: Page[],
|
||||
pages: PageMeta[],
|
||||
field: 'updatedDate' | 'createDate',
|
||||
order: 'asc' | 'desc' = 'desc'
|
||||
) => {
|
||||
return [...pages].sort((a, b) => {
|
||||
return (
|
||||
(order === 'asc' ? 1 : -1) *
|
||||
dayjs(b.meta[field]).diff(dayjs(a.meta[field]))
|
||||
);
|
||||
return (order === 'asc' ? 1 : -1) * dayjs(b[field]).diff(dayjs(a[field]));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -141,21 +147,21 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const t = useAFFiNEI18N();
|
||||
const [activeItem, setActiveItem] = useState<NavItemName>('createdToday');
|
||||
const pageMetas = useBlockSuitePageMeta(workspace);
|
||||
|
||||
const navigateHelper = useNavigateHelper();
|
||||
|
||||
const getTodaysPages = useCallback(
|
||||
(field: 'createDate' | 'updatedDate') => {
|
||||
const pages: Page[] = [];
|
||||
Array.from(workspace.pages.values()).forEach(page => {
|
||||
if (page.meta.trash) return;
|
||||
if (page.meta[field] && dayjs(page.meta[field]).isSame(date, 'day')) {
|
||||
pages.push(page);
|
||||
}
|
||||
});
|
||||
return sortPagesByDate(pages, field);
|
||||
return sortPagesByDate(
|
||||
pageMetas.filter(pageMeta => {
|
||||
if (pageMeta.trash) return false;
|
||||
return pageMeta[field] && dayjs(pageMeta[field]).isSame(date, 'day');
|
||||
}),
|
||||
field
|
||||
);
|
||||
},
|
||||
[date, workspace.pages]
|
||||
[date, pageMetas]
|
||||
);
|
||||
|
||||
const createdToday = useMemo(
|
||||
@@ -224,14 +230,15 @@ const JournalDailyCountBlock = ({ workspace, date }: JournalBlockProps) => {
|
||||
<Scrollable.Scrollbar />
|
||||
<Scrollable.Viewport>
|
||||
<div className={styles.dailyCountContent} ref={nodeRef}>
|
||||
{renderList.map((page, index) => (
|
||||
{renderList.map((pageMeta, index) => (
|
||||
<PageItem
|
||||
onClick={() =>
|
||||
navigateHelper.openPage(workspace.id, page.id)
|
||||
navigateHelper.openPage(workspace.id, pageMeta.id)
|
||||
}
|
||||
tabIndex={name === activeItem ? 0 : -1}
|
||||
key={index}
|
||||
page={page}
|
||||
pageMeta={pageMeta}
|
||||
workspace={workspace}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -282,7 +289,8 @@ const ConflictList = ({
|
||||
<PageItem
|
||||
aria-label={page.meta.title}
|
||||
aria-selected={isCurrent}
|
||||
page={page}
|
||||
pageMeta={page.meta}
|
||||
workspace={workspace}
|
||||
key={page.id}
|
||||
right={
|
||||
<Menu
|
||||
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
} from '@affine/core/modules/workspace';
|
||||
import { type Workspace } from '@affine/workspace';
|
||||
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 { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
|
||||
@@ -67,12 +73,34 @@ export const Component = (): ReactElement => {
|
||||
localStorage.setItem('last_workspace_id', workspace.id);
|
||||
}, [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 === false && meta === undefined) {
|
||||
return <PageNotFound />;
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
if (!workspace || workspaceIsLoading) {
|
||||
return <WorkspaceFallback key="workspaceLoading" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ export const TrashPage = () => {
|
||||
permanentlyDeletePage(page.id);
|
||||
toast(t['com.affine.toastMessage.permanentlyDeleted']());
|
||||
};
|
||||
|
||||
return (
|
||||
<TrashOperationCell
|
||||
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