mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-09-11 19:17:43 +03:00
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:
parent
e0d6b194bf
commit
6d0598b101
2
.github/workflows/ios_ci.yaml
vendored
2
.github/workflows/ios_ci.yaml
vendored
@ -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"
|
||||
|
@ -31,7 +31,6 @@ declare global {
|
||||
interface Chainable {
|
||||
mount: typeof mount;
|
||||
mockAPI: () => void;
|
||||
mockFullDocument: () => void;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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'}
|
||||
|
@ -1,3 +1,276 @@
|
||||
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,
|
||||
@ -12,3 +285,10 @@ export enum CollabOrigin {
|
||||
Local = 'local',
|
||||
Remote = 'remote',
|
||||
}
|
||||
|
||||
export const layoutMap = {
|
||||
[ViewLayout.Document]: 'document',
|
||||
[ViewLayout.Grid]: 'grid',
|
||||
[ViewLayout.Board]: 'board',
|
||||
[ViewLayout.Calendar]: 'calendar',
|
||||
};
|
||||
|
@ -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);
|
||||
};
|
@ -0,0 +1,2 @@
|
||||
export * from './selector';
|
||||
export * from './context';
|
@ -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,
|
||||
};
|
||||
}
|
@ -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';
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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}`;
|
||||
}
|
@ -1,3 +1,5 @@
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
|
||||
const tokenKey = 'token';
|
||||
|
||||
export function readTokenStr() {
|
||||
@ -31,6 +33,5 @@ export function writeToken (token: string) {
|
||||
|
||||
export function invalidToken() {
|
||||
sessionStorage.removeItem(tokenKey);
|
||||
window.location.reload();
|
||||
notify.error('Invalid token, please login again');
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 & {
|
||||
export function initAPIService(
|
||||
config: AFCloudConfig & {
|
||||
deviceId: string;
|
||||
clientId: string;
|
||||
}) {
|
||||
}
|
||||
) {
|
||||
window.refresh_token = writeToken;
|
||||
window.invalid_token = invalidToken;
|
||||
client = ClientAPI.new({
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
@ -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;
|
||||
};
|
||||
@ -31,5 +40,6 @@ export class AFClientService implements AFService {
|
||||
});
|
||||
this.userService = new TauriUserService();
|
||||
this.documentService = new TauriDocumentService();
|
||||
this.folderService = new TauriFolderService();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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: '' }],
|
||||
},
|
||||
|
@ -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';
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { YSharedRoot } from '@/application/document.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';
|
||||
@ -12,11 +12,7 @@ import { translateYTextEvent } from 'src/application/slate-yjs/utils/translateYj
|
||||
* @param sharedType
|
||||
* @param op
|
||||
*/
|
||||
export function translateYjsEvent (
|
||||
sharedRoot: YSharedRoot,
|
||||
editor: Editor,
|
||||
event: Y.YEvent<YSharedRoot>,
|
||||
): Operation[] {
|
||||
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);
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
|
6
frontend/appflowy_web_app/src/assets/clock_alarm.svg
Normal file
6
frontend/appflowy_web_app/src/assets/clock_alarm.svg
Normal 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 |
@ -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 |
@ -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 |
@ -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>;
|
||||
};
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './RecordNotFound';
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export * from './Page';
|
@ -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,
|
||||
};
|
||||
}
|
@ -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 />} />
|
||||
@ -24,4 +26,3 @@ function App () {
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
@ -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: {
|
||||
'&:not(.MuiButton-contained)': {
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
'&:active': {
|
||||
backgroundColor: 'var(--fill-list-hover)',
|
||||
},
|
||||
},
|
||||
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: 'none',
|
||||
|
@ -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);
|
||||
}}
|
||||
|
@ -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;
|
||||
|
@ -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 (
|
||||
<>
|
||||
{doc && (
|
||||
<div className={'relative w-full'}>
|
||||
<DocumentHeader doc={doc} viewId={documentId} />
|
||||
<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 className={'max-w-screen w-[964px] min-w-0'}>
|
||||
<Editor doc={doc} readOnly={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecordNotFound open={notFound} workspaceId={workspaceId} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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 |
@ -0,0 +1 @@
|
||||
export * from './DocumentHeader';
|
@ -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,
|
||||
};
|
||||
}
|
@ -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} />
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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} />
|
||||
|
@ -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('');
|
||||
},
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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]);
|
||||
|
@ -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);
|
||||
|
@ -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'
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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]
|
||||
|
@ -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';
|
||||
|
@ -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>
|
||||
);
|
||||
})
|
||||
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -16,7 +16,7 @@ import {
|
||||
TableCellBlockData,
|
||||
BlockId,
|
||||
BlockData,
|
||||
} from '@/application/document.type';
|
||||
} from '@/application/collab.type';
|
||||
import { HTMLAttributes } from 'react';
|
||||
import { Element } from 'slate';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
17
frontend/appflowy_web_app/src/components/folder/Folder.tsx
Normal file
17
frontend/appflowy_web_app/src/components/folder/Folder.tsx
Normal 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;
|
24
frontend/appflowy_web_app/src/components/folder/ViewItem.tsx
Normal file
24
frontend/appflowy_web_app/src/components/folder/ViewItem.tsx
Normal 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;
|
1
frontend/appflowy_web_app/src/components/folder/index.ts
Normal file
1
frontend/appflowy_web_app/src/components/folder/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Folder';
|
@ -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 />
|
||||
<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>
|
||||
) : (
|
||||
<Button>Login</Button>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
86
frontend/appflowy_web_app/src/components/layout/layout.scss
Normal file
86
frontend/appflowy_web_app/src/components/layout/layout.scss
Normal 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;
|
||||
}
|
||||
}
|
8
frontend/appflowy_web_app/src/pages/FolderPage.tsx
Normal file
8
frontend/appflowy_web_app/src/pages/FolderPage.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Folder } from 'src/components/folder';
|
||||
|
||||
function FolderPage() {
|
||||
return <Folder />;
|
||||
}
|
||||
|
||||
export default FolderPage;
|
@ -10,13 +10,16 @@ function LoginPage () {
|
||||
useEffect(() => {
|
||||
if (currentUser.isAuthenticated) {
|
||||
const redirect = new URLSearchParams(window.location.search).get('redirect');
|
||||
const workspaceId = currentUser.user?.workspaceId;
|
||||
|
||||
navigate(`${redirect || ''}`);
|
||||
if (!redirect || redirect === '/') {
|
||||
return navigate(`/workspace/${workspaceId}`);
|
||||
}
|
||||
|
||||
navigate(`${redirect}`);
|
||||
}
|
||||
}, [currentUser, navigate]);
|
||||
return (
|
||||
<Welcome />
|
||||
);
|
||||
return <Welcome />;
|
||||
}
|
||||
|
||||
export default LoginPage;
|
@ -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:
|
||||
|
20
frontend/appflowy_web_app/src/utils/log.ts
Normal file
20
frontend/appflowy_web_app/src/utils/log.ts
Normal 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);
|
||||
}
|
||||
}
|
10
frontend/appflowy_web_app/src/utils/time.ts
Normal file
10
frontend/appflowy_web_app/src/utils/time.ts
Normal 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);
|
||||
}
|
47
frontend/appflowy_web_app/src/utils/url.ts
Normal file
47
frontend/appflowy_web_app/src/utils/url.ts
Normal 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);
|
||||
}
|
Loading…
Reference in New Issue
Block a user