0.1.04 文件树与导入mmap
This commit is contained in:
@@ -1,341 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||||
import type { DocumentNode } from "@/lib/documents";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { getFileTreeRowLabel } from "@/lib/file-tree/types";
|
||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||
|
||||
interface FileTreeProps {
|
||||
nodes: DocumentNode[];
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
expanded: Set<string>;
|
||||
rows: FileTreeRow[];
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onOpenDocument: (id: string) => void;
|
||||
onOpenAsset: (asset: MediaAsset) => void;
|
||||
selectedRowIds: Set<string>;
|
||||
onRowClick: (row: FileTreeRow, event: React.MouseEvent) => void;
|
||||
onRowDoubleClick: (row: FileTreeRow, event: React.MouseEvent) => void;
|
||||
onRowContextMenu: (row: FileTreeRow, event: React.MouseEvent) => void;
|
||||
onRowDragStart?: (row: FileTreeRow, event: React.DragEvent) => void;
|
||||
onToggleExpand: (docId: string) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||||
selectedAssetIds?: Set<string>;
|
||||
onToggleAssetSelect?: (assetId: string) => void;
|
||||
onSelectOnlyAsset?: (assetId: string) => void;
|
||||
disableSelection?: boolean;
|
||||
onBlankMouseDown?: (event: React.MouseEvent) => void;
|
||||
onDropFiles?: (docId: string, files: FileList) => void;
|
||||
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
||||
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
|
||||
onInternalDrop?: (args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => void;
|
||||
}
|
||||
|
||||
const INDENT = 16;
|
||||
|
||||
export function FileTree({
|
||||
nodes,
|
||||
assetsByDoc,
|
||||
expanded,
|
||||
rows,
|
||||
activeId,
|
||||
selectedRowIds,
|
||||
onRowClick,
|
||||
onRowDoubleClick,
|
||||
onRowContextMenu,
|
||||
onRowDragStart,
|
||||
onToggleExpand,
|
||||
onOpenDocument,
|
||||
onOpenAsset,
|
||||
onCreateChild,
|
||||
onContextMenu,
|
||||
selectedAssetIds = new Set<string>(),
|
||||
onToggleAssetSelect,
|
||||
onSelectOnlyAsset,
|
||||
disableSelection = false,
|
||||
onBlankMouseDown,
|
||||
onDropFiles,
|
||||
onAssetContextMenu,
|
||||
onAssetClick,
|
||||
onInternalDrop,
|
||||
}: FileTreeProps) {
|
||||
if (nodes.length === 0) {
|
||||
if (rows.length === 0) {
|
||||
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="divide-y divide-[#f4f4f5]"
|
||||
onDragOver={(e) => {
|
||||
if (onDropFiles) {
|
||||
e.preventDefault();
|
||||
}
|
||||
className="space-y-0.5"
|
||||
onDragOver={(event) => {
|
||||
if (onDropFiles) event.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (onDropFiles && e.dataTransfer.files?.length) {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer.files;
|
||||
const targetDoc = nodes[0]?.id ?? "";
|
||||
onDropFiles(targetDoc, files);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{nodes.map((node) => (
|
||||
<FileTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
assetsByDoc={assetsByDoc}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onOpenDocument={onOpenDocument}
|
||||
onOpenAsset={onOpenAsset}
|
||||
onCreateChild={onCreateChild}
|
||||
onContextMenu={onContextMenu}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
onToggleAssetSelect={onToggleAssetSelect}
|
||||
onSelectOnlyAsset={onSelectOnlyAsset}
|
||||
disableSelection={disableSelection}
|
||||
onDropFiles={onDropFiles}
|
||||
onAssetContextMenu={onAssetContextMenu}
|
||||
onAssetClick={onAssetClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileTreeNodeProps {
|
||||
node: DocumentNode;
|
||||
depth: number;
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
expanded: Set<string>;
|
||||
activeId: string;
|
||||
onToggleExpand: (id: string) => void;
|
||||
onOpenDocument: (id: string) => void;
|
||||
onOpenAsset: (asset: MediaAsset) => void;
|
||||
onCreateChild: (parentId: string | null) => void;
|
||||
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||||
selectedAssetIds: Set<string>;
|
||||
onToggleAssetSelect?: (assetId: string) => void;
|
||||
onSelectOnlyAsset?: (assetId: string) => void;
|
||||
disableSelection: boolean;
|
||||
onDropFiles?: (docId: string, files: FileList) => void;
|
||||
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
||||
onAssetClick?: (asset: MediaAsset, event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
node,
|
||||
depth,
|
||||
assetsByDoc,
|
||||
expanded,
|
||||
activeId,
|
||||
onToggleExpand,
|
||||
onOpenDocument,
|
||||
onOpenAsset,
|
||||
onCreateChild,
|
||||
onContextMenu,
|
||||
selectedAssetIds,
|
||||
onToggleAssetSelect,
|
||||
onSelectOnlyAsset,
|
||||
disableSelection,
|
||||
onDropFiles,
|
||||
onAssetContextMenu,
|
||||
onAssetClick,
|
||||
}: FileTreeNodeProps) {
|
||||
const isExpanded = expanded.has(node.id);
|
||||
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
|
||||
const hasChildren = node.children.length > 0 || assets.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="py-0.5"
|
||||
onDragOver={(e) => {
|
||||
if (onDropFiles) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (onDropFiles && e.dataTransfer.files?.length) {
|
||||
e.preventDefault();
|
||||
onDropFiles(node.id, e.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]",
|
||||
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
)}
|
||||
style={{ paddingLeft: depth * INDENT + 8 }}
|
||||
onContextMenu={(event) => onContextMenu(event, node)}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
onClick={() => onToggleExpand(node.id)}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2 text-left"
|
||||
onClick={() => onOpenDocument(node.id)}
|
||||
>
|
||||
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||||
<span className="truncate">{node.title || "无标题"}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建子页面"
|
||||
onClick={() => onCreateChild(node.id)}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-0.5">
|
||||
<FileLeafRow
|
||||
depth={depth + 1}
|
||||
label="index.md"
|
||||
icon={<FileText className="h-4 w-4 text-gray-500" />}
|
||||
onClick={() => onOpenDocument(node.id)}
|
||||
stopBubble
|
||||
/>
|
||||
{assets.map((asset) => (
|
||||
<FileLeafRow
|
||||
key={asset.id}
|
||||
depth={depth + 1}
|
||||
label={asset.file_name || "附件"}
|
||||
icon={<Paperclip className="h-4 w-4 text-gray-500" />}
|
||||
selected={selectedAssetIds.has(asset.id)}
|
||||
selectable={!disableSelection}
|
||||
onSelectToggle={
|
||||
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
|
||||
}
|
||||
onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(asset.id) : undefined}
|
||||
onClick={(e) => {
|
||||
if (onAssetClick) {
|
||||
onAssetClick(asset, e);
|
||||
} else {
|
||||
onOpenAsset(asset);
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => onOpenAsset(asset)}
|
||||
stopBubble
|
||||
onContextMenu={
|
||||
onAssetContextMenu
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onAssetClick) {
|
||||
onAssetClick(asset, e);
|
||||
}
|
||||
onAssetContextMenu(e, asset);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{node.children.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
assetsByDoc={assetsByDoc}
|
||||
expanded={expanded}
|
||||
activeId={activeId}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onOpenDocument={onOpenDocument}
|
||||
onOpenAsset={onOpenAsset}
|
||||
onCreateChild={onCreateChild}
|
||||
onContextMenu={onContextMenu}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
onToggleAssetSelect={onToggleAssetSelect}
|
||||
onSelectOnlyAsset={onSelectOnlyAsset}
|
||||
disableSelection={disableSelection}
|
||||
onDropFiles={onDropFiles}
|
||||
onAssetContextMenu={onAssetContextMenu}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileLeafRow({
|
||||
depth,
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
selected = false,
|
||||
selectable = false,
|
||||
onSelectToggle,
|
||||
onSelectOnly,
|
||||
stopBubble = false,
|
||||
onContextMenu,
|
||||
}: {
|
||||
depth: number;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
selected?: boolean;
|
||||
selectable?: boolean;
|
||||
onSelectToggle?: () => void;
|
||||
onSelectOnly?: () => void;
|
||||
stopBubble?: boolean;
|
||||
onContextMenu?: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]",
|
||||
selected && "bg-[#e8f2ff] text-[#2563eb]",
|
||||
)}
|
||||
style={{ paddingLeft: depth * INDENT + 32 }}
|
||||
onClick={(e) => {
|
||||
if (stopBubble) e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
onDoubleClick={(e) => {
|
||||
if (stopBubble) e.stopPropagation();
|
||||
onDoubleClick?.();
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (stopBubble) e.stopPropagation();
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onContextMenu={(e) => {
|
||||
if (stopBubble) e.stopPropagation();
|
||||
if (onContextMenu) onContextMenu(e);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
onDrop={(event) => {
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
onClick();
|
||||
const files = event.dataTransfer.files;
|
||||
const firstDocRow = rows.find((row) => row.kind === "doc");
|
||||
const targetDocId = firstDocRow?.docId ?? "";
|
||||
onDropFiles(targetDocId, files);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onBlankMouseDown?.(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
if (onSelectToggle) onSelectToggle();
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-[#2563eb]"
|
||||
/>
|
||||
) : (
|
||||
<span className="h-4 w-4" />
|
||||
)}
|
||||
{icon}
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
{rows.map((row) => {
|
||||
const label = getFileTreeRowLabel(row);
|
||||
const selected = selectedRowIds.has(row.rowId);
|
||||
const active = row.kind !== "asset" && row.docId === activeId;
|
||||
const draggable =
|
||||
row.kind === "doc" || (row.kind === "asset" && isRealFileAsset(row.asset));
|
||||
const baseClass =
|
||||
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]";
|
||||
const activeClass =
|
||||
row.kind === "index"
|
||||
? "text-[#2563eb] font-medium"
|
||||
: row.kind === "doc"
|
||||
? "text-[#2563eb]"
|
||||
: "";
|
||||
const paddingLeft =
|
||||
row.kind === "doc" ? row.depth * INDENT + 8 : row.depth * INDENT + 32;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.rowId}
|
||||
className={cn(baseClass, active && activeClass, selected && "bg-[#e8f2ff] text-[#2563eb]")}
|
||||
style={{ paddingLeft }}
|
||||
onClick={(event) => onRowClick(row, event)}
|
||||
onDoubleClick={(event) => onRowDoubleClick(row, event)}
|
||||
onContextMenu={(event) => onRowContextMenu(row, event)}
|
||||
draggable={draggable}
|
||||
onDragStart={(event) => {
|
||||
if (!draggable) return;
|
||||
if (!selectedRowIds.has(row.rowId)) {
|
||||
onRowDragStart?.(row, event);
|
||||
}
|
||||
const rowIds = selectedRowIds.has(row.rowId) ? Array.from(selectedRowIds) : [row.rowId];
|
||||
const payload = JSON.stringify({ type: "mnote-file-tree-dnd", version: 1, rowIds });
|
||||
try {
|
||||
event.dataTransfer.setData("application/x-mnote-file-tree", payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
event.dataTransfer.setData("text/plain", payload);
|
||||
event.dataTransfer.effectAllowed = event.altKey ? "copyMove" : "move";
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const hasInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (hasInternal && onInternalDrop) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = event.altKey ? "copy" : "move";
|
||||
return;
|
||||
}
|
||||
if (onDropFiles) event.preventDefault();
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
const types = Array.from(event.dataTransfer.types ?? []);
|
||||
const isInternal = types.includes("application/x-mnote-file-tree");
|
||||
if (isInternal && onInternalDrop) {
|
||||
const raw =
|
||||
event.dataTransfer.getData("application/x-mnote-file-tree") || "";
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { type?: string; version?: number; rowIds?: unknown };
|
||||
if (parsed?.type === "mnote-file-tree-dnd" && parsed.version === 1 && Array.isArray(parsed.rowIds)) {
|
||||
event.preventDefault();
|
||||
onInternalDrop({
|
||||
targetRow: row,
|
||||
rowIds: parsed.rowIds.filter((id) => typeof id === "string") as string[],
|
||||
copy: event.altKey,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (onDropFiles && event.dataTransfer.files?.length) {
|
||||
event.preventDefault();
|
||||
onDropFiles(row.docId, event.dataTransfer.files);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{row.kind === "doc" ? (
|
||||
<>
|
||||
{row.hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="展开或折叠"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleExpand(row.docId);
|
||||
}}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform",
|
||||
row.isExpanded && "rotate-90",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="h-5 w-5" />
|
||||
)}
|
||||
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建子页面"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCreateChild(row.docId);
|
||||
}}
|
||||
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : row.kind === "index" ? (
|
||||
<>
|
||||
<span className="h-4 w-4" />
|
||||
<FileText className="h-4 w-4 text-gray-500" />
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="h-4 w-4" />
|
||||
<Paperclip className="h-4 w-4 text-gray-500" />
|
||||
<span className="flex-1 truncate text-left">{label}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,20 @@ import { buildSidebarSectionsFromTree, flattenDocumentTree } from "@/lib/sidebar
|
||||
import { supabaseBrowser } from "@/lib/supabase/client";
|
||||
import { useSearchPaletteStore } from "@/store/search-palette";
|
||||
import { FileTree } from "@/components/sidebar/file-tree";
|
||||
import { buildVisibleRows } from "@/lib/file-tree/rows";
|
||||
import type { FileTreeSelectionState } from "@/lib/file-tree/selection";
|
||||
import { reduceFileTreeSelection } from "@/lib/file-tree/selection";
|
||||
import type { FileTreeRow } from "@/lib/file-tree/types";
|
||||
import { parseFileTreeRowId } from "@/lib/file-tree/types";
|
||||
import { buildParentById, filterTopLevelDocIds, inferDropTargetDocId, isInvalidDocDrop } from "@/lib/file-tree/dnd";
|
||||
import { isRealFileAsset } from "@/lib/file-tree/asset";
|
||||
import { computeFileTreeDeleteTargets } from "@/lib/file-tree/delete";
|
||||
import {
|
||||
inferPasteTargetDocId,
|
||||
isTextInputTarget,
|
||||
readFileTreeClipboardPayload,
|
||||
writeFileTreeClipboardPayload,
|
||||
} from "@/lib/file-tree/clipboard";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import { AssetContextMenu } from "@/components/sidebar/asset-context-menu";
|
||||
import { ASSETS_CHANGED_EVENT, DOCUMENTS_CHANGED_EVENT, emitAssetsChanged, emitDocumentsChanged } from "@/lib/events";
|
||||
@@ -100,10 +114,14 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
const [assetMenu, setAssetMenu] = useState<{ asset: MediaAsset; x: number; y: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<Set<string>>(new Set());
|
||||
const [lastSelectedAssetId, setLastSelectedAssetId] = useState<string | null>(null);
|
||||
const [fileTreeSelection, setFileTreeSelection] = useState<FileTreeSelectionState>(() => ({
|
||||
selectedRowIds: new Set(),
|
||||
anchorRowId: null,
|
||||
focusedRowId: null,
|
||||
}));
|
||||
|
||||
const workspaceMenuRef = useRef<HTMLDivElement>(null);
|
||||
const fileTreeContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setTree(() => {
|
||||
@@ -122,8 +140,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}, [sidebarData.mindmapDocs]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedAssetIds(new Set());
|
||||
setLastSelectedAssetId(null);
|
||||
setFileTreeSelection({ selectedRowIds: new Set(), anchorRowId: null, focusedRowId: null });
|
||||
}, [mediaAssets, sidebarData.documents]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,19 +282,49 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
return map;
|
||||
}, [sidebarData.documents, mediaAssets, mindmapDocs]);
|
||||
|
||||
const assetOrder = useMemo(() => {
|
||||
const list: string[] = [];
|
||||
Object.values(assetsByDoc).forEach((arr) => {
|
||||
arr.forEach((asset) => list.push(asset.id));
|
||||
});
|
||||
return list;
|
||||
}, [assetsByDoc]);
|
||||
const fileTreeRows = useMemo(
|
||||
() =>
|
||||
buildVisibleRows({
|
||||
nodes: filteredPrivateTree,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
}),
|
||||
[assetsByDoc, expanded, filteredPrivateTree],
|
||||
);
|
||||
|
||||
const assetIndexMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
assetOrder.forEach((id, idx) => map.set(id, idx));
|
||||
const fileTreeVisibleRowIds = useMemo(() => fileTreeRows.map((row) => row.rowId), [fileTreeRows]);
|
||||
const fileTreeRowById = useMemo(() => new Map(fileTreeRows.map((row) => [row.rowId, row])), [fileTreeRows]);
|
||||
|
||||
const docParentById = useMemo(
|
||||
() =>
|
||||
buildParentById(
|
||||
(sidebarData.documents ?? []).map((doc) => ({
|
||||
id: doc.id,
|
||||
parentId: doc.parent_id ?? null,
|
||||
})),
|
||||
),
|
||||
[sidebarData.documents],
|
||||
);
|
||||
|
||||
const childrenCountByParentId = useMemo(() => {
|
||||
const map = new Map<string | null, number>();
|
||||
(sidebarData.documents ?? []).forEach((doc) => {
|
||||
const parentId = doc.parent_id ?? null;
|
||||
map.set(parentId, (map.get(parentId) ?? 0) + 1);
|
||||
});
|
||||
return map;
|
||||
}, [assetOrder]);
|
||||
}, [sidebarData.documents]);
|
||||
|
||||
const selectedAssetIdsForMenu = useMemo(() => {
|
||||
const ids: string[] = [];
|
||||
fileTreeSelection.selectedRowIds.forEach((rowId) => {
|
||||
const parsed = parseFileTreeRowId(rowId);
|
||||
if (parsed?.kind === "asset") {
|
||||
ids.push(parsed.assetId);
|
||||
}
|
||||
});
|
||||
return ids;
|
||||
}, [fileTreeSelection.selectedRowIds]);
|
||||
|
||||
const activeWorkspace =
|
||||
sidebarData.workspaces.find((workspace) => workspace.id === sidebarData.activeWorkspaceId) ??
|
||||
@@ -285,8 +332,6 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const handleOpenDocument = useCallback(
|
||||
(documentId: string, mode: "main" | "sidebar") => {
|
||||
setSelectedAssetIds(new Set());
|
||||
setLastSelectedAssetId(null);
|
||||
const targetPath = `/documents/${documentId}`;
|
||||
if (mode === "main") {
|
||||
router.push(targetPath);
|
||||
@@ -380,67 +425,204 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
}
|
||||
}, [router, setOpen]);
|
||||
|
||||
const handleAssetContextMenu = useCallback(
|
||||
(event: React.MouseEvent, asset: MediaAsset) => {
|
||||
event.preventDefault();
|
||||
if (!selectedAssetIds.has(asset.id)) {
|
||||
setSelectedAssetIds(new Set([asset.id]));
|
||||
setLastSelectedAssetId(asset.id);
|
||||
}
|
||||
setAssetMenu({ asset, x: event.clientX, y: event.clientY });
|
||||
const handleFileTreeBlankMouseDown = useCallback(() => {
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
}, []);
|
||||
|
||||
const handleFileTreeRowClick = useCallback(
|
||||
(row: FileTreeRow, event: React.MouseEvent) => {
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, {
|
||||
type: "click",
|
||||
rowId: row.rowId,
|
||||
visibleRowIds: fileTreeVisibleRowIds,
|
||||
modifiers: {
|
||||
shiftKey: event.shiftKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[selectedAssetIds],
|
||||
[fileTreeVisibleRowIds],
|
||||
);
|
||||
|
||||
const handleSelectAsset = useCallback(
|
||||
(asset: MediaAsset, event?: { type?: string; shiftKey?: boolean; metaKey?: boolean; ctrlKey?: boolean }) => {
|
||||
const evtType = event?.type ?? "";
|
||||
if (evtType === "contextmenu" && selectedAssetIds.has(asset.id)) {
|
||||
setLastSelectedAssetId(asset.id);
|
||||
const handleFileTreeRowDragStart = useCallback((row: FileTreeRow) => {
|
||||
setFileTreeSelection((prev) => {
|
||||
if (prev.selectedRowIds.has(row.rowId)) return prev;
|
||||
return reduceFileTreeSelection(prev, {
|
||||
type: "click",
|
||||
rowId: row.rowId,
|
||||
visibleRowIds: fileTreeVisibleRowIds,
|
||||
modifiers: { shiftKey: false, ctrlKey: false, metaKey: false },
|
||||
});
|
||||
});
|
||||
}, [fileTreeVisibleRowIds]);
|
||||
|
||||
const handleFileTreeRowDoubleClick = useCallback(
|
||||
(row: FileTreeRow, _event?: React.MouseEvent) => {
|
||||
if (row.kind === "asset") {
|
||||
handleOpenAsset(row.asset);
|
||||
return;
|
||||
}
|
||||
setSelectedAssetIds((prev) => {
|
||||
let next = new Set(prev);
|
||||
const withShift = Boolean(event?.shiftKey) && lastSelectedAssetId && assetIndexMap.has(lastSelectedAssetId);
|
||||
if (withShift) {
|
||||
const start = assetIndexMap.get(lastSelectedAssetId!) ?? 0;
|
||||
const end = assetIndexMap.get(asset.id) ?? start;
|
||||
const [lo, hi] = start < end ? [start, end] : [end, start];
|
||||
const idsInRange = assetOrder.slice(lo, hi + 1);
|
||||
next = new Set([...prev, ...idsInRange]);
|
||||
} else if (event?.metaKey || event?.ctrlKey) {
|
||||
if (next.has(asset.id)) {
|
||||
next.delete(asset.id);
|
||||
} else {
|
||||
next.add(asset.id);
|
||||
}
|
||||
} else {
|
||||
next = new Set([asset.id]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedAssetId(asset.id);
|
||||
handleOpenDocument(row.docId, "main");
|
||||
},
|
||||
[assetIndexMap, assetOrder, lastSelectedAssetId, selectedAssetIds],
|
||||
[handleOpenAsset, handleOpenDocument],
|
||||
);
|
||||
|
||||
const toggleAssetCheckbox = useCallback((assetId: string) => {
|
||||
setSelectedAssetIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(assetId)) {
|
||||
next.delete(assetId);
|
||||
} else {
|
||||
next.add(assetId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLastSelectedAssetId(assetId);
|
||||
}, []);
|
||||
const handleFileTreeRowContextMenu = useCallback(
|
||||
(row: FileTreeRow, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setFileTreeSelection((prev) =>
|
||||
reduceFileTreeSelection(prev, { type: "contextmenu", rowId: row.rowId }),
|
||||
);
|
||||
|
||||
const selectOnlyAsset = useCallback((assetId: string) => {
|
||||
setSelectedAssetIds(new Set([assetId]));
|
||||
setLastSelectedAssetId(assetId);
|
||||
}, []);
|
||||
if (row.kind === "asset") {
|
||||
setAssetMenu({ asset: row.asset, x: event.clientX, y: event.clientY });
|
||||
return;
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
node: row.node,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = async (event: KeyboardEvent) => {
|
||||
const isCopy =
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.altKey &&
|
||||
(event.key === "c" || event.key === "C");
|
||||
const isPaste =
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
!event.altKey &&
|
||||
(event.key === "v" || event.key === "V");
|
||||
|
||||
if (!isCopy && !isPaste) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTextInputTarget(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = fileTreeContainerRef.current;
|
||||
const activeElement = document.activeElement;
|
||||
if (!container || !activeElement || !container.contains(activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCopy) {
|
||||
if (fileTreeSelection.selectedRowIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const orderedRowIds = fileTreeRows
|
||||
.filter((row) => fileTreeSelection.selectedRowIds.has(row.rowId))
|
||||
.map((row) => row.rowId);
|
||||
await writeFileTreeClipboardPayload({
|
||||
type: "mnote-file-tree",
|
||||
version: 1,
|
||||
action: "copy",
|
||||
rowIds: orderedRowIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaste) {
|
||||
event.preventDefault();
|
||||
const payload = await readFileTreeClipboardPayload();
|
||||
if (!payload || payload.rowIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetDocId = inferPasteTargetDocId({
|
||||
focusedRowId: fileTreeSelection.focusedRowId,
|
||||
rowById: fileTreeRowById,
|
||||
activeDocId: activeId || null,
|
||||
});
|
||||
if (!targetDocId) {
|
||||
setTimeout(() => window.alert("请选择一个目标页面后再粘贴"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = payload.rowIds
|
||||
.map((rowId) => fileTreeRowById.get(rowId))
|
||||
.filter(Boolean) as FileTreeRow[];
|
||||
|
||||
const docItemsMap = new Map<string, boolean>();
|
||||
rows.forEach((row) => {
|
||||
if (row.kind === "doc") {
|
||||
docItemsMap.set(row.docId, true);
|
||||
} else if (row.kind === "index") {
|
||||
if (!docItemsMap.has(row.docId)) {
|
||||
docItemsMap.set(row.docId, false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (docItemsMap.size > 0) {
|
||||
const resp = await fetch("/api/documents/copy-tree", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: Array.from(docItemsMap.entries()).map(([documentId, recursive]) => ({
|
||||
documentId,
|
||||
recursive,
|
||||
})),
|
||||
targetParentId: targetDocId,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
setTimeout(() => window.alert(data?.error ?? "粘贴页面失败"), 0);
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
emitDocumentsChanged(targetDocId);
|
||||
}
|
||||
|
||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
|
||||
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
|
||||
|
||||
if (copyableAssetIds.length > 0) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "copy",
|
||||
assetIds: copyableAssetIds,
|
||||
targetDocumentId: targetDocId,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
setTimeout(() => window.alert(data?.error ?? "粘贴附件失败"), 0);
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
emitAssetsChanged(targetDocId);
|
||||
} else if (docItemsMap.size === 0) {
|
||||
setTimeout(() => window.alert("没有可粘贴的真实文件(mindmap.json 等虚拟附件暂不支持)"), 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [
|
||||
activeId,
|
||||
fileTreeRowById,
|
||||
fileTreeRows,
|
||||
fileTreeSelection.focusedRowId,
|
||||
fileTreeSelection.selectedRowIds,
|
||||
sidebarQuery,
|
||||
]);
|
||||
|
||||
const handleCopyAssetLink = useCallback(
|
||||
async (asset: MediaAsset) => {
|
||||
@@ -552,39 +734,124 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
|
||||
const handleDeleteAssets = useCallback(
|
||||
async (assetIds: string[], assetHint?: MediaAsset) => {
|
||||
const target =
|
||||
assetHint ??
|
||||
mediaAssets.find((item) => assetIds.includes(item.id)) ??
|
||||
null;
|
||||
if (target?.asset_type === "mindmap") {
|
||||
const resp = await fetch(`/api/mindmap/${target.document_id}`, { method: "DELETE" });
|
||||
const uniqueAssetIds = Array.from(new Set(assetIds));
|
||||
const assets = uniqueAssetIds
|
||||
.map((id) => mediaAssets.find((item) => item.id === id))
|
||||
.filter(Boolean) as MediaAsset[];
|
||||
|
||||
if (assetHint && !assets.find((item) => item.id === assetHint.id)) {
|
||||
assets.unshift(assetHint);
|
||||
}
|
||||
|
||||
const mindmapDocIds = Array.from(
|
||||
new Set(assets.filter((item) => item.asset_type === "mindmap").map((item) => item.document_id)),
|
||||
);
|
||||
const fileAssetIdsByDocId = new Map<string, string[]>();
|
||||
assets
|
||||
.filter((item) => item.asset_type !== "mindmap")
|
||||
.forEach((item) => {
|
||||
const prev = fileAssetIdsByDocId.get(item.document_id) ?? [];
|
||||
prev.push(item.id);
|
||||
fileAssetIdsByDocId.set(item.document_id, prev);
|
||||
});
|
||||
const fileAssetIds = Array.from(fileAssetIdsByDocId.values()).flat();
|
||||
|
||||
for (const docId of mindmapDocIds) {
|
||||
const resp = await fetch(`/api/mindmap/${docId}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除思维导图失败");
|
||||
return;
|
||||
}
|
||||
setMindmapDocs((prev) => prev.filter((id) => id !== target.document_id));
|
||||
setAssetMenu(null);
|
||||
emitAssetsChanged(target.document_id, undefined, undefined, true);
|
||||
return;
|
||||
}
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除失败");
|
||||
return;
|
||||
|
||||
if (fileAssetIds.length > 0) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete", assetIds: fileAssetIds }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
window.alert(payload?.error ?? "删除失败");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mindmapDocIds.length > 0) {
|
||||
setMindmapDocs((prev) => prev.filter((id) => !mindmapDocIds.includes(id)));
|
||||
}
|
||||
if (uniqueAssetIds.length > 0) {
|
||||
setMediaAssets((prev) => prev.filter((item) => !uniqueAssetIds.includes(item.id)));
|
||||
}
|
||||
setMediaAssets((prev) => prev.filter((item) => !assetIds.includes(item.id)));
|
||||
setAssetMenu(null);
|
||||
emitAssetsChanged(target?.document_id, undefined, assetIds);
|
||||
mindmapDocIds.forEach((docId) => emitAssetsChanged(docId, undefined, undefined, true));
|
||||
fileAssetIdsByDocId.forEach((ids, docId) => emitAssetsChanged(docId, undefined, ids));
|
||||
},
|
||||
[mediaAssets, sidebarQuery],
|
||||
);
|
||||
|
||||
const handleDeleteFileTreeSelection = useCallback(async () => {
|
||||
const { docIds, assetIds } = computeFileTreeDeleteTargets({
|
||||
visibleRows: fileTreeRows,
|
||||
selectedRowIds: fileTreeSelection.selectedRowIds,
|
||||
parentById: docParentById,
|
||||
});
|
||||
|
||||
if (docIds.length === 0 && assetIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docText = docIds.length > 0 ? `${docIds.length} 个页面(删除到垃圾桶)` : "";
|
||||
const assetText = assetIds.length > 0 ? `${assetIds.length} 个附件(彻底删除)` : "";
|
||||
const joinText = docText && assetText ? " + " : "";
|
||||
const ok = window.confirm(`确认删除选中的 ${docText}${joinText}${assetText} 吗?`);
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
if (docIds.length > 0) {
|
||||
const results = await Promise.all(
|
||||
docIds.map(async (documentId) => {
|
||||
const resp = await fetch("/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ documentId }),
|
||||
});
|
||||
return { documentId, ok: resp.ok };
|
||||
}),
|
||||
);
|
||||
const failed = results.filter((item) => !item.ok).map((item) => item.documentId);
|
||||
if (failed.length > 0) {
|
||||
window.alert(`部分页面删除失败:${failed.slice(0, 5).join(", ")}${failed.length > 5 ? "…" : ""}`);
|
||||
return;
|
||||
}
|
||||
|
||||
docIds.forEach((documentId) => emitDocumentsChanged(documentId));
|
||||
if (activeId && docIds.includes(activeId)) {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
|
||||
if (assetIds.length > 0) {
|
||||
await handleDeleteAssets(assetIds);
|
||||
}
|
||||
|
||||
await refreshTree();
|
||||
setContextMenu(null);
|
||||
setFileTreeSelection((prev) => reduceFileTreeSelection(prev, { type: "clear" }));
|
||||
} catch (error) {
|
||||
window.alert(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
}, [
|
||||
activeId,
|
||||
docParentById,
|
||||
fileTreeRows,
|
||||
fileTreeSelection.selectedRowIds,
|
||||
handleDeleteAssets,
|
||||
refreshTree,
|
||||
router,
|
||||
]);
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -693,6 +960,147 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
[moveLocalNode, refreshTree, setExpanded],
|
||||
);
|
||||
|
||||
const handleFileTreeInternalDrop = useCallback(
|
||||
(args: { targetRow: FileTreeRow; rowIds: string[]; copy: boolean }) => {
|
||||
void (async () => {
|
||||
const targetDocId = inferDropTargetDocId(args.targetRow);
|
||||
if (!targetDocId) {
|
||||
setTimeout(() => window.alert("无法识别拖拽目标页面"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueRowIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
args.rowIds.forEach((id) => {
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
uniqueRowIds.push(id);
|
||||
});
|
||||
|
||||
const rows = uniqueRowIds
|
||||
.map((rowId) => fileTreeRowById.get(rowId))
|
||||
.filter(Boolean) as FileTreeRow[];
|
||||
|
||||
const docIds = rows.filter((row) => row.kind === "doc").map((row) => row.docId);
|
||||
const assetRows = rows.filter((row) => row.kind === "asset") as Extract<FileTreeRow, { kind: "asset" }>[];
|
||||
const copyableAssetIds = assetRows.filter((row) => isRealFileAsset(row.asset)).map((row) => row.asset.id);
|
||||
|
||||
if (docIds.length === 0 && copyableAssetIds.length === 0) {
|
||||
setTimeout(() => window.alert("没有可拖拽的对象(虚拟附件暂不支持)"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.copy) {
|
||||
if (docIds.length > 0) {
|
||||
const resp = await fetch("/api/documents/copy-tree", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
items: docIds.map((documentId) => ({ documentId, recursive: true })),
|
||||
targetParentId: targetDocId,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
setTimeout(() => window.alert(payload?.error ?? "复制页面失败"), 0);
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
emitDocumentsChanged(targetDocId);
|
||||
}
|
||||
|
||||
if (copyableAssetIds.length > 0) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "copy",
|
||||
assetIds: copyableAssetIds,
|
||||
targetDocumentId: targetDocId,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
setTimeout(() => window.alert(payload?.error ?? "复制附件失败"), 0);
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
emitAssetsChanged(targetDocId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const topLevelDocIds = filterTopLevelDocIds(docIds, docParentById);
|
||||
if (topLevelDocIds.length > 0) {
|
||||
if (
|
||||
isInvalidDocDrop({
|
||||
sourceDocIds: topLevelDocIds,
|
||||
targetParentId: targetDocId,
|
||||
parentById: docParentById,
|
||||
})
|
||||
) {
|
||||
setTimeout(() => window.alert("不能把页面移动到自身或其子页面中"), 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const baseIndex = childrenCountByParentId.get(targetDocId) ?? 0;
|
||||
setTree((prev) => {
|
||||
let next = prev;
|
||||
topLevelDocIds.forEach((id, offset) => {
|
||||
next = moveLocalNode(next, id, targetDocId, baseIndex + offset);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setExpanded((prev) => new Set(prev).add(targetDocId));
|
||||
|
||||
for (let i = 0; i < topLevelDocIds.length; i += 1) {
|
||||
await fetch("/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
documentId: topLevelDocIds[i],
|
||||
parentId: targetDocId,
|
||||
position: baseIndex + i,
|
||||
}),
|
||||
});
|
||||
}
|
||||
await refreshTree();
|
||||
emitDocumentsChanged(targetDocId);
|
||||
}
|
||||
|
||||
if (copyableAssetIds.length > 0) {
|
||||
const resp = await fetch("/api/media/batch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "move",
|
||||
assetIds: copyableAssetIds,
|
||||
targetDocumentId: targetDocId,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const payload = await resp.json().catch(() => ({}));
|
||||
setTimeout(() => window.alert(payload?.error ?? "移动附件失败"), 0);
|
||||
return;
|
||||
}
|
||||
await sidebarQuery.refetch();
|
||||
const sourceDocIds = new Set(assetRows.map((row) => row.asset.document_id));
|
||||
sourceDocIds.forEach((id) => emitAssetsChanged(id));
|
||||
emitAssetsChanged(targetDocId);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[
|
||||
childrenCountByParentId,
|
||||
docParentById,
|
||||
fileTreeRowById,
|
||||
moveLocalNode,
|
||||
refreshTree,
|
||||
sidebarQuery,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMovePrompt = useCallback(
|
||||
async (node: DocumentNode) => {
|
||||
if (typeof window === "undefined") {
|
||||
@@ -1033,22 +1441,19 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col border-t border-[#f1f1f1]">
|
||||
<div className="flex-1 px-1 pb-2">
|
||||
<div className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<div ref={fileTreeContainerRef} className="h-full rounded-md border border-[#eff2f6] bg-white">
|
||||
<FileTree
|
||||
nodes={filteredPrivateTree}
|
||||
assetsByDoc={assetsByDoc}
|
||||
expanded={expanded}
|
||||
rows={fileTreeRows}
|
||||
activeId={activeId}
|
||||
selectedRowIds={fileTreeSelection.selectedRowIds}
|
||||
onRowClick={handleFileTreeRowClick}
|
||||
onRowDoubleClick={(row, event) => handleFileTreeRowDoubleClick(row, event)}
|
||||
onRowContextMenu={handleFileTreeRowContextMenu}
|
||||
onRowDragStart={(row) => handleFileTreeRowDragStart(row)}
|
||||
onToggleExpand={toggleExpand}
|
||||
onOpenDocument={(id) => handleOpenDocument(id, "main")}
|
||||
onOpenAsset={handleOpenAsset}
|
||||
onCreateChild={handleCreate}
|
||||
onContextMenu={openContextMenu}
|
||||
onAssetContextMenu={handleAssetContextMenu}
|
||||
selectedAssetIds={selectedAssetIds}
|
||||
onAssetClick={(asset, e) => handleSelectAsset(asset, e)}
|
||||
onToggleAssetSelect={toggleAssetCheckbox}
|
||||
onSelectOnlyAsset={selectOnlyAsset}
|
||||
onBlankMouseDown={handleFileTreeBlankMouseDown}
|
||||
onInternalDrop={handleFileTreeInternalDrop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1105,7 +1510,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onRename={() => void handleRename(contextMenu.node.id, contextMenu.node.title)}
|
||||
onCreateChild={() => void handleCreate(contextMenu.node.id)}
|
||||
onConvertChild={() => void handleConvertToChild(contextMenu.node.id)}
|
||||
onDelete={() => void handleDelete(contextMenu.node.id)}
|
||||
onDelete={() => void handleDeleteFileTreeSelection()}
|
||||
/>
|
||||
)}
|
||||
{assetMenu && (
|
||||
@@ -1120,7 +1525,7 @@ export function Sidebar({ initialData }: SidebarProps) {
|
||||
onMove={handleMoveAsset}
|
||||
onDelete={(ids) =>
|
||||
void handleDeleteAssets(
|
||||
selectedAssetIds.size > 0 ? Array.from(selectedAssetIds) : ids,
|
||||
selectedAssetIdsForMenu.length > 0 ? selectedAssetIdsForMenu : ids,
|
||||
assetMenu.asset,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user