mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 18:16:15 +08:00
feat: add basic tauri client app
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import styled from '@emotion/styled';
|
||||
import { routes } from '../pages/routes.js';
|
||||
|
||||
const Container = styled.nav`
|
||||
height: var(--title-bar-height);
|
||||
background: #329ea3;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
`;
|
||||
const RouteSelect = styled.select`
|
||||
margin-left: 100px;
|
||||
`;
|
||||
|
||||
export function TitleBar() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Container data-tauri-drag-region>
|
||||
<RouteSelect
|
||||
onChange={event => {
|
||||
navigate(event?.target?.value);
|
||||
}}
|
||||
>
|
||||
{routes.map(route => (
|
||||
<option key={route.path} value={route.path}>
|
||||
{route.name}
|
||||
</option>
|
||||
))}
|
||||
</RouteSelect>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import * as Y from 'yjs';
|
||||
import { Observable } from 'lib0/observable';
|
||||
import { DocProvider } from '@blocksuite/store';
|
||||
import type { Awareness } from 'y-protocols/awareness';
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { updateYDocument } from './methods';
|
||||
|
||||
export class TauriIPCProvider
|
||||
extends Observable<string>
|
||||
implements DocProvider
|
||||
{
|
||||
#yDocument: Y.Doc;
|
||||
constructor(
|
||||
room: string,
|
||||
yDocument: Y.Doc,
|
||||
options?: { awareness?: Awareness }
|
||||
) {
|
||||
super();
|
||||
this.#yDocument = yDocument;
|
||||
this.#yDocument.on(
|
||||
'updateV2',
|
||||
async (
|
||||
update: Uint8Array,
|
||||
origin: any,
|
||||
_yDocument: Y.Doc,
|
||||
_transaction: Y.Transaction
|
||||
) => {
|
||||
try {
|
||||
// TODO: need handle potential data race when update is frequent?
|
||||
// TODO: update seems too frequent upon each keydown, why no batching?
|
||||
const success = await updateYDocument({
|
||||
update: Array.from(update),
|
||||
room,
|
||||
});
|
||||
} catch (error) {
|
||||
// TODO: write error log to disk, and add button to open them in settings panel
|
||||
console.error("#yDocument.on('updateV2'", error);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
connect() {}
|
||||
|
||||
destroy() {}
|
||||
disconnect() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
async clearData() {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { invoke } from '@tauri-apps/api';
|
||||
import { YDocumentUpdate } from '../types/ipc/document';
|
||||
import { CreateWorkspace } from '../types/ipc/workspace';
|
||||
import { GetBlob, PutBlob } from '../types/ipc/blob';
|
||||
|
||||
export const updateYDocument = async (parameters: YDocumentUpdate) =>
|
||||
await invoke<boolean>('update_y_document', {
|
||||
parameters,
|
||||
});
|
||||
|
||||
export const createWorkspace = async (parameters: CreateWorkspace) =>
|
||||
await invoke<boolean>('create_workspace', {
|
||||
parameters,
|
||||
});
|
||||
|
||||
export const putBlob = async (parameters: PutBlob) =>
|
||||
await invoke<string>('put_blob', {
|
||||
parameters,
|
||||
});
|
||||
|
||||
export const getBlob = async (parameters: GetBlob) =>
|
||||
await invoke<number[]>('get_blob', {
|
||||
parameters,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
:root {
|
||||
--title-bar-height: 30px;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
margin-top: var(--title-bar-height);
|
||||
height: calc(100vh - var(--title-bar-height));
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#react-root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import '@emotion/react';
|
||||
import { StrictMode } from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { router } from './pages/routes.js';
|
||||
import './main.css';
|
||||
|
||||
const root = document.querySelector('#react-root');
|
||||
if (root !== null) {
|
||||
ReactDOM.createRoot(root).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { redirect } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function AffineBasicPage() {
|
||||
useEffect(() => {
|
||||
location.href = '/affine-out/index.html';
|
||||
redirect('/affine-out/index.html');
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function LandingPage() {
|
||||
return <div>TBD</div>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { TitleBar } from '../components/TitleBar.js';
|
||||
|
||||
export function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<TitleBar />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import { AffineBasicPage } from './AFFiNE/index.js';
|
||||
import { LandingPage } from './Landing/index.js';
|
||||
import { RootLayout } from './Root.js';
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Landing Page',
|
||||
element: <LandingPage />,
|
||||
},
|
||||
{
|
||||
path: '/affine',
|
||||
name: 'AFFiNE Basic',
|
||||
element: <AffineBasicPage />,
|
||||
},
|
||||
];
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/', element: <RootLayout />, children: routes },
|
||||
]);
|
||||
@@ -0,0 +1,5 @@
|
||||
# Preload Scripts
|
||||
|
||||
Here are preload scripts (See [tauri's doc](https://tauri.app/v1/references/architecture/inter-process-communication/isolation)). This is simillar to Electron's [preload script](https://www.electronjs.org/docs/latest/tutorial/sandbox#preload-scripts).
|
||||
|
||||
We pass env variables to AFFiNE side from here.
|
||||
@@ -0,0 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
|
||||
// tauri preload script can't have `export {}`
|
||||
// @ts-ignore 'index.ts' cannot be compiled under '--isolatedModules' because it is considered a global script file. Add an import, export, or an empty 'export {}' statement to make it a module.ts(1208)
|
||||
window.__TAURI_ISOLATION_HOOK__ = payload => {
|
||||
console.log('Tauri isolation hook', payload);
|
||||
|
||||
return payload;
|
||||
};
|
||||
|
||||
/**
|
||||
* Give AFFiNE app code some env to know it is inside a tauri app.
|
||||
*/
|
||||
function setEnvironmentVariables() {
|
||||
window.CLIENT_APP = true;
|
||||
}
|
||||
|
||||
setEnvironmentVariables();
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copied from packages/data-services/src/sdks/workspace.ts
|
||||
* // TODO: after it published, use that package
|
||||
*/
|
||||
export enum WorkspaceType {
|
||||
Private = 0,
|
||||
Normal = 1,
|
||||
}
|
||||
export enum PermissionType {
|
||||
Read = 0,
|
||||
Write = 1,
|
||||
Admin = 2,
|
||||
Owner = 3,
|
||||
}
|
||||
export interface Workspace {
|
||||
avatar: string;
|
||||
id: number;
|
||||
create_at: number;
|
||||
name: string;
|
||||
permission_type: PermissionType;
|
||||
public: boolean;
|
||||
type: WorkspaceType;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// TODO: find official typings if available
|
||||
type IsolationPayload = unknown;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CLIENT_APP?: boolean;
|
||||
__TAURI_ISOLATION_HOOK__: (payload: IsolationPayload) => IsolationPayload;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "IBlobParameters",
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["Put"],
|
||||
"properties": {
|
||||
"Put": {
|
||||
"$ref": "#/definitions/PutBlob"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["Get"],
|
||||
"properties": {
|
||||
"Get": {
|
||||
"$ref": "#/definitions/GetBlob"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"GetBlob": {
|
||||
"type": "object",
|
||||
"required": ["id", "workspace_id"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
}
|
||||
}
|
||||
},
|
||||
"PutBlob": {
|
||||
"type": "object",
|
||||
"required": ["blob", "workspace_id"],
|
||||
"properties": {
|
||||
"blob": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"minimum": 0.0
|
||||
}
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "integer",
|
||||
"format": "uint64",
|
||||
"minimum": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/* tslint:disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
export type IBlobParameters =
|
||||
| {
|
||||
Put: PutBlob;
|
||||
}
|
||||
| {
|
||||
Get: GetBlob;
|
||||
};
|
||||
|
||||
export interface PutBlob {
|
||||
[k: string]: unknown;
|
||||
blob: number[];
|
||||
workspace_id: number;
|
||||
}
|
||||
export interface GetBlob {
|
||||
[k: string]: unknown;
|
||||
id: string;
|
||||
workspace_id: number;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "YDocumentUpdate",
|
||||
"type": "object",
|
||||
"required": ["room", "update"],
|
||||
"properties": {
|
||||
"room": {
|
||||
"type": "string"
|
||||
},
|
||||
"update": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "uint8",
|
||||
"minimum": 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* tslint:disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
export interface YDocumentUpdate {
|
||||
[k: string]: unknown;
|
||||
room: string;
|
||||
update: number[];
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "CreateWorkspace",
|
||||
"type": "object",
|
||||
"required": ["avatar", "id", "name"],
|
||||
"properties": {
|
||||
"avatar": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* tslint:disable */
|
||||
/**
|
||||
* This file was automatically generated by json-schema-to-typescript.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file,
|
||||
* and run json-schema-to-typescript to regenerate this file.
|
||||
*/
|
||||
|
||||
export interface CreateWorkspace {
|
||||
[k: string]: unknown;
|
||||
avatar: string;
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const DefaultHeadImgColors = [
|
||||
['#C6F2F3', '#0C6066'],
|
||||
['#FFF5AB', '#896406'],
|
||||
['#FFCCA7', '#8F4500'],
|
||||
['#FFCECE', '#AF1212'],
|
||||
['#E3DEFF', '#511AAB'],
|
||||
];
|
||||
|
||||
// TODO: move this back to AFFiNE repo
|
||||
export async function createDefaultUserAvatar(
|
||||
workspaceName: string
|
||||
): Promise<Blob> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.height = 100;
|
||||
canvas.width = 100;
|
||||
const context = canvas.getContext('2d');
|
||||
if (context === null) {
|
||||
throw new Error('Failed to create avatar canvas');
|
||||
}
|
||||
const randomNumber = Math.floor(Math.random() * 5);
|
||||
const randomColor = DefaultHeadImgColors[randomNumber];
|
||||
context.fillStyle = randomColor[0];
|
||||
context.fillRect(0, 0, 100, 100);
|
||||
context.font = "600 50px 'PingFang SC', 'Microsoft Yahei'";
|
||||
context.fillStyle = randomColor[1];
|
||||
context.textAlign = 'center';
|
||||
context.textBaseline = 'middle';
|
||||
context.fillText(workspaceName[0], 50, 50);
|
||||
return await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob(blob => {
|
||||
if (blob === null) {
|
||||
reject(new Error('Failed to convert avatar canvas to blob'));
|
||||
} else {
|
||||
resolve(blob);
|
||||
}
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user