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:
Kilu.He 2024-04-29 14:32:14 +08:00 committed by GitHub
parent 969726ef73
commit 9135fb94ad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
177 changed files with 5872 additions and 1089 deletions

View File

@ -1,4 +1,4 @@
name: Tauri2-CI
name: Tauri-CI
on:
pull_request:
paths:

View File

@ -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"

View File

@ -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
View 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

View File

@ -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>
);

View File

@ -4,4 +4,6 @@ src-tauri/
.eslintrc.cjs
tsconfig.json
**/backend/**
vite.config.ts
vite.config.ts
**/*.cy.tsx
*.config.ts

View File

@ -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
```

View 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,
},
});

File diff suppressed because one or more lines are too long

View File

@ -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

View 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."
}

View File

@ -0,0 +1,6 @@
{
"code": 0,
"data": {
"is_new": false
}
}

View 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();
// });

View File

@ -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>

View 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 />)

View 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;
}
}

View File

@ -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>

View 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})`],
};

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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;

View 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',
}

View File

@ -0,0 +1,2 @@
export const databasePrefix = 'af_database';

View 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;
}

View File

@ -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;
}

View File

@ -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();
};
}

View File

@ -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();
}

View File

@ -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',
};

View File

@ -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;
};
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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,
},
};
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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}`;
}

View File

@ -0,0 +1,2 @@
export * from './token';
export * from './user';

View File

@ -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();
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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,
};
}

View File

@ -0,0 +1 @@
export * as APIService from './client_api';

View File

@ -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>;
}

View File

@ -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,
};
}

View File

@ -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');
}
}

View File

@ -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
}
}

View File

@ -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);
}
}

View File

@ -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);
// }
// }
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -0,0 +1 @@
export * from './plugins/withYjs';

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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 [];
}

View File

@ -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 [];
}

View File

@ -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');
}

View File

@ -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 [];
}

View File

@ -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 [];
}

View File

@ -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 {

View File

@ -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 {};

View File

@ -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
);
}

View File

@ -0,0 +1 @@
export * from 'src/application/ydoc/apply/document';

View File

@ -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);
}

View File

@ -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;

View File

@ -0,0 +1,4 @@
.katex-html {
white-space: normal;
}

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './AFScroller';

View 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;

View File

@ -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>;

View 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;

View File

@ -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>
);
};
}

View File

@ -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>
);
};

View File

@ -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>
);

View File

@ -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>

View File

@ -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;

View 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');
});
});

View File

@ -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;

View File

@ -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,

View File

@ -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>
);
};

View File

@ -0,0 +1 @@
export * from './Document';

View File

@ -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;

View 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;

View File

@ -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 />);
}

View 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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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`}
/>
);
}

View File

@ -0,0 +1,2 @@
export * from './BulletedList';
export * from './BulletedListIcon';

View File

@ -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;

View File

@ -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);

View File

@ -0,0 +1 @@
export * from './Callout';

View File

@ -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,
};
}

View File

@ -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>
</>
);
})
);

View File

@ -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;

View File

@ -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',
},
];

View File

@ -0,0 +1 @@
export * from './Code';

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