feat: parse folder collab and display document title/icon/cover (#5222)

* feat: support web document and cypress test

* fix: support blocks

* fix: support table and outline

* fix: update nginx

* fix: support document title, icon, cover

fix: mock test folder
This commit is contained in:
Kilu.He 2024-04-30 11:32:26 +08:00 committed by GitHub
parent e0d6b194bf
commit 6d0598b101
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 1355 additions and 363 deletions

View File

@ -8,6 +8,7 @@ on:
- ".github/workflows/mobile_ci.yaml"
- "frontend/**"
- "!frontend/appflowy_tauri/**"
- "!frontend/appflowy_web_app/**"
pull_request:
branches:
@ -16,6 +17,7 @@ on:
- ".github/workflows/mobile_ci.yaml"
- "frontend/**"
- "!frontend/appflowy_tauri/**"
- "!frontend/appflowy_web_app/**"
env:
FLUTTER_VERSION: "3.19.0"

View File

@ -31,7 +31,6 @@ declare global {
interface Chainable {
mount: typeof mount;
mockAPI: () => void;
mockFullDocument: () => void;
}
}
}

View File

@ -1,20 +1,7 @@
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 { BlockId, BlockType, YBlocks, YChildrenMap, YjsEditorKey, YTextMap } from '@/application/collab.type';
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;

View File

@ -83,6 +83,7 @@
"ts-results": "^3.3.0",
"unsplash-js": "^7.0.19",
"utf8": "^3.0.0",
"validator": "^13.11.0",
"valtio": "^1.12.1",
"vite-plugin-wasm": "^3.3.0",
"y-indexeddb": "9.0.12",
@ -110,6 +111,7 @@
"@types/react-window": "^1.8.8",
"@types/utf8": "^3.0.1",
"@types/uuid": "^9.0.1",
"@types/validator": "^13.11.9",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",

View File

@ -188,6 +188,9 @@ dependencies:
utf8:
specifier: ^3.0.0
version: 3.0.0
validator:
specifier: ^13.11.0
version: 13.11.0
valtio:
specifier: ^1.12.1
version: 1.12.1(@types/react@18.2.66)(react@18.2.0)
@ -265,6 +268,9 @@ devDependencies:
'@types/uuid':
specifier: ^9.0.1
version: 9.0.1
'@types/validator':
specifier: ^13.11.9
version: 13.11.9
'@typescript-eslint/eslint-plugin':
specifier: ^7.2.0
version: 7.2.0(@typescript-eslint/parser@7.2.0)(eslint@8.57.0)(typescript@4.9.5)
@ -2818,6 +2824,10 @@ packages:
resolution: {integrity: sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA==}
dev: true
/@types/validator@13.11.9:
resolution: {integrity: sha512-FCTsikRozryfayPuiI46QzH3fnrOoctTjvOYZkho9BTFLCOZ2rgZJHMOVgCOfttjPJcgOx52EpkY0CMfy87MIw==}
dev: true
/@types/warning@3.0.3:
resolution: {integrity: sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==}
dev: false
@ -8483,6 +8493,11 @@ packages:
'@types/istanbul-lib-coverage': 2.0.6
convert-source-map: 2.0.0
/validator@13.11.0:
resolution: {integrity: sha512-Ii+sehpSfZy+At5nPdnyMhx78fEoPDkR2XW/zimHEL3MyGJQOCQ7WeP20jPYRz7ZCpcKLB21NxuXHF3bxjStBQ==}
engines: {node: '>= 0.10'}
dev: false
/valtio@1.12.1(@types/react@18.2.66)(react@18.2.0):
resolution: {integrity: sha512-R0V4H86Xi2Pp7pmxN/EtV4Q6jr6PMN3t1IwxEvKUp6160r8FimvPh941oWyeK1iec/DTsh9Jb3Q+GputMS8SYg==}
engines: {node: '>=12.20.0'}

View File

@ -1,14 +1,294 @@
export enum CollabType {
Document = 0,
Database = 1,
WorkspaceDatabase = 2,
Folder = 3,
DatabaseRow = 4,
UserAwareness = 5,
Empty = 6,
}
export enum CollabOrigin {
Local = 'local',
Remote = 'remote',
}
import Y from 'yjs';
export type BlockId = string;
export type ExternalId = string;
export type ChildrenId = string;
export type ViewId = 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;
reminder_id?: string;
reminder_option?: string;
type: MentionType;
}
export interface FolderMeta {
current_view: ViewId;
current_workspace: string;
}
export enum CoverType {
Color = 'CoverType.color',
Image = 'CoverType.file',
Asset = 'CoverType.asset',
}
export type PageCover = {
image_type?: ImageType;
cover_selection_type?: CoverType;
cover_selection?: string;
} | null;
export enum ViewLayout {
Document = 0,
Grid = 1,
Board = 2,
Calendar = 3,
}
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',
// document
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 enum YjsFolderKey {
views = 'views',
relation = 'relation',
section = 'section',
private = 'private',
favorite = 'favorite',
recent = 'recent',
trash = 'trash',
meta = 'meta',
current_view = 'current_view',
current_workspace = 'current_workspace',
id = 'id',
name = 'name',
icon = 'icon',
type = 'ty',
value = 'value',
layout = 'layout',
}
export interface YDoc extends Y.Doc {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getMap(key: YjsEditorKey.data_section): YSharedRoot | any;
}
export interface YSharedRoot extends Y.Map<unknown> {
get(key: YjsEditorKey.document): YDocument;
// eslint-disable-next-line @typescript-eslint/unified-signatures
get(key: YjsEditorKey.folder): YFolder;
}
export interface YFolder extends Y.Map<unknown> {
get(key: YjsFolderKey.views): YViews;
// eslint-disable-next-line @typescript-eslint/unified-signatures
get(key: YjsFolderKey.meta): YFolderMeta;
// eslint-disable-next-line @typescript-eslint/unified-signatures
get(key: YjsFolderKey.relation): YFolderRelation;
// eslint-disable-next-line @typescript-eslint/unified-signatures
get(key: YjsFolderKey.section): YFolderSection;
}
export interface YViews extends Y.Map<unknown> {
get(key: ViewId): YView;
}
export interface YView extends Y.Map<unknown> {
get(key: YjsFolderKey.id): ViewId;
get(key: YjsFolderKey.name): string;
// eslint-disable-next-line @typescript-eslint/unified-signatures
get(key: YjsFolderKey.icon): string;
// eslint-disable-next-line @typescript-eslint/unified-signatures
get(key: YjsFolderKey.layout): string;
}
export interface YFolderRelation extends Y.Map<unknown> {
get(key: ViewId): Y.Array<ViewId>;
}
export interface YFolderMeta extends Y.Map<unknown> {
get(key: YjsFolderKey.current_view | YjsFolderKey.current_workspace): string;
}
export interface YFolderSection extends Y.Map<unknown> {
get(key: YjsFolderKey.favorite | YjsFolderKey.private | YjsFolderKey.recent | YjsFolderKey.trash): YFolderSectionItem;
}
export interface YFolderSectionItem extends Y.Map<unknown> {
get(key: string): Y.Array<unknown>;
}
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;
}
export enum CollabType {
Document = 0,
Database = 1,
WorkspaceDatabase = 2,
Folder = 3,
DatabaseRow = 4,
UserAwareness = 5,
Empty = 6,
}
export enum CollabOrigin {
Local = 'local',
Remote = 'remote',
}
export const layoutMap = {
[ViewLayout.Document]: 'document',
[ViewLayout.Grid]: 'grid',
[ViewLayout.Board]: 'board',
[ViewLayout.Calendar]: 'calendar',
};

