From bd712f506fc680f4a8e9b57baf07d5f412075682 Mon Sep 17 00:00:00 2001 From: MasonDye Date: Tue, 1 Jul 2025 03:20:18 +0800 Subject: [PATCH] feat: list reordering with drag-and-drop and move buttons --- .../Admin/Settings/Filesystem/EmojiList.tsx | 249 ++++++++++++----- .../ViewerSetting/FileViewerEditDialog.tsx | 252 +++++++++++------- .../ViewerSetting/FileViewerList.tsx | 142 ++++++++-- .../ViewerSetting/FileViewerRow.tsx | 162 ++++++----- .../Explorer/ListView/ColumnSetting.tsx | 208 ++++++++------- 5 files changed, 667 insertions(+), 346 deletions(-) diff --git a/src/component/Admin/Settings/Filesystem/EmojiList.tsx b/src/component/Admin/Settings/Filesystem/EmojiList.tsx index 6a9b73d..c6c3f52 100644 --- a/src/component/Admin/Settings/Filesystem/EmojiList.tsx +++ b/src/component/Admin/Settings/Filesystem/EmojiList.tsx @@ -1,3 +1,4 @@ +import React from "react"; import { Box, IconButton, Stack, Table, TableBody, TableContainer, TableHead, TableRow } from "@mui/material"; import { memo, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -10,12 +11,145 @@ import { } from "../../../Common/StyledComponents.tsx"; import Add from "../../../Icons/Add.tsx"; import Dismiss from "../../../Icons/Dismiss.tsx"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; export interface EmojiListProps { config: string; onChange: (value: string) => void; } +const DND_TYPE = "emoji-row"; + +interface DraggableEmojiRowProps { + r: string; + i: number; + moveRow: (from: number, to: number) => void; + configParsed: { [key: string]: string[] }; + inputCache: { [key: number]: string | undefined }; + setInputCache: React.Dispatch>; + onChange: (value: string) => void; + isFirst: boolean; + isLast: boolean; + t: (key: string) => string; +} + +function DraggableEmojiRow({ + r, + i, + moveRow, + configParsed, + inputCache, + setInputCache, + onChange, + isFirst, + isLast, +}: DraggableEmojiRowProps) { + const ref = React.useRef(null); + const [, drop] = useDrop({ + accept: DND_TYPE, + hover(item: any, monitor) { + if (!ref.current) return; + + const dragIndex = item.index; + const hoverIndex = i; + if (dragIndex === hoverIndex) return; + + const hoverBoundingRect = ref.current.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return; + + moveRow(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + const [{ isDragging }, drag] = useDrag({ + type: DND_TYPE, + item: { index: i }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + drag(drop(ref)); + return ( + + + { + const newConfig = { + ...configParsed, + [e.target.value]: configParsed[r], + }; + delete newConfig[r]; + onChange(JSON.stringify(newConfig)); + }} + /> + + + { + onChange( + JSON.stringify({ + ...configParsed, + [r]: inputCache[i]?.split(",") ?? configParsed[r], + }), + ); + setInputCache({ + ...inputCache, + [i]: undefined, + }); + }} + onChange={(e) => + setInputCache({ + ...inputCache, + [i]: e.target.value, + }) + } + /> + + + { + const newConfig = { + ...configParsed, + }; + delete newConfig[r]; + onChange(JSON.stringify(newConfig)); + }} + size={"small"} + > + + + moveRow(i, i - 1)} disabled={isFirst}> + + + moveRow(i, i + 1)} disabled={isLast}> + + + + + ); +} + const EmojiList = memo(({ config, onChange }: EmojiListProps) => { const { t } = useTranslation("dashboard"); const [render, setRender] = useState(false); @@ -32,80 +166,49 @@ const EmojiList = memo(({ config, onChange }: EmojiListProps) => { )} {render && Object.keys(configParsed ?? {}).length > 0 && ( - - - - - {t("settings.category")} - {t("settings.emojiOptions")} - - - - - {Object.keys(configParsed ?? {}).map((r, i) => ( - - - { - const newConfig = { - ...configParsed, - [e.target.value]: configParsed[r], - }; - delete newConfig[r]; - onChange(JSON.stringify(newConfig)); - }} - /> - - - { - onChange( - JSON.stringify({ - ...configParsed, - [r]: inputCache[i]?.split(",") ?? configParsed[r], - }), - ); - setInputCache({ - ...inputCache, - [i]: undefined, - }); - }} - onChange={(e) => - setInputCache({ - ...inputCache, - [i]: e.target.value, - }) - } - /> - - - - { - const newConfig = { - ...configParsed, - }; - - delete newConfig[r]; - onChange(JSON.stringify(newConfig)); - }} - size={"small"} - > - - - + + +
+ + + {t("settings.category")} + {t("settings.emojiOptions")} + {t("settings.actions")} - ))} - -
-
+ + + {Object.keys(configParsed ?? {}).map((r, i, arr) => ( + { + if (from === to || to < 0 || to >= arr.length) return; + const keys = Object.keys(configParsed); + const values = Object.values(configParsed); + const [movedKey] = keys.splice(from, 1); + const [movedValue] = values.splice(from, 1); + keys.splice(to, 0, movedKey); + values.splice(to, 0, movedValue); + const newConfig: { [key: string]: string[] } = {}; + keys.forEach((k, idx) => { + newConfig[k] = values[idx]; + }); + onChange(JSON.stringify(newConfig)); + }} + configParsed={configParsed} + inputCache={inputCache} + setInputCache={setInputCache} + onChange={onChange} + isFirst={i === 0} + isLast={i === arr.length - 1} + t={t} + /> + ))} + + + + )} {render && ( diff --git a/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerEditDialog.tsx b/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerEditDialog.tsx index d6c31f7..c57192c 100644 --- a/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerEditDialog.tsx +++ b/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerEditDialog.tsx @@ -38,6 +38,11 @@ import Dismiss from "../../../../Icons/Dismiss.tsx"; import SettingForm from "../../../../Pages/Setting/SettingForm.tsx"; import MagicVarDialog, { MagicVar } from "../../../Common/MagicVarDialog.tsx"; import { NoMarginHelperText } from "../../Settings.tsx"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import { SelectChangeEvent } from "@mui/material"; const MonacoEditor = lazy(() => import("../../../../Viewers/CodeViewer/MonacoEditor.tsx")); @@ -86,6 +91,96 @@ const magicVars: MagicVar[] = [ }, ]; +const DND_TYPE = "template-row"; + +interface DraggableTemplateRowProps { + t: any; + i: number; + moveRow: (from: number, to: number) => void; + onExtChange: (e: SelectChangeEvent, child: React.ReactNode) => void; + onNameChange: (e: React.ChangeEvent) => void; + onDelete: () => void; + isFirst: boolean; + isLast: boolean; + extList: string[]; + template: any; +} + +function DraggableTemplateRow({ i, moveRow, onExtChange, onNameChange, onDelete, isFirst, isLast, extList, template }: DraggableTemplateRowProps) { + const ref = React.useRef(null); + const [, drop] = useDrop({ + accept: DND_TYPE, + hover(item: any, monitor) { + if (!ref.current) return; + + const dragIndex = item.index; + const hoverIndex = i; + if (dragIndex === hoverIndex) return; + + const hoverBoundingRect = ref.current.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return; + + moveRow(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + const [{ isDragging }, drag] = useDrag({ + type: DND_TYPE, + item: { index: i }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + drag(drop(ref)); + return ( + + + + {extList.map((ext) => ( + + {ext} + + ))} + + + + + + + + + + moveRow(i, i - 1)} disabled={isFirst}> + + + moveRow(i, i + 1)} disabled={isLast}> + + + + + ); +} + const FileViewerEditDialog = ({ viewer, onChange, open, onClose }: FileViewerEditDialogProps) => { const { t } = useTranslation("dashboard"); const theme = useTheme(); @@ -300,99 +395,72 @@ const FileViewerEditDialog = ({ viewer, onChange, open, onClose }: FileViewerEdi )} {viewerShadowed?.templates && viewerShadowed.templates.length > 0 && ( - - - - - {t("settings.ext")} - {t("settings.displayName")} - - - - - {viewerShadowed.templates?.map((t, i) => ( - - - { - const newExt = e.target.value as string; - setViewerShadowed((v) => ({ - ...(v as Viewer), - templates: (v?.templates ?? []).map((template, index) => - index == i ? { ...template, ext: newExt } : template, - ), - })); - }} - > - {viewerShadowed.exts.map((ext) => ( - - - {ext} - - - ))} - - - - { - setViewerShadowed((v) => ({ - ...(v as Viewer), - templates: (v?.templates ?? []).map((template, index) => - index == i - ? { - ...template, - display_name: e.target.value, - } - : template, - ), - })); - }} - /> - - - - setViewerShadowed((v) => ({ - ...(v as Viewer), - templates: (v?.templates ?? []).filter((_, index) => index != i), - })) - } - > - - - + + +
+ + + {t("settings.ext")} + {t("settings.displayName")} + {t("settings.actions")} - ))} - -
-
+ + + {viewerShadowed.templates?.map((template, i) => ( + { + if (from === to || to < 0 || to >= (viewerShadowed.templates?.length ?? 0)) return; + setViewerShadowed((v) => { + const arr = [...(v?.templates ?? [])]; + const [moved] = arr.splice(from, 1); + arr.splice(to, 0, moved); + return { ...(v as Viewer), templates: arr }; + }); + }} + onExtChange={(e) => { + const newExt = e.target.value as string; + setViewerShadowed((v) => ({ + ...(v as Viewer), + templates: (v?.templates ?? []).map((template, index) => + index == i ? { ...template, ext: newExt } : template, + ), + })); + }} + onNameChange={(e) => { + setViewerShadowed((v) => ({ + ...(v as Viewer), + templates: (v?.templates ?? []).map((template, index) => + index == i ? { ...template, display_name: e.target.value } : template, + ), + })); + }} + onDelete={() => { + setViewerShadowed((v) => ({ + ...(v as Viewer), + templates: (v?.templates ?? []).filter((_, index) => index != i), + })); + }} + isFirst={i === 0} + isLast={i === (viewerShadowed.templates?.length ?? 0) - 1} + extList={viewerShadowed.exts} + template={template} + /> + ))} + + + + )} void; } +const DND_TYPE = "viewer-row"; + +const DraggableViewerRow = memo(function DraggableViewerRow({ + viewer, + index, + moveRow, + onChange, + onDelete, + onMoveUp, + onMoveDown, + isLast, + isFirst, +}: any) { + const ref = React.useRef(null); + const [, drop] = useDrop({ + accept: DND_TYPE, + hover(item: any, monitor) { + if (!ref.current) return; + + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) return; + + const hoverBoundingRect = ref.current.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return; + + moveRow(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + const [{ isDragging }, drag] = useDrag({ + type: DND_TYPE, + item: { index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + drag(drop(ref)); + return ( + + ); +}); + const ViewerGroupRow = memo(({ group, index, onDelete, onGroupChange }: ViewerGroupProps) => { const { t } = useTranslation("dashboard"); @@ -56,6 +118,27 @@ const ViewerGroupRow = memo(({ group, index, onDelete, onGroupChange }: ViewerGr }); }, [group.viewers]); + const [viewers, setViewers] = useState(group.viewers); + React.useEffect(() => { + setViewers(group.viewers); + }, [group.viewers]); + const moveRow = useCallback((from: number, to: number) => { + if (from === to) return; + const updated = [...viewers]; + const [moved] = updated.splice(from, 1); + updated.splice(to, 0, moved); + setViewers(updated); + onGroupChange({ viewers: updated }); + }, [viewers, onGroupChange]); + const handleMoveUp = (idx: number) => { + if (idx <= 0) return; + moveRow(idx, idx - 1); + }; + const handleMoveDown = (idx: number) => { + if (idx >= viewers.length - 1) return; + moveRow(idx, idx + 1); + }; + return ( - - - - - {t("settings.icon")} - {t("settings.viewerType")} - {t("settings.displayName")} - {t("settings.exts")} - {t("settings.newFileAction")} - {t("settings.viewerEnabled")} - - - - - {group.viewers.map((viewer, index) => ( - - ))} - -
-
+ + + + + + {t("settings.icon")} + {t("settings.viewerType")} + {t("settings.displayName")} + {t("settings.exts")} + {t("settings.newFileAction")} + {t("settings.viewerEnabled")} + {t("settings.actions")} + + + + + {viewers.map((viewer, idx) => ( + handleMoveUp(idx)} + onMoveDown={() => handleMoveDown(idx)} + isFirst={idx === 0} + isLast={idx === viewers.length - 1} + /> + ))} + +
+
+
); diff --git a/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerRow.tsx b/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerRow.tsx index 80726b8..13d18a2 100644 --- a/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerRow.tsx +++ b/src/component/Admin/Settings/Filesystem/ViewerSetting/FileViewerRow.tsx @@ -8,75 +8,111 @@ import { ViewerIcon } from "../../../../FileManager/Dialogs/OpenWith.tsx"; import Dismiss from "../../../../Icons/Dismiss.tsx"; import Edit from "../../../../Icons/Edit.tsx"; import FileViewerEditDialog from "./FileViewerEditDialog.tsx"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; export interface FileViewerRowProps { viewer: Viewer; onChange: (viewer: Viewer) => void; onDelete: (e: React.MouseEvent) => void; + onMoveUp?: () => void; + onMoveDown?: () => void; + isFirst?: boolean; + isLast?: boolean; + style?: React.CSSProperties; } -const FileViewerRow = memo(({ viewer, onChange, onDelete }: FileViewerRowProps) => { - const { t } = useTranslation("dashboard"); - const [extCached, setExtCached] = useState(""); - const [editOpen, setEditOpen] = useState(false); - const onClose = useCallback(() => { - setEditOpen(false); - }, [setEditOpen]); - return ( - - - - - - {t(`settings.${viewer.type}ViewerType`)} - - {t(viewer.display_name, { - ns: "application", - })} - - - { - onChange({ - ...viewer, - exts: extCached == "" ? viewer.exts : extCached?.split(",")?.map((ext) => ext.trim()), - }); - setExtCached(""); - }} - onChange={(e) => setExtCached(e.target.value)} - /> - - - {viewer.templates?.length ? t("settings.nMapping", { num: viewer.templates?.length }) : t("share.none")} - - - - onChange({ - ...viewer, - disabled: !e.target.checked, - }) - } - /> - - - setEditOpen(true)}> - - - {viewer.type != ViewerType.builtin && ( - - - - )} - - - ); -}); +const FileViewerRow = React.memo( + React.forwardRef( + ( + { + viewer, + onChange, + onDelete, + onMoveUp, + onMoveDown, + isFirst, + isLast, + style, + }, + ref + ) => { + const { t } = useTranslation("dashboard"); + const [extCached, setExtCached] = useState(""); + const [editOpen, setEditOpen] = useState(false); + const onClose = useCallback(() => { + setEditOpen(false); + }, [setEditOpen]); + return ( + + + + + + {t(`settings.${viewer.type}ViewerType`)} + + {t(viewer.display_name, { + ns: "application", + })} + + + { + onChange({ + ...viewer, + exts: extCached == "" ? viewer.exts : extCached?.split(",")?.map((ext) => ext.trim()), + }); + setExtCached(""); + }} + onChange={(e) => setExtCached(e.target.value)} + /> + + + {viewer.templates?.length ? t("settings.nMapping", { num: viewer.templates?.length }) : t("share.none")} + + + + onChange({ + ...viewer, + disabled: !e.target.checked, + }) + } + /> + + + setEditOpen(true)}> + + + {viewer.type != ViewerType.builtin && ( + + + + )} + + + + + + + + + + + ); + } + ) +); export default FileViewerRow; diff --git a/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx b/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx index 65a9a2d..b98a3bc 100644 --- a/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx +++ b/src/component/FileManager/Explorer/ListView/ColumnSetting.tsx @@ -19,11 +19,90 @@ import AutoHeight from "../../../Common/AutoHeight.tsx"; import { DefaultCloseAction } from "../../../Common/Snackbar/snackbar.tsx"; import { StyledTableContainerPaper } from "../../../Common/StyledComponents.tsx"; import DraggableDialog from "../../../Dialogs/DraggableDialog.tsx"; -import ArrowDown from "../../../Icons/ArrowDown.tsx"; import Dismiss from "../../../Icons/Dismiss.tsx"; import { FileManagerIndex } from "../../FileManager.tsx"; import AddColumn from "./AddColumn.tsx"; import { getColumnTypeDefaults, ListViewColumnSetting } from "./Column.tsx"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { DndProvider, useDrag, useDrop } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; +import React from "react"; +import type { Dispatch, SetStateAction } from "react"; + +const DND_TYPE = "column-row"; + +interface DraggableColumnRowProps { + column: ListViewColumnSetting; + index: number; + moveRow: (from: number, to: number) => void; + columns: ListViewColumnSetting[]; + setColumns: Dispatch>; + t: (key: string) => string; + onDelete: (idx: number) => void; + isFirst: boolean; + isLast: boolean; +} + +const DraggableColumnRow: React.FC = ({ column, index, moveRow, columns, t, onDelete, isFirst, isLast }) => { + const ref = React.useRef(null); + const [, drop] = useDrop({ + accept: DND_TYPE, + hover(item: any, monitor) { + if (!ref.current) return; + + const dragIndex = item.index; + const hoverIndex = index; + if (dragIndex === hoverIndex) return; + + const hoverBoundingRect = ref.current.getBoundingClientRect(); + const hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2; + const clientOffset = monitor.getClientOffset(); + if (!clientOffset) return; + + const hoverClientY = clientOffset.y - hoverBoundingRect.top; + + if (dragIndex < hoverIndex && hoverClientY < hoverMiddleY) return; + if (dragIndex > hoverIndex && hoverClientY > hoverMiddleY) return; + + moveRow(dragIndex, hoverIndex); + item.index = hoverIndex; + }, + }); + const [{ isDragging }, drag] = useDrag({ + type: DND_TYPE, + item: { index }, + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + }); + drag(drop(ref)); + return ( + + + {t(getColumnTypeDefaults(column).title)} + + + + moveRow(index, index - 1)} disabled={isFirst}> + + + moveRow(index, index + 1)} disabled={isLast}> + + + onDelete(index)} disabled={columns.length <= 1}> + + + + + + ); +}; const ColumnSetting = () => { const { t } = useTranslation(); @@ -82,99 +161,42 @@ const ColumnSetting = () => { > - - - - - {t("fileManager.column")} - {t("fileManager.actions")} - - - - {columns.map((column, index) => ( - - - {t(getColumnTypeDefaults(column).title)} - - - - {index > 0 && columns.length > 1 ? ( - { - setColumns((prev) => { - const newColumns = [...prev]; - const temp = newColumns[index]; - newColumns[index] = newColumns[index - 1]; - newColumns[index - 1] = temp; - return newColumns; - }); - }} - > - - - ) : ( - - )} - {index < columns.length - 1 && columns.length > 1 ? ( - { - setColumns((prev) => { - const newColumns = [...prev]; - const temp = newColumns[index]; - newColumns[index] = newColumns[index + 1]; - newColumns[index + 1] = temp; - return newColumns; - }); - }} - > - - - ) : ( - - )} - {columns.length > 1 ? ( - { - setColumns((prev) => { - return prev.filter((_, i) => i !== index); - }); - }} - > - - - ) : ( - - )} - - + + +
+ + + {t("fileManager.column")} + {t("fileManager.actions")} - ))} - -
-
+ + + {columns.map((column, index) => ( + { + if (from === to || to < 0 || to >= columns.length) return; + setColumns((prev) => { + const arr = [...prev]; + const [moved] = arr.splice(from, 1); + arr.splice(to, 0, moved); + return arr; + }); + }} + columns={columns} + setColumns={setColumns} + t={t} + onDelete={(idx) => setColumns((prev) => prev.filter((_, i) => i !== idx))} + isFirst={index === 0} + isLast={index === columns.length - 1} + /> + ))} + + + +