fix: support inserting grid block in editor (#3875)

* fix: support inserting grid block in editor

* feat: support adding view in table

* feat: support the operations of row in tauri grid
This commit is contained in:
Kilu.He 2023-11-08 14:13:17 +08:00 committed by GitHub
parent 5d4142d5b6
commit 663f9d3423
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1529 additions and 676 deletions

View File

@ -29,6 +29,7 @@
"@slate-yjs/core": "^1.0.0",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.2.0",
"@types/react-swipeable-views": "^0.13.4",
"dayjs": "^1.11.9",
"emoji-mart": "^5.5.2",
"emoji-regex": "^10.2.1",
@ -40,6 +41,7 @@
"is-hotkey": "^0.2.0",
"jest": "^29.5.0",
"katex": "^0.16.7",
"lodash-es": "^4.17.21",
"nanoid": "^4.0.0",
"prismjs": "^1.29.0",
"protoc-gen-ts": "^0.8.5",
@ -55,6 +57,7 @@
"react-katex": "^3.0.1",
"react-redux": "^8.0.5",
"react-router-dom": "^6.8.0",
"react-swipeable-views": "^0.14.0",
"react-transition-group": "^4.4.5",
"react18-input-otp": "^1.1.2",
"redux": "^4.2.1",
@ -73,6 +76,7 @@
"@types/is-hotkey": "^0.1.7",
"@types/jest": "^29.5.3",
"@types/katex": "^0.16.0",
"@types/lodash-es": "^4.17.11",
"@types/node": "^18.7.10",
"@types/prismjs": "^1.26.0",
"@types/quill": "^2.0.10",

View File

@ -34,6 +34,9 @@ dependencies:
'@tauri-apps/api':
specifier: ^1.2.0
version: 1.3.0
'@types/react-swipeable-views':
specifier: ^0.13.4
version: 0.13.4
dayjs:
specifier: ^1.11.9
version: 1.11.9
@ -67,6 +70,9 @@ dependencies:
katex:
specifier: ^0.16.7
version: 0.16.7
lodash-es:
specifier: ^4.17.21
version: 4.17.21
nanoid:
specifier: ^4.0.0
version: 4.0.2
@ -112,6 +118,9 @@ dependencies:
react-router-dom:
specifier: ^6.8.0
version: 6.11.1(react-dom@18.2.0)(react@18.2.0)
react-swipeable-views:
specifier: ^0.14.0
version: 0.14.0(react@18.2.0)
react-transition-group:
specifier: ^4.4.5
version: 4.4.5(react-dom@18.2.0)(react@18.2.0)
@ -162,6 +171,9 @@ devDependencies:
'@types/katex':
specifier: ^0.16.0
version: 0.16.0
'@types/lodash-es':
specifier: ^4.17.11
version: 4.17.11
'@types/node':
specifier: ^18.7.10
version: 18.16.9
@ -553,6 +565,12 @@ packages:
'@babel/helper-plugin-utils': 7.21.5
dev: true
/@babel/runtime@7.0.0:
resolution: {integrity: sha512-7hGhzlcmg01CvH1EHdSPVXYX1aJ8KCEyz6I9xYIi/asDtzBPMyMhVibhM/K6g/5qnKBwjZtp10bNZIEFTRW1MA==}
dependencies:
regenerator-runtime: 0.12.1
dev: false
/@babel/runtime@7.21.5:
resolution: {integrity: sha512-8jI69toZqqcsnqGGqwGS4Qb1VwLOEp4hz+CXPywcvjs60u3B4Pom/U/7rm4W8tMOYEB+E9wgD0mW1l3r8qlI9Q==}
engines: {node: '>=6.9.0'}
@ -1951,6 +1969,12 @@ packages:
resolution: {integrity: sha512-hz+S3nV6Mym5xPbT9fnO8dDhBFQguMYpY0Ipxv06JMi1ORgnEM4M1ymWDUhUNer3ElLmT583opRo4RzxKmh9jw==}
dev: true
/@types/lodash-es@4.17.11:
resolution: {integrity: sha512-eCw8FYAWHt2DDl77s+AMLLzPn310LKohruumpucZI4oOFJkIgnlaJcy23OKMJxx4r9PeTF13Gv6w+jqjWQaYUg==}
dependencies:
'@types/lodash': 4.14.194
dev: true
/@types/lodash.memoize@4.1.7:
resolution: {integrity: sha512-lGN7WeO4vO6sICVpf041Q7BX/9k1Y24Zo3FY0aUezr1QlKznpjzsDk3T3wvH8ofYzoK0QupN9TWcFAFZlyPwQQ==}
dependencies:
@ -1959,7 +1983,6 @@ packages:
/@types/lodash@4.14.194:
resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==}
dev: false
/@types/node@18.16.9:
resolution: {integrity: sha512-IeB32oIV4oGArLrd7znD2rkHQ6EDCM+2Sr76dJnrHwv9OHBTTM6nuDLK9bmikXzPa0ZlWMWtRGo/Uw4mrzQedA==}
@ -2030,6 +2053,12 @@ packages:
redux: 4.2.1
dev: false
/@types/react-swipeable-views@0.13.4:
resolution: {integrity: sha512-hQV9Oq6oa+9HKdnGd43xkckElwf5dThOiegtQxqE7qX761oHhxnZO07fz6IsKSnUy9J3tzlRQBu3sNyvC8+kYw==}
dependencies:
'@types/react': 18.2.6
dev: false
/@types/react-transition-group@4.4.6:
resolution: {integrity: sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==}
dependencies:
@ -2460,7 +2489,7 @@ packages:
resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==}
engines: {node: '>=10', npm: '>=6'}
dependencies:
'@babel/runtime': 7.21.5
'@babel/runtime': 7.22.10
cosmiconfig: 7.1.0
resolve: 1.22.2
dev: false
@ -4557,6 +4586,10 @@ packages:
commander: 8.3.0
dev: false
/keycode@2.2.1:
resolution: {integrity: sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg==}
dev: false
/kleur@3.0.3:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
@ -5317,6 +5350,17 @@ packages:
react: 18.2.0
dev: false
/react-event-listener@0.6.6(react@18.2.0):
resolution: {integrity: sha512-+hCNqfy7o9wvO6UgjqFmBzARJS7qrNoda0VqzvOuioEpoEXKutiKuv92dSz6kP7rYLmyHPyYNLesi5t/aH1gfw==}
peerDependencies:
react: ^16.3.0
dependencies:
'@babel/runtime': 7.22.10
prop-types: 15.8.1
react: 18.2.0
warning: 4.0.3
dev: false
/react-i18next@12.2.2(i18next@22.4.15)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-KBB6buBmVKXUWNxXHdnthp+38gPyBT46hJCAIQ8rX19NFL/m2ahte2KARfIDf2tMnSAL7wwck6eDOd/9zn6aFg==}
peerDependencies:
@ -5442,6 +5486,42 @@ packages:
react: 18.2.0
dev: false
/react-swipeable-views-core@0.14.0:
resolution: {integrity: sha512-0W/e9uPweNEOSPjmYtuKSC/SvKKg1sfo+WtPdnxeLF3t2L82h7jjszuOHz9C23fzkvLfdgkaOmcbAxE9w2GEjA==}
engines: {node: '>=6.0.0'}
dependencies:
'@babel/runtime': 7.0.0
warning: 4.0.3
dev: false
/react-swipeable-views-utils@0.14.0(react@18.2.0):
resolution: {integrity: sha512-W+fXBOsDqgFK1/g7MzRMVcDurp3LqO3ksC8UgInh2P/tKgb5DusuuB1geKHFc6o1wKl+4oyER4Zh3Lxmr8xbXA==}
engines: {node: '>=6.0.0'}
dependencies:
'@babel/runtime': 7.0.0
keycode: 2.2.1
prop-types: 15.8.1
react-event-listener: 0.6.6(react@18.2.0)
react-swipeable-views-core: 0.14.0
shallow-equal: 1.2.1
transitivePeerDependencies:
- react
dev: false
/react-swipeable-views@0.14.0(react@18.2.0):
resolution: {integrity: sha512-wrTT6bi2nC3JbmyNAsPXffUXLn0DVT9SbbcFr36gKpbaCgEp7rX/OFxsu5hPc/NBsUhHyoSRGvwqJNNrWTwCww==}
engines: {node: '>=6.0.0'}
peerDependencies:
react: ^15.3.0 || ^16.0.0 || ^17.0.0
dependencies:
'@babel/runtime': 7.0.0
prop-types: 15.8.1
react: 18.2.0
react-swipeable-views-core: 0.14.0
react-swipeable-views-utils: 0.14.0(react@18.2.0)
warning: 4.0.3
dev: false
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
@ -5509,6 +5589,10 @@ packages:
'@babel/runtime': 7.21.5
dev: false
/regenerator-runtime@0.12.1:
resolution: {integrity: sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==}
dev: false
/regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
dev: false
@ -5669,6 +5753,10 @@ packages:
upper-case-first: 2.0.2
dev: true
/shallow-equal@1.2.1:
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
dev: false
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -6365,6 +6453,12 @@ packages:
dependencies:
makeerror: 1.0.12
/warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
dependencies:
loose-envify: 1.4.0
dev: false
/webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}

View File