View File

@ -0,0 +1,8 @@
import { YFolder } from '@/application/collab.type';
import { createContext, useContext } from 'react';
export const FolderContext = createContext<YFolder | null>(null);
export const useFolderContext = () => {
return useContext(FolderContext);
};

View File

@ -0,0 +1,2 @@
export * from './selector';
export * from './context';

View File

@ -0,0 +1,62 @@
import { YjsFolderKey, YView } from '@/application/collab.type';
import { useFolderContext } from '@/application/folder-yjs/context';
import { useEffect, useState } from 'react';
export function useViewsIdSelector() {
const folder = useFolderContext();
const [viewsId, setViewsId] = useState<string[]>([]);
useEffect(() => {
if (!folder) return;
const views = folder.get(YjsFolderKey.views);
const trash = folder.get(YjsFolderKey.section)?.get(YjsFolderKey.trash);
const meta = folder.get(YjsFolderKey.meta);
console.log('folder', folder.toJSON());
const collectIds = () => {
return Array.from(views.keys()).filter(
(id) => !trash?.has(id) && id !== meta?.get(YjsFolderKey.current_workspace)
);
};
setViewsId(collectIds());
const observerEvent = () => setViewsId(collectIds());
folder.observe(observerEvent);
return () => {
folder.unobserve(observerEvent);
};
}, [folder]);
return {
viewsId,
};
}
export function useViewSelector(viewId: string) {
const folder = useFolderContext();
const [clock, setClock] = useState<number>(0);
const [view, setView] = useState<YView | null>(null);
useEffect(() => {
if (!folder) return;
const view = folder.get(YjsFolderKey.views)?.get(viewId);
setView(view || null);
const observerEvent = () => setClock((prev) => prev + 1);
view.observe(observerEvent);
return () => {
view.unobserve(observerEvent);
};
}, [folder, viewId]);
return {
clock,
view,
};
}

View File

@ -1,4 +1,4 @@
import { YDoc } from '@/application/document.type';
import { YDoc } from '@/application/collab.type';
import { getAuthInfo } from '@/application/services/js-services/storage';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb';

View File

@ -1,8 +1,7 @@
import { YDoc } from '@/application/document.type';
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
import { getDocumentStorage } from '@/application/services/js-services/storage/document';
import { DocumentService } from '@/application/services/services.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 {

View File

@ -0,0 +1,45 @@
import { CollabOrigin, CollabType, YDoc } from '@/application/collab.type';
import { getFolderStorage } from '@/application/services/js-services/storage/folder';
import { FolderService } from '@/application/services/services.type';
import { APIService } from 'src/application/services/js-services/wasm';
import { applyDocument } from 'src/application/ydoc/apply';
export class JSFolderService implements FolderService {
constructor() {
//
}
fetchFolder(workspaceId: string) {
return APIService.getCollab(workspaceId, workspaceId, CollabType.Folder);
}
async openWorkspace(workspaceId: string): Promise<YDoc> {
const { doc, localExist } = await getFolderStorage(workspaceId);
const asyncApply = async () => {
const res = await this.fetchFolder(workspaceId);
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

@ -3,10 +3,12 @@ import {
AFServiceConfig,
AuthService,
DocumentService,
FolderService,
UserService,
} from '@/application/services/services.type';
import { JSUserService } from '@/application/services/js-services/user.service';
import { JSAuthService } from '@/application/services/js-services/auth.service';
import { JSFolderService } from '@/application/services/js-services/folder.service';
import { JSDocumentService } from '@/application/services/js-services/document.service';
import { nanoid } from 'nanoid';
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
@ -18,6 +20,8 @@ export class AFClientService implements AFService {
documentService: DocumentService;
folderService: FolderService;
private deviceId: string = nanoid(8);
private clientId: string = 'web';
@ -40,5 +44,6 @@ export class AFClientService implements AFService {
this.authService = new JSAuthService();
this.userService = new JSUserService();
this.documentService = new JSDocumentService();
this.folderService = new JSFolderService();
}
}

View File

@ -1,4 +1,4 @@
import { YjsEditorKey } from '@/application/document.type';
import { YjsEditorKey } from '@/application/collab.type';
import { openCollabDB } from '@/application/services/js-services/db';
import { getAuthInfo } from '@/application/services/js-services/storage/token';

View File

@ -0,0 +1,21 @@
import { YjsEditorKey } from '@/application/collab.type';
import { openCollabDB } from '@/application/services/js-services/db';
import { getAuthInfo } from '@/application/services/js-services/storage/token';
export async function getFolderStorage(workspaceId: string) {
const docName = getDocName(workspaceId);
const doc = await openCollabDB(docName);
const localExist = doc.share.has(YjsEditorKey.data_section);
return {
doc,
localExist,
};
}
export function getDocName(workspaceId: string) {
const { uuid } = getAuthInfo() || {};
if (!uuid) throw new Error('No user found');
return `${uuid}_folder_${workspaceId}`;
}

View File

@ -1,36 +1,37 @@
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();
}
import { notify } from '@/components/_shared/notify';
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);
notify.error('Invalid token, please login again');
}

View File

@ -1,8 +1,7 @@
import { UserService } from '@/application/services/services.type';
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 { getAuthInfo, getSignInUser, invalidToken, setSignInUser } from '@/application/services/js-services/storage';
import { asyncDataDecorator } from '@/application/services/js-services/decorator';
async function getUser() {
@ -12,8 +11,7 @@ async function getUser() {
return user;
} catch (e) {
console.error(e);
notify.error('Failed to get user profile, please try refreshing the page');
// invalidToken();
invalidToken();
}
}

View File

