mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
feat: support web document and cypress test (#5116)
* feat: support web document and cypress test * fix: support blocks * fix: support table and outline * fix: update nginx
This commit is contained in:
parent
969726ef73
commit
9135fb94ad
2
.github/workflows/tauri2_ci.yaml
vendored
2
.github/workflows/tauri2_ci.yaml
vendored
@ -1,4 +1,4 @@
|
||||
name: Tauri2-CI
|
||||
name: Tauri-CI
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
|
9
.github/workflows/tauri_ci.yaml
vendored
9
.github/workflows/tauri_ci.yaml
vendored
@ -1,11 +1,8 @@
|
||||
name: Tauri-CI
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/tauri_ci.yaml"
|
||||
- "frontend/rust-lib/**"
|
||||
- "frontend/appflowy_tauri/**"
|
||||
- "frontend/resources/**"
|
||||
push:
|
||||
branches:
|
||||
- build/tauri
|
||||
|
||||
env:
|
||||
NODE_VERSION: "18.16.0"
|
||||
|
7
.github/workflows/web2_ci.yaml
vendored
7
.github/workflows/web2_ci.yaml
vendored
@ -1,4 +1,4 @@
|
||||
name: Web2-CI
|
||||
name: Web-CI
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
@ -17,14 +17,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ ubuntu-latest ]
|
||||
platform: [ ubuntu-20.04 ]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Maximize build space (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-latest'
|
||||
if: matrix.platform == 'ubuntu-20.04'
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
@ -56,6 +56,7 @@ jobs:
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
pnpm run lint
|
||||
pnpm run test:unit
|
||||
- name: build and analyze
|
||||
working-directory: frontend/appflowy_web_app
|
||||
run: |
|
||||
|
48
.github/workflows/web_cypress_ci.yaml
vendored
Normal file
48
.github/workflows/web_cypress_ci.yaml
vendored
Normal file
@ -0,0 +1,48 @@
|
||||
name: Cypress Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/web2_ci.yaml"
|
||||
- "frontend/appflowy_web_app/**"
|
||||
- "frontend/resources/**"
|
||||
env:
|
||||
NODE_VERSION: "18.16.0"
|
||||
PNPM_VERSION: "8.5.0"
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
cypress-run:
|
||||
if: github.event.pull_request.draft != true
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Maximize build space (ubuntu only)
|
||||
run: |
|
||||
sudo rm -rf /usr/share/dotnet
|
||||
sudo rm -rf /opt/ghc
|
||||
sudo rm -rf "/usr/local/share/boost"
|
||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||
sudo docker image prune --all --force
|
||||
sudo rm -rf /opt/hostedtoolcache/codeQL
|
||||
sudo rm -rf ${GITHUB_WORKSPACE}/.git
|
||||
sudo rm -rf $ANDROID_HOME/ndk
|
||||
- name: setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
- name: setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: ${{ env.PNPM_VERSION }}
|
||||
# Install pnpm dependencies, cache them correctly
|
||||
# and run all Cypress tests
|
||||
- name: Cypress run
|
||||
uses: cypress-io/github-action@v6
|
||||
with:
|
||||
working-directory: frontend/appflowy_web_app
|
||||
component: true
|
||||
build: pnpm run build
|
||||
start: pnpm run start
|
||||
browser: chrome
|
@ -14,10 +14,10 @@ export const DatabasePage = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex h-full w-full flex-col overflow-hidden caret-text-title'>
|
||||
<div className="flex h-full w-full flex-col overflow-hidden caret-text-title">
|
||||
<ViewIdProvider value={viewId}>
|
||||
<DatabaseTitle />
|
||||
<Database selectedViewId={selectedViewId} setSelectedViewId={onChange} />
|
||||
<DatabaseTitle/>
|
||||
<Database selectedViewId={selectedViewId} setSelectedViewId={onChange}/>
|
||||
</ViewIdProvider>
|
||||
</div>
|
||||
);
|
||||
|
@ -4,4 +4,6 @@ src-tauri/
|
||||
.eslintrc.cjs
|
||||
tsconfig.json
|
||||
**/backend/**
|
||||
vite.config.ts
|
||||
vite.config.ts
|
||||
**/*.cy.tsx
|
||||
*.config.ts
|
@ -186,7 +186,7 @@ Don't modify the theme file in `frontend/appflowy_web_app/src/styles/variables`
|
||||
// ...
|
||||
{
|
||||
find: '$client-services',
|
||||
replacement: process.env.TAURI_MODE
|
||||
replacement: !!process.env.TAURI_PLATFORM
|
||||
? `${__dirname}/src/application/services/tauri-services`
|
||||
: `${__dirname}/src/application/services/js-services`,
|
||||
},
|
||||
@ -207,7 +207,7 @@ Use the AppFlowy CI/CD pipeline to deploy the application to the test and produc
|
||||
- Enter the options
|
||||
- Click on the Run workflow button
|
||||
|
||||
#### 📦 Deployment (Self-Hosted)
|
||||
#### 📦 Deployment (Self-Hosted EC2)
|
||||
|
||||
##### Pre-requisites
|
||||
|
||||
@ -267,6 +267,18 @@ And then follow the steps below:
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
> To be Continued...
|
||||
> We use Cypress for end-to-end testing and component testing - [Cypress](https://www.cypress.io/)
|
||||
|
||||
#### 🧪 End-to-End Testing
|
||||
|
||||
> to be continued
|
||||
|
||||
#### 🧪 Component Testing
|
||||
|
||||
Run the following command to run the component tests
|
||||
|
||||
```bash
|
||||
pnpm run test:components
|
||||
```
|
||||
|
||||
|
||||
|
18
frontend/appflowy_web_app/cypress.config.ts
Normal file
18
frontend/appflowy_web_app/cypress.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'cypress';
|
||||
|
||||
export default defineConfig({
|
||||
component: {
|
||||
devServer: {
|
||||
framework: 'react',
|
||||
bundler: 'vite',
|
||||
},
|
||||
},
|
||||
retries: {
|
||||
// Configure retry attempts for `cypress run`
|
||||
// Default is 0
|
||||
runMode: 2,
|
||||
// Configure retry attempts for `cypress open`
|
||||
// Default is 0
|
||||
openMode: 0,
|
||||
},
|
||||
});
|
1
frontend/appflowy_web_app/cypress/fixtures/full_doc.json
Normal file
1
frontend/appflowy_web_app/cypress/fixtures/full_doc.json
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,66 @@
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI4Mjk2MjAsImlhdCI6MTcxMjgyNjAyMCwic3ViIjoiY2JmZjA2MGEtMTk2ZC00MTVhLWFhODAtNzU5YzAxODg2NDY2IiwiZW1haWwiOiJsdUBhcHBmbG93eS5pbyIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZ29vZ2xlIiwicHJvdmlkZXJzIjpbImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTEhabVZBczRTb0ZlVFFuWG5CU2JiNTBBVXF0YktHNWx5MGllVHZCSklYZ1o3UmdRPXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImFwcGZsb3d5LmlvIn0sImVtYWlsIjoibHVAYXBwZmxvd3kuaW8iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTHUgSGUiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTHUgSGUiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NMSFptVkFzNFNvRmVUUW5YbkJTYmI1MEFVcXRiS0c1bHkwaWVUdkJKSVhnWjdSZ1E9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSIsInN1YiI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3MTI4MjYwMjB9XSwic2Vzc2lvbl9pZCI6ImJmMzE5OTRlLTQwMTgtNDhjMS05Yzc0LWVmYzkyMGNjOWQ0NSJ9.QeTrRhsnBjBL1GUS3TIWOgU1SPM6RcaWwxZdMVfcFBU",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3600,
|
||||
"expires_at": 4869016461,
|
||||
"refresh_token": "71vp1jJnSAVluZKaXkhG1A",
|
||||
"user": {
|
||||
"id": "cbff060a-196d-415a-aa80-759c01886466",
|
||||
"aud": "",
|
||||
"role": "",
|
||||
"email": "lu@appflowy.io",
|
||||
"email_confirmed_at": "2024-03-13T10:49:53.165361Z",
|
||||
"phone": "",
|
||||
"confirmed_at": "2024-03-13T10:49:53.165361Z",
|
||||
"last_sign_in_at": "2024-04-11T09:00:20.547468985Z",
|
||||
"app_metadata": {
|
||||
"provider": "google",
|
||||
"providers": [
|
||||
"google"
|
||||
]
|
||||
},
|
||||
"user_metadata": {
|
||||
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"custom_claims": {
|
||||
"hd": "appflowy.io"
|
||||
},
|
||||
"email": "lu@appflowy.io",
|
||||
"email_verified": true,
|
||||
"full_name": "Lu He",
|
||||
"iss": "https://accounts.google.com",
|
||||
"name": "Lu He",
|
||||
"phone_verified": false,
|
||||
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"provider_id": "101169250829554028381",
|
||||
"sub": "101169250829554028381"
|
||||
},
|
||||
"identities": [
|
||||
{
|
||||
"identity_id": "e4cf8b69-7f80-42e9-aed2-e25132ad0178",
|
||||
"id": "101169250829554028381",
|
||||
"user_id": "cbff060a-196d-415a-aa80-759c01886466",
|
||||
"identity_data": {
|
||||
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"custom_claims": {
|
||||
"hd": "appflowy.io"
|
||||
},
|
||||
"email": "lu@appflowy.io",
|
||||
"email_verified": true,
|
||||
"full_name": "Lu He",
|
||||
"iss": "https://accounts.google.com",
|
||||
"name": "Lu He",
|
||||
"phone_verified": false,
|
||||
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||
"provider_id": "101169250829554028381",
|
||||
"sub": "101169250829554028381"
|
||||
},
|
||||
"provider": "google",
|
||||
"last_sign_in_at": "2024-03-13T07:22:43.110504Z",
|
||||
"created_at": "2024-03-13T07:22:43.110543Z",
|
||||
"updated_at": "2024-04-04T06:15:14.03093Z"
|
||||
}
|
||||
],
|
||||
"created_at": "2024-03-13T07:22:43.102586Z",
|
||||
"updated_at": "2024-04-11T09:00:20.551485Z"
|
||||
}
|
||||
}
|
File diff suppressed because one or more lines are too long
17
frontend/appflowy_web_app/cypress/fixtures/user.json
Normal file
17
frontend/appflowy_web_app/cypress/fixtures/user.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"data": {
|
||||
"uid": 304120109071339520,
|
||||
"uuid": "cbff060a-196d-415a-aa80-759c01886466",
|
||||
"email": "lu@appflowy.io",
|
||||
"password": "",
|
||||
"name": "Kilu",
|
||||
"metadata": {
|
||||
"icon_url": "🇽🇰"
|
||||
},
|
||||
"encryption_sign": null,
|
||||
"latest_workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968",
|
||||
"updated_at": 1710421586
|
||||
},
|
||||
"code": 0,
|
||||
"message": "Operation completed successfully."
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"is_new": false
|
||||
}
|
||||
}
|
46
frontend/appflowy_web_app/cypress/support/commands.ts
Normal file
46
frontend/appflowy_web_app/cypress/support/commands.ts
Normal file
@ -0,0 +1,46 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
Cypress.Commands.add('mockAPI', () => {
|
||||
cy.fixture('sign_in_success').then((json) => {
|
||||
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
|
||||
fixture: 'verify_token',
|
||||
}).as('verifyToken');
|
||||
cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess');
|
||||
cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken');
|
||||
});
|
||||
cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile');
|
||||
});
|
||||
|
||||
// Example use:
|
||||
// beforeEach(() => {
|
||||
// cy.mockAPI();
|
||||
// });
|
||||
|
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Components App</title>
|
||||
</head>
|
||||
<body id="body">
|
||||
<div data-cy-root></div>
|
||||
</body>
|
||||
</html>
|
42
frontend/appflowy_web_app/cypress/support/component.ts
Normal file
42
frontend/appflowy_web_app/cypress/support/component.ts
Normal file
@ -0,0 +1,42 @@
|
||||
// ***********************************************************
|
||||
// This example support/component.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands';
|
||||
import './document';
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
|
||||
import { mount } from 'cypress/react18';
|
||||
|
||||
// Augment the Cypress namespace to include type definitions for
|
||||
// your custom command.
|
||||
// Alternatively, can be defined in cypress/support/component.d.ts
|
||||
// with a <reference path="./component" /> at the top of your spec.
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
mount: typeof mount;
|
||||
mockAPI: () => void;
|
||||
mockFullDocument: () => void;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add('mount', mount);
|
||||
|
||||
// Example use:
|
||||
// cy.mount(<MyComponent />)
|
88
frontend/appflowy_web_app/cypress/support/document.ts
Normal file
88
frontend/appflowy_web_app/cypress/support/document.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { BlockId, BlockType, YBlocks, YChildrenMap, YjsEditorKey, YTextMap } from '@/application/document.type';
|
||||
import { applyDocument } from 'src/application/ydoc/apply';
|
||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||
import { nanoid } from 'nanoid';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
Cypress.Commands.add('mockFullDocument', () => {
|
||||
cy.fixture('full_doc').then((docJson) => {
|
||||
const collab = new Y.Doc();
|
||||
const state = new Uint8Array(docJson.data.doc_state);
|
||||
|
||||
applyDocument(collab, state);
|
||||
|
||||
cy.stub(JSDocumentService.prototype, 'openDocument').returns(Promise.resolve(collab));
|
||||
});
|
||||
});
|
||||
|
||||
export class DocumentTest {
|
||||
public doc: Y.Doc;
|
||||
|
||||
private blocks: YBlocks;
|
||||
|
||||
private childrenMap: YChildrenMap;
|
||||
|
||||
private textMap: YTextMap;
|
||||
|
||||
private pageId: string;
|
||||
|
||||
constructor() {
|
||||
const doc = new Y.Doc();
|
||||
|
||||
this.doc = doc;
|
||||
const collab = doc.getMap(YjsEditorKey.data_section);
|
||||
const document = new Y.Map();
|
||||
const blocks = new Y.Map() as YBlocks;
|
||||
const pageId = nanoid(8);
|
||||
const meta = new Y.Map();
|
||||
const childrenMap = new Y.Map() as YChildrenMap;
|
||||
const textMap = new Y.Map() as YTextMap;
|
||||
|
||||
const block = new Y.Map();
|
||||
|
||||
block.set(YjsEditorKey.block_id, pageId);
|
||||
block.set(YjsEditorKey.block_type, BlockType.Page);
|
||||
block.set(YjsEditorKey.block_children, pageId);
|
||||
block.set(YjsEditorKey.block_external_id, pageId);
|
||||
block.set(YjsEditorKey.block_external_type, YjsEditorKey.text);
|
||||
block.set(YjsEditorKey.block_data, '');
|
||||
blocks.set(pageId, block);
|
||||
|
||||
document.set(YjsEditorKey.page_id, pageId);
|
||||
document.set(YjsEditorKey.blocks, blocks);
|
||||
document.set(YjsEditorKey.meta, meta);
|
||||
meta.set(YjsEditorKey.children_map, childrenMap);
|
||||
meta.set(YjsEditorKey.text_map, textMap);
|
||||
collab.set(YjsEditorKey.document, document);
|
||||
|
||||
this.blocks = blocks;
|
||||
this.childrenMap = childrenMap;
|
||||
this.textMap = textMap;
|
||||
this.pageId = pageId;
|
||||
}
|
||||
|
||||
insertParagraph(text: string) {
|
||||
const blockId = nanoid(8);
|
||||
const block = new Y.Map();
|
||||
|
||||
block.set(YjsEditorKey.block_id, blockId);
|
||||
block.set(YjsEditorKey.block_type, BlockType.Paragraph);
|
||||
block.set(YjsEditorKey.block_children, blockId);
|
||||
block.set(YjsEditorKey.block_external_id, blockId);
|
||||
block.set(YjsEditorKey.block_external_type, YjsEditorKey.text);
|
||||
block.set(YjsEditorKey.block_parent, this.pageId);
|
||||
block.set(YjsEditorKey.block_data, '');
|
||||
this.blocks.set(blockId, block);
|
||||
const pageChildren = this.childrenMap.get(this.pageId) ?? new Y.Array<BlockId>();
|
||||
|
||||
pageChildren.push([blockId]);
|
||||
this.childrenMap.set(this.pageId, pageChildren);
|
||||
|
||||
const blockText = new Y.Text();
|
||||
|
||||
blockText.insert(0, text);
|
||||
this.textMap.set(blockId, blockText);
|
||||
|
||||
return blockText;
|
||||
}
|
||||
}
|
@ -1,16 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/appflowy.svg"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
||||
</style>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/appflowy.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AppFlowy</title>
|
||||
</head>
|
||||
<body id="body">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
20
frontend/appflowy_web_app/jest.config.cjs
Normal file
20
frontend/appflowy_web_app/jest.config.cjs
Normal file
@ -0,0 +1,20 @@
|
||||
const { compilerOptions } = require('./tsconfig.json');
|
||||
const { pathsToModuleNameMapper } = require('ts-jest');
|
||||
const esModules = ['lodash-es', 'nanoid'].join('|');
|
||||
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>'],
|
||||
modulePaths: [compilerOptions.baseUrl],
|
||||
moduleNameMapper: {
|
||||
...pathsToModuleNameMapper(compilerOptions.paths),
|
||||
'^lodash-es(/(.*)|$)': 'lodash$1',
|
||||
'^nanoid(/(.*)|$)': 'nanoid$1',
|
||||
},
|
||||
'transform': {
|
||||
'(.*)/node_modules/nanoid/.+\\.(j|t)sx?$': 'ts-jest',
|
||||
},
|
||||
'transformIgnorePatterns': [`/node_modules/(?!${esModules})`],
|
||||
};
|
@ -63,6 +63,19 @@ http {
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /static/ {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location /appflowy.svg {
|
||||
root /usr/share/nginx/html;
|
||||
expires 30d;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
|
@ -5,27 +5,31 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpm run sync:i18n && vite",
|
||||
"dev:tauri": "TAURI_MODE=true pnpm run sync:i18n && vite",
|
||||
"build": "vite build",
|
||||
"build:tauri": "TAURI_MODE=true vite build",
|
||||
"lint:tauri": "TAURI_MODE=true pnpm run sync:i18n && tsc --noEmit && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore",
|
||||
"dev:tauri": "pnpm run sync:i18n && vite",
|
||||
"build": "pnpm run sync:i18n && vite build",
|
||||
"build:tauri": "vite build",
|
||||
"lint:tauri": "pnpm run sync:i18n && tsc --noEmit && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore",
|
||||
"lint": "pnpm run sync:i18n && tsc --noEmit --project tsconfig.web.json && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore.web",
|
||||
"preview": "vite preview --port 8080",
|
||||
"start": "vite preview --port 3000",
|
||||
"tauri:dev": "tauri dev",
|
||||
"css:variables": "node style-dictionary/config.cjs",
|
||||
"sync:i18n": "node scripts/i18n.cjs",
|
||||
"link:client-api": "rm -rf node_modules/.vite && node scripts/create-symlink.cjs",
|
||||
"analyze": "ANALYZE_MODE=true vite build"
|
||||
"analyze": "cross-env ANALYZE_MODE=true vite build",
|
||||
"cypress:open": "cypress open",
|
||||
"test": "pnpm run test:unit && pnpm run test:components",
|
||||
"test:components": "cypress run --component --browser chrome --headless",
|
||||
"test:unit": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@appflowyinc/client-api-wasm": "^0.0.2",
|
||||
"@appflowyinc/client-api-wasm": "0.0.2-alpha.2",
|
||||
"@atlaskit/primitives": "^5.5.3",
|
||||
"@emoji-mart/data": "^1.1.2",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@emotion/react": "^11.10.6",
|
||||
"@emotion/styled": "^11.10.6",
|
||||
"@mui/icons-material": "^5.11.11",
|
||||
"@mui/material": "^5.11.12",
|
||||
"@mui/system": "^5.14.4",
|
||||
"@mui/material": "6.0.0-alpha.2",
|
||||
"@mui/x-date-pickers-pro": "^6.18.2",
|
||||
"@reduxjs/toolkit": "2.0.0",
|
||||
"@slate-yjs/core": "^1.0.2",
|
||||
@ -33,6 +37,7 @@
|
||||
"@types/react-swipeable-views": "^0.13.4",
|
||||
"axios": "^1.6.8",
|
||||
"dayjs": "^1.11.9",
|
||||
"dexie": "^4.0.1",
|
||||
"emoji-mart": "^5.5.2",
|
||||
"emoji-regex": "^10.2.1",
|
||||
"events": "^3.3.0",
|
||||
@ -80,7 +85,8 @@
|
||||
"utf8": "^3.0.0",
|
||||
"valtio": "^1.12.1",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"yjs": "^13.5.51"
|
||||
"y-indexeddb": "9.0.12",
|
||||
"yjs": "^13.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@svgr/plugin-svgo": "^8.0.1",
|
||||
@ -110,6 +116,8 @@
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-jest": "^29.6.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"cypress": "^13.7.2",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,35 +0,0 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './stores/store';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorHandlerPage } from './components/error/ErrorHandlerPage';
|
||||
import '@/i18n/config';
|
||||
import AppTheme from '@/AppTheme';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import ProtectedRoutes from '@/components/auth/ProtectedRoutes';
|
||||
import AppConfig from '@/AppConfig';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<AppTheme>
|
||||
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
|
||||
<AppConfig>
|
||||
<Routes>
|
||||
<Route path={'/'} element={<ProtectedRoutes />}>
|
||||
{/*<Route path={'/page/document/:id'} element={<DocumentPage />} />*/}
|
||||
{/*<Route path={'/page/grid/:id'} element={<DatabasePage />} />*/}
|
||||
{/*<Route path={'/trash'} id={'trash'} element={<TrashPage />} />*/}
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</AppConfig>
|
||||
</ErrorBoundary>
|
||||
</AppTheme>
|
||||
</Provider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
@ -1,178 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import createTheme from '@mui/material/styles/createTheme';
|
||||
import ThemeProvider from '@mui/material/styles/ThemeProvider';
|
||||
|
||||
function AppTheme ({ children }: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const isDark = false;
|
||||
const theme = useMemo(() => createTheme({
|
||||
typography: {
|
||||
fontFamily: ['Poppins'].join(','),
|
||||
fontSize: 12,
|
||||
button: {
|
||||
textTransform: 'none',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiMenuItem: {
|
||||
defaultProps: {
|
||||
sx: {
|
||||
'&.Mui-selected.Mui-focusVisible': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
'&.Mui-focusVisible': {
|
||||
backgroundColor: 'unset',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
contained: {
|
||||
color: 'var(--content-on-fill)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
containedPrimary: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-default)',
|
||||
},
|
||||
},
|
||||
containedInherit: {
|
||||
color: 'var(--text-title)',
|
||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--bg-body)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
},
|
||||
outlinedInherit: {
|
||||
color: 'var(--text-title)',
|
||||
borderColor: 'var(--line-border)',
|
||||
'&:hover': {
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
sx: {
|
||||
'&.Mui-selected:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
},
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
sx: {
|
||||
'& .MuiBackdrop-root': {
|
||||
backgroundColor: 'var(--bg-mask)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
arrow: {
|
||||
color: 'var(--bg-tips)',
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'var(--bg-tips)',
|
||||
color: 'var(--text-title)',
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
input: {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDivider: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderColor: 'var(--line-divider)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
mode: isDark ? 'dark' : 'light',
|
||||
primary: {
|
||||
main: '#00BCF0',
|
||||
dark: '#00BCF0',
|
||||
},
|
||||
error: {
|
||||
main: '#FB006D',
|
||||
dark: '#D32772',
|
||||
},
|
||||
warning: {
|
||||
main: '#FFC107',
|
||||
dark: '#E9B320',
|
||||
},
|
||||
info: {
|
||||
main: '#00BCF0',
|
||||
dark: '#2E9DBB',
|
||||
},
|
||||
success: {
|
||||
main: '#66CF80',
|
||||
dark: '#3BA856',
|
||||
},
|
||||
text: {
|
||||
primary: isDark ? '#E2E9F2' : '#333333',
|
||||
secondary: isDark ? '#7B8A9D' : '#828282',
|
||||
disabled: isDark ? '#363D49' : '#F2F2F2',
|
||||
},
|
||||
divider: isDark ? '#59647A' : '#BDBDBD',
|
||||
background: {
|
||||
default: isDark ? '#1A202C' : '#FFFFFF',
|
||||
paper: isDark ? '#1A202C' : '#FFFFFF',
|
||||
},
|
||||
},
|
||||
|
||||
}), [isDark]);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppTheme;
|
14
frontend/appflowy_web_app/src/application/collab.type.ts
Normal file
14
frontend/appflowy_web_app/src/application/collab.type.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export enum CollabType {
|
||||
Document = 0,
|
||||
Database = 1,
|
||||
WorkspaceDatabase = 2,
|
||||
Folder = 3,
|
||||
DatabaseRow = 4,
|
||||
UserAwareness = 5,
|
||||
Empty = 6,
|
||||
}
|
||||
|
||||
export enum CollabOrigin {
|
||||
Local = 'local',
|
||||
Remote = 'remote',
|
||||
}
|
2
frontend/appflowy_web_app/src/application/constants.ts
Normal file
2
frontend/appflowy_web_app/src/application/constants.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const databasePrefix = 'af_database';
|
||||
|
176
frontend/appflowy_web_app/src/application/document.type.ts
Normal file
176
frontend/appflowy_web_app/src/application/document.type.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import Y from 'yjs';
|
||||
|
||||
export type BlockId = string;
|
||||
|
||||
export type ExternalId = string;
|
||||
|
||||
export type ChildrenId = string;
|
||||
|
||||
export enum BlockType {
|
||||
Paragraph = 'paragraph',
|
||||
Page = 'page',
|
||||
HeadingBlock = 'heading',
|
||||
TodoListBlock = 'todo_list',
|
||||
BulletedListBlock = 'bulleted_list',
|
||||
NumberedListBlock = 'numbered_list',
|
||||
ToggleListBlock = 'toggle_list',
|
||||
CodeBlock = 'code',
|
||||
EquationBlock = 'math_equation',
|
||||
QuoteBlock = 'quote',
|
||||
CalloutBlock = 'callout',
|
||||
DividerBlock = 'divider',
|
||||
ImageBlock = 'image',
|
||||
GridBlock = 'grid',
|
||||
OutlineBlock = 'outline',
|
||||
TableBlock = 'table',
|
||||
TableCell = 'table/cell',
|
||||
}
|
||||
|
||||
export enum InlineBlockType {
|
||||
Formula = 'formula',
|
||||
Mention = 'mention',
|
||||
}
|
||||
|
||||
export enum AlignType {
|
||||
Left = 'left',
|
||||
Center = 'center',
|
||||
Right = 'right',
|
||||
}
|
||||
|
||||
export interface BlockData {
|
||||
bg_color?: string;
|
||||
font_color?: string;
|
||||
align?: AlignType;
|
||||
}
|
||||
|
||||
export interface HeadingBlockData extends BlockData {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export interface NumberedListBlockData extends BlockData {
|
||||
number: number;
|
||||
}
|
||||
|
||||
export interface TodoListBlockData extends BlockData {
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
export interface ToggleListBlockData extends BlockData {
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface CodeBlockData extends BlockData {
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface CalloutBlockData extends BlockData {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface MathEquationBlockData extends BlockData {
|
||||
formula?: string;
|
||||
}
|
||||
|
||||
export enum ImageType {
|
||||
Local = 0,
|
||||
Internal = 1,
|
||||
External = 2,
|
||||
}
|
||||
|
||||
export interface ImageBlockData extends BlockData {
|
||||
url?: string;
|
||||
width?: number;
|
||||
align?: AlignType;
|
||||
image_type?: ImageType;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface OutlineBlockData extends BlockData {
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
export interface TableBlockData extends BlockData {
|
||||
colDefaultWidth: number;
|
||||
colMinimumWidth: number;
|
||||
colsHeight: number;
|
||||
colsLen: number;
|
||||
rowDefaultHeight: number;
|
||||
rowsLen: number;
|
||||
}
|
||||
|
||||
export interface TableCellBlockData extends BlockData {
|
||||
colPosition: number;
|
||||
height: number;
|
||||
rowPosition: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export enum MentionType {
|
||||
PageRef = 'page',
|
||||
Date = 'date',
|
||||
}
|
||||
|
||||
export interface Mention {
|
||||
// inline page ref id
|
||||
page_id?: string;
|
||||
// reminder date ref id
|
||||
date?: string;
|
||||
|
||||
type: MentionType;
|
||||
}
|
||||
|
||||
export enum YjsEditorKey {
|
||||
data_section = 'data',
|
||||
document = 'document',
|
||||
database = 'database',
|
||||
workspace_database = 'databases',
|
||||
folder = 'folder',
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
database_row = 'data',
|
||||
user_awareness = 'user_awareness',
|
||||
blocks = 'blocks',
|
||||
page_id = 'page_id',
|
||||
meta = 'meta',
|
||||
children_map = 'children_map',
|
||||
text_map = 'text_map',
|
||||
text = 'text',
|
||||
delta = 'delta',
|
||||
|
||||
block_id = 'id',
|
||||
block_type = 'ty',
|
||||
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||
block_data = 'data',
|
||||
block_parent = 'parent',
|
||||
block_children = 'children',
|
||||
block_external_id = 'external_id',
|
||||
block_external_type = 'external_type',
|
||||
}
|
||||
|
||||
export interface YDoc extends Y.Doc {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
get(key: YjsEditorKey.data_section | string): YSharedRoot | any;
|
||||
}
|
||||
|
||||
export interface YSharedRoot extends Y.Map<unknown> {
|
||||
get(key: YjsEditorKey.document): YDocument;
|
||||
}
|
||||
|
||||
export interface YDocument extends Y.Map<unknown> {
|
||||
get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string;
|
||||
}
|
||||
|
||||
export interface YBlocks extends Y.Map<unknown> {
|
||||
get(key: BlockId): Y.Map<unknown>;
|
||||
}
|
||||
|
||||
export interface YMeta extends Y.Map<unknown> {
|
||||
get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap;
|
||||
}
|
||||
|
||||
export interface YChildrenMap extends Y.Map<unknown> {
|
||||
get(key: ChildrenId): Y.Array<BlockId>;
|
||||
}
|
||||
|
||||
export interface YTextMap extends Y.Map<unknown> {
|
||||
get(key: ExternalId): Y.Text;
|
||||
}
|
@ -3,10 +3,9 @@ import { AFClientService } from '$client-services';
|
||||
|
||||
let service: AFService;
|
||||
|
||||
export async function getService(config: AFServiceConfig) {
|
||||
export async function getService (config: AFServiceConfig) {
|
||||
if (service) return service;
|
||||
|
||||
service = new AFClientService(config);
|
||||
await service.load();
|
||||
return service;
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { AuthService } from '@/application/services/services.type';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/services/user.type';
|
||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
||||
import { ACCESS_TOKEN_NAME, REFRESH_TOKEN_NAME, TOKEN_TYPE_NAME } from '@/application/services/js-services/http/const';
|
||||
import { AFWasmService } from '@/application/services/wasm-services';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams } from '@/application/user.type';
|
||||
import { APIService } from 'src/application/services/js-services/wasm';
|
||||
import { signInSuccess } from '@/application/services/js-services/storage/auth';
|
||||
import { invalidToken } from '@/application/services/js-services/storage';
|
||||
import { afterSignInDecorator } from '@/application/services/js-services/decorator';
|
||||
|
||||
export class JSAuthService implements AuthService {
|
||||
|
||||
constructor (private httpClient: HttpClient, private wasmService: AFWasmService) {
|
||||
constructor() {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
@ -14,32 +14,26 @@ export class JSAuthService implements AuthService {
|
||||
return Promise.reject('Not implemented');
|
||||
};
|
||||
|
||||
signInWithOAuth = async ({ uri }: { uri: string }): Promise<UserProfile> => {
|
||||
const params = uri.split('#')[1].split('&');
|
||||
const data: Record<string, string> = {};
|
||||
@afterSignInDecorator(signInSuccess)
|
||||
async signInWithOAuth(_: { uri: string }): Promise<void> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
|
||||
params.forEach((param) => {
|
||||
const [key, value] = param.split('=');
|
||||
|
||||
data[key] = value;
|
||||
});
|
||||
|
||||
sessionStorage.setItem(TOKEN_TYPE_NAME, data.token_type);
|
||||
sessionStorage.setItem(ACCESS_TOKEN_NAME, data.access_token);
|
||||
sessionStorage.setItem(REFRESH_TOKEN_NAME, data.refresh_token);
|
||||
return this.httpClient.getUser();
|
||||
};
|
||||
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<UserProfile> => {
|
||||
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||
return Promise.reject('Not implemented');
|
||||
};
|
||||
|
||||
signinWithEmailPassword = async (email: string, password: string): Promise<UserProfile> => {
|
||||
// await this.wasmService.cloudService.signIn(email, password);
|
||||
// return Promise.reject('Not implemented');
|
||||
return this.httpClient.signInWithEmailPassword(email, password);
|
||||
};
|
||||
@afterSignInDecorator(signInSuccess)
|
||||
async signinWithEmailPassword(email: string, password: string): Promise<void> {
|
||||
try {
|
||||
return APIService.signIn(email, password);
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
signOut = async (): Promise<void> => {
|
||||
return this.httpClient.logout();
|
||||
invalidToken();
|
||||
return APIService.logout();
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,68 @@
|
||||
import { YDoc } from '@/application/document.type';
|
||||
import { getAuthInfo } from '@/application/services/js-services/storage';
|
||||
import * as Y from 'yjs';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import { databasePrefix } from '@/application/constants';
|
||||
import BaseDexie from 'dexie';
|
||||
import { usersSchema, UsersTable } from './tables/users';
|
||||
|
||||
const version = 1;
|
||||
|
||||
type DexieTables = UsersTable;
|
||||
export type Dexie<T = DexieTables> = BaseDexie & T;
|
||||
|
||||
let db: Dexie | undefined;
|
||||
|
||||
export function getDB() {
|
||||
const authInfo = getAuthInfo();
|
||||
|
||||
if (!db && authInfo?.uuid) {
|
||||
return openDB(authInfo?.uuid);
|
||||
}
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
export function openDB(uuid: string) {
|
||||
const dbName = `${databasePrefix}_${uuid}`;
|
||||
|
||||
if (db && db.name === dbName) {
|
||||
return db;
|
||||
}
|
||||
|
||||
db = new BaseDexie(dbName) as Dexie;
|
||||
const schema = Object.assign({}, usersSchema);
|
||||
|
||||
db.version(version).stores(schema);
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the collaboration database, and return a function to close it
|
||||
*/
|
||||
export async function openCollabDB(docName: string): Promise<YDoc> {
|
||||
const name = `${databasePrefix}_${docName}`;
|
||||
const doc = new Y.Doc();
|
||||
const provider = new IndexeddbPersistence(name, doc);
|
||||
|
||||
let resolve: (value: unknown) => void;
|
||||
const promise = new Promise((resolveFn) => {
|
||||
resolve = resolveFn;
|
||||
});
|
||||
|
||||
provider.on('synced', () => {
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
await promise;
|
||||
|
||||
return doc as YDoc;
|
||||
}
|
||||
|
||||
export async function deleteCollabDB(docName: string) {
|
||||
const name = `${databasePrefix}_${docName}`;
|
||||
const doc = new Y.Doc();
|
||||
const provider = new IndexeddbPersistence(name, doc);
|
||||
|
||||
await provider.destroy();
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
import { Table } from 'dexie';
|
||||
import { UserProfile } from '@/application/user.type';
|
||||
|
||||
export type UsersTable = {
|
||||
users: Table<UserProfile>;
|
||||
};
|
||||
|
||||
export const usersSchema = {
|
||||
users: 'uuid, uid, email, name, workspaceId, iconUrl',
|
||||
};
|
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @description:
|
||||
* * This is a decorator that can be used to read data from storage and fetch data from the server.
|
||||
* * If the data is already in storage, it will return the data from storage and fetch the data from the server in the background.
|
||||
*
|
||||
* @param getStorage A function that returns the data from storage. eg. `() => Promise<T | undefined>`
|
||||
*
|
||||
* @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise<void>`
|
||||
*
|
||||
* @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise<T | undefined>`
|
||||
*
|
||||
* @returns: A function that returns the data from storage and fetches the data from the server in the background.
|
||||
*/
|
||||
export function asyncDataDecorator<P, T>(
|
||||
getStorage: () => Promise<T | undefined>,
|
||||
setStorage: (data: T) => Promise<void>,
|
||||
fetchFunction: (params: P) => Promise<T | undefined>
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
async function fetchData(params: P) {
|
||||
const data = await fetchFunction(params);
|
||||
|
||||
if (!data) return;
|
||||
await setStorage(data);
|
||||
return data;
|
||||
}
|
||||
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
descriptor.value = async function (params: P) {
|
||||
const data = await getStorage();
|
||||
|
||||
await originalMethod.apply(this, [params]);
|
||||
if (data) {
|
||||
void fetchData(params);
|
||||
return data;
|
||||
} else {
|
||||
return fetchData(params);
|
||||
}
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
||||
|
||||
export function afterSignInDecorator(successCallback: () => Promise<void>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||
const originalMethod = descriptor.value;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
await originalMethod.apply(this, args);
|
||||
await successCallback();
|
||||
};
|
||||
|
||||
return descriptor;
|
||||
};
|
||||
}
|
@ -1,17 +1,46 @@
|
||||
import { YDoc } from '@/application/document.type';
|
||||
import { getDocumentStorage } from '@/application/services/js-services/storage/document';
|
||||
import { DocumentService } from '@/application/services/services.type';
|
||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
||||
import { CollabType } from '@/application/services/js-services/http/http.type';
|
||||
import { APIService } from 'src/application/services/js-services/wasm';
|
||||
import { CollabOrigin, CollabType } from '@/application/collab.type';
|
||||
import { applyDocument } from 'src/application/ydoc/apply';
|
||||
|
||||
export class JSDocumentService implements DocumentService {
|
||||
constructor(private httpClient: HttpClient) {}
|
||||
constructor() {
|
||||
//
|
||||
}
|
||||
|
||||
async openDocument(docID: string): Promise<void> {
|
||||
const workspaceId = '9eebea03-3ed5-4298-86b2-a7f77856d48b';
|
||||
const docId = '26d5c8c1-1c66-459c-bc6c-f4da1a663348';
|
||||
const data = await this.httpClient.getObject(workspaceId, docId, CollabType.Document);
|
||||
fetchDocument(workspaceId: string, docId: string) {
|
||||
return APIService.getCollab(workspaceId, docId, CollabType.Document);
|
||||
}
|
||||
|
||||
console.log(docID, data);
|
||||
async openDocument(workspaceId: string, docId: string): Promise<YDoc> {
|
||||
const { doc, localExist } = await getDocumentStorage(docId);
|
||||
const asyncApply = async () => {
|
||||
const res = await this.fetchDocument(workspaceId, docId);
|
||||
|
||||
return;
|
||||
applyDocument(doc, res.state);
|
||||
};
|
||||
|
||||
// If the document exists locally, apply the state asynchronously,
|
||||
// otherwise, apply the state synchronously
|
||||
if (localExist) {
|
||||
void asyncApply();
|
||||
} else {
|
||||
await asyncApply();
|
||||
}
|
||||
|
||||
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
|
||||
if (origin === CollabOrigin.Remote) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the update to the server
|
||||
console.log('update', update);
|
||||
};
|
||||
|
||||
doc.on('update', handleUpdate);
|
||||
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
@ -1,86 +0,0 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { UserProfile, Workspace } from '@/application/services/user.type';
|
||||
import {
|
||||
CollabType,
|
||||
EncodedCollab,
|
||||
UserProfilePB,
|
||||
WorkspacePB,
|
||||
} from '@/application/services/js-services/http/http.type';
|
||||
import {
|
||||
parseUserPBToUserProfile,
|
||||
getAxiosInstances,
|
||||
parseWorkspacePBToWorkspace,
|
||||
} from '@/application/services/js-services/http/utils';
|
||||
import {
|
||||
ACCESS_TOKEN_NAME,
|
||||
baseHttpUrls,
|
||||
gotrueHttpUrls,
|
||||
REFRESH_TOKEN_NAME,
|
||||
URL_NAME,
|
||||
} from '@/application/services/js-services/http/const';
|
||||
|
||||
export class HttpClient {
|
||||
private gotrueAPI: AxiosInstance;
|
||||
private baseAPI: AxiosInstance;
|
||||
|
||||
constructor(private config: { baseURL: string; gotrueURL: string }) {
|
||||
const { baseInstance, gotrueInstance } = getAxiosInstances(config.baseURL, config.gotrueURL);
|
||||
|
||||
this.gotrueAPI = gotrueInstance;
|
||||
this.baseAPI = baseInstance;
|
||||
}
|
||||
|
||||
async signInWithEmailPassword(email: string, password: string): Promise<UserProfile> {
|
||||
const { data } = await this.gotrueAPI.post<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}>(gotrueHttpUrls[URL_NAME.SIGN_IN_WITH_EMAIL], {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
sessionStorage.setItem(ACCESS_TOKEN_NAME, data.access_token);
|
||||
sessionStorage.setItem(REFRESH_TOKEN_NAME, data.refresh_token);
|
||||
|
||||
return this.getUser();
|
||||
}
|
||||
|
||||
async getUser(): Promise<UserProfile> {
|
||||
const { data } = await this.gotrueAPI.get<UserProfilePB>(gotrueHttpUrls[URL_NAME.GET_USER]);
|
||||
|
||||
return parseUserPBToUserProfile(data);
|
||||
}
|
||||
|
||||
async logout() {
|
||||
await this.gotrueAPI.post(gotrueHttpUrls[URL_NAME.LOGOUT]);
|
||||
sessionStorage.removeItem(REFRESH_TOKEN_NAME);
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_NAME);
|
||||
}
|
||||
|
||||
async getWorkspaces(): Promise<Workspace[]> {
|
||||
const { data } = await this.baseAPI.get<WorkspacePB[]>(baseHttpUrls[URL_NAME.GET_WORKSPACES]);
|
||||
|
||||
return data.map(parseWorkspacePBToWorkspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get object(document/database/view) from workspace
|
||||
* @param workspaceId - workspace id
|
||||
* @param objectId - document id or database id or view id
|
||||
* @param objectType - type of object [CollabType]
|
||||
*/
|
||||
async getObject(workspaceId: string, objectId: string, objectType: CollabType): Promise<EncodedCollab> {
|
||||
// const workspaces = await this.getWorkspaces();
|
||||
//
|
||||
// console.log(workspaces);
|
||||
const { data } = await this.baseAPI.get<EncodedCollab>(baseHttpUrls[URL_NAME.GET_OBJECT](workspaceId, objectId), {
|
||||
data: JSON.stringify({
|
||||
workspace_id: workspaceId,
|
||||
object_id: objectId,
|
||||
collab_type: objectType,
|
||||
}),
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
export enum URL_NAME {
|
||||
SIGN_IN_WITH_EMAIL,
|
||||
GET_USER,
|
||||
LOGOUT,
|
||||
REFRESH_TOKEN,
|
||||
GET_WORKSPACES,
|
||||
GET_OBJECT,
|
||||
}
|
||||
|
||||
export const gotrueHttpUrls = {
|
||||
[URL_NAME.SIGN_IN_WITH_EMAIL]: '/token?grant_type=password',
|
||||
[URL_NAME.GET_USER]: '/user',
|
||||
[URL_NAME.LOGOUT]: '/logout',
|
||||
[URL_NAME.REFRESH_TOKEN]: '/token?grant_type=refresh_token',
|
||||
};
|
||||
|
||||
export const baseHttpUrls = {
|
||||
[URL_NAME.GET_WORKSPACES]: '/api/workspace',
|
||||
[URL_NAME.GET_OBJECT]: (workspaceId: string, objectId: string) => `/api/workspace/${workspaceId}/collab/${objectId}`,
|
||||
};
|
||||
|
||||
export const ACCESS_TOKEN_NAME = 'access_token';
|
||||
export const REFRESH_TOKEN_NAME = 'refresh_token';
|
||||
export const TOKEN_TYPE_NAME = 'token_type';
|
||||
|
||||
export const AUTHORIZATION_NAME = 'Authorization';
|
@ -1,40 +0,0 @@
|
||||
export interface UserProfilePB {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
user_metadata: {
|
||||
avatar_url: string;
|
||||
full_name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspacePB {
|
||||
workspace_id: string;
|
||||
database_storage_id: string;
|
||||
owner_uid: number;
|
||||
owner_name: string;
|
||||
workspace_type: number;
|
||||
workspace_name: string;
|
||||
created_at: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export enum EncoderVersion {
|
||||
V1 = 0,
|
||||
V2 = 1,
|
||||
}
|
||||
|
||||
export enum CollabType {
|
||||
Document = 0,
|
||||
Database = 1,
|
||||
WorkspaceDatabase = 2,
|
||||
Folder = 3,
|
||||
DatabaseRow = 4,
|
||||
UserAwareness = 5,
|
||||
}
|
||||
|
||||
export interface EncodedCollab {
|
||||
state_vector: Uint8Array;
|
||||
doc_state: Uint8Array;
|
||||
version: EncoderVersion;
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
import { UserProfilePB, WorkspacePB } from '@/application/services/js-services/http/http.type';
|
||||
import { Authenticator, UserProfile, Workspace } from '@/application/services/user.type';
|
||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosRequestConfig } from 'axios';
|
||||
import {
|
||||
ACCESS_TOKEN_NAME,
|
||||
AUTHORIZATION_NAME,
|
||||
gotrueHttpUrls,
|
||||
REFRESH_TOKEN_NAME,
|
||||
TOKEN_TYPE_NAME,
|
||||
URL_NAME,
|
||||
} from '@/application/services/js-services/http/const';
|
||||
|
||||
async function refreshToken(instance: AxiosInstance) {
|
||||
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_NAME);
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('Refresh token not found');
|
||||
}
|
||||
|
||||
const { data } = await instance.post(gotrueHttpUrls[URL_NAME.REFRESH_TOKEN], {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
sessionStorage.setItem(ACCESS_TOKEN_NAME, data.access_token);
|
||||
sessionStorage.setItem(REFRESH_TOKEN_NAME, data.refresh_token);
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
export function getAxiosInstances(baseURL: string, gotrueURL: string) {
|
||||
const gotrueInstance = axios.create({
|
||||
baseURL: gotrueURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
});
|
||||
const baseInstance = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: '*/*',
|
||||
},
|
||||
});
|
||||
|
||||
const requestInterceptor = async (config: InternalAxiosRequestConfig) => {
|
||||
const accessToken = sessionStorage.getItem(ACCESS_TOKEN_NAME);
|
||||
const tokenType = sessionStorage.getItem(TOKEN_TYPE_NAME) || 'Bearer';
|
||||
|
||||
if (accessToken) {
|
||||
config.headers[AUTHORIZATION_NAME] = `${tokenType} ${accessToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
const errorInterceptor = async (error: {
|
||||
response?: AxiosResponse;
|
||||
config: AxiosRequestConfig;
|
||||
}) => {
|
||||
if (error.response?.status === 401 && !error.config.url?.includes(gotrueHttpUrls[URL_NAME.LOGOUT])) {
|
||||
try {
|
||||
const tokenType = sessionStorage.getItem(TOKEN_TYPE_NAME) || 'Bearer';
|
||||
const accessToken = await refreshToken(gotrueInstance);
|
||||
|
||||
const config = {
|
||||
...error.config,
|
||||
[AUTHORIZATION_NAME]: `${tokenType} ${accessToken}`,
|
||||
}
|
||||
|
||||
return gotrueInstance.request(config);
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
};
|
||||
|
||||
gotrueInstance.interceptors.request.use(requestInterceptor);
|
||||
gotrueInstance.interceptors.response.use((response) => response, errorInterceptor);
|
||||
|
||||
baseInstance.interceptors.request.use(requestInterceptor);
|
||||
baseInstance.interceptors.response.use((response) => response, errorInterceptor);
|
||||
return {
|
||||
baseInstance,
|
||||
gotrueInstance,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserPBToUserProfile(userPB: UserProfilePB): UserProfile {
|
||||
return {
|
||||
id: userPB.id,
|
||||
email: userPB.email,
|
||||
authenticator: Authenticator.AppFlowyCloud,
|
||||
iconUrl: userPB.user_metadata.avatar_url,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspacePBToWorkspace(workspacePB: WorkspacePB): Workspace {
|
||||
return {
|
||||
id: workspacePB.workspace_id,
|
||||
name: workspacePB.workspace_name,
|
||||
icon: workspacePB.icon,
|
||||
owner: {
|
||||
id: workspacePB.owner_uid,
|
||||
name: workspacePB.owner_name,
|
||||
},
|
||||
};
|
||||
}
|
@ -7,19 +7,21 @@ import {
|
||||
} from '@/application/services/services.type';
|
||||
import { JSUserService } from '@/application/services/js-services/user.service';
|
||||
import { JSAuthService } from '@/application/services/js-services/auth.service';
|
||||
import { AFWasmService } from '@/application/services/wasm-services';
|
||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
|
||||
|
||||
export class AFClientService implements AFService {
|
||||
authService: AuthService;
|
||||
|
||||
userService: UserService;
|
||||
wasmService: AFWasmService;
|
||||
httpClient: HttpClient;
|
||||
|
||||
documentService: DocumentService;
|
||||
|
||||
private deviceId: string = nanoid(8);
|
||||
|
||||
private clientId: string = 'web';
|
||||
|
||||
getDeviceID = (): string => {
|
||||
return this.deviceId;
|
||||
};
|
||||
@ -28,21 +30,15 @@ export class AFClientService implements AFService {
|
||||
return this.clientId;
|
||||
};
|
||||
|
||||
constructor(private config: AFServiceConfig) {
|
||||
this.wasmService = new AFWasmService(config, {
|
||||
constructor(config: AFServiceConfig) {
|
||||
initAPIService({
|
||||
...config.cloudConfig,
|
||||
deviceId: this.deviceId,
|
||||
clientId: this.clientId,
|
||||
});
|
||||
this.httpClient = new HttpClient({
|
||||
baseURL: config.cloudConfig.baseURL,
|
||||
gotrueURL: config.cloudConfig.gotrueURL,
|
||||
});
|
||||
this.authService = new JSAuthService(this.httpClient, this.wasmService);
|
||||
this.userService = new JSUserService(this.httpClient);
|
||||
this.documentService = new JSDocumentService(this.httpClient);
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.wasmService.load();
|
||||
this.authService = new JSAuthService();
|
||||
this.userService = new JSUserService();
|
||||
this.documentService = new JSDocumentService();
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,11 @@
|
||||
import { getAuthInfo } from '@/application/services/js-services/storage/token';
|
||||
import { openDB } from '@/application/services/js-services/db';
|
||||
|
||||
export async function signInSuccess() {
|
||||
const authInfo = getAuthInfo();
|
||||
|
||||
if (authInfo) {
|
||||
// Open the database
|
||||
openDB(authInfo.uuid);
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
import { YjsEditorKey } from '@/application/document.type';
|
||||
import { openCollabDB } from '@/application/services/js-services/db';
|
||||
import { getAuthInfo } from '@/application/services/js-services/storage/token';
|
||||
|
||||
export async function getDocumentStorage(docId: string) {
|
||||
const docName = getDocName(docId);
|
||||
const doc = await openCollabDB(docName);
|
||||
const localExist = doc.share.has(YjsEditorKey.data_section);
|
||||
|
||||
return {
|
||||
doc,
|
||||
localExist,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDocName(docId: string) {
|
||||
const { uuid } = getAuthInfo() || {};
|
||||
|
||||
if (!uuid) throw new Error('No user found');
|
||||
return `${uuid}_document_${docId}`;
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './token';
|
||||
export * from './user';
|
@ -0,0 +1,36 @@
|
||||
const tokenKey = 'token';
|
||||
|
||||
export function readTokenStr () {
|
||||
return sessionStorage.getItem(tokenKey);
|
||||
}
|
||||
|
||||
export function getAuthInfo () {
|
||||
const token = readTokenStr() || '';
|
||||
|
||||
try {
|
||||
const info = JSON.parse(token);
|
||||
|
||||
return {
|
||||
uuid: info.user.id,
|
||||
access_token: info.access_token,
|
||||
email: info.user.email,
|
||||
};
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeToken (token: string) {
|
||||
if (!token) {
|
||||
invalidToken();
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage.setItem(tokenKey, token);
|
||||
}
|
||||
|
||||
export function invalidToken () {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
window.location.reload();
|
||||
}
|
||||
|
@ -0,0 +1,18 @@
|
||||
import { UserProfile } from '@/application/user.type';
|
||||
import { getDB } from '@/application/services/js-services/db';
|
||||
import { getAuthInfo } from '@/application/services/js-services/storage/token';
|
||||
|
||||
const primaryKeyName = 'uid';
|
||||
|
||||
export async function getSignInUser(): Promise<UserProfile | undefined> {
|
||||
const db = getDB();
|
||||
const authInfo = getAuthInfo();
|
||||
|
||||
return db?.users.get(authInfo?.uuid);
|
||||
}
|
||||
|
||||
export async function setSignInUser(profile: UserProfile) {
|
||||
const db = getDB();
|
||||
|
||||
return db?.users.put(profile, primaryKeyName);
|
||||
}
|
@ -1,11 +1,33 @@
|
||||
import { UserService } from '@/application/services/services.type';
|
||||
import { UserProfile } from '@/application/services/user.type';
|
||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
||||
import { UserProfile } from '@/application/user.type';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import { APIService } from 'src/application/services/js-services/wasm';
|
||||
import { getAuthInfo, getSignInUser, setSignInUser } from '@/application/services/js-services/storage';
|
||||
import { asyncDataDecorator } from '@/application/services/js-services/decorator';
|
||||
|
||||
export class JSUserService implements UserService {
|
||||
constructor(private httpClient: HttpClient) {}
|
||||
async function getUser() {
|
||||
try {
|
||||
const user = await APIService.getUser();
|
||||
|
||||
async getUserProfile(): Promise<UserProfile> {
|
||||
return this.httpClient.getUser();
|
||||
return user;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
notify.error('Failed to get user profile, please try refreshing the page');
|
||||
// invalidToken();
|
||||
}
|
||||
}
|
||||
|
||||
export class JSUserService implements UserService {
|
||||
@asyncDataDecorator<void, UserProfile>(getSignInUser, setSignInUser, getUser)
|
||||
async getUserProfile(): Promise<UserProfile> {
|
||||
if (!getAuthInfo()) {
|
||||
return Promise.reject('Not authenticated');
|
||||
}
|
||||
|
||||
return null!;
|
||||
}
|
||||
|
||||
async checkUser(): Promise<boolean> {
|
||||
return (await getSignInUser()) !== undefined;
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,77 @@
|
||||
import { ClientAPI } from '@appflowyinc/client-api-wasm';
|
||||
import { UserProfile } from '@/application/user.type';
|
||||
import { AFCloudConfig } from '@/application/services/services.type';
|
||||
import { invalidToken, readTokenStr, writeToken } from '@/application/services/js-services/storage';
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
|
||||
let client: ClientAPI;
|
||||
|
||||
export function initAPIService (config: AFCloudConfig & {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
window.refresh_token = writeToken;
|
||||
window.invalid_token = invalidToken;
|
||||
client = ClientAPI.new({
|
||||
base_url: config.baseURL,
|
||||
ws_addr: config.wsURL,
|
||||
gotrue_url: config.gotrueURL,
|
||||
device_id: config.deviceId,
|
||||
client_id: config.clientId,
|
||||
configuration: {
|
||||
compression_quality: 8,
|
||||
compression_buffer_size: 10240,
|
||||
},
|
||||
});
|
||||
|
||||
const token = readTokenStr();
|
||||
|
||||
if (token) {
|
||||
client.restore_token(token);
|
||||
}
|
||||
|
||||
client.subscribe();
|
||||
}
|
||||
|
||||
export function signIn (email: string, password: string) {
|
||||
return client.login(email, password);
|
||||
}
|
||||
|
||||
export function logout () {
|
||||
return client.logout();
|
||||
}
|
||||
|
||||
export async function getUser (): Promise<UserProfile> {
|
||||
try {
|
||||
const user = await client.get_user();
|
||||
|
||||
if (!user) {
|
||||
throw new Error('No user found');
|
||||
}
|
||||
|
||||
return {
|
||||
uid: parseInt(user.uid),
|
||||
uuid: user.uuid || undefined,
|
||||
email: user.email || undefined,
|
||||
name: user.name || undefined,
|
||||
workspaceId: user.latest_workspace_id,
|
||||
iconUrl: user.icon_url || undefined,
|
||||
};
|
||||
} catch (e) {
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCollab (workspaceId: string, object_id: string, collabType: CollabType) {
|
||||
const res = await client.get_collab({
|
||||
workspace_id: workspaceId,
|
||||
object_id: object_id,
|
||||
collab_type: Number(collabType) as 0 | 1 | 2 | 3 | 4 | 5,
|
||||
});
|
||||
|
||||
const state = new Uint8Array(res.doc_state);
|
||||
|
||||
return {
|
||||
state,
|
||||
};
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * as APIService from './client_api';
|
@ -1,4 +1,5 @@
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/services/user.type';
|
||||
import { YDoc } from '@/application/document.type';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||
|
||||
export interface AFService {
|
||||
getDeviceID: () => string;
|
||||
@ -6,7 +7,6 @@ export interface AFService {
|
||||
authService: AuthService;
|
||||
userService: UserService;
|
||||
documentService: DocumentService;
|
||||
load: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AFServiceConfig {
|
||||
@ -20,18 +20,18 @@ export interface AFCloudConfig {
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
|
||||
getOAuthURL: (provider: ProviderType) => Promise<string>;
|
||||
signInWithOAuth: (params: { uri: string }) => Promise<UserProfile>;
|
||||
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<UserProfile>;
|
||||
signinWithEmailPassword: (email: string, password: string) => Promise<UserProfile>;
|
||||
signInWithOAuth: (params: { uri: string }) => Promise<void>;
|
||||
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<void>;
|
||||
signinWithEmailPassword: (email: string, password: string) => Promise<void>;
|
||||
signOut: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DocumentService {
|
||||
openDocument: (docID: string) => Promise<void>;
|
||||
openDocument: (workspaceId: string, docId: string) => Promise<YDoc>;
|
||||
}
|
||||
|
||||
export interface UserService {
|
||||
getUserProfile: () => Promise<UserProfile | null>;
|
||||
checkUser: () => Promise<boolean>;
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ import {
|
||||
UserEventSignUp,
|
||||
UserProfilePB,
|
||||
} from './backend/events/flowy-user';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/services/user.type';
|
||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||
|
||||
export class TauriAuthService implements AuthService {
|
||||
|
||||
@ -41,7 +41,7 @@ export class TauriAuthService implements AuthService {
|
||||
return providerData.oauth_url;
|
||||
};
|
||||
|
||||
signInWithOAuth = async ({ uri }: { uri: string }): Promise<UserProfile> => {
|
||||
signInWithOAuth = async ({ uri }: { uri: string }): Promise<void> => {
|
||||
const payload = OauthSignInPB.fromObject({
|
||||
authenticator: AuthenticatorPB.AppFlowyCloud,
|
||||
map: {
|
||||
@ -56,9 +56,9 @@ export class TauriAuthService implements AuthService {
|
||||
throw new Error(res.val.msg);
|
||||
}
|
||||
|
||||
return parseUserProfileFrom(res.val);
|
||||
return;
|
||||
};
|
||||
signinWithEmailPassword = async (email: string, password: string): Promise<UserProfile> => {
|
||||
signinWithEmailPassword = async (email: string, password: string): Promise<void> => {
|
||||
const payload = SignInPayloadPB.fromObject({
|
||||
email,
|
||||
password,
|
||||
@ -70,10 +70,10 @@ export class TauriAuthService implements AuthService {
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return parseUserProfileFrom(res.val);
|
||||
return;
|
||||
};
|
||||
|
||||
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<UserProfile> => {
|
||||
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||
const payload = SignUpPayloadPB.fromObject({
|
||||
name: params.name,
|
||||
email: params.email,
|
||||
@ -84,11 +84,10 @@ export class TauriAuthService implements AuthService {
|
||||
const res = await UserEventSignUp(payload);
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(res.val.msg);
|
||||
return Promise.reject(res.val.msg);
|
||||
}
|
||||
|
||||
return parseUserProfileFrom(res.val);
|
||||
return;
|
||||
};
|
||||
|
||||
signOut = async () => {
|
||||
@ -106,16 +105,10 @@ export function parseUserProfileFrom (userPB: UserProfilePB): UserProfile {
|
||||
const user = userPB.toObject();
|
||||
|
||||
return {
|
||||
id: String(user.id),
|
||||
uid: user.id as number,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
token: user.token,
|
||||
iconUrl: user.icon_url,
|
||||
openaiKey: user.openai_key,
|
||||
authenticator: user.authenticator as number,
|
||||
encryptionSign: user.encryption_sign,
|
||||
encryptionType: user.encryption_type as number,
|
||||
workspaceId: user.workspace_id,
|
||||
stabilityAiKey: user.stability_ai_key,
|
||||
};
|
||||
}
|
||||
|
@ -1,68 +1,8 @@
|
||||
import { DocumentService } from '@/application/services/services.type';
|
||||
import { OpenDocumentPayloadPB } from './backend';
|
||||
import { DocumentEventOpenDocument } from './backend/events/flowy-document';
|
||||
import Y from 'yjs';
|
||||
|
||||
export class TauriDocumentService implements DocumentService {
|
||||
async openDocument(docId: string): Promise<void> {
|
||||
const payload = OpenDocumentPayloadPB.fromObject({
|
||||
document_id: docId,
|
||||
});
|
||||
|
||||
const result = await DocumentEventOpenDocument(payload);
|
||||
|
||||
if (!result.ok) {
|
||||
return Promise.reject(result.val);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
// const documentDataPB = result.val;
|
||||
//
|
||||
// if (!documentDataPB) {
|
||||
// return Promise.reject('documentDataPB is null');
|
||||
// }
|
||||
//
|
||||
// const data: {
|
||||
// viewId: string;
|
||||
// rootId: string;
|
||||
// nodeMap: Record<string, any>;
|
||||
// childrenMap: Record<string, string[]>;
|
||||
// relativeMap: Record<string, string>;
|
||||
// deltaMap: Record<string, Op[]>;
|
||||
// externalIdMap: Record<string, string>;
|
||||
// } = {
|
||||
// viewId: docId,
|
||||
// rootId: documentDataPB.page_id,
|
||||
// nodeMap: {},
|
||||
// childrenMap: {},
|
||||
// relativeMap: {},
|
||||
// deltaMap: {},
|
||||
// externalIdMap: {},
|
||||
// };
|
||||
//
|
||||
// get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => {
|
||||
// Object.assign(data.nodeMap, {
|
||||
// [block.id]: blockPB2Node(block),
|
||||
// });
|
||||
// data.relativeMap[block.children_id] = block.id;
|
||||
// if (block.external_id) {
|
||||
// data.externalIdMap[block.external_id] = block.id;
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
|
||||
// const blockId = data.relativeMap[key];
|
||||
//
|
||||
// data.childrenMap[blockId] = child.children;
|
||||
// });
|
||||
//
|
||||
// get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => {
|
||||
// const blockId = data.externalIdMap[key];
|
||||
//
|
||||
// data.deltaMap[blockId] = delta ? JSON.parse(delta) : [];
|
||||
// });
|
||||
//
|
||||
// // return data;
|
||||
// return;
|
||||
async openDocument(_id: string): Promise<Y.Doc> {
|
||||
return Promise.reject('Not implemented');
|
||||
}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ export class AFClientService implements AFService {
|
||||
return this.clientId;
|
||||
};
|
||||
|
||||
constructor(config: AFServiceConfig) {
|
||||
constructor (config: AFServiceConfig) {
|
||||
this.authService = new TauriAuthService(config.cloudConfig, {
|
||||
deviceId: this.deviceId,
|
||||
clientId: this.clientId,
|
||||
@ -32,8 +32,4 @@ export class AFClientService implements AFService {
|
||||
this.userService = new TauriUserService();
|
||||
this.documentService = new TauriDocumentService();
|
||||
}
|
||||
|
||||
async load() {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { UserService } from '@/application/services/services.type';
|
||||
import { UserProfile } from '@/application/services/user.type';
|
||||
import { UserProfile } from '@/application/user.type';
|
||||
import { UserEventGetUserProfile } from './backend/events/flowy-user';
|
||||
import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service';
|
||||
|
||||
export class TauriUserService implements UserService {
|
||||
async getUserProfile (): Promise<UserProfile | null> {
|
||||
async getUserProfile(): Promise<UserProfile | null> {
|
||||
const res = await UserEventGetUserProfile();
|
||||
|
||||
if (res.ok) {
|
||||
@ -13,4 +13,8 @@ export class TauriUserService implements UserService {
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async checkUser(): Promise<boolean> {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
}
|
||||
|
@ -1,36 +0,0 @@
|
||||
import { CloudServiceConfig } from '@/application/services/wasm-services/cloud.type';
|
||||
|
||||
// import { ClientAPI } from '@appflowyinc/client-api-wasm';
|
||||
|
||||
export class CloudService {
|
||||
// private client?: ClientAPI;
|
||||
|
||||
constructor (private config: CloudServiceConfig) {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
async init () {
|
||||
// this.client = ClientAPI.new({
|
||||
// base_url: this.config.baseURL,
|
||||
// ws_addr: this.config.wsURL,
|
||||
// gotrue_url: this.config.gotrueURL,
|
||||
// device_id: this.config.deviceId,
|
||||
// client_id: this.config.clientId,
|
||||
// configuration: {
|
||||
// compression_quality: 8,
|
||||
// compression_buffer_size: 10240,
|
||||
// },
|
||||
// });
|
||||
|
||||
}
|
||||
|
||||
// async signIn (email: string, password: string) {
|
||||
// try {
|
||||
// const res = await this.client?.sign_in_password(email, password);
|
||||
//
|
||||
// console.log(res);
|
||||
// } catch (error) {
|
||||
// console.error(error);
|
||||
// }
|
||||
// }
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
import { AFCloudConfig } from '@/application/services/services.type';
|
||||
|
||||
export type CloudServiceEventPayload = Record<string, string>;
|
||||
export type CloudServiceConfig = AFCloudConfig & {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import { AFServiceConfig } from '@/application/services/services.type';
|
||||
import { CloudService } from '@/application/services/wasm-services/cloud.service';
|
||||
|
||||
export class AFWasmService {
|
||||
cloudService: CloudService;
|
||||
|
||||
constructor (private config: AFServiceConfig, clientConfig: {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
this.cloudService = new CloudService({
|
||||
...config.cloudConfig,
|
||||
...clientConfig,
|
||||
});
|
||||
}
|
||||
|
||||
async load () {
|
||||
await this.cloudService.init();
|
||||
}
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from './plugins/withYjs';
|
@ -0,0 +1,147 @@
|
||||
import { YjsEditorKey, YSharedRoot } from '@/application/document.type';
|
||||
import { applySlateOp } from '@/application/slate-yjs/utils/applySlateOpts';
|
||||
import { translateYjsEvent } from 'src/application/slate-yjs/utils/translateYjsEvent';
|
||||
import { Editor, Operation, Descendant } from 'slate';
|
||||
import Y, { YEvent, Transaction } from 'yjs';
|
||||
import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert';
|
||||
import { CollabOrigin } from '@/application/collab.type';
|
||||
|
||||
type LocalChange = {
|
||||
op: Operation;
|
||||
slateContent: Descendant[];
|
||||
};
|
||||
|
||||
export interface YjsEditor extends Editor {
|
||||
connect: () => void;
|
||||
disconnect: () => void;
|
||||
sharedRoot: YSharedRoot;
|
||||
applyRemoteEvents: (events: Array<YEvent<YSharedRoot>>, transaction: Transaction) => void;
|
||||
flushLocalChanges: () => void;
|
||||
storeLocalChange: (op: Operation) => void;
|
||||
}
|
||||
|
||||
const connectSet = new WeakSet<YjsEditor>();
|
||||
|
||||
const localChanges = new WeakMap<YjsEditor, LocalChange[]>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||
export const YjsEditor = {
|
||||
connected(editor: YjsEditor): boolean {
|
||||
return connectSet.has(editor);
|
||||
},
|
||||
|
||||
connect(editor: YjsEditor): void {
|
||||
editor.connect();
|
||||
},
|
||||
|
||||
disconnect(editor: YjsEditor): void {
|
||||
editor.disconnect();
|
||||
},
|
||||
|
||||
applyRemoteEvents(editor: YjsEditor, events: Array<YEvent<YSharedRoot>>, transaction: Transaction): void {
|
||||
editor.applyRemoteEvents(events, transaction);
|
||||
},
|
||||
|
||||
localChanges(editor: YjsEditor): LocalChange[] {
|
||||
return localChanges.get(editor) ?? [];
|
||||
},
|
||||
|
||||
storeLocalChange(editor: YjsEditor, op: Operation): void {
|
||||
editor.storeLocalChange(op);
|
||||
},
|
||||
|
||||
flushLocalChanges(editor: YjsEditor): void {
|
||||
editor.flushLocalChanges();
|
||||
},
|
||||
};
|
||||
|
||||
export function withYjs<T extends Editor>(editor: T, doc: Y.Doc): T & YjsEditor {
|
||||
const e = editor as T & YjsEditor;
|
||||
const { apply, onChange } = e;
|
||||
|
||||
e.sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
e.applyRemoteEvents = (events: Array<YEvent<YSharedRoot>>, _: Transaction) => {
|
||||
YjsEditor.flushLocalChanges(e);
|
||||
|
||||
Editor.withoutNormalizing(editor, () => {
|
||||
events.forEach((event) => {
|
||||
translateYjsEvent(e.sharedRoot, editor, event).forEach((op) => {
|
||||
// apply remote events to slate, don't call e.apply here because e.apply has been overridden.
|
||||
apply(op);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleYEvents = (events: Array<YEvent<YSharedRoot>>, transaction: Transaction) => {
|
||||
if (transaction.origin === CollabOrigin.Local) {
|
||||
return;
|
||||
}
|
||||
|
||||
YjsEditor.applyRemoteEvents(e, events, transaction);
|
||||
};
|
||||
|
||||
e.connect = () => {
|
||||
if (YjsEditor.connected(e)) {
|
||||
throw new Error('Already connected');
|
||||
}
|
||||
|
||||
const content = yDocToSlateContent(doc, true);
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(content);
|
||||
|
||||
e.sharedRoot.observeDeep(handleYEvents);
|
||||
e.children = content.children;
|
||||
Editor.normalize(editor, { force: true });
|
||||
connectSet.add(e);
|
||||
};
|
||||
|
||||
e.disconnect = () => {
|
||||
if (!YjsEditor.connected(e)) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
|
||||
e.sharedRoot.unobserveDeep(handleYEvents);
|
||||
connectSet.delete(e);
|
||||
};
|
||||
|
||||
e.storeLocalChange = (op) => {
|
||||
const changes = localChanges.get(e) ?? [];
|
||||
|
||||
localChanges.set(e, [...changes, { op, slateContent: e.children }]);
|
||||
};
|
||||
|
||||
e.flushLocalChanges = () => {
|
||||
const changes = YjsEditor.localChanges(e);
|
||||
|
||||
localChanges.delete(e);
|
||||
// parse changes and apply to ydoc
|
||||
doc.transact(() => {
|
||||
changes.forEach((change) => {
|
||||
applySlateOp(doc, { children: change.slateContent }, change.op);
|
||||
});
|
||||
}, CollabOrigin.Local);
|
||||
};
|
||||
|
||||
e.apply = (op) => {
|
||||
if (YjsEditor.connected(e)) {
|
||||
YjsEditor.storeLocalChange(e, op);
|
||||
}
|
||||
|
||||
apply(op);
|
||||
};
|
||||
|
||||
e.onChange = () => {
|
||||
if (YjsEditor.connected(e)) {
|
||||
YjsEditor.flushLocalChanges(e);
|
||||
}
|
||||
|
||||
onChange();
|
||||
};
|
||||
|
||||
return e;
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import { Operation, Node } from 'slate';
|
||||
import Y from 'yjs';
|
||||
|
||||
export function applySlateOp (ydoc: Y.Doc, slateRoot: Node, op: Operation) {
|
||||
console.log('applySlateOp', op);
|
||||
}
|
@ -0,0 +1,240 @@
|
||||
import {
|
||||
InlineBlockType,
|
||||
YBlocks,
|
||||
YChildrenMap,
|
||||
YSharedRoot,
|
||||
YDoc,
|
||||
YjsEditorKey,
|
||||
YMeta,
|
||||
YTextMap,
|
||||
BlockData,
|
||||
BlockType,
|
||||
} from '@/application/document.type';
|
||||
import { getFontFamily } from '@/utils/font';
|
||||
import { uniq } from 'lodash-es';
|
||||
import { Element, Text } from 'slate';
|
||||
|
||||
interface BlockJson {
|
||||
id: string;
|
||||
ty: string;
|
||||
data?: string;
|
||||
children?: string;
|
||||
external_id?: string;
|
||||
}
|
||||
|
||||
export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element | undefined {
|
||||
console.log(doc);
|
||||
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||
|
||||
console.log(sharedRoot.toJSON());
|
||||
const document = sharedRoot.get(YjsEditorKey.document);
|
||||
const pageId = document.get(YjsEditorKey.page_id) as string;
|
||||
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
|
||||
const meta = document.get(YjsEditorKey.meta) as YMeta;
|
||||
const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap;
|
||||
const textMap = meta.get(YjsEditorKey.text_map) as YTextMap;
|
||||
const fontFamilys: string[] = [];
|
||||
|
||||
function traverse(id: string) {
|
||||
const block = blocks.get(id).toJSON() as BlockJson;
|
||||
const childrenId = block.children as string;
|
||||
|
||||
const children = (childrenMap.get(childrenId)?.toJSON() ?? []).map(traverse) as (Element | Text)[];
|
||||
|
||||
const slateNode = blockToSlateNode(block);
|
||||
|
||||
slateNode.children = children;
|
||||
|
||||
if (slateNode.type === BlockType.Page) {
|
||||
return slateNode;
|
||||
}
|
||||
|
||||
let textId = block.external_id as string;
|
||||
|
||||
let delta;
|
||||
|
||||
if (!textId) {
|
||||
if (children.length === 0) {
|
||||
children.push({
|
||||
text: '',
|
||||
});
|
||||
}
|
||||
|
||||
// Compatible data
|
||||
// The old version of delta data is fully covered through the data field
|
||||
if (slateNode.data) {
|
||||
const data = slateNode.data as BlockData;
|
||||
|
||||
if (YjsEditorKey.delta in data) {
|
||||
textId = block.id;
|
||||
delta = data.delta;
|
||||
} else {
|
||||
return slateNode;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
delta = textMap.get(textId)?.toDelta();
|
||||
}
|
||||
|
||||
try {
|
||||
const slateDelta = delta.flatMap(deltaInsertToSlateNode);
|
||||
|
||||
// collect font family
|
||||
slateDelta.forEach((node: Text) => {
|
||||
if (node.font_family) {
|
||||
fontFamilys.push(getFontFamily(node.font_family));
|
||||
}
|
||||
});
|
||||
const textNode: Element = {
|
||||
textId,
|
||||
type: YjsEditorKey.text,
|
||||
children: slateDelta,
|
||||
};
|
||||
|
||||
children.unshift(textNode);
|
||||
return slateNode;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const root = blocks.get(pageId);
|
||||
|
||||
if (!root) return;
|
||||
|
||||
const result = traverse(pageId);
|
||||
|
||||
if (!result) return;
|
||||
|
||||
if (!includeRoot) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const { children, ...rootNode } = result;
|
||||
|
||||
// load font family
|
||||
if (fontFamilys.length > 0) {
|
||||
window.WebFont?.load({
|
||||
google: {
|
||||
families: uniq(fontFamilys),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
children: [
|
||||
{
|
||||
...rootNode,
|
||||
children: [
|
||||
{
|
||||
textId: root.toJSON().external_id,
|
||||
type: YjsEditorKey.text,
|
||||
children: [{ text: '' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function blockToSlateNode(block: BlockJson): Element {
|
||||
const data = block.data;
|
||||
let blockData;
|
||||
|
||||
try {
|
||||
blockData = data ? JSON.parse(data) : {};
|
||||
} catch (e) {
|
||||
blockData = {};
|
||||
}
|
||||
|
||||
return {
|
||||
blockId: block.id,
|
||||
data: blockData,
|
||||
type: block.ty,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function deltaInsertToSlateNode({
|
||||
attributes,
|
||||
insert,
|
||||
}: {
|
||||
insert: string;
|
||||
attributes: Record<string, string | number | undefined | boolean>;
|
||||
}): Element | Text | Element[] {
|
||||
const matchInlines = transformToInlineElement({
|
||||
insert,
|
||||
attributes,
|
||||
});
|
||||
|
||||
if (matchInlines.length > 0) {
|
||||
return matchInlines;
|
||||
}
|
||||
|
||||
if (attributes) {
|
||||
if ('font_color' in attributes && attributes['font_color'] === '') {
|
||||
delete attributes['font_color'];
|
||||
}
|
||||
|
||||
if ('bg_color' in attributes && attributes['bg_color'] === '') {
|
||||
delete attributes['bg_color'];
|
||||
}
|
||||
|
||||
if ('code' in attributes && !attributes['code']) {
|
||||
delete attributes['code'];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
text: insert,
|
||||
};
|
||||
}
|
||||
|
||||
export function transformToInlineElement(op: {
|
||||
insert: string;
|
||||
attributes: Record<string, string | number | undefined | boolean>;
|
||||
}): Element[] {
|
||||
const attributes = op.attributes;
|
||||
|
||||
if (!attributes) return [];
|
||||
const { formula, mention, ...attrs } = attributes;
|
||||
|
||||
if (formula) {
|
||||
const texts = op.insert.split('');
|
||||
|
||||
return texts.map((text) => {
|
||||
return {
|
||||
type: InlineBlockType.Formula,
|
||||
data: formula,
|
||||
children: [
|
||||
{
|
||||
text,
|
||||
...attrs,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (mention) {
|
||||
const texts = op.insert.split('');
|
||||
|
||||
return texts.map((text) => {
|
||||
return {
|
||||
type: InlineBlockType.Mention,
|
||||
data: mention,
|
||||
children: [
|
||||
{
|
||||
text,
|
||||
...attrs,
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { YSharedRoot } from '@/application/document.type';
|
||||
import * as Y from 'yjs';
|
||||
import { Editor, Operation } from 'slate';
|
||||
|
||||
export function translateYArrayEvent(
|
||||
sharedRoot: YSharedRoot,
|
||||
editor: Editor,
|
||||
event: Y.YEvent<Y.Array<string>>
|
||||
): Operation[] {
|
||||
console.log('translateYArrayEvent', sharedRoot, editor, event);
|
||||
return [];
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
import { YSharedRoot } from '@/application/document.type';
|
||||
import { translateYArrayEvent } from '@/application/slate-yjs/utils/translateYjsEvent/arrayEvent';
|
||||
import { translateYMapEvent } from '@/application/slate-yjs/utils/translateYjsEvent/mapEvent';
|
||||
import { Editor, Operation } from 'slate';
|
||||
import * as Y from 'yjs';
|
||||
import { translateYTextEvent } from 'src/application/slate-yjs/utils/translateYjsEvent/textEvent';
|
||||
|
||||
/**
|
||||
* Translate a yjs event into slate operations. The editor state has to match the
|
||||
* yText state before the event occurred.
|
||||
*
|
||||
* @param sharedType
|
||||
* @param op
|
||||
*/
|
||||
export function translateYjsEvent (
|
||||
sharedRoot: YSharedRoot,
|
||||
editor: Editor,
|
||||
event: Y.YEvent<YSharedRoot>,
|
||||
): Operation[] {
|
||||
console.log('translateYjsEvent', event);
|
||||
if (event instanceof Y.YMapEvent) {
|
||||
return translateYMapEvent(sharedRoot, editor, event);
|
||||
}
|
||||
|
||||
if (event instanceof Y.YTextEvent) {
|
||||
return translateYTextEvent(sharedRoot, editor, event);
|
||||
}
|
||||
|
||||
if (event instanceof Y.YArrayEvent) {
|
||||
return translateYArrayEvent(sharedRoot, editor, event);
|
||||
}
|
||||
|
||||
throw new Error('Unexpected Y event type');
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import { YSharedRoot } from '@/application/document.type';
|
||||
import * as Y from 'yjs';
|
||||
import { Editor, Operation } from 'slate';
|
||||
|
||||
export function translateYMapEvent(
|
||||
sharedRoot: YSharedRoot,
|
||||
editor: Editor,
|
||||
event: Y.YEvent<Y.Map<unknown>>
|
||||
): Operation[] {
|
||||
console.log('translateYMapEvent', sharedRoot, editor, event);
|
||||
return [];
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
import { YSharedRoot } from '@/application/document.type';
|
||||
import * as Y from 'yjs';
|
||||
import { Editor, Operation } from 'slate';
|
||||
|
||||
export function translateYTextEvent(sharedRoot: YSharedRoot, editor: Editor, event: Y.YEvent<Y.Text>): Operation[] {
|
||||
console.log('translateYTextEvent', sharedRoot, editor, event);
|
||||
return [];
|
||||
}
|
@ -10,17 +10,12 @@ export enum EncryptionType {
|
||||
}
|
||||
|
||||
export interface UserProfile {
|
||||
id?: string;
|
||||
uid: number;
|
||||
uuid?: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
token?: string;
|
||||
iconUrl?: string;
|
||||
openaiKey?: string;
|
||||
authenticator?: Authenticator;
|
||||
encryptionSign?: string;
|
||||
encryptionType?: EncryptionType;
|
||||
workspaceId?: string;
|
||||
stabilityAiKey?: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
@ -0,0 +1,18 @@
|
||||
import { YjsEditorKey } from '@/application/document.type';
|
||||
import { applyDocument } from '@/application/ydoc/apply';
|
||||
import * as Y from 'yjs';
|
||||
import * as docJson from '../../../../../cypress/fixtures/simple_doc.json';
|
||||
|
||||
describe('apply document', () => {
|
||||
it('should apply document', () => {
|
||||
const collab = new Y.Doc();
|
||||
const data = collab.getMap(YjsEditorKey.data_section);
|
||||
const document = new Y.Map();
|
||||
data.set(YjsEditorKey.document, document);
|
||||
|
||||
const state = new Uint8Array(docJson.data.doc_state);
|
||||
applyDocument(collab, state);
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
@ -0,0 +1,18 @@
|
||||
import * as Y from 'yjs';
|
||||
import { CollabOrigin } from '@/application/collab.type';
|
||||
|
||||
/**
|
||||
* Apply doc state from server to client
|
||||
* Note: origin is always remote
|
||||
* @param doc local Y.Doc
|
||||
* @param state state from server
|
||||
*/
|
||||
export function applyDocument(doc: Y.Doc, state: Uint8Array) {
|
||||
Y.transact(
|
||||
doc,
|
||||
() => {
|
||||
Y.applyUpdate(doc, state);
|
||||
},
|
||||
CollabOrigin.Remote
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
export * from 'src/application/ydoc/apply/document';
|
@ -0,0 +1,18 @@
|
||||
import { CollabType } from '@/application/collab.type';
|
||||
import { useContext, createContext } from 'react';
|
||||
|
||||
export const IdContext = createContext<IdProviderProps | null>(null);
|
||||
|
||||
interface IdProviderProps {
|
||||
workspaceId: string;
|
||||
objectId: string;
|
||||
collabType: CollabType;
|
||||
}
|
||||
|
||||
export const IdProvider = ({ children, ...props }: IdProviderProps & { children: React.ReactNode }) => {
|
||||
return <IdContext.Provider value={props}>{children}</IdContext.Provider>;
|
||||
};
|
||||
|
||||
export function useId() {
|
||||
return useContext(IdContext);
|
||||
}
|
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { BlockMath, InlineMath } from 'react-katex';
|
||||
import './index.css';
|
||||
|
||||
function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
|
||||
return isInline ? (
|
||||
<InlineMath math={latex} />
|
||||
) : (
|
||||
<BlockMath
|
||||
renderError={(error) => {
|
||||
return (
|
||||
<div className='flex h-[51px] items-center justify-center'>
|
||||
{error.name}: {error.message}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{latex}
|
||||
</BlockMath>
|
||||
);
|
||||
}
|
||||
|
||||
export default KatexMath;
|
@ -0,0 +1,4 @@
|
||||
|
||||
.katex-html {
|
||||
white-space: normal;
|
||||
}
|
@ -0,0 +1,55 @@
|
||||
import { Scrollbars } from 'react-custom-scrollbars';
|
||||
import React from 'react';
|
||||
|
||||
export interface AFScrollerProps {
|
||||
children: React.ReactNode;
|
||||
overflowXHidden?: boolean;
|
||||
overflowYHidden?: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
export const AFScroller = ({ style, children, overflowXHidden, overflowYHidden, className }: AFScrollerProps) => {
|
||||
return (
|
||||
<Scrollbars
|
||||
autoHide
|
||||
renderThumbHorizontal={(props) => <div {...props} className='appflowy-scrollbar-thumb-horizontal' />}
|
||||
renderThumbVertical={(props) => <div {...props} className='appflowy-scrollbar-thumb-vertical' />}
|
||||
{...(overflowXHidden && {
|
||||
renderTrackHorizontal: (props) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
{...(overflowYHidden && {
|
||||
renderTrackVertical: (props) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
display: 'none',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
style={style}
|
||||
renderView={(props) => (
|
||||
<div
|
||||
{...props}
|
||||
style={{
|
||||
...props.style,
|
||||
overflowX: overflowXHidden ? 'hidden' : 'auto',
|
||||
overflowY: overflowYHidden ? 'hidden' : 'auto',
|
||||
marginRight: 0,
|
||||
marginBottom: 0,
|
||||
}}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './AFScroller';
|
27
frontend/appflowy_web_app/src/components/app/App.tsx
Normal file
27
frontend/appflowy_web_app/src/components/app/App.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import ProtectedRoutes from '@/components/auth/ProtectedRoutes';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import ProductPage from '@/pages/ProductPage';
|
||||
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||
|
||||
const AppMain = withAppWrapper(() => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path={'/'} element={<ProtectedRoutes />}>
|
||||
<Route path={'/workspace/:workspaceId/:collabType/:objectId'} element={<ProductPage />} />
|
||||
</Route>
|
||||
<Route path={'/login'} element={<LoginPage />} />
|
||||
</Routes>
|
||||
);
|
||||
});
|
||||
|
||||
function App () {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppMain />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
@ -5,13 +5,12 @@ import { useAppSelector } from '@/stores/store';
|
||||
|
||||
export const AFConfigContext = createContext<
|
||||
| {
|
||||
service: AFService | undefined;
|
||||
}
|
||||
service: AFService | undefined;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
|
||||
|
||||
function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
function AppConfig ({ children }: { children: React.ReactNode }) {
|
||||
const appConfig = useAppSelector((state) => state.app.appConfig);
|
||||
const [service, setService] = useState<AFService>();
|
||||
|
||||
@ -26,7 +25,7 @@ function AppConfig({ children }: { children: React.ReactNode }) {
|
||||
() => ({
|
||||
service,
|
||||
}),
|
||||
[service]
|
||||
[service],
|
||||
);
|
||||
|
||||
return <AFConfigContext.Provider value={config}>{children}</AFConfigContext.Provider>;
|
181
frontend/appflowy_web_app/src/components/app/AppTheme.tsx
Normal file
181
frontend/appflowy_web_app/src/components/app/AppTheme.tsx
Normal file
@ -0,0 +1,181 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import createTheme from '@mui/material/styles/createTheme';
|
||||
import ThemeProvider from '@mui/material/styles/ThemeProvider';
|
||||
import '@/i18n/config';
|
||||
|
||||
import 'src/styles/tailwind.css';
|
||||
import 'src/styles/template.css';
|
||||
|
||||
function AppTheme({ children }: { children: React.ReactNode }) {
|
||||
const isDark = false;
|
||||
const theme = useMemo(
|
||||
() =>
|
||||
createTheme({
|
||||
typography: {
|
||||
fontFamily: ['inherit'].join(','),
|
||||
fontSize: 12,
|
||||
button: {
|
||||
textTransform: 'none',
|
||||
},
|
||||
},
|
||||
components: {
|
||||
MuiMenuItem: {
|
||||
defaultProps: {
|
||||
sx: {
|
||||
'&.Mui-selected.Mui-focusVisible': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
'&.Mui-focusVisible': {
|
||||
backgroundColor: 'unset',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiIconButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
contained: {
|
||||
color: 'var(--content-on-fill)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
containedPrimary: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-default)',
|
||||
},
|
||||
},
|
||||
containedInherit: {
|
||||
color: 'var(--text-title)',
|
||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--bg-body)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
},
|
||||
outlinedInherit: {
|
||||
color: 'var(--text-title)',
|
||||
borderColor: 'var(--line-border)',
|
||||
'&:hover': {
|
||||
boxShadow: 'var(--shadow)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButtonBase: {
|
||||
defaultProps: {
|
||||
sx: {
|
||||
'&.Mui-selected:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
},
|
||||
},
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
backgroundImage: 'none',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
sx: {
|
||||
'& .MuiBackdrop-root': {
|
||||
backgroundColor: 'var(--bg-mask)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
MuiTooltip: {
|
||||
styleOverrides: {
|
||||
arrow: {
|
||||
color: 'var(--bg-tips)',
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'var(--bg-tips)',
|
||||
color: 'var(--text-title)',
|
||||
fontSize: '0.85rem',
|
||||
borderRadius: '8px',
|
||||
fontWeight: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiInputBase: {
|
||||
styleOverrides: {
|
||||
input: {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiDivider: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderColor: 'var(--line-divider)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
mode: isDark ? 'dark' : 'light',
|
||||
primary: {
|
||||
main: '#00BCF0',
|
||||
dark: '#00BCF0',
|
||||
},
|
||||
error: {
|
||||
main: '#FB006D',
|
||||
dark: '#D32772',
|
||||
},
|
||||
warning: {
|
||||
main: '#FFC107',
|
||||
dark: '#E9B320',
|
||||
},
|
||||
info: {
|
||||
main: '#00BCF0',
|
||||
dark: '#2E9DBB',
|
||||
},
|
||||
success: {
|
||||
main: '#66CF80',
|
||||
dark: '#3BA856',
|
||||
},
|
||||
text: {
|
||||
primary: isDark ? '#E2E9F2' : '#333333',
|
||||
secondary: isDark ? '#7B8A9D' : '#828282',
|
||||
disabled: isDark ? '#363D49' : '#F2F2F2',
|
||||
},
|
||||
divider: isDark ? '#59647A' : '#BDBDBD',
|
||||
background: {
|
||||
default: isDark ? '#1A202C' : '#FFFFFF',
|
||||
paper: isDark ? '#1A202C' : '#FFFFFF',
|
||||
},
|
||||
},
|
||||
}),
|
||||
[isDark]
|
||||
);
|
||||
|
||||
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
|
||||
}
|
||||
|
||||
export default AppTheme;
|
@ -0,0 +1,27 @@
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from 'src/stores/store';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ErrorHandlerPage } from 'src/components/error/ErrorHandlerPage';
|
||||
import AppTheme from '@/components/app/AppTheme';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import AppConfig from '@/components/app/AppConfig';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export default function withAppWrapper (Component: React.FC): React.FC {
|
||||
return function AppWrapper (): JSX.Element {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AppTheme>
|
||||
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
|
||||
<AppConfig>
|
||||
<Suspense>
|
||||
<Component />
|
||||
<Toaster />
|
||||
</Suspense>
|
||||
</AppConfig>
|
||||
</ErrorBoundary>
|
||||
</AppTheme>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
}
|
@ -4,7 +4,7 @@ import GithubIcon from '@/assets/settings/github.png';
|
||||
import DiscordIcon from '@/assets/settings/discord.png';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from './auth.hooks';
|
||||
import { ProviderType } from '@/application/services/user.type';
|
||||
import { ProviderType } from '@/application/user.type';
|
||||
import { useState } from 'react';
|
||||
import EmailOutlined from '@mui/icons-material/EmailOutlined';
|
||||
import SignInWithEmail from './SignInWithEmail';
|
||||
@ -17,6 +17,7 @@ export const LoginButtonGroup = () => {
|
||||
return (
|
||||
<div className={'flex w-full flex-col items-center gap-4'}>
|
||||
<Button
|
||||
data-cy={'signInWithEmail'}
|
||||
onClick={() => {
|
||||
setOpenSignInWithEmail(true);
|
||||
}}
|
||||
@ -24,7 +25,7 @@ export const LoginButtonGroup = () => {
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<EmailOutlined className={'mr-2 h-6 w-6'}/>
|
||||
<EmailOutlined className={'mr-2 h-6 w-6'} />
|
||||
{t('signIn.signInWithEmail')}
|
||||
</Button>
|
||||
<Button
|
||||
@ -35,7 +36,7 @@ export const LoginButtonGroup = () => {
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'}/>
|
||||
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'} />
|
||||
{t('button.signInGoogle')}
|
||||
</Button>
|
||||
<Button
|
||||
@ -46,7 +47,7 @@ export const LoginButtonGroup = () => {
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'}/>
|
||||
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'} />
|
||||
{t('button.signInGithub')}
|
||||
</Button>
|
||||
<Button
|
||||
@ -57,10 +58,10 @@ export const LoginButtonGroup = () => {
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
>
|
||||
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'}/>
|
||||
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'} />
|
||||
{t('button.signInDiscord')}
|
||||
</Button>
|
||||
<SignInWithEmail open={openSignInWithEmail} onClose={() => setOpenSignInWithEmail(false)}/>
|
||||
<SignInWithEmail open={openSignInWithEmail} onClose={() => setOpenSignInWithEmail(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -7,22 +7,28 @@ import SplashScreen from '@/components/auth/SplashScreen';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import Portal from '@mui/material/Portal';
|
||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
const TauriAuth = lazy(() => import('@/components/tauri/TauriAuth'));
|
||||
|
||||
function ProtectedRoutes () {
|
||||
const { currentUser, checkUser } = useAuth();
|
||||
const { currentUser, checkUser, isReady } = useAuth();
|
||||
|
||||
const isLoading = currentUser?.loginState === LoginState.LOADING;
|
||||
const [checked, setChecked] = useState(false);
|
||||
|
||||
const checkUserStatus = useCallback(async () => {
|
||||
if (!isReady) return;
|
||||
setChecked(false);
|
||||
try {
|
||||
await checkUser();
|
||||
if (!currentUser.isAuthenticated) {
|
||||
await checkUser();
|
||||
}
|
||||
|
||||
} finally {
|
||||
setChecked(true);
|
||||
}
|
||||
}, [checkUser]);
|
||||
}, [checkUser, isReady, currentUser.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
void checkUserStatus();
|
||||
@ -30,18 +36,26 @@ function ProtectedRoutes () {
|
||||
|
||||
const platform = useMemo(() => getPlatform(), []);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
console.log('ProtectedRoutes', currentUser, checked);
|
||||
if (checked && !currentUser.isAuthenticated && window.location.pathname !== '/login') {
|
||||
navigate(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'relative h-screen w-screen'}>
|
||||
{checked ? (
|
||||
<SplashScreen isAuthenticated={currentUser.isAuthenticated}/>
|
||||
<SplashScreen />
|
||||
) : (
|
||||
<div className={'flex h-screen w-screen items-center justify-center'}>
|
||||
<Logo className={'h-20 w-20'}/>
|
||||
<Logo className={'h-20 w-20'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <StartLoading/>}
|
||||
<Suspense>{platform.isTauri && <TauriAuth/>}</Suspense>
|
||||
{isLoading && <StartLoading />}
|
||||
<Suspense>{platform.isTauri && <TauriAuth />}</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -68,7 +82,7 @@ const StartLoading = () => {
|
||||
return (
|
||||
<Portal>
|
||||
<div className={'fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-bg-mask bg-opacity-50'}>
|
||||
<CircularProgress/>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
|
@ -9,10 +9,12 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const { signInWithEmailPassword } = useAuth();
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await signInWithEmailPassword(email, password);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
// Handle error
|
||||
}
|
||||
@ -27,9 +29,11 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
||||
sx={{
|
||||
zIndex: 1500,
|
||||
}}
|
||||
data-cy={'signInWithEmailDialog'}
|
||||
PaperProps={{
|
||||
className: 'w-[400px]',
|
||||
}}
|
||||
keepMounted={false}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@ -41,6 +45,7 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
||||
<TextField
|
||||
label={'Email'}
|
||||
size={'small'}
|
||||
data-cy={'email'}
|
||||
required={true}
|
||||
placeholder={'name@gmail.com'}
|
||||
type={'email'}
|
||||
@ -48,6 +53,7 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
||||
/>
|
||||
<TextField
|
||||
size={'small'}
|
||||
data-cy={'password'}
|
||||
required={true}
|
||||
label={'Password'}
|
||||
placeholder={'Password'}
|
||||
@ -59,8 +65,15 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
||||
<Button variant={'outlined'} className={'flex-1'} color={'inherit'} onClick={onClose}>
|
||||
{t('button.cancel')}
|
||||
</Button>
|
||||
<Button disabled={loading} className={'flex-1'} variant={'contained'} onClick={handleSignIn}>
|
||||
{loading ? <CircularProgress size={12} /> : t('button.signIn')}
|
||||
<Button
|
||||
data-cy={'submit'}
|
||||
disabled={loading}
|
||||
className={'justify-content flex h-[33px] flex-1 items-center gap-2'}
|
||||
variant={'contained'}
|
||||
onClick={handleSignIn}
|
||||
>
|
||||
{loading && <CircularProgress size={20} />}
|
||||
{t('button.signIn')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
@ -1,22 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Layout from '@/components/layout/Layout';
|
||||
import Welcome from './Welcome';
|
||||
|
||||
function SplashScreen({
|
||||
isAuthenticated,
|
||||
}: {
|
||||
isAuthenticated: boolean;
|
||||
}) {
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet/>
|
||||
</Layout>
|
||||
);
|
||||
} else {
|
||||
return <Welcome/>;
|
||||
}
|
||||
function SplashScreen () {
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet/>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
export default SplashScreen;
|
34
frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx
Normal file
34
frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import Welcome from './Welcome';
|
||||
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||
|
||||
describe('<Welcome />', () => {
|
||||
beforeEach(() => {
|
||||
cy.mockAPI();
|
||||
});
|
||||
|
||||
it('renders', () => {
|
||||
const AppWrapper = withAppWrapper(Welcome);
|
||||
|
||||
cy.mount(<AppWrapper />);
|
||||
});
|
||||
|
||||
it('should handle login success', () => {
|
||||
const AppWrapper = withAppWrapper(Welcome);
|
||||
|
||||
cy.mount(<AppWrapper />);
|
||||
|
||||
cy.get('[data-cy=signInWithEmail]').click();
|
||||
|
||||
cy.wait(100);
|
||||
|
||||
cy.get('[data-cy=signInWithEmailDialog]').as('dialog').should('be.visible');
|
||||
cy.get('[data-cy=email]').type('fakeEmail123');
|
||||
cy.get('[data-cy=password]').type('fakePassword123');
|
||||
cy.get('[data-cy=submit]').click();
|
||||
cy.wait('@loginSuccess');
|
||||
cy.wait('@verifyToken');
|
||||
cy.wait('@getUserProfile');
|
||||
cy.get('@dialog').should('not.exist');
|
||||
});
|
||||
});
|
@ -1,51 +1,39 @@
|
||||
import { ReactComponent as AppflowyLogo } from '@/assets/logo.svg';
|
||||
import Button from '@mui/material/Button';
|
||||
import { Stack } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from './auth.hooks';
|
||||
import { LoginButtonGroup } from './LoginButtonGroup';
|
||||
import { getPlatform } from '@/utils/platform';
|
||||
import { lazy } from 'react';
|
||||
|
||||
const SignInAsAnonymous = lazy(() => import('@/components/tauri/SignInAsAnonymous'));
|
||||
|
||||
export const Welcome = () => {
|
||||
const { signInAsAnonymous } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={(e) => e.preventDefault()} method="POST">
|
||||
<div
|
||||
className="relative flex h-screen w-screen flex-col items-center justify-center gap-12 bg-bg-body text-center text-text-title">
|
||||
<div className="flex justify-center" id="appflowy">
|
||||
<AppflowyLogo className={'h-16 w-16'}/>
|
||||
<form onSubmit={(e) => e.preventDefault()} method='POST'>
|
||||
<Stack className='relative flex h-screen w-screen flex-col items-center justify-center gap-12 bg-bg-body text-center text-text-title'>
|
||||
<div className='flex justify-center' id='appflowy'>
|
||||
<AppflowyLogo className={'h-16 w-16'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-2xl font-semibold leading-9">
|
||||
<span className='text-2xl font-semibold leading-9'>
|
||||
{t('welcomeTo')} {t('appName')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="Get-Started" className="flex w-[340px] flex-col gap-4 " aria-label="Get-Started">
|
||||
<Button
|
||||
size={'large'}
|
||||
color={'inherit'}
|
||||
className={'border-transparent bg-line-divider py-3'}
|
||||
variant={'outlined'}
|
||||
onClick={signInAsAnonymous}
|
||||
>
|
||||
{t('signIn.loginStartWithAnonymous')}
|
||||
</Button>
|
||||
<div className={'flex w-full items-center justify-center gap-2 text-sm'}>
|
||||
<div className={'h-px flex-1 bg-line-divider'}/>
|
||||
{t('signIn.or')}
|
||||
<div className={'h-px flex-1 bg-line-divider'}/>
|
||||
</div>
|
||||
<div id='Get-Started' className='flex w-[340px] flex-col gap-4 ' aria-label='Get-Started'>
|
||||
{getPlatform().isTauri && <SignInAsAnonymous />}
|
||||
<div className={'w-w-full flex items-center justify-center gap-2 text-sm'}>
|
||||
<LoginButtonGroup/>
|
||||
<LoginButtonGroup />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Welcome;
|
||||
export default Welcome;
|
||||
|
@ -2,22 +2,24 @@ import { useAppDispatch, useAppSelector } from '@/stores/store';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { open } from '@tauri-apps/api/shell';
|
||||
import { ProviderType, UserProfile } from '@/application/services/user.type';
|
||||
import { ProviderType, UserProfile } from '@/application/user.type';
|
||||
import { currentUserActions } from '@/stores/currentUser/slice';
|
||||
import { AFConfigContext } from '@/AppConfig';
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
|
||||
export const useAuth = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const AFConfig = useContext(AFConfigContext);
|
||||
const currentUser = useAppSelector((state) => state.currentUser);
|
||||
const isReady = !!AFConfig?.service;
|
||||
|
||||
const handleSuccess = useCallback(() => {
|
||||
notify.clear();
|
||||
dispatch(currentUserActions.loginSuccess());
|
||||
}, [dispatch]);
|
||||
|
||||
const setUser = useCallback(
|
||||
async (userProfile: Partial<UserProfile>) => {
|
||||
async (userProfile: UserProfile) => {
|
||||
handleSuccess();
|
||||
dispatch(currentUserActions.updateUser(userProfile));
|
||||
},
|
||||
@ -41,8 +43,13 @@ export const useAuth = () => {
|
||||
|
||||
// Check if the user is authenticated
|
||||
const checkUser = useCallback(async () => {
|
||||
handleStart();
|
||||
try {
|
||||
const userHasSignIn = await AFConfig?.service?.userService.checkUser();
|
||||
|
||||
if (!userHasSignIn) {
|
||||
throw new Error('Failed to check user');
|
||||
}
|
||||
|
||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||
|
||||
if (!userProfile) {
|
||||
@ -53,13 +60,9 @@ export const useAuth = () => {
|
||||
|
||||
return userProfile;
|
||||
} catch (e) {
|
||||
handleError({
|
||||
message: 'Failed to check user',
|
||||
});
|
||||
|
||||
return Promise.reject('Failed to check user');
|
||||
}
|
||||
}, [AFConfig?.service?.userService, handleError, handleStart, setUser]);
|
||||
}, [AFConfig?.service?.userService, setUser]);
|
||||
|
||||
const register = useCallback(
|
||||
async (email: string, password: string, name: string): Promise<UserProfile | null> => {
|
||||
@ -114,7 +117,7 @@ export const useAuth = () => {
|
||||
const url = await AFConfig?.service?.authService.getOAuthURL(provider);
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Failed to sign in');
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await open(url);
|
||||
@ -135,7 +138,7 @@ export const useAuth = () => {
|
||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||
|
||||
if (!userProfile) {
|
||||
throw new Error('Failed to sign in');
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await setUser(userProfile);
|
||||
@ -154,10 +157,12 @@ export const useAuth = () => {
|
||||
async (email: string, password: string) => {
|
||||
handleStart();
|
||||
try {
|
||||
const userProfile = await AFConfig?.service?.authService.signinWithEmailPassword(email, password);
|
||||
await AFConfig?.service?.authService.signinWithEmailPassword(email, password);
|
||||
|
||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||
|
||||
if (!userProfile) {
|
||||
throw new Error('Failed to sign in');
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
await setUser(userProfile);
|
||||
@ -169,10 +174,11 @@ export const useAuth = () => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[AFConfig?.service?.authService, handleError, handleStart, setUser]
|
||||
[AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser]
|
||||
);
|
||||
|
||||
return {
|
||||
isReady,
|
||||
currentUser,
|
||||
checkUser,
|
||||
register,
|
||||
|
@ -0,0 +1,19 @@
|
||||
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||
import { Editor } from '@/components/editor';
|
||||
import React from 'react';
|
||||
|
||||
export const Document = () => {
|
||||
const { objectId: documentId, workspaceId } = useId() || {};
|
||||
|
||||
if (!documentId || !workspaceId) return null;
|
||||
|
||||
return (
|
||||
<div className={'relative w-full'}>
|
||||
<div className={'flex w-full justify-center'}>
|
||||
<div className={'max-w-screen mt-6 w-[964px] min-w-0'}>
|
||||
<Editor readOnly={true} documentId={documentId} workspaceId={workspaceId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './Document';
|
@ -0,0 +1,33 @@
|
||||
import { withYjs, YjsEditor } from '@/application/slate-yjs/plugins/withYjs';
|
||||
import EditorEditable from '@/components/editor/Editable';
|
||||
import { withPlugins } from '@/components/editor/plugins';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createEditor, Descendant } from 'slate';
|
||||
import { Slate, withReact } from 'slate-react';
|
||||
import * as Y from 'yjs';
|
||||
|
||||
const defaultInitialValue: Descendant[] = [];
|
||||
|
||||
function CollaborativeEditor({ doc }: { doc: Y.Doc }) {
|
||||
const editor = useMemo(() => doc && (withPlugins(withReact(withYjs(createEditor(), doc))) as YjsEditor), [doc]);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [_, setIsConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
editor.connect();
|
||||
setIsConnected(true);
|
||||
|
||||
return () => {
|
||||
editor.disconnect();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
return (
|
||||
<Slate editor={editor} initialValue={defaultInitialValue}>
|
||||
<EditorEditable editor={editor} />
|
||||
</Slate>
|
||||
);
|
||||
}
|
||||
|
||||
export default CollaborativeEditor;
|
35
frontend/appflowy_web_app/src/components/editor/Editable.tsx
Normal file
35
frontend/appflowy_web_app/src/components/editor/Editable.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { useDecorate } from '@/components/editor/components/blocks/code/useDecorate';
|
||||
import { Leaf } from '@/components/editor/components/leaf';
|
||||
import { useEditorContext } from '@/components/editor/EditorContext';
|
||||
import React, { useCallback } from 'react';
|
||||
import { NodeEntry } from 'slate';
|
||||
import { Editable, ReactEditor } from 'slate-react';
|
||||
import { Element } from './components/element';
|
||||
|
||||
const EditorEditable = ({ editor }: { editor: ReactEditor }) => {
|
||||
const { readOnly } = useEditorContext();
|
||||
const codeDecorate = useDecorate(editor);
|
||||
|
||||
const decorate = useCallback(
|
||||
(entry: NodeEntry) => {
|
||||
return [...codeDecorate(entry)];
|
||||
},
|
||||
[codeDecorate]
|
||||
);
|
||||
|
||||
return (
|
||||
<Editable
|
||||
role={'textbox'}
|
||||
decorate={decorate}
|
||||
className={'px-16 outline-none focus:outline-none max-md:px-4'}
|
||||
renderLeaf={Leaf}
|
||||
renderElement={Element}
|
||||
readOnly={readOnly}
|
||||
spellCheck={false}
|
||||
autoCorrect={'off'}
|
||||
autoComplete={'off'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorEditable;
|
@ -0,0 +1,37 @@
|
||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||
import { DocumentTest } from '@/../cypress/support/document';
|
||||
import { nanoid } from 'nanoid';
|
||||
import React from 'react';
|
||||
import { Editor } from './Editor';
|
||||
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||
|
||||
describe('<Editor />', () => {
|
||||
it('renders with a paragraph', () => {
|
||||
const documentTest = new DocumentTest();
|
||||
|
||||
documentTest.insertParagraph('Hello, world!');
|
||||
cy.stub(JSDocumentService.prototype, 'openDocument').returns(Promise.resolve(documentTest.doc));
|
||||
renderEditor();
|
||||
cy.get('[role="textbox"]').should('contain', 'Hello, world!');
|
||||
});
|
||||
|
||||
it('renders with a full document', () => {
|
||||
cy.mockFullDocument();
|
||||
renderEditor();
|
||||
});
|
||||
});
|
||||
|
||||
function renderEditor() {
|
||||
const documentId = nanoid(8);
|
||||
const workspaceId = nanoid(8);
|
||||
|
||||
const AppWrapper = withAppWrapper(() => {
|
||||
return (
|
||||
<div className={'h-screen w-screen overflow-y-auto'}>
|
||||
<Editor documentId={documentId} readOnly workspaceId={workspaceId} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
cy.mount(<AppWrapper />);
|
||||
}
|
48
frontend/appflowy_web_app/src/components/editor/Editor.tsx
Normal file
48
frontend/appflowy_web_app/src/components/editor/Editor.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||
import CollaborativeEditor from '@/components/editor/CollaborativeEditor';
|
||||
import { EditorContextProvider } from '@/components/editor/EditorContext';
|
||||
import { CircularProgress } from '@mui/material';
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import './editor.scss';
|
||||
|
||||
export const Editor = ({
|
||||
workspaceId,
|
||||
documentId,
|
||||
readOnly,
|
||||
}: {
|
||||
documentId: string;
|
||||
workspaceId: string;
|
||||
readOnly: boolean;
|
||||
}) => {
|
||||
const [doc, setDoc] = useState<Y.Doc>();
|
||||
|
||||
const documentService = useContext(AFConfigContext)?.service?.documentService;
|
||||
|
||||
const handleOpenDocument = useCallback(async () => {
|
||||
if (!documentService) return;
|
||||
const doc = await documentService.openDocument(workspaceId, documentId);
|
||||
|
||||
setDoc(doc);
|
||||
}, [documentService, workspaceId, documentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void handleOpenDocument();
|
||||
}, [handleOpenDocument]);
|
||||
|
||||
if (!doc) {
|
||||
return (
|
||||
<div className={'justify-content flex h-full w-full items-center'}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<EditorContextProvider readOnly={readOnly}>
|
||||
<CollaborativeEditor doc={doc} />
|
||||
</EditorContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Editor;
|
@ -0,0 +1,17 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
interface EditorContextState {
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
export const EditorContext = createContext<EditorContextState>({
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
export const EditorContextProvider = ({ children, ...props }: EditorContextState & { children: React.ReactNode }) => {
|
||||
return <EditorContext.Provider value={props}>{children}</EditorContext.Provider>;
|
||||
};
|
||||
|
||||
export function useEditorContext() {
|
||||
return useContext(EditorContext);
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
import { BulletedListNode, EditorElementProps } from '@/components/editor/editor.type';
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
|
||||
export const BulletedList = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(
|
||||
({ node: _, children, className, ...attributes }, ref) => {
|
||||
return (
|
||||
<div ref={ref} {...attributes} className={`${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export default BulletedList;
|
@ -0,0 +1,49 @@
|
||||
import { BulletedListNode } from '@/components/editor/editor.type';
|
||||
import { getListLevel } from '@/components/editor/utils/list';
|
||||
import React, { useMemo } from 'react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
|
||||
enum Letter {
|
||||
Disc,
|
||||
Circle,
|
||||
Square,
|
||||
}
|
||||
|
||||
export function BulletedListIcon({ block, className }: { block: BulletedListNode; className: string }) {
|
||||
const staticEditor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(staticEditor, block);
|
||||
|
||||
const letter = useMemo(() => {
|
||||
const level = getListLevel(staticEditor, block.type, path);
|
||||
|
||||
if (level % 3 === 0) {
|
||||
return Letter.Disc;
|
||||
} else if (level % 3 === 1) {
|
||||
return Letter.Circle;
|
||||
} else {
|
||||
return Letter.Square;
|
||||
}
|
||||
}, [block.type, staticEditor, path]);
|
||||
|
||||
const dataLetter = useMemo(() => {
|
||||
switch (letter) {
|
||||
case Letter.Disc:
|
||||
return '•';
|
||||
case Letter.Circle:
|
||||
return '◦';
|
||||
case Letter.Square:
|
||||
return '▪';
|
||||
}
|
||||
}, [letter]);
|
||||
|
||||
return (
|
||||
<span
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
data-letter={dataLetter}
|
||||
contentEditable={false}
|
||||
className={`${className} bulleted-icon flex min-w-[24px] justify-center pr-1 font-medium`}
|
||||
/>
|
||||
);
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
export * from './BulletedList';
|
||||
export * from './BulletedListIcon';
|
@ -0,0 +1,22 @@
|
||||
import { EditorElementProps, CalloutNode } from '@/components/editor/editor.type';
|
||||
import React, { forwardRef, memo } from 'react';
|
||||
import CalloutIcon from './CalloutIcon';
|
||||
|
||||
export const Callout = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<CalloutNode>>(({ node, children, ...attributes }, ref) => {
|
||||
return (
|
||||
<>
|
||||
<div contentEditable={false} className={'absolute w-full select-none px-2 pt-[15px]'}>
|
||||
<CalloutIcon node={node} />
|
||||
</div>
|
||||
<div ref={ref} className={`${attributes.className ?? ''} w-full bg-bg-body py-2`}>
|
||||
<div {...attributes} className={`flex w-full flex-col rounded bg-content-blue-50 py-2 pl-10`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
export default Callout;
|
@ -0,0 +1,17 @@
|
||||
import { CalloutNode } from '@/components/editor/editor.type';
|
||||
import React, { useRef } from 'react';
|
||||
import { IconButton } from '@mui/material';
|
||||
|
||||
function CalloutIcon({ node }: { node: CalloutNode }) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton contentEditable={false} ref={ref} className={`h-8 w-8 p-1`}>
|
||||
{node.data.icon}
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(CalloutIcon);
|
@ -0,0 +1 @@
|
||||
export * from './Callout';
|
@ -0,0 +1,27 @@
|
||||
import { CodeNode } from '@/components/editor/editor.type';
|
||||
import { useCallback } from 'react';
|
||||
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||
import { Element as SlateElement, Transforms } from 'slate';
|
||||
|
||||
export function useCodeBlock(node: CodeNode) {
|
||||
const language = node.data.language;
|
||||
const editor = useSlateStatic() as ReactEditor;
|
||||
const handleChangeLanguage = useCallback(
|
||||
(newLang: string) => {
|
||||
const path = ReactEditor.findPath(editor, node);
|
||||
const newProperties = {
|
||||
data: {
|
||||
language: newLang,
|
||||
},
|
||||
} as Partial<SlateElement>;
|
||||
|
||||
Transforms.setNodes(editor, newProperties, { at: path });
|
||||
},
|
||||
[editor, node]
|
||||
);
|
||||
|
||||
return {
|
||||
language,
|
||||
handleChangeLanguage,
|
||||
};
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
import { useCodeBlock } from '@/components/editor/components/blocks/code/Code.hooks';
|
||||
import { CodeNode, EditorElementProps } from '@/components/editor/editor.type';
|
||||
import { forwardRef, memo } from 'react';
|
||||
import LanguageSelect from './SelectLanguage';
|
||||
|
||||
export const CodeBlock = memo(
|
||||
forwardRef<HTMLDivElement, EditorElementProps<CodeNode>>(({ node, children, ...attributes }, ref) => {
|
||||
const { language, handleChangeLanguage } = useCodeBlock(node);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div contentEditable={false} className={'absolute mt-2 flex h-20 w-full select-none items-center px-6'}>
|
||||
<LanguageSelect readOnly language={language} onChangeLanguage={handleChangeLanguage} />
|
||||
</div>
|
||||
<div {...attributes} ref={ref} className={`${attributes.className ?? ''} flex w-full bg-bg-body py-2`}>
|
||||
<pre
|
||||
spellCheck={false}
|
||||
className={`flex w-full rounded border border-solid border-line-divider bg-content-blue-50 p-5 pt-20`}
|
||||
>
|
||||
<code>{children}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})
|
||||
);
|
@ -0,0 +1,43 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { TextField } from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
function SelectLanguage({
|
||||
readOnly,
|
||||
language = 'json',
|
||||
}: {
|
||||
readOnly?: boolean;
|
||||
language: string;
|
||||
onChangeLanguage: (language: string) => void;
|
||||
onBlur?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
ref={ref}
|
||||
size={'small'}
|
||||
variant={'standard'}
|
||||
sx={{
|
||||
'& .MuiInputBase-root, & .MuiInputBase-input': {
|
||||
userSelect: 'none',
|
||||
},
|
||||
}}
|
||||
className={'w-[150px]'}
|
||||
value={language}
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
}}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
placeholder={t('document.codeBlock.language.placeholder')}
|
||||
label={t('document.codeBlock.language.label')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default SelectLanguage;
|
@ -0,0 +1,154 @@
|
||||
export const supportLanguage = [
|
||||
{
|
||||
id: 'bash',
|
||||
title: 'Bash',
|
||||
},
|
||||
{
|
||||
id: 'basic',
|
||||
title: 'Basic',
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
title: 'C',
|
||||
},
|
||||
{
|
||||
id: 'clojure',
|
||||
title: 'Clojure',
|
||||
},
|
||||
{
|
||||
id: 'cpp',
|
||||
title: 'C++',
|
||||
},
|
||||
{
|
||||
id: 'cs',
|
||||
title: 'CS',
|
||||
},
|
||||
{
|
||||
id: 'css',
|
||||
title: 'CSS',
|
||||
},
|
||||
{
|
||||
id: 'dart',
|
||||
title: 'Dart',
|
||||
},
|
||||
{
|
||||
id: 'elixir',
|
||||
title: 'Elixir',
|
||||
},
|
||||
{
|
||||
id: 'elm',
|
||||
title: 'Elm',
|
||||
},
|
||||
{
|
||||
id: 'erlang',
|
||||
title: 'Erlang',
|
||||
},
|
||||
{
|
||||
id: 'fortran',
|
||||
title: 'Fortran',
|
||||
},
|
||||
{
|
||||
id: 'go',
|
||||
title: 'Go',
|
||||
},
|
||||
{
|
||||
id: 'graphql',
|
||||
title: 'GraphQL',
|
||||
},
|
||||
{
|
||||
id: 'haskell',
|
||||
title: 'Haskell',
|
||||
},
|
||||
{
|
||||
id: 'java',
|
||||
title: 'Java',
|
||||
},
|
||||
{
|
||||
id: 'javascript',
|
||||
title: 'JavaScript',
|
||||
},
|
||||
{
|
||||
id: 'json',
|
||||
title: 'JSON',
|
||||
},
|
||||
{
|
||||
id: 'kotlin',
|
||||
title: 'Kotlin',
|
||||
},
|
||||
{
|
||||
id: 'lisp',
|
||||
title: 'Lisp',
|
||||
},
|
||||
{
|
||||
id: 'lua',
|
||||
title: 'Lua',
|
||||
},
|
||||
{
|
||||
id: 'markdown',
|
||||
title: 'Markdown',
|
||||
},
|
||||
{
|
||||
id: 'matlab',
|
||||
title: 'Matlab',
|
||||
},
|
||||
{
|
||||
id: 'ocaml',
|
||||
title: 'OCaml',
|
||||
},
|
||||
{
|
||||
id: 'perl',
|
||||
title: 'Perl',
|
||||
},
|
||||
{
|
||||
id: 'php',
|
||||
title: 'PHP',
|
||||
},
|
||||
{
|
||||
id: 'powershell',
|
||||
title: 'Powershell',
|
||||
},
|
||||
{
|
||||
id: 'python',
|
||||
title: 'Python',
|
||||
},
|
||||
{
|
||||
id: 'r',
|
||||
title: 'R',
|
||||
},
|
||||
{
|
||||
id: 'ruby',
|
||||
title: 'Ruby',
|
||||
},
|
||||
{
|
||||
id: 'rust',
|
||||
title: 'Rust',
|
||||
},
|
||||
{
|
||||
id: 'scala',
|
||||
title: 'Scala',
|
||||
},
|
||||
{
|
||||
id: 'shell',
|
||||
title: 'Shell',
|
||||
},
|
||||
{
|
||||
id: 'sql',
|
||||
title: 'SQL',
|
||||
},
|
||||
{
|
||||
id: 'swift',
|
||||
title: 'Swift',
|
||||
},
|
||||
{
|
||||
id: 'typescript',
|
||||
title: 'TypeScript',
|
||||
},
|
||||
{
|
||||
id: 'xml',
|
||||
title: 'XML',
|
||||
},
|
||||
{
|
||||
id: 'yaml',
|
||||
title: 'YAML',
|
||||
},
|
||||
];
|
@ -0,0 +1 @@
|
||||
export * from './Code';
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user