diff --git a/frontend/appflowy_tauri/src-tauri/tauri.conf.json b/frontend/appflowy_tauri/src-tauri/tauri.conf.json index c0da4386af..8c8e36b10a 100644 --- a/frontend/appflowy_tauri/src-tauri/tauri.conf.json +++ b/frontend/appflowy_tauri/src-tauri/tauri.conf.json @@ -83,10 +83,12 @@ { "fileDropEnabled": false, "fullscreen": false, - "height": 1200, + "height": 800, "resizable": true, "title": "AppFlowy", - "width": 1200 + "width": 1200, + "minWidth": 800, + "minHeight": 600 } ] } diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts index 8d4088ca25..48c8194d27 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.hooks.ts @@ -10,14 +10,17 @@ import { UserService } from '$app/application/user/user.service'; export function useUserSetting() { const dispatch = useAppDispatch(); const { i18n } = useTranslation(); - const { - themeMode = ThemeMode.System, - isDark = false, - theme: themeType = ThemeType.Default, - } = useAppSelector((state) => { - return state.currentUser.userSetting || {}; + const { themeMode = ThemeMode.System, theme: themeType = ThemeType.Default } = useAppSelector((state) => { + return { + themeMode: state.currentUser.userSetting.themeMode, + theme: state.currentUser.userSetting.theme, + }; }); + const isDark = + themeMode === ThemeMode.Dark || + (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); + useEffect(() => { void (async () => { const settings = await UserService.getAppearanceSetting(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx index c418791461..b9fd53130a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/AppMain.tsx @@ -7,6 +7,7 @@ import { ThemeProvider } from '@mui/material'; import { useUserSetting } from '$app/AppMain.hooks'; import TrashPage from '$app/views/TrashPage'; import DocumentPage from '$app/views/DocumentPage'; +import { Toaster } from 'react-hot-toast'; function AppMain() { const { muiTheme } = useUserSetting(); @@ -20,6 +21,7 @@ function AppMain() { } /> + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts index edb51fc97e..950f5becb3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/cell/cell_service.ts @@ -132,5 +132,9 @@ export async function updateDateCell( const result = await DatabaseEventUpdateDateCell(payload); - return result.unwrap(); + if (!result.ok) { + return Promise.reject(typeof result.val.msg === 'string' ? result.val.msg : 'Unknown error'); + } + + return result.val; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts index 559ed32b7a..74ebfb1df0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/database/database_service.ts @@ -26,6 +26,8 @@ export async function getDatabase(viewId: string) { const result = await DatabaseEventGetDatabase(payload); + if (!result.ok) return Promise.reject('Failed to get database'); + return result .map((value) => { return { diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts index 9a0cd46b2c..33cea9c45a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/filter/filter_listeners.ts @@ -23,6 +23,7 @@ const updateFiltersFromChange = (database: Database, changeset: FilterChangesetN const newFilter = pbToFilter(pb.filter); Object.assign(found, newFilter); + database.filters = [...database.filters]; } }); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts index acee8d141b..e8a638403e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/database/row/row_listeners.ts @@ -2,6 +2,7 @@ import { ReorderAllRowsPB, ReorderSingleRowPB, RowsChangePB, RowsVisibilityChang import { Database } from '../database'; import { pbToRowMeta, RowMeta } from './row_types'; import { didDeleteCells } from '$app/application/database/cell/cell_listeners'; +import { getDatabase } from '$app/application/database/database/database_service'; const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { changeset.deleted_rows.forEach((rowId) => { @@ -15,12 +16,6 @@ const deleteRowsFromChangeset = (database: Database, changeset: RowsChangePB) => }); }; -const insertRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { - changeset.inserted_rows.forEach(({ index, row_meta: rowMetaPB }) => { - database.rowMetas.splice(index, 0, pbToRowMeta(rowMetaPB)); - }); -}; - const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => { changeset.updated_rows.forEach(({ row_id: rowId, row_meta: rowMetaPB }) => { const found = database.rowMetas.find((rowMeta) => rowMeta.id === rowId); @@ -31,9 +26,15 @@ const updateRowsFromChangeset = (database: Database, changeset: RowsChangePB) => }); }; -export const didUpdateViewRows = (database: Database, changeset: RowsChangePB) => { +export const didUpdateViewRows = async (viewId: string, database: Database, changeset: RowsChangePB) => { + if (changeset.inserted_rows.length > 0) { + const { rowMetas } = await getDatabase(viewId); + + database.rowMetas = rowMetas; + return; + } + deleteRowsFromChangeset(database, changeset); - insertRowsFromChangeset(database, changeset); updateRowsFromChangeset(database, changeset); }; @@ -56,18 +57,39 @@ export const didReorderSingleRow = (database: Database, changeset: ReorderSingle } }; -export const didUpdateViewRowsVisibility = (database: Database, changeset: RowsVisibilityChangePB) => { +export const didUpdateViewRowsVisibility = async ( + viewId: string, + database: Database, + changeset: RowsVisibilityChangePB +) => { const { invisible_rows, visible_rows } = changeset; - database.rowMetas.forEach((rowMeta) => { - if (invisible_rows.includes(rowMeta.id)) { + let reFetchRows = false; + + for (const rowId of invisible_rows) { + const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === rowId); + + if (rowMeta) { rowMeta.isHidden = true; } + } - const found = visible_rows.find((visibleRow) => visibleRow.row_meta.id === rowMeta.id); + for (const insertedRow of visible_rows) { + const rowMeta = database.rowMetas.find((rowMeta) => rowMeta.id === insertedRow.row_meta.id); - if (found) { + if (rowMeta) { rowMeta.isHidden = false; + } else { + reFetchRows = true; + break; } - }); + } + + if (reFetchRows) { + const { rowMetas } = await getDatabase(viewId); + + database.rowMetas = rowMetas; + + await didUpdateViewRowsVisibility(viewId, database, changeset); + } }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts index 98b493581d..3d187336d2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/document/document.types.ts @@ -151,6 +151,7 @@ export interface EditorProps { title?: string; onTitleChange?: (title: string) => void; showTitle?: boolean; + disableFocus?: boolean; } export enum EditorNodeType { diff --git a/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts index 726bfabaec..91bb15f069 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/application/notification.ts @@ -23,6 +23,7 @@ import { RepeatedTrashPB, ChildViewUpdatePB, } from '@/services/backend'; +import { AsyncQueue } from '$app/utils/async_queue'; const Notification = { [DatabaseNotification.DidUpdateViewRowsVisibility]: RowsVisibilityChangePB, @@ -56,7 +57,9 @@ type NullableInstanceType any) | null> any ? InstanceType : void; -export type NotificationHandler = (result: NullableInstanceType) => void; +export type NotificationHandler = ( + result: NullableInstanceType +) => void | Promise; /** * Subscribes to a set of notifications. @@ -105,8 +108,7 @@ export function subscribeNotifications( }, options?: { id?: string } ): Promise<() => void> { - return listen>('af-notification', (event) => { - const subject = SubscribeObject.fromObject(event.payload); + const handler = async (subject: SubscribeObject) => { const { id, ty } = subject; if (options?.id !== undefined && id !== options.id) { @@ -127,8 +129,20 @@ export function subscribeNotifications( } else { const { payload } = subject; - pb ? callback(pb.deserialize(payload)) : callback(); + if (pb) { + await callback(pb.deserialize(payload)); + } else { + await callback(); + } } + }; + + const queue = new AsyncQueue(handler); + + return listen>('af-notification', (event) => { + const subject = SubscribeObject.fromObject(event.payload); + + queue.enqueue(subject); }); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx index b46ce37345..9a2d0dd882 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/confirm_dialog/DeleteConfirmDialog.tsx @@ -1,24 +1,28 @@ import React, { useCallback } from 'react'; import DialogContent from '@mui/material/DialogContent'; -import { Button, DialogActions, Divider } from '@mui/material'; +import { Button, DialogProps } from '@mui/material'; import Dialog from '@mui/material/Dialog'; import { useTranslation } from 'react-i18next'; import { Log } from '$app/utils/log'; -interface Props { +interface Props extends DialogProps { open: boolean; title: string; - subtitle: string; - onOk: () => Promise; + subtitle?: string; + onOk?: () => Promise; onClose: () => void; + onCancel?: () => void; + okText?: string; + cancelText?: string; + container?: HTMLElement | null; } -function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { +function DeleteConfirmDialog({ open, title, onOk, onCancel, onClose, okText, cancelText, container, ...props }: Props) { const { t } = useTranslation(); const onDone = useCallback(async () => { try { - await onOk(); + await onOk?.(); onClose(); } catch (e) { Log.error(e); @@ -27,6 +31,7 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { return ( { if (e.key === 'Escape') { @@ -44,20 +49,26 @@ function DeleteConfirmDialog({ open, title, subtitle, onOk, onClose }: Props) { onMouseDown={(e) => e.stopPropagation()} open={open} onClose={onClose} + {...props} > - -
{title}
- {subtitle &&
{subtitle}
} + + {title} +
+ + +
- - - - -
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx index 0d7c1858a3..b8dcb3f6c7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPicker.tsx @@ -7,9 +7,10 @@ import EmojiPickerCategories from './EmojiPickerCategories'; interface Props { onEmojiSelect: (emoji: string) => void; onEscape?: () => void; + defaultEmoji?: string; } -function EmojiPicker({ onEscape, ...props }: Props) { +function EmojiPicker({ defaultEmoji, onEscape, ...props }: Props) { const { skin, onSkinChange, emojiCategories, setSearchValue, searchValue, onSelect } = useLoadEmojiData(props); return ( @@ -21,7 +22,12 @@ function EmojiPicker({ onEscape, ...props }: Props) { searchValue={searchValue} onSearchChange={setSearchValue} /> - + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx index d410b6109e..eefea8db11 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/emoji_picker/EmojiPickerCategories.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { EMOJI_SIZE, EmojiCategory, @@ -14,10 +14,12 @@ function EmojiPickerCategories({ emojiCategories, onEmojiSelect, onEscape, + defaultEmoji, }: { emojiCategories: EmojiCategory[]; onEmojiSelect: (emoji: string) => void; onEscape?: () => void; + defaultEmoji?: string; }) { const scrollRef = React.useRef(null); const { t } = useTranslation(); @@ -28,6 +30,8 @@ function EmojiPickerCategories({ const rows = useMemo(() => { return getRowsWithCategories(emojiCategories, PER_ROW_EMOJI_COUNT); }, [emojiCategories]); + const mouseY = useRef(null); + const mouseX = useRef(null); const ref = React.useRef(null); @@ -75,6 +79,8 @@ function EmojiPickerCategories({ {item.emojis?.map((emoji, columnIndex) => { const isSelected = selectCell.row === index && selectCell.column === columnIndex; + const isDefaultEmoji = defaultEmoji === emoji.native; + return (
{ onEmojiSelect(emoji.native); }} - className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-active ${ - isSelected ? 'bg-fill-list-hover' : '' - }`} + onMouseMove={(e) => { + mouseY.current = e.clientY; + mouseX.current = e.clientX; + }} + onMouseEnter={(e) => { + if (mouseY.current === null || mouseY.current !== e.clientY || mouseX.current !== e.clientX) { + setSelectCell({ + row: index, + column: columnIndex, + }); + } + + mouseX.current = e.clientX; + mouseY.current = e.clientY; + }} + className={`flex cursor-pointer items-center justify-center rounded hover:bg-fill-list-hover ${ + isSelected ? 'bg-fill-list-hover' : 'hover:bg-transparent' + } ${isDefaultEmoji ? 'bg-fill-list-active' : ''}`} > {emoji.native}
@@ -98,7 +119,7 @@ function EmojiPickerCategories({ ); }, - [getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row] + [defaultEmoji, getCategoryName, onEmojiSelect, rows, selectCell.column, selectCell.row] ); const getNewColumnIndex = useCallback( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx index b7c6db9db0..9d035e57ed 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/keyboard_navigation/KeyboardNavigation.tsx @@ -48,6 +48,7 @@ export interface KeyboardNavigationProps { defaultFocusedKey?: T; onFocus?: () => void; onBlur?: () => void; + itemClassName?: string; } function KeyboardNavigation({ @@ -65,6 +66,7 @@ function KeyboardNavigation({ disableSelect = false, onBlur, onFocus, + itemClassName, }: KeyboardNavigationProps) { const { t } = useTranslation(); const ref = useRef(null); @@ -197,7 +199,7 @@ function KeyboardNavigation({ const renderOption = useCallback( (option: KeyboardNavigationOption, index: number) => { - const hasChildren = option.children && option.children.length > 0; + const hasChildren = option.children; const isFocused = focusedKey === option.key; @@ -216,6 +218,7 @@ function KeyboardNavigation({ mouseY.current = e.clientY; }} onMouseEnter={(e) => { + onFocus?.(); if (mouseY.current === null || mouseY.current !== e.clientY) { setFocusedKey(option.key); } @@ -231,7 +234,7 @@ function KeyboardNavigation({ selected={isFocused} className={`ml-0 flex w-full items-center justify-start rounded-none px-2 py-1 text-xs ${ !isFocused ? 'hover:bg-transparent' : '' - }`} + } ${itemClassName ?? ''}`} > {option.content} @@ -243,7 +246,7 @@ function KeyboardNavigation({ ); }, - [focusedKey, onConfirm] + [itemClassName, focusedKey, onConfirm, onFocus] ); useEffect(() => { @@ -290,7 +293,7 @@ function KeyboardNavigation({ {options.length > 0 ? ( options.map(renderOption) ) : ( - + {t('findAndReplace.noResult')} )} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/notify/index.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts similarity index 100% rename from frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/notify/index.ts rename to frontend/appflowy_tauri/src/appflowy_app/components/_shared/notify/index.ts diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts index 44e2df4099..bb531ba551 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/popover/Popover.hooks.ts @@ -84,7 +84,9 @@ const usePopoverAutoPosition = ({ initialPaperHeight, marginThreshold = 16, open, -}: UsePopoverAutoPositionProps): PopoverPosition => { +}: UsePopoverAutoPositionProps): PopoverPosition & { + calculateAnchorSize: () => void; +} => { const [position, setPosition] = useState({ anchorOrigin: initialAnchorOrigin, transformOrigin: initialTransformOrigin, @@ -94,24 +96,21 @@ const usePopoverAutoPosition = ({ isEntered: false, }); - const getAnchorOffset = useCallback(() => { - if (anchorPosition) { - return { - ...anchorPosition, - width: 0, - }; - } - - return anchorEl ? anchorEl.getBoundingClientRect() : undefined; - }, [anchorEl, anchorPosition]); - - useEffect(() => { - if (!open) { - return; - } - + const calculateAnchorSize = useCallback(() => { const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; + + const getAnchorOffset = () => { + if (anchorPosition) { + return { + ...anchorPosition, + width: 0, + }; + } + + return anchorEl ? anchorEl.getBoundingClientRect() : undefined; + }; + const anchorRect = getAnchorOffset(); if (!anchorRect) return; @@ -190,17 +189,24 @@ const usePopoverAutoPosition = ({ // Set new position and set isEntered to true setPosition({ ...newPosition, isEntered: true }); }, [ - anchorPosition, - open, initialAnchorOrigin, initialTransformOrigin, initialPaperWidth, initialPaperHeight, marginThreshold, - getAnchorOffset, + anchorEl, + anchorPosition, ]); - return position; + useEffect(() => { + if (!open) return; + calculateAnchorSize(); + }, [open, calculateAnchorSize]); + + return { + ...position, + calculateAnchorSize, + }; }; export default usePopoverAutoPosition; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx index 706827ba9c..ec38130c05 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/_shared/view_title/ViewIcon.tsx @@ -44,6 +44,7 @@ function ViewIcon({ icon, onUpdateIcon }: { icon?: PageIcon; onUpdateIcon: (icon onClose={() => setAnchorPosition(undefined)} > { setAnchorPosition(undefined); }} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts index 10db88ba43..ef478b62c4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.hooks.ts @@ -52,8 +52,6 @@ export const DatabaseProvider = DatabaseContext.Provider; export const useDatabase = () => useSnapshot(useContext(DatabaseContext)); -export const useContextDatabase = () => useContext(DatabaseContext); - export const useSelectorCell = (rowId: string, fieldId: string) => { const database = useContext(DatabaseContext); const cells = useSnapshot(database.cells); @@ -86,10 +84,16 @@ export const useDispatchCell = () => { }; }; -export const useTypeOptions = () => { +export const useDatabaseSorts = () => { const context = useContext(DatabaseContext); - return useSnapshot(context.typeOptions); + return useSnapshot(context.sorts); +}; + +export const useSortsCount = () => { + const { sorts } = useDatabase(); + + return sorts?.length; }; export const useFiltersCount = () => { @@ -154,8 +158,8 @@ export const useConnectDatabase = (viewId: string) => { [DatabaseNotification.DidUpdateFieldSettings]: (changeset) => { fieldListeners.didUpdateFieldSettings(database, changeset); }, - [DatabaseNotification.DidUpdateViewRows]: (changeset) => { - rowListeners.didUpdateViewRows(database, changeset); + [DatabaseNotification.DidUpdateViewRows]: async (changeset) => { + await rowListeners.didUpdateViewRows(viewId, database, changeset); }, [DatabaseNotification.DidReorderRows]: (changeset) => { rowListeners.didReorderRows(database, changeset); @@ -171,8 +175,8 @@ export const useConnectDatabase = (viewId: string) => { [DatabaseNotification.DidUpdateFilter]: (changeset) => { filterListeners.didUpdateFilter(database, changeset); }, - [DatabaseNotification.DidUpdateViewRowsVisibility]: (changeset) => { - rowListeners.didUpdateViewRowsVisibility(database, changeset); + [DatabaseNotification.DidUpdateViewRowsVisibility]: async (changeset) => { + await rowListeners.didUpdateViewRowsVisibility(viewId, database, changeset); }, }, { id: viewId } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx index 6acf59d7fe..72c995a8e8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/Database.tsx @@ -25,6 +25,7 @@ export const Database = forwardRef(({ selectedViewId, set const innerRef = useRef(); const databaseRef = (ref ?? innerRef) as React.MutableRefObject; const viewId = useViewId(); + const [settingDom, setSettingDom] = useState(null); const [page, setPage] = useState(null); const { t } = useTranslation(); @@ -161,12 +162,16 @@ export const Database = forwardRef(({ selectedViewId, set } return ( -
+
(({ selectedViewId, set {selectedViewId === view.id && ( <> - -
+ {settingDom && ( + onToggleCollection(view.id, forceOpen)} /> -
-
+ + )} + {editRecordRowId && ( { const viewId = useViewId(); + const pageName = useAppSelector((state) => state.pages.pageMap[viewId]?.name || ''); const dispatch = useAppDispatch(); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/LinearProgressWithLabel.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/LinearProgressWithLabel.tsx new file mode 100644 index 0000000000..7d4c0d1811 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/_shared/LinearProgressWithLabel.tsx @@ -0,0 +1,47 @@ +import React, { useMemo } from 'react'; + +function LinearProgressWithLabel({ + value, + count, + selectedCount, +}: { + value: number; + count: number; + selectedCount: number; +}) { + const result = useMemo(() => `${Math.round(value * 100)}%`, [value]); + + const options = useMemo(() => { + return Array.from({ length: count }, (_, i) => ({ + id: i, + checked: i < selectedCount, + })); + }, [count, selectedCount]); + + const isSplit = count < 6; + + return ( +
+
+ {options.map((option) => ( + + ))} +
+
{result}
+
+ ); +} + +export default LinearProgressWithLabel; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx index ab591855ee..5ecac431c4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/ChecklistCell.tsx @@ -1,16 +1,30 @@ import React, { useState, Suspense, useMemo } from 'react'; import { ChecklistCell as ChecklistCellType, ChecklistField } from '$app/application/database'; -import Typography from '@mui/material/Typography'; import ChecklistCellActions from '$app/components/database/components/field_types/checklist/ChecklistCellActions'; +import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; interface Props { field: ChecklistField; cell: ChecklistCellType; + placeholder?: string; } -function ChecklistCell({ cell }: Props) { - const value = cell?.data.percentage ?? 0; +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'left', +}; +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; + +function ChecklistCell({ cell, placeholder }: Props) { + const value = cell?.data.percentage ?? 0; + const options = useMemo(() => cell?.data.options ?? [], [cell?.data.options]); + const selectedOptions = useMemo(() => cell?.data.selectedOptions ?? [], [cell?.data.selectedOptions]); const [anchorEl, setAnchorEl] = useState(undefined); const open = Boolean(anchorEl); const handleClick = (e: React.MouseEvent) => { @@ -21,27 +35,32 @@ function ChecklistCell({ cell }: Props) { setAnchorEl(undefined); }; - const result = useMemo(() => `${Math.round(value * 100)}%`, [value]); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 369, + initialPaperHeight: 300, + anchorEl, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); return ( <>
- - {result} - + {options.length > 0 ? ( + + ) : ( +
{placeholder}
+ )}
{open && ( {placeholder}
; }, [cell, includeTime, isRange, placeholder]); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered, calculateAnchorSize } = + usePopoverAutoPosition({ + initialPaperWidth: 248, + initialPaperHeight: 500, + anchorEl: ref.current, + initialAnchorOrigin, + initialTransformOrigin, + open, + marginThreshold: 34, + }); + + useEffect(() => { + if (!open) return; + + const anchorEl = ref.current; + + const parent = anchorEl?.parentElement?.parentElement; + + if (!anchorEl || !parent) return; + + let timeout: NodeJS.Timeout; + const handleObserve = () => { + anchorEl.scrollIntoView({ block: 'nearest' }); + + timeout = setTimeout(() => { + calculateAnchorSize(); + }, 200); + }; + + const observer = new MutationObserver(handleObserve); + + observer.observe(parent, { + childList: true, + subtree: true, + }); + return () => { + observer.disconnect(); + clearTimeout(timeout); + }; + }, [calculateAnchorSize, open]); + return ( <> -
+
{content}
{open && ( - + )} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx index 5abe28cbfe..9506959792 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/SelectCell.tsx @@ -1,9 +1,21 @@ import { FC, useCallback, useMemo, useState, Suspense, lazy } from 'react'; -import { MenuProps, Menu } from '@mui/material'; +import { MenuProps } from '@mui/material'; import { SelectField, SelectCell as SelectCellType, SelectTypeOption } from '$app/application/database'; import { Tag } from '../field_types/select/Tag'; import { useTypeOption } from '$app/components/database'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; +import Popover from '@mui/material/Popover'; +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'left', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; const SelectCellActions = lazy( () => import('$app/components/database/components/field_types/select/select_cell_actions/SelectCellActions') ); @@ -11,14 +23,6 @@ const menuProps: Partial = { classes: { list: 'py-5', }, - anchorOrigin: { - vertical: 'bottom', - horizontal: 'left', - }, - transformOrigin: { - vertical: 'top', - horizontal: 'left', - }, }; export const SelectCell: FC<{ @@ -43,6 +47,15 @@ export const SelectCell: FC<{ [typeOption] ); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 369, + initialPaperHeight: 400, + anchorEl, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); + return (
{open ? ( - { + const isInput = (e.target as Element).closest('input'); + + if (isInput) return; + + e.preventDefault(); + e.stopPropagation(); + }} > - - + + ) : null}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx index e07e81c1af..c041cbde97 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/cell/URLCell.tsx @@ -1,12 +1,11 @@ -import React, { FormEventHandler, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { FormEventHandler, lazy, Suspense, useCallback, useMemo, useRef } from 'react'; import { useInputCell } from '$app/components/database/components/cell/Cell.hooks'; import { Field, UrlCell as URLCellType } from '$app/application/database'; import { CellText } from '$app/components/database/_shared'; +import { openUrl } from '$app/utils/open_url'; const EditTextCellInput = lazy(() => import('$app/components/database/components/field_types/text/EditTextCellInput')); -const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/; - interface Props { field: Field; cell: URLCellType; @@ -14,7 +13,6 @@ interface Props { } function UrlCell({ field, cell, placeholder }: Props) { - const [isUrl, setIsUrl] = useState(false); const cellRef = useRef(null); const { value, editing, updateCell, setEditing, setValue } = useInputCell(cell); const handleClick = useCallback(() => { @@ -33,33 +31,26 @@ function UrlCell({ field, cell, placeholder }: Props) { [setValue] ); - useEffect(() => { - if (editing) return; - const str = cell.data.content; - - if (!str) return; - const isUrl = pattern.test(str); - - setIsUrl(isUrl); - }, [cell, editing]); - const content = useMemo(() => { const str = cell.data.content; if (str) { - if (isUrl) { - return ( - - {str} - - ); - } - - return str; + return ( + { + e.stopPropagation(); + openUrl(str); + }} + target={'_blank'} + className={'cursor-pointer text-content-blue-400 underline'} + > + {str} + + ); } - return
{placeholder}
; - }, [isUrl, cell, placeholder]); + return
{placeholder}
; + }, [cell, placeholder]); return ( <> @@ -68,6 +59,7 @@ function UrlCell({ field, cell, placeholder }: Props) { width: `${field.width}px`, minHeight: 37, }} + className={'cursor-text'} ref={cellRef} onClick={handleClick} > diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx index a972b4cf28..b3c4a2c054 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseCollection.tsx @@ -8,7 +8,7 @@ interface Props { export const DatabaseCollection = ({ open }: Props) => { return ( -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx index b6672ec007..48885c7fe7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/DatabaseSettings.tsx @@ -1,5 +1,4 @@ import React, { useState } from 'react'; -import { Stack } from '@mui/material'; import { TextButton } from '$app/components/database/components/tab_bar/TextButton'; import { useTranslation } from 'react-i18next'; @@ -16,7 +15,7 @@ function DatabaseSettings(props: Props) { const [settingAnchorEl, setSettingAnchorEl] = useState(null); return ( - +
setSettingAnchorEl(e.currentTarget)}> @@ -27,7 +26,7 @@ function DatabaseSettings(props: Props) { anchorEl={settingAnchorEl} onClose={() => setSettingAnchorEl(null)} /> - +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx index a89b533bca..af2bbce218 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/Properties.tsx @@ -17,6 +17,7 @@ function Properties({ onItemClick }: PropertiesProps) { const { fields } = useDatabase(); const [state, setState] = useState(fields as FieldType[]); const viewId = useViewId(); + const [menuPropertyId, setMenuPropertyId] = useState(); useEffect(() => { setState(fields as FieldType[]); @@ -60,7 +61,12 @@ function Properties({ onItemClick }: PropertiesProps) { { + setMenuPropertyId(field.id); + }} key={field.id} >
- + { + setMenuPropertyId(undefined); + }} + menuOpened={menuPropertyId === field.id} + field={field} + />
onItemClick(field)} + onClick={(e) => { + e.stopPropagation(); + onItemClick(field); + }} className={'ml-2'} > {field.visibility !== FieldVisibility.AlwaysHidden ? : } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx index 2b740e6e0b..c6a9d244f0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/database_settings/SettingsMenu.tsx @@ -1,16 +1,18 @@ -import React, { useState } from 'react'; -import { Menu, MenuItem, MenuProps, Popover } from '@mui/material'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Menu, MenuProps, Popover } from '@mui/material'; import { useTranslation } from 'react-i18next'; import Properties from '$app/components/database/components/database_settings/Properties'; import { Field } from '$app/application/database'; import { FieldVisibility } from '@/services/backend'; import { updateFieldSetting } from '$app/application/database/field/field_service'; import { useViewId } from '$app/hooks'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; type SettingsMenuProps = MenuProps; function SettingsMenu(props: SettingsMenuProps) { const viewId = useViewId(); + const ref = useRef(null); const { t } = useTranslation(); const [propertiesAnchorElPosition, setPropertiesAnchorElPosition] = useState< | undefined @@ -36,25 +38,39 @@ function SettingsMenu(props: SettingsMenuProps) { }); }; + const options = useMemo(() => { + return [{ key: 'properties', content:
{t('grid.settings.properties')}
}]; + }, [t]); + + const onConfirm = useCallback( + (optionKey: string) => { + if (optionKey === 'properties') { + const target = ref.current?.querySelector(`[data-key=${optionKey}]`) as HTMLElement; + const rect = target.getBoundingClientRect(); + + setPropertiesAnchorElPosition({ + top: rect.top, + left: rect.left + rect.width, + }); + props.onClose?.({}, 'backdropClick'); + } + }, + [props] + ); + return ( <> - - { - const rect = event.currentTarget.getBoundingClientRect(); - - setPropertiesAnchorElPosition({ - top: rect.top, - left: rect.left + rect.width, - }); - props.onClose?.({}, 'backdropClick'); + + { + props.onClose?.({}, 'escapeKeyDown'); }} - > - {t('grid.settings.properties')} - + options={options} + /> { setPropertiesAnchorElPosition(undefined); @@ -65,6 +81,13 @@ function SettingsMenu(props: SettingsMenuProps) { vertical: 'top', horizontal: 'right', }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} > diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx index 34d05d0988..cf3b1878dd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/ExpandRecordModal.tsx @@ -25,6 +25,9 @@ function ExpandRecordModal({ open, onClose, rowId }: Props) { className: 'h-[calc(100%-144px)] w-[80%] max-w-[960px] overflow-visible', }} > + + + - - - { + onClose?.({}, 'escapeKeyDown'); + }} onClose={() => setDetailAnchorEl(null)} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx index 81fc0ed146..412c1a953e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordActions.tsx @@ -9,19 +9,22 @@ import MenuItem from '@mui/material/MenuItem'; interface Props extends MenuProps { rowId: string; + onEscape?: () => void; onClose?: () => void; } -function RecordActions({ anchorEl, open, onClose, rowId }: Props) { +function RecordActions({ anchorEl, open, onEscape, onClose, rowId }: Props) { const viewId = useViewId(); const { t } = useTranslation(); const handleDelRow = useCallback(() => { void rowService.deleteRow(viewId, rowId); - }, [viewId, rowId]); + onEscape?.(); + }, [viewId, rowId, onEscape]); const handleDuplicateRow = useCallback(() => { void rowService.duplicateRow(viewId, rowId); - }, [viewId, rowId]); + onEscape?.(); + }, [viewId, rowId, onEscape]); const menuOptions = [ { @@ -46,6 +49,7 @@ function RecordActions({ anchorEl, open, onClose, rowId }: Props) { onClick={() => { option.onClick(); onClose?.(); + onEscape?.(); }} > {option.icon} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx index ad01a335d4..28714881bd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordDocument.tsx @@ -6,7 +6,7 @@ interface Props { } function RecordDocument({ documentId }: Props) { - return ; + return ; } export default React.memo(RecordDocument); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx index f3f1820a49..d2381ec165 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/edit_record/RecordHeader.tsx @@ -28,7 +28,7 @@ function RecordHeader({ page, row }: Props) { }, []); return ( -
+
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx index fc3f72e224..762a9dbf08 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/AddNewOption.tsx @@ -1,11 +1,10 @@ import React, { useState } from 'react'; import { updateChecklistCell } from '$app/application/database/cell/cell_service'; import { useViewId } from '$app/hooks'; -import { ReactComponent as AddIcon } from '$app/assets/add.svg'; -import { IconButton } from '@mui/material'; +import { Button } from '@mui/material'; import { useTranslation } from 'react-i18next'; -function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) { +function AddNewOption({ rowId, fieldId, onClose }: { rowId: string; fieldId: string; onClose: () => void }) { const { t } = useTranslation(); const [value, setValue] = useState(''); const viewId = useViewId(); @@ -17,23 +16,35 @@ function AddNewOption({ rowId, fieldId }: { rowId: string; fieldId: string }) { }; return ( -
+
{ if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); void createOption(); + return; + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + onClose(); + return; } }} value={value} + spellCheck={false} onChange={(e) => { setValue(e.target.value); }} /> - - - +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx index 4905765481..da8d4b832e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistCellActions.tsx @@ -1,39 +1,78 @@ -import React from 'react'; +import React, { useState } from 'react'; import Popover, { PopoverProps } from '@mui/material/Popover'; -import { LinearProgressWithLabel } from '$app/components/database/components/field_types/checklist/LinearProgressWithLabel'; import { Divider } from '@mui/material'; import { ChecklistCell as ChecklistCellType } from '$app/application/database'; import ChecklistItem from '$app/components/database/components/field_types/checklist/ChecklistItem'; import AddNewOption from '$app/components/database/components/field_types/checklist/AddNewOption'; +import LinearProgressWithLabel from '$app/components/database/_shared/LinearProgressWithLabel'; function ChecklistCellActions({ cell, + maxHeight, + maxWidth, ...props }: PopoverProps & { cell: ChecklistCellType; + maxWidth?: number; + maxHeight?: number; }) { const { fieldId, rowId } = cell; - const { percentage, selectedOptions = [], options } = cell.data; + const { percentage, selectedOptions = [], options = [] } = cell.data; + + const [hoverId, setHoverId] = useState(null); return ( - - -
- {options?.map((option) => { - return ( - - ); - })} -
+ { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + > +
setHoverId(null)} + className={'flex h-full w-full flex-col overflow-hidden'} + > + {options.length > 0 && ( + <> +
+ +
+
+ {options?.map((option) => { + return ( + setHoverId(option.id)} + key={option.id} + option={option} + onClose={() => props.onClose?.({}, 'escapeKeyDown')} + checked={selectedOptions?.includes(option.id) || false} + /> + ); + })} +
- - + + + )} + + props.onClose?.({}, 'escapeKeyDown')} fieldId={fieldId} rowId={rowId} /> +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx index 9a33c8b9a3..b8bfa13f4a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/checklist/ChecklistItem.tsx @@ -1,27 +1,38 @@ -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { SelectOption } from '$app/application/database'; -import { Checkbox, IconButton } from '@mui/material'; +import { IconButton } from '@mui/material'; import { updateChecklistCell } from '$app/application/database/cell/cell_service'; import { useViewId } from '$app/hooks'; import { ReactComponent as DeleteIcon } from '$app/assets/delete.svg'; import { ReactComponent as CheckboxCheckSvg } from '$app/assets/database/checkbox-check.svg'; import { ReactComponent as CheckboxUncheckSvg } from '$app/assets/database/checkbox-uncheck.svg'; +import isHotkey from 'is-hotkey'; +import debounce from 'lodash-es/debounce'; +import { useTranslation } from 'react-i18next'; + +const DELAY_CHANGE = 200; function ChecklistItem({ checked, option, rowId, fieldId, + onClose, + isHovered, + onMouseEnter, }: { checked: boolean; option: SelectOption; rowId: string; fieldId: string; + onClose: () => void; + isHovered: boolean; + onMouseEnter: () => void; }) { - const [hover, setHover] = useState(false); + const { t } = useTranslation(); const [value, setValue] = useState(option.name); const viewId = useViewId(); - const updateText = async () => { + const updateText = useCallback(async () => { await updateChecklistCell(viewId, rowId, fieldId, { updateOptions: [ { @@ -30,49 +41,75 @@ function ChecklistItem({ }, ], }); - }; + }, [fieldId, option, rowId, value, viewId]); - const onCheckedChange = async () => { - void updateChecklistCell(viewId, rowId, fieldId, { - selectedOptionIds: [option.id], - }); - }; + const onCheckedChange = useMemo(() => { + return debounce( + () => + updateChecklistCell(viewId, rowId, fieldId, { + selectedOptionIds: [option.id], + }), + DELAY_CHANGE + ); + }, [fieldId, option.id, rowId, viewId]); - const deleteOption = async () => { + const deleteOption = useCallback(async () => { await updateChecklistCell(viewId, rowId, fieldId, { deleteOptionIds: [option.id], }); - }; + }, [fieldId, option.id, rowId, viewId]); return (
{ - setHover(true); - }} - onMouseLeave={() => { - setHover(false); - }} - className={`flex items-center justify-between gap-2 rounded p-1 text-sm ${hover ? 'bg-fill-list-hover' : ''}`} + onMouseEnter={onMouseEnter} + className={`flex items-center justify-between gap-2 rounded p-1 text-sm hover:bg-fill-list-active`} > - } - checkedIcon={} - /> +
+ {checked ? : } +
+ { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + void updateText(); + onClose(); + return; + } + + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + void updateText(); + if (isHotkey('mod+enter', e)) { + void onCheckedChange(); + } + + return; + } + }} + spellCheck={false} onChange={(e) => { setValue(e.target.value); }} /> - - - +
+ + + +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx index d2b904a4a6..d3de2b8330 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/CustomCalendar.tsx @@ -14,18 +14,25 @@ function CustomCalendar({ }: { handleChange: (params: { date?: number; endDate?: number }) => void; isRange: boolean; - timestamp: number; - endTimestamp: number; + timestamp?: number; + endTimestamp?: number; }) { - const [startDate, setStartDate] = useState(new Date(timestamp * 1000)); - const [endDate, setEndDate] = useState(new Date(endTimestamp * 1000)); + const [startDate, setStartDate] = useState(() => { + if (!timestamp) return null; + return new Date(timestamp * 1000); + }); + const [endDate, setEndDate] = useState(() => { + if (!endTimestamp) return null; + return new Date(endTimestamp * 1000); + }); useEffect(() => { - if (!isRange) return; + if (!isRange || !endTimestamp) return; setEndDate(new Date(endTimestamp * 1000)); }, [isRange, endTimestamp]); useEffect(() => { + if (!timestamp) return; setStartDate(new Date(timestamp * 1000)); }, [timestamp]); @@ -33,7 +40,7 @@ function CustomCalendar({
{ return ( @@ -56,8 +63,30 @@ function CustomCalendar({ selected={startDate} onChange={(dates) => { if (!dates) return; - if (isRange) { - const [start, end] = dates as [Date | null, Date | null]; + if (isRange && Array.isArray(dates)) { + let start = dates[0] as Date; + let end = dates[1] as Date; + + if (!end && start && startDate && endDate) { + const currentTime = start.getTime(); + const startTimeStamp = startDate.getTime(); + const endTimeStamp = endDate.getTime(); + const isGreaterThanStart = currentTime > startTimeStamp; + const isGreaterThanEnd = currentTime > endTimeStamp; + const isLessThanStart = currentTime < startTimeStamp; + const isLessThanEnd = currentTime < endTimeStamp; + const isEqualsStart = currentTime === startTimeStamp; + const isEqualsEnd = currentTime === endTimeStamp; + + if ((isGreaterThanStart && isLessThanEnd) || isGreaterThanEnd) { + end = start; + start = startDate; + } else if (isEqualsStart || isEqualsEnd) { + end = start; + } else if (isLessThanStart) { + end = endDate; + } + } setStartDate(start); setEndDate(end); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx index b6f91ca74f..fd5ba57889 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateFormat.tsx @@ -1,10 +1,13 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { MenuItem, Menu } from '@mui/material'; import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; import { DateFormatPB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; interface Props { value: DateFormatPB; @@ -15,17 +18,44 @@ function DateFormat({ value, onChange }: Props) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); - const dateFormatMap = useMemo( - () => ({ - [DateFormatPB.Friendly]: t('grid.field.dateFormatFriendly'), - [DateFormatPB.ISO]: t('grid.field.dateFormatISO'), - [DateFormatPB.US]: t('grid.field.dateFormatUS'), - [DateFormatPB.Local]: t('grid.field.dateFormatLocal'), - [DateFormatPB.DayMonthYear]: t('grid.field.dateFormatDayMonthYear'), - }), - [t] + + const renderOptionContent = useCallback( + (option: DateFormatPB, title: string) => { + return ( +
+
{title}
+ {value === option && } +
+ ); + }, + [value] ); + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: DateFormatPB.Friendly, + content: renderOptionContent(DateFormatPB.Friendly, t('grid.field.dateFormatFriendly')), + }, + { + key: DateFormatPB.ISO, + content: renderOptionContent(DateFormatPB.ISO, t('grid.field.dateFormatISO')), + }, + { + key: DateFormatPB.US, + content: renderOptionContent(DateFormatPB.US, t('grid.field.dateFormatUS')), + }, + { + key: DateFormatPB.Local, + content: renderOptionContent(DateFormatPB.Local, t('grid.field.dateFormatLocal')), + }, + { + key: DateFormatPB.DayMonthYear, + content: renderOptionContent(DateFormatPB.DayMonthYear, t('grid.field.dateFormatDayMonthYear')), + }, + ]; + }, [renderOptionContent, t]); + const handleClick = (option: DateFormatPB) => { onChange(option); setOpen(false); @@ -42,7 +72,6 @@ function DateFormat({ value, onChange }: Props) { setOpen(false)} > - {Object.keys(dateFormatMap).map((option) => { - const optionValue = Number(option) as DateFormatPB; - - return ( - handleClick(optionValue)} - > - {dateFormatMap[optionValue]} - {value === optionValue && } - - ); - })} + { + setOpen(false); + }} + disableFocus={true} + options={options} + onConfirm={handleClick} + /> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx index f0c6a84250..78e3129d4f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeCellActions.tsx @@ -13,14 +13,19 @@ import DateTimeFormatSelect from '$app/components/database/components/field_type import DateTimeSet from '$app/components/database/components/field_types/date/DateTimeSet'; import { useTypeOption } from '$app/components/database'; import { getDateFormat, getTimeFormat } from '$app/components/database/components/field_types/date/utils'; +import { notify } from '$app/components/_shared/notify'; function DateTimeCellActions({ cell, field, + maxWidth, + maxHeight, ...props }: PopoverProps & { field: DateTimeField; cell: DateTimeCell; + maxWidth?: number; + maxHeight?: number; }) { const typeOption = useTypeOption(field.id); @@ -34,10 +39,10 @@ function DateTimeCellActions({ const { includeTime } = cell.data; - const timestamp = useMemo(() => cell.data.timestamp || dayjs().unix(), [cell.data.timestamp]); - const endTimestamp = useMemo(() => cell.data.endTimestamp || dayjs().unix(), [cell.data.endTimestamp]); - const time = useMemo(() => cell.data.time || dayjs().format(timeFormat), [cell.data.time, timeFormat]); - const endTime = useMemo(() => cell.data.endTime || dayjs().format(timeFormat), [cell.data.endTime, timeFormat]); + const timestamp = useMemo(() => cell.data.timestamp || undefined, [cell.data.timestamp]); + const endTimestamp = useMemo(() => cell.data.endTimestamp || undefined, [cell.data.endTimestamp]); + const time = useMemo(() => cell.data.time || undefined, [cell.data.time]); + const endTime = useMemo(() => cell.data.endTime || undefined, [cell.data.endTime]); const viewId = useViewId(); const { t } = useTranslation(); @@ -55,7 +60,7 @@ function DateTimeCellActions({ try { const isRange = params.isRange ?? cell.data.isRange; - await updateDateCell(viewId, cell.rowId, cell.fieldId, { + const data = { date: params.date ?? timestamp, endDate: isRange ? params.endDate ?? endTimestamp : undefined, time: params.time ?? time, @@ -63,9 +68,30 @@ function DateTimeCellActions({ includeTime: params.includeTime ?? includeTime, isRange, clearFlag: params.clearFlag, - }); + }; + + // if isRange and date is greater than endDate, swap date and endDate + if ( + data.isRange && + data.date && + data.endDate && + dayjs(dayjs.unix(data.date).format('YYYY/MM/DD ') + data.time).unix() > + dayjs(dayjs.unix(data.endDate).format('YYYY/MM/DD ') + data.endTime).unix() + ) { + if (params.date || params.time) { + data.endDate = data.date; + data.endTime = data.time; + } + + if (params.endDate || params.endTime) { + data.date = data.endDate; + data.time = data.endTime; + } + } + + await updateDateCell(viewId, cell.rowId, cell.fieldId, data); } catch (e) { - // toast.error(e.message); + notify.error(String(e)); } }, [cell, endTime, endTimestamp, includeTime, time, timestamp, viewId] @@ -75,78 +101,95 @@ function DateTimeCellActions({ return ( { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} > - - - - - -
- { - void handleChange({ - isRange: val, - // reset endTime when isRange is changed - endTime: time, - endDate: timestamp, - }); - }} - checked={isRange} +
+ - { - void handleChange({ - includeTime: val, - // reset time when includeTime is changed - time: val ? dayjs().format(timeFormat) : undefined, - endTime: val && isRange ? dayjs().format(timeFormat) : undefined, - }); - }} - checked={includeTime} + + + + +
+ { + void handleChange({ + isRange: val, + // reset endTime when isRange is changed + endTime: time, + endDate: timestamp, + }); + }} + checked={isRange} + /> + { + void handleChange({ + includeTime: val, + // reset time when includeTime is changed + time: val ? dayjs().format(timeFormat) : undefined, + endTime: val && isRange ? dayjs().format(timeFormat) : undefined, + }); + }} + checked={includeTime} + /> +
+ + + + + + { + await handleChange({ + isRange: false, + includeTime: false, + }); + await handleChange({ + clearFlag: true, + }); + + props.onClose?.({}, 'backdropClick'); + }} + > + {t('grid.field.clearDate')} + +
- - - - - - { - await handleChange({ - clearFlag: true, - }); - - props.onClose?.({}, 'backdropClick'); - }} - > - {t('grid.field.clearDate')} - - ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx index 81ea28ed47..f0393139b0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeFormatSelect.tsx @@ -23,7 +23,6 @@ function DateTimeFormatSelect({ field }: Props) { { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + setOpen(false); + } + }} onClose={() => setOpen(false)} MenuListProps={{ className: 'px-2', diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx index 4114c93e4d..82080b7d25 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/DateTimeInput.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { DateField, TimeField } from '@mui/x-date-pickers-pro'; import dayjs from 'dayjs'; import { Divider } from '@mui/material'; +import debounce from 'lodash-es/debounce'; interface Props { onChange: (params: { date?: number; time?: string }) => void; @@ -23,13 +24,17 @@ const sx = { function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) { const date = useMemo(() => { - return dayjs.unix(props.date || dayjs().unix()); + return props.date ? dayjs.unix(props.date) : undefined; }, [props.date]); const time = useMemo(() => { - return dayjs(dayjs().format('YYYY/MM/DD ') + props.time); + return props.time ? dayjs(dayjs().format('YYYY/MM/DD ') + props.time) : undefined; }, [props.time]); + const debounceOnChange = useMemo(() => { + return debounce(props.onChange, 500); + }, [props.onChange]); + return (
{ if (!date) return; - props.onChange({ + debounceOnChange({ date: date.unix(), }); }} @@ -63,7 +68,7 @@ function DateTimeInput({ includeTime, dateFormat, timeFormat, ...props }: Props) }} onChange={(time) => { if (!time) return; - props.onChange({ + debounceOnChange({ time: time.format(timeFormat), }); }} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx index 8523e4ca75..e95ac20c8f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/date/TimeFormat.tsx @@ -1,9 +1,12 @@ -import React, { useMemo, useRef, useState } from 'react'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; import { TimeFormatPB } from '@/services/backend'; import { useTranslation } from 'react-i18next'; import { Menu, MenuItem } from '@mui/material'; import { ReactComponent as MoreSvg } from '$app/assets/more.svg'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; interface Props { value: TimeFormatPB; @@ -13,14 +16,32 @@ function TimeFormat({ value, onChange }: Props) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const ref = useRef(null); - const timeFormatMap = useMemo( - () => ({ - [TimeFormatPB.TwelveHour]: t('grid.field.timeFormatTwelveHour'), - [TimeFormatPB.TwentyFourHour]: t('grid.field.timeFormatTwentyFourHour'), - }), - [t] + + const renderOptionContent = useCallback( + (option: TimeFormatPB, title: string) => { + return ( +
+
{title}
+ {value === option && } +
+ ); + }, + [value] ); + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: TimeFormatPB.TwelveHour, + content: renderOptionContent(TimeFormatPB.TwelveHour, t('grid.field.timeFormatTwelveHour')), + }, + { + key: TimeFormatPB.TwentyFourHour, + content: renderOptionContent(TimeFormatPB.TwentyFourHour, t('grid.field.timeFormatTwentyFourHour')), + }, + ]; + }, [renderOptionContent, t]); + const handleClick = (option: TimeFormatPB) => { onChange(option); setOpen(false); @@ -37,7 +58,6 @@ function TimeFormat({ value, onChange }: Props) { setOpen(false)} > - {Object.keys(timeFormatMap).map((option) => { - const optionValue = Number(option) as TimeFormatPB; - - return ( - handleClick(optionValue)} - > - {timeFormatMap[optionValue]} - {value === optionValue && } - - ); - })} + { + setOpen(false); + }} + disableFocus={true} + options={options} + onConfirm={handleClick} + /> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx index a72b1a3053..d2b538a7e1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/EditNumberCellInput.tsx @@ -55,6 +55,7 @@ function EditNumberCellInput({ padding: 0, }, }} + spellCheck={false} autoFocus={true} value={value} onInput={handleInput} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx index c91717093b..eceb128804 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFieldActions.tsx @@ -22,8 +22,8 @@ function NumberFieldActions({ field }: { field: NumberField }) { return ( <> -
-
{t('grid.field.format')}
+
+
{t('grid.field.format')}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx index 23088de342..13571244b3 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatMenu.tsx @@ -1,8 +1,11 @@ -import React from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; import { NumberFormatPB } from '@/services/backend'; -import { Menu, MenuItem, MenuProps } from '@mui/material'; -import { formats } from '$app/components/database/components/field_types/number/const'; +import { Menu, MenuProps } from '@mui/material'; +import { formats, formatText } from '$app/components/database/components/field_types/number/const'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; function NumberFormatMenu({ value, @@ -12,21 +15,48 @@ function NumberFormatMenu({ value: NumberFormatPB; onChangeFormat: (value: NumberFormatPB) => void; }) { + const scrollRef = useRef(null); + const onConfirm = useCallback( + (format: NumberFormatPB) => { + onChangeFormat(format); + props.onClose?.({}, 'backdropClick'); + }, + [onChangeFormat, props] + ); + + const renderContent = useCallback( + (format: NumberFormatPB) => { + return ( + <> + {formatText(format)} + {value === format && } + + ); + }, + [value] + ); + + const options: KeyboardNavigationOption[] = useMemo( + () => + formats.map((format) => ({ + key: format.value as NumberFormatPB, + content: renderContent(format.value as NumberFormatPB), + })), + [renderContent] + ); + return ( - - {formats.map((format) => ( - { - onChangeFormat(format.value as NumberFormatPB); - props.onClose?.({}, 'backdropClick'); - }} - className={'flex justify-between text-xs font-medium'} - key={format.value} - > -
{format.key}
- {value === format.value && } -
- ))} + +
+ props.onClose?.({}, 'escapeKeyDown')} + /> +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx index 1e963e8d37..5a02c6759b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/number/NumberFormatSelect.tsx @@ -16,7 +16,7 @@ function NumberFormatSelect({ value, onChange }: { value: NumberFormatPB; onChan onClick={() => { setExpanded(!expanded); }} - className={'flex w-full justify-between'} + className={'flex w-full justify-between rounded-none'} >
{formatText(value)}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx similarity index 73% rename from frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionMenu.tsx rename to frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx index 7b31d203b4..669e402813 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/SelectOptionModifyMenu.tsx @@ -1,4 +1,4 @@ -import { FC, useState } from 'react'; +import { FC, useMemo, useRef, useState } from 'react'; import { t } from 'i18next'; import { Divider, ListSubheader, MenuItem, MenuList, MenuProps, OutlinedInput } from '@mui/material'; import { SelectOptionColorPB } from '@/services/backend'; @@ -13,6 +13,7 @@ import { } from '$app/application/database/field/select_option/select_option_service'; import { useViewId } from '$app/hooks'; import Popover from '@mui/material/Popover'; +import debounce from 'lodash-es/debounce'; interface SelectOptionMenuProps { fieldId: string; @@ -32,9 +33,10 @@ const Colors = [ SelectOptionColorPB.Blue, ]; -export const SelectOptionMenu: FC = ({ fieldId, option, MenuProps: menuProps }) => { +export const SelectOptionModifyMenu: FC = ({ fieldId, option, MenuProps: menuProps }) => { const [tagName, setTagName] = useState(option.name); const viewId = useViewId(); + const inputRef = useRef(null); const updateColor = async (color: SelectOptionColorPB) => { await insertOrUpdateSelectOption(viewId, fieldId, [ { @@ -44,15 +46,18 @@ export const SelectOptionMenu: FC = ({ fieldId, option, M ]); }; - const updateName = async () => { - if (tagName === option.name) return; - await insertOrUpdateSelectOption(viewId, fieldId, [ - { - ...option, - name: tagName, - }, - ]); - }; + const updateName = useMemo(() => { + return debounce(async (tagName) => { + if (tagName === option.name) return; + + await insertOrUpdateSelectOption(viewId, fieldId, [ + { + ...option, + name: tagName, + }, + ]); + }, 500); + }, [option, viewId, fieldId]); const onClose = () => { menuProps.onClose?.({}, 'backdropClick'); @@ -79,18 +84,28 @@ export const SelectOptionMenu: FC = ({ fieldId, option, M }} {...menuProps} onClose={onClose} - disableRestoreFocus={true} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + e.preventDefault(); + e.stopPropagation(); + }} > { setTagName(e.target.value); + void updateName(e.target.value); }} - onBlur={updateName} onKeyDown={(e) => { - if (e.key === 'Enter') { - void updateName(); + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + void updateName(tagName); + onClose(); } }} autoFocus={true} @@ -114,7 +129,12 @@ export const SelectOptionMenu: FC = ({ fieldId, option, M {Colors.map((color) => ( { + onMouseDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onClick={(e) => { + e.preventDefault(); void updateColor(color); }} key={color} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/CreateOption.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/CreateOption.tsx index 03ca280599..bd0d037922 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/CreateOption.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/CreateOption.tsx @@ -9,7 +9,7 @@ export interface CreateOptionProps { export const CreateOption: FC = ({ label, onClick }) => { return ( - + ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx index 2ac5f524ee..5d351db617 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SearchInput.tsx @@ -1,15 +1,17 @@ import React, { FormEvent, useCallback } from 'react'; -import { ListSubheader, OutlinedInput } from '@mui/material'; +import { OutlinedInput } from '@mui/material'; import { t } from 'i18next'; function SearchInput({ setNewOptionName, newOptionName, onEnter, + onEscape, }: { newOptionName: string; setNewOptionName: (value: string) => void; onEnter: () => void; + onEscape?: () => void; }) { const handleInput = useCallback( (event: FormEvent) => { @@ -21,20 +23,26 @@ function SearchInput({ ); return ( - - { - if (e.key === 'Enter') { - onEnter(); - } - }} - placeholder={t('grid.selectOption.searchOrCreateOption')} - /> - + { + if (e.key === 'Enter') { + onEnter(); + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + onEscape?.(); + } + }} + placeholder={t('grid.selectOption.searchOrCreateOption')} + /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx index fd658fbc5d..8bc77470cc 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectCellActions.tsx @@ -17,10 +17,12 @@ function SelectCellActions({ field, cell, onUpdated, + onClose, }: { field: SelectField; cell: SelectCellType; onUpdated?: () => void; + onClose?: () => void; }) { const rowId = cell?.rowId; const viewId = useViewId(); @@ -117,29 +119,37 @@ function SelectCellActions({ }, [field.type, filteredOptions, handleNewTagClick, shouldCreateOption, updateCell]); return ( -
- +
+ +
{shouldCreateOption ? t('grid.selectOption.createNew') : t('grid.selectOption.orSelectOne')}
- {shouldCreateOption ? ( - - ) : ( -
- {filteredOptions.map((option) => ( - - { - handleClickOption(option.id); - }} - isSelected={selectedOptionIds?.includes(option.id)} - fieldId={cell?.fieldId || ''} - option={option} - /> - - ))} -
- )} +
+ {shouldCreateOption ? ( + + ) : ( +
+ {filteredOptions.map((option) => ( + + { + handleClickOption(option.id); + }} + isSelected={selectedOptionIds?.includes(option.id)} + fieldId={cell?.fieldId || ''} + option={option} + /> + + ))} +
+ )} +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx index 508d1e4aaa..54635c9b90 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_cell_actions/SelectOptionItem.tsx @@ -2,7 +2,7 @@ import { FC, MouseEventHandler, useCallback, useRef, useState } from 'react'; import { IconButton } from '@mui/material'; import { ReactComponent as DetailsSvg } from '$app/assets/details.svg'; import { SelectOption } from '$app/application/database'; -import { SelectOptionMenu } from '../SelectOptionMenu'; +import { SelectOptionModifyMenu } from '../SelectOptionModifyMenu'; import { Tag } from '../Tag'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; @@ -42,7 +42,7 @@ export const SelectOptionItem: FC = ({ onClick, isSelecte )}
{open && ( - { + return options.some((option) => option.name === newOptionName); + }, [options, newOptionName]); + const createOption = async () => { + if (!newOptionName) return; + if (isOptionExist) { + notify.error(t('grid.field.optionAlreadyExist')); + return; + } + const option = await createSelectOption(viewId, fieldId, newOptionName); if (!option) return; @@ -31,13 +43,23 @@ function AddAnOption({ fieldId }: { fieldId: string }) { { setNewOptionName(e.target.value); }} value={newOptionName} onKeyDown={(e) => { if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); void createOption(); + return; + } + + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + exitEdit(); } }} className={'mx-2 mb-1'} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx index f0190b5c92..ad363d4a1d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/field_types/select/select_field_actions/Option.tsx @@ -3,7 +3,7 @@ import { ReactComponent as MoreIcon } from '$app/assets/more.svg'; import { SelectOption } from '$app/application/database'; // import { ReactComponent as DragIcon } from '$app/assets/drag.svg'; -import { SelectOptionMenu } from '$app/components/database/components/field_types/select/SelectOptionMenu'; +import { SelectOptionModifyMenu } from '$app/components/database/components/field_types/select/SelectOptionModifyMenu'; import Button from '@mui/material/Button'; import { SelectOptionColorMap } from '$app/components/database/components/field_types/select/constants'; @@ -26,7 +26,7 @@ function Option({ option, fieldId }: { option: SelectOption; fieldId: string })
{option.name}
- +
{options.map((option) => { return
+ + + +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx index 895f3cb39c..8bd5f1688f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filter.tsx @@ -32,6 +32,7 @@ interface FilterComponentProps { filter: FilterType; field: FieldData; onChange: (data: UndeterminedFilter['data']) => void; + onClose?: () => void; } type FilterComponent = FC; @@ -110,16 +111,15 @@ function Filter({ filter, field }: Props) { clickable variant='outlined' label={ -
+
- +
} onClick={handleClick} /> {condition !== undefined && open && ( { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + handleClose(); + } + }} >
- {Component && } + {Component && }
)} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx index 090102f34b..ebc9e8982c 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterActions.tsx @@ -1,17 +1,22 @@ -import React, { useState } from 'react'; -import { IconButton, Menu, MenuItem } from '@mui/material'; +import React, { useMemo, useState } from 'react'; +import { IconButton, Menu } from '@mui/material'; import { ReactComponent as MoreSvg } from '$app/assets/details.svg'; import { Filter } from '$app/application/database'; import { useTranslation } from 'react-i18next'; import { deleteFilter } from '$app/application/database/filter/filter_service'; import { useViewId } from '$app/hooks'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; function FilterActions({ filter }: { filter: Filter }) { const viewId = useViewId(); const { t } = useTranslation(); + const [disableSelect, setDisableSelect] = useState(true); const [anchorEl, setAnchorEl] = useState(null); const open = Boolean(anchorEl); const onClose = () => { + setDisableSelect(true); setAnchorEl(null); }; @@ -21,8 +26,20 @@ function FilterActions({ filter }: { filter: Filter }) { } catch (e) { // toast.error(e.message); } + + setDisableSelect(true); }; + const options: KeyboardNavigationOption[] = useMemo( + () => [ + { + key: 'delete', + content: t('grid.settings.deleteFilter'), + }, + ], + [t] + ); + return ( <> - - {t('grid.settings.deleteFilter')} - + {open && ( + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }} + keepMounted={false} + open={open} + anchorEl={anchorEl} + onClose={onClose} + > + { + if (e.key === 'ArrowDown') { + setDisableSelect(false); + } + }} + disableSelect={disableSelect} + options={options} + onConfirm={onDelete} + onEscape={onClose} + /> + + )} ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx index ca5731222f..aaff29287b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterConditionSelect.tsx @@ -183,14 +183,12 @@ function FilterConditionSelect({ }, [fieldType, t]); return ( -
+
{name}
{ - const value = Number(e.target.value); - - onChange(value); + onChange(e); }} value={condition} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx index 1b6d87296b..e161badbf8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/FilterFieldsMenu.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, useCallback } from 'react'; +import React, { useCallback } from 'react'; import { MenuProps } from '@mui/material'; import PropertiesList from '$app/components/database/components/property/PropertiesList'; import { Field } from '$app/application/database'; @@ -18,7 +18,7 @@ function FilterFieldsMenu({ const { t } = useTranslation(); const addFilter = useCallback( - async (event: MouseEvent, field: Field) => { + async (field: Field) => { const filterData = getDefaultFilter(field.type); await insertFilter({ @@ -34,8 +34,24 @@ function FilterFieldsMenu({ ); return ( - - + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + {...props} + > + { + props.onClose?.({}, 'escapeKeyDown'); + }} + showSearch + searchPlaceholder={t('grid.settings.filterBy')} + onItemClick={addFilter} + /> ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx index 2ded60dbb4..ce4874b814 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/Filters.tsx @@ -29,9 +29,9 @@ function Filters() { }; return ( -
+
{options.map(({ filter, field }) => (field ? : null))} - void; + onClose?: () => void; } -function SelectFilter({ filter, field, onChange }: Props) { +function SelectFilter({ onClose, filter, field, onChange }: Props) { + const scrollRef = useRef(null); const condition = filter.data.condition; const typeOption = useTypeOption(field.id); - const options = useMemo(() => typeOption.options ?? [], [typeOption]); + const options: KeyboardNavigationOption[] = useMemo(() => { + return ( + typeOption?.options?.map((option) => { + return { + key: option.id, + content: ( +
+ + {filter.data.optionIds?.includes(option.id) && } +
+ ), + }; + }) ?? [] + ); + }, [filter.data.optionIds, typeOption?.options]); const showOptions = options.length > 0 && @@ -65,22 +83,9 @@ function SelectFilter({ filter, field, onChange }: Props) { if (!showOptions) return null; return ( - - {options?.map((option) => { - const isSelected = filter.data.optionIds?.includes(option.id); - - return ( - handleSelectOption(option.id)} - key={option.id} - > - - {isSelected && } - - ); - })} - +
+ +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx index ccb3e6dde5..0c7eab6e05 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/filter/text_filter/TextFilter.tsx @@ -1,13 +1,17 @@ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { TextFilter as TextFilterType, TextFilterData } from '$app/application/database'; import { TextField } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { TextFilterConditionPB } from '@/services/backend'; +import debounce from 'lodash-es/debounce'; interface Props { filter: TextFilterType; onChange: (filterData: TextFilterData) => void; } + +const DELAY = 500; + function TextFilter({ filter, onChange }: Props) { const { t } = useTranslation(); const [content, setContext] = useState(filter.data.content); @@ -15,21 +19,29 @@ function TextFilter({ filter, onChange }: Props) { const showField = condition !== TextFilterConditionPB.TextIsEmpty && condition !== TextFilterConditionPB.TextIsNotEmpty; + const onConditionChange = useMemo(() => { + return debounce((content: string) => { + onChange({ + content, + condition, + }); + }, DELAY); + }, [condition, onChange]); + if (!showField) return null; return ( { setContext(e.target.value); - }} - onBlur={() => { - onChange({ - content, - condition, - }); + onConditionChange(e.target.value ?? ''); }} /> ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx index 6ff76392b3..bb71befa8d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/NewProperty.tsx @@ -27,7 +27,12 @@ function NewProperty({ onInserted }: NewPropertyProps) { }, [onInserted, viewId]); return ( - ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx index e47e7200f8..a9865c467f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertiesList.tsx @@ -1,16 +1,18 @@ -import React, { useCallback, useMemo, useState } from 'react'; -import { OutlinedInput, MenuItem, MenuList } from '@mui/material'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { OutlinedInput } from '@mui/material'; import { Property } from '$app/components/database/components/property/Property'; import { Field as FieldType } from '$app/application/database'; import { useDatabase } from '$app/components/database'; +import KeyboardNavigation from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; interface FieldListProps { searchPlaceholder?: string; showSearch?: boolean; - onItemClick?: (event: React.MouseEvent, field: FieldType) => void; + onItemClick?: (field: FieldType) => void; + onClose?: () => void; } -function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldListProps) { +function PropertiesList({ onClose, showSearch, onItemClick, searchPlaceholder }: FieldListProps) { const { fields } = useDatabase(); const [fieldsResult, setFieldsResult] = useState(fields as FieldType[]); @@ -24,38 +26,65 @@ function PropertiesList({ showSearch, onItemClick, searchPlaceholder }: FieldLis [fields] ); + const inputRef = useRef(null); + const searchInput = useMemo(() => { return showSearch ? (
- +
) : null; }, [onInputChange, searchPlaceholder, showSearch]); - const emptyList = useMemo(() => { - return fieldsResult.length === 0 ? ( -
No fields found
- ) : null; + const scrollRef = useRef(null); + + const options = useMemo(() => { + return fieldsResult.map((field) => { + return { + key: field.id, + content: ( +
+ +
+ ), + }; + }); }, [fieldsResult]); + const onConfirm = useCallback( + (key: string) => { + const field = fields.find((field) => field.id === key); + + onItemClick?.(field as FieldType); + }, + [fields, onItemClick] + ); + return (
{searchInput} - {emptyList} - - {fieldsResult.map((field) => ( - { - onItemClick?.(event, field); - }} - > - - - ))} - +
+ +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx index e3a93cd28f..b2cd83a9a1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/Property.tsx @@ -2,6 +2,8 @@ import { FC, useEffect, useRef, useState } from 'react'; import { Field as FieldType } from '$app/application/database'; import { ProppertyTypeSvg } from './property_type/ProppertyTypeSvg'; import { PropertyMenu } from '$app/components/database/components/property/PropertyMenu'; +import usePopoverAutoPosition from '$app/components/_shared/popover/Popover.hooks'; +import { PopoverOrigin } from '@mui/material/Popover/Popover'; export interface FieldProps { field: FieldType; @@ -10,17 +12,28 @@ export interface FieldProps { onCloseMenu?: (id: string) => void; } +const initialAnchorOrigin: PopoverOrigin = { + vertical: 'bottom', + horizontal: 'left', +}; + +const initialTransformOrigin: PopoverOrigin = { + vertical: 'top', + horizontal: 'left', +}; + export const Property: FC = ({ field, onCloseMenu, menuOpened }) => { const ref = useRef(null); const [anchorPosition, setAnchorPosition] = useState< | { top: number; left: number; + height: number; } | undefined >(undefined); - const open = Boolean(anchorPosition) && menuOpened; + const open = Boolean(anchorPosition && menuOpened); useEffect(() => { if (menuOpened) { @@ -28,8 +41,9 @@ export const Property: FC = ({ field, onCloseMenu, menuOpened }) => if (rect) { setAnchorPosition({ - top: rect.top + rect.height, + top: rect.top + 28, left: rect.left, + height: rect.height, }); return; } @@ -38,6 +52,15 @@ export const Property: FC = ({ field, onCloseMenu, menuOpened }) => setAnchorPosition(undefined); }, [menuOpened]); + const { paperHeight, paperWidth, transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ + initialPaperWidth: 369, + initialPaperHeight: 400, + anchorPosition, + initialAnchorOrigin, + initialTransformOrigin, + open, + }); + return ( <>
@@ -48,10 +71,20 @@ export const Property: FC = ({ field, onCloseMenu, menuOpened }) => {open && ( { onCloseMenu?.(field.id); }} + transformOrigin={transformOrigin} + anchorOrigin={anchorOrigin} + PaperProps={{ + style: { + maxHeight: paperHeight, + maxWidth: paperWidth, + height: 'auto', + }, + className: 'flex h-full flex-col overflow-hidden', + }} anchorPosition={anchorPosition} anchorReference={'anchorPosition'} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx index ce7c57ec00..b319940996 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyActions.tsx @@ -1,7 +1,9 @@ -import React, { useMemo, useState } from 'react'; +import React, { RefObject, useCallback, useMemo, useState } from 'react'; import { ReactComponent as EditSvg } from '$app/assets/edit.svg'; import { ReactComponent as HideSvg } from '$app/assets/hide.svg'; +import { ReactComponent as ShowSvg } from '$app/assets/eye_open.svg'; + import { ReactComponent as CopySvg } from '$app/assets/copy.svg'; import { ReactComponent as DeleteSvg } from '$app/assets/delete.svg'; import { ReactComponent as LeftSvg } from '$app/assets/left.svg'; @@ -9,13 +11,17 @@ import { ReactComponent as RightSvg } from '$app/assets/right.svg'; import { useViewId } from '$app/hooks'; import { fieldService } from '$app/application/database'; import { OrderObjectPositionTypePB, FieldVisibility } from '@/services/backend'; -import { MenuItem } from '@mui/material'; import DeleteConfirmDialog from '$app/components/_shared/confirm_dialog/DeleteConfirmDialog'; import { useTranslation } from 'react-i18next'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; export enum FieldAction { EditProperty, Hide, + Show, Duplicate, Delete, InsertLeft, @@ -25,6 +31,7 @@ export enum FieldAction { const FieldActionSvgMap = { [FieldAction.EditProperty]: EditSvg, [FieldAction.Hide]: HideSvg, + [FieldAction.Show]: ShowSvg, [FieldAction.Duplicate]: CopySvg, [FieldAction.Delete]: DeleteSvg, [FieldAction.InsertLeft]: LeftSvg, @@ -47,18 +54,28 @@ interface PropertyActionsProps { fieldId: string; actions?: FieldAction[]; isPrimary?: boolean; + inputRef?: RefObject; + onClose?: () => void; onMenuItemClick?: (action: FieldAction, newFieldId?: string) => void; } -function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaultActions }: PropertyActionsProps) { +function PropertyActions({ + onClose, + inputRef, + fieldId, + onMenuItemClick, + isPrimary, + actions = defaultActions, +}: PropertyActionsProps) { const viewId = useViewId(); const { t } = useTranslation(); const [openConfirm, setOpenConfirm] = useState(false); - + const [focusMenu, setFocusMenu] = useState(false); const menuTextMap = useMemo( () => ({ [FieldAction.EditProperty]: t('grid.field.editProperty'), [FieldAction.Hide]: t('grid.field.hide'), + [FieldAction.Show]: t('grid.field.show'), [FieldAction.Duplicate]: t('grid.field.duplicate'), [FieldAction.Delete]: t('grid.field.delete'), [FieldAction.InsertLeft]: t('grid.field.insertLeft'), @@ -101,6 +118,11 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul visibility: FieldVisibility.AlwaysHidden, }); break; + case FieldAction.Show: + await fieldService.updateFieldSetting(viewId, fieldId, { + visibility: FieldVisibility.AlwaysShown, + }); + break; case FieldAction.Duplicate: await fieldService.duplicateField(viewId, fieldId); break; @@ -112,19 +134,124 @@ function PropertyActions({ fieldId, onMenuItemClick, isPrimary, actions = defaul onMenuItemClick?.(action); }; + const renderActionContent = useCallback((item: { text: string; Icon: React.FC> }) => { + const { Icon, text } = item; + + return ( +
+ +
{text}
+
+ ); + }, []); + + const options: KeyboardNavigationOption[] = useMemo( + () => + [ + { + key: FieldAction.EditProperty, + content: renderActionContent({ + text: menuTextMap[FieldAction.EditProperty], + Icon: FieldActionSvgMap[FieldAction.EditProperty], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.EditProperty), + }, + { + key: FieldAction.InsertLeft, + content: renderActionContent({ + text: menuTextMap[FieldAction.InsertLeft], + Icon: FieldActionSvgMap[FieldAction.InsertLeft], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertLeft), + }, + { + key: FieldAction.InsertRight, + content: renderActionContent({ + text: menuTextMap[FieldAction.InsertRight], + Icon: FieldActionSvgMap[FieldAction.InsertRight], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.InsertRight), + }, + { + key: FieldAction.Hide, + content: renderActionContent({ + text: menuTextMap[FieldAction.Hide], + Icon: FieldActionSvgMap[FieldAction.Hide], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Hide), + }, + { + key: FieldAction.Show, + content: renderActionContent({ + text: menuTextMap[FieldAction.Show], + Icon: FieldActionSvgMap[FieldAction.Show], + }), + }, + { + key: FieldAction.Duplicate, + content: renderActionContent({ + text: menuTextMap[FieldAction.Duplicate], + Icon: FieldActionSvgMap[FieldAction.Duplicate], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Duplicate), + }, + { + key: FieldAction.Delete, + content: renderActionContent({ + text: menuTextMap[FieldAction.Delete], + Icon: FieldActionSvgMap[FieldAction.Delete], + }), + disabled: isPrimary && primaryPreventDefaultActions.includes(FieldAction.Delete), + }, + ].filter((option) => actions.includes(option.key)), + [renderActionContent, menuTextMap, isPrimary, actions] + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + const isTab = e.key === 'Tab'; + + if (!focusMenu && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + e.stopPropagation(); + notify.clear(); + notify.info(`Press Tab to focus on the menu`); + return; + } + + if (isTab) { + e.preventDefault(); + e.stopPropagation(); + if (focusMenu) { + inputRef?.current?.focus(); + setFocusMenu(false); + } else { + inputRef?.current?.blur(); + setFocusMenu(true); + } + + return; + } + }, + [focusMenu, inputRef] + ); + return ( <> - {actions.map((action) => { - const ActionSvg = FieldActionSvgMap[action]; - const disabled = isPrimary && primaryPreventDefaultActions.includes(action); - - return ( - handleMenuItemClick(action)} key={action} dense> - - {menuTextMap[action]} - - ); - })} + { + setFocusMenu(true); + }} + onBlur={() => { + setFocusMenu(false); + }} + onKeyDown={handleKeyDown} + onConfirm={handleMenuItemClick} + /> { setOpenConfirm(false); - onMenuItemClick?.(FieldAction.Delete); + onClose?.(); }} /> diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx index abd26a62ea..55b314b821 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyMenu.tsx @@ -1,25 +1,35 @@ -import { Divider, MenuList } from '@mui/material'; -import { FC, useCallback } from 'react'; +import { Divider } from '@mui/material'; +import { FC, useCallback, useMemo, useRef } from 'react'; import { useViewId } from '$app/hooks'; import { Field, fieldService } from '$app/application/database'; import PropertyTypeMenuExtension from '$app/components/database/components/property/property_type/PropertyTypeMenuExtension'; import PropertyTypeSelect from '$app/components/database/components/property/property_type/PropertyTypeSelect'; -import { FieldType } from '@/services/backend'; +import { FieldType, FieldVisibility } from '@/services/backend'; import { Log } from '$app/utils/log'; import Popover, { PopoverProps } from '@mui/material/Popover'; import PropertyNameInput from '$app/components/database/components/property/PropertyNameInput'; import PropertyActions, { FieldAction } from '$app/components/database/components/property/PropertyActions'; -const actions = [FieldAction.Hide, FieldAction.Duplicate, FieldAction.Delete]; - export interface GridFieldMenuProps extends PopoverProps { field: Field; } export const PropertyMenu: FC = ({ field, ...props }) => { const viewId = useViewId(); + const inputRef = useRef(null); const isPrimary = field.isPrimary; + const actions = useMemo(() => { + const keys = [FieldAction.Duplicate, FieldAction.Delete]; + + if (field.visibility === FieldVisibility.AlwaysHidden) { + keys.unshift(FieldAction.Show); + } else { + keys.unshift(FieldAction.Hide); + } + + return keys; + }, [field.visibility]); const onUpdateFieldType = useCallback( async (type: FieldType) => { @@ -35,39 +45,45 @@ export const PropertyMenu: FC = ({ field, ...props }) => { return ( e.stopPropagation()} keepMounted={false} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.stopPropagation(); + e.preventDefault(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + + e.stopPropagation(); + e.preventDefault(); + }} {...props} > - - -
- {!isPrimary && ( - <> - - - - )} - - { - props.onClose?.({}, 'backdropClick'); - }} - fieldId={field.id} - /> -
-
+ +
+ {!isPrimary && ( +
+ + +
+ )} + + props.onClose?.({}, 'backdropClick')} + isPrimary={isPrimary} + actions={actions} + onMenuItemClick={() => { + props.onClose?.({}, 'backdropClick'); + }} + fieldId={field.id} + /> +
); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx index 31e7a26456..4e20531335 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertyNameInput.tsx @@ -1,47 +1,49 @@ -import React, { ChangeEventHandler, useCallback, useState } from 'react'; +import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react'; import { useViewId } from '$app/hooks'; import { fieldService } from '$app/application/database'; import { Log } from '$app/utils/log'; import TextField from '@mui/material/TextField'; +import debounce from 'lodash-es/debounce'; -function PropertyNameInput({ id, name }: { id: string; name: string }) { +const PropertyNameInput = React.forwardRef(({ id, name }, ref) => { const viewId = useViewId(); const [inputtingName, setInputtingName] = useState(name); - const handleInput = useCallback>((e) => { - setInputtingName(e.target.value); - }, []); - - const handleSubmit = useCallback(async () => { - if (inputtingName !== name) { - try { - await fieldService.updateField(viewId, id, { - name: inputtingName, - }); - } catch (e) { - // TODO - Log.error(`change field ${id} name from '${name}' to ${inputtingName} fail`, e); + const handleSubmit = useCallback( + async (newName: string) => { + if (newName !== name) { + try { + await fieldService.updateField(viewId, id, { + name: newName, + }); + } catch (e) { + // TODO + Log.error(`change field ${id} name from '${name}' to ${newName} fail`, e); + } } - } - }, [viewId, id, name, inputtingName]); + }, + [viewId, id, name] + ); + + const debouncedHandleSubmit = useMemo(() => debounce(handleSubmit, 500), [handleSubmit]); + const handleInput = useCallback>( + (e) => { + setInputtingName(e.target.value); + void debouncedHandleSubmit(e.target.value); + }, + [debouncedHandleSubmit] + ); return ( { - if (e.key === 'Enter') { - e.preventDefault(); - e.stopPropagation(); - void handleSubmit(); - } - }} value={inputtingName} onChange={handleInput} - onBlur={handleSubmit} /> ); -} +}); export default PropertyNameInput; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx index 5c4522904a..0741bbc05b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/PropertySelect.tsx @@ -1,44 +1,84 @@ -import { MenuItem, Select, SelectChangeEvent, SelectProps } from '@mui/material'; -import { FC, useCallback } from 'react'; +import { FC, useCallback, useMemo, useRef, useState } from 'react'; import { Field as FieldType } from '$app/application/database'; import { useDatabase } from '../../Database.hooks'; import { Property } from './Property'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { ReactComponent as DropDownSvg } from '$app/assets/more.svg'; +import Popover from '@mui/material/Popover'; -export interface FieldSelectProps extends Omit { +export interface FieldSelectProps { onChange?: (field: FieldType | undefined) => void; + value?: string; } -export const PropertySelect: FC = ({ onChange, ...props }) => { +export const PropertySelect: FC = ({ value, onChange }) => { const { fields } = useDatabase(); - const handleChange = useCallback( - (event: SelectChangeEvent) => { - const selectedId = event.target.value; + const scrollRef = useRef(null); + const ref = useRef(null); + const [open, setOpen] = useState(false); + const handleClose = () => { + setOpen(false); + }; - onChange?.(fields.find((field) => field.id === selectedId)); + const options: KeyboardNavigationOption[] = useMemo( + () => + fields.map((field) => { + return { + key: field.id, + content: , + }; + }), + [fields] + ); + + const onConfirm = useCallback( + (optionKey: string) => { + onChange?.(fields.find((field) => field.id === optionKey)); }, [onChange, fields] ); + const selectedField = useMemo(() => fields.find((field) => field.id === value), [fields, value]); + return ( - + <> +
{ + setOpen(true); + }} + > +
{selectedField ? : null}
+ +
+ {open && ( + +
+ +
+
+ )} + ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx index f6bb705fc4..890a4e2a33 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeMenu.tsx @@ -1,29 +1,13 @@ -import { Divider, Menu, MenuItem, MenuProps } from '@mui/material'; -import { FC, useMemo } from 'react'; +import { Menu, MenuProps } from '@mui/material'; +import { FC, useCallback, useMemo } from 'react'; import { FieldType } from '@/services/backend'; import { PropertyTypeText, ProppertyTypeSvg } from '$app/components/database/components/property'; import { Field } from '$app/application/database'; import { ReactComponent as SelectCheckSvg } from '$app/assets/select-check.svg'; - -const FieldTypeGroup = [ - { - name: 'Basic', - types: [ - FieldType.RichText, - FieldType.Number, - FieldType.SingleSelect, - FieldType.MultiSelect, - FieldType.DateTime, - FieldType.Checkbox, - FieldType.Checklist, - FieldType.URL, - ], - }, - { - name: 'Advanced', - types: [FieldType.LastEditedTime, FieldType.CreatedTime], - }, -]; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import Typography from '@mui/material/Typography'; export const PropertyTypeMenu: FC< MenuProps & { @@ -39,23 +23,99 @@ export const PropertyTypeMenu: FC< [props.PopoverClasses] ); + const renderGroupContent = useCallback((title: string) => { + return ( + + {title} + + ); + }, []); + + const renderContent = useCallback( + (type: FieldType) => { + return ( + <> + + + + + {type === field.type && } + + ); + }, + [field.type] + ); + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: 100, + content: renderGroupContent('Basic'), + children: [ + { + key: FieldType.RichText, + content: renderContent(FieldType.RichText), + }, + { + key: FieldType.Number, + content: renderContent(FieldType.Number), + }, + { + key: FieldType.SingleSelect, + content: renderContent(FieldType.SingleSelect), + }, + { + key: FieldType.MultiSelect, + content: renderContent(FieldType.MultiSelect), + }, + { + key: FieldType.DateTime, + content: renderContent(FieldType.DateTime), + }, + { + key: FieldType.Checkbox, + content: renderContent(FieldType.Checkbox), + }, + { + key: FieldType.Checklist, + content: renderContent(FieldType.Checklist), + }, + { + key: FieldType.URL, + content: renderContent(FieldType.URL), + }, + ], + }, + { + key: 101, + content:
, + children: [], + }, + { + key: 102, + content: renderGroupContent('Advanced'), + children: [ + { + key: FieldType.LastEditedTime, + content: renderContent(FieldType.LastEditedTime), + }, + { + key: FieldType.CreatedTime, + content: renderContent(FieldType.CreatedTime), + }, + ], + }, + ]; + }, [renderContent, renderGroupContent]); + return ( - - {FieldTypeGroup.map((group, index) => [ - - {group.name} - , - group.types.map((type) => ( - onClickItem?.(type)} key={type} dense className={'flex justify-between'}> - - - - - {type === field.type && } - - )), - index < FieldTypeGroup.length - 1 && , - ])} + + props?.onClose?.({}, 'escapeKeyDown')} + options={options} + disableFocus={true} + onConfirm={onClickItem} + /> ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx index 27805c0035..28d62b82c6 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/property/property_type/PropertyTypeSelect.tsx @@ -16,19 +16,21 @@ function PropertyTypeSelect({ field, onUpdateFieldType }: Props) { const ref = useRef(null); return ( -
+
{ setExpanded(!expanded); }} - className={'px-23 mx-0'} + className={'mx-0 rounded-none px-0'} > - - - - - +
+ + + + + +
{expanded && ( void; + value?: SortConditionPB; +}> = ({ onChange, value }) => { + const ref = useRef(null); + const [open, setOpen] = useState(false); + const handleClose = () => { + setOpen(false); + }; + + const options: KeyboardNavigationOption[] = useMemo(() => { + return [ + { + key: SortConditionPB.Ascending, + content: t('grid.sort.ascending'), + }, + { + key: SortConditionPB.Descending, + content: t('grid.sort.descending'), + }, + ]; + }, []); + + const onConfirm = (optionKey: SortConditionPB) => { + onChange?.(optionKey); + handleClose(); + }; + + const selectedField = useMemo(() => options.find((option) => option.key === value), [options, value]); -export const SortConditionSelect: FC> = (props) => { return ( - + <> +
{ + setOpen(true); + }} + > +
{selectedField?.content}
+ +
+ {open && ( + +
+ +
+
+ )} + ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx index ced63779d2..724c28467a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortFieldsMenu.tsx @@ -1,4 +1,4 @@ -import React, { FC, MouseEvent, useCallback } from 'react'; +import React, { FC, useCallback } from 'react'; import { MenuProps } from '@mui/material'; import PropertiesList from '$app/components/database/components/property/PropertiesList'; import { Field, sortService } from '$app/application/database'; @@ -15,7 +15,7 @@ const SortFieldsMenu: FC< const { t } = useTranslation(); const viewId = useViewId(); const addSort = useCallback( - async (event: MouseEvent, field: Field) => { + async (field: Field) => { await sortService.insertSort(viewId, { fieldId: field.id, condition: SortConditionPB.Ascending, @@ -27,8 +27,25 @@ const SortFieldsMenu: FC< ); return ( - - + { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} + keepMounted={false} + {...props} + > + { + props.onClose?.({}, 'escapeKeyDown'); + }} + showSearch={true} + onItemClick={addSort} + searchPlaceholder={t('grid.settings.sortBy')} + /> ); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx index bf2e45915f..fe1074bbde 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortItem.tsx @@ -1,4 +1,4 @@ -import { IconButton, SelectChangeEvent, Stack } from '@mui/material'; +import { IconButton, Stack } from '@mui/material'; import { FC, useCallback } from 'react'; import { ReactComponent as CloseSvg } from '$app/assets/close.svg'; import { Field, Sort, sortService } from '$app/application/database'; @@ -28,10 +28,10 @@ export const SortItem: FC = ({ className, sort }) => { ); const handleConditionChange = useCallback( - (event: SelectChangeEvent) => { + (value: SortConditionPB) => { void sortService.updateSort(viewId, { ...sort, - condition: event.target.value as SortConditionPB, + condition: value, }); }, [viewId, sort] @@ -43,13 +43,8 @@ export const SortItem: FC = ({ className, sort }) => { return ( - - + +
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx index 148d98f1cb..88df70b2e4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/components/sort/SortMenu.tsx @@ -2,7 +2,7 @@ import { Menu, MenuProps } from '@mui/material'; import { FC, MouseEventHandler, useCallback, useState } from 'react'; import { useViewId } from '$app/hooks'; import { sortService } from '$app/application/database'; -import { useDatabase } from '../../Database.hooks'; +import { useDatabaseSorts } from '../../Database.hooks'; import { SortItem } from './SortItem'; import { useTranslation } from 'react-i18next'; @@ -15,7 +15,7 @@ export const SortMenu: FC = (props) => { const { onClose } = props; const { t } = useTranslation(); const viewId = useViewId(); - const { sorts } = useDatabase(); + const sorts = useDatabaseSorts(); const [anchorEl, setAnchorEl] = useState(null); const openFieldListMenu = Boolean(anchorEl); const handleClick = useCallback>((event) => { @@ -30,25 +30,31 @@ export const SortMenu: FC = (props) => { return ( <> { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + props.onClose?.({}, 'escapeKeyDown'); + } + }} keepMounted={false} MenuListProps={{ - className: 'py-1', + className: 'py-1 w-[360px]', }} {...props} onClose={onClose} > -
+
{sorts.map((sort) => ( ))}
-
+
- + )} + + {open && ( void; } -export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, ...props }: Props) { - return ( - - e.stopPropagation()} - {...props} - keepMounted={false} - > - - - { - if (action === FieldAction.EditProperty) { - onOpenPropertyMenu?.(); - } else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) { - onOpenMenu?.(newFieldId); - } +export function GridFieldMenu({ field, onOpenPropertyMenu, onOpenMenu, onClose, ...props }: Props) { + const inputRef = useRef(null); - props.onClose?.({}, 'backdropClick'); - }} - fieldId={field.id} - /> - - - + return ( + e.stopPropagation()} + {...props} + onClose={onClose} + keepMounted={false} + onMouseDown={(e) => { + const isInput = inputRef.current?.contains(e.target as Node); + + if (isInput) return; + + e.stopPropagation(); + e.preventDefault(); + }} + > + + + onClose?.({}, 'backdropClick')} + onMenuItemClick={(action, newFieldId?: string) => { + if (action === FieldAction.EditProperty) { + onOpenPropertyMenu?.(); + } else if (newFieldId && (action === FieldAction.InsertLeft || action === FieldAction.InsertRight)) { + onOpenMenu?.(newFieldId); + } + + onClose?.({}, 'backdropClick'); + }} + fieldId={field.id} + /> + + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx index e673458318..12aef74996 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_field/GridResizer.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; import { Field, fieldService } from '$app/application/database'; import { useViewId } from '$app/hooks'; @@ -7,17 +7,16 @@ interface GridResizerProps { onWidthChange?: (width: number) => void; } -const minWidth = 100; +const minWidth = 150; export function GridResizer({ field, onWidthChange }: GridResizerProps) { const viewId = useViewId(); const fieldId = field.id; const width = field.width || 0; const [isResizing, setIsResizing] = useState(false); - const [newWidth, setNewWidth] = useState(width); const [hover, setHover] = useState(false); const startX = useRef(0); - + const newWidthRef = useRef(width); const onResize = useCallback( (e: MouseEvent) => { const diff = e.clientX - startX.current; @@ -27,25 +26,21 @@ export function GridResizer({ field, onWidthChange }: GridResizerProps) { return; } - setNewWidth(newWidth); + newWidthRef.current = newWidth; onWidthChange?.(newWidth); }, [width, onWidthChange] ); - useEffect(() => { - if (!isResizing && width !== newWidth) { - void fieldService.updateFieldSetting(viewId, fieldId, { - width: newWidth, - }); - } - }, [fieldId, isResizing, newWidth, viewId, width]); - const onResizeEnd = useCallback(() => { setIsResizing(false); + + void fieldService.updateFieldSetting(viewId, fieldId, { + width: newWidthRef.current, + }); document.removeEventListener('mousemove', onResize); document.removeEventListener('mouseup', onResizeEnd); - }, [onResize]); + }, [fieldId, onResize, viewId]); const onResizeStart = useCallback( (e: React.MouseEvent) => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx index 713430eb51..789701ef3b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_new_row/GridNewRow.tsx @@ -49,7 +49,7 @@ function GridNewRow({ index, groupId, getContainerRef }: Props) { toggleCssProperty(false); }} onClick={handleClick} - className={'grid-new-row flex grow cursor-pointer'} + className={'grid-new-row flex grow cursor-pointer text-text-title'} > (); + const { t } = useTranslation(); + const [openConfirm, setOpenConfirm] = useState(false); + const [confirmModalProps, setConfirmModalProps] = useState< + | { + onOk: () => Promise; + onCancel: () => void; + } + | undefined + >(undefined); + const { hoverRowId } = useGridTableHoverState(containerRef); + const handleOpenConfirm = useCallback((onOk: () => Promise, onCancel: () => void) => { + setOpenConfirm(true); + setConfirmModalProps({ onOk, onCancel }); + }, []); + useEffect(() => { const container = containerRef.current; @@ -32,12 +49,25 @@ function GridTableOverlay({ return (
- + + {openConfirm && ( + { + setOpenConfirm(false); + }} + {...confirmModalProps} + /> + )}
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts index 9237bb3c03..a4251c9ed5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.hooks.ts @@ -2,6 +2,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useViewId } from '$app/hooks'; import { rowService } from '$app/application/database'; import { autoScrollOnEdge, ScrollDirection } from '$app/components/database/_shared/dnd/utils'; +import { useSortsCount } from '$app/components/database'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; export function getCellsWithRowId(rowId: string, container: HTMLDivElement) { return Array.from(container.querySelectorAll(`[data-key^="row:${rowId}"]`)); @@ -64,12 +66,16 @@ function createVirtualDragElement(rowId: string, container: HTMLDivElement) { export function useDraggableGridRow( rowId: string, containerRef: React.RefObject, - getScrollElement: () => HTMLDivElement | null + getScrollElement: () => HTMLDivElement | null, + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void ) { + const viewId = useViewId(); + const sortsCount = useSortsCount(); + const [isDragging, setIsDragging] = useState(false); const dropRowIdRef = useRef(undefined); const previewRef = useRef(); - const viewId = useViewId(); + const onDragStart = useCallback( (e: React.DragEvent) => { e.dataTransfer.effectAllowed = 'move'; @@ -100,6 +106,13 @@ export function useDraggableGridRow( [containerRef, rowId, getScrollElement] ); + const moveRowTo = useCallback( + async (toRowId: string) => { + return rowService.moveRow(viewId, rowId, toRowId); + }, + [viewId, rowId] + ); + useEffect(() => { if (!isDragging) { if (previewRef.current) { @@ -156,8 +169,23 @@ export function useDraggableGridRow( e.stopPropagation(); const dropRowId = dropRowIdRef.current; + toggleProperty(container, rowId, false); if (dropRowId) { - void rowService.moveRow(viewId, rowId, dropRowId); + if (sortsCount > 0) { + onOpenConfirm( + async () => { + await deleteAllSorts(viewId); + await moveRowTo(dropRowId); + }, + () => { + void moveRowTo(dropRowId); + } + ); + } else { + void moveRowTo(dropRowId); + } + + toggleProperty(container, dropRowId, false); } setIsDragging(false); @@ -169,7 +197,7 @@ export function useDraggableGridRow( container.addEventListener('dragover', onDragOver); container.addEventListener('dragend', onDragEnd); container.addEventListener('drop', onDrop); - }, [containerRef, isDragging, rowId, viewId]); + }, [isDragging, containerRef, moveRowTo, onOpenConfirm, rowId, sortsCount, viewId]); return { isDragging, diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx index 2813c05f86..f4b39e2561 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowActions.tsx @@ -1,25 +1,31 @@ import React, { useCallback, useState } from 'react'; import { IconButton, Tooltip } from '@mui/material'; -import { t } from 'i18next'; import { ReactComponent as AddSvg } from '$app/assets/add.svg'; import { GRID_ACTIONS_WIDTH } from '$app/components/database/grid/constants'; import { rowService } from '$app/application/database'; import { useViewId } from '$app/hooks'; -import { GridRowDragButton, GridRowMenu } from '$app/components/database/grid/grid_row_actions'; +import { GridRowDragButton, GridRowMenu, toggleProperty } from '$app/components/database/grid/grid_row_actions'; import { OrderObjectPositionTypePB } from '@/services/backend'; +import { useSortsCount } from '$app/components/database'; +import { useTranslation } from 'react-i18next'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; export function GridRowActions({ rowId, rowTop, containerRef, getScrollElement, + onOpenConfirm, }: { + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void; rowId?: string; rowTop?: string; containerRef: React.MutableRefObject; getScrollElement: () => HTMLDivElement | null; }) { + const { t } = useTranslation(); const viewId = useViewId(); + const sortsCount = useSortsCount(); const [menuRowId, setMenuRowId] = useState(undefined); const [menuPosition, setMenuPosition] = useState< | { @@ -31,17 +37,32 @@ export function GridRowActions({ const openMenu = Boolean(menuPosition); - const handleInsertRecordBelow = useCallback(() => { - void rowService.createRow(viewId, { - position: OrderObjectPositionTypePB.After, - rowId: rowId, - }); - }, [viewId, rowId]); + const handleCloseMenu = useCallback(() => { + setMenuPosition(undefined); + if (containerRef.current && menuRowId) { + toggleProperty(containerRef.current, menuRowId, false); + } + }, [containerRef, menuRowId]); + + const handleInsertRecordBelow = useCallback( + async (rowId: string) => { + await rowService.createRow(viewId, { + position: OrderObjectPositionTypePB.After, + rowId: rowId, + }); + handleCloseMenu(); + }, + [viewId, handleCloseMenu] + ); const handleOpenMenu = (e: React.MouseEvent) => { const target = e.target as HTMLButtonElement; const rect = target.getBoundingClientRect(); + if (containerRef.current && rowId) { + toggleProperty(containerRef.current, rowId, true); + } + setMenuRowId(rowId); setMenuPosition({ top: rect.top + rect.height / 2, @@ -49,11 +70,6 @@ export function GridRowActions({ }); }; - const handleCloseMenu = useCallback(() => { - setMenuPosition(undefined); - setMenuRowId(undefined); - }, []); - return ( <> {rowId && rowTop && ( @@ -64,10 +80,28 @@ export function GridRowActions({ left: GRID_ACTIONS_WIDTH, transform: 'translateY(4px)', }} - className={'z-10 flex w-full items-center justify-end'} + className={'z-10 flex w-full items-center justify-end py-[3px]'} > - - + + { + if (sortsCount > 0) { + onOpenConfirm( + async () => { + await deleteAllSorts(viewId); + void handleInsertRecordBelow(rowId); + }, + () => { + void handleInsertRecordBelow(rowId); + } + ); + } else { + void handleInsertRecordBelow(rowId); + } + }} + > @@ -76,12 +110,14 @@ export function GridRowActions({ rowId={rowId} containerRef={containerRef} onClick={handleOpenMenu} + onOpenConfirm={onOpenConfirm} />
)} - {openMenu && menuRowId && ( + {menuRowId && ( Promise, onCancel: () => void) => void; containerRef: React.MutableRefObject; }) { const [position, setPosition] = useState<{ left: number; top: number } | undefined>(); @@ -23,7 +25,7 @@ export function GridRowContextMenu({ if (!container || !rowId) return; toggleProperty(container, rowId, false); - setRowId(undefined); + // setRowId(undefined); }, [rowId, containerRef]); const openContextMenu = useCallback( @@ -56,8 +58,14 @@ export function GridRowContextMenu({ }; }, [containerRef, openContextMenu]); - return isContextMenuOpen && rowId ? ( - + return rowId ? ( + ) : null; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx index 6d271270a9..0790e48183 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowDragButton.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useDraggableGridRow } from './GridRowActions.hooks'; import { IconButton, Tooltip } from '@mui/material'; import { ReactComponent as DragSvg } from '$app/assets/drag.svg'; @@ -9,7 +9,9 @@ export function GridRowDragButton({ containerRef, onClick, getScrollElement, + onOpenConfirm, }: { + onOpenConfirm: (onOk: () => Promise, onCancel: () => void) => void; rowId: string; onClick?: (e: React.MouseEvent) => void; containerRef: React.MutableRefObject; @@ -17,19 +19,40 @@ export function GridRowDragButton({ }) { const { t } = useTranslation(); - const { onDragStart } = useDraggableGridRow(rowId, containerRef, getScrollElement); + const [openTooltip, setOpenTooltip] = useState(false); + const { onDragStart, isDragging } = useDraggableGridRow(rowId, containerRef, getScrollElement, onOpenConfirm); + + useEffect(() => { + if (isDragging) { + setOpenTooltip(false); + } + }, [isDragging]); return ( - - + { + setOpenTooltip(true); + }} + onClose={() => { + setOpenTooltip(false); + }} + placement='top' + disableInteractive={true} + title={t('grid.row.dragAndClick')} > - - - + + + + + ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx index 3daa5041e3..2190e8739b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_row_actions/GridRowMenu.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; 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'; @@ -7,22 +7,27 @@ import Popover, { PopoverProps } from '@mui/material/Popover'; import { useViewId } from '$app/hooks'; import { useTranslation } from 'react-i18next'; import { rowService } from '$app/application/database'; -import { Icon, MenuItem, MenuList } from '@mui/material'; import { OrderObjectPositionTypePB } from '@/services/backend'; +import KeyboardNavigation, { + KeyboardNavigationOption, +} from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; +import { useSortsCount } from '$app/components/database'; +import { deleteAllSorts } from '$app/application/database/sort/sort_service'; -interface Option { - label: string; - icon: JSX.Element; - onClick: () => void; - divider?: boolean; +enum RowAction { + InsertAbove, + InsertBelow, + Duplicate, + Delete, } - interface Props extends PopoverProps { rowId: string; + onOpenConfirm?: (onOk: () => Promise, onCancel: () => void) => void; } -export function GridRowMenu({ rowId, ...props }: Props) { +export function GridRowMenu({ onOpenConfirm, rowId, onClose, ...props }: Props) { const viewId = useViewId(); + const sortsCount = useSortsCount(); const { t } = useTranslation(); @@ -48,56 +53,107 @@ export function GridRowMenu({ rowId, ...props }: Props) { void rowService.duplicateRow(viewId, rowId); }, [viewId, rowId]); - const options: Option[] = [ - { - label: t('grid.row.insertRecordAbove'), - icon: , - onClick: handleInsertRecordAbove, - }, - { - label: t('grid.row.insertRecordBelow'), - icon: , - onClick: handleInsertRecordBelow, - }, - { - label: t('grid.row.duplicate'), - icon: , - onClick: handleDuplicateRow, - }, + const renderContent = useCallback((title: string, Icon: React.FC>) => { + return ( +
+ +
{title}
+
+ ); + }, []); - { - label: t('grid.row.delete'), - icon: , - onClick: handleDelRow, - divider: true, + const handleAction = useCallback( + (confirmKey?: RowAction) => { + switch (confirmKey) { + case RowAction.InsertAbove: + handleInsertRecordAbove(); + break; + case RowAction.InsertBelow: + handleInsertRecordBelow(); + break; + case RowAction.Duplicate: + handleDuplicateRow(); + break; + case RowAction.Delete: + handleDelRow(); + break; + default: + break; + } }, - ]; + [handleDelRow, handleDuplicateRow, handleInsertRecordAbove, handleInsertRecordBelow] + ); + + const onConfirm = useCallback( + (key: RowAction) => { + if (sortsCount > 0) { + onOpenConfirm?.( + async () => { + await deleteAllSorts(viewId); + handleAction(key); + }, + () => { + handleAction(key); + } + ); + } else { + handleAction(key); + } + + onClose?.({}, 'backdropClick'); + }, + [handleAction, onClose, onOpenConfirm, sortsCount, viewId] + ); + + const options: KeyboardNavigationOption[] = useMemo( + () => [ + { + key: RowAction.InsertAbove, + content: renderContent(t('grid.row.insertRecordAbove'), UpSvg), + }, + { + key: RowAction.InsertBelow, + content: renderContent(t('grid.row.insertRecordBelow'), AddSvg), + }, + { + key: RowAction.Duplicate, + content: renderContent(t('grid.row.duplicate'), CopySvg), + }, + + { + key: 100, + content:
, + children: [], + }, + { + key: RowAction.Delete, + content: renderContent(t('grid.row.delete'), DelSvg), + }, + ], + [renderContent, t] + ); return ( - - - {options.map((option) => ( -
- {option.divider &&
} - { - option.onClick(); - props.onClose?.({}, 'backdropClick'); - }} - > - {option.icon} - {option.label} - -
- ))} - - + <> + +
+ { + onClose?.({}, 'escapeKeyDown'); + }} + /> +
+
+ ); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx index 333edf89ba..e9d01508b1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_sticky_header/GridStickyHeader.tsx @@ -1,19 +1,29 @@ import React, { useCallback, useState } from 'react'; -import { GridChildComponentProps, VariableSizeGrid as Grid } from 'react-window'; +import { GridChildComponentProps, GridOnScrollProps, VariableSizeGrid as Grid } from 'react-window'; import AutoSizer from 'react-virtualized-auto-sizer'; import { useGridColumn } from '$app/components/database/grid/grid_table'; import { GridField } from 'src/appflowy_app/components/database/grid/grid_field'; import NewProperty from '$app/components/database/components/property/NewProperty'; -import { GridColumn, GridColumnType } from '$app/components/database/grid/constants'; +import { GridColumn, GridColumnType, RenderRow } from '$app/components/database/grid/constants'; import { OpenMenuContext } from '$app/components/database/grid/grid_sticky_header/GridStickyHeader.hooks'; const GridStickyHeader = React.forwardRef< - Grid | null, - { columns: GridColumn[]; getScrollElement?: () => HTMLDivElement | null } ->(({ columns, getScrollElement }, ref) => { + Grid | null, + { + columns: GridColumn[]; + getScrollElement?: () => HTMLDivElement | null; + onScroll?: (props: GridOnScrollProps) => void; + } +>(({ onScroll, columns, getScrollElement }, ref) => { const { columnWidth, resizeColumnWidth } = useGridColumn( columns, - ref as React.MutableRefObject | null> + ref as React.MutableRefObject | null> ); const [openMenuId, setOpenMenuId] = useState(null); @@ -33,9 +43,10 @@ const GridStickyHeader = React.forwardRef< }, []); const Cell = useCallback( - ({ columnIndex, style }: GridChildComponentProps) => { - const column = columns[columnIndex]; + ({ columnIndex, style, data }: GridChildComponentProps) => { + const column = data[columnIndex]; + if (!column || column.type === GridColumnType.Action) return
; if (column.type === GridColumnType.NewProperty) { const width = (style.width || 0) as number; @@ -43,7 +54,7 @@ const GridStickyHeader = React.forwardRef<
@@ -52,10 +63,6 @@ const GridStickyHeader = React.forwardRef< ); } - if (column.type === GridColumnType.Action) { - return
; - } - const field = column.field; if (!field) return
; @@ -72,7 +79,7 @@ const GridStickyHeader = React.forwardRef< /> ); }, - [columns, handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement] + [handleCloseMenu, handleOpenMenu, resizeColumnWidth, getScrollElement] ); return ( @@ -81,6 +88,7 @@ const GridStickyHeader = React.forwardRef< {({ height, width }: { height: number; width: number }) => { return ( 36} @@ -88,7 +96,9 @@ const GridStickyHeader = React.forwardRef< columnCount={columns.length} columnWidth={columnWidth} ref={ref} - style={{ overflowX: 'hidden', overscrollBehavior: 'none' }} + onScroll={onScroll} + itemData={columns} + style={{ overscrollBehavior: 'none' }} > {Cell} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts index 3d534fbd3e..0d676f3bb2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.hooks.ts @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn } from '$app/components/database/grid/constants'; +import { DEFAULT_FIELD_WIDTH, GRID_ACTIONS_WIDTH, GridColumn, RenderRow } from '$app/components/database/grid/constants'; import { VariableSizeGrid as Grid } from 'react-window'; export function useGridRow() { @@ -12,7 +12,16 @@ export function useGridRow() { }; } -export function useGridColumn(columns: GridColumn[], ref: React.RefObject | null>) { +export function useGridColumn( + columns: GridColumn[], + ref: React.RefObject | null> +) { const [columnWidths, setColumnWidths] = useState([]); useEffect(() => { diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx index ffaa0f8458..3c1aa4b8c5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/database/grid/grid_table/GridTable.tsx @@ -20,8 +20,16 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { const fields = useDatabaseVisibilityFields(); const renderRows = useMemo(() => rowMetasToRenderRow(rowMetas as RowMeta[]), [rowMetas]); const columns = useMemo(() => fieldsToColumns(fields), [fields]); - const ref = useRef>(null); - const { columnWidth } = useGridColumn(columns, ref); + const ref = useRef< + Grid<{ + columns: GridColumn[]; + renderRows: RenderRow[]; + }> + >(null); + const { columnWidth } = useGridColumn( + columns, + ref as React.MutableRefObject | null> + ); const { rowHeight } = useGridRow(); const onRendered = useDatabaseRendered(); @@ -54,9 +62,9 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { }, []); const Cell = useCallback( - ({ columnIndex, rowIndex, style }: GridChildComponentProps) => { - const row = renderRows[rowIndex]; - const column = columns[columnIndex]; + ({ columnIndex, rowIndex, style, data }: GridChildComponentProps) => { + const row = data.renderRows[rowIndex]; + const column = data.columns[columnIndex]; return ( = React.memo(({ onEditRecord }) => { /> ); }, - [columns, getContainerRef, renderRows, onEditRecord] + [getContainerRef, onEditRecord] ); - const staticGrid = useRef | null>(null); + const staticGrid = useRef | null>(null); const onScroll = useCallback(({ scrollLeft, scrollUpdateWasRequested }: GridOnScrollProps) => { if (!scrollUpdateWasRequested) { @@ -80,6 +88,10 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { } }, []); + const onHeaderScroll = useCallback(({ scrollLeft }: GridOnScrollProps) => { + ref.current?.scrollTo({ scrollLeft }); + }, []); + const containerRef = useRef(null); const scrollElementRef = useRef(null); @@ -95,7 +107,12 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => {
)}
- +
@@ -110,6 +127,10 @@ export const GridTable: FC = React.memo(({ onEditRecord }) => { rowCount={renderRows.length} rowHeight={rowHeight} width={width} + itemData={{ + columns, + renderRows, + }} overscanRowCount={10} itemKey={getItemKey} style={{ diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx index c77ee0c6a3..eeb8b85904 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/document/Document.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Editor } from 'src/appflowy_app/components/editor'; +import Editor from '$app/components/editor/Editor'; import { DocumentHeader } from 'src/appflowy_app/components/document/document_header'; import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { updatePageName } from '$app_reducers/pages/async_actions'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx index 6ba63da503..879dc5f9c0 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/Editor.tsx @@ -1,7 +1,6 @@ import React, { memo } from 'react'; import { EditorProps } from '../../application/document/document.types'; -import { Toaster } from 'react-hot-toast'; import { CollaborativeEditor } from '$app/components/editor/components/editor'; import { EditorIdProvider } from '$app/components/editor/Editor.hooks'; import './editor.scss'; @@ -12,7 +11,6 @@ export function Editor(props: EditorProps) {
-
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts index c4c13187b2..f12d21d76e 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/formula.ts @@ -1,6 +1,7 @@ import { ReactEditor } from 'slate-react'; import { Editor, Element as SlateElement, NodeEntry, Range, Transforms } from 'slate'; import { EditorInlineNodeType, FormulaNode } from '$app/application/document/document.types'; +import { isMarkActive } from '$app/components/editor/command/mark'; export function insertFormula(editor: ReactEditor, formula?: string) { if (editor.selection) { @@ -79,9 +80,5 @@ export function unwrapFormula(editor: ReactEditor) { } export function isFormulaActive(editor: ReactEditor) { - const [node] = Editor.nodes(editor, { - match: (n) => !Editor.isEditor(n) && SlateElement.isElement(n) && n.type === EditorInlineNodeType.Formula, - }); - - return !!node; + return isMarkActive(editor, EditorInlineNodeType.Formula); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts index 9da3ca40aa..42130e24c5 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/mark.ts @@ -1,6 +1,6 @@ import { ReactEditor } from 'slate-react'; import { Editor, Text, Range } from 'slate'; -import { EditorMarkFormat } from '$app/application/document/document.types'; +import { EditorInlineNodeType, EditorMarkFormat } from '$app/application/document/document.types'; export function toggleMark( editor: ReactEditor, @@ -25,7 +25,7 @@ export function toggleMark( * @param editor * @param format */ -export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat) { +export function isMarkActive(editor: ReactEditor, format: EditorMarkFormat | EditorInlineNodeType) { const selection = editor.selection; if (!selection) return false; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts index 74ebe80be4..819596f92f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/command/tab.ts @@ -31,6 +31,10 @@ export function tabForward(editor: ReactEditor) { const [node, path] = match as NodeEntry; + const hasPrevious = Path.hasPrevious(path); + + if (!hasPrevious) return; + const previousPath = Path.previous(path); const previous = editor.node(previousPath); @@ -40,6 +44,7 @@ export function tabForward(editor: ReactEditor) { const type = previousNode.type as EditorNodeType; + if (type === EditorNodeType.Page) return; // the previous node is not a list if (!LIST_TYPES.includes(type)) return; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx index fd12121a09..c10dde829a 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/_shared/PlaceholderContent.tsx @@ -102,11 +102,14 @@ function PlaceholderContent({ node, ...attributes }: { node: Element; className? const editorDom = ReactEditor.toDOMNode(editor, editor); + // placeholder should be hidden when composing editorDom.addEventListener('compositionstart', handleCompositionStart); editorDom.addEventListener('compositionend', handleCompositionEnd); + editorDom.addEventListener('compositionupdate', handleCompositionStart); return () => { editorDom.removeEventListener('compositionstart', handleCompositionStart); editorDom.removeEventListener('compositionend', handleCompositionEnd); + editorDom.removeEventListener('compositionupdate', handleCompositionStart); }; }, [editor, selected]); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx index 41d6165958..a0f50016e1 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseEmpty.tsx @@ -13,7 +13,7 @@ function DatabaseEmpty({ node }: { node: GridNode }) { const [open, setOpen] = React.useState(false); const toggleDrawer = useCallback((open: boolean) => { - return (e: React.MouseEvent | KeyboardEvent) => { + return (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => { e.stopPropagation(); setOpen(open); }; @@ -23,7 +23,7 @@ function DatabaseEmpty({ node }: { node: GridNode }) {
{t('document.plugins.database.noDataSource')}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx index 5a342a08aa..6fdb224e27 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/DatabaseList.tsx @@ -17,7 +17,7 @@ function DatabaseList({ toggleDrawer, }: { node: GridNode; - toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent) => void; + toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => void; }) { const scrollRef = React.useRef(null); @@ -70,10 +70,12 @@ function DatabaseList({ ); return ( -
+
(e: React.MouseEvent | KeyboardEvent) => void; + toggleDrawer: (open: boolean) => (e: React.MouseEvent | KeyboardEvent | React.FocusEvent) => void; node: GridNode; }) { const editor = useSlateStatic(); @@ -38,6 +38,13 @@ function Drawer({ width: open ? '250px' : '0px', transition: 'width 0.3s ease-in-out', }} + onMouseDown={(e) => { + const isInput = (e.target as HTMLElement).closest('input'); + + if (isInput) return; + e.stopPropagation(); + e.preventDefault(); + }} >
@@ -46,7 +53,9 @@ function Drawer({
-
{open && }
+
+ {open && } +
+
{children}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx index 9873beaeae..9bc2e6a23f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/database/GridView.tsx @@ -19,7 +19,7 @@ function GridView({ viewId }: { viewId: string }) { const gridScroller = element.querySelector('.grid-scroll-container') as HTMLDivElement; - const scrollLayout = gridScroller?.closest('.appflowy-layout') as HTMLDivElement; + const scrollLayout = gridScroller?.closest('.appflowy-scroll-container') as HTMLDivElement; if (!gridScroller || !scrollLayout) { return; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx index 933766f214..f44158bdf2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/math_equation/EditPopover.tsx @@ -58,7 +58,7 @@ function EditPopover({ }, [onClose, editor, node]); const handleDone = () => { - if (!node) return; + if (!node || error) return; if (value !== node.data.formula) { CustomEditor.setMathEquationBlockFormula(editor, node, value); } @@ -100,7 +100,7 @@ function EditPopover({ const { transformOrigin, anchorOrigin, isEntered } = usePopoverAutoPosition({ initialPaperWidth: 300, - initialPaperHeight: 200, + initialPaperHeight: 170, anchorEl, initialAnchorOrigin: initialOrigin.anchorOrigin, initialTransformOrigin: initialOrigin.transformOrigin, @@ -128,7 +128,7 @@ function EditPopover({ autoComplete={'off'} spellCheck={false} value={value} - minRows={3} + minRows={4} onInput={onInput} onKeyDown={onKeyDown} placeholder={`|x| = \\begin{cases} @@ -138,7 +138,7 @@ function EditPopover({ /> {error && ( -
+
{error.name}: {error.message}
)} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx index bf8df3c8e7..6ff97c2836 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/blocks/text/Text.tsx @@ -21,7 +21,7 @@ export const Text = memo( {renderIcon()} - {children} + {children} ); }) diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx index af636eeb5a..b05000c6e2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CollaborativeEditor.tsx @@ -6,7 +6,7 @@ import { Provider } from '$app/components/editor/provider'; import { YXmlText } from 'yjs/dist/src/types/YXmlText'; import { getInsertTarget, getYTarget } from '$app/components/editor/provider/utils/relation'; -export const CollaborativeEditor = memo(({ id, title, showTitle = true, onTitleChange }: EditorProps) => { +export const CollaborativeEditor = memo(({ id, title, showTitle = true, onTitleChange, disableFocus }: EditorProps) => { const [sharedType, setSharedType] = useState(null); const provider = useMemo(() => { setSharedType(null); @@ -62,5 +62,5 @@ export const CollaborativeEditor = memo(({ id, title, showTitle = true, onTitleC return null; } - return ; + return ; }); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx index c6bdd330b6..0f077b82d8 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/editor/CustomEditable.tsx @@ -4,15 +4,22 @@ import Element from './Element'; import { Leaf } from './Leaf'; type CustomEditableProps = Omit, 'renderElement' | 'renderLeaf'> & - Partial, 'renderElement' | 'renderLeaf'>>; + Partial, 'renderElement' | 'renderLeaf'>> & { + disableFocus?: boolean; + }; -export function CustomEditable({ renderElement = Element, renderLeaf = Leaf, ...props }: CustomEditableProps) { +export function CustomEditable({ + renderElement = Element, + disableFocus = false, + renderLeaf = Leaf, + ...props +}: CustomEditableProps) { return ( void; + onClear: () => void; onDone: (formula: string) => void; }) { const [text, setText] = useState(defaultText); @@ -37,7 +42,7 @@ function FormulaEditPopover({ horizontal: 'center', }} > -
+
- + + onDone(text)}> + + + + + + + +
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx index 89ae42291a..bbbd485fab 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/inline_formula/InlineFormula.tsx @@ -42,6 +42,46 @@ export const InlineFormula = memo( moveCursorToNodeEnd(editor, anchor.current); }, [closePopover, editor]); + const selectNode = useCallback(() => { + if (anchor.current === null) { + return; + } + + const path = getNodePath(editor, anchor.current); + + ReactEditor.focus(editor); + Transforms.select(editor, path); + }, [editor]); + + const onClear = useCallback(() => { + selectNode(); + CustomEditor.toggleFormula(editor); + closePopover(); + }, [selectNode, closePopover, editor]); + + const onDone = useCallback( + (newFormula: string) => { + selectNode(); + if (newFormula === '' && anchor.current) { + const path = getNodePath(editor, anchor.current); + const point = editor.before(path); + + CustomEditor.deleteFormula(editor); + closePopover(); + if (point) { + ReactEditor.focus(editor); + editor.select(point); + } + + return; + } else { + CustomEditor.updateFormula(editor, newFormula); + handleEditPopoverClose(); + } + }, + [closePopover, editor, handleEditPopoverClose, selectNode] + ); + return ( <> { - if (anchor.current === null || newFormula === formula) { - handleEditPopoverClose(); - return; - } - - const path = getNodePath(editor, anchor.current); - - // select the node before updating the formula - Transforms.select(editor, path); - if (newFormula === '') { - const point = editor.before(path); - - CustomEditor.deleteFormula(editor); - closePopover(); - if (point) { - ReactEditor.focus(editor); - editor.select(point); - } - - return; - } else { - CustomEditor.updateFormula(editor, newFormula); - handleEditPopoverClose(); - } - }} + onClear={onClear} + onDone={onDone} anchorEl={anchor.current} open={open} onClose={handleEditPopoverClose} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx index dd980c7faa..0bc9a59a3f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/link/LinkEditContent.tsx @@ -2,8 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Typography from '@mui/material/Typography'; import { addMark, removeMark } from 'slate'; import { EditorMarkFormat } from '$app/application/document/document.types'; -import { open as openWindow } from '@tauri-apps/api/shell'; -import { notify } from '$app/components/editor/components/tools/notify'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; import { CustomEditor } from '$app/components/editor/command'; import { useTranslation } from 'react-i18next'; import { useSlateStatic } from 'slate-react'; @@ -15,6 +14,7 @@ import KeyboardNavigation, { } from '$app/components/_shared/keyboard_navigation/KeyboardNavigation'; import isHotkey from 'is-hotkey'; import LinkEditInput, { pattern } from '$app/components/editor/components/inline_nodes/link/LinkEditInput'; +import { openUrl } from '$app/utils/open_url'; function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaultHref: string }) { const editor = useSlateStatic(); @@ -85,13 +85,7 @@ function LinkEditContent({ onClose, defaultHref }: { onClose: () => void; defaul const onConfirm = useCallback( (key: string) => { if (key === 'open') { - const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:']; - - if (linkPrefix.some((prefix) => link.startsWith(prefix))) { - void openWindow(link); - } else { - void openWindow('https://' + link); - } + openUrl(link); } else if (key === 'copy') { void navigator.clipboard.writeText(link); notify.success(t('message.copy.success')); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx index fb6f630214..eba7c77169 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/inline_nodes/mention/MentionLeaf.tsx @@ -7,7 +7,7 @@ import { pageTypeMap } from '$app_reducers/pages/slice'; import { getPage } from '$app/application/folder/page.service'; import { useSelected } from 'slate-react'; import { ReactComponent as EyeClose } from '$app/assets/eye_close.svg'; -import { notify } from '$app/components/editor/components/tools/notify'; +import { notify } from 'src/appflowy_app/components/_shared/notify'; import { subscribeNotifications } from '$app/application/notification'; import { FolderNotification } from '@/services/backend'; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts index 2697187386..633d09349d 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/Command.hooks.ts @@ -50,19 +50,21 @@ export function useCommandPanel() { if (deleteText && startPoint.current && endPoint.current) { const anchor = { path: startPoint.current.path, - offset: startPoint.current.offset - 1, + offset: startPoint.current.offset > 0 ? startPoint.current.offset - 1 : 0, }; const focus = { path: endPoint.current.path, offset: endPoint.current.offset, }; - Transforms.delete(editor, { - at: { - anchor, - focus, - }, - }); + if (!Point.equals(anchor, focus)) { + Transforms.delete(editor, { + at: { + anchor, + focus, + }, + }); + } } setSlashOpen(false); @@ -117,7 +119,7 @@ export function useCommandPanel() { * listen to editor insertText and deleteBackward event */ useEffect(() => { - const { insertText, deleteBackward } = editor; + const { insertText } = editor; /** * insertText: when insert char at after space or at start of element, show the panel @@ -171,50 +173,10 @@ export function useCommandPanel() { openPanel(); }; - /** - * deleteBackward: when delete char at start of panel char, and then it will be deleted, so we should close the panel if it is open - * close condition: - * 1. open is true - * 2. current block is not code block - * 3. current selection is not include root - * 4. current selection is collapsed - * 5. before text is command char - * --------- start ----------------- - * | - selection point - * @ - panel char - * - - other text - * -------- close panel ---------------- - * --@|--- => delete text is panel char, close the panel - * -------- delete text ---------------- - * ---@__|--- => delete text is not panel char, delete the text - */ - editor.deleteBackward = (...args) => { - if (!open || CustomEditor.isCodeBlock(editor)) { - deleteBackward(...args); - return; - } - - const { selection } = editor; - - if (selection && Range.isCollapsed(selection)) { - const { anchor } = selection; - const block = CustomEditor.getBlock(editor); - const path = block ? block[1] : []; - const beforeText = Editor.string(editor, { anchor, focus: Editor.start(editor, path) }); - - deleteBackward(...args); - // if delete backward at start of panel char, and then it will be deleted, so we should close the panel if it is open - if (beforeText === command) { - closePanel(); - } - } - }; - return () => { editor.insertText = insertText; - editor.deleteBackward = deleteBackward; }; - }, [setSlashOpen, command, open, setPosition, editor, closePanel, openPanel]); + }, [open, editor, openPanel, setSlashOpen]); /** * listen to editor onChange event diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx index 91c9ed7024..c55462f87b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/command_panel/slash_command_panel/SlashCommandPanel.hooks.tsx @@ -136,7 +136,7 @@ export function useSlashCommandPanel({ if (!newNode || !path) return; - const isEmpty = CustomEditor.isEmptyText(editor, newNode) && newNode.type === EditorNodeType.Paragraph; + const isEmpty = CustomEditor.isEmptyText(editor, newNode) && node.type === EditorNodeType.Paragraph; if (!isEmpty) { const nextPath = Path.next(path); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx index 7da1a3f45d..fde7d4d138 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/components/tools/selection_toolbar/actions/href/Href.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import ActionButton from '$app/components/editor/components/tools/selection_toolbar/actions/_shared/ActionButton'; import { useTranslation } from 'react-i18next'; import { ReactEditor, useSlateStatic } from 'slate-react'; @@ -8,6 +8,8 @@ import { Editor } from 'slate'; import { EditorMarkFormat } from '$app/application/document/document.types'; import { useDecorateDispatch, useDecorateState } from '$app/components/editor/stores'; import { LinkEditPopover } from '$app/components/editor/components/inline_nodes/link'; +import isHotkey from 'is-hotkey'; +import { getModifier } from '$app/utils/get_modifier'; export function Href() { const { t } = useTranslation(); @@ -63,9 +65,36 @@ export function Href() { } }, [clearDecorate, decorateState?.range, editor]); + useEffect(() => { + const editorDom = ReactEditor.toDOMNode(editor, editor); + const handleShortcut = (e: KeyboardEvent) => { + if (isHotkey('mod+k', e)) { + e.preventDefault(); + e.stopPropagation(); + onClick(); + } + }; + + editorDom.addEventListener('keydown', handleShortcut); + return () => { + editorDom.removeEventListener('keydown', handleShortcut); + }; + }, [editor, onClick]); + + const tooltip = useMemo(() => { + const modifier = getModifier(); + + return ( + <> +
{t('editor.link')}
+
{`${modifier} + K`}
+ + ); + }, [t]); + return ( <> - + {openEditPopover && ( diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss index 33e1e5fbe8..ba18a81185 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/editor.scss @@ -46,6 +46,7 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } + [role="textbox"] { ::selection { @apply bg-content-blue-100; @@ -82,6 +83,17 @@ span[data-slate-placeholder="true"]:not(.inline-block-content) { } +.text-content { + &.empty-content { + @apply min-w-[1px]; + span { + &::selection { + @apply bg-transparent; + } + } + } +} + .text-element:has(.text-placeholder), .divider-node { ::selection { @apply bg-transparent; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts index f375bd9f3c..03a6833bd2 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/shortcuts/shortcuts.hooks.ts @@ -30,64 +30,34 @@ import { getHotKeys } from '$app/components/editor/plugins/shortcuts/hotkey'; * - toggle todo or toggle: Mod+Enter (toggle todo list or toggle list) */ -const inputTypeToFormat: Record = { - formatBold: EditorMarkFormat.Bold, - formatItalic: EditorMarkFormat.Italic, - formatUnderline: EditorMarkFormat.Underline, - formatStrikethrough: EditorMarkFormat.StrikeThrough, - formatCode: EditorMarkFormat.Code, -}; - export function useShortcuts(editor: ReactEditor) { - const onDOMBeforeInput = useCallback( - (e: InputEvent) => { - const inputType = e.inputType; - - const format = inputTypeToFormat[inputType]; - - if (format) { - e.preventDefault(); - if (CustomEditor.selectionIncludeRoot(editor)) return; - return CustomEditor.toggleMark(editor, { - key: format, - value: true, - }); - } - }, - [editor] - ); - const onKeyDown = useCallback( (e: KeyboardEvent) => { - const isAppleWebkit = navigator.userAgent.includes('AppleWebKit'); - - // Apple Webkit does not support the input event for formatting - if (isAppleWebkit) { - Object.entries(getHotKeys()).forEach(([_, item]) => { - if (isHotkey(item.hotkey, e)) { - e.stopPropagation(); - e.preventDefault(); - if (CustomEditor.selectionIncludeRoot(editor)) return; - if (item.markKey === EditorMarkFormat.Align) { - CustomEditor.toggleAlign(editor, item.markValue as string); - return; - } - - CustomEditor.toggleMark(editor, { - key: item.markKey, - value: item.markValue, - }); + Object.entries(getHotKeys()).forEach(([_, item]) => { + if (isHotkey(item.hotkey, e)) { + e.stopPropagation(); + e.preventDefault(); + if (CustomEditor.selectionIncludeRoot(editor)) return; + if (item.markKey === EditorMarkFormat.Align) { + CustomEditor.toggleAlign(editor, item.markValue as string); return; } - }); - } + + CustomEditor.toggleMark(editor, { + key: item.markKey, + value: item.markValue, + }); + return; + } + }); const node = getBlock(editor); if (isHotkey('Escape', e)) { e.preventDefault(); - e.stopPropagation(); + editor.deselect(); + return; } @@ -153,7 +123,6 @@ export function useShortcuts(editor: ReactEditor) { ); return { - onDOMBeforeInput, onKeyDown, }; } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts index 02ca4dc43b..d73ebcb9c4 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/components/editor/plugins/withBlockDelete.ts @@ -94,6 +94,34 @@ export function withBlockDelete(editor: ReactEditor) { }); } + // if previous node is an embed, merge the current node to another node which is not an embed + if (Element.isElement(previousNode) && editor.isEmbed(previousNode)) { + const previousTextMatch = editor.previous({ + at: path, + match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n.textId !== undefined, + }); + + if (!previousTextMatch) { + deleteBackward(unit); + return; + } + + const previousTextPath = previousTextMatch[1]; + const textNode = node.children[0] as Element; + + const at = Editor.end(editor, previousTextPath); + + editor.select(at); + editor.insertNodes(textNode.children, { + at, + }); + + editor.removeNodes({ + at: path, + }); + return; + } + deleteBackward(unit); }; diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx index c5bbb17424..f977bbe852 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/Layout.tsx @@ -12,6 +12,10 @@ function Layout({ children }: { children: ReactNode }) { if (e.key === 'Backspace' && e.target instanceof HTMLBodyElement) { e.preventDefault(); } + + if (e.key === 'Escape') { + e.preventDefault(); + } }; window.addEventListener('keydown', onKeyDown); @@ -21,12 +25,12 @@ function Layout({ children }: { children: ReactNode }) { }, []); return ( <> -
+
@@ -34,7 +38,7 @@ function Layout({ children }: { children: ReactNode }) { style={{ height: 'calc(100vh - 64px - 48px)', }} - className={'appflowy-layout overflow-y-auto overflow-x-hidden'} + className={'appflowy-layout appflowy-scroll-container select-none overflow-y-auto overflow-x-hidden'} > {children}
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx index 986ba9337d..8dc6ec6617 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/bread_crumb/BreadCrumb.tsx @@ -25,7 +25,7 @@ function Breadcrumb() { if (!currentPage) { if (isTrash) { - return {t('trash.text')}; + return {t('trash.text')}; } return null; @@ -36,7 +36,7 @@ function Breadcrumb() { {parentPages?.map((page: Page) => ( { @@ -48,10 +48,11 @@ function Breadcrumb() { {page.name || t('document.title.placeholder')} ))} - -
{getPageIcon(currentPage)}
- {currentPage?.name || t('menuAppHeader.defaultNewPageName')} -
+ +
+
{getPageIcon(currentPage)}
+ {currentPage.name || t('menuAppHeader.defaultNewPageName')} +
); } diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx index e1b6f9e414..ca37d8aedd 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/collapse_menu_button/CollapseMenuButton.tsx @@ -42,8 +42,8 @@ function CollapseMenuButton() { return ( - - {isCollapsed ? : } + + ); diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx index cee598b66d..02e8bfb60b 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/side_bar/SideBar.tsx @@ -1,22 +1,50 @@ -import React from 'react'; -import { useAppSelector } from '$app/stores/store'; +import React, { useEffect, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '$app/stores/store'; import { AppflowyLogoDark } from '$app/components/_shared/svg/AppflowyLogoDark'; import { AppflowyLogoLight } from '$app/components/_shared/svg/AppflowyLogoLight'; import CollapseMenuButton from '$app/components/layout/collapse_menu_button/CollapseMenuButton'; import Resizer from '$app/components/layout/side_bar/Resizer'; import UserInfo from '$app/components/layout/side_bar/UserInfo'; import WorkspaceManager from '$app/components/layout/workspace_manager/WorkspaceManager'; +import { ThemeMode } from '$app_reducers/current-user/slice'; +import { sidebarActions } from '$app_reducers/sidebar/slice'; function SideBar() { const { isCollapsed, width, isResizing } = useAppSelector((state) => state.sidebar); - const isDark = useAppSelector((state) => state.currentUser?.userSetting?.isDark); + const dispatch = useAppDispatch(); + const themeMode = useAppSelector((state) => state.currentUser?.userSetting?.themeMode); + const isDark = + themeMode === ThemeMode.Dark || + (themeMode === ThemeMode.System && window.matchMedia('(prefers-color-scheme: dark)').matches); + + const lastCollapsedRef = useRef(isCollapsed); + + useEffect(() => { + const handleResize = () => { + const width = window.innerWidth; + + if (width <= 800 && !isCollapsed) { + lastCollapsedRef.current = false; + dispatch(sidebarActions.setCollapse(true)); + } else if (width > 800 && !lastCollapsedRef.current) { + lastCollapsedRef.current = true; + dispatch(sidebarActions.setCollapse(false)); + } + }; + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [dispatch, isCollapsed]); return ( <>
diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx index d1b0fead63..fd7fe34ec7 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/top_bar/TopBar.tsx @@ -9,7 +9,7 @@ function TopBar() { return (
{sidebarIsCollapsed && ( -
+
)} diff --git a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx index 165a9ab1d1..0035ba702f 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx +++ b/frontend/appflowy_tauri/src/appflowy_app/components/layout/workspace_manager/Workspace.tsx @@ -29,7 +29,9 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo onClick={(e) => { e.stopPropagation(); e.preventDefault(); - setShowPages(!showPages); + setShowPages((prev) => { + return !prev; + }); }} onMouseEnter={() => { setShowAdd(true); @@ -59,7 +61,9 @@ function Workspace({ workspace, opened }: { workspace: WorkspaceItem; opened: bo )}
- {showPages && } +
+ +
); diff --git a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts index c8a9d864ac..fae1d59214 100644 --- a/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts +++ b/frontend/appflowy_tauri/src/appflowy_app/stores/reducers/sidebar/slice.ts @@ -19,6 +19,9 @@ export const sidebarSlice = createSlice({ toggleCollapse(state) { state.isCollapsed = !state.isCollapsed; }, + setCollapse(state, action: PayloadAction) { + state.isCollapsed = action.payload; + }, changeWidth(state, action: PayloadAction) { state.width = action.payload; }, diff --git a/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts new file mode 100644 index 0000000000..f14b256517 --- /dev/null +++ b/frontend/appflowy_tauri/src/appflowy_app/utils/open_url.ts @@ -0,0 +1,18 @@ +import { open as openWindow } from '@tauri-apps/api/shell'; + +export const pattern = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w.-]*)*\/?$/; + +export function openUrl(str: string) { + if (pattern.test(str)) { + const linkPrefix = ['http://', 'https://', 'file://', 'ftp://', 'ftps://', 'mailto:']; + + if (linkPrefix.some((prefix) => str.startsWith(prefix))) { + void openWindow(str); + } else { + void openWindow('https://' + str); + } + } else { + // open google search + void openWindow('https://www.google.com/search?q=' + encodeURIComponent(str)); + } +} diff --git a/frontend/appflowy_tauri/src/styles/template.css b/frontend/appflowy_tauri/src/styles/template.css index f17595b74a..9d5d3efbc1 100644 --- a/frontend/appflowy_tauri/src/styles/template.css +++ b/frontend/appflowy_tauri/src/styles/template.css @@ -101,23 +101,20 @@ th { } .react-datepicker__day:hover { border-radius: 100%; - background: var(--fill-hover); + background: var(--fill-default); + color: var(--content-on-fill); } .react-datepicker__day--outside-month { color: var(--text-caption); } .react-datepicker__day--in-range { - background: var(--fill-active); + background: var(--fill-hover); + color: var(--content-on-fill); } -.react-datepicker__day--in-selecting-range { - background: var(--fill-active) !important; -} - - .react-datepicker__day--today { - border: 1px solid var(--fill-hover); + border: 1px solid var(--fill-default); color: var(--text-title); border-radius: 100%; background: transparent; @@ -126,7 +123,14 @@ th { } .react-datepicker__day--today:hover{ + background: var(--fill-default); + color: var(--content-on-fill); +} + +.react-datepicker__day--in-selecting-range, .react-datepicker__day--today.react-datepicker__day--in-range { background: var(--fill-hover); + color: var(--content-on-fill); + border-color: transparent; } .react-datepicker__day--keyboard-selected { @@ -135,14 +139,28 @@ th { .react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected { - background: var(--fill-default); + &.react-datepicker__day--today { + background: var(--fill-default); + color: var(--content-on-fill); + } + background: var(--fill-default) !important; color: var(--content-on-fill); } .react-datepicker__day--range-start, .react-datepicker__day--range-end, .react-datepicker__day--selected:hover { - background: var(--fill-hover); + background: var(--fill-default); + color: var(--content-on-fill); } .react-swipeable-view-container { height: 100%; } + +.grid-sticky-header::-webkit-scrollbar { + width: 0; + height: 0; +} +.grid-scroll-container::-webkit-scrollbar { + width: 0; + height: 0; +} diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index 74b8839b4a..f5258622be 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -240,7 +240,12 @@ "rename": "Rename", "helpCenter": "Help Center", "add": "Add", - "yes": "Yes" + "yes": "Yes", + "Done": "Done", + "Cancel": "Cancel", + "clear": "Clear", + "remove": "Remove", + "dontRemove": "Don't remove" }, "label": { "welcome": "Welcome!", @@ -605,7 +610,8 @@ "deleteFieldPromptMessage": "Are you sure? This property will be deleted", "newColumn": "New Column", "format": "Format", - "reminderOnDateTooltip": "This cell has a scheduled reminder" + "reminderOnDateTooltip": "This cell has a scheduled reminder", + "optionAlreadyExist": "Option already exists" }, "rowPage": { "newField": "Add a new field", @@ -683,7 +689,8 @@ "median": "Median", "min": "Min", "sum": "Sum" - } + }, + "removeSorting": "Would you like to remove sorting?" }, "document": { "menuName": "Document",