mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
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:
parent
5d4142d5b6
commit
663f9d3423
@ -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",
|
||||
|
@ -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'}
|
||||
|
@ -15,7 +15,7 @@ const languages = [
|
||||
'pt-BR',
|
||||
'pt-PT',
|
||||
'ru-RU',
|
||||
'sv',
|
||||
'sv-SE',
|
||||
'tr-TR',
|
||||
'zh-CN',
|
||||
'zh-TW',
|
||||
|
3
frontend/appflowy_tauri/src/appflowy_app/assets/up.svg
Normal file
3
frontend/appflowy_tauri/src/appflowy_app/assets/up.svg
Normal 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 |
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
@ -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}
|
||||
>
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -1,2 +1 @@
|
||||
export * from './database_view_types';
|
||||
export * as databaseViewService from './database_view_service';
|
||||
|
@ -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}` });
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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 }}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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}
|
||||
/>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -1 +1 @@
|
||||
export * from './DatabaseSettings';
|
||||
export * from './DatabaseCollection';
|
||||
|
@ -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;
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
|
@ -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:
|
||||
|
@ -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[] => {
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
||||
}
|
@ -39,6 +39,10 @@ export function useBlockSideToolbar(id: string) {
|
||||
return -6;
|
||||
}
|
||||
|
||||
if (block.type === BlockType.GridBlock) {
|
||||
return 16;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}, [docId, id]);
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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 />;
|
||||
}
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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;
|
@ -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
|
||||
|
@ -90,6 +90,9 @@ export const blockConfig: Record<string, BlockConfig> = {
|
||||
[BlockType.DividerBlock]: {
|
||||
canAddChild: false,
|
||||
},
|
||||
[BlockType.GridBlock]: {
|
||||
canAddChild: false,
|
||||
},
|
||||
[BlockType.EquationBlock]: {
|
||||
canAddChild: false,
|
||||
defaultData: {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -23,6 +23,11 @@ body {
|
||||
@apply bg-content-blue-100;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
div[role="textbox"] ::selection {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user