@ -15,7 +15,7 @@ const languages = [
'pt-BR',
'pt-PT',
'ru-RU',
'sv',
'sv-SE',
'tr-TR',
'zh-CN',
'zh-TW',

View File

@ -0,0 +1,3 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 7L8 4.5M8 4.5L5 7M8 4.5V12.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 212 B

View File

@ -1,32 +1,27 @@
import { RefObject, createContext, createRef, useContext, useCallback, useMemo, useEffect } from 'react';
import { createContext, useContext, useCallback, useMemo, useEffect, useState, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { proxy, useSnapshot } from 'valtio';
import { DatabaseLayoutPB, DatabaseNotification } from '@/services/backend';
import { subscribeNotifications } from '$app/hooks';
import {
Database,
databaseService,
fieldService,
rowListeners,
sortListeners,
} from './application';
import { Database, databaseService, fieldService, rowListeners, sortListeners } from './application';
const VerticalScrollElementRefContext = createContext<RefObject<Element>>(createRef());
export const VerticalScrollElementProvider = VerticalScrollElementRefContext.Provider;
export const useVerticalScrollElement = () => useContext(VerticalScrollElementRefContext);
export function useSelectDatabaseView() {
export function useSelectDatabaseView({ viewId }: { viewId?: string }) {
const key = 'v';
const [searchParams, setSearchParams] = useSearchParams();
const selectedViewId = useMemo(() => searchParams.get(key), [searchParams]);
const selectedViewId = useMemo(() => searchParams.get(key) || viewId, [searchParams, viewId]);
const selectViewId = useCallback((value: string) => {
setSearchParams({ [key]: value });
}, [setSearchParams]);
const onChange = useCallback(
(value: string) => {
setSearchParams({ [key]: value });
},
[setSearchParams]
);
return [selectedViewId, selectViewId] as const;
return {
selectedViewId,
onChange,
};
}
const DatabaseContext = createContext<Database>({
@ -59,26 +54,82 @@ export const useConnectDatabase = (viewId: string) => {
groups: [],
});
void databaseService.openDatabase(viewId).then(value => Object.assign(proxyDatabase, value));
void databaseService.openDatabase(viewId).then((value) => Object.assign(proxyDatabase, value));
return proxyDatabase;
}, [viewId]);
useEffect(() => {
const unsubscribePromise = subscribeNotifications({
[DatabaseNotification.DidUpdateFields]: async () => {
database.fields = await fieldService.getFields(viewId);
const unsubscribePromise = subscribeNotifications(
{
[DatabaseNotification.DidUpdateFields]: async () => {
database.fields = await fieldService.getFields(viewId);
},
[DatabaseNotification.DidUpdateViewRows]: (changeset) => {
rowListeners.didUpdateViewRows(database, changeset);
},
[DatabaseNotification.DidReorderRows]: (changeset) => {
rowListeners.didReorderRows(database, changeset);
},
[DatabaseNotification.DidReorderSingleRow]: (changeset) => {
rowListeners.didReorderSingleRow(database, changeset);
},
[DatabaseNotification.DidUpdateSort]: (changeset) => {
sortListeners.didUpdateSort(database, changeset);
},
},
{ id: viewId }
);
[DatabaseNotification.DidUpdateViewRows]: changeset => rowListeners.didUpdateViewRows(database, changeset),
[DatabaseNotification.DidReorderRows]: changeset => rowListeners.didReorderRows(database, changeset),
[DatabaseNotification.DidReorderSingleRow]: changeset => rowListeners.didReorderSingleRow(database, changeset),
[DatabaseNotification.DidUpdateSort]: changeset => sortListeners.didUpdateSort(database, changeset),
}, { id: viewId });
return () => void unsubscribePromise.then(unsubscribe => unsubscribe());
return () => void unsubscribePromise.then((unsubscribe) => unsubscribe());
}, [viewId, database]);
return database;
};
export function useDatabaseResize() {
const ref = useRef<HTMLDivElement>(null);
const collectionRef = useRef<HTMLDivElement>(null);
const [tableHeight, setTableHeight] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const collectionElement = collectionRef.current;
const handleResize = () => {
const rect = element.getBoundingClientRect();
const collectionRect = collectionElement?.getBoundingClientRect();
let height = rect.height - 31;
if (collectionRect) {
height -= collectionRect.height;
}
setTableHeight(height);
};
handleResize();
const resizeObserver = new ResizeObserver(() => {
handleResize();
});
resizeObserver.observe(element);
if (collectionElement) {
resizeObserver.observe(collectionRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
return {
ref,
collectionRef,
tableHeight,
};
}

View File

@ -1,42 +1,70 @@
import { useEffect, useMemo, useState } from 'react';
import { useViewId } from '$app/hooks';
import { DatabaseView as DatabaseViewType, databaseViewService } from './application';
import { useViewId } from '$app/hooks/ViewId.hooks';
import { databaseViewService } from './application';
import { DatabaseTabBar } from './components';
import { useSelectDatabaseView } from './Database.hooks';
import { DatabaseLoader } from './DatabaseLoader';
import { DatabaseView } from './DatabaseView';
import { DatabaseSettings } from './components/database_settings';
import { DatabaseCollection } from './components/database_settings';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
import SwipeableViews from 'react-swipeable-views';
import { TabPanel } from '$app/components/database/components/tab_bar/ViewTabs';
import { useDatabaseResize } from '$app/components/database/Database.hooks';
export const Database = () => {
interface Props {
selectedViewId?: string;
setSelectedViewId?: (viewId: string) => void;
}
export const Database = ({ selectedViewId, setSelectedViewId }: Props) => {
const viewId = useViewId();
const [views, setViews] = useState<DatabaseViewType[]>([]);
const [selectedViewId, selectViewId] = useSelectDatabaseView();
const activeView = useMemo(() => views?.find((view) => view.id === selectedViewId), [views, selectedViewId]);
const [childViewIds, setChildViewIds] = useState<string[]>([]);
const { ref, collectionRef, tableHeight } = useDatabaseResize();
useEffect(() => {
setViews([]);
void databaseViewService.getDatabaseViews(viewId).then((value) => {
setViews(value);
const onPageChanged = () => {
void databaseViewService.getDatabaseViews(viewId).then((value) => {
setChildViewIds(value.map((view) => view.id));
});
};
onPageChanged();
const pageController = new PageController(viewId);
void pageController.subscribe({
onPageChanged,
});
return () => {
void pageController.unsubscribe();
};
}, [viewId]);
useEffect(() => {
if (!activeView) {
const firstViewId = views?.[0]?.id;
const index = useMemo(() => {
return Math.max(0, childViewIds.indexOf(selectedViewId ?? viewId));
}, [childViewIds, selectedViewId, viewId]);
if (firstViewId) {
selectViewId(firstViewId);
}
}
}, [views, activeView, selectViewId]);
return (
<div ref={ref} className='appflowy-database flex flex-1 flex-col overflow-y-hidden'>
<DatabaseTabBar
pageId={viewId}
setSelectedViewId={setSelectedViewId}
selectedViewId={selectedViewId}
childViewIds={childViewIds}
/>
<SwipeableViews className={'flex-1 overflow-hidden'} axis={'x'} index={index}>
{childViewIds.map((id) => (
<TabPanel key={id} index={index} value={index}>
<DatabaseLoader viewId={id}>
<div ref={collectionRef}>
<DatabaseCollection />
</div>
return activeView ? (
<DatabaseLoader viewId={viewId}>
<div className='px-16'>
<DatabaseTabBar views={views} />
<DatabaseSettings />
</div>
<DatabaseView />
</DatabaseLoader>
) : null;
<DatabaseView isActivated={selectedViewId === id} tableHeight={tableHeight} />
</DatabaseLoader>
</TabPanel>
))}
</SwipeableViews>
</div>
);
};

View File

@ -3,20 +3,16 @@ import { ViewIdProvider } from '$app/hooks';
import { DatabaseProvider, useConnectDatabase } from './Database.hooks';
export interface DatabaseLoaderProps {
viewId: string
viewId: string;
}
export const DatabaseLoader: FC<PropsWithChildren<DatabaseLoaderProps>> = ({
viewId,
children,
}) => {
export const DatabaseLoader: FC<PropsWithChildren<DatabaseLoaderProps>> = ({ viewId, children }) => {
const database = useConnectDatabase(viewId);
return (
<DatabaseProvider value={database}>
<ViewIdProvider value={viewId}>
{children}
</ViewIdProvider>
{/* Make sure that the viewId is current */}
<ViewIdProvider value={viewId}>{children}</ViewIdProvider>
</DatabaseProvider>
);
};

View File

@ -5,12 +5,12 @@ import { useViewId } from '$app/hooks';
export const DatabaseTitle = () => {
const viewId = useViewId();
const [ title, setTitle ] = useState('');
const [title, setTitle] = useState('');
const controller = useMemo(() => new PageController(viewId), [ viewId ]);
const controller = useMemo(() => new PageController(viewId), [viewId]);
useEffect(() => {
void controller.getPage().then(page => {
void controller.getPage().then((page) => {
setTitle(page.name);
});
@ -23,21 +23,24 @@ export const DatabaseTitle = () => {
return () => {
void controller.unsubscribe();
};
}, [ controller ]);
}, [controller]);
const handleInput = useCallback<FormEventHandler>((event) => {
const newTitle = (event.target as HTMLInputElement).value;
const handleInput = useCallback<FormEventHandler>(
(event) => {
const newTitle = (event.target as HTMLInputElement).value;
void controller.updatePage({
id: viewId,
name: newTitle,
});
}, [ viewId, controller ]);
void controller.updatePage({
id: viewId,
name: newTitle,
});
},
[viewId, controller]
);
return (
<div className="px-16 pt-8 mb-6">
<div className='mb-6 h-[70px] pt-8'>
<input
className="text-3xl font-semibold"
className='text-3xl font-semibold'
value={title}
placeholder={t('grid.title.placeholder')}
onInput={handleInput}

View File

@ -5,15 +5,20 @@ import { Grid } from './grid';
import { Board } from './board';
import { Calendar } from './calendar';
const ViewMap: Record<DatabaseLayoutPB, FC | null> = {
[DatabaseLayoutPB.Grid]: Grid,
[DatabaseLayoutPB.Board]: Board,
[DatabaseLayoutPB.Calendar]: Calendar,
};
export const DatabaseView: FC = () => {
export const DatabaseView: FC<{
tableHeight: number;
isActivated: boolean;
}> = (props) => {
const { layoutType } = useDatabase();
const View = ViewMap[layoutType];
return View && <View />;
switch (layoutType) {
case DatabaseLayoutPB.Grid:
return <Grid {...props} />;
case DatabaseLayoutPB.Board:
return <Board />;
case DatabaseLayoutPB.Calendar:
return <Calendar />;
default:
return null;
}
};

View File

@ -4,9 +4,10 @@ import React, { CSSProperties, FC } from 'react';
export interface VirtualizedListProps {
className?: string;
style?: CSSProperties | undefined;
virtualizer: Virtualizer<Element, Element>,
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
itemClassName?: string;
renderItem: (index: number) => React.ReactNode;
getItemStyle?: (index: number) => CSSProperties | undefined;
}
export const VirtualizedList: FC<VirtualizedListProps> = ({
@ -15,6 +16,7 @@ export const VirtualizedList: FC<VirtualizedListProps> = ({
itemClassName,
virtualizer,
renderItem,
getItemStyle,
}) => {
const virtualItems = virtualizer.getVirtualItems();
const { horizontal } = virtualizer.options;
@ -32,7 +34,10 @@ export const VirtualizedList: FC<VirtualizedListProps> = ({
<div
key={key}
className={itemClassName}
style={{ [sizeProp]: size }}
style={{
[sizeProp]: size,
...getItemStyle?.(index),
}}
data-key={key}
data-index={index}
>

View File

@ -37,7 +37,9 @@ export const useDraggable = ({
previewRef.current = previewElement;
}, []);
const attributes = useMemo(() => {
const attributes: {
draggable?: boolean;
} = useMemo(() => {
if (disabled) {
return {};
}
@ -89,7 +91,10 @@ export const useDraggable = ({
context.dragging = null;
}, [context]);
const listeners = useMemo(
const listeners: {
onDragStart?: DragEventHandler;
onDragEnd?: DragEventHandler;
} = useMemo(
() => ({
onDragStart,
onDragEnd,

View File

@ -1,10 +1,4 @@
import {
CreateViewPayloadPB,
RepeatedViewIdPB,
UpdateViewPayloadPB,
ViewIdPB,
ViewLayoutPB,
} from '@/services/backend';
import { CreateViewPayloadPB, RepeatedViewIdPB, UpdateViewPayloadPB, ViewIdPB, ViewLayoutPB } from '@/services/backend';
import {
FolderEventCreateView,
FolderEventDeleteView,
@ -12,42 +6,45 @@ import {
FolderEventUpdateView,
} from '@/services/backend/events/flowy-folder2';
import { databaseService } from '../database';
import { DatabaseView, DatabaseViewLayout, pbToDatabaseView } from './database_view_types';
import { Page, parserViewPBToPage } from '$app_reducers/pages/slice';
export async function getDatabaseViews(viewId: string): Promise<DatabaseView[]> {
export async function getDatabaseViews(viewId: string): Promise<Page[]> {
const payload = ViewIdPB.fromObject({ value: viewId });
const result = await FolderEventReadView(payload);
return result.map(value => {
return [
pbToDatabaseView(value),
...value.child_views.map(pbToDatabaseView),
];
}).unwrap();
if (result.ok) {
return [parserViewPBToPage(result.val), ...result.val.child_views.map(parserViewPBToPage)];
}
return Promise.reject(result.err);
}
export async function createDatabaseView(
viewId: string,
layout: DatabaseViewLayout,
layout: ViewLayoutPB,
name: string,
databaseId?: string,
): Promise<DatabaseView> {
databaseId?: string
): Promise<Page> {
const payload = CreateViewPayloadPB.fromObject({
parent_view_id: viewId,
name,
layout,
meta: {
'database_id': databaseId || await databaseService.getDatabaseId(viewId),
database_id: databaseId || (await databaseService.getDatabaseId(viewId)),
},
});
const result = await FolderEventCreateView(payload);
return result.map(pbToDatabaseView).unwrap();
if (result.ok) {
return parserViewPBToPage(result.val);
}
return Promise.reject(result.err);
}
export async function updateView(viewId: string, view: { name?: string; layout?: ViewLayoutPB }): Promise<DatabaseView> {
export async function updateView(viewId: string, view: { name?: string; layout?: ViewLayoutPB }): Promise<Page> {
const payload = UpdateViewPayloadPB.fromObject({
view_id: viewId,
name: view.name,
@ -56,7 +53,11 @@ export async function updateView(viewId: string, view: { name?: string; layout?:
const result = await FolderEventUpdateView(payload);
return result.map(pbToDatabaseView).unwrap();
if (result.ok) {
return parserViewPBToPage(result.val);
}
return Promise.reject(result.err);
}
export async function deleteView(viewId: string): Promise<void> {
@ -66,5 +67,9 @@ export async function deleteView(viewId: string): Promise<void> {
const result = await FolderEventDeleteView(payload);
return result.unwrap();
if (result.ok) {
return;
}
return Promise.reject(result.err);
}

View File

@ -1,25 +0,0 @@
import { CalendarLayoutPB, ViewLayoutPB, ViewPB } from '@/services/backend';
export type DatabaseViewLayout = ViewLayoutPB.Grid | ViewLayoutPB.Board | ViewLayoutPB.Calendar;
export interface DatabaseView {
id: string;
name: string;
layout: DatabaseViewLayout;
}
export interface CalendarLayoutSetting {
fieldId?: string;
layoutTy?: CalendarLayoutPB;
firstDayOfWeek?: number;
showWeekends?: boolean;
showWeekNumbers?: boolean;
}
export function pbToDatabaseView(viewPB: ViewPB): DatabaseView {
return {
id: viewPB.id,
layout: viewPB.layout as DatabaseViewLayout,
name: viewPB.name,
};
}

View File

@ -1,2 +1 @@
export * from './database_view_types';
export * as databaseViewService from './database_view_service';

View File

@ -1,22 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { DatabaseNotification, FieldType } from '@/services/backend';
import { useNotification, useViewId } from '$app/hooks';
import { cellService, Cell } from '../../application';
import { debounce } from 'lodash-es';
// delay for debounced fetch
// Because we don't want to fetch cell when element is scrolling
const DELAY = 200;
export const useCell = (rowId: string, fieldId: string, fieldType: FieldType) => {
const viewId = useViewId();
const [cell, setCell] = useState<Cell | null>(null);
const [cell, setCell] = useState<Cell | undefined>(undefined);
const fetchCell = useCallback(() => {
void cellService.getCell(viewId, rowId, fieldId, fieldType)
.then(data => {
setCell(data);
});
void cellService.getCell(viewId, rowId, fieldId, fieldType).then((data) => {
setCell(data);
});
}, [viewId, rowId, fieldId, fieldType]);
const debouncedFetchCell = useMemo(() => debounce(fetchCell, DELAY), [fetchCell]);
useEffect(() => {
void fetchCell();
}, [fetchCell]);
debouncedFetchCell();
return () => {
debouncedFetchCell.cancel();
};
}, [debouncedFetchCell]);
useNotification(DatabaseNotification.DidUpdateCell, fetchCell, { id: `${rowId}:${fieldId}` });

View File

@ -15,26 +15,23 @@ export interface CellProps {
const getCellComponent = (fieldType: FieldType) => {
switch (fieldType) {
case FieldType.RichText:
return TextCell as FC<{ field: Field, cell: CellType }>;
return TextCell as FC<{ field: Field; cell?: CellType }>;
case FieldType.SingleSelect:
case FieldType.MultiSelect:
return SelectCell as FC<{ field: Field, cell: CellType }>;
return SelectCell as FC<{ field: Field; cell?: CellType }>;
case FieldType.Checkbox:
return CheckboxCell as FC<{ field: Field, cell: CellType }>;
return CheckboxCell as FC<{ field: Field; cell?: CellType }>;
default:
return null;
}
};
export const Cell: FC<CellProps> = ({
rowId,
field,
}) => {
export const Cell: FC<CellProps> = ({ rowId, field }) => {
const cell = useCell(rowId, field.id, field.type);
const Component = getCellComponent(field.type);
if (!cell || !Component) {
if (!Component) {
return null;
}

View File

@ -6,23 +6,19 @@ import { useViewId } from '$app/hooks';
import { cellService, CheckboxCell as CheckboxCellType, Field } from '../../application';
export const CheckboxCell: FC<{
field: Field,
cell: CheckboxCellType,
field: Field;
cell?: CheckboxCellType;
}> = ({ field, cell }) => {
const viewId = useViewId();
const checked = cell.data === 'Yes';
const checked = cell?.data === 'Yes';
const handleClick = useCallback(() => {
void cellService.updateCell(
viewId,
cell.rowId,
field.id,
!checked ? 'Yes' : 'No',
);
}, [viewId, cell.rowId, field.id, checked ]);
if (!cell) return;
void cellService.updateCell(viewId, cell.rowId, field.id, !checked ? 'Yes' : 'No');
}, [viewId, cell, field.id, checked]);
return (
<div className="flex items-center w-full px-2 cursor-pointer" onClick={handleClick}>
<div className='flex w-full cursor-pointer items-center px-2' onClick={handleClick}>
<Checkbox
disableRipple
style={{ padding: 0 }}

View File

@ -1,14 +1,6 @@
import { FC, FormEvent, useCallback, useMemo, useState } from 'react';
import { t } from 'i18next';
import {
ListSubheader,
Select,
OutlinedInput,
SelectChangeEvent,
InputBase,
MenuProps,
MenuItem,
} from '@mui/material';
import { ListSubheader, Select, OutlinedInput, SelectChangeEvent, InputBase, MenuProps, MenuItem } from '@mui/material';
import { FieldType } from '@/services/backend';
import { useViewId } from '$app/hooks';
import { cellService, SelectField, SelectCell as SelectCellType } from '../../../application';
@ -32,16 +24,21 @@ const menuProps: Partial<MenuProps> = {
export const SelectCell: FC<{
field: SelectField;
cell: SelectCellType;
cell?: SelectCellType;
}> = ({ field, cell }) => {
const rowId = cell.rowId;
const [open, setOpen] = useState(false);
const rowId = cell?.rowId;
const viewId = useViewId();
const options = useMemo(() => field.typeOption.options ?? [], [field.typeOption.options]);
const selectedIds = useMemo(() => cell.data.selectedOptionIds ?? [], [cell.data.selectedOptionIds]);
const selectedIds = useMemo(() => cell?.data.selectedOptionIds ?? [], [cell?.data.selectedOptionIds]);
const [newOptionName, setNewOptionName] = useState('');
const filteredOptions = useMemo(() => options.filter(option => {
return option.name.toLowerCase().includes(newOptionName.toLowerCase());
}), [options, newOptionName]);
const filteredOptions = useMemo(
() =>
options.filter((option) => {
return option.name.toLowerCase().includes(newOptionName.toLowerCase());
}),
[options, newOptionName]
);
const shouldCreateOption = !!newOptionName && filteredOptions.length === 0;
@ -53,14 +50,18 @@ export const SelectCell: FC<{
const handleClose = useCallback(() => {
setNewOptionName('');
setOpen(false);
}, []);
const handleChange = (event: SelectChangeEvent<string | string[]>) => {
const { target: { value } } = event;
if (!cell || !rowId) return;
const {
target: { value },
} = event;
const current = Array.isArray(value) ? value : [value];
const prev = cell.data.selectedOptionIds;
const deleteOptionIds = prev?.filter(id => current.find(cur => cur === id) === undefined);
const deleteOptionIds = prev?.filter((id) => current.find((cur) => cur === id) === undefined);
void cellService.updateSelectCell(viewId, rowId, field.id, {
insertOptionIds: current,
@ -69,7 +70,8 @@ export const SelectCell: FC<{
};
const handleNewTagClick = async () => {
const exist = options.find(option => option.name.toLowerCase() === newOptionName.toLowerCase());
if (!cell || !rowId) return;
const exist = options.find((option) => option.name.toLowerCase() === newOptionName.toLowerCase());
if (exist) {
return cellService.updateSelectCell(viewId, rowId, field.id, {
@ -83,9 +85,9 @@ export const SelectCell: FC<{
};
const searchInput = (
<ListSubheader className="flex">
<ListSubheader className='flex'>
<OutlinedInput
size="small"
size='small'
value={newOptionName}
onInput={handleInput}
placeholder={t('grid.selectOption.searchOrCreateOption')}
@ -93,50 +95,52 @@ export const SelectCell: FC<{
</ListSubheader>
);
const renderSelectedOptions = useCallback((selected: string[]) => selected
.map((id) => options.find(option => option.id === id))
.map((option) => option && (
<Tag
key={option.id}
size="small"
color={option.color}
label={option.name}
/>
)), [options]);
const renderSelectedOptions = useCallback(
(selected: string[]) =>
selected
.map((id) => options.find((option) => option.id === id))
.map((option) => option && <Tag key={option.id} size='small' color={option.color} label={option.name} />),
[options]
);
return (
<Select
className="w-full"
classes={{
select: 'flex items-center gap-2 px-4 py-1 h-6',
}}
size="small"
value={selectedIds}
multiple={field.type === FieldType.MultiSelect}
input={<InputBase />}
IconComponent={() => null}
MenuProps={menuProps}
renderValue={renderSelectedOptions}
onChange={handleChange}
onClose={handleClose}
>
{searchInput}
<ListSubheader className="text-xs mt-4 mb-2">
{shouldCreateOption
? t('grid.selectOption.createNew')
: t('grid.selectOption.orSelectOne')}
</ListSubheader>
{shouldCreateOption
? <CreateOption label={newOptionName} onClick={handleNewTagClick} />
: filteredOptions.map((option, index) => (
<MenuItem
className={index === 0 ? '' : 'mt-2'}
key={option.id}
value={option.id}
>
<SelectOptionItem option={option} />
</MenuItem>
))}
</Select>
<div className={'relative w-full'}>
<div
onClick={() => {
setOpen(true);
}}
className={'absolute left-0 top-0 flex h-full w-full items-center gap-2 px-4 py-1'}
>
{renderSelectedOptions(selectedIds)}
</div>
{open ? (
<Select
className='h-full w-full'
size='small'
value={selectedIds}
open={open}
multiple={field.type === FieldType.MultiSelect}
input={<InputBase />}
IconComponent={() => null}
MenuProps={menuProps}
onChange={handleChange}
onClose={handleClose}
>
{searchInput}
<ListSubheader className='mb-2 mt-4 text-xs'>
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
</ListSubheader>
{shouldCreateOption ? (
<CreateOption label={newOptionName} onClick={handleNewTagClick} />
) : (
filteredOptions.map((option, index) => (
<MenuItem className={index === 0 ? '' : 'mt-2'} key={option.id} value={option.id}>
<SelectOptionItem option={option} />
</MenuItem>
))
)}
</Select>
) : null}
</div>
);
};

View File

@ -5,16 +5,17 @@ import { cellService, Field, TextCell as TextCellType } from '../../application'
import { CellText } from '../../_shared';
export const TextCell: FC<{
field: Field,
cell: TextCellType;
field: Field;
cell?: TextCellType;
}> = ({ field, cell }) => {
const viewId = useViewId();
const cellRef = useRef<HTMLDivElement>(null);
const [ editing, setEditing ] = useState(false);
const [ text, setText ] = useState('');
const [ width, setWidth ] = useState<number | undefined>(undefined);
const [editing, setEditing] = useState(false);
const [text, setText] = useState('');
const [width, setWidth] = useState<number | undefined>(undefined);
const handleClose = () => {
if (!cell) return;
if (editing) {
if (text !== cell.data) {
void cellService.updateCell(viewId, cell.rowId, field.id, text);
@ -25,9 +26,10 @@ export const TextCell: FC<{
};
const handleClick = useCallback(() => {
if (!cell) return;
setText(cell.data);
setEditing(true);
}, [cell.data]);
}, [cell]);
const handleInput = useCallback<FormEventHandler<HTMLTextAreaElement>>((event) => {
setText((event.target as HTMLTextAreaElement).value);
@ -41,12 +43,8 @@ export const TextCell: FC<{
return (
<>
<CellText
ref={cellRef}
className="w-full"
onClick={handleClick}
>
{cell.data}
<CellText ref={cellRef} className='w-full' onClick={handleClick}>
{cell?.data}
</CellText>
{editing && (
<Popover
@ -64,9 +62,9 @@ export const TextCell: FC<{
onClose={handleClose}
>
<TextareaAutosize
className="resize-none text-sm"
className='resize-none text-sm'
autoFocus
autoCorrect="off"
autoCorrect='off'
value={text}
onInput={handleInput}
/>

View File

@ -0,0 +1,17 @@
import { Sort } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { Sorts } from '../sort';
export const DatabaseCollection = () => {
const { sorts } = useDatabase();
const showSorts = sorts && sorts.length > 0;
const showCollection = showSorts;
return (
<div className={`flex items-center ${!showCollection ? 'h-0' : 'border-b border-line-divider py-3'}`}>
{showSorts && <Sorts sorts={sorts as Sort[]} />}
</div>
);
};

View File

@ -1,13 +0,0 @@
import { Sort } from '../../application';
import { useDatabase } from '../../Database.hooks';
import { Sorts } from '../sort';
export const DatabaseSettings = () => {
const { sorts } = useDatabase();
return (
<div className="flex items-center border-t">
<Sorts sorts={sorts as Sort[]} />
</div>
);
};

View File

@ -1 +1 @@
export * from './DatabaseSettings';
export * from './DatabaseCollection';

View File

@ -0,0 +1,25 @@
import React from 'react';
import { IconButton } from '@mui/material';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useTranslation } from 'react-i18next';
import { ViewLayoutPB } from '@/services/backend';
import { createDatabaseView } from '$app/components/database/application/database_view/database_view_service';
function AddViewBtn({ pageId }: { pageId: string }) {
const { t } = useTranslation();
const onClick = async () => {
try {
await createDatabaseView(pageId, ViewLayoutPB.Grid, t('editor.table'));
} catch (e) {
console.error(e);
}
};
return (
<IconButton onClick={onClick} size='small'>
<AddSvg />
</IconButton>
);
}
export default AddViewBtn;

View File

@ -1,64 +1,51 @@
import { FC, MouseEventHandler, useCallback, useState } from 'react';
import { t } from 'i18next';
import { IconButton, Stack } from '@mui/material';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { DatabaseView } from '../../application';
import { useSelectDatabaseView } from '../../Database.hooks';
import { FC, useEffect } from 'react';
import { ViewTabs, ViewTab } from './ViewTabs';
import { TextButton } from './TextButton';
import { SortMenu } from '../sort';
import { useAppSelector } from '$app/stores/store';
import { useTranslation } from 'react-i18next';
import AddViewBtn from '$app/components/database/components/tab_bar/AddViewBtn';
export interface DatabaseTabBarProps {
views: DatabaseView[];
childViewIds: string[];
selectedViewId?: string;
setSelectedViewId?: (viewId: string) => void;
pageId: string;
}
export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({
views,
}) => {
const [selectedViewId, selectViewId] = useSelectDatabaseView();
const [sortAnchorEl, setSortAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(sortAnchorEl);
export const DatabaseTabBar: FC<DatabaseTabBarProps> = ({ pageId, childViewIds, selectedViewId, setSelectedViewId }) => {
const { t } = useTranslation();
const views = useAppSelector((state) => {
const map = state.pages.pageMap;
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
selectViewId(newValue);
return childViewIds.map((id) => map[id]).filter(Boolean);
});
const handleChange = (_: React.SyntheticEvent, newValue: string) => {
setSelectedViewId?.(newValue);
};
const handleClick = useCallback<MouseEventHandler<HTMLElement>>((event) => {
setSortAnchorEl(event.currentTarget);
}, []);
const handleClose = () => {
setSortAnchorEl(null);
};
useEffect(() => {
if (selectedViewId === undefined && views.length > 0) {
setSelectedViewId?.(views[0].id);
}
}, [selectedViewId, setSelectedViewId, views]);
return (
<div className="flex items-center -mb-px">
<div className='-mb-px flex items-center border-b border-line-divider'>
<div className='flex flex-1 items-center'>
<ViewTabs value={selectedViewId} onChange={handleChange}>
{views.map(view => (
{views.map((view) => (
<ViewTab
key={view.id}
icon={undefined}
iconPosition="start"
color="inherit"
label={view.name}
iconPosition='start'
color='inherit'
label={view.name || t('grid.title.placeholder')}
value={view.id}
/>
))}
</ViewTabs>
<IconButton size="small">
<AddSvg />
</IconButton>
<AddViewBtn pageId={pageId} />
</div>
<Stack className="text-neutral-500" direction="row" spacing="2px">
<TextButton color="inherit">
{t('grid.settings.filter')}
</TextButton>
<TextButton color="inherit" onClick={handleClick}>
{t('grid.settings.sort')}
</TextButton>
<SortMenu open={open} anchorEl={sortAnchorEl} onClose={handleClose} />
</Stack>
</div>
);
};

View File

@ -5,7 +5,7 @@ export const ViewTabs = styled(Tabs)({
'& .MuiTabs-scroller': {
paddingBottom: '2px',
}
},
});
export const ViewTab = styled((props: TabProps) => <Tab disableRipple {...props} />)({
@ -19,3 +19,26 @@ export const ViewTab = styled((props: TabProps) => <Tab disableRipple {...props}
color: 'inherit',
},
});
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
export function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role='tabpanel'
hidden={value !== index}
id={`full-width-tabpanel-${index}`}
aria-labelledby={`full-width-tab-${index}`}
dir={'ltr'}
{...other}
>
{value === index && children}
</div>
);
}

View File

@ -1,8 +1,11 @@
import { FC } from 'react';
import { GridTable } from '../GridTable';
import GridUIProvider from '$app/components/database/proxy/grid/ui_state/Provider';
export const Grid: FC = () => {
export const Grid: FC<{ isActivated: boolean; tableHeight: number }> = ({ isActivated, tableHeight }) => {
return (
<GridTable />
<GridUIProvider isActivated={isActivated}>
<GridTable tableHeight={tableHeight} />
</GridUIProvider>
);
};

View File

@ -15,9 +15,9 @@ export interface GridFieldProps {
export const GridField: FC<GridFieldProps> = ({ field }) => {
const viewId = useViewId();
const { fields } = useDatabase();
const [ openMenu, setOpenMenu ] = useState(false);
const [ openTooltip, setOpenTooltip ] = useState(false);
const [ dropPosition, setDropPosition ] = useState<DropPosition>(DropPosition.Before);
const [openMenu, setOpenMenu] = useState(false);
const [openTooltip, setOpenTooltip] = useState(false);
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const handleClick = useCallback(() => {
setOpenMenu(true);
@ -35,17 +35,14 @@ export const GridField: FC<GridFieldProps> = ({ field }) => {
setOpenTooltip(false);
}, []);
const draggingData = useMemo(() => ({
field,
}), [field]);
const draggingData = useMemo(
() => ({
field,
}),
[field]
);
const {
isDragging,
attributes,
listeners,
setPreviewRef,
previewRef,
} = useDraggable({
const { isDragging, attributes, listeners, setPreviewRef, previewRef } = useDraggable({
type: DragType.Field,
data: draggingData,
scrollOnEdge: {
@ -68,23 +65,23 @@ export const GridField: FC<GridFieldProps> = ({ field }) => {
}, 20);
}, [previewRef]);
const onDrop = useCallback(({ data }: DragItem) => {
const dragField = data.field as Field;
const fromIndex = fields.findIndex(item => item.id === dragField.id);
const dropIndex = fields.findIndex(item => item.id === field.id);
const toIndex = dropIndex + dropPosition + (fromIndex < dropIndex ? -1 : 0);
const onDrop = useCallback(
({ data }: DragItem) => {
const dragField = data.field as Field;
const fromIndex = fields.findIndex((item) => item.id === dragField.id);
const dropIndex = fields.findIndex((item) => item.id === field.id);
const toIndex = dropIndex + dropPosition + (fromIndex < dropIndex ? -1 : 0);
if (fromIndex === toIndex) {
return;
}
if (fromIndex === toIndex) {
return;
}
void fieldService.moveField(viewId, dragField.id, fromIndex, toIndex);
}, [viewId, field, fields, dropPosition]);
void fieldService.moveField(viewId, dragField.id, fromIndex, toIndex);
},
[viewId, field, fields, dropPosition]
);
const {
isOver,
listeners: dropListeners,
} = useDroppable({
const { isOver, listeners: dropListeners } = useDroppable({
accept: DragType.Field,
disabled: isDragging,
onDragOver,
@ -96,35 +93,35 @@ export const GridField: FC<GridFieldProps> = ({ field }) => {
<Tooltip
open={openTooltip && !isDragging}
title={field.name}
placement="right"
placement='right'
enterDelay={1000}
enterNextDelay={1000}
onOpen={handleTooltipOpen}
onClose={handleTooltipClose}
>
<Button
color={'inherit'}
ref={setPreviewRef}
className="flex items-center px-2 w-full relative"
className='relative flex w-full items-center px-2'
disableRipple
onClick={handleClick}
{...attributes}
{...listeners}
{...dropListeners}
>
<FieldTypeSvg className="text-base mr-1" type={field.type} />
<span className="flex-1 text-left text-xs truncate">
{field.name}
</span>
{isOver && <div className={`absolute top-0 bottom-0 w-0.5 bg-blue-500 z-10 ${dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'}`} />}
<FieldTypeSvg className='mr-1 text-base' type={field.type} />
<span className='flex-1 truncate text-left text-xs'>{field.name}</span>
{isOver && (
<div
className={`absolute bottom-0 top-0 z-10 w-0.5 bg-blue-500 ${
dropPosition === DropPosition.Before ? 'left-[-1px]' : 'left-full'
}`}
/>
)}
</Button>
</Tooltip>
{openMenu && (
<GridFieldMenu
field={field}
open={openMenu}
anchorEl={previewRef.current}
onClose={handleMenuClose}
/>
<GridFieldMenu field={field} open={openMenu} anchorEl={previewRef.current} onClose={handleMenuClose} />
)}
</>
);

View File

@ -0,0 +1,71 @@
import { useGridUIStateDispatcher, useGridUIStateSelector } from '$app/components/database/proxy/grid/ui_state/actions';
import { CSSProperties, useCallback, useEffect, useMemo, useState } from 'react';
export function useGridRowActionsDisplay(rowId: string, ref: React.RefObject<HTMLDivElement>) {
const { hoverRowId, isActivated } = useGridUIStateSelector();
const hover = useMemo(() => {
return isActivated && hoverRowId === rowId;
}, [hoverRowId, rowId, isActivated]);
const { setRowHover } = useGridUIStateDispatcher();
const [actionsStyle, setActionsStyle] = useState<CSSProperties | undefined>();
const onMouseEnter = useCallback(() => {
setRowHover(rowId);
}, [setRowHover, rowId]);
useEffect(() => {
// Next frame to avoid layout thrashing
requestAnimationFrame(() => {
const element = ref.current;
if (!hover || !element) {
setActionsStyle(undefined);
return;
}
const rect = element.getBoundingClientRect();
setActionsStyle({
position: 'absolute',
top: rect.top + 6,
left: rect.left - 50,
});
});
}, [ref, hover]);
return {
actionsStyle,
onMouseEnter,
hover,
};
}
export const useGridRowContextMenu = () => {
const [position, setPosition] = useState<{ left: number; top: number } | undefined>();
const isContextMenuOpen = useMemo(() => {
return !!position;
}, [position]);
const closeContextMenu = useCallback(() => {
setPosition(undefined);
}, []);
const openContextMenu = useCallback((event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
setPosition({
left: event.clientX,
top: event.clientY,
});
}, []);
return {
isContextMenuOpen,
closeContextMenu,
openContextMenu,
position,
};
};

View File

@ -1,56 +1,58 @@
import { Virtualizer } from '@tanstack/react-virtual';
import { IconButton, Tooltip } from '@mui/material';
import { t } from 'i18next';
import { DragEventHandler, FC, useCallback, useMemo, useState } from 'react';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
import { Portal } from '@mui/material';
import { DragEventHandler, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { throttle } from '$app/utils/tool';
import { useViewId } from '$app/hooks';
import { useDatabase } from '../../../Database.hooks';
import { rowService, RowMeta } from '../../../application';
import { DragItem, DragType, DropPosition, VirtualizedList, useDraggable, useDroppable, ScrollDirection } from '../../../_shared';
import {
DragItem,
DragType,
DropPosition,
VirtualizedList,
useDraggable,
useDroppable,
ScrollDirection,
} from '../../../_shared';
import { GridCell } from '../../GridCell';
import { GridCellRowActions } from './GridCellRowActions';
import {
useGridRowActionsDisplay,
useGridRowContextMenu,
} from '$app/components/database/grid/GridRow/GridCellRow/GridCellRow.hooks';
import GridCellRowContextMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowContextMenu';
export interface GridCellRowProps {
rowMeta: RowMeta;
virtualizer: Virtualizer<Element, Element>;
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
getPrevRowId: (id: string) => string | null;
}
export const GridCellRow: FC<GridCellRowProps> = ({
rowMeta,
virtualizer,
}) => {
export const GridCellRow: FC<GridCellRowProps> = ({ rowMeta, virtualizer, getPrevRowId }) => {
const rowId = rowMeta.id;
const viewId = useViewId();
const ref = useRef<HTMLDivElement | null>(null);
const { onMouseEnter, actionsStyle, hover } = useGridRowActionsDisplay(rowId, ref);
const {
isContextMenuOpen,
closeContextMenu,
openContextMenu,
position: contextMenuPosition,
} = useGridRowContextMenu();
const { fields } = useDatabase();
const [ hover, setHover ] = useState(false);
const [ openTooltip, setOpenTooltip ] = useState(false);
const [ dropPosition, setDropPosition ] = useState<DropPosition>(DropPosition.Before);
const handleMouseEnter = useCallback(() => {
setHover(true);
}, []);
const handleMouseLeave = useCallback(() => {
setHover(false);
}, []);
const handleTooltipOpen = useCallback(() => {
setOpenTooltip(true);
}, []);
const handleTooltipClose = useCallback(() => {
setOpenTooltip(false);
}, []);
const dragData = useMemo(() => ({
rowMeta,
}), [rowMeta]);
const [dropPosition, setDropPosition] = useState<DropPosition>(DropPosition.Before);
const dragData = useMemo(
() => ({
rowMeta,
}),
[rowMeta]
);
const {
isDragging,
attributes,
listeners,
attributes: dragAttributes,
listeners: dragListeners,
setPreviewRef,
previewRef,
} = useDraggable({
@ -76,65 +78,73 @@ export const GridCellRow: FC<GridCellRowProps> = ({
}, 20);
}, [previewRef]);
const onDrop = useCallback(({ data }: DragItem) => {
void rowService.moveRow(viewId, (data.rowMeta as RowMeta).id, rowMeta.id);
}, [viewId, rowMeta.id]);
const onDrop = useCallback(
({ data }: DragItem) => {
void rowService.moveRow(viewId, (data.rowMeta as RowMeta).id, rowMeta.id);
},
[viewId, rowMeta.id]
);
const {
isOver,
listeners: dropListeners,
} = useDroppable({
const { isOver, listeners: dropListeners } = useDroppable({
accept: DragType.Row,
disabled: isDragging,
onDragOver,
onDrop,
});
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
element.addEventListener('contextmenu', openContextMenu);
return () => {
element.removeEventListener('contextmenu', openContextMenu);
};
}, [openContextMenu]);
return (
<div
className="flex grow ml-[-49px]"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
{...dropListeners}
>
<GridCellRowActions
className={hover ? 'visible' : 'invisible'}
rowId={rowMeta.id}
>
<Tooltip
placement="top"
title={t('grid.row.drag')}
open={openTooltip && !isDragging}
onOpen={handleTooltipOpen}
onClose={handleTooltipClose}
>
<IconButton
className="mx-1 cursor-grab active:cursor-grabbing"
{...attributes}
{...listeners}
>
<DragSvg className='-mx-1' />
</IconButton>
</Tooltip>
</GridCellRowActions>
<div ref={ref} className='flex grow' onMouseEnter={onMouseEnter} {...dropListeners}>
<div
ref={setPreviewRef}
className={`flex grow border-b border-line-divider relative ${isDragging ? 'bg-blue-50' : ''}`}
className={`relative flex grow border-b border-line-divider ${isDragging ? 'bg-blue-50' : ''}`}
>
<VirtualizedList
className="flex"
itemClassName="flex border-r border-line-divider"
className='flex'
itemClassName='flex border-r border-line-divider'
virtualizer={virtualizer}
renderItem={index => (
<GridCell
rowId={rowMeta.id}
field={fields[index]}
/>
)}
renderItem={(index) => <GridCell rowId={rowMeta.id} field={fields[index]} />}
/>
<div className="min-w-20 grow" />
{isOver && <div className={`absolute left-0 right-0 h-0.5 bg-blue-500 z-10 ${dropPosition === DropPosition.Before ? 'top-[-1px]' : 'top-full'}`} />}
<div className='min-w-20 grow' />
{isOver && (
<div
className={`absolute left-0 right-0 z-10 h-0.5 bg-blue-500 ${
dropPosition === DropPosition.Before ? 'top-[-1px]' : 'top-full'
}`}
/>
)}
</div>
<Portal>
<GridCellRowActions
isHidden={!hover}
style={actionsStyle}
dragProps={{
...dragListeners,
...dragAttributes,
}}
rowId={rowMeta.id}
getPrevRowId={getPrevRowId}
/>
<GridCellRowContextMenu
open={isContextMenuOpen}
onClose={closeContextMenu}
anchorPosition={contextMenuPosition}
rowId={rowId}
getPrevRowId={getPrevRowId}
/>
</Portal>
</div>
);
};

View File

@ -1,36 +1,85 @@
import { IconButton, Tooltip } from '@mui/material';
import { FC, PropsWithChildren, useCallback } from 'react';
import { DragEventHandler, FC, HTMLAttributes, PropsWithChildren, useCallback, useRef, useState } from 'react';
import { t } from 'i18next';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { useViewId } from '$app/hooks';
import { rowService } from '../../../application';
import { ReactComponent as DragSvg } from '$app/assets/drag.svg';
import GridCellRowMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu';
import Popover from '@mui/material/Popover';
export interface GridCellRowActionsProps {
className?: string;
export interface GridCellRowActionsProps extends HTMLAttributes<HTMLDivElement> {
rowId: string;
getPrevRowId: (id: string) => string | null;
dragProps: {
draggable?: boolean;
onDragStart?: DragEventHandler;
onDragEnd?: DragEventHandler;
};
isHidden?: boolean;
}
export const GridCellRowActions: FC<PropsWithChildren<GridCellRowActionsProps>> = ({
className,
isHidden,
rowId,
children,
getPrevRowId,
className,
dragProps: { draggable, onDragStart, onDragEnd },
...props
}) => {
const viewId = useViewId();
const handleInsertRowClick = useCallback(() => {
const ref = useRef<HTMLDivElement | null>(null);
const [openMenu, setOpenMenu] = useState(false);
const handleInsertRecordBelow = useCallback(() => {
void rowService.createRow(viewId, {
startRowId: rowId,
});
}, [viewId, rowId]);
const handleOpenMenu = () => {
setOpenMenu(true);
};
const handleCloseMenu = () => {
setOpenMenu(false);
};
if (isHidden) return null;
return (
<div className={`inline-flex items-center ${className}`}>
<Tooltip placement="top" title={t('grid.row.add')}>
<IconButton onClick={handleInsertRowClick}>
<div ref={ref} className={`relative inline-flex items-center ${className || ''}`} {...props}>
<Tooltip placement='top' title={t('grid.row.add')}>
<IconButton onClick={handleInsertRecordBelow}>
<AddSvg />
</IconButton>
</Tooltip>
{children}
<Tooltip placement='top' title={t('grid.row.dragAndClick')}>
<IconButton
onClick={handleOpenMenu}
className='mx-1 cursor-grab active:cursor-grabbing'
draggable={draggable}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<DragSvg className='-mx-1' />
</IconButton>
</Tooltip>
<Popover
open={openMenu}
onClose={handleCloseMenu}
transformOrigin={{
vertical: 'center',
horizontal: 'left',
}}
anchorOrigin={{
vertical: 'center',
horizontal: 'right',
}}
container={ref.current}
anchorEl={ref.current}
>
<GridCellRowMenu onClickItem={() => handleCloseMenu} rowId={rowId} getPrevRowId={getPrevRowId} />
</Popover>
</div>
);
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import GridCellRowMenu from '$app/components/database/grid/GridRow/GridCellRow/GridCellRowMenu';
import Popover from '@mui/material/Popover';
interface Props {
open: boolean;
onClose: () => void;
anchorPosition?: {
top: number;
left: number;
};
rowId: string;
getPrevRowId: (id: string) => string | null;
}
function GridCellRowContextMenu({ open, anchorPosition, onClose, rowId, getPrevRowId }: Props) {
return (
<Popover
open={open}
onClose={onClose}
anchorPosition={anchorPosition}
anchorReference={'anchorPosition'}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
>
<GridCellRowMenu
rowId={rowId}
getPrevRowId={getPrevRowId}
onClickItem={() => {
onClose();
}}
/>
</Popover>
);
}
export default GridCellRowContextMenu;

View File

@ -0,0 +1,96 @@
import React, { useCallback } from 'react';
import { MenuList, MenuItem, Icon } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { ReactComponent as UpSvg } from '$app/assets/up.svg';
import { ReactComponent as AddSvg } from '$app/assets/add.svg';
import { ReactComponent as DelSvg } from '$app/assets/delete.svg';
import { ReactComponent as CopySvg } from '$app/assets/copy.svg';
import { rowService } from '$app/components/database/application';
import { useViewId } from '@/appflowy_app/hooks/ViewId.hooks';
interface Props {
rowId: string;
getPrevRowId: (id: string) => string | null;
onClickItem: (label: string) => void;
}
interface Option {
label: string;
icon: JSX.Element;
onClick: () => void;
divider?: boolean;
}
function GridCellRowMenu({ rowId, getPrevRowId, onClickItem }: Props) {
const viewId = useViewId();
const { t } = useTranslation();
const handleInsertRecordBelow = useCallback(() => {
void rowService.createRow(viewId, {
startRowId: rowId,
});
}, [viewId, rowId]);
const handleInsertRecordAbove = useCallback(() => {
const prevRowId = getPrevRowId(rowId);
void rowService.createRow(viewId, {
startRowId: prevRowId || undefined,
});
}, [getPrevRowId, rowId, viewId]);
const handleDelRow = useCallback(() => {
void rowService.deleteRow(viewId, rowId);
}, [viewId, rowId]);
const handleDuplicateRow = useCallback(() => {
void rowService.duplicateRow(viewId, rowId);
}, [viewId, rowId]);
const options: Option[] = [
{
label: t('grid.row.insertRecordAbove'),
icon: <UpSvg />,
onClick: handleInsertRecordAbove,
},
{
label: t('grid.row.insertRecordBelow'),
icon: <AddSvg />,
onClick: handleInsertRecordBelow,
},
{
label: t('grid.row.duplicate'),
icon: <CopySvg />,
onClick: handleDuplicateRow,
},
{
label: t('grid.row.delete'),
icon: <DelSvg />,
onClick: handleDelRow,
divider: true,
},
];
return (
<MenuList>
{options.map((option) => (
<div className={'w-full'} key={option.label}>
{option.divider && <div className='mx-2 my-1.5 h-[1px] bg-line-divider' />}
<MenuItem
onClick={() => {
option.onClick();
onClickItem(option.label);
}}
>
<Icon className='mr-2'>{option.icon}</Icon>
{option.label}
</MenuItem>
</div>
))}
</MenuList>
);
}
export default GridCellRowMenu;

View File

@ -8,14 +8,14 @@ import { useDatabase } from '../../Database.hooks';
import { VirtualizedList } from '../../_shared';
import { GridField } from '../GridField';
import { useViewId } from '@/appflowy_app/hooks';
import { useTranslation } from 'react-i18next';
export interface GridFieldRowProps {
virtualizer: Virtualizer<Element, Element>;
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
}
export const GridFieldRow: FC<GridFieldRowProps> = ({
virtualizer,
}) => {
export const GridFieldRow: FC<GridFieldRowProps> = ({ virtualizer }) => {
const { t } = useTranslation();
const viewId = useViewId();
const { fields } = useDatabase();
const handleClick = async () => {
@ -23,20 +23,23 @@ export const GridFieldRow: FC<GridFieldRowProps> = ({
};
return (
<div className="flex grow border-b border-line-divider">
<div className='z-10 flex border-b border-line-divider'>
<VirtualizedList
className="flex"
className='flex'
virtualizer={virtualizer}
itemClassName="flex border-r border-line-divider"
renderItem={index => <GridField field={fields[index]} />}
itemClassName='flex border-r border-line-divider'
renderItem={(index) => <GridField field={fields[index]} />}
/>
<div className="min-w-20 grow">
<div className='min-w-20 grow'>
<Button
className="w-full h-full"
size="small"
color={'inherit'}
className='flex h-full w-full items-center justify-start whitespace-nowrap text-left'
size='small'
startIcon={<AddSvg />}
onClick={handleClick}
/>
>
{t('grid.field.newColumn')}
</Button>
</div>
</div>
);

View File

@ -10,10 +10,7 @@ export interface GridNewRowProps {
groupId?: string;
}
export const GridNewRow: FC<GridNewRowProps> = ({
startRowId,
groupId,
}) => {
export const GridNewRow: FC<GridNewRowProps> = ({ startRowId, groupId }) => {
const viewId = useViewId();
const handleClick = useCallback(() => {
@ -24,13 +21,10 @@ export const GridNewRow: FC<GridNewRowProps> = ({
}, [viewId, groupId, startRowId]);
return (
<div className="flex grow border-b border-line-divider">
<Button
className="grow justify-start"
onClick={handleClick}
>
<span className="inline-flex items-center sticky left-2">
<AddSvg className="text-base mr-1" />
<div className='flex grow border-b border-line-divider'>
<Button className='grow justify-start' onClick={handleClick} color={'inherit'}>
<span className='sticky left-2 inline-flex items-center'>
<AddSvg className='mr-1 text-base' />
{t('grid.row.newRow')}
</span>
</Button>

View File

@ -8,22 +8,14 @@ import { GridCalculateRow } from './GridCalculateRow';
export interface GridRowProps {
row: RenderRow;
virtualizer: Virtualizer<Element, Element>;
virtualizer: Virtualizer<HTMLDivElement, HTMLDivElement>;
getPrevRowId: (id: string) => string | null;
}
export const GridRow: FC<GridRowProps> = ({
row,
virtualizer,
}) => {
export const GridRow: FC<GridRowProps> = ({ row, virtualizer, getPrevRowId }) => {
switch (row.type) {
case RenderRowType.Row:
return (
<GridCellRow
rowMeta={row.data.meta}
virtualizer={virtualizer}
/>
);
return <GridCellRow rowMeta={row.data.meta} virtualizer={virtualizer} getPrevRowId={getPrevRowId} />;
case RenderRowType.Fields:
return <GridFieldRow virtualizer={virtualizer} />;
case RenderRowType.NewRow:

View File

@ -14,7 +14,7 @@ export interface FieldRenderRow {
export interface CellRenderRow {
type: RenderRowType.Row;
data: {
meta: RowMeta,
meta: RowMeta;
};
}
@ -23,7 +23,7 @@ export interface NewRenderRow {
data: {
startRowId?: string;
groupId?: string;
},
};
}
export interface CalculateRenderRow {
@ -37,7 +37,7 @@ export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
{
type: RenderRowType.Fields,
},
...rowMetas.map<RenderRow>(rowMeta => ({
...rowMetas.map<RenderRow>((rowMeta) => ({
type: RenderRowType.Row,
data: {
meta: rowMeta,
@ -54,4 +54,3 @@ export const rowMetasToRenderRow = (rowMetas: RowMeta[]): RenderRow[] => {
},
];
};

View File

@ -1,7 +1,7 @@
import { useVirtualizer } from '@tanstack/react-virtual';
import { FC, useMemo, useRef } from 'react';
import { RowMeta } from '../../application';
import { useDatabase, useVerticalScrollElement } from '../../Database.hooks';
import { useDatabase } from '../../Database.hooks';
import { VirtualizedList } from '../../_shared';
import { GridRow, RenderRow, RenderRowType, rowMetasToRenderRow } from '../GridRow';
@ -13,44 +13,62 @@ const getRenderRowKey = (row: RenderRow) => {
return row.type;
};
export const GridTable: FC = () => {
const verticalScrollElementRef = useVerticalScrollElement();
const horizontalScrollElementRef = useRef<HTMLDivElement>(null);
export const GridTable: FC<{ tableHeight: number }> = ({ tableHeight }) => {
const verticalScrollElementRef = useRef<HTMLDivElement | null>(null);
const horizontalScrollElementRef = useRef<HTMLDivElement | null>(null);
const { rowMetas, fields } = useDatabase();
const renderRows = useMemo<RenderRow[]>(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]);
const rowVirtualizer = useVirtualizer({
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
count: renderRows.length,
overscan: 10,
getItemKey: i => getRenderRowKey(renderRows[i]),
overscan: 20,
getItemKey: (i) => getRenderRowKey(renderRows[i]),
getScrollElement: () => verticalScrollElementRef.current,
estimateSize: () => 37,
});
const columnVirtualizer = useVirtualizer<Element, Element>({
const columnVirtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({
horizontal: true,
count: fields.length,
overscan: 5,
getItemKey: i => fields[i].id,
getItemKey: (i) => fields[i].id,
getScrollElement: () => horizontalScrollElementRef.current,
estimateSize: (i) => fields[i].width ?? 201,
});
const getPrevRowId = (id: string) => {
const index = rowMetas.findIndex((rowMeta) => rowMeta.id === id);
if (index === 0) {
return null;
}
return rowMetas[index - 1].id;
};
return (
<div
ref={horizontalScrollElementRef}
className="flex w-full overflow-x-auto px-16"
style={{ minHeight: 'calc(100% - 132px)' }}
style={{
height: tableHeight,
}}
className={'flex w-full flex-col'}
>
<VirtualizedList
className="flex flex-col basis-full"
virtualizer={rowVirtualizer}
itemClassName="flex"
renderItem={index => (
<GridRow row={renderRows[index]} virtualizer={columnVirtualizer} />
)}
/>
<div
className={'w-full flex-1 overflow-auto'}
ref={(e) => {
verticalScrollElementRef.current = e;
horizontalScrollElementRef.current = e;
}}
>
<VirtualizedList
className='flex w-fit basis-full flex-col'
virtualizer={rowVirtualizer}
itemClassName='flex'
renderItem={(index) => (
<GridRow getPrevRowId={getPrevRowId} row={renderRows[index]} virtualizer={columnVirtualizer} />
)}
/>
</div>
</div>
);
};

View File

@ -0,0 +1,14 @@
import React, { useEffect } from 'react';
import { GridUIContext, useProxyGridUIState } from '$app/components/database/proxy/grid/ui_state/actions';
function GridUIProvider({ children, isActivated }: { children: React.ReactNode; isActivated: boolean }) {
const context = useProxyGridUIState();
useEffect(() => {
context.isActivated = isActivated;
}, [isActivated, context]);
return <GridUIContext.Provider value={context}>{children}</GridUIContext.Provider>;
}
export default GridUIProvider;

View File

@ -0,0 +1,46 @@
import { useMemo, useContext, createContext, useCallback } from 'react';
import { proxy, useSnapshot } from 'valtio';
export interface GridUIContextState {
hoverRowId: string | null;
isActivated: boolean;
}
const initialUIState: GridUIContextState = {
hoverRowId: null,
isActivated: false,
};
function proxyGridUIState(state: GridUIContextState) {
return proxy<GridUIContextState>(state);
}
export const GridUIContext = createContext<GridUIContextState>(proxyGridUIState(initialUIState));
export function useProxyGridUIState() {
const context = useMemo<GridUIContextState>(() => {
return proxyGridUIState({
...initialUIState,
});
}, []);
return context;
}
export function useGridUIStateSelector() {
return useSnapshot(useContext(GridUIContext));
}
export function useGridUIStateDispatcher() {
const context = useContext(GridUIContext);
const setRowHover = useCallback(
(rowId: string | null) => {
context.hoverRowId = rowId;
},
[context]
);
return {
setRowHover,
};
}

View File

@ -39,6 +39,10 @@ export function useBlockSideToolbar(id: string) {
return -6;
}
if (block.type === BlockType.GridBlock) {
return 16;
}
return 0;
}, [docId, id]);

View File

@ -75,14 +75,13 @@ export default function BlockSideToolbar({ id }: { id: string }) {
}}
data-draggable-anchor={id}
onClick={async (e: React.MouseEvent<HTMLButtonElement>) => {
handleOpen(e);
await dispatch(
setRectSelectionThunk({
docId,
selection: [id],
})
);
handleOpen(e);
}}
sx={{
height: 24,

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useMemo } from 'react';
import MenuItem from '$app/components/document/_shared/MenuItem';
import {
ArrowRight,
@ -13,6 +13,7 @@ import {
SafetyDivider,
Image,
Functions,
BackupTableOutlined,
} from '@mui/icons-material';
import {
BlockData,
@ -23,29 +24,29 @@ import {
} from '$app/interfaces/document';
import { useAppDispatch } from '$app/stores/store';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { slashCommandActions } from '$app_reducers/document/slice';
import { Keyboard } from '$app/constants/document/keyboard';
import { selectOptionByUpDown } from '$app/utils/document/menu';
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
import { useTranslation } from 'react-i18next';
import { useKeyboardShortcut } from '$app/components/document/BlockSlash/index.hooks';
function BlockSlashMenu({
id,
onClose,
searchText,
hoverOption,
onHoverOption,
container,
}: {
id: string;
onClose?: () => void;
searchText?: string;
hoverOption?: SlashCommandOption;
onHoverOption: (option: SlashCommandOption, target: HTMLElement) => void;
container: HTMLDivElement;
}) {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const ref = useRef<HTMLDivElement | null>(null);
const { docId, controller } = useSubscribeDocument();
const { controller } = useSubscribeDocument();
const handleInsert = useCallback(
async (type: BlockType, data?: BlockData) => {
if (!controller) return;
@ -72,14 +73,14 @@ function BlockSlashMenu({
{
key: SlashCommandOptionKey.TEXT,
type: BlockType.TextBlock,
title: 'Text',
title: t('editor.text'),
icon: <TextFields />,
group: SlashCommandGroup.BASIC,
},
{
key: SlashCommandOptionKey.HEADING_1,
type: BlockType.HeadingBlock,
title: 'Heading 1',
title: t('editor.heading1'),
icon: <Title />,
data: {
level: 1,
@ -89,7 +90,7 @@ function BlockSlashMenu({
{
key: SlashCommandOptionKey.HEADING_2,
type: BlockType.HeadingBlock,
title: 'Heading 2',
title: t('editor.heading2'),
icon: <Title />,
data: {
level: 2,
@ -99,7 +100,7 @@ function BlockSlashMenu({
{
key: SlashCommandOptionKey.HEADING_3,
type: BlockType.HeadingBlock,
title: 'Heading 3',
title: t('editor.heading3'),
icon: <Title />,
data: {
level: 3,
@ -109,35 +110,35 @@ function BlockSlashMenu({
{
key: SlashCommandOptionKey.TODO,
type: BlockType.TodoListBlock,
title: 'To-do list',
title: t('editor.checkbox'),
icon: <Check />,
group: SlashCommandGroup.BASIC,
},
{
key: SlashCommandOptionKey.BULLET,
type: BlockType.BulletedListBlock,
title: 'Bulleted list',
title: t('editor.bulletedList'),
icon: <FormatListBulleted />,
group: SlashCommandGroup.BASIC,
},
{
key: SlashCommandOptionKey.NUMBER,
type: BlockType.NumberedListBlock,
title: 'Numbered list',
title: t('editor.numberedList'),
icon: <FormatListNumbered />,
group: SlashCommandGroup.BASIC,
},
{
key: SlashCommandOptionKey.TOGGLE,
type: BlockType.ToggleListBlock,
title: 'Toggle list',
title: t('document.plugins.toggleList'),
icon: <ArrowRight />,
group: SlashCommandGroup.BASIC,
},
{
key: SlashCommandOptionKey.QUOTE,
type: BlockType.QuoteBlock,
title: 'Quote',
title: t('toolbar.quote'),
icon: <FormatQuote />,
group: SlashCommandGroup.BASIC,
},
@ -151,31 +152,41 @@ function BlockSlashMenu({
{
key: SlashCommandOptionKey.DIVIDER,
type: BlockType.DividerBlock,
title: 'Divider',
title: t('editor.divider'),
icon: <SafetyDivider />,
group: SlashCommandGroup.BASIC,
},
{
key: SlashCommandOptionKey.CODE,
type: BlockType.CodeBlock,
title: 'Code',
title: t('document.selectionMenu.codeBlock'),
icon: <DataObject />,
group: SlashCommandGroup.MEDIA,
},
{
key: SlashCommandOptionKey.IMAGE,
type: BlockType.ImageBlock,
title: 'Image',
title: t('editor.image'),
icon: <Image />,
group: SlashCommandGroup.MEDIA,
},
{
key: SlashCommandOptionKey.EQUATION,
type: BlockType.EquationBlock,
title: 'Block equation',
title: t('document.plugins.mathEquation.addMathEquation'),
icon: <Functions />,
group: SlashCommandGroup.ADVANCED,
},
{
key: SlashCommandOptionKey.GRID_REFERENCE,
type: BlockType.GridBlock,
title: t('document.plugins.referencedGrid'),
icon: <BackupTableOutlined />,
group: SlashCommandGroup.ADVANCED,
onClick: () => {
// do nothing
},
},
].filter((option) => {
if (!searchText) return true;
const match = (text: string) => {
@ -184,9 +195,16 @@ function BlockSlashMenu({
return match(option.title) || match(option.type);
}),
[searchText]
[searchText, t]
);
const { ref } = useKeyboardShortcut({
container,
options,
handleInsert,
hoverOption,
});
const optionsByGroup = useMemo(() => {
return options.reduce((acc, option) => {
if (!acc[option.group]) {
@ -198,89 +216,6 @@ function BlockSlashMenu({
}, {} as Record<SlashCommandGroup, typeof options>);
}, [options]);
const scrollIntoOption = useCallback((option: SlashCommandOption) => {
if (!ref.current) return;
const containerRect = ref.current.getBoundingClientRect();
const optionElement = document.querySelector(`#slash-item-${option.key}`);
if (!optionElement) return;
const itemRect = optionElement?.getBoundingClientRect();
if (!itemRect) return;
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
optionElement.scrollIntoView({ behavior: 'smooth' });
}
}, []);
const selectOptionByArrow = useCallback(
({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => {
if (!isUp && !isDown) return;
const optionsKeys = options.map((option) => String(option.key));
const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys);
const nextOption = options.find((option) => String(option.key) === nextKey);
if (!nextOption) return;
scrollIntoOption(nextOption);
dispatch(
slashCommandActions.setHoverOption({
option: nextOption,
docId,
})
);
},
[dispatch, docId, hoverOption?.key, options, scrollIntoOption]
);
useEffect(() => {
const handleKeyDownCapture = (e: KeyboardEvent) => {
const isUp = e.key === Keyboard.keys.UP;
const isDown = e.key === Keyboard.keys.DOWN;
const isEnter = e.key === Keyboard.keys.ENTER;
// if any arrow key is pressed, prevent default behavior and stop propagation
if (isUp || isDown || isEnter) {
e.stopPropagation();
e.preventDefault();
if (isEnter) {
if (hoverOption) {
void handleInsert(hoverOption.type, hoverOption.data);
}
return;
}
selectOptionByArrow({
isUp,
isDown,
});
}
};
// intercept keydown event in capture phase before it reaches the editor
container.addEventListener('keydown', handleKeyDownCapture, true);
return () => {
container.removeEventListener('keydown', handleKeyDownCapture, true);
};
}, [container, handleInsert, hoverOption, selectOptionByArrow]);
const onHoverOption = useCallback(
(option: SlashCommandOption) => {
dispatch(
slashCommandActions.setHoverOption({
option: {
key: option.key,
type: option.type,
data: option.data,
},
docId,
})
);
},
[dispatch, docId]
);
const renderEmptyContent = useCallback(() => {
return (
<div className={'m-5 flex items-center justify-center text-text-caption'}>{t('findAndReplace.noResult')}</div>
@ -309,12 +244,17 @@ function BlockSlashMenu({
key={option.key}
title={option.title}
icon={option.icon}
onHover={() => {
onHoverOption(option);
onHover={(e) => {
onHoverOption(option, e.currentTarget as HTMLElement);
}}
isHovered={hoverOption?.key === option.key}
onClick={() => {
void handleInsert(option.type, option.data);
if (!option.onClick) {
void handleInsert(option.type, option.data);
return;
}
option.onClick();
}}
/>
);

View File

@ -1,9 +1,101 @@
import { useAppDispatch } from '$app/stores/store';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { slashCommandActions } from '$app_reducers/document/slice';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { useSubscribeSlashState } from '$app/components/document/_shared/SubscribeSlash.hooks';
import { useSubscribePanelSearchText } from '$app/components/document/_shared/usePanelSearchText';
import { BlockData, BlockType, SlashCommandOption, SlashCommandOptionKey } from '$app/interfaces/document';
import { selectOptionByUpDown } from '$app/utils/document/menu';
import { Keyboard } from '$app/constants/document/keyboard';
export function useKeyboardShortcut({
container,
options,
handleInsert,
hoverOption,
}: {
container: HTMLElement;
options: SlashCommandOption[];
handleInsert: (type: BlockType, data?: BlockData) => Promise<void>;
hoverOption?: SlashCommandOption;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const dispatch = useAppDispatch();
const { docId } = useSubscribeDocument();
const scrollIntoOption = useCallback(
(option: SlashCommandOption) => {
if (!ref.current) return;
const containerRect = ref.current.getBoundingClientRect();
const optionElement = document.querySelector(`#slash-item-${option.key}`);
if (!optionElement) return;
const itemRect = optionElement?.getBoundingClientRect();
if (!itemRect) return;
if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) {
optionElement.scrollIntoView({ behavior: 'smooth' });
}
},
[ref]
);
const selectOptionByArrow = useCallback(
({ isUp = false, isDown = false }: { isUp?: boolean; isDown?: boolean }) => {
if (!isUp && !isDown) return;
const optionsKeys = options.map((option) => String(option.key));
const nextKey = selectOptionByUpDown(isUp, String(hoverOption?.key), optionsKeys);
const nextOption = options.find((option) => String(option.key) === nextKey);
if (!nextOption) return;
scrollIntoOption(nextOption);
dispatch(
slashCommandActions.setHoverOption({
option: nextOption,
docId,
})
);
},
[dispatch, docId, hoverOption?.key, options, scrollIntoOption]
);
useEffect(() => {
const handleKeyDownCapture = (e: KeyboardEvent) => {
const isUp = e.key === Keyboard.keys.UP;
const isDown = e.key === Keyboard.keys.DOWN;
const isEnter = e.key === Keyboard.keys.ENTER;
// if any arrow key is pressed, prevent default behavior and stop propagation
if (isUp || isDown || isEnter) {
e.stopPropagation();
e.preventDefault();
if (isEnter) {
if (hoverOption) {
void handleInsert(hoverOption.type, hoverOption.data);
}
return;
}
selectOptionByArrow({
isUp,
isDown,
});
}
};
// intercept keydown event in capture phase before it reaches the editor
container.addEventListener('keydown', handleKeyDownCapture, true);
return () => {
container.removeEventListener('keydown', handleKeyDownCapture, true);
};
}, [container, handleInsert, hoverOption, selectOptionByArrow]);
return {
ref,
};
}
export function useBlockSlash() {
const dispatch = useAppDispatch();
@ -13,6 +105,10 @@ export function useBlockSlash() {
top: number;
left: number;
}>();
const [subMenuAnchorPosition, setSubMenuAnchorPosition] = useState<{
top: number;
left: number;
}>();
useEffect(() => {
if (blockId && visible) {
@ -41,11 +137,42 @@ export function useBlockSlash() {
}, [slashText]);
const onClose = useCallback(() => {
setSubMenuAnchorPosition(undefined);
dispatch(slashCommandActions.closeSlashCommand(docId));
}, [dispatch, docId]);
const open = Boolean(anchorPosition);
const onHoverOption = useCallback(
(option: SlashCommandOption, target: HTMLElement) => {
setSubMenuAnchorPosition(undefined);
dispatch(
slashCommandActions.setHoverOption({
option: {
key: option.key,
type: option.type,
data: option.data,
},
docId,
})
);
if (option.key === SlashCommandOptionKey.GRID_REFERENCE) {
const rect = target.getBoundingClientRect();
setSubMenuAnchorPosition({
top: rect.top,
left: rect.right,
});
}
},
[dispatch, docId]
);
const onCloseSubMenu = useCallback(() => {
setSubMenuAnchorPosition(undefined);
}, []);
return {
open,
anchorPosition,
@ -53,6 +180,9 @@ export function useBlockSlash() {
blockId,
searchText,
hoverOption,
onHoverOption,
onCloseSubMenu,
subMenuAnchorPosition,
};
}

View File

@ -1,10 +1,33 @@
import React from 'react';
import React, { useCallback } from 'react';
import Popover from '@mui/material/Popover';
import BlockSlashMenu from '$app/components/document/BlockSlash/BlockSlashMenu';
import { useBlockSlash } from '$app/components/document/BlockSlash/index.hooks';
import { SlashCommandOptionKey } from '$app/interfaces/document';
import DatabaseList from '$app/components/document/_shared/DatabaseList';
import { ViewLayoutPB } from '@/services/backend';
function BlockSlash({ container }: { container: HTMLDivElement }) {
const { blockId, open, onClose, anchorPosition, searchText, hoverOption } = useBlockSlash();
const {
blockId,
open,
onClose,
anchorPosition,
searchText,
hoverOption,
onHoverOption,
subMenuAnchorPosition,
onCloseSubMenu,
} = useBlockSlash();
const renderSubMenu = useCallback(() => {
if (!blockId) return null;
switch (hoverOption?.key) {
case SlashCommandOptionKey.GRID_REFERENCE:
return <DatabaseList onClose={onClose} blockId={blockId} layout={ViewLayoutPB.Grid} searchText={searchText} />;
default:
return null;
}
}, [blockId, hoverOption?.key, onClose, searchText]);
if (!blockId) return null;
@ -26,7 +49,29 @@ function BlockSlash({ container }: { container: HTMLDivElement }) {
id={blockId}
onClose={onClose}
searchText={searchText}
onHoverOption={onHoverOption}
/>
<Popover
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
disableAutoFocus
sx={{
pointerEvents: 'none',
}}
PaperProps={{
style: {
pointerEvents: 'auto',
},
}}
open={!!subMenuAnchorPosition}
anchorReference={'anchorPosition'}
anchorPosition={subMenuAnchorPosition}
onClose={onCloseSubMenu}
>
{renderSubMenu()}
</Popover>
</Popover>
);
}

View File

@ -0,0 +1,36 @@
import React, { useEffect, useRef, useState } from 'react';
import { BlockType, NestedBlock } from '$app/interfaces/document';
import { Database } from '$app/components/database';
import { ViewIdProvider } from '@/appflowy_app/hooks';
function GridBlock({ node }: { node: NestedBlock<BlockType.GridBlock> }) {
const viewId = node.data.viewId;
const ref = useRef<HTMLDivElement>(null);
const [selectedViewId, onChangeSelectedViewId] = useState(viewId);
useEffect(() => {
const element = ref.current;
if (!element) return;
const resizeObserver = new ResizeObserver(() => {
element.style.minHeight = `${element.clientHeight}px`;
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}, []);
return (
<div className='flex h-[400px] overflow-hidden py-3 caret-text-title' ref={ref}>
<ViewIdProvider value={viewId}>
<Database selectedViewId={selectedViewId} setSelectedViewId={onChangeSelectedViewId} />
</ViewIdProvider>
</div>
);
}
export default GridBlock;

View File

@ -19,6 +19,8 @@ import CodeBlock from '$app/components/document/CodeBlock';
import { NodeIdContext } from '$app/components/document/_shared/SubscribeNode.hooks';
import EquationBlock from '$app/components/document/EquationBlock';
import ImageBlock from '$app/components/document/ImageBlock';
import GridBlock from '$app/components/document/GridBlock';
import { useTranslation } from 'react-i18next';
import BlockDraggable from '$app/components/_shared/BlockDraggable';
import { BlockDraggableType } from '$app_reducers/block-draggable/slice';
@ -28,41 +30,31 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
const renderBlock = useCallback(() => {
switch (node.type) {
case BlockType.TextBlock: {
case BlockType.TextBlock:
return <TextBlock node={node} childIds={childIds} />;
}
case BlockType.HeadingBlock: {
case BlockType.HeadingBlock:
return <HeadingBlock node={node} />;
}
case BlockType.TodoListBlock: {
case BlockType.TodoListBlock:
return <TodoListBlock node={node} childIds={childIds} />;
}
case BlockType.QuoteBlock: {
case BlockType.QuoteBlock:
return <QuoteBlock node={node} childIds={childIds} />;
}
case BlockType.BulletedListBlock: {
case BlockType.BulletedListBlock:
return <BulletedListBlock node={node} childIds={childIds} />;
}
case BlockType.NumberedListBlock: {
case BlockType.NumberedListBlock:
return <NumberedListBlock node={node} childIds={childIds} />;
}
case BlockType.ToggleListBlock: {
case BlockType.ToggleListBlock:
return <ToggleListBlock node={node} childIds={childIds} />;
}
case BlockType.DividerBlock: {
case BlockType.DividerBlock:
return <DividerBlock />;
}
case BlockType.CalloutBlock: {
case BlockType.CalloutBlock:
return <CalloutBlock node={node} childIds={childIds} />;
}
case BlockType.CodeBlock:
return <CodeBlock node={node} />;
@ -70,6 +62,8 @@ function NodeComponent({ id, ...props }: { id: string } & React.HTMLAttributes<H
return <EquationBlock node={node} />;
case BlockType.ImageBlock:
return <ImageBlock node={node} />;
case BlockType.GridBlock:
return <GridBlock node={node} />;
default:
return <UnSupportedBlock />;
}

View File

@ -0,0 +1,26 @@
import { useAppSelector } from '$app/stores/store';
import { useEffect, useState } from 'react';
import { Page } from '$app_reducers/pages/slice';
import { ViewLayoutPB } from '@/services/backend';
export function useLoadDatabaseList({ searchText, layout }: { searchText: string; layout: ViewLayoutPB }) {
const [list, setList] = useState<Page[]>([]);
const pages = useAppSelector((state) => state.pages.pageMap);
useEffect(() => {
const list = Object.values(pages)
.map((page) => {
return page;
})
.filter((page) => {
if (page.layout !== layout) return false;
return page.name.toLowerCase().includes(searchText.toLowerCase());
});
setList(list);
}, [layout, pages, searchText]);
return {
list,
};
}

View File

@ -0,0 +1,103 @@
import React, { useMemo } from 'react';
import { ViewLayoutPB } from '@/services/backend';
import { useLoadDatabaseList } from '$app/components/document/_shared/DatabaseList/index.hooks';
import { List } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { BackupTableOutlined } from '@mui/icons-material';
import MenuItem from '@mui/material/MenuItem';
import { useAppDispatch } from '@/appflowy_app/stores/store';
import { turnToBlockThunk } from '$app_reducers/document/async-actions';
import { useSubscribeDocument } from '$app/components/document/_shared/SubscribeDoc.hooks';
import { BlockType } from '$app/interfaces/document';
import AddSvg from '$app/components/_shared/svg/AddSvg';
import Button from '@mui/material/Button';
import { PageController } from '$app/stores/effects/workspace/page/page_controller';
interface Props {
layout: ViewLayoutPB;
searchText?: string;
blockId: string;
onClose?: () => void;
}
function DatabaseList({ layout, searchText, blockId, onClose }: Props) {
const { t } = useTranslation();
const { docId } = useSubscribeDocument();
const pageController = useMemo(() => new PageController(docId), [docId]);
const dispatch = useAppDispatch();
const { controller } = useSubscribeDocument();
const { list } = useLoadDatabaseList({
searchText: searchText || '',
layout,
});
const renderEmpty = () => {
return <div className={'p-2 text-text-caption'}>No {layout === ViewLayoutPB.Grid ? 'grid' : 'list'} found</div>;
};
const handleReferenceDatabase = (viewId: string) => {
let blockType;
switch (layout) {
case ViewLayoutPB.Grid:
blockType = BlockType.GridBlock;
break;
default:
break;
}
if (blockType === undefined) return;
onClose?.();
void dispatch(
turnToBlockThunk({
id: blockId,
controller,
type: blockType,
data: {
viewId,
},
})
);
};
const handleCreateNewGrid = async () => {
const newViewId = await pageController.createPage({
layout,
name: t('editor.table'),
});
handleReferenceDatabase(newViewId);
};
return (
<div className={'max-h-[360px] w-[200px] p-3'}>
<div className={'flex items-center justify-center'}>
<Button
color='inherit'
startIcon={
<i className={'h-8 w-8'}>
<AddSvg />
</i>
}
onClick={handleCreateNewGrid}
>
{t('document.slashMenu.grid.createANewGrid')}
</Button>
</div>
{list.length === 0 ? (
renderEmpty()
) : (
<List>
{list.map((item) => (
<MenuItem onClick={() => handleReferenceDatabase(item.id)} key={item.id}>
<div className={'mr-2'}>{item.icon?.value || <BackupTableOutlined />}</div>
{item.name || t('grid.title.placeholder')}
</MenuItem>
))}
</List>
)}
</div>
);
}
export default DatabaseList;

View File

@ -1,8 +1,10 @@
import React from 'react';
import { WorkspaceItem } from '$app_reducers/workspace/slice';
import NestedViews from '$app/components/layout/WorkspaceManager/NestedPages';
import { useLoadWorkspace } from '$app/components/layout/WorkspaceManager/Workspace.hooks';
function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: boolean }) {
useLoadWorkspace(workspace);
return (
<div className={'flex h-[100%] flex-col'}>
<div

View File

@ -90,6 +90,9 @@ export const blockConfig: Record<string, BlockConfig> = {
[BlockType.DividerBlock]: {
canAddChild: false,
},
[BlockType.GridBlock]: {
canAddChild: false,
},
[BlockType.EquationBlock]: {
canAddChild: false,
defaultData: {

View File

@ -23,6 +23,7 @@ export enum BlockType {
CalloutBlock = 'callout',
DividerBlock = 'divider',
ImageBlock = 'image',
GridBlock = 'grid',
}
export interface EauqtionBlockData {
@ -83,6 +84,10 @@ export interface PageBlockData extends TextBlockData {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Data = any;
export interface ReferenceBlockData {
viewId: string;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type BlockData<Type = any> = Type extends BlockType.HeadingBlock
? HeadingBlockData
@ -106,13 +111,15 @@ export type BlockData<Type = any> = Type extends BlockType.HeadingBlock
? ImageBlockData
: Type extends BlockType.TextBlock
? TextBlockData
: Type extends BlockType.GridBlock
? ReferenceBlockData
: Data;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export interface NestedBlock<Type = any> {
id: string;
type: BlockType;
data: BlockData<Type> | Data;
data: BlockData<Type>;
parent: string | null;
children: string;
externalId?: string;
@ -159,12 +166,14 @@ export enum SlashCommandOptionKey {
HEADING_2,
HEADING_3,
IMAGE,
GRID_REFERENCE,
}
export interface SlashCommandOption {
type: BlockType;
data?: BlockData;
key: SlashCommandOptionKey;
onClick?: () => void;
}
export enum SlashCommandGroup {

View File

@ -44,52 +44,63 @@ export const turnToBlockThunk = createAsyncThunk(
delta = new Delta([{ insert: node.data.formula }]);
}
if (type === BlockType.EquationBlock) {
data.formula = deltaOperator.getDeltaText(delta);
const block = newBlock(type, parent.id, data);
const block = newBlock(type, parent.id, data);
insertActions.push(controller.getInsertAction(block, node.id));
caretId = block.id;
caretIndex = 0;
} else if (type === BlockType.DividerBlock) {
const block = newBlock(type, parent.id, data);
caretId = block.id;
insertActions.push(controller.getInsertAction(block, node.id));
const nodeId = generateId();
const actions = deltaOperator.getNewTextLineActions({
blockId: nodeId,
parentId: parent.id,
prevId: block.id || null,
delta: delta ? delta : new Delta([{ insert: '' }]),
type: BlockType.TextBlock,
data,
});
switch (type) {
case BlockType.GridBlock:
insertActions.push(controller.getInsertAction(block, node.id));
caretIndex = 0;
break;
case BlockType.EquationBlock:
data.formula = deltaOperator.getDeltaText(delta);
insertActions.push(controller.getInsertAction(block, node.id));
caretIndex = 0;
break;
case BlockType.DividerBlock: {
insertActions.push(controller.getInsertAction(block, node.id));
caretId = nodeId;
caretIndex = 0;
insertActions.push(...actions);
} else {
caretId = generateId();
const nodeId = generateId();
const actions = deltaOperator.getNewTextLineActions({
blockId: caretId,
parentId: parent.id,
prevId: node.id,
delta: delta,
type,
data,
});
caretId = nodeId;
caretIndex = 0;
insertActions.push(
...deltaOperator.getNewTextLineActions({
blockId: nodeId,
parentId: parent.id,
prevId: block.id || null,
delta: delta ? delta : new Delta([{ insert: '' }]),
type: BlockType.TextBlock,
data,
})
);
break;
}
insertActions.push(...actions);
default:
caretId = generateId();
insertActions.push(
...deltaOperator.getNewTextLineActions({
blockId: caretId,
parentId: parent.id,
prevId: node.id,
delta,
type,
data,
})
);
break;
}
if (!caretId) return;
// check if prev node is allowed to have children
const config = blockConfig[type];
// if new block is not allowed to have children, move children to parent
const newParentId = config.canAddChild ? caretId : parent.id;
const newParentId = config?.canAddChild ? caretId : parent.id;
// if move children to parent, set prev to current block, otherwise the prev is empty
const newPrev = config.canAddChild ? null : caretId;
const newPrev = config?.canAddChild ? null : caretId;
const moveChildrenActions = controller.getMoveChildrenAction(children, newParentId, newPrev);
// delete current block

View File

@ -100,11 +100,13 @@ export function getPrevNodeId(state: DocumentState, id: string) {
}
export function newBlock<Type>(type: BlockType, parentId: string, data?: BlockData<Type>): NestedBlock<Type> {
const blockData = data || ({} as BlockData<Type>);
return {
id: generateId(),
type,
parent: parentId,
children: generateId(),
data: data ? data : {},
data: blockData,
};
}

View File

@ -1,25 +1,24 @@
import { useParams } from 'react-router-dom';
import { ViewIdProvider } from '$app/hooks';
import { Database, DatabaseTitle, VerticalScrollElementProvider } from '../components/database';
import { useRef } from 'react';
import { Database, DatabaseTitle, useSelectDatabaseView } from '../components/database';
export const DatabasePage = () => {
const viewId = useParams().id;
const ref = useRef<HTMLDivElement>(null);
const { selectedViewId, onChange } = useSelectDatabaseView({
viewId,
});
if (!viewId) {
return null;
}
return (
<div className="h-full overflow-y-auto" ref={ref}>
<VerticalScrollElementProvider value={ref}>
<ViewIdProvider value={viewId}>
<DatabaseTitle />
<Database />
</ViewIdProvider>
</VerticalScrollElementProvider>
<div className='flex h-full w-full flex-col overflow-hidden px-16 caret-text-title'>
<ViewIdProvider value={viewId}>
<DatabaseTitle />
<Database selectedViewId={selectedViewId} setSelectedViewId={onChange} />
</ViewIdProvider>
</div>
);
};

View File

@ -23,6 +23,11 @@ body {
@apply bg-content-blue-100;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
div[role="textbox"] ::selection {
@apply bg-transparent;
}

View File

@ -531,7 +531,10 @@
"newRow": "New row",
"action": "Action",
"add": "Click add to below",
"drag": "Drag to move"
"drag": "Drag to move",
"dragAndClick": "Drag to move, click to open menu",
"insertRecordAbove": "Insert record above",
"insertRecordBelow": "Insert record below"
},
"selectOption": {
"create": "Create",

View File

@ -24,10 +24,8 @@ export async function {{ event_func_name }}(): Promise<Result<{{ output_deserial
if (result.code == 0) {
{%- if has_output %}
let object = {{ output_deserializer }}.deserializeBinary(result.payload);
console.log({{ event_func_name }}.name, object);
return Ok(object);
{%- else %}
console.log({{ event_func_name }}.name);
return Ok.EMPTY;
{%- endif %}
} else {