@ -1,15 +1,17 @@
import { CollabType } from '@/application/collab.type';
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;
}) {
export function initAPIService(
config: AFCloudConfig & {
deviceId: string;
clientId: string;
}
) {
window.refresh_token = writeToken;
window.invalid_token = invalidToken;
client = ClientAPI.new({
@ -33,15 +35,15 @@ export function initAPIService (config: AFCloudConfig & {
client.subscribe();
}
export function signIn (email: string, password: string) {
export function signIn(email: string, password: string) {
return client.login(email, password);
}
export function logout () {
export function logout() {
return client.logout();
}
export async function getUser (): Promise<UserProfile> {
export async function getUser(): Promise<UserProfile> {
try {
const user = await client.get_user();
@ -62,7 +64,7 @@ export async function getUser (): Promise<UserProfile> {
}
}
export async function getCollab (workspaceId: string, object_id: string, collabType: CollabType) {
export async function getCollab(workspaceId: string, object_id: string, collabType: CollabType) {
const res = await client.get_collab({
workspace_id: workspaceId,
object_id: object_id,

View File

@ -1,4 +1,4 @@
import { YDoc } from '@/application/document.type';
import { YDoc } from '@/application/collab.type';
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
export interface AFService {
@ -7,6 +7,7 @@ export interface AFService {
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
}
export interface AFServiceConfig {
@ -35,3 +36,7 @@ export interface UserService {
getUserProfile: () => Promise<UserProfile | null>;
checkUser: () => Promise<boolean>;
}
export interface FolderService {
openWorkspace: (workspaceId: string) => Promise<YDoc>;
}

View File

@ -0,0 +1,12 @@
import { YDoc } from '@/application/collab.type';
import { FolderService } from '@/application/services/services.type';
export class TauriFolderService implements FolderService {
constructor() {
//
}
async openWorkspace(_workspaceId: string): Promise<YDoc> {
return Promise.reject('Not implemented');
}
}

View File

@ -3,19 +3,28 @@ import {
AFServiceConfig,
AuthService,
DocumentService,
FolderService,
UserService,
} from '@/application/services/services.type';
import { TauriAuthService } from '@/application/services/tauri-services/auth.service';
import { TauriFolderService } from '@/application/services/tauri-services/folder.service';
import { TauriUserService } from '@/application/services/tauri-services/user.service';
import { TauriDocumentService } from '@/application/services/tauri-services/document.service';
import { nanoid } from 'nanoid';
export class AFClientService implements AFService {
authService: AuthService;
userService: UserService;
documentService: DocumentService;
folderService: FolderService;
private deviceId: string = nanoid(8);
private clientId: string = 'web';
getDeviceID = (): string => {
return this.deviceId;
};
@ -24,12 +33,13 @@ 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,
});
this.userService = new TauriUserService();
this.documentService = new TauriDocumentService();
this.folderService = new TauriFolderService();
}
}

View File

@ -1,10 +1,9 @@
import { YjsEditorKey, YSharedRoot } from '@/application/document.type';
import { CollabOrigin, YjsEditorKey, YSharedRoot } from '@/application/collab.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;

View File

@ -9,7 +9,7 @@ import {
YTextMap,
BlockData,
BlockType,
} from '@/application/document.type';
} from '@/application/collab.type';
import { getFontFamily } from '@/utils/font';
import { uniq } from 'lodash-es';
import { Element, Text } from 'slate';
@ -128,7 +128,7 @@ export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element |
...rootNode,
children: [
{
textId: root.toJSON().external_id,
textId: pageId,
type: YjsEditorKey.text,
children: [{ text: '' }],
},

View File

@ -1,4 +1,4 @@
import { YSharedRoot } from '@/application/document.type';
import { YSharedRoot } from '@/application/collab.type';
import * as Y from 'yjs';
import { Editor, Operation } from 'slate';

View File

@ -1,34 +1,30 @@
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');
}
import { YSharedRoot } from '@/application/collab.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

@ -1,4 +1,4 @@
import { YSharedRoot } from '@/application/document.type';
import { YSharedRoot } from '@/application/collab.type';
import * as Y from 'yjs';
import { Editor, Operation } from 'slate';

View File

@ -1,4 +1,4 @@
import { YjsEditorKey } from '@/application/document.type';
import { YjsEditorKey } from '@/application/collab.type';
import { applyDocument } from '@/application/ydoc/apply';
import * as Y from 'yjs';
import * as docJson from '../../../../../cypress/fixtures/simple_doc.json';

View File

@ -1,5 +1,5 @@
import * as Y from 'yjs';
import { CollabOrigin } from '@/application/collab.type';
import * as Y from 'yjs';
/**
* Apply doc state from server to client

View File

@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 13C10.7614 13 13 10.7614 13 8C13 5.23858 10.7614 3 8 3C5.23858 3 3 5.23858 3 8C3 10.7614 5.23858 13 8 13Z" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 5V8L10 9" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 2.5L13.5 4.5" stroke="#333333" stroke-linecap="round"/>
<path d="M4.5 2.5L2.5 4.5" stroke="#333333" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 516 B

View File

@ -1,4 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="12" height="12" rx="4" fill="currentColor"/>
<path d="M6 8L7.61538 9.5L10.5 6.5" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="2" y="3" width="13" height="13" rx="3.5" fill="currentColor"/>
<path d="M6 9L8 12L12 7" stroke="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 273 B

After

Width:  |  Height:  |  Size: 272 B

View File

@ -1,3 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2.5" y="2.5" width="11" height="11" rx="3.5" stroke="#BDBDBD"/>
<rect x="2.5" y="3" width="12" height="12" rx="3.5" stroke="#BDBDBD"/>
</svg>

Before

Width:  |  Height:  |  Size: 176 B

After

Width:  |  Height:  |  Size: 178 B

View File

@ -0,0 +1,9 @@
import { YFolder } from '@/application/collab.type';
import { FolderContext } from '@/application/folder-yjs';
export const FolderProvider: React.FC<{ folder: YFolder | null; children?: React.ReactNode }> = ({
folder,
children,
}) => {
return <FolderContext.Provider value={folder}>{children}</FolderContext.Provider>;
};

View File

@ -13,6 +13,8 @@ export const IdProvider = ({ children, ...props }: IdProviderProps & { children:
return <IdContext.Provider value={props}>{children}</IdContext.Provider>;
};
const defaultIdValue = {} as IdProviderProps;
export function useId() {
return useContext(IdContext);
return useContext(IdContext) || defaultIdValue;
}

View File

@ -0,0 +1,29 @@
import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material';
import React from 'react';
import { useNavigate } from 'react-router-dom';
export function RecordNotFound({ open, workspaceId }: { workspaceId: string; open: boolean }) {
const navigate = useNavigate();
return (
<Dialog open={open}>
<DialogTitle>Oops.. something went wrong</DialogTitle>
<DialogContent>
<DialogContentText id='alert-dialog-description'>
Sorry, the document you are looking for does not exist.
</DialogContentText>
</DialogContent>
<DialogActions className={'flex w-full items-center justify-center'}>
<Button
onClick={() => {
navigate(`/workspace/${workspaceId}`);
}}
>
Go back
</Button>
</DialogActions>
</Dialog>
);
}
export default RecordNotFound;

View File

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

View File

@ -0,0 +1,30 @@
import { YView } from '@/application/collab.type';
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
import React from 'react';
export function Page({
id,
onClick,
...props
}: {
id: string;
onClick?: (view: YView) => void;
style?: React.CSSProperties;
}) {
const { view, icon, name } = usePageInfo(id);
return (
<div
onClick={() => {
onClick && view && onClick(view);
}}
className={'flex items-center justify-center gap-2 overflow-hidden'}
{...props}
>
<div>{icon}</div>
<div className={'flex-1 truncate'}>{name}</div>
</div>
);
}
export default Page;

View File

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

View File

@ -0,0 +1,45 @@
import { ViewLayout, YjsFolderKey, YView } from '@/application/collab.type';
import { useViewSelector } from '@/application/folder-yjs';
import React, { useMemo } from 'react';
import { ReactComponent as DocumentSvg } from '@/assets/document.svg';
import { ReactComponent as GridSvg } from '@/assets/grid.svg';
import { ReactComponent as BoardSvg } from '@/assets/board.svg';
import { ReactComponent as CalendarSvg } from '@/assets/date.svg';
import { useTranslation } from 'react-i18next';
export function usePageInfo(id: string) {
const { view } = useViewSelector(id);
const layout = view?.get(YjsFolderKey.layout);
const icon = view?.get(YjsFolderKey.icon);
const name = view?.get(YjsFolderKey.name) || '';
const iconObj = useMemo(() => {
try {
return JSON.parse(icon || '');
} catch (e) {
return null;
}
}, [icon]);
const defaultIcon = useMemo(() => {
switch (parseInt(layout ?? '0')) {
case ViewLayout.Document:
return <DocumentSvg />;
case ViewLayout.Grid:
return <GridSvg />;
case ViewLayout.Board:
return <BoardSvg />;
case ViewLayout.Calendar:
return <CalendarSvg />;
default:
return <DocumentSvg />;
}
}, [layout]);
const { t } = useTranslation();
return {
icon: iconObj?.value || defaultIcon,
name: name || t('menuAppHeader.defaultNewPageName'),
view: view as YView,
};
}

View File

@ -1,3 +1,4 @@
import FolderPage from '@/pages/FolderPage';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import ProtectedRoutes from '@/components/auth/ProtectedRoutes';
import LoginPage from '@/pages/LoginPage';
@ -8,6 +9,7 @@ const AppMain = withAppWrapper(() => {
return (
<Routes>
<Route path={'/'} element={<ProtectedRoutes />}>
<Route path={'/workspace/:workspaceId'} element={<FolderPage />} />
<Route path={'/workspace/:workspaceId/:collabType/:objectId'} element={<ProductPage />} />
</Route>
<Route path={'/login'} element={<LoginPage />} />
@ -15,7 +17,7 @@ const AppMain = withAppWrapper(() => {
);
});
function App () {
function App() {
return (
<BrowserRouter>
<AppMain />
@ -24,4 +26,3 @@ function App () {
}
export default App;

View File

@ -48,44 +48,20 @@ function AppTheme({ children }: { children: React.ReactNode }) {
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)',
'&:not(.MuiButton-contained)': {
'&:hover': {
backgroundColor: 'var(--fill-list-hover)',
},
'&:active': {
backgroundColor: 'var(--fill-list-hover)',
},
},
borderRadius: '4px',
padding: '2px',
boxShadow: 'none',

View File

@ -29,6 +29,7 @@ export const LoginButtonGroup = () => {
{t('signIn.signInWithEmail')}
</Button>
<Button
disabled
onClick={() => {
void signInWithProvider(ProviderType.Google);
}}
@ -40,6 +41,7 @@ export const LoginButtonGroup = () => {
{t('button.signInGoogle')}
</Button>
<Button
disabled
onClick={() => {
void signInWithProvider(ProviderType.Github);
}}
@ -51,6 +53,7 @@ export const LoginButtonGroup = () => {
{t('button.signInGithub')}
</Button>
<Button
disabled
onClick={() => {
void signInWithProvider(ProviderType.Discord);
}}

View File

@ -11,7 +11,7 @@ import { useNavigate } from 'react-router-dom';
const TauriAuth = lazy(() => import('@/components/tauri/TauriAuth'));
function ProtectedRoutes () {
function ProtectedRoutes() {
const { currentUser, checkUser, isReady } = useAuth();
const isLoading = currentUser?.loginState === LoginState.LOADING;
@ -24,7 +24,6 @@ function ProtectedRoutes () {
if (!currentUser.isAuthenticated) {
await checkUser();
}
} finally {
setChecked(true);
}
@ -38,7 +37,6 @@ function ProtectedRoutes () {
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;

View File

@ -1,19 +1,53 @@
import { YDoc } from '@/application/collab.type';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { AFConfigContext } from '@/components/app/AppConfig';
import { DocumentHeader } from '@/components/document/document_header';
import { Editor } from '@/components/editor';
import React from 'react';
import { Log } from '@/utils/log';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import RecordNotFound from 'src/components/_shared/not-found/RecordNotFound';
export const Document = () => {
const { objectId: documentId, workspaceId } = useId() || {};
const [doc, setDoc] = useState<YDoc | null>(null);
const [notFound, setNotFound] = useState<boolean>(false);
if (!documentId || !workspaceId) return null;
const documentService = useContext(AFConfigContext)?.service?.documentService;
const handleOpenDocument = useCallback(async () => {
if (!documentService || !workspaceId || !documentId) return;
try {
setDoc(null);
const doc = await documentService.openDocument(workspaceId, documentId);
setDoc(doc);
} catch (e) {
Log.error(e);
setNotFound(true);
}
}, [documentService, workspaceId, documentId]);
useEffect(() => {
setNotFound(false);
void handleOpenDocument();
}, [handleOpenDocument]);
if (!documentId) 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} />
<>
{doc && (
<div className={'relative w-full'}>
<DocumentHeader doc={doc} viewId={documentId} />
<div className={'flex w-full justify-center'}>
<div className={'max-w-screen w-[964px] min-w-0'}>
<Editor doc={doc} readOnly={true} />
</div>
</div>
</div>
</div>
</div>
)}
<RecordNotFound open={notFound} workspaceId={workspaceId} />
</>
);
};

View File

@ -0,0 +1,37 @@
import { CoverType, YDoc } from '@/application/collab.type';
import { useBlockCover } from '@/components/document/document_header/useBlockCover';
import { renderColor } from '@/utils/color';
import React, { useCallback } from 'react';
import DefaultImage from './default_cover.jpg';
function DocumentCover({ doc }: { doc: YDoc }) {
const { cover } = useBlockCover(doc);
const renderCoverColor = useCallback((color: string) => {
return (
<div
style={{
backgroundColor: renderColor(color),
}}
className={`h-full w-full`}
/>
);
}, []);
const renderCoverImage = useCallback((url: string) => {
return <img draggable={false} src={url} alt={''} className={'h-full w-full object-cover'} />;
}, []);
const { cover_selection_type: type, cover_selection: value = '' } = cover || {};
return value ? (
<div className={`relative mb-[-80px] flex h-[255px] w-full`}>
<>
{type === CoverType.Asset ? renderCoverImage(DefaultImage) : null}
{type === CoverType.Color ? renderCoverColor(value) : null}
{type === CoverType.Image ? renderCoverImage(value) : null}
</>
</div>
) : null;
}
export default DocumentCover;

View File

@ -0,0 +1,42 @@
import { YDoc, YjsFolderKey } from '@/application/collab.type';
import { useViewSelector } from '@/application/folder-yjs';
import DocumentCover from '@/components/document/document_header/DocumentCover';
import React, { memo, useMemo, useRef } from 'react';
export function DocumentHeader({ viewId, doc }: { viewId: string; doc: YDoc }) {
const ref = useRef<HTMLDivElement>(null);
const { view } = useViewSelector(viewId);
const icon = view?.get(YjsFolderKey.icon);
const iconObject = useMemo(() => {
try {
return JSON.parse(icon || '');
} catch (e) {
return null;
}
}, [icon]);
return (
<div ref={ref} className={'document-header select-none'}>
<div className={'flex flex-col justify-end'}>
<div className={'view-banner flex w-full flex-col overflow-hidden'}>
<DocumentCover doc={doc} />
<div className={`relative min-h-[65px] w-[964px] min-w-0 max-w-full px-16 pt-10 max-md:px-4`}>
<div
style={{
position: 'relative',
bottom: '50%',
}}
>
<div className={`view-icon`}>{iconObject?.value}</div>
</div>
<div className={'py-2'}></div>
</div>
</div>
</div>
</div>
);
}
export default memo(DocumentHeader);

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

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

View File

@ -0,0 +1,36 @@
import { PageCover, YBlocks, YDoc, YDocument, YjsEditorKey } from '@/application/collab.type';
import { useEffect, useMemo, useState } from 'react';
export function useBlockCover(doc: YDoc) {
const [cover, setCover] = useState<string | null>(null);
useEffect(() => {
if (!doc) return;
const document = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.document) as YDocument;
const pageId = document.get(YjsEditorKey.page_id) as string;
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
const root = blocks.get(pageId);
setCover(root.toJSON().data || null);
const observerEvent = () => setCover(root.toJSON().data || null);
root.observe(observerEvent);
return () => {
root.unobserve(observerEvent);
};
}, [doc]);
const coverObj: PageCover = useMemo(() => {
try {
return JSON.parse(cover || '');
} catch (e) {
return null;
}
}, [cover]);
return {
cover: coverObj,
};
}

View File

@ -1,4 +1,8 @@
import { YjsFolderKey } from '@/application/collab.type';
import { useViewSelector } from '@/application/folder-yjs';
import { withYjs, YjsEditor } from '@/application/slate-yjs/plugins/withYjs';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { CustomEditor } from '@/components/editor/command';
import EditorEditable from '@/components/editor/Editable';
import { withPlugins } from '@/components/editor/plugins';
import React, { useEffect, useMemo, useState } from 'react';
@ -10,8 +14,10 @@ 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);
const [connected, setIsConnected] = useState(false);
const viewId = useId()?.objectId || '';
const { view } = useViewSelector(viewId);
const title = view?.get(YjsFolderKey.name);
useEffect(() => {
if (!editor) return;
@ -23,6 +29,11 @@ function CollaborativeEditor({ doc }: { doc: Y.Doc }) {
};
}, [editor]);
useEffect(() => {
if (!editor || !connected) return;
CustomEditor.setDocumentTitle(editor, title || '');
}, [editor, title, connected]);
return (
<Slate editor={editor} initialValue={defaultInitialValue}>
<EditorEditable editor={editor} />

View File

@ -1,7 +1,8 @@
import { JSDocumentService } from '@/application/services/js-services/document.service';
import { YDoc } from '@/application/collab.type';
import { DocumentTest } from '@/../cypress/support/document';
import { nanoid } from 'nanoid';
import { applyDocument } from '@/application/ydoc/apply';
import React from 'react';
import * as Y from 'yjs';
import { Editor } from './Editor';
import withAppWrapper from '@/components/app/withAppWrapper';
@ -10,25 +11,26 @@ describe('<Editor />', () => {
const documentTest = new DocumentTest();
documentTest.insertParagraph('Hello, world!');
cy.stub(JSDocumentService.prototype, 'openDocument').returns(Promise.resolve(documentTest.doc));
renderEditor();
renderEditor(documentTest.doc);
cy.get('[role="textbox"]').should('contain', 'Hello, world!');
});
it('renders with a full document', () => {
cy.mockFullDocument();
renderEditor();
cy.fixture('full_doc').then((docJson) => {
const doc = new Y.Doc();
const state = new Uint8Array(docJson.data.doc_state);
applyDocument(doc, state);
renderEditor(doc);
});
});
});
function renderEditor() {
const documentId = nanoid(8);
const workspaceId = nanoid(8);
function renderEditor(doc: YDoc) {
const AppWrapper = withAppWrapper(() => {
return (
<div className={'h-screen w-screen overflow-y-auto'}>
<Editor documentId={documentId} readOnly workspaceId={workspaceId} />
<Editor doc={doc} readOnly />
</div>
);
});

View File

@ -1,43 +1,10 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import { YDoc } from '@/application/collab.type';
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 React from 'react';
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>
);
}
export const Editor = ({ readOnly, doc }: { readOnly: boolean; doc: YDoc }) => {
return (
<EditorContextProvider readOnly={readOnly}>
<CollaborativeEditor doc={doc} />

View File

@ -0,0 +1,37 @@
import { InlineBlockType, Mention, MentionType } from '@/application/collab.type';
import { FormulaNode } from '@/components/editor/editor.type';
import { renderDate } from '@/utils/time';
import { Editor, Transforms, Element, Text, Node } from 'slate';
import { ReactEditor } from 'slate-react';
export const CustomEditor = {
setDocumentTitle: (editor: ReactEditor, title: string) => {
const length = Editor.string(editor, [0, 0]).length;
Transforms.insertText(editor, title, {
at: {
anchor: { path: [0, 0, 0], offset: 0 },
focus: { path: [0, 0, 0], offset: length },
},
});
},
// Get the text content of a block node, including the text content of its children and formula nodes
getBlockTextContent(node: Node): string {
if (Element.isElement(node)) {
if (node.type === InlineBlockType.Formula) {
return (node as FormulaNode).data || '';
}
if (node.type === InlineBlockType.Mention && (node.data as Mention)?.type === MentionType.Date) {
return renderDate((node.data as Mention).date || '');
}
}
if (Text.isText(node)) {
return node.text || '';
}
return node.children.map((n) => CustomEditor.getBlockTextContent(n)).join('');
},
};

View File

@ -1,15 +1,14 @@
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`}>
<span contentEditable={false} ref={ref} className={`h-8 w-8 p-1`}>
{node.data.icon}
</IconButton>
</span>
</>
);
}

View File

@ -1,4 +1,4 @@
import { BlockType } from '@/application/document.type';
import { BlockType } from '@/application/collab.type';
import { decorateCode } from '@/components/editor/components/blocks/code/utils';
import { CodeNode } from '@/components/editor/editor.type';
import { useCallback } from 'react';

View File

@ -1,4 +1,4 @@
import { AlignType } from '@/application/document.type';
import { AlignType } from '@/application/collab.type';
import { EditorElementProps, ImageBlockNode } from '@/components/editor/editor.type';
import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react';
import { ReactEditor, useSelected, useSlateStatic } from 'slate-react';

View File

@ -11,7 +11,7 @@ export const Outline = memo(
const { t } = useTranslation();
useEffect(() => {
const root = nestHeadings(extractHeadings(editor.children, node.data.depth || 6));
const root = nestHeadings(extractHeadings(editor, node.data.depth || 6));
setRoot(root);
}, [editor, node.data.depth]);

View File

@ -1,21 +1,22 @@
import { BlockType } from '@/application/document.type';
import { BlockType } from '@/application/collab.type';
import { CustomEditor } from '@/components/editor/command';
import { HeadingNode } from '@/components/editor/editor.type';
import { Element, Text } from 'slate';
import { ReactEditor } from 'slate-react';
export function extractHeadings(blocks: (Element | Text)[], maxDepth: number): HeadingNode[] {
export function extractHeadings(editor: ReactEditor, maxDepth: number): HeadingNode[] {
const headings: HeadingNode[] = [];
const blocks = editor.children;
function traverse(children: (Element | Text)[]) {
for (const block of children) {
if (Text.isText(block)) continue;
if (block.type === BlockType.HeadingBlock && (block as HeadingNode).data?.level <= maxDepth) {
const texts = (block.children[0] as Element).children as Text[];
headings.push({
...block,
data: {
level: (block as HeadingNode).data.level,
text: texts.map((node) => node.text).join(''),
text: CustomEditor.getBlockTextContent(block),
},
children: [],
} as HeadingNode);

View File

@ -37,7 +37,7 @@ const Table = memo(
}, [rowGroup, rowDefaultHeight]);
return (
<div ref={ref} {...attributes} className={`table-block relative my-2 px-1 ${className || ''}`}>
<div ref={ref} {...attributes} className={`table-block relative my-2 w-full px-1 ${className || ''}`}>
<Grid
id={`table-${node.blockId}`}
rowGap='space.0'

View File

@ -1,4 +1,4 @@
import { BlockType } from '@/application/document.type';
import { BlockType } from '@/application/collab.type';
import { HeadingNode } from '@/components/editor/editor.type';
import { useEditorContext } from '@/components/editor/EditorContext';
import React, { CSSProperties, useEffect, useMemo, useState } from 'react';

View File

@ -1,4 +1,4 @@
import { BlockType } from '@/application/document.type';
import { BlockType } from '@/application/collab.type';
import { BulletedListIcon } from '@/components/editor/components/blocks/bulleted_list';
import { NumberListIcon } from '@/components/editor/components/blocks/numbered_list';
import ToggleIcon from '@/components/editor/components/blocks/toggle_list/ToggleIcon';

View File

@ -13,7 +13,7 @@ export const Text = memo(
const isEmpty = editor.isEmpty(node);
const className = useMemo(
() =>
`text-element relative my-1 flex w-full whitespace-pre-wrap break-words px-1 ${classNameProp ?? ''} ${
`text-element relative my-1 flex w-full whitespace-pre-wrap break-all px-1 ${classNameProp ?? ''} ${
hasStartIcon ? 'has-start-icon' : ''
}`,
[classNameProp, hasStartIcon]

View File

@ -1,4 +1,4 @@
import { BlockData, BlockType, InlineBlockType, YjsEditorKey } from '@/application/document.type';
import { BlockData, BlockType, InlineBlockType, YjsEditorKey } from '@/application/collab.type';
import { BulletedList } from '@/components/editor/components/blocks/bulleted_list';
import { Callout } from '@/components/editor/components/blocks/callout';
import { CodeBlock } from '@/components/editor/components/blocks/code';

View File

@ -21,7 +21,7 @@ export const Formula = memo(
<KatexMath latex={formula || ''} isInline />
</span>
<span className={'absolute left-0 right-0 h-0 w-0 select-none opacity-0'}>{children}</span>
<span className={'absolute left-0 right-0 h-0 w-0 opacity-0'}>{children}</span>
</span>
);
})

View File

@ -1,6 +1,21 @@
import { useEditorContext } from '@/components/editor/EditorContext';
import { openUrl } from '@/utils/url';
import React, { memo } from 'react';
import { Text } from 'slate';
export const Href = memo(({ children }: { leaf: Text; children: React.ReactNode }) => {
return <span className={`cursor-pointer select-auto px-1 py-0.5 text-fill-default underline`}>{children}</span>;
export const Href = memo(({ children, leaf }: { leaf: Text; children: React.ReactNode }) => {
const readonly = useEditorContext().readOnly;
return (
<span
onClick={() => {
if (readonly && leaf.href) {
void openUrl(leaf.href, '_blank');
}
}}
className={`cursor-pointer select-auto px-1 py-0.5 text-fill-default underline`}
>
{children}
</span>
);
});

View File

@ -1,15 +1,17 @@
import dayjs from 'dayjs';
import { renderDate } from '@/utils/time';
import React, { useMemo } from 'react';
import { ReactComponent as DateSvg } from '@/assets/date.svg';
import { ReactComponent as ReminderSvg } from '@/assets/clock_alarm.svg';
function MentionDate({ date }: { date: string }) {
function MentionDate({ date, reminder }: { date: string; reminder?: { id: string; option: string } }) {
const dateFormat = useMemo(() => {
return dayjs(date).format('MMM D, YYYY');
return renderDate(date);
}, [date]);
return (
<span className={'mention-inline'}>
<DateSvg className={'mention-icon'} />
{reminder ? <ReminderSvg className={'mention-icon'} /> : <DateSvg className={'mention-icon'} />}
<span className={'mention-content'}>{dateFormat}</span>
</span>
);

View File

@ -1,16 +1,21 @@
import { Mention, MentionType } from '@/application/document.type';
import { Mention, MentionType } from '@/application/collab.type';
import MentionDate from '@/components/editor/components/leaf/mention/MentionDate';
import MentionPage from '@/components/editor/components/leaf/mention/MentionPage';
import { useMemo } from 'react';
export function MentionLeaf({ mention }: { mention: Mention }) {
const { type, date, page_id } = mention;
const { type, date, page_id, reminder_id, reminder_option } = mention;
const reminder = useMemo(() => {
return reminder_id ? { id: reminder_id ?? '', option: reminder_option ?? '' } : undefined;
}, [reminder_id, reminder_option]);
if (type === MentionType.PageRef && page_id) {
return <MentionPage pageId={page_id} />;
}
if (type === MentionType.Date && date) {
return <MentionDate date={date} />;
return <MentionDate date={date} reminder={reminder} />;
}
return null;

View File

@ -1,50 +1,27 @@
import React, { useCallback, useEffect, useState } from 'react';
import { ReactComponent as DocumentSvg } from '@/assets/document.svg';
import { useTranslation } from 'react-i18next';
import { useSelected } from 'slate-react';
import { ReactComponent as EyeClose } from '@/assets/eye_close.svg';
import { layoutMap, ViewLayout, YjsFolderKey } from '@/application/collab.type';
import { useId } from '@/components/_shared/context-provider/IdProvider';
import { usePageInfo } from '@/components/_shared/page/usePageInfo';
import React from 'react';
import { useNavigate } from 'react-router-dom';
function MentionPage({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { workspaceId } = useId();
const { view, icon, name } = usePageInfo(pageId);
const selected = useSelected();
const [page, setPage] = useState<{
icon?: {
value: string | null;
};
name: string;
} | null>(null);
const [error, setError] = useState<boolean>(false);
const loadPage = useCallback(async () => {
setError(false);
setPage(null);
console.log(pageId);
}, [pageId]);
useEffect(() => {
void loadPage();
}, [loadPage]);
return (
<span
className={`mention-inline mx-1 inline-flex select-none items-center gap-1`}
contentEditable={false}
style={{
backgroundColor: selected ? 'var(--content-blue-100)' : undefined,
onClick={() => {
const layout = parseInt(view?.get(YjsFolderKey.layout) ?? '0') as ViewLayout;
navigate(`/workspace/${workspaceId}/${layoutMap[layout]}/${pageId}`);
}}
className={`mention-inline px-1 underline`}
contentEditable={false}
>
{error ? (
<>
<EyeClose />
<span className={'mr-0.5 text-text-caption underline'}>{t('document.mention.deleted')}</span>
</>
) : (
page && (
<>
{page.icon?.value || <DocumentSvg />}
<span className={'mr-1 underline'}>{page.name.trim() || t('menuAppHeader.defaultNewPageName')}</span>
</>
)
)}
<span className={'mention-icon'}>{icon}</span>
<span className={'mention-content'}>{name}</span>
</span>
);
}

View File

@ -136,6 +136,18 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
}
}
.text-placeholder {
&:after {
@apply left-0;
}
}
.has-start-icon .text-placeholder {
&:after {
@apply left-[24px];
}
}
.block-align-center {
.text-placeholder {
@apply left-[calc(50%+1px)];
@ -153,23 +165,6 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
}
.block-align-left {
.text-placeholder {
&:after {
@apply left-0;
}
}
.has-start-icon .text-placeholder {
&:after {
@apply left-[24px];
}
}
}
.text-block-icon {
@apply top-[2px];
}
.block-align-right {
@ -264,10 +259,11 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) {
.mention-inline {
height: inherit;
overflow: hidden;
@apply px-1 inline-flex select-none gap-1 relative;
@apply inline-flex select-none gap-1 relative;
.mention-icon {
@apply absolute top-1/2 transform -translate-y-1/2;
font-size: 1.25em;
}
.mention-content {

View File

@ -16,7 +16,7 @@ import {
TableCellBlockData,
BlockId,
BlockData,
} from '@/application/document.type';
} from '@/application/collab.type';
import { HTMLAttributes } from 'react';
import { Element } from 'slate';

View File

@ -1,4 +1,4 @@
import { InlineBlockType } from '@/application/document.type';
import { InlineBlockType } from '@/application/collab.type';
import { ReactEditor } from 'slate-react';
import { Element } from 'slate';

View File

@ -1,4 +1,4 @@
import { BlockType } from '@/application/document.type';
import { BlockType } from '@/application/collab.type';
import { Element, NodeEntry, Path } from 'slate';
import { ReactEditor } from 'slate-react';

View File

@ -0,0 +1,17 @@
import { useViewsIdSelector } from '@/application/folder-yjs';
import ViewItem from '@/components/folder/ViewItem';
import React from 'react';
export function Folder() {
const { viewsId } = useViewsIdSelector();
return (
<div className={'m-10 p-10'}>
{viewsId.map((viewId) => {
return <ViewItem key={viewId} id={viewId} />;
})}
</div>
);
}
export default Folder;

View File

@ -0,0 +1,24 @@
import { layoutMap, ViewLayout, YjsFolderKey } from '@/application/collab.type';
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import Page from 'src/components/_shared/page/Page';
function ViewItem({ id }: { id: string }) {
const navigate = useNavigate();
const { pathname } = useLocation();
return (
<div className={'cursor-pointer border-b border-line-border py-4 px-2'}>
<Page
onClick={(view) => {
const layout = parseInt(view?.get(YjsFolderKey.layout) ?? '0') as ViewLayout;
navigate(`${pathname}/${layoutMap[layout]}/${id}`);
}}
id={id}
/>
</div>
);
}
export default ViewItem;

View File

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

View File

@ -1,36 +1,74 @@
import { useAuth } from '@/components/auth/auth.hooks';
import { downloadPage, openAppFlowySchema, openUrl } from '@/utils/url';
import { Button } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import React, { useMemo } from 'react';
import LogoutOutlined from '@mui/icons-material/LogoutOutlined';
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import Page from 'src/components/_shared/page/Page';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import Popover, { PopoverOrigin } from '@mui/material/Popover';
const popoverOrigin: {
anchorOrigin: PopoverOrigin;
transformOrigin: PopoverOrigin;
} = {
anchorOrigin: {
vertical: 'bottom',
horizontal: 'right',
},
transformOrigin: {
vertical: -10,
horizontal: 'right',
},
};
function Header() {
const { logout, currentUser } = useAuth();
const user = useMemo(() => currentUser?.user, [currentUser]);
const { objectId } = useParams();
const { t } = useTranslation();
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
return (
<div className={'appflowy-top-bar flex h-[64px] border-b border-line-divider p-4'}>
<div className={'appflowy-top-bar flex h-[64px] p-4'}>
<div className={'flex flex-1 items-center justify-between'}>
<div className={'flex items-center justify-between gap-2'}>
<Avatar>AppFlowy</Avatar>
Page Name
</div>
<div className={'flex flex-1 items-center justify-center'}>
<Button>Download Desktop</Button>
</div>
{user ? (
<div className={'flex items-center gap-2'}>
<Avatar src={user.iconUrl} />
{user.email}
<Button onClick={logout}>
<LogoutOutlined />
</Button>
</div>
) : (
<Button>Login</Button>
)}
<div className={'flex-1'}>{objectId && <Page id={objectId} />}</div>
<Button
className={'border-line-border'}
onClick={(e) => {
setAnchorEl(e.currentTarget);
}}
variant={'outlined'}
color={'inherit'}
endIcon={<Logo />}
>
Built with
</Button>
</div>
<Popover open={Boolean(anchorEl)} anchorEl={anchorEl} {...popoverOrigin} onClose={() => setAnchorEl(null)}>
<div className={'flex w-fit flex-col gap-2 p-4'}>
<Button
onClick={() => {
void openUrl(openAppFlowySchema);
}}
className={'w-full'}
variant={'outlined'}
>
{`🥳 Open AppFlowy`}
</Button>
<div className={'flex w-full items-center justify-center gap-2 text-xs text-text-caption'}>
<div className={'h-px flex-1 bg-line-divider'} />
{t('signIn.or')}
<div className={'h-px flex-1 bg-line-divider'} />
</div>
<Button
onClick={() => {
void openUrl(downloadPage, '_blank');
}}
variant={'contained'}
>
{`Download AppFlowy`}
</Button>
</div>
</Popover>
</div>
);
}

View File

@ -1,10 +1,36 @@
import { YFolder, YjsEditorKey } from '@/application/collab.type';
import { FolderProvider } from '@/components/_shared/context-provider/FolderProvider';
import { AFConfigContext } from '@/components/app/AppConfig';
import Header from '@/components/layout/Header';
import { AFScroller } from '@/components/_shared/scroller';
import React from 'react';
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import './layout.scss';
function Layout({ children }: { children: React.ReactNode }) {
const { workspaceId } = useParams();
const folderService = useContext(AFConfigContext)?.service?.folderService;
const [folder, setFolder] = useState<YFolder | null>(null);
const getFolder = useCallback(
async (workspaceId: string) => {
const folder = (await folderService?.openWorkspace(workspaceId))
?.getMap(YjsEditorKey.data_section)
.get(YjsEditorKey.folder);
if (!folder) return;
setFolder(folder);
},
[folderService]
);
useEffect(() => {
if (!workspaceId) return;
void getFolder(workspaceId);
}, [getFolder, workspaceId]);
return (
<div>
<FolderProvider folder={folder}>
<Header />
<AFScroller
overflowXHidden
@ -15,7 +41,7 @@ function Layout({ children }: { children: React.ReactNode }) {
>
{children}
</AFScroller>
</div>
</FolderProvider>
);
}

View File

@ -0,0 +1,86 @@
.sketch-picker {
background-color: var(--bg-body) !important;
border-color: transparent !important;
box-shadow: none !important;
}
.sketch-picker .flexbox-fix {
border-color: var(--line-divider) !important;
}
.sketch-picker [id^='rc-editable-input'] {
background-color: var(--bg-body) !important;
border-color: var(--line-divider) !important;
color: var(--text-title) !important;
box-shadow: var(--line-border) 0px 0px 0px 1px inset !important;
}
.appflowy-date-picker-calendar {
width: 100%;
}
.grid-sticky-header::-webkit-scrollbar {
width: 0;
height: 0;
}
.grid-scroll-container::-webkit-scrollbar {
width: 0;
height: 0;
}
.appflowy-scroll-container {
&::-webkit-scrollbar {
width: 0;
}
}
.appflowy-scrollbar-thumb-horizontal, .appflowy-scrollbar-thumb-vertical {
background-color: var(--scrollbar-thumb);
border-radius: 4px;
opacity: 60%;
}
.workspaces {
::-webkit-scrollbar {
width: 0px;
}
}
.MuiPopover-root, .MuiPaper-root {
::-webkit-scrollbar {
width: 0;
height: 0;
}
}
.view-icon {
@apply flex w-fit cursor-pointer rounded-lg py-2 text-6xl;
font-family: "Apple Color Emoji", "Segoe UI Emoji", NotoColorEmoji, "Noto Color Emoji", "Segoe UI Symbol", "Android Emoji", EmojiSymbols;
line-height: 1em;
white-space: nowrap;
//&:hover {
// background-color: rgba(156, 156, 156, 0.20);
//}
}
.theme-mode-item {
@apply relative flex h-[72px] w-[88px] cursor-pointer items-end justify-end rounded border hover:shadow;
background: linear-gradient(150.74deg, rgba(231, 231, 231, 0) 17.95%, #C5C5C5 95.51%);
}
[data-dark-mode="true"] {
.theme-mode-item {
background: linear-gradient(150.74deg, rgba(128, 125, 125, 0) 17.95%, #4d4d4d 95.51%);
}
}
.document-header {
.view-banner {
@apply items-center;
}
}

View File

@ -0,0 +1,8 @@
import React from 'react';
import { Folder } from 'src/components/folder';
function FolderPage() {
return <Folder />;
}
export default FolderPage;

View File

@ -1,22 +1,25 @@
import React, { useEffect } from 'react';
import Welcome from '@/components/auth/Welcome';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '@/stores/store';
function LoginPage () {
const currentUser = useAppSelector((state) => state.currentUser);
const navigate = useNavigate();
useEffect(() => {
if (currentUser.isAuthenticated) {
const redirect = new URLSearchParams(window.location.search).get('redirect');
navigate(`${redirect || ''}`);
}
}, [currentUser, navigate]);
return (
<Welcome />
);
}
export default LoginPage;
import React, { useEffect } from 'react';
import Welcome from '@/components/auth/Welcome';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '@/stores/store';
function LoginPage() {
const currentUser = useAppSelector((state) => state.currentUser);
const navigate = useNavigate();
useEffect(() => {
if (currentUser.isAuthenticated) {
const redirect = new URLSearchParams(window.location.search).get('redirect');
const workspaceId = currentUser.user?.workspaceId;
if (!redirect || redirect === '/') {
return navigate(`/workspace/${workspaceId}`);
}
navigate(`${redirect}`);
}
}, [currentUser, navigate]);
return <Welcome />;
}
export default LoginPage;

View File

@ -16,7 +16,6 @@ const collabTypeMap: Record<string, CollabType> = {
function ProductPage() {
const { workspaceId, collabType, objectId } = useParams();
const PageComponent = useMemo(() => {
switch (collabType) {
case URL_COLLAB_TYPE.DOCUMENT:

View File

@ -0,0 +1,20 @@
export class Log {
static error(...msg: unknown[]) {
console.error(...msg);
}
static info(...msg: unknown[]) {
console.info(...msg);
}
static debug(...msg: unknown[]) {
console.debug(...msg);
}
static trace(...msg: unknown[]) {
console.trace(...msg);
}
static warn(...msg: unknown[]) {
console.warn(...msg);
}
}

View File

@ -0,0 +1,10 @@
import dayjs from 'dayjs';
export enum DateFormat {
Date = 'MMM D, YYYY',
DateTime = 'MMM D, YYYY h:mm A',
}
export function renderDate(date: string, format: DateFormat = DateFormat.Date): string {
return dayjs(date).format(format);
}

View File

@ -0,0 +1,47 @@
import { getPlatform } from '@/utils/platform';
import validator from 'validator';
export const downloadPage = 'https://appflowy.io/download';
export const openAppFlowySchema = 'appflowy-flutter://';
export function isValidUrl(input: string) {
return validator.isURL(input, { require_protocol: true, require_host: false });
}
// Process the URL to make sure it's a valid URL
// If it's not a valid URL(eg: 'appflowy.io' or '192.168.1.2'), we'll add 'https://' to the URL
export function processUrl(input: string) {
let processedUrl = input;
if (isValidUrl(input)) {
return processedUrl;
}
const domain = input.split('/')[0];
if (validator.isIP(domain) || validator.isFQDN(domain)) {
processedUrl = `https://${input}`;
if (isValidUrl(processedUrl)) {
return processedUrl;
}
}
return;
}
export async function openUrl(url: string, target: string = '_current') {
const platform = getPlatform();
const newUrl = processUrl(url);
if (!newUrl) return;
if (platform.isTauri) {
const { open } = await import('@tauri-apps/api/shell');
await open(newUrl);
return;
}
window.open(newUrl, target);
}