Add file move capabilities to docs editor file tree
Drag-and-drop tree nodes to move files/folders between directories (desktop), and right-click "Move to..." with searchable directory picker modal (desktop + mobile). Bunker Admin
This commit is contained in:
parent
c306e061ab
commit
d7ab8f0d99
@ -30,6 +30,7 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
FileOutlined,
|
FileOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
|
import type { UseDocsEditorReturn } from '@/hooks/useDocsEditor';
|
||||||
import { isImageFile } from '@/hooks/useDocsEditor';
|
import { isImageFile } from '@/hooks/useDocsEditor';
|
||||||
@ -52,6 +53,7 @@ import { AdPickerModal } from '@/components/media/AdPickerModal';
|
|||||||
import type { AdInsertResult } from '@/components/media/AdPickerModal';
|
import type { AdInsertResult } from '@/components/media/AdPickerModal';
|
||||||
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
|
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
|
||||||
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
|
import { WikiLinkPickerModal } from '@/components/docs/WikiLinkPickerModal';
|
||||||
|
import { MoveToModal } from '@/components/docs/MoveToModal';
|
||||||
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
|
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
|
||||||
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
|
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
|
||||||
import { YTextareaBinding } from '@/lib/y-textarea';
|
import { YTextareaBinding } from '@/lib/y-textarea';
|
||||||
@ -259,6 +261,8 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
|||||||
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
const [adPickerOpen, setAdPickerOpen] = useState(false);
|
||||||
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
const [pollInsertOpen, setPollInsertOpen] = useState(false);
|
||||||
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
|
const [wikiLinkPickerOpen, setWikiLinkPickerOpen] = useState(false);
|
||||||
|
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
|
||||||
|
const [moveSourcePath, setMoveSourcePath] = useState('');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
fileTree,
|
fileTree,
|
||||||
@ -287,6 +291,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
|||||||
onContentChange,
|
onContentChange,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleModalOk,
|
handleModalOk,
|
||||||
|
handleMoveFile,
|
||||||
handleNewFileRoot,
|
handleNewFileRoot,
|
||||||
handleNewFolderRoot,
|
handleNewFolderRoot,
|
||||||
refreshTree,
|
refreshTree,
|
||||||
@ -430,6 +435,7 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
|||||||
}
|
}
|
||||||
items.push(
|
items.push(
|
||||||
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
|
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
|
||||||
|
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
|
||||||
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
||||||
);
|
);
|
||||||
return items;
|
return items;
|
||||||
@ -910,6 +916,14 @@ export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEd
|
|||||||
setWikiLinkPickerOpen(false);
|
setWikiLinkPickerOpen(false);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MoveToModal
|
||||||
|
open={moveToModalOpen}
|
||||||
|
fileTree={fileTree}
|
||||||
|
sourcePath={moveSourcePath}
|
||||||
|
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
|
||||||
|
onClose={() => setMoveToModalOpen(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
154
admin/src/components/docs/MoveToModal.tsx
Normal file
154
admin/src/components/docs/MoveToModal.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Modal, Input, List, theme, Typography } from 'antd';
|
||||||
|
import { FolderOutlined, HomeOutlined } from '@ant-design/icons';
|
||||||
|
import type { FileNode } from '@/types/api';
|
||||||
|
|
||||||
|
interface DirEntry {
|
||||||
|
path: string;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDirs(nodes: FileNode[], exclude?: string, depth = 0): DirEntry[] {
|
||||||
|
const dirs: DirEntry[] = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node.isDirectory) continue;
|
||||||
|
if (exclude && (node.path === exclude || node.path.startsWith(exclude + '/'))) continue;
|
||||||
|
dirs.push({ path: node.path, depth });
|
||||||
|
if (node.children) dirs.push(...collectDirs(node.children, exclude, depth + 1));
|
||||||
|
}
|
||||||
|
return dirs;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MoveToModalProps {
|
||||||
|
open: boolean;
|
||||||
|
fileTree: FileNode[];
|
||||||
|
sourcePath: string;
|
||||||
|
onMove: (targetDir: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoveToModal({ open, fileTree, sourcePath, onMove, onClose }: MoveToModalProps) {
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
|
const currentParent = useMemo(() => {
|
||||||
|
const lastSlash = sourcePath.lastIndexOf('/');
|
||||||
|
return lastSlash >= 0 ? sourcePath.substring(0, lastSlash) : '';
|
||||||
|
}, [sourcePath]);
|
||||||
|
|
||||||
|
const isSourceDir = useMemo(() => {
|
||||||
|
function find(nodes: FileNode[]): boolean {
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.path === sourcePath) return n.isDirectory;
|
||||||
|
if (n.isDirectory && n.children && find(n.children)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return find(fileTree);
|
||||||
|
}, [fileTree, sourcePath]);
|
||||||
|
|
||||||
|
const allDirs = useMemo(
|
||||||
|
() => collectDirs(fileTree, isSourceDir ? sourcePath : undefined),
|
||||||
|
[fileTree, sourcePath, isSourceDir],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search.trim()) return allDirs;
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return allDirs.filter(d => d.path.toLowerCase().includes(q));
|
||||||
|
}, [allDirs, search]);
|
||||||
|
|
||||||
|
const handleSelect = (dir: string) => {
|
||||||
|
onMove(dir);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fileName = sourcePath.split('/').pop() || sourcePath;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Move "${fileName}"`}
|
||||||
|
open={open}
|
||||||
|
onCancel={handleClose}
|
||||||
|
footer={null}
|
||||||
|
destroyOnHidden
|
||||||
|
width={420}
|
||||||
|
>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Search directories..."
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
autoFocus
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ maxHeight: 360, overflow: 'auto' }}>
|
||||||
|
{/* Root directory option */}
|
||||||
|
{(!search.trim() || '/ (root)'.includes(search.toLowerCase())) && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: currentParent === '' ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: currentParent === '' ? 0.5 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onClick={() => currentParent !== '' && handleSelect('')}
|
||||||
|
onMouseEnter={e => { if (currentParent !== '') (e.currentTarget.style.background = token.colorBgTextHover); }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<HomeOutlined style={{ color: token.colorTextSecondary }} />
|
||||||
|
<span style={{ flex: 1 }}>/ (root)</span>
|
||||||
|
{currentParent === '' && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>current</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<List
|
||||||
|
size="small"
|
||||||
|
dataSource={filtered}
|
||||||
|
locale={{ emptyText: 'No matching directories' }}
|
||||||
|
renderItem={item => {
|
||||||
|
const isCurrent = item.path === currentParent;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
paddingLeft: 12 + item.depth * 16,
|
||||||
|
cursor: isCurrent ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isCurrent ? 0.5 : 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
transition: 'background 0.15s',
|
||||||
|
}}
|
||||||
|
onClick={() => !isCurrent && handleSelect(item.path)}
|
||||||
|
onMouseEnter={e => { if (!isCurrent) (e.currentTarget.style.background = token.colorBgTextHover); }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<FolderOutlined style={{ color: token.colorTextSecondary, flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{item.path}
|
||||||
|
</div>
|
||||||
|
{isCurrent && (
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 11, flexShrink: 0 }}>current</Typography.Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -121,6 +121,7 @@ export interface UseDocsEditorReturn {
|
|||||||
onContentChange: (value: string) => void;
|
onContentChange: (value: string) => void;
|
||||||
handleDelete: (filePath: string) => void;
|
handleDelete: (filePath: string) => void;
|
||||||
handleModalOk: () => Promise<void>;
|
handleModalOk: () => Promise<void>;
|
||||||
|
handleMoveFile: (sourcePath: string, targetDir: string) => Promise<void>;
|
||||||
handleNewFileRoot: () => void;
|
handleNewFileRoot: () => void;
|
||||||
handleNewFolderRoot: () => void;
|
handleNewFolderRoot: () => void;
|
||||||
refreshTree: () => void;
|
refreshTree: () => void;
|
||||||
@ -377,6 +378,45 @@ export function useDocsEditor(): UseDocsEditorReturn {
|
|||||||
setModalType(null);
|
setModalType(null);
|
||||||
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
|
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
|
||||||
|
|
||||||
|
const handleMoveFile = useCallback(async (sourcePath: string, targetDir: string) => {
|
||||||
|
const fileName = sourcePath.split('/').pop() || '';
|
||||||
|
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
|
||||||
|
if (newPath === sourcePath) return;
|
||||||
|
if (sourcePath === targetDir || targetDir.startsWith(sourcePath + '/')) {
|
||||||
|
messageApi.warning('Cannot move a folder into itself');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post('/docs/files/rename', { from: sourcePath, to: newPath });
|
||||||
|
messageApi.success(`Moved to ${targetDir || '/'}`);
|
||||||
|
invalidateTreeCache();
|
||||||
|
setFileContentCache(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
// Move exact match
|
||||||
|
const cached = next.get(sourcePath);
|
||||||
|
next.delete(sourcePath);
|
||||||
|
if (cached) next.set(newPath, cached);
|
||||||
|
// Move children (for directory moves)
|
||||||
|
const prefix = sourcePath + '/';
|
||||||
|
for (const [key, val] of Array.from(next.entries())) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
next.delete(key);
|
||||||
|
next.set(newPath + key.substring(sourcePath.length), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (selectedFile === sourcePath) {
|
||||||
|
setSelectedFile(newPath);
|
||||||
|
} else if (selectedFile && selectedFile.startsWith(sourcePath + '/')) {
|
||||||
|
setSelectedFile(newPath + selectedFile.substring(sourcePath.length));
|
||||||
|
}
|
||||||
|
fetchTree();
|
||||||
|
} catch {
|
||||||
|
messageApi.error('Failed to move');
|
||||||
|
}
|
||||||
|
}, [selectedFile, messageApi, fetchTree]);
|
||||||
|
|
||||||
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
||||||
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
||||||
|
|
||||||
@ -513,6 +553,7 @@ export function useDocsEditor(): UseDocsEditorReturn {
|
|||||||
onContentChange,
|
onContentChange,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleModalOk,
|
handleModalOk,
|
||||||
|
handleMoveFile,
|
||||||
handleNewFileRoot,
|
handleNewFileRoot,
|
||||||
handleNewFolderRoot,
|
handleNewFolderRoot,
|
||||||
refreshTree,
|
refreshTree,
|
||||||
|
|||||||
@ -63,6 +63,7 @@ import {
|
|||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import Editor from '@monaco-editor/react';
|
import Editor from '@monaco-editor/react';
|
||||||
import type { OnMount } from '@monaco-editor/react';
|
import type { OnMount } from '@monaco-editor/react';
|
||||||
@ -72,6 +73,7 @@ import { buildServiceUrl } from '@/lib/service-url';
|
|||||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||||
import { useDocsEditor } from '@/hooks/useDocsEditor';
|
import { useDocsEditor } from '@/hooks/useDocsEditor';
|
||||||
import { MobileDocsEditor } from '@/components/docs/MobileDocsEditor';
|
import { MobileDocsEditor } from '@/components/docs/MobileDocsEditor';
|
||||||
|
import { MoveToModal } from '@/components/docs/MoveToModal';
|
||||||
import type { FileNode, ServicesConfig } from '@/types/api';
|
import type { FileNode, ServicesConfig } from '@/types/api';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
import type { AppOutletContext } from '@/components/AppLayout';
|
||||||
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
|
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
|
||||||
@ -623,6 +625,9 @@ export default function DocsPage() {
|
|||||||
const [modalInput, setModalInput] = useState('');
|
const [modalInput, setModalInput] = useState('');
|
||||||
const [contextPath, setContextPath] = useState<string>('');
|
const [contextPath, setContextPath] = useState<string>('');
|
||||||
|
|
||||||
|
const [moveToModalOpen, setMoveToModalOpen] = useState(false);
|
||||||
|
const [moveSourcePath, setMoveSourcePath] = useState('');
|
||||||
|
|
||||||
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
||||||
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
|
const [photoPickerOpen, setPhotoPickerOpen] = useState(false);
|
||||||
const [photoInsertOpen, setPhotoInsertOpen] = useState(false);
|
const [photoInsertOpen, setPhotoInsertOpen] = useState(false);
|
||||||
@ -1412,6 +1417,7 @@ export default function DocsPage() {
|
|||||||
}
|
}
|
||||||
items.push(
|
items.push(
|
||||||
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
|
{ key: 'rename', icon: <EditOutlined />, label: 'Rename', onClick: () => { setContextPath(nodePath); setModalInput(nodePath.split('/').pop() || ''); setModalType('rename'); } },
|
||||||
|
{ key: 'moveTo', icon: <FolderOpenOutlined />, label: 'Move to...', onClick: () => { setMoveSourcePath(nodePath); setMoveToModalOpen(true); } },
|
||||||
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
{ key: 'delete', icon: <DeleteOutlined />, label: 'Delete', danger: true, onClick: () => handleDelete(nodePath) },
|
||||||
);
|
);
|
||||||
return items;
|
return items;
|
||||||
@ -1496,6 +1502,68 @@ export default function DocsPage() {
|
|||||||
setModalType(null);
|
setModalType(null);
|
||||||
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
|
}, [modalType, modalInput, contextPath, messageApi, fetchTree, loadFile, selectedFile]);
|
||||||
|
|
||||||
|
const handleMoveFile = useCallback(async (sourcePath: string, targetDir: string) => {
|
||||||
|
const fileName = sourcePath.split('/').pop() || '';
|
||||||
|
const newPath = targetDir ? `${targetDir}/${fileName}` : fileName;
|
||||||
|
if (newPath === sourcePath) return;
|
||||||
|
if (sourcePath === targetDir || targetDir.startsWith(sourcePath + '/')) {
|
||||||
|
messageApi.warning('Cannot move a folder into itself');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.post('/docs/files/rename', { from: sourcePath, to: newPath });
|
||||||
|
messageApi.success(`Moved to ${targetDir || '/'}`);
|
||||||
|
invalidateTreeCache();
|
||||||
|
setFileContentCache(prev => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
const cached = next.get(sourcePath);
|
||||||
|
next.delete(sourcePath);
|
||||||
|
if (cached) next.set(newPath, cached);
|
||||||
|
const prefix = sourcePath + '/';
|
||||||
|
for (const [key, val] of Array.from(next.entries())) {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
next.delete(key);
|
||||||
|
next.set(newPath + key.substring(sourcePath.length), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (selectedFile === sourcePath) {
|
||||||
|
setSelectedFile(newPath);
|
||||||
|
} else if (selectedFile && selectedFile.startsWith(sourcePath + '/')) {
|
||||||
|
setSelectedFile(newPath + selectedFile.substring(sourcePath.length));
|
||||||
|
}
|
||||||
|
fetchTree();
|
||||||
|
} catch {
|
||||||
|
messageApi.error('Failed to move');
|
||||||
|
}
|
||||||
|
}, [selectedFile, messageApi, fetchTree]);
|
||||||
|
|
||||||
|
const handleTreeNodeDrop = useCallback((info: {
|
||||||
|
node: TreeDataNode;
|
||||||
|
dragNode: TreeDataNode;
|
||||||
|
dropToGap: boolean;
|
||||||
|
}) => {
|
||||||
|
const dragPath = info.dragNode.key as string;
|
||||||
|
let targetDir: string;
|
||||||
|
|
||||||
|
if (info.dropToGap) {
|
||||||
|
const targetPath = info.node.key as string;
|
||||||
|
const lastSlash = targetPath.lastIndexOf('/');
|
||||||
|
targetDir = lastSlash >= 0 ? targetPath.substring(0, lastSlash) : '';
|
||||||
|
} else {
|
||||||
|
const targetPath = info.node.key as string;
|
||||||
|
if (info.node.isLeaf) {
|
||||||
|
const lastSlash = targetPath.lastIndexOf('/');
|
||||||
|
targetDir = lastSlash >= 0 ? targetPath.substring(0, lastSlash) : '';
|
||||||
|
} else {
|
||||||
|
targetDir = targetPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMoveFile(dragPath, targetDir);
|
||||||
|
}, [handleMoveFile]);
|
||||||
|
|
||||||
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
const handleNewFileRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFile'); }, []);
|
||||||
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
const handleNewFolderRoot = useCallback(() => { setContextPath(''); setModalInput(''); setModalType('newFolder'); }, []);
|
||||||
|
|
||||||
@ -1703,6 +1771,7 @@ export default function DocsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTreeDragOver = useCallback((e: React.DragEvent) => {
|
const handleTreeDragOver = useCallback((e: React.DragEvent) => {
|
||||||
|
if (!e.dataTransfer.types.includes('Files')) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
@ -1801,6 +1870,15 @@ export default function DocsPage() {
|
|||||||
.docs-tree .ant-tree-list-holder-inner {
|
.docs-tree .ant-tree-list-holder-inner {
|
||||||
padding: 2px 0 !important;
|
padding: 2px 0 !important;
|
||||||
}
|
}
|
||||||
|
.docs-tree .ant-tree-treenode.drag-over > .ant-tree-node-content-wrapper,
|
||||||
|
.docs-tree .ant-tree-treenode.drop-target:not(.drag-over-gap-top):not(.drag-over-gap-bottom) > .ant-tree-node-content-wrapper {
|
||||||
|
background: rgba(22, 119, 255, 0.15) !important;
|
||||||
|
outline: 1px solid rgba(22, 119, 255, 0.4);
|
||||||
|
border-radius: 3px !important;
|
||||||
|
}
|
||||||
|
.docs-tree .ant-tree-treenode.dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
.docs-filter .ant-input-affix-wrapper {
|
.docs-filter .ant-input-affix-wrapper {
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
@ -1960,6 +2038,15 @@ export default function DocsPage() {
|
|||||||
showIcon={false}
|
showIcon={false}
|
||||||
showLine={false}
|
showLine={false}
|
||||||
motion={false}
|
motion={false}
|
||||||
|
draggable={!filterQuery.trim() ? { icon: false, nodeDraggable: () => true } : false}
|
||||||
|
allowDrop={({ dragNode, dropNode }) => {
|
||||||
|
const dragPath = dragNode.key as string;
|
||||||
|
const dropPath = dropNode.key as string;
|
||||||
|
if (dragPath === dropPath) return false;
|
||||||
|
if (dropPath.startsWith(dragPath + '/')) return false;
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
onDrop={handleTreeNodeDrop}
|
||||||
selectedKeys={selectedFile ? [selectedFile] : []}
|
selectedKeys={selectedFile ? [selectedFile] : []}
|
||||||
expandedKeys={expandedKeys}
|
expandedKeys={expandedKeys}
|
||||||
onExpand={(keys) => setExpandedKeys(keys)}
|
onExpand={(keys) => setExpandedKeys(keys)}
|
||||||
@ -2434,6 +2521,14 @@ export default function DocsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MoveToModal
|
||||||
|
open={moveToModalOpen}
|
||||||
|
fileTree={fileTree}
|
||||||
|
sourcePath={moveSourcePath}
|
||||||
|
onMove={(targetDir) => { setMoveToModalOpen(false); handleMoveFile(moveSourcePath, targetDir); }}
|
||||||
|
onClose={() => setMoveToModalOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Custom right-click context menu with submenus */}
|
{/* Custom right-click context menu with submenus */}
|
||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user