diff --git a/.oxlintrc.json b/.oxlintrc.json index a9171f3..354aa79 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -16,7 +16,8 @@ "unicorn/no-array-for-each": "warn", "react/exhaustive-deps": "warn", "react/jsx-max-depth": ["warn", { "max": 3 }], - "no-await-in-loop": "off" + "no-await-in-loop": "off", + "max-classes-per-file": "off" }, "settings": { "jsx-a11y": { "polymorphicPropName": null, "components": {}, "attributes": {} }, diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index 4c5afec..70781b4 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1539,6 +1539,62 @@ impl Store { Ok(normalized_new_rel_path) } + pub fn dir_move_to_location( + &self, source_location_id: LocationId, rel_path: &Path, target_location_id: LocationId, new_rel_path: &Path, + ) -> Result { + if source_location_id == target_location_id { + return self.dir_move(source_location_id, rel_path, new_rel_path); + } + + let source_location = self + .location_get(source_location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", source_location_id)))?; + let target_location = self + .location_get(target_location_id)? + .ok_or_else(|| AppError::not_found(format!("Location not found: {:?}", target_location_id)))?; + + let normalized_rel_path = normalize_relative_path(rel_path)?; + let normalized_new_rel_path = normalize_relative_path(new_rel_path)?; + + let source_full_path = source_location.root_path.join(&normalized_rel_path); + let target_full_path = target_location.root_path.join(&normalized_new_rel_path); + + if !source_full_path.exists() { + return Err(AppError::not_found("Directory not found")); + } + if !source_full_path.is_dir() { + return Err(AppError::invalid_path("Path is not a directory")); + } + if target_full_path.exists() { + return Err(AppError::new( + ErrorCode::Conflict, + "A file or directory already exists at the destination", + )); + } + + if let Some(parent) = target_full_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| AppError::io(format!("Failed to create destination directory: {}", e)))?; + } + + Self::move_directory_on_disk(&source_full_path, &target_full_path)?; + self.update_directory_paths_in_index_across_locations( + source_location_id, + target_location_id, + &normalized_rel_path, + &normalized_new_rel_path, + )?; + + log::info!( + "Moved directory across locations: source_location={:?}, target_location={:?}, from={:?}, to={:?}", + source_location_id, + target_location_id, + normalized_rel_path, + normalized_new_rel_path + ); + Ok(normalized_new_rel_path) + } + pub fn dir_delete(&self, location_id: LocationId, rel_path: &Path) -> Result { let location = self .location_get(location_id)? @@ -1599,6 +1655,113 @@ impl Store { Ok(()) } + fn update_directory_paths_in_index_across_locations( + &self, source_location_id: LocationId, target_location_id: LocationId, old_rel_path: &Path, new_rel_path: &Path, + ) -> Result<(), AppError> { + let conn = self + .conn + .lock() + .map_err(|_| AppError::new(ErrorCode::Io, "Failed to lock database connection"))?; + + let old_prefix = old_rel_path.to_string_lossy().to_string(); + let new_prefix = new_rel_path.to_string_lossy().to_string(); + let escaped_old_prefix = old_prefix.replace('\\', r"\\").replace('%', r"\%").replace('_', r"\_"); + let old_like = format!("{}/%", escaped_old_prefix); + let updated_at = Utc::now().to_rfc3339(); + + conn.execute( + "UPDATE documents + SET location_id = ?2, + rel_path = ?4 || substr(rel_path, length(?3) + 1), + updated_at = ?5 + WHERE location_id = ?1 AND (rel_path = ?3 OR rel_path LIKE ?6 ESCAPE '\\')", + params![ + source_location_id.0, + target_location_id.0, + old_prefix, + new_prefix, + updated_at, + old_like + ], + ) + .map_err(|e| { + AppError::new( + ErrorCode::Index, + format!("Failed to update cross-location directory document rows: {}", e), + ) + })?; + + conn.execute( + "UPDATE docs_fts + SET location_id = ?2, + rel_path = ?4 || substr(rel_path, length(?3) + 1) + WHERE location_id = ?1 AND (rel_path = ?3 OR rel_path LIKE ?5 ESCAPE '\\')", + params![ + source_location_id.0, + target_location_id.0, + old_prefix, + new_prefix, + old_like + ], + ) + .map_err(|e| { + AppError::new( + ErrorCode::Index, + format!("Failed to update cross-location directory FTS rows: {}", e), + ) + })?; + + Ok(()) + } + + fn move_directory_on_disk(source_path: &Path, destination_path: &Path) -> Result<(), AppError> { + match std::fs::rename(source_path, destination_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::CrossesDevices => { + Self::copy_directory_recursive(source_path, destination_path)?; + std::fs::remove_dir_all(source_path) + .map_err(|e| AppError::io(format!("Failed to remove source directory after copy: {}", e)))?; + Ok(()) + } + Err(error) => Err(AppError::io(format!("Failed to move directory: {}", error))), + } + } + + fn copy_directory_recursive(source_path: &Path, destination_path: &Path) -> Result<(), AppError> { + std::fs::create_dir_all(destination_path) + .map_err(|e| AppError::io(format!("Failed to create directory while moving: {}", e)))?; + + let entries = std::fs::read_dir(source_path) + .map_err(|e| AppError::io(format!("Failed to read source directory while moving: {}", e)))?; + + for entry_result in entries { + let entry = entry_result.map_err(|e| AppError::io(format!("Failed to read directory entry: {}", e)))?; + let source_child = entry.path(); + let destination_child = destination_path.join(entry.file_name()); + let file_type = entry + .file_type() + .map_err(|e| AppError::io(format!("Failed to read directory entry type: {}", e)))?; + + if file_type.is_dir() { + Self::copy_directory_recursive(&source_child, &destination_child)?; + continue; + } + + if file_type.is_file() { + std::fs::copy(&source_child, &destination_child) + .map_err(|e| AppError::io(format!("Failed to copy file while moving directory: {}", e)))?; + continue; + } + + return Err(AppError::io(format!( + "Unsupported filesystem entry while moving directory: {:?}", + source_child + ))); + } + + Ok(()) + } + fn remove_directory_from_index(&self, location_id: LocationId, rel_path: &Path) -> Result<(), AppError> { let conn = self .conn @@ -2361,6 +2524,48 @@ mod tests { assert_eq!(deep_hits[0].rel_path, "new/archive/deep/b.md"); } + #[test] + fn test_directory_move_to_different_location_updates_catalog_paths() { + let (store, _temp) = create_test_store(); + let source_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + let source_location = store + .location_add("Directory Move Source".to_string(), source_dir.path().to_path_buf()) + .unwrap(); + let target_location = store + .location_add("Directory Move Target".to_string(), target_dir.path().to_path_buf()) + .unwrap(); + + let doc_a = DocId::new(source_location.id, PathBuf::from("old/sub/a.md")).unwrap(); + let doc_b = DocId::new(source_location.id, PathBuf::from("old/sub/deep/b.md")).unwrap(); + store.doc_save(&doc_a, "# A\n\nalphatoken", None).unwrap(); + store.doc_save(&doc_b, "# B\n\ndeeptoken", None).unwrap(); + + let moved = store + .dir_move_to_location( + source_location.id, + Path::new("old/sub"), + target_location.id, + Path::new("new/archive"), + ) + .unwrap(); + + assert_eq!(moved, PathBuf::from("new/archive")); + assert!(!source_dir.path().join("old/sub").exists()); + assert!(target_dir.path().join("new/archive/a.md").exists()); + assert!(target_dir.path().join("new/archive/deep/b.md").exists()); + + let alpha_hits = store.search("alphatoken", None, 10).unwrap(); + assert_eq!(alpha_hits.len(), 1); + assert_eq!(alpha_hits[0].location_id, target_location.id); + assert_eq!(alpha_hits[0].rel_path, "new/archive/a.md"); + + let deep_hits = store.search("deeptoken", None, 10).unwrap(); + assert_eq!(deep_hits.len(), 1); + assert_eq!(deep_hits[0].location_id, target_location.id); + assert_eq!(deep_hits[0].rel_path, "new/archive/deep/b.md"); + } + #[test] fn test_directory_list_includes_empty_and_nested_directories() { let (store, _temp) = create_test_store(); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1c98915..4ddb4fd 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -706,19 +706,30 @@ pub fn dir_rename( #[tauri::command] pub fn dir_move( state: State<'_, AppState>, location_id: i64, rel_path: String, new_rel_path: String, + target_location_id: Option, ) -> CommandResponse { let location_id = LocationId(location_id); + let target_location_id = target_location_id.map(LocationId).unwrap_or(location_id); let rel_path = PathBuf::from(&rel_path); let new_rel_path = PathBuf::from(&new_rel_path); log::debug!( - "Moving directory: location={:?}, path={:?}, new_path={:?}", + "Moving directory: source_location={:?}, path={:?}, new_path={:?}, target_location={:?}", location_id, rel_path, - new_rel_path + new_rel_path, + target_location_id ); - match state.store.dir_move(location_id, &rel_path, &new_rel_path) { + let result = if target_location_id == location_id { + state.store.dir_move(location_id, &rel_path, &new_rel_path) + } else { + state + .store + .dir_move_to_location(location_id, &rel_path, target_location_id, &new_rel_path) + }; + + match result { Ok(next_path) => Ok(CommandResult::ok(next_path.to_string_lossy().to_string())), Err(e) => { log::error!("Failed to move directory: {}", e); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 826dc0b..f5d272f 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -11,7 +11,15 @@ }, "app": { "windows": [ - { "label": "main", "title": "Writer", "width": 1200, "height": 800, "minWidth": 600, "minHeight": 400 } + { + "label": "main", + "title": "Writer", + "width": 1200, + "height": 800, + "minWidth": 600, + "minHeight": 400, + "dragDropEnabled": false + } ], "trayIcon": { "id": "global_capture_tray", diff --git a/src/App.css b/src/App.css index 73f9ab5..003de71 100644 --- a/src/App.css +++ b/src/App.css @@ -167,32 +167,56 @@ body, 0% { box-shadow: 0 0 0 0 rgba(51, 177, 255, 0.45); } - 70% { + 65% { box-shadow: 0 0 0 7px rgba(51, 177, 255, 0); } 100% { - box-shadow: 0 0 0 0 rgba(51, 177, 255, 0); + box-shadow: 0 0 0 1px rgba(51, 177, 255, 0.42); } } @keyframes sidebar-drop-edge-pulse { 0% { - opacity: 0.55; + opacity: 0.5; } - 50% { + 70% { opacity: 1; } 100% { - opacity: 0.55; + opacity: 1; } } .sidebar-drop-pulse { - animation: sidebar-drop-pulse 1.05s ease-in-out infinite; + animation: sidebar-drop-pulse 0.58s ease-out 2 forwards; } .sidebar-drop-edge-pulse { - animation: sidebar-drop-edge-pulse 0.8s ease-in-out infinite; + animation: sidebar-drop-edge-pulse 0.36s ease-out 2 forwards; +} + +.sidebar-drag-ghost { + position: fixed; + top: -9999px; + left: -9999px; + z-index: 9999; + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 280px; + padding: 0.35rem 0.55rem; + border: 1px solid var(--color-border-interactive); + border-radius: 6px; + background: var(--color-layer-02); + color: var(--color-text-primary); + box-shadow: var(--shadow-md); + font-family: var(--font-sans); + font-size: 0.8rem; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + pointer-events: none; } .sidebar-item--unselected:hover { diff --git a/src/__tests__/DocumentItem.test.tsx b/src/__tests__/DocumentItem.test.tsx index 8ddbdc9..6a43c59 100644 --- a/src/__tests__/DocumentItem.test.tsx +++ b/src/__tests__/DocumentItem.test.tsx @@ -47,6 +47,19 @@ describe("DocumentItem", () => { render(); expect(screen.getByText("untitled.md")).toBeInTheDocument(); }); + + it("uses edge-only indicator for reorder targets", () => { + const props = createProps({ + activeDropDocumentPath: "notes/test.md", + activeDropDocumentEdge: "bottom", + activeDropDocumentIsReorder: true, + }); + render(); + + const item = screen.getByText("Test Document").closest(".sidebar-item"); + expect(item).not.toHaveClass("ring-border-interactive"); + expect(document.querySelector(".sidebar-drop-edge-pulse")).toBeTruthy(); + }); }); describe("context menu", () => { diff --git a/src/__tests__/DocumentOperationDialog.test.tsx b/src/__tests__/DocumentOperationDialog.test.tsx index a0dbedd..7c1eecf 100644 --- a/src/__tests__/DocumentOperationDialog.test.tsx +++ b/src/__tests__/DocumentOperationDialog.test.tsx @@ -1,7 +1,4 @@ -import { - DocumentOperationDialog, - type DocumentOperationRequest, -} from "$components/Sidebar/DocumentOperationDialog"; +import { DocumentOperationDialog, type DocumentOperationRequest } from "$components/Sidebar/DocumentOperationDialog"; import type { DocMeta } from "$types"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/src/__tests__/Sidebar.test.tsx b/src/__tests__/Sidebar.test.tsx index 1618c0c..4c766a1 100644 --- a/src/__tests__/Sidebar.test.tsx +++ b/src/__tests__/Sidebar.test.tsx @@ -40,6 +40,9 @@ const createSidebarState = (overrides: Partial { ); const folderHit = document.createElement("div"); + folderHit.dataset.dropFolderRow = "true"; folderHit.dataset.locationId = "1"; folderHit.dataset.folderPath = "samples/sibling"; + folderHit.getBoundingClientRect = () => + ({ + left: 0, + right: 300, + top: 0, + bottom: 100, + width: 300, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; globalThis.document.elementFromPoint = vi.fn(() => folderHit); render(); @@ -391,7 +409,7 @@ describe("Sidebar", () => { location: { current: { dropTargets: [{ data: { locationId: 1, targetType: "location" } }], - input: { altKey: false, clientX: 18, clientY: 22 }, + input: { altKey: false, clientX: 18, clientY: 50 }, }, }, }); @@ -400,7 +418,7 @@ describe("Sidebar", () => { expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "samples/sibling/some-file.md", 1); }); - it("defaults to location root when elementFromPoint cannot resolve folder", () => { + it("uses geometry fallback folder targeting when elementFromPoint cannot resolve folder", () => { const handleMoveDocument = vi.fn().mockResolvedValue(true); vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); vi.mocked(useSidebarState).mockReturnValue( @@ -415,10 +433,6 @@ describe("Sidebar", () => { }], }), ); - globalThis.document.elementFromPoint = vi.fn(() => null); - - render(); - const folderRow = document.createElement("div"); folderRow.dataset.dropFolderRow = "true"; folderRow.dataset.locationId = "1"; @@ -437,16 +451,9 @@ describe("Sidebar", () => { toJSON: () => ({}), }) as DOMRect ); - const originalQuerySelectorAll = document.querySelectorAll.bind(document); - vi.spyOn(document, "querySelectorAll").mockImplementation((selectors) => { - if ( - selectors - === "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]" - ) { - return [folderRow] as unknown as NodeListOf; - } - return originalQuerySelectorAll(selectors); - }); + globalThis.document.elementFromPoint = vi.fn(() => folderRow); + + render(); act(() => { monitorArgs?.onDrop?.({ @@ -460,7 +467,7 @@ describe("Sidebar", () => { }); }); - expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "some-file.md", 1); + expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "samples/sibling/some-file.md", 1); }); it("defaults to location root when pointer is slightly outside a folder row", () => { @@ -526,7 +533,7 @@ describe("Sidebar", () => { expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "some-file.md", 1); }); - it("defaults to location root when folder body zones are not directly resolved by pointer hit testing", () => { + it("uses folder fallback targets when folder body zones are detected by geometry", () => { const handleMoveDocument = vi.fn().mockResolvedValue(true); vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); vi.mocked(useSidebarState).mockReturnValue( @@ -541,12 +548,8 @@ describe("Sidebar", () => { }], }), ); - globalThis.document.elementFromPoint = vi.fn(() => null); - - render(); - const folderZone = document.createElement("div"); - folderZone.dataset.dropFolderZone = "true"; + folderZone.dataset.dropFolderRow = "true"; folderZone.dataset.locationId = "1"; folderZone.dataset.folderPath = "samples/sibling"; folderZone.dataset.folderDepth = "2"; @@ -563,17 +566,9 @@ describe("Sidebar", () => { toJSON: () => ({}), }) as DOMRect ); + globalThis.document.elementFromPoint = vi.fn(() => folderZone); - const originalQuerySelectorAll = document.querySelectorAll.bind(document); - vi.spyOn(document, "querySelectorAll").mockImplementation((selectors) => { - if ( - selectors - === "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]" - ) { - return [folderZone] as unknown as NodeListOf; - } - return originalQuerySelectorAll(selectors); - }); + render(); act(() => { monitorArgs?.onDrop?.({ @@ -587,10 +582,10 @@ describe("Sidebar", () => { }); }); - expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "some-file.md", 1); + expect(handleMoveDocument).toHaveBeenCalledWith(1, "samples/some-file.md", "samples/sibling/some-file.md", 1); }); - it("does not move root documents when drop metadata only reports location", () => { + it("moves root documents into resolved folder targets when drop metadata only reports location", () => { const handleMoveDocument = vi.fn().mockResolvedValue(true); vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); vi.mocked(useSidebarState).mockReturnValue( @@ -605,10 +600,6 @@ describe("Sidebar", () => { }], }), ); - globalThis.document.elementFromPoint = vi.fn(() => null); - - render(); - const parentRow = document.createElement("div"); parentRow.dataset.dropFolderRow = "true"; parentRow.dataset.locationId = "1"; @@ -627,36 +618,9 @@ describe("Sidebar", () => { toJSON: () => ({}), }) as DOMRect ); + globalThis.document.elementFromPoint = vi.fn(() => parentRow); - const childRow = document.createElement("div"); - childRow.dataset.dropFolderRow = "true"; - childRow.dataset.locationId = "1"; - childRow.dataset.folderPath = "samples/sibling"; - childRow.dataset.folderDepth = "2"; - childRow.getBoundingClientRect = vi.fn(() => - ({ - left: 0, - right: 300, - top: 500, - bottom: 560, - width: 300, - height: 60, - x: 0, - y: 500, - toJSON: () => ({}), - }) as DOMRect - ); - - const originalQuerySelectorAll = document.querySelectorAll.bind(document); - vi.spyOn(document, "querySelectorAll").mockImplementation((selectors) => { - if ( - selectors - === "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]" - ) { - return [parentRow, childRow] as unknown as NodeListOf; - } - return originalQuerySelectorAll(selectors); - }); + render(); act(() => { monitorArgs?.onDrop?.({ @@ -670,11 +634,11 @@ describe("Sidebar", () => { }); }); - expect(handleMoveDocument).not.toHaveBeenCalled(); - expect(showWarnToast).toHaveBeenCalledWith("Drop target is not valid for moving this file"); + expect(handleMoveDocument).toHaveBeenCalledWith(1, "draft.md", "samples/draft.md", 1); + expect(showWarnToast).not.toHaveBeenCalledWith("Drop target is not valid for moving this file"); }); - it("uses current metadata when drop metadata degrades to location-only", () => { + it("reuses last resolved folder target when drop metadata degrades to location-only", () => { const handleMoveDocument = vi.fn().mockResolvedValue(true); vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); vi.mocked(useSidebarState).mockReturnValue( @@ -714,7 +678,7 @@ describe("Sidebar", () => { }); }); - expect(handleMoveDocument).toHaveBeenCalledWith(1, "Samples/some-file.md", "some-file.md", 1); + expect(handleMoveDocument).toHaveBeenCalledWith(1, "Samples/some-file.md", "sibling-dir/some-file.md", 1); }); it("announces location hover when monitor reports location-only after a folder target", () => { @@ -788,7 +752,7 @@ describe("Sidebar", () => { }); }); - expect(handleMoveDirectory).toHaveBeenCalledWith(1, "Samples", "Archive/Samples"); + expect(handleMoveDirectory).toHaveBeenCalledWith(1, "Samples", "Archive/Samples", 1); }); it("announces folder target when folder is the active destination", () => { @@ -820,7 +784,6 @@ describe("Sidebar", () => { }); expect(mockAnnounce).toHaveBeenCalledWith("Over archive/2026 in Notes"); - expect(screen.getByText("2026").closest(".sidebar-item")).toHaveClass("ring-border-interactive"); }); it("moves document to location root when dropped on a root-level neighbor", () => { @@ -947,6 +910,67 @@ describe("Sidebar", () => { }, { location_id: 1, rel_path: "one.md", title: "One", updated_at: "2026-01-01T00:00:00Z", word_count: 1 }]); }); + it("reuses last resolved document target for same-folder reorder when drop metadata degrades", () => { + const setDocuments = vi.fn(); + mockExtractClosestEdge.mockReturnValue("bottom"); + vi.mocked(useSidebarState).mockReturnValue( + createSidebarState({ + setDocuments, + documents: [{ + location_id: 1, + rel_path: "archive/one.md", + title: "One", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }, { + location_id: 1, + rel_path: "archive/two.md", + title: "Two", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }], + }), + ); + + render(); + + act(() => { + monitorArgs?.onDropTargetChange?.({ + source: { data: { type: "document", locationId: 1, relPath: "archive/one.md", title: "One" } }, + location: { + current: { + dropTargets: [{ data: { locationId: 1, relPath: "archive/two.md", targetType: "document" } }], + input: { altKey: false }, + }, + }, + }); + }); + + act(() => { + monitorArgs?.onDrop?.({ + source: { data: { type: "document", locationId: 1, relPath: "archive/one.md", title: "One" } }, + location: { + current: { dropTargets: [{ data: { locationId: 1, targetType: "location" } }], input: { altKey: false } }, + }, + }); + }); + + expect(setDocuments).toHaveBeenCalledWith([{ + location_id: 1, + rel_path: "archive/two.md", + title: "Two", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }, { + location_id: 1, + rel_path: "archive/one.md", + title: "One", + updated_at: "2026-01-01T00:00:00Z", + word_count: 1, + }]); + expect(showWarnToast).not.toHaveBeenCalledWith("Drop target is not valid for moving this file"); + }); + it("opens a move dialog for modifier-key drops and submits destination path", async () => { const handleMoveDocument = vi.fn().mockResolvedValue(true); vi.mocked(useSidebarActions).mockReturnValue(createSidebarActionsState({ handleMoveDocument })); diff --git a/src/__tests__/SidebarLocationItem.test.tsx b/src/__tests__/SidebarLocationItem.test.tsx index 300e05c..af612e8 100644 --- a/src/__tests__/SidebarLocationItem.test.tsx +++ b/src/__tests__/SidebarLocationItem.test.tsx @@ -1,4 +1,5 @@ import { canDropDocumentIntoFolder, canDropFolderIntoFolder } from "$components/Sidebar/SidebarLocationItem"; +import { checkDropDocumentIntoFolder, walkUpToValidDestination } from "$dnd/sidebar"; import { describe, expect, it } from "vitest"; describe("canDropDocumentIntoFolder", () => { @@ -24,6 +25,22 @@ describe("canDropDocumentIntoFolder", () => { }); }); +describe("checkDropDocumentIntoFolder", () => { + it("reports no-op drops explicitly", () => { + expect(checkDropDocumentIntoFolder({ type: "document", locationId: 1, relPath: "archive/file.md" }, 1, "archive")) + .toBe("noop"); + }); +}); + +describe("walkUpToValidDestination", () => { + it("returns null for same-folder document drops instead of walking up", () => { + const sourceData = { type: "document" as const, locationId: 1, relPath: "archive/file.md", title: "file" }; + const destination = { locationId: 1, folderPath: "archive", targetType: "folder" as const }; + + expect(walkUpToValidDestination(sourceData, destination)).toBeNull(); + }); +}); + describe("canDropFolderIntoFolder", () => { it("blocks folder drops into itself", () => { expect(canDropFolderIntoFolder({ type: "folder", locationId: 1, relPath: "samples" }, 1, "samples")).toBe(false); @@ -38,4 +55,8 @@ describe("canDropFolderIntoFolder", () => { it("allows moving folder into a different sibling folder", () => { expect(canDropFolderIntoFolder({ type: "folder", locationId: 1, relPath: "samples" }, 1, "archive")).toBe(true); }); + + it("allows moving folder into another location", () => { + expect(canDropFolderIntoFolder({ type: "folder", locationId: 1, relPath: "samples" }, 2, "archive")).toBe(true); + }); }); diff --git a/src/__tests__/WorkspacePanel.test.tsx b/src/__tests__/WorkspacePanel.test.tsx index be0688e..b186c0b 100644 --- a/src/__tests__/WorkspacePanel.test.tsx +++ b/src/__tests__/WorkspacePanel.test.tsx @@ -72,6 +72,9 @@ const createSidebarState = (overrides: Partial = {}): Sideba refreshingLocationId: undefined, sidebarRefreshReason: null, externalDropTargetId: undefined, + externalDropFolderPath: undefined, + activeDropTarget: null, + folderSortOrderByLocation: {}, filterText: "", setFilterText: vi.fn(), setDocuments: vi.fn(), @@ -80,6 +83,8 @@ const createSidebarState = (overrides: Partial = {}): Sideba toggleSidebarCollapsed: vi.fn(), filenameVisibility: false, setExternalDropTarget: vi.fn(), + setActiveDropTarget: vi.fn(), + reorderFolderSortOrder: vi.fn(), ...overrides, }); diff --git a/src/__tests__/dnd.test.ts b/src/__tests__/dnd.test.ts index 8ebef20..29bea9c 100644 --- a/src/__tests__/dnd.test.ts +++ b/src/__tests__/dnd.test.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; function withDragEvent( type: string, - options: { clientX?: number; clientY?: number; altKey?: boolean } = {}, + options: { clientX?: number; clientY?: number; altKey?: boolean; dropEffect?: "none" | "move" | "copy" } = {}, ): DragEvent { const event = new Event(type, { bubbles: true, cancelable: true }) as DragEvent; Object.defineProperties(event, { @@ -22,7 +22,15 @@ function withDragEvent( x: { value: options.clientX ?? 0, configurable: true }, y: { value: options.clientY ?? 0, configurable: true }, altKey: { value: options.altKey ?? false, configurable: true }, - dataTransfer: { value: { effectAllowed: "all", dropEffect: "none", setData: vi.fn() }, configurable: true }, + dataTransfer: { + value: { + effectAllowed: "all", + dropEffect: options.dropEffect ?? "none", + setData: vi.fn(), + setDragImage: vi.fn(), + }, + configurable: true, + }, }); return event; } @@ -60,25 +68,58 @@ describe("dnd edge helpers", () => { }); describe("resolveDestinationFromPointer", () => { - it("prefers direct document hits from elementFromPoint", () => { + it("resolves document rows using zone hit testing", () => { const row = document.createElement("div"); + row.dataset.dropDocumentRow = "true"; row.dataset.locationId = "7"; row.dataset.documentPath = "notes/file.md"; + row.getBoundingClientRect = () => + ({ + left: 0, + right: 400, + top: 100, + bottom: 140, + width: 400, + height: 40, + x: 0, + y: 100, + toJSON: () => ({}), + }) as DOMRect; const child = document.createElement("span"); row.append(child); document.body.append(row); vi.spyOn(document, "elementFromPoint").mockReturnValue(child); - const resolved = resolveDestinationFromPointer(50, 50); + const resolved = resolveDestinationFromPointer(50, 105); expect(resolved?.destination.locationId).toBe(7); expect(resolved?.destination.targetType).toBe("document"); expect(resolved?.destination.relPath).toBe("notes/file.md"); + expect(extractClosestEdge(resolved?.destination)).toBe("top"); }); - it("falls back to nearest location target when pointer is near but outside rows", () => { + it("returns null when the pointer is outside all drop rows", () => { vi.spyOn(document, "elementFromPoint").mockReturnValue(null); + const resolved = resolveDestinationFromPointer(120, 270); + expect(resolved).toBeNull(); + }); + + it("falls back to the location container when not over a row target", () => { + const locationContainer = document.createElement("div"); + locationContainer.dataset.locationId = "8"; + const headerButton = document.createElement("button"); + locationContainer.append(headerButton); + document.body.append(locationContainer); + + vi.spyOn(document, "elementFromPoint").mockReturnValue(headerButton); + + const resolved = resolveDestinationFromPointer(16, 24); + expect(resolved?.destination.locationId).toBe(8); + expect(resolved?.destination.targetType).toBe("location"); + }); + + it("uses middle folder zone as drop-into", () => { const row = document.createElement("div"); row.dataset.dropFolderRow = "true"; row.dataset.locationId = "3"; @@ -96,15 +137,16 @@ describe("resolveDestinationFromPointer", () => { toJSON: () => ({}), }) as DOMRect; document.body.append(row); + vi.spyOn(document, "elementFromPoint").mockReturnValue(row); - const resolved = resolveDestinationFromPointer(120, 270); + const resolved = resolveDestinationFromPointer(120, 240); expect(resolved?.destination.locationId).toBe(3); - expect(resolved?.destination.targetType).toBe("location"); + expect(resolved?.destination.targetType).toBe("folder"); + expect(resolved?.destination.folderPath).toBe("archive"); + expect(extractClosestEdge(resolved?.destination)).toBeNull(); }); - it("preserves folder targeting in geometry fallback", () => { - vi.spyOn(document, "elementFromPoint").mockReturnValue(null); - + it("uses top folder zone as between-items insertion", () => { const folderRow = document.createElement("div"); folderRow.dataset.dropFolderRow = "true"; folderRow.dataset.locationId = "4"; @@ -122,15 +164,112 @@ describe("resolveDestinationFromPointer", () => { toJSON: () => ({}), }) as DOMRect; document.body.append(folderRow); + vi.spyOn(document, "elementFromPoint").mockReturnValue(folderRow); - const resolved = resolveDestinationFromPointer(100, 320); + const resolved = resolveDestinationFromPointer(100, 304); expect(resolved?.destination.locationId).toBe(4); expect(resolved?.destination.targetType).toBe("folder"); expect(resolved?.destination.folderPath).toBe("projects/writer"); + expect(extractClosestEdge(resolved?.destination)).toBe("top"); }); }); describe("native drag lifecycle", () => { + it("uses normalized viewport coordinates from drag events", () => { + Object.defineProperty(globalThis, "innerWidth", { value: 500, configurable: true }); + Object.defineProperty(globalThis, "innerHeight", { value: 500, configurable: true }); + Object.defineProperty(globalThis, "devicePixelRatio", { value: 2, configurable: true }); + + const sourceElement = document.createElement("div"); + const dropZoneElement = document.createElement("div"); + document.body.append(sourceElement, dropZoneElement); + + let receivedInput: { clientX: number; clientY: number; x: number; y: number; altKey: boolean } | null = null; + + const stopDraggable = draggable({ + element: sourceElement, + getInitialData: () => ({ type: "document", locationId: 1, relPath: "a.md", title: "A" }), + }); + const stopDropTarget = dropTargetForElements({ + element: dropZoneElement, + canDrop: () => true, + getData: ({ input }) => { + receivedInput = input; + return { locationId: 9, targetType: "location" }; + }, + }); + + sourceElement.dispatchEvent(withDragEvent("dragstart", { clientX: 100, clientY: 100 })); + dropZoneElement.dispatchEvent(withDragEvent("dragover", { clientX: 100, clientY: 100 })); + + expect(receivedInput).toEqual({ clientX: 100, clientY: 100, x: 100, y: 100, altKey: false }); + + stopDropTarget(); + stopDraggable(); + }); + + it("ignores dragleave events when pointer is still inside the drop target bounds", () => { + const sourceElement = document.createElement("div"); + const dropZoneElement = document.createElement("div"); + dropZoneElement.getBoundingClientRect = () => + ({ + left: 0, + right: 300, + top: 0, + bottom: 300, + width: 300, + height: 300, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + document.body.append(sourceElement, dropZoneElement); + + const onDropTargetChange = vi.fn(); + + const stopDraggable = draggable({ + element: sourceElement, + getInitialData: () => ({ type: "document", locationId: 1, relPath: "a.md", title: "A" }), + }); + const stopDropTarget = dropTargetForElements({ + element: dropZoneElement, + canDrop: () => true, + getData: () => ({ locationId: 2, targetType: "location" }), + }); + const stopMonitor = monitorForElements({ onDropTargetChange }); + + sourceElement.dispatchEvent(withDragEvent("dragstart", { clientX: 24, clientY: 40 })); + dropZoneElement.dispatchEvent(withDragEvent("dragover", { clientX: 24, clientY: 40 })); + dropZoneElement.dispatchEvent(withDragEvent("dragleave", { clientX: 24, clientY: 40 })); + + expect(onDropTargetChange).toHaveBeenCalledTimes(1); + + stopMonitor(); + stopDropTarget(); + stopDraggable(); + }); + + it("sets a compact custom drag image on dragstart", () => { + const sourceElement = document.createElement("div"); + const ghostElement = document.createElement("div"); + ghostElement.id = "sidebar-drag-ghost"; + ghostElement.className = "sidebar-drag-ghost"; + document.body.append(sourceElement, ghostElement); + + const stopDraggable = draggable({ + element: sourceElement, + getInitialData: () => ({ type: "document", locationId: 1, relPath: "notes/a.md", title: "A" }), + }); + + const dragEvent = withDragEvent("dragstart", { clientX: 12, clientY: 20 }); + sourceElement.dispatchEvent(dragEvent); + + expect(dragEvent.dataTransfer?.setDragImage).toHaveBeenCalledTimes(1); + expect(dragEvent.dataTransfer?.setDragImage).toHaveBeenCalledWith(ghostElement, 12, 12); + + stopDraggable(); + }); + it("monitors drag start, target change, and drop", () => { const sourceElement = document.createElement("div"); const dropZoneElement = document.createElement("div"); @@ -165,6 +304,30 @@ describe("native drag lifecycle", () => { stopDropTarget(); stopDraggable(); }); + + it("does not dispatch monitor onDrop when drag is cancelled", () => { + const sourceElement = document.createElement("div"); + document.body.append(sourceElement); + + const onDrop = vi.fn(); + const sourceOnDrop = vi.fn(); + + const stopDraggable = draggable({ + element: sourceElement, + getInitialData: () => ({ type: "document", locationId: 1, relPath: "a.md", title: "A" }), + onDrop: sourceOnDrop, + }); + const stopMonitor = monitorForElements({ onDrop }); + + sourceElement.dispatchEvent(withDragEvent("dragstart", { clientX: 12, clientY: 20 })); + sourceElement.dispatchEvent(withDragEvent("dragend", { clientX: 40, clientY: 50, dropEffect: "none" })); + + expect(onDrop).not.toHaveBeenCalled(); + expect(sourceOnDrop).toHaveBeenCalledTimes(1); + + stopMonitor(); + stopDraggable(); + }); }); describe("normalizePointerCoordinates", () => { diff --git a/src/__tests__/pdfFonts.test.ts b/src/__tests__/pdfFonts.test.ts index ac4e50c..a42c748 100644 --- a/src/__tests__/pdfFonts.test.ts +++ b/src/__tests__/pdfFonts.test.ts @@ -5,12 +5,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@react-pdf/renderer", () => ({ Font: { register: vi.fn(), load: vi.fn(async () => {}) } })); describe("pdf fonts", () => { - // oxlint-disable-next-line require-await const fetchMock = vi.fn(async () => - new Response(new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x00, 0x00]), { - status: 200, - headers: { "content-type": "font/ttf" }, - }) + await Promise.resolve( + new Response(new Uint8Array([0x00, 0x01, 0x00, 0x00, 0x00, 0x00]), { + status: 200, + headers: { "content-type": "font/ttf" }, + }), + ) ); beforeEach(() => { diff --git a/src/__tests__/ports.test.ts b/src/__tests__/ports.test.ts index 3ab0de8..65cffa9 100644 --- a/src/__tests__/ports.test.ts +++ b/src/__tests__/ports.test.ts @@ -2,6 +2,7 @@ import { appVersionGet, backendEvents, batch, + dirMove, docList, docMove, docOpen, @@ -734,6 +735,31 @@ describe("document Commands", () => { }); }); + describe(dirMove, () => { + it("should create command payload without target location by default", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + const cmd = dirMove(11, "notes", "archive/notes", onOk, onErr) as InvokeCmd; + + expect(cmd.type).toBe("Invoke"); + expect(cmd.command).toBe("dir_move"); + expect(cmd.payload).toStrictEqual({ locationId: 11, relPath: "notes", newRelPath: "archive/notes" }); + }); + + it("should include target location for cross-location directory moves", () => { + const onOk = vi.fn(); + const onErr = vi.fn(); + const cmd = dirMove(11, "notes", "notes", onOk, onErr, 22) as InvokeCmd; + + expect(cmd.payload).toStrictEqual({ + locationId: 11, + relPath: "notes", + newRelPath: "notes", + targetLocationId: 22, + }); + }); + }); + describe(renderMarkdown, () => { it("should create command with expected payload keys", () => { const onOk = vi.fn(); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index a0f795f..167d533 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,4 +1,3 @@ -// oxlint-disable max-classes-per-file import "@testing-library/jest-dom"; import { vi } from "vitest"; @@ -35,16 +34,15 @@ vi.mock( ); vi.mock("@tauri-apps/api/event", () => ({ - // oxlint-disable-next-line require-await listen: vi.fn(async (eventName: string, handler: EventHandler) => { if (!mockListeners.has(eventName)) { mockListeners.set(eventName, new Set()); } mockListeners.get(eventName)!.add(handler); - return () => { + return await Promise.resolve(() => { mockListeners.get(eventName)?.delete(handler); - }; + }); }), })); diff --git a/src/__tests__/useExternalDropHandler.test.tsx b/src/__tests__/useExternalDropHandler.test.tsx index 70f0fc5..66b2430 100644 --- a/src/__tests__/useExternalDropHandler.test.tsx +++ b/src/__tests__/useExternalDropHandler.test.tsx @@ -29,11 +29,11 @@ describe("useExternalDropHandler", () => { } as never); vi.mocked(readTextFile).mockResolvedValue("# imported"); - const setExternalDropTarget = vi.fn(); + const setActiveDropTarget = vi.fn(); const refreshSidebar = vi.fn(); const handleImportExternalFile = vi.fn().mockResolvedValue(true); - renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar, handleImportExternalFile)); + renderHook(() => useExternalDropHandler(1, DOCS, setActiveDropTarget, refreshSidebar, handleImportExternalFile)); await act(async () => { await dragDropListener?.({ @@ -50,6 +50,7 @@ describe("useExternalDropHandler", () => { expect(refreshSidebar).toHaveBeenCalledWith(1); expect(showSuccessToast).toHaveBeenCalledWith("Imported 1 file"); expect(showWarnToast).toHaveBeenCalledWith("Skipped 1 file (already exists)"); + expect(setActiveDropTarget).toHaveBeenCalledWith(null); }); it("uses hovered folder target from pointer position", async () => { @@ -62,29 +63,100 @@ describe("useExternalDropHandler", () => { } as never); const hitTarget = document.createElement("div"); + hitTarget.dataset.dropFolderRow = "true"; hitTarget.dataset.locationId = "2"; hitTarget.dataset.folderPath = "archive/2026"; - globalThis.document.elementFromPoint = vi.fn(() => hitTarget); + hitTarget.getBoundingClientRect = () => + ({ + left: 0, + right: 300, + top: 0, + bottom: 100, + width: 300, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + vi.spyOn(document, "elementFromPoint").mockReturnValue(hitTarget); vi.mocked(readTextFile).mockResolvedValue("# moved by drop"); - const setExternalDropTarget = vi.fn(); + const setActiveDropTarget = vi.fn(); const refreshSidebar = vi.fn(); const handleImportExternalFile = vi.fn().mockResolvedValue(true); - renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar, handleImportExternalFile)); + renderHook(() => useExternalDropHandler(1, DOCS, setActiveDropTarget, refreshSidebar, handleImportExternalFile)); await act(async () => { - await dragDropListener?.({ payload: { type: "enter", position: { x: 12, y: 18 }, paths: ["/tmp/entry.md"] } }); - await dragDropListener?.({ payload: { type: "over", position: { x: 12, y: 18 } } }); - await dragDropListener?.({ payload: { type: "drop", position: { x: 12, y: 18 }, paths: ["/tmp/entry.md"] } }); + await dragDropListener?.({ payload: { type: "enter", position: { x: 12, y: 50 }, paths: ["/tmp/entry.md"] } }); + await dragDropListener?.({ payload: { type: "over", position: { x: 12, y: 50 } } }); + await dragDropListener?.({ payload: { type: "drop", position: { x: 12, y: 50 }, paths: ["/tmp/entry.md"] } }); }); - expect(setExternalDropTarget).toHaveBeenCalledWith(2); - expect(setExternalDropTarget.mock.calls.at(-1)?.[0]).toBeUndefined(); + expect(setActiveDropTarget).toHaveBeenCalledWith({ + source: "external", + locationId: 2, + targetType: "folder", + folderPath: "archive/2026", + intent: "into", + }); + expect(setActiveDropTarget.mock.calls.at(-1)).toEqual([null]); expect(handleImportExternalFile).toHaveBeenCalledWith(2, "archive/2026/entry.md", "# moved by drop"); expect(refreshSidebar).toHaveBeenCalledWith(2); }); + it("uses parent folder when pointer hovers a document row", async () => { + let dragDropListener: ((event: { payload: unknown }) => Promise) | undefined; + vi.mocked(getCurrentWindow).mockReturnValue({ + onDragDropEvent: vi.fn((listener) => { + dragDropListener = listener; + return Promise.resolve(() => {}); + }), + } as never); + + const hitTarget = document.createElement("div"); + hitTarget.dataset.dropDocumentRow = "true"; + hitTarget.dataset.locationId = "2"; + hitTarget.dataset.documentPath = "archive/2026/entry.md"; + hitTarget.getBoundingClientRect = () => + ({ + left: 0, + right: 300, + top: 0, + bottom: 100, + width: 300, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + vi.spyOn(document, "elementFromPoint").mockReturnValue(hitTarget); + vi.mocked(readTextFile).mockResolvedValue("# from document hover"); + + const setActiveDropTarget = vi.fn(); + const refreshSidebar = vi.fn(); + const handleImportExternalFile = vi.fn().mockResolvedValue(true); + + renderHook(() => useExternalDropHandler(1, DOCS, setActiveDropTarget, refreshSidebar, handleImportExternalFile)); + + await act(async () => { + await dragDropListener?.({ payload: { type: "enter", position: { x: 50, y: 42 }, paths: ["/tmp/entry.md"] } }); + await dragDropListener?.({ payload: { type: "over", position: { x: 50, y: 42 } } }); + await dragDropListener?.({ payload: { type: "drop", position: { x: 50, y: 42 }, paths: ["/tmp/entry.md"] } }); + }); + + expect(setActiveDropTarget).toHaveBeenCalledWith({ + source: "external", + locationId: 2, + targetType: "document", + relPath: "archive/2026/entry.md", + folderPath: "archive/2026", + edge: "bottom", + intent: "between", + }); + expect(handleImportExternalFile).toHaveBeenCalledWith(2, "archive/2026/entry.md", "# from document hover"); + }); + it("ignores non-file drops from internal drag and drop", async () => { let dragDropListener: ((event: { payload: unknown }) => Promise) | undefined; vi.mocked(getCurrentWindow).mockReturnValue({ @@ -94,11 +166,11 @@ describe("useExternalDropHandler", () => { }), } as never); - const setExternalDropTarget = vi.fn(); + const setActiveDropTarget = vi.fn(); const refreshSidebar = vi.fn(); const handleImportExternalFile = vi.fn().mockResolvedValue(true); - renderHook(() => useExternalDropHandler(1, DOCS, setExternalDropTarget, refreshSidebar, handleImportExternalFile)); + renderHook(() => useExternalDropHandler(1, DOCS, setActiveDropTarget, refreshSidebar, handleImportExternalFile)); await act(async () => { await dragDropListener?.({ payload: { type: "enter", position: { x: 4, y: 8 }, paths: [] } }); @@ -110,6 +182,6 @@ describe("useExternalDropHandler", () => { expect(refreshSidebar).not.toHaveBeenCalled(); expect(showWarnToast).not.toHaveBeenCalled(); expect(showSuccessToast).not.toHaveBeenCalled(); - expect(setExternalDropTarget).toHaveBeenCalledWith(undefined); + expect(setActiveDropTarget).toHaveBeenCalledWith(null); }); }); diff --git a/src/__tests__/useWorkspaceController.test.tsx b/src/__tests__/useWorkspaceController.test.tsx index 7713641..d17ac05 100644 --- a/src/__tests__/useWorkspaceController.test.tsx +++ b/src/__tests__/useWorkspaceController.test.tsx @@ -270,4 +270,38 @@ describe("useWorkspaceController", () => { expect.any(Function), ); }); + + it("updates open tabs with target location for cross-location directory moves", async () => { + useTabsStore.setState({ + tabs: [{ + id: "tab-1", + docRef: { location_id: 1, rel_path: "Samples/some-file.md" }, + title: "Some File", + isModified: false, + }], + activeTabId: "tab-1", + isSessionHydrated: true, + }); + vi.mocked(dirMove).mockImplementation((_locationId, _relPath, _newRelPath, onOk, _onErr, targetLocationId) => { + expect(targetLocationId).toBe(2); + onOk("Imported/Samples"); + return { type: "None" }; + }); + + const { result } = renderHook(() => useWorkspaceController()); + + const moved = await act(async () => { + return await result.current.handleMoveDirectory(1, "Samples", "Imported/Samples", 2); + }); + + expect(moved).toBeTruthy(); + expect(sessionUpdateTabDoc).toHaveBeenCalledWith( + 1, + "Samples/some-file.md", + { location_id: 2, rel_path: "Imported/Samples/some-file.md" }, + "Some File", + expect.any(Function), + expect.any(Function), + ); + }); }); diff --git a/src/components/Sidebar/DocumentItem.tsx b/src/components/Sidebar/DocumentItem.tsx index 3eb8f2d..e6ff459 100644 --- a/src/components/Sidebar/DocumentItem.tsx +++ b/src/components/Sidebar/DocumentItem.tsx @@ -24,6 +24,8 @@ type DocumentItemProps = { filenameVisibility: boolean; activeDropDocumentPath?: string; activeDropDocumentEdge?: Edge | null; + activeDropDocumentIsReorder?: boolean; + suppressDraggingAppearance?: boolean; }; export function DocumentItem( @@ -36,6 +38,8 @@ export function DocumentItem( filenameVisibility, activeDropDocumentPath, activeDropDocumentEdge, + activeDropDocumentIsReorder = false, + suppressDraggingAppearance = false, }: DocumentItemProps, ) { const { isOpen, position, open, close } = useContextMenu(); @@ -117,6 +121,7 @@ export function DocumentItem( const isActiveDropDocument = activeDropDocumentPath === doc.rel_path; const closestEdge = isActiveDropDocument ? activeDropDocumentEdge ?? null : null; + const isReorderTarget = isActiveDropDocument && activeDropDocumentIsReorder; const edgeStyle = useMemo(() => { if (!closestEdge) { return {}; @@ -140,15 +145,17 @@ export function DocumentItem( level={level} onClick={handleClick} onContextMenu={handleContextMenu} - isDragging={dragState === "dragging"} - isDropTarget={isActiveDropDocument} /> + isDragging={dragState === "dragging" && !suppressDraggingAppearance} + isDropTarget={isActiveDropDocument && !isReorderTarget} /> {closestEdge && (
+ style={edgeStyle}> +
+
)}
diff --git a/src/components/Sidebar/DragGhost.tsx b/src/components/Sidebar/DragGhost.tsx new file mode 100644 index 0000000..c5a4137 --- /dev/null +++ b/src/components/Sidebar/DragGhost.tsx @@ -0,0 +1,14 @@ +import { createPortal } from "react-dom"; + +type DragGhostProps = { label: string | null }; + +export function DragGhost({ label }: DragGhostProps) { + if (typeof document === "undefined") { + return null; + } + + return createPortal( + , + document.body, + ); +} diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index 2d137b3..a783f5f 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -1,7 +1,5 @@ import { Button } from "$components/Button"; -import { extractClosestEdge } from "$dnd"; import { useSidebarActions } from "$hooks/controllers/useSidebarActions"; -import { useExternalDropHandler } from "$hooks/useExternalDropHandler"; import { CollapseIcon, FileAddIcon, FolderAddIcon, RefreshIcon } from "$icons"; import { useSidebarState } from "$state/selectors"; import type { DocMeta } from "$types"; @@ -12,6 +10,7 @@ import { type DocumentOperationRequest, type DocumentOperationType, } from "./DocumentOperationDialog"; +import { DragGhost } from "./DragGhost"; import { EmptyLocations } from "./EmptyLocations"; import { SearchInput } from "./SearchInput"; import { SidebarLocationItem, SidebarLocationProvider } from "./SidebarLocationItem"; @@ -68,7 +67,6 @@ export function Sidebar({ onNewDocument }: SidebarProps) { handleMoveDocument, handleMoveDirectory, handleDeleteDocument, - handleImportExternalFile, } = useSidebarActions(); const { locations, @@ -81,11 +79,13 @@ export function Sidebar({ onNewDocument }: SidebarProps) { sidebarRefreshReason, filterText, setDocuments, + activeDropTarget, + folderSortOrderByLocation, + setActiveDropTarget, + reorderFolderSortOrder, selectLocation, toggleSidebarCollapsed, filenameVisibility, - externalDropTargetId, - setExternalDropTarget, } = useSidebarState(); const [expandedLocations, setExpandedLocations] = useState>(() => new Set(locations.map((l) => l.id))); @@ -95,19 +95,13 @@ export function Sidebar({ onNewDocument }: SidebarProps) { locations, documents, setDocuments, + setActiveDropTarget, + reorderFolderSortOrder, handleMoveDocument, handleMoveDirectory, handleRefreshSidebar, }); - useExternalDropHandler( - selectedLocationId, - documents, - setExternalDropTarget, - handleRefreshSidebar, - handleImportExternalFile, - ); - const locationDocuments = useMemo( () => (selectedLocationId ? documents.filter((doc) => doc.location_id === selectedLocationId) : []), [documents, selectedLocationId], @@ -189,15 +183,22 @@ export function Sidebar({ onNewDocument }: SidebarProps) { const isRefreshingLocation = refreshingLocationId === location.id; const locationDocs = isSelectedLocation ? filteredDocuments : EMPTY_DOCUMENTS; const locationDirs = isSelectedLocation ? filteredDirectories : EMPTY_DIRECTORIES; - const isActiveDropLocation = internalDnd.activeInternalDropTarget?.locationId === location.id; - const activeDropDocumentPath = - isActiveDropLocation && internalDnd.activeInternalDropTarget?.targetType === "document" - ? internalDnd.activeInternalDropTarget.relPath - : undefined; - const activeDropDocumentEdge = - isActiveDropLocation && internalDnd.activeInternalDropTarget?.targetType === "document" - ? extractClosestEdge(internalDnd.activeInternalDropTarget) - : null; + const isActiveDropLocation = activeDropTarget?.locationId === location.id; + const activeDropDocumentPath = isActiveDropLocation && activeDropTarget?.targetType === "document" + ? activeDropTarget.relPath + : undefined; + const activeDropDocumentEdge = isActiveDropLocation && activeDropTarget?.targetType === "document" + ? (activeDropTarget.edge ?? null) + : null; + const activeDropFolderPath = isActiveDropLocation && activeDropTarget?.targetType === "folder" + ? activeDropTarget.folderPath + : undefined; + const activeDropFolderEdge = isActiveDropLocation && activeDropTarget?.targetType === "folder" + ? (activeDropTarget.edge ?? null) + : null; + const activeDragDocumentPath = internalDnd.activeDragDocumentLocationId === location.id + ? internalDnd.activeDragDocumentPath + : null; return { location, @@ -209,19 +210,34 @@ export function Sidebar({ onNewDocument }: SidebarProps) { filterText, isRefreshing: isRefreshingLocation, refreshReason: sidebarRefreshReason, - isExternalDropTarget: externalDropTargetId === location.id, - isInternalDropTarget: isActiveDropLocation && internalDnd.activeInternalDropTarget?.targetType === "location", - activeDropFolderPath: isActiveDropLocation ? internalDnd.activeInternalDropTarget?.folderPath : undefined, + isExternalDropTarget: isActiveDropLocation && activeDropTarget?.source === "external", + isInternalDropTarget: isActiveDropLocation && activeDropTarget?.source === "internal" + && activeDropTarget.targetType === "location", + isDragInProgress: internalDnd.isDraggingInternal + || (isActiveDropLocation && activeDropTarget?.source === "external"), + activeDropFolderPath, + activeDropFolderEdge, + activeDropFolderIntent: activeDropTarget?.targetType === "folder" ? activeDropTarget.intent : undefined, activeDropDocumentPath, activeDropDocumentEdge, + activeDropDocumentIsReorder: isActiveDropLocation + && activeDropTarget?.targetType === "document" + && activeDropTarget.intent === "between", + activeDragDocumentPath, + suppressActiveDragSourceOpacity: internalDnd.suppressActiveDragSourceOpacity, + folderSortOrder: folderSortOrderByLocation[location.id] ?? [], }; }), [ + activeDropTarget, expandedLocations, - externalDropTargetId, filterText, + folderSortOrderByLocation, filteredDirectories, filteredDocuments, - internalDnd.activeInternalDropTarget, + internalDnd.isDraggingInternal, + internalDnd.activeDragDocumentLocationId, + internalDnd.activeDragDocumentPath, + internalDnd.suppressActiveDragSourceOpacity, locations, refreshingLocationId, selectedDocPath, @@ -279,6 +295,7 @@ export function Sidebar({ onNewDocument }: SidebarProps) { onMoveDocument={handleMoveDocument} onDeleteDocument={handleDeleteDocument} onRefreshSidebar={handleRefreshSidebar} /> + {internalDnd.moveDialog} ); diff --git a/src/components/Sidebar/SidebarLocationItem.tsx b/src/components/Sidebar/SidebarLocationItem.tsx index cad562a..3bdd222 100644 --- a/src/components/Sidebar/SidebarLocationItem.tsx +++ b/src/components/Sidebar/SidebarLocationItem.tsx @@ -40,9 +40,16 @@ type SidebarLocationItemProps = { refreshReason: SidebarRefreshReason | null; isExternalDropTarget?: boolean; isInternalDropTarget?: boolean; + isDragInProgress?: boolean; activeDropFolderPath?: string; + activeDropFolderEdge?: Edge | null; + activeDropFolderIntent?: "into" | "between"; activeDropDocumentPath?: string; activeDropDocumentEdge?: Edge | null; + activeDropDocumentIsReorder?: boolean; + activeDragDocumentPath?: string | null; + suppressActiveDragSourceOpacity?: boolean; + folderSortOrder?: string[]; onRemoveLocation: (locationId: number) => void; onSelectLocation: (locationId: number) => void; onRefreshLocation: (locationId: number) => void; @@ -78,8 +85,13 @@ type SidebarTreeContextValue = { onOpenDocumentOperation: (type: DocumentOperationType, doc: DocMeta, anchor?: DialogAnchor) => void; dropIndicators: { activeDropFolderPath?: string; + activeDropFolderEdge?: Edge | null; + activeDropFolderIntent?: "into" | "between"; activeDropDocumentPath?: string; activeDropDocumentEdge?: Edge | null; + activeDropDocumentIsReorder?: boolean; + activeDragDocumentPath?: string | null; + suppressActiveDragSourceOpacity?: boolean; }; }; @@ -206,7 +218,10 @@ function TreeDocumentNode({ doc, level }: { doc: DocMeta; level: number }) { filenameVisibility={filenameVisibility} level={level} activeDropDocumentPath={dropIndicators.activeDropDocumentPath} - activeDropDocumentEdge={dropIndicators.activeDropDocumentEdge} /> + activeDropDocumentEdge={dropIndicators.activeDropDocumentEdge} + activeDropDocumentIsReorder={dropIndicators.activeDropDocumentIsReorder} + suppressDraggingAppearance={Boolean(dropIndicators.suppressActiveDragSourceOpacity) + && dropIndicators.activeDragDocumentPath === doc.rel_path} /> ); } @@ -216,7 +231,17 @@ function NestedDirectoryItem({ node, level, expandedDirectories, onToggleDirecto const folderRowRef = useRef(null); const [dragState, setDragState] = useState<"idle" | "dragging">("idle"); const skipAnimation = useSkipAnimation(); - const showDropTarget = dropIndicators.activeDropFolderPath === node.path; + const isActiveFolderTarget = dropIndicators.activeDropFolderPath === node.path; + const isDropIntoTarget = isActiveFolderTarget && dropIndicators.activeDropFolderIntent !== "between"; + const folderEdge = isActiveFolderTarget ? dropIndicators.activeDropFolderEdge ?? null : null; + const showInsertionLine = isActiveFolderTarget && dropIndicators.activeDropFolderIntent === "between" + && (folderEdge === "top" || folderEdge === "bottom"); + const edgeStyle = useMemo(() => { + if (!folderEdge) { + return {}; + } + return { [folderEdge === "top" ? "top" : "bottom"]: "-1px" }; + }, [folderEdge]); useEffect(() => { const rowElement = folderRowRef.current; @@ -250,7 +275,7 @@ function NestedDirectoryItem({ node, level, expandedDirectories, onToggleDirecto data-folder-depth={level} className={cn( "pb-0.5", - showDropTarget ? "ring-2 ring-border-interactive rounded bg-layer-hover-01" : "", + isDropIntoTarget ? "rounded border-2 border-border-interactive bg-layer-hover-01" : "", skipAnimation ? "" : "transition-[box-shadow,background-color] duration-150", )}> + {showInsertionLine + ? ( +
+
+
+ ) + : null}
{isExpanded @@ -300,9 +337,16 @@ function SidebarLocationItemComponent( refreshReason, isExternalDropTarget, isInternalDropTarget, + isDragInProgress = false, activeDropFolderPath, + activeDropFolderEdge, + activeDropFolderIntent, activeDropDocumentPath, activeDropDocumentEdge, + activeDropDocumentIsReorder, + activeDragDocumentPath, + suppressActiveDragSourceOpacity, + folderSortOrder = [], onRemoveLocation, onSelectLocation, onRefreshLocation, @@ -310,6 +354,9 @@ function SidebarLocationItemComponent( ) { const { filenameVisibility, documentActions, onToggleLocation, openDocumentOperation } = useSidebarLocationContext(); const [expandedDirectories, setExpandedDirectories] = useState>(new Set()); + const hoverExpandRef = useRef<{ path: string; timer: ReturnType } | null>(null); + const dragExpandedSnapshotRef = useRef | null>(null); + const autoExpandedDuringDragRef = useRef>(new Set()); const skipAnimation = useSkipAnimation(); const showHighlight = Boolean(isExternalDropTarget) || Boolean(isInternalDropTarget); const showRootDropIndicator = Boolean(isInternalDropTarget) && !activeDropFolderPath && !activeDropDocumentPath; @@ -330,7 +377,11 @@ function SidebarLocationItemComponent( onToggleLocation(location.id); }, [location.id, onToggleLocation]); - const documentTree = useMemo(() => buildDocumentTree(documents, directories), [documents, directories]); + const documentTree = useMemo(() => buildDocumentTree(documents, directories, folderSortOrder), [ + documents, + directories, + folderSortOrder, + ]); useEffect(() => { if (!selectedDocPath) { @@ -367,6 +418,80 @@ function SidebarLocationItemComponent( }); }, []); + const clearHoverExpand = useCallback(() => { + if (!hoverExpandRef.current) { + return; + } + globalThis.clearTimeout(hoverExpandRef.current.timer); + hoverExpandRef.current = null; + }, []); + + useEffect(() => { + if ( + !isDragInProgress || activeDropFolderIntent !== "into" || !activeDropFolderPath + || expandedDirectories.has(activeDropFolderPath) + ) { + clearHoverExpand(); + return; + } + + if (hoverExpandRef.current?.path === activeDropFolderPath) { + return; + } + + clearHoverExpand(); + const folderPath = activeDropFolderPath; + hoverExpandRef.current = { + path: folderPath, + timer: globalThis.setTimeout(() => { + setExpandedDirectories((previous) => { + if (previous.has(folderPath)) { + return previous; + } + if (!dragExpandedSnapshotRef.current?.has(folderPath)) { + autoExpandedDuringDragRef.current.add(folderPath); + } + return new Set([...previous, folderPath]); + }); + hoverExpandRef.current = null; + }, 800), + }; + }, [activeDropFolderIntent, activeDropFolderPath, clearHoverExpand, expandedDirectories, isDragInProgress]); + + useEffect(() => { + if (isDragInProgress) { + if (!dragExpandedSnapshotRef.current) { + dragExpandedSnapshotRef.current = new Set(expandedDirectories); + autoExpandedDuringDragRef.current = new Set(); + } + return; + } + + clearHoverExpand(); + const snapshot = dragExpandedSnapshotRef.current; + if (!snapshot) { + return; + } + + const autoExpanded = autoExpandedDuringDragRef.current; + if (autoExpanded.size > 0) { + setExpandedDirectories((previous) => { + const next = new Set(previous); + for (const path of autoExpanded) { + if (!snapshot.has(path)) { + next.delete(path); + } + } + return next; + }); + } + + dragExpandedSnapshotRef.current = null; + autoExpandedDuringDragRef.current = new Set(); + }, [clearHoverExpand, expandedDirectories, isDragInProgress]); + + useEffect(() => () => clearHoverExpand(), [clearHoverExpand]); + const treeContextValue = useMemo( () => ({ locationId: location.id, @@ -379,17 +504,31 @@ function SidebarLocationItemComponent( onDeleteDocument: documentActions.onDeleteDocument, }, onOpenDocumentOperation: openDocumentOperation, - dropIndicators: { activeDropFolderPath, activeDropDocumentPath, activeDropDocumentEdge }, + dropIndicators: { + activeDropFolderPath, + activeDropFolderEdge, + activeDropFolderIntent, + activeDropDocumentPath, + activeDropDocumentEdge, + activeDropDocumentIsReorder, + activeDragDocumentPath, + suppressActiveDragSourceOpacity, + }, }), [ activeDropDocumentEdge, + activeDropDocumentIsReorder, activeDropDocumentPath, + activeDropFolderEdge, + activeDropFolderIntent, activeDropFolderPath, + activeDragDocumentPath, documentActions, filenameVisibility, location.id, openDocumentOperation, selectedDocPath, + suppressActiveDragSourceOpacity, ], ); diff --git a/src/components/Sidebar/buildDocumentTree.ts b/src/components/Sidebar/buildDocumentTree.ts index 36fc641..0594752 100644 --- a/src/components/Sidebar/buildDocumentTree.ts +++ b/src/components/Sidebar/buildDocumentTree.ts @@ -19,25 +19,47 @@ function ensureDirectoryNode(parent: DirectoryTreeNode, name: string, path: stri return directoryNode; } -function sortTreeNodes(children: TreeNode[]): TreeNode[] { +function sortTreeNodes(children: TreeNode[], directoryOrderIndex: Map): TreeNode[] { return children.toSorted((left, right) => { if (left.type !== right.type) { return left.type === "directory" ? -1 : 1; } + if (left.type === "directory" && right.type === "directory") { + const leftOrder = directoryOrderIndex.get(left.path); + const rightOrder = directoryOrderIndex.get(right.path); + if (leftOrder !== undefined && rightOrder !== undefined && leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + if (leftOrder !== undefined && rightOrder === undefined) { + return -1; + } + if (leftOrder === undefined && rightOrder !== undefined) { + return 1; + } + } + return left.name.localeCompare(right.name, void 0, { sensitivity: "base" }); }); } -function normalizeTree(node: DirectoryTreeNode): DirectoryTreeNode { - const normalizedChildren = sortTreeNodes(node.children).map((child) => - child.type === "directory" ? normalizeTree(child) : child +function normalizeTree(node: DirectoryTreeNode, directoryOrderIndex: Map): DirectoryTreeNode { + const normalizedChildren = sortTreeNodes(node.children, directoryOrderIndex).map((child) => + child.type === "directory" ? normalizeTree(child, directoryOrderIndex) : child ); return { ...node, children: normalizedChildren }; } -export function buildDocumentTree(documents: DocMeta[], directories: string[]): DirectoryTreeNode { +export function buildDocumentTree( + documents: DocMeta[], + directories: string[], + directoryOrder: string[] = [], +): DirectoryTreeNode { const root: DirectoryTreeNode = { type: "directory", name: "", path: "", children: [] }; + const directoryOrderIndex = new Map(); + for (let index = 0; index < directoryOrder.length; index += 1) { + directoryOrderIndex.set(directoryOrder[index], index); + } for (const directoryPath of directories) { const segments = splitPathSegments(directoryPath); @@ -73,7 +95,7 @@ export function buildDocumentTree(documents: DocMeta[], directories: string[]): currentParent.children.push({ type: "file", name: fileName, path: doc.rel_path, doc }); } - return normalizeTree(root); + return normalizeTree(root, directoryOrderIndex); } export function parentDirectoryPaths(relPath: string): string[] { diff --git a/src/components/Sidebar/useSidebarInternalDnD.ts b/src/components/Sidebar/useSidebarInternalDnD.ts index 1bbd120..e1851b5 100644 --- a/src/components/Sidebar/useSidebarInternalDnD.ts +++ b/src/components/Sidebar/useSidebarInternalDnD.ts @@ -1,24 +1,28 @@ import { announce, - attachClosestEdge, cleanup as cleanupLiveRegion, type DestinationData, dropTargetForElements, + type Edge, extractClosestEdge, monitorForElements, + normalizePointerCoordinates, resolveDestinationFromPointer, } from "$dnd"; import { + checkDropDocumentIntoFolder, getFilename, getParentDirectoryPath, isSidebarDragData, pointerFromInput, reorderDocumentsInLocation, resolveDestinationFromDropTargets, + type SidebarDragData, summarizePointerInput, walkUpToValidDestination, } from "$dnd/sidebar"; import { showErrorToast, showSuccessToast, showWarnToast } from "$state/stores/toasts"; +import type { SidebarActiveDropTarget } from "$state/types"; import type { DocMeta, LocationDescriptor } from "$types"; import { f } from "$utils/serialize"; import * as logger from "@tauri-apps/plugin-log"; @@ -38,43 +42,257 @@ type UseSidebarInternalDnDArgs = { locations: LocationDescriptor[]; documents: DocMeta[]; setDocuments: (documents: DocMeta[]) => void; + setActiveDropTarget: (target: SidebarActiveDropTarget | null) => void; + reorderFolderSortOrder: (locationId: number, sourcePath: string, destinationPath: string, edge: Edge) => void; handleMoveDocument: ( locationId: number, relPath: string, newRelPath: string, targetLocationId?: number, ) => Promise; - handleMoveDirectory: (locationId: number, relPath: string, newRelPath: string) => Promise; + handleMoveDirectory: ( + locationId: number, + relPath: string, + newRelPath: string, + targetLocationId?: number, + ) => Promise; handleRefreshSidebar: (locationId?: number) => void; }; type SidebarInternalDnDResult = { dropZoneRef: RefObject; handleDragOver: (event: DragEvent) => void; - handleDragLeave: () => void; - activeInternalDropTarget: DestinationData | null; + handleDragLeave: (event: DragEvent) => void; + isDraggingInternal: boolean; + dragGhostLabel: string | null; + activeDragDocumentPath: string | null; + activeDragDocumentLocationId: number | null; + suppressActiveDragSourceOpacity: boolean; moveDialog: ReactNode; }; +type ResolvedDropDestination = { + pointerDestination: DestinationData | null; + dropTargetDestination: DestinationData | null; + rawDestination: DestinationData | null; + destination: DestinationData | null; + isNoopDrop: boolean; +}; + +const UNSET_DROP_DESTINATION = Symbol("unset-drop-destination"); +const EDGE_SCROLL_THRESHOLD_PX = 40; +const EDGE_SCROLL_STEP_PX = 14; +const DRAG_LEAVE_CLEAR_DELAY_MS = 40; + +function resolveDropDestination( + sourceData: SidebarDragData, + nativeDragPos: { x: number; y: number } | null, + locationInput: unknown, + dropTargets: unknown, +): ResolvedDropDestination { + const pos = nativeDragPos ?? pointerFromInput(locationInput); + const pointerDestination = pos ? resolveDestinationFromPointer(pos.x, pos.y)?.destination ?? null : null; + const dropTargetDestination = resolveDestinationFromDropTargets(dropTargets); + + let rawDestination = pointerDestination && pointerDestination.targetType !== "location" + ? pointerDestination + : (dropTargetDestination ?? pointerDestination); + + if ( + rawDestination?.targetType === "document" + && rawDestination.relPath + && dropTargetDestination?.targetType === "document" + && dropTargetDestination.relPath === rawDestination.relPath + && dropTargetDestination.locationId === rawDestination.locationId + ) { + rawDestination = dropTargetDestination; + } + + const isNoopDrop = sourceData.type === "document" + && rawDestination?.folderPath !== undefined + && checkDropDocumentIntoFolder(sourceData, rawDestination.locationId, rawDestination.folderPath) === "noop"; + const rawEdge = rawDestination ? extractClosestEdge(rawDestination) : null; + const isFolderSiblingReorder = sourceData.type === "folder" + && rawDestination?.targetType === "folder" + && rawDestination.folderPath !== undefined + && (rawEdge === "top" || rawEdge === "bottom") + && sourceData.locationId === rawDestination.locationId + && sourceData.relPath !== rawDestination.folderPath + && getParentDirectoryPath(sourceData.relPath) === getParentDirectoryPath(rawDestination.folderPath); + + return { + pointerDestination, + dropTargetDestination, + rawDestination, + destination: rawDestination + ? (isFolderSiblingReorder ? rawDestination : walkUpToValidDestination(sourceData, rawDestination)) + : null, + isNoopDrop, + }; +} + export function useSidebarInternalDnD( - { locations, documents, setDocuments, handleMoveDocument, handleMoveDirectory, handleRefreshSidebar }: - UseSidebarInternalDnDArgs, + { + locations, + documents, + setDocuments, + setActiveDropTarget, + reorderFolderSortOrder, + handleMoveDocument, + handleMoveDirectory, + handleRefreshSidebar, + }: UseSidebarInternalDnDArgs, ): SidebarInternalDnDResult { const [moveDropDialog, setMoveDropDialog] = useState(null); const [moveDropPath, setMoveDropPath] = useState(""); const [isMovingDrop, setIsMovingDrop] = useState(false); - const [activeInternalDropTarget, setActiveInternalDropTarget] = useState(null); + const [isDraggingInternal, setIsDraggingInternal] = useState(false); + const [dragGhostLabel, setDragGhostLabel] = useState(null); + const [activeDragDocumentPath, setActiveDragDocumentPath] = useState(null); + const [activeDragDocumentLocationId, setActiveDragDocumentLocationId] = useState(null); + const [suppressActiveDragSourceOpacity, setSuppressActiveDragSourceOpacity] = useState(false); const dropZoneRef = useRef(null); const nativeDragPosRef = useRef<{ x: number; y: number } | null>(null); - - const handleDragOver = useCallback((event: DragEvent) => { - nativeDragPosRef.current = { x: event.clientX, y: event.clientY }; + const resolvedDestinationRef = useRef(UNSET_DROP_DESTINATION); + const edgeScrollRafRef = useRef(null); + const edgeScrollDirectionRef = useRef<1 | -1 | 0>(0); + const dragLeaveClearTimerRef = useRef | null>(null); + + const cancelDeferredDragLeaveClear = useCallback(() => { + if (dragLeaveClearTimerRef.current !== null) { + globalThis.clearTimeout(dragLeaveClearTimerRef.current); + dragLeaveClearTimerRef.current = null; + } }, []); - const handleDragLeave = useCallback(() => { - nativeDragPosRef.current = null; + const stopEdgeScroll = useCallback(() => { + if (edgeScrollRafRef.current !== null) { + cancelAnimationFrame(edgeScrollRafRef.current); + edgeScrollRafRef.current = null; + } + edgeScrollDirectionRef.current = 0; }, []); + const applyEdgeScrollForY = useCallback((clientY: number | null) => { + const scrollContainer = dropZoneRef.current; + if (!scrollContainer || clientY === null) { + stopEdgeScroll(); + return; + } + + const rect = scrollContainer.getBoundingClientRect(); + let nextDirection: 1 | -1 | 0 = 0; + if (clientY <= rect.top + EDGE_SCROLL_THRESHOLD_PX) { + nextDirection = -1; + } else if (clientY >= rect.bottom - EDGE_SCROLL_THRESHOLD_PX) { + nextDirection = 1; + } + + if (nextDirection === 0) { + stopEdgeScroll(); + return; + } + + edgeScrollDirectionRef.current = nextDirection; + if (edgeScrollRafRef.current !== null) { + return; + } + + const tick = () => { + const container = dropZoneRef.current; + const direction = edgeScrollDirectionRef.current; + if (!container || direction === 0) { + edgeScrollRafRef.current = null; + return; + } + + const previousTop = container.scrollTop; + container.scrollTop = Math.max( + 0, + Math.min(container.scrollHeight - container.clientHeight, previousTop + direction * EDGE_SCROLL_STEP_PX), + ); + + if (container.scrollTop === previousTop) { + edgeScrollRafRef.current = null; + return; + } + + edgeScrollRafRef.current = requestAnimationFrame(tick); + }; + + edgeScrollRafRef.current = requestAnimationFrame(tick); + }, [stopEdgeScroll]); + + const handleDragOver = useCallback((event: DragEvent) => { + cancelDeferredDragLeaveClear(); + const normalized = normalizePointerCoordinates(event.clientX, event.clientY); + nativeDragPosRef.current = normalized; + applyEdgeScrollForY(normalized.y); + }, [applyEdgeScrollForY, cancelDeferredDragLeaveClear]); + + const handleDragLeave = useCallback((event: DragEvent) => { + const container = dropZoneRef.current; + if (!container) { + return; + } + + const related = event.relatedTarget; + if (related instanceof Node && container.contains(related)) { + return; + } + + const rect = container.getBoundingClientRect(); + const leavePoint = { x: event.clientX, y: event.clientY }; + const pointInsideContainer = leavePoint.x >= rect.left && leavePoint.x <= rect.right + && leavePoint.y >= rect.top + && leavePoint.y <= rect.bottom; + if (pointInsideContainer) { + return; + } + + cancelDeferredDragLeaveClear(); + dragLeaveClearTimerRef.current = globalThis.setTimeout(() => { + dragLeaveClearTimerRef.current = null; + const currentContainer = dropZoneRef.current; + const latestPoint = nativeDragPosRef.current ?? leavePoint; + if (!currentContainer) { + nativeDragPosRef.current = null; + stopEdgeScroll(); + return; + } + + const currentRect = currentContainer.getBoundingClientRect(); + const stillInside = latestPoint.x >= currentRect.left && latestPoint.x <= currentRect.right + && latestPoint.y >= currentRect.top + && latestPoint.y <= currentRect.bottom; + if (stillInside) { + return; + } + + nativeDragPosRef.current = null; + stopEdgeScroll(); + }, DRAG_LEAVE_CLEAR_DELAY_MS); + }, [cancelDeferredDragLeaveClear, stopEdgeScroll]); + + useEffect(() => { + const handleGlobalDragEnd = () => { + setActiveDropTarget(null); + setIsDraggingInternal(false); + setDragGhostLabel(null); + setSuppressActiveDragSourceOpacity(false); + setActiveDragDocumentPath(null); + setActiveDragDocumentLocationId(null); + resolvedDestinationRef.current = UNSET_DROP_DESTINATION; + cancelDeferredDragLeaveClear(); + stopEdgeScroll(); + }; + + globalThis.addEventListener("dragend", handleGlobalDragEnd); + return () => { + globalThis.removeEventListener("dragend", handleGlobalDragEnd); + }; + }, [cancelDeferredDragLeaveClear, setActiveDropTarget, stopEdgeScroll]); + useEffect(() => { const dropZoneElement = dropZoneRef.current; if (!dropZoneElement) { @@ -90,15 +308,6 @@ export function useSidebarInternalDnD( if (!resolved) { return { targetType: "none" as const }; } - - if (resolved.destination.targetType === "document" && resolved.element) { - return attachClosestEdge(resolved.destination, { - input, - element: resolved.element, - allowedEdges: ["top", "bottom"], - }); - } - return resolved.destination; }, }); @@ -114,28 +323,53 @@ export function useSidebarInternalDnD( if (!isSidebarDragData(source.data)) { return; } + setIsDraggingInternal(true); + setDragGhostLabel(source.data.title); + resolvedDestinationRef.current = UNSET_DROP_DESTINATION; + setActiveDropTarget(null); + setSuppressActiveDragSourceOpacity(false); + if (source.data.type === "document") { + setActiveDragDocumentPath(source.data.relPath); + setActiveDragDocumentLocationId(source.data.locationId); + } else { + setActiveDragDocumentPath(null); + setActiveDragDocumentLocationId(null); + } announce(`Picked up ${source.data.title}`); }, onDropTargetChange: ({ source, location }) => { if (!isSidebarDragData(source.data)) { - setActiveInternalDropTarget(null); + setActiveDropTarget(null); + setSuppressActiveDragSourceOpacity(false); + resolvedDestinationRef.current = null; return; } - const pos = nativeDragPosRef.current ?? pointerFromInput(location.current.input); - const pointerDestination = pos ? resolveDestinationFromPointer(pos.x, pos.y)?.destination ?? null : null; - const dropTargetDestination = resolveDestinationFromDropTargets(location.current.dropTargets); - const rawDestination = pointerDestination && pointerDestination.targetType !== "location" - ? pointerDestination - : (dropTargetDestination ?? pointerDestination); - const destinationData = rawDestination ? walkUpToValidDestination(source.data, rawDestination) : null; + const resolution = resolveDropDestination( + source.data, + nativeDragPosRef.current, + location.current.input, + location.current.dropTargets, + ); + resolvedDestinationRef.current = resolution.destination; + setSuppressActiveDragSourceOpacity(resolution.isNoopDrop); + const destinationData = resolution.destination; if (!destinationData) { - setActiveInternalDropTarget(null); + setActiveDropTarget(null); return; } - setActiveInternalDropTarget(destinationData); + const edge = extractClosestEdge(destinationData); + setActiveDropTarget({ + source: "internal", + locationId: destinationData.locationId, + targetType: destinationData.targetType ?? "location", + ...(destinationData.folderPath ? { folderPath: destinationData.folderPath } : {}), + ...(destinationData.relPath ? { relPath: destinationData.relPath } : {}), + ...(edge ? { edge } : {}), + intent: edge ? "between" : "into", + }); if (destinationData.folderPath) { announce(`Over ${destinationData.folderPath} in ${getLocationName(destinationData.locationId)}`); @@ -150,24 +384,37 @@ export function useSidebarInternalDnD( announce(`Over ${getLocationName(destinationData.locationId)}`); }, onDrop: ({ source, location }) => { - setActiveInternalDropTarget(null); + setActiveDropTarget(null); + setIsDraggingInternal(false); + setDragGhostLabel(null); + setSuppressActiveDragSourceOpacity(false); + setActiveDragDocumentPath(null); + setActiveDragDocumentLocationId(null); + cancelDeferredDragLeaveClear(); + stopEdgeScroll(); if (!isSidebarDragData(source.data)) { + resolvedDestinationRef.current = UNSET_DROP_DESTINATION; return; } - const pos = nativeDragPosRef.current ?? pointerFromInput(location.current.input); - const pointerDestination = pos ? resolveDestinationFromPointer(pos.x, pos.y)?.destination ?? null : null; - const dropTargetDestination = resolveDestinationFromDropTargets(location.current.dropTargets); - const rawDestination = pointerDestination && pointerDestination.targetType !== "location" - ? pointerDestination - : (dropTargetDestination ?? pointerDestination); - const destinationData = rawDestination ? walkUpToValidDestination(source.data, rawDestination) : null; + const resolution = resolveDropDestination( + source.data, + nativeDragPosRef.current, + location.current.input, + location.current.dropTargets, + ); + const destinationData = resolvedDestinationRef.current === UNSET_DROP_DESTINATION + ? resolution.destination + : resolvedDestinationRef.current; + resolvedDestinationRef.current = UNSET_DROP_DESTINATION; logger.warn( f("Sidebar DnD drop trace", { source: source.data, pointer: summarizePointerInput(location.current.input), - rawDestination, + pointerDestination: resolution.pointerDestination, + dropTargetDestination: resolution.dropTargetDestination, + rawDestination: resolution.rawDestination, resolvedDestination: destinationData, }), ); @@ -191,17 +438,37 @@ export function useSidebarInternalDnD( ?? (destinationData.targetType === "document" && destinationData.relPath ? getParentDirectoryPath(destinationData.relPath) : ""); + const destinationEdge = extractClosestEdge(destinationData); if (sourceData.type === "folder") { - if (resolvedTargetLocationId !== sourceData.locationId) { - announce("Could not move folder across locations"); - showWarnToast("Moving folders across locations is not supported yet"); + if ( + destinationData.targetType === "folder" && destinationData.folderPath + && (destinationEdge === "top" || destinationEdge === "bottom") + && resolvedTargetLocationId === sourceData.locationId + && getParentDirectoryPath(sourceData.relPath) === getParentDirectoryPath(destinationData.folderPath) + ) { + reorderFolderSortOrder( + sourceData.locationId, + sourceData.relPath, + destinationData.folderPath, + destinationEdge, + ); + announce( + `Moved ${sourceData.title} ${destinationEdge === "top" ? "before" : "after"} ${ + getFilename(destinationData.folderPath) + }`, + ); return; } - const newRelPath = destinationData.folderPath - ? `${destinationData.folderPath}/${sourceFilename}` - : sourceFilename; + const destinationFolderPath = destinationData.folderPath ?? ""; + const siblingParentPath = destinationFolderPath + ? getParentDirectoryPath(destinationFolderPath) + : destinationParentPath; + const nextParentPath = destinationEdge === "top" || destinationEdge === "bottom" + ? siblingParentPath + : destinationFolderPath || destinationParentPath; + const newRelPath = nextParentPath ? `${nextParentPath}/${sourceFilename}` : sourceFilename; if (modifierDrop) { setMoveDropDialog({ @@ -221,27 +488,28 @@ export function useSidebarInternalDnD( return; } - void Promise.resolve(handleMoveDirectory(sourceData.locationId, sourceData.relPath, newRelPath)).then( - (moved) => { - if (!moved) { - announce(`Could not move ${sourceData.title}`); - showErrorToast(`Could not move ${sourceData.title}`); - return; - } + void Promise.resolve( + handleMoveDirectory(sourceData.locationId, sourceData.relPath, newRelPath, resolvedTargetLocationId), + ).then((moved) => { + if (!moved) { + announce(`Could not move ${sourceData.title}`); + showErrorToast(`Could not move ${sourceData.title}`); + return; + } - handleRefreshSidebar(sourceData.locationId); - announce(`Moved ${sourceData.title}`); - showSuccessToast(`Moved ${sourceData.title}`); - }, - ).catch((error: unknown) => { + handleRefreshSidebar(sourceData.locationId); + if (resolvedTargetLocationId !== sourceData.locationId) { + handleRefreshSidebar(resolvedTargetLocationId); + } + announce(`Moved ${sourceData.title}`); + showSuccessToast(`Moved ${sourceData.title}`); + }).catch((error: unknown) => { logger.error(f("Failed to move folder", { source: sourceData, dest: destinationData, error })); showErrorToast(`Could not move ${sourceData.title}`); }); return; } - const destinationEdge = extractClosestEdge(destinationData); - if (modifierDrop) { const nextPathFromDestination = destinationParentPath ? `${destinationParentPath}/${sourceFilename}` @@ -396,11 +664,30 @@ export function useSidebarInternalDnD( }); return () => { - setActiveInternalDropTarget(null); + setActiveDropTarget(null); + setIsDraggingInternal(false); + setDragGhostLabel(null); + setSuppressActiveDragSourceOpacity(false); + setActiveDragDocumentPath(null); + setActiveDragDocumentLocationId(null); + resolvedDestinationRef.current = UNSET_DROP_DESTINATION; + cancelDeferredDragLeaveClear(); + stopEdgeScroll(); stop(); cleanupLiveRegion(); }; - }, [documents, handleMoveDirectory, handleMoveDocument, handleRefreshSidebar, locations, setDocuments]); + }, [ + documents, + handleMoveDirectory, + handleMoveDocument, + handleRefreshSidebar, + locations, + reorderFolderSortOrder, + setDocuments, + setActiveDropTarget, + cancelDeferredDragLeaveClear, + stopEdgeScroll, + ]); const closeMoveDropDialog = useCallback(() => { if (isMovingDrop) { @@ -428,7 +715,12 @@ export function useSidebarInternalDnD( setIsMovingDrop(true); try { const moved = moveDropDialog.sourceType === "folder" - ? await handleMoveDirectory(moveDropDialog.sourceLocationId, moveDropDialog.sourceRelPath, nextPath) + ? await handleMoveDirectory( + moveDropDialog.sourceLocationId, + moveDropDialog.sourceRelPath, + nextPath, + moveDropDialog.targetLocationId, + ) : await handleMoveDocument( moveDropDialog.sourceLocationId, moveDropDialog.sourceRelPath, @@ -443,9 +735,7 @@ export function useSidebarInternalDnD( } handleRefreshSidebar(moveDropDialog.sourceLocationId); - if ( - moveDropDialog.sourceType === "document" && moveDropDialog.targetLocationId !== moveDropDialog.sourceLocationId - ) { + if (moveDropDialog.targetLocationId !== moveDropDialog.sourceLocationId) { handleRefreshSidebar(moveDropDialog.targetLocationId); } announce(`Moved ${moveDropDialog.sourceTitle}`); @@ -478,5 +768,15 @@ export function useSidebarInternalDnD( confirmDisabled: !moveDropPathTrimmed || isMoveDropUnchanged, }); - return { dropZoneRef, handleDragOver, handleDragLeave, activeInternalDropTarget, moveDialog }; + return { + dropZoneRef, + handleDragOver, + handleDragLeave, + isDraggingInternal, + dragGhostLabel, + activeDragDocumentPath, + activeDragDocumentLocationId, + suppressActiveDragSourceOpacity, + moveDialog, + }; } diff --git a/src/dnd/index.ts b/src/dnd/index.ts index 36fb678..bf135da 100644 --- a/src/dnd/index.ts +++ b/src/dnd/index.ts @@ -1,12 +1,12 @@ export type Edge = "top" | "bottom"; type DragInput = { clientX: number; clientY: number; x: number; y: number; altKey: boolean }; - type DragSource = { data: unknown; element: HTMLElement }; - type DropTarget = { element: HTMLElement; data: unknown }; - type DragLocation = { dropTargets: DropTarget[]; input: DragInput }; +type EdgeWith = T & { [EDGE_KEY]?: Edge }; +type AttachOpts = { input: DragInput; element: HTMLElement; allowedEdges: Edge[] }; +type ResolvedDestination = { destination: DestinationData; element: HTMLElement }; type DraggableArgs = { element: HTMLElement; @@ -49,6 +49,9 @@ export type DestinationData = { const INTERNAL_MIME = "application/x-writer-sidebar-dnd"; const EDGE_KEY = "__writerClosestEdge"; const LIVE_REGION_ID = "writer-dnd-live-region"; +const DROP_ROW_SELECTOR = + "[data-drop-document-row][data-location-id], [data-drop-folder-row][data-location-id], [data-drop-location-root][data-location-id]"; +const LOCATION_SELECTOR = "[data-location-id]"; const monitors = new Set(); let activeDrag: ActiveDrag | null = null; @@ -107,8 +110,22 @@ function isPointInViewport(x: number, y: number): boolean { return x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight; } +function isPointInsideRect(x: number, y: number, rect: DOMRect): boolean { + return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom; +} + +/** + * When both are valid, check if elements exist at either position. + * On Tauri/macOS with Retina displays, DragEvent coordinates can be + * in physical pixels while the DOM layout uses CSS pixels. Prefer + * whichever coordinate finds an actual sidebar drop target. + */ function normalizePoint(rawX: number, rawY: number): { x: number; y: number } { const dpr = window.devicePixelRatio || 1; + if (dpr <= 1) { + return { x: rawX, y: rawY }; + } + const direct = { x: rawX, y: rawY }; const scaled = { x: rawX / dpr, y: rawY / dpr }; @@ -121,7 +138,18 @@ function normalizePoint(rawX: number, rawY: number): { x: number; y: number } { if (!directValid && scaledValid) { return scaled; } + if (directValid && scaledValid) { + const selector = + `[data-drop-document-row], [data-drop-folder-row], [data-drop-location-root], ${LOCATION_SELECTOR}`; + const directHit = document.elementFromPoint(direct.x, direct.y); + const scaledHit = document.elementFromPoint(scaled.x, scaled.y); + const directMatch = directHit instanceof HTMLElement && directHit.closest(selector); + const scaledMatch = scaledHit instanceof HTMLElement && scaledHit.closest(selector); + + if (scaledMatch && !directMatch) { + return scaled; + } return direct; } @@ -218,6 +246,27 @@ function refreshDropEffect(event: DragEvent, canDrop: boolean): void { event.dataTransfer.dropEffect = canDrop ? "move" : "none"; } +function toDragGhostLabel(data: unknown): string { + if (!data || typeof data !== "object") { + return "Moving item"; + } + + const maybe = data as Partial<{ title: string; relPath: string; rel_path: string }>; + if (typeof maybe.title === "string" && maybe.title.trim()) { + return maybe.title.trim(); + } + + const relPath = typeof maybe.relPath === "string" + ? maybe.relPath + : (typeof maybe.rel_path === "string" ? maybe.rel_path : ""); + if (!relPath) { + return "Moving item"; + } + + const parts = relPath.split(/[\\/]+/).filter(Boolean); + return parts.at(-1) ?? relPath; +} + function startInternalDrag(event: DragEvent, args: DraggableArgs): void { const data = args.getInitialData(); if (data === undefined) { @@ -236,6 +285,14 @@ function startInternalDrag(event: DragEvent, args: DraggableArgs): void { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData(INTERNAL_MIME, "1"); event.dataTransfer.setData("text/plain", "writer-sidebar-drag"); + + if (typeof event.dataTransfer.setDragImage === "function") { + const ghost = document.querySelector("#sidebar-drag-ghost"); + if (ghost) { + ghost.textContent = toDragGhostLabel(data); + event.dataTransfer.setDragImage(ghost, 12, 12); + } + } } args.onDragStart?.(); @@ -261,6 +318,13 @@ export function draggable(args: DraggableArgs): () => void { return; } + if (event.dataTransfer?.dropEffect === "none") { + activeDrag.sourceOnDrop?.(); + activeDrag = null; + lastKnownPoint = null; + return; + } + const input = readDragInputFromEvent(event); finalizeActiveDrag(input); }; @@ -338,6 +402,10 @@ export function dropTargetForElements(args: DropTargetArgs): () => void { } const input = readDragInputFromEvent(event); + const rect = element.getBoundingClientRect(); + if (isPointInsideRect(input.clientX, input.clientY, rect)) { + return; + } updateLocationForMonitors(makeEmptyLocation(input)); }; @@ -375,10 +443,7 @@ export function monitorForElements(args: MonitorArgs): () => void { }; } -export function attachClosestEdge( - data: T, - options: { input: DragInput; element: HTMLElement; allowedEdges: Edge[] }, -): T & { [EDGE_KEY]?: Edge } { +export function attachClosestEdge(data: T, options: AttachOpts): EdgeWith { if (options.allowedEdges.length === 0) { return data; } @@ -465,93 +530,99 @@ function parseLocationId(locationIdRaw: string | undefined): number | null { return Number.isNaN(locationId) ? null : locationId; } -function toDestinationDataFromElement(element: HTMLElement): DestinationData | null { - const locationId = parseLocationId(element.dataset.locationId); - if (locationId === null) { - return null; - } - - if (element.dataset.documentPath) { - return { locationId, relPath: element.dataset.documentPath, targetType: "document" }; - } - - if (element.dataset.folderPath) { - return { locationId, folderPath: element.dataset.folderPath, targetType: "folder" }; - } - - return { locationId, targetType: "location" }; -} - -export function resolveDestinationFromPointer( +function resolveDestinationAtPoint( x: number, y: number, ): { destination: DestinationData; element: HTMLElement } | null { - const hitElement = document.elementFromPoint(x, y); - const locationElement = hitElement instanceof HTMLElement - ? hitElement.closest("[data-location-id]") - : null; - if (locationElement) { - const destination = toDestinationDataFromElement(locationElement); - if (destination) { - return { destination, element: locationElement }; + const hitElements = typeof document.elementsFromPoint === "function" ? document.elementsFromPoint(x, y) : (() => { + const hit = document.elementFromPoint(x, y); + return hit ? [hit] : []; + })(); + for (const elementCandidate of hitElements) { + if (!(elementCandidate instanceof HTMLElement)) { + continue; } - } - - const allTargets = document.querySelectorAll( - "[data-drop-folder-row][data-location-id], [data-drop-document-row][data-location-id], [data-drop-location-root][data-location-id]", - ); - let best: { destination: DestinationData; element: HTMLElement; priority: number; area: number } | null = null; + const rowElement = elementCandidate.closest(DROP_ROW_SELECTOR); + if (!rowElement) { + if (elementCandidate.closest("[data-drop-folder-zone]")) { + continue; + } - for (const element of allTargets) { - const rect = element.getBoundingClientRect(); - if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + const locationElement = elementCandidate.closest(LOCATION_SELECTOR); + const locationId = parseLocationId(locationElement?.dataset.locationId); + if (locationId !== null && locationElement) { + return { destination: { locationId, targetType: "location" }, element: locationElement }; + } continue; } - const destination = toDestinationDataFromElement(element); - if (!destination) { + const locationId = parseLocationId(rowElement.dataset.locationId); + if (locationId === null) { continue; } - const priority = destination.targetType === "folder" ? 3 : (destination.targetType === "document" ? 2 : 1); - const area = Math.max(1, rect.width * rect.height); - - if (!best || priority > best.priority || (priority === best.priority && area < best.area)) { - best = { destination, element, priority, area }; - } - } - - if (!best) { - let nearestDistance = Infinity; - let nearest: { destination: DestinationData; element: HTMLElement } | null = null; + const rect = rowElement.getBoundingClientRect(); + const height = Math.max(1, rect.height); + const relativeY = Math.max(0, Math.min(height, y - rect.top)); + const ratio = relativeY / height; + const zone: "top" | "middle" | "bottom" = ratio < 0.25 ? "top" : ratio <= 0.75 ? "middle" : "bottom"; - for (const element of allTargets) { - const rect = element.getBoundingClientRect(); - if (x < rect.left || x > rect.right) { + if (rowElement.dataset.dropDocumentRow === "true") { + const relPath = rowElement.dataset.documentPath; + if (!relPath) { continue; } - const destination = toDestinationDataFromElement(element); - if (!destination) { + const edge: Edge = zone === "top" ? "top" : "bottom"; + return { + destination: { locationId, targetType: "document", relPath, [EDGE_KEY]: edge } as DestinationData, + element: rowElement, + }; + } + + if (rowElement.dataset.dropFolderRow === "true") { + const folderPath = rowElement.dataset.folderPath; + if (!folderPath) { continue; } - const distance = y < rect.top ? rect.top - y : (y > rect.bottom ? y - rect.bottom : 0); - if (distance < nearestDistance) { - nearestDistance = distance; - nearest = { destination, element }; + if (zone === "middle") { + return { destination: { locationId, targetType: "folder", folderPath }, element: rowElement }; } + + const edge: Edge = zone === "top" ? "top" : "bottom"; + return { + destination: { locationId, targetType: "folder", folderPath, [EDGE_KEY]: edge } as DestinationData, + element: rowElement, + }; } - if (nearest && nearestDistance < 64) { - return nearest; + if (rowElement.dataset.dropLocationRoot === "true") { + return { destination: { locationId, targetType: "location" }, element: rowElement }; } + } - return null; + return null; +} + +/** + * On Tauri/macOS with Retina displays, DragEvent.clientX/Y can report physical + * pixels while elementsFromPoint expects CSS pixels. Fall back to DPR-scaled + * coordinates when the direct lookup finds nothing. + */ +export function resolveDestinationFromPointer(x: number, y: number): ResolvedDestination | null { + const result = resolveDestinationAtPoint(x, y); + if (result) { + return result; } - return { destination: best.destination, element: best.element }; + const dpr = window.devicePixelRatio || 1; + if (dpr > 1) { + return resolveDestinationAtPoint(x / dpr, y / dpr); + } + + return null; } export function normalizePointerCoordinates(x: number, y: number): { x: number; y: number } { diff --git a/src/dnd/sidebar.ts b/src/dnd/sidebar.ts index 534f869..dfca9a4 100644 --- a/src/dnd/sidebar.ts +++ b/src/dnd/sidebar.ts @@ -4,6 +4,7 @@ import type { DocMeta } from "$types"; export type FolderDragData = { type: "folder"; locationId: number; relPath: string; title: string }; export type SidebarDragData = DocumentDragData | FolderDragData; +export type DropValidity = "valid" | "noop" | "invalid"; type DestinationDropTarget = { data?: unknown }; type PointerInfo = { x?: number; y?: number; altKey?: boolean }; @@ -37,7 +38,10 @@ function destinationPriority(destination: DestinationData): number { function canDropIntoLocationTarget(sourceData: SidebarDragData, locationId: number): boolean { if (sourceData.type === "folder") { - return sourceData.locationId === locationId && getParentDirectoryPath(sourceData.relPath) !== ""; + if (sourceData.locationId !== locationId) { + return true; + } + return getParentDirectoryPath(sourceData.relPath) !== ""; } return sourceData.type === "document"; @@ -82,16 +86,24 @@ export function getParentDirectoryPath(relPath: string): string { return parts.length > 1 ? parts.slice(0, -1).join("/") : ""; } -export function canDropDocumentIntoFolder(sourceData: unknown, locationId: number, folderPath: string): boolean { +export function checkDropDocumentIntoFolder(sourceData: unknown, locationId: number, folderPath: string): DropValidity { if (!isDocumentDragData(sourceData)) { - return false; + return "invalid"; } if (sourceData.locationId !== locationId) { - return true; + return "valid"; + } + + if (getParentDirectoryPath(sourceData.relPath) === folderPath) { + return "noop"; } - return getParentDirectoryPath(sourceData.relPath) !== folderPath; + return "valid"; +} + +export function canDropDocumentIntoFolder(sourceData: unknown, locationId: number, folderPath: string): boolean { + return checkDropDocumentIntoFolder(sourceData, locationId, folderPath) === "valid"; } export function canDropFolderIntoFolder(sourceData: unknown, locationId: number, folderPath: string): boolean { @@ -99,19 +111,19 @@ export function canDropFolderIntoFolder(sourceData: unknown, locationId: number, return false; } - if (sourceData.locationId !== locationId) { - return false; - } + if (sourceData.locationId === locationId) { + if (sourceData.relPath === folderPath) { + return false; + } - if (sourceData.relPath === folderPath) { - return false; - } + if (folderPath.startsWith(`${sourceData.relPath}/`)) { + return false; + } - if (folderPath.startsWith(`${sourceData.relPath}/`)) { - return false; + return !isFolderDropNoop(sourceData.relPath, folderPath); } - return !isFolderDropNoop(sourceData.relPath, folderPath); + return true; } export function resolveDestinationFromDropTargets(dropTargets: unknown): DestinationData | null { @@ -140,7 +152,7 @@ export function canDropIntoDestination(sourceData: SidebarDragData, destination: return canDropFolderIntoFolder(sourceData, destination.locationId, destination.folderPath); } - return canDropDocumentIntoFolder(sourceData, destination.locationId, destination.folderPath); + return checkDropDocumentIntoFolder(sourceData, destination.locationId, destination.folderPath) === "valid"; } if (destination.targetType === "document") { @@ -154,7 +166,15 @@ export function walkUpToValidDestination( sourceData: SidebarDragData, destination: DestinationData, ): DestinationData | null { - if (canDropIntoDestination(sourceData, destination)) { + if (destination.folderPath && sourceData.type === "document") { + const validity = checkDropDocumentIntoFolder(sourceData, destination.locationId, destination.folderPath); + if (validity === "valid") { + return destination; + } + if (validity === "noop") { + return null; + } + } else if (canDropIntoDestination(sourceData, destination)) { return destination; } @@ -175,7 +195,15 @@ export function walkUpToValidDestination( folderPath: parentPath, targetType: "folder", }; - if (canDropIntoDestination(sourceData, parentDestination)) { + if (sourceData.type === "document") { + const validity = checkDropDocumentIntoFolder(sourceData, parentDestination.locationId, parentPath); + if (validity === "valid") { + return parentDestination; + } + if (validity === "noop") { + return null; + } + } else if (canDropIntoDestination(sourceData, parentDestination)) { return parentDestination; } } else { diff --git a/src/hooks/controllers/useSidebarActions.ts b/src/hooks/controllers/useSidebarActions.ts index 937e94e..fd00b8d 100644 --- a/src/hooks/controllers/useSidebarActions.ts +++ b/src/hooks/controllers/useSidebarActions.ts @@ -299,8 +299,9 @@ export function useSidebarActions() { }, [applySession]); const handleMoveDirectory = useCallback( - (locationId: number, relPath: string, newRelPath: string): Promise => { + (locationId: number, relPath: string, newRelPath: string, targetLocationId?: number): Promise => { return new Promise((resolve) => { + const resolvedTargetLocationId = targetLocationId ?? locationId; runCmd(dirMove(locationId, relPath, newRelPath, (resolvedPath) => { for (const tab of tabs) { if (tab.docRef.location_id !== locationId) { @@ -316,7 +317,7 @@ export function useSidebarActions() { sessionUpdateTabDoc( locationId, tab.docRef.rel_path, - { location_id: locationId, rel_path: remappedRelPath }, + { location_id: resolvedTargetLocationId, rel_path: remappedRelPath }, tab.title, applySession, () => {}, @@ -324,12 +325,27 @@ export function useSidebarActions() { ); } - logger.info(f("Directory moved", { locationId, relPath, newRelPath: resolvedPath })); + logger.info( + f("Directory moved", { + sourceLocationId: locationId, + targetLocationId: resolvedTargetLocationId, + relPath, + newRelPath: resolvedPath, + }), + ); resolve(true); }, (error: AppError) => { - logger.error(f("Failed to move directory", { locationId, relPath, newRelPath, error })); + logger.error( + f("Failed to move directory", { + sourceLocationId: locationId, + targetLocationId: resolvedTargetLocationId, + relPath, + newRelPath, + error, + }), + ); resolve(false); - })); + }, resolvedTargetLocationId)); }); }, [applySession, tabs], diff --git a/src/hooks/controllers/useWorkspaceController.ts b/src/hooks/controllers/useWorkspaceController.ts index 62a1039..f44508b 100644 --- a/src/hooks/controllers/useWorkspaceController.ts +++ b/src/hooks/controllers/useWorkspaceController.ts @@ -369,8 +369,9 @@ export function useWorkspaceController() { }, [applySession]); const handleMoveDirectory = useCallback( - (locationId: number, relPath: string, newRelPath: string): Promise => { + (locationId: number, relPath: string, newRelPath: string, targetLocationId?: number): Promise => { return new Promise((resolve) => { + const resolvedTargetLocationId = targetLocationId ?? locationId; runCmd(dirMove(locationId, relPath, newRelPath, (resolvedPath) => { for (const tab of tabs) { if (tab.docRef.location_id !== locationId) { @@ -386,7 +387,7 @@ export function useWorkspaceController() { sessionUpdateTabDoc( locationId, tab.docRef.rel_path, - { location_id: locationId, rel_path: remappedRelPath }, + { location_id: resolvedTargetLocationId, rel_path: remappedRelPath }, tab.title, applySession, () => {}, @@ -394,12 +395,27 @@ export function useWorkspaceController() { ); } - logger.info(f("Directory moved", { locationId, relPath, newRelPath: resolvedPath })); + logger.info( + f("Directory moved", { + sourceLocationId: locationId, + targetLocationId: resolvedTargetLocationId, + relPath, + newRelPath: resolvedPath, + }), + ); resolve(true); }, (error: AppError) => { - logger.error(f("Failed to move directory", { locationId, relPath, newRelPath, error })); + logger.error( + f("Failed to move directory", { + sourceLocationId: locationId, + targetLocationId: resolvedTargetLocationId, + relPath, + newRelPath, + error, + }), + ); resolve(false); - })); + }, resolvedTargetLocationId)); }); }, [applySession, tabs], diff --git a/src/hooks/useExternalDropHandler.ts b/src/hooks/useExternalDropHandler.ts index 4698ad4..ba03edf 100644 --- a/src/hooks/useExternalDropHandler.ts +++ b/src/hooks/useExternalDropHandler.ts @@ -1,14 +1,20 @@ import { normalizePointerCoordinates, resolveDestinationFromPointer } from "$dnd"; +import { extractClosestEdge } from "$dnd"; import { showSuccessToast, showWarnToast } from "$state/stores/toasts"; +import type { SidebarActiveDropTarget } from "$state/types"; import type { DocMeta } from "$types"; import { f } from "$utils/serialize"; import { getCurrentWindow } from "@tauri-apps/api/window"; import type { DragDropEvent } from "@tauri-apps/api/window"; import { readTextFile } from "@tauri-apps/plugin-fs"; import * as logger from "@tauri-apps/plugin-log"; -import { useEffect, useRef } from "react"; +import type { RefObject } from "react"; +import { useCallback, useEffect, useRef } from "react"; -type DropTargetInfo = { locationId: number; folderPath?: string }; +type DropTargetInfo = { locationId: number; folderPath?: string; activeDropTarget: SidebarActiveDropTarget }; + +const EDGE_SCROLL_THRESHOLD_PX = 40; +const EDGE_SCROLL_STEP_PX = 14; function getParentDirectoryPath(relPath: string): string | undefined { const parts = relPath.split("/").filter(Boolean); @@ -19,34 +25,128 @@ function getParentDirectoryPath(relPath: string): string | undefined { return parts.slice(0, -1).join("/"); } -function resolveDropTarget(x: number, y: number): DropTargetInfo | null { - const point = normalizePointerCoordinates(x, y); - const target = resolveDestinationFromPointer(point.x, point.y); +function resolveDropTarget(viewportX: number, viewportY: number): DropTargetInfo | null { + const target = resolveDestinationFromPointer(viewportX, viewportY); if (!target) { return null; } - if (target.destination.folderPath) { - return { locationId: target.destination.locationId, folderPath: target.destination.folderPath }; + const destination = target.destination; + const edge = extractClosestEdge(destination); + + if (destination.folderPath) { + const folderPath = destination.folderPath; + return { + locationId: destination.locationId, + folderPath, + activeDropTarget: { + source: "external", + locationId: destination.locationId, + targetType: "folder", + folderPath, + intent: "into", + }, + }; } - if (target.destination.targetType === "document" && target.destination.relPath) { - const parentFolderPath = getParentDirectoryPath(target.destination.relPath); - return { locationId: target.destination.locationId, ...(parentFolderPath ? { folderPath: parentFolderPath } : {}) }; + if (destination.targetType === "document" && destination.relPath) { + const parentFolderPath = getParentDirectoryPath(destination.relPath); + return { + locationId: destination.locationId, + ...(parentFolderPath ? { folderPath: parentFolderPath } : {}), + activeDropTarget: { + source: "external", + locationId: destination.locationId, + targetType: "document", + relPath: destination.relPath, + ...(parentFolderPath ? { folderPath: parentFolderPath } : {}), + ...(edge ? { edge } : {}), + intent: "between", + }, + }; } - return { locationId: target.destination.locationId }; + return { + locationId: destination.locationId, + activeDropTarget: { + source: "external", + locationId: destination.locationId, + targetType: "location", + intent: "into", + }, + }; } export function useExternalDropHandler( selectedLocationId: number | undefined, documents: DocMeta[], - setExternalDropTarget: (locationId?: number) => void, + setActiveDropTarget: (target: SidebarActiveDropTarget | null) => void, refreshSidebar: (locationId?: number) => void, handleImportExternalFile: (locationId: number, relPath: string, content: string) => Promise, + dropZoneRef?: RefObject, ) { const dropTargetRef = useRef(null); const hasExternalFileDragRef = useRef(false); + const edgeScrollRafRef = useRef(null); + const edgeScrollDirectionRef = useRef<1 | -1 | 0>(0); + + const stopEdgeScroll = useCallback(() => { + if (edgeScrollRafRef.current !== null) { + cancelAnimationFrame(edgeScrollRafRef.current); + edgeScrollRafRef.current = null; + } + edgeScrollDirectionRef.current = 0; + }, []); + + const applyEdgeScrollForY = useCallback((clientY: number | null) => { + const scrollContainer = dropZoneRef?.current; + if (!scrollContainer || clientY === null) { + stopEdgeScroll(); + return; + } + + const rect = scrollContainer.getBoundingClientRect(); + let nextDirection: 1 | -1 | 0 = 0; + if (clientY <= rect.top + EDGE_SCROLL_THRESHOLD_PX) { + nextDirection = -1; + } else if (clientY >= rect.bottom - EDGE_SCROLL_THRESHOLD_PX) { + nextDirection = 1; + } + + if (nextDirection === 0) { + stopEdgeScroll(); + return; + } + + edgeScrollDirectionRef.current = nextDirection; + if (edgeScrollRafRef.current !== null) { + return; + } + + const tick = () => { + const container = dropZoneRef?.current; + const direction = edgeScrollDirectionRef.current; + if (!container || direction === 0) { + edgeScrollRafRef.current = null; + return; + } + + const previousTop = container.scrollTop; + container.scrollTop = Math.max( + 0, + Math.min(container.scrollHeight - container.clientHeight, previousTop + direction * EDGE_SCROLL_STEP_PX), + ); + + if (container.scrollTop === previousTop) { + edgeScrollRafRef.current = null; + return; + } + + edgeScrollRafRef.current = requestAnimationFrame(tick); + }; + + edgeScrollRafRef.current = requestAnimationFrame(tick); + }, [dropZoneRef, stopEdgeScroll]); useEffect(() => { const window = getCurrentWindow(); @@ -59,7 +159,8 @@ export function useExternalDropHandler( hasExternalFileDragRef.current = dragEvent.paths.length > 0; if (!hasExternalFileDragRef.current) { dropTargetRef.current = null; - setExternalDropTarget(undefined); + stopEdgeScroll(); + setActiveDropTarget(null); } break; } @@ -68,16 +169,43 @@ export function useExternalDropHandler( return; } - const target = resolveDropTarget(dragEvent.position.x, dragEvent.position.y); + const point = normalizePointerCoordinates(dragEvent.position.x, dragEvent.position.y); + applyEdgeScrollForY(point.y); + const target = resolveDropTarget(point.x, point.y); const targetId = target?.locationId ?? selectedLocationId ?? null; - const targetKey = target ? `${target.locationId}:${target.folderPath ?? ""}` : null; + const targetKey = target + ? `${target.activeDropTarget.targetType}:${target.locationId}:${target.folderPath ?? ""}:${ + target.activeDropTarget.relPath ?? "" + }:${target.activeDropTarget.edge ?? ""}:${target.activeDropTarget.intent}` + : null; const currentKey = dropTargetRef.current - ? `${dropTargetRef.current.locationId}:${dropTargetRef.current.folderPath ?? ""}` + ? `${dropTargetRef.current.activeDropTarget.targetType}:${dropTargetRef.current.locationId}:${ + dropTargetRef.current.folderPath ?? "" + }:${dropTargetRef.current.activeDropTarget.relPath ?? ""}:${ + dropTargetRef.current.activeDropTarget.edge ?? "" + }:${dropTargetRef.current.activeDropTarget.intent}` : null; if (targetKey !== currentKey) { - dropTargetRef.current = target ?? (targetId ? { locationId: targetId } : null); - setExternalDropTarget(targetId ?? undefined); + dropTargetRef.current = target + ?? (targetId + ? { + locationId: targetId, + activeDropTarget: { + source: "external", + locationId: targetId, + targetType: "location", + intent: "into", + }, + } + : null); + if (target?.activeDropTarget) { + setActiveDropTarget(target.activeDropTarget); + } else if (targetId) { + setActiveDropTarget({ source: "external", locationId: targetId, targetType: "location", intent: "into" }); + } else { + setActiveDropTarget(null); + } } break; } @@ -89,7 +217,8 @@ export function useExternalDropHandler( hasExternalFileDragRef.current = false; dropTargetRef.current = null; - setExternalDropTarget(undefined); + stopEdgeScroll(); + setActiveDropTarget(null); if (!isExternalFileDrop || droppedPaths.length === 0) { return; @@ -159,15 +288,26 @@ export function useExternalDropHandler( default: { hasExternalFileDragRef.current = false; dropTargetRef.current = null; - setExternalDropTarget(undefined); + stopEdgeScroll(); + setActiveDropTarget(null); } } }); return () => { + stopEdgeScroll(); unlisten.then((fn) => fn()).catch((error) => { logger.error(f("Failed to unlisten from drag-drop events", { error })); }); }; - }, [selectedLocationId, documents, setExternalDropTarget, refreshSidebar, handleImportExternalFile]); + }, [ + selectedLocationId, + documents, + setActiveDropTarget, + refreshSidebar, + handleImportExternalFile, + dropZoneRef, + applyEdgeScrollForY, + stopEdgeScroll, + ]); } diff --git a/src/pdf/errors.ts b/src/pdf/errors.ts index f87af5d..4be7000 100644 --- a/src/pdf/errors.ts +++ b/src/pdf/errors.ts @@ -1,5 +1,3 @@ -// oxlint-disable max-classes-per-file - import type { PdfFontResolution, PdfFontSourceDescriptor } from "./types"; export type SerializedError = { diff --git a/src/ports/commands.ts b/src/ports/commands.ts index 217bdbc..0d8a2c0 100644 --- a/src/ports/commands.ts +++ b/src/ports/commands.ts @@ -197,8 +197,15 @@ export function dirRename(...[locationId, relPath, newName, onOk, onErr]: DirRen return invokeCmd("dir_rename", { locationId, relPath, newName }, onOk, onErr); } -export function dirMove(...[locationId, relPath, newRelPath, onOk, onErr]: DirMoveParams): Cmd { - return invokeCmd("dir_move", { locationId, relPath, newRelPath }, onOk, onErr); +export function dirMove( + ...[locationId, relPath, newRelPath, onOk, onErr, targetLocationId]: DirMoveParams +): Cmd { + return invokeCmd( + "dir_move", + { locationId, relPath, newRelPath, ...(targetLocationId === undefined ? {} : { targetLocationId }) }, + onOk, + onErr, + ); } export function dirDelete(...[locationId, relPath, onOk, onErr]: DirDeleteParams): Cmd { diff --git a/src/ports/types.ts b/src/ports/types.ts index 1cad3f6..a2eb646 100644 --- a/src/ports/types.ts +++ b/src/ports/types.ts @@ -122,7 +122,14 @@ export type DirRenameParams = Parameters< (locationId: LocationId, relPath: string, newName: string, onOk: SuccessCallback, onErr: ErrorCallback) => void >; export type DirMoveParams = Parameters< - (locationId: LocationId, relPath: string, newRelPath: string, onOk: SuccessCallback, onErr: ErrorCallback) => void + ( + locationId: LocationId, + relPath: string, + newRelPath: string, + onOk: SuccessCallback, + onErr: ErrorCallback, + targetLocationId?: LocationId, + ) => void >; export type SearchDateRangePayload = { from?: string; to?: string }; diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 989b67c..942914b 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -150,6 +150,9 @@ export const useWorkspaceDocumentsState = () => refreshingLocationId: state.refreshingLocationId, sidebarRefreshReason: state.sidebarRefreshReason, externalDropTargetId: state.externalDropTargetId, + externalDropFolderPath: state.externalDropFolderPath, + activeDropTarget: state.activeDropTarget, + folderSortOrderByLocation: state.folderSortOrderByLocation, })), ); @@ -162,6 +165,8 @@ export const useWorkspaceDocumentsActions = () => setLoadingDocuments: state.setLoadingDocuments, setSidebarRefreshState: state.setSidebarRefreshState, setExternalDropTarget: state.setExternalDropTarget, + setActiveDropTarget: state.setActiveDropTarget, + reorderFolderSortOrder: state.reorderFolderSortOrder, })), ); @@ -379,12 +384,17 @@ export const useSidebarState = () => { refreshingLocationId: state.refreshingLocationId, sidebarRefreshReason: state.sidebarRefreshReason, externalDropTargetId: state.externalDropTargetId, + externalDropFolderPath: state.externalDropFolderPath, + activeDropTarget: state.activeDropTarget, + folderSortOrderByLocation: state.folderSortOrderByLocation, filterText: state.sidebarFilter, setFilterText: state.setSidebarFilter, selectLocation: state.setSelectedLocation, setDocuments: state.setDocuments, setDirectories: state.setDirectories, setExternalDropTarget: state.setExternalDropTarget, + setActiveDropTarget: state.setActiveDropTarget, + reorderFolderSortOrder: state.reorderFolderSortOrder, })), ); @@ -398,6 +408,9 @@ export const useSidebarState = () => { refreshingLocationId: workspaceState.refreshingLocationId, sidebarRefreshReason: workspaceState.sidebarRefreshReason, externalDropTargetId: workspaceState.externalDropTargetId, + externalDropFolderPath: workspaceState.externalDropFolderPath, + activeDropTarget: workspaceState.activeDropTarget, + folderSortOrderByLocation: workspaceState.folderSortOrderByLocation, filterText: workspaceState.filterText, setFilterText: workspaceState.setFilterText, selectLocation: workspaceState.selectLocation, @@ -406,6 +419,8 @@ export const useSidebarState = () => { toggleSidebarCollapsed: layoutState.toggleSidebarCollapsed, filenameVisibility: layoutState.filenameVisibility, setExternalDropTarget: workspaceState.setExternalDropTarget, + setActiveDropTarget: workspaceState.setActiveDropTarget, + reorderFolderSortOrder: workspaceState.reorderFolderSortOrder, }; }; diff --git a/src/state/stores/workspace.ts b/src/state/stores/workspace.ts index 1785175..c1e03d0 100644 --- a/src/state/stores/workspace.ts +++ b/src/state/stores/workspace.ts @@ -1,4 +1,5 @@ import type { + SidebarDropEdge, SidebarRefreshReason, WorkspaceDocumentsActions, WorkspaceDocumentsState, @@ -29,6 +30,9 @@ export const getInitialWorkspaceDocumentsState = (): WorkspaceDocumentsState => refreshingLocationId: undefined, sidebarRefreshReason: null, externalDropTargetId: undefined, + externalDropFolderPath: undefined, + activeDropTarget: null, + folderSortOrderByLocation: {}, moveDialog: null, }); @@ -37,6 +41,11 @@ export const getInitialWorkspaceState = (): WorkspaceState => ({ ...getInitialWorkspaceDocumentsState(), }); +function parentOfPath(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts.length > 1 ? parts.slice(0, -1).join("/") : ""; +} + export const useWorkspaceStore = create()((set) => ({ ...getInitialWorkspaceState(), @@ -53,7 +62,65 @@ export const useWorkspaceStore = create()((set) => ({ setLoadingDocuments: (value) => set({ isLoadingDocuments: value }), setSidebarRefreshState: (locationId, reason: SidebarRefreshReason | null = null) => set({ refreshingLocationId: locationId, sidebarRefreshReason: locationId === undefined ? null : reason }), - setExternalDropTarget: (locationId) => set({ externalDropTargetId: locationId }), + setExternalDropTarget: (locationId, folderPath) => + set((state) => { + if (locationId === undefined) { + return { + externalDropTargetId: undefined, + externalDropFolderPath: undefined, + ...(state.activeDropTarget?.source === "external" ? { activeDropTarget: null } : {}), + }; + } + + return { + externalDropTargetId: locationId, + externalDropFolderPath: folderPath, + activeDropTarget: { + source: "external", + locationId, + targetType: folderPath ? "folder" : "location", + ...(folderPath ? { folderPath } : {}), + intent: "into" as const, + }, + }; + }), + setActiveDropTarget: (target) => + set({ + activeDropTarget: target, + externalDropTargetId: target?.source === "external" ? target.locationId : undefined, + externalDropFolderPath: target?.source === "external" ? target.folderPath : undefined, + }), + reorderFolderSortOrder: (locationId, sourcePath, destinationPath, edge: SidebarDropEdge) => + set((state) => { + if (sourcePath === destinationPath) { + return state; + } + + if (parentOfPath(sourcePath) !== parentOfPath(destinationPath)) { + return state; + } + + const currentOrder = [...(state.folderSortOrderByLocation[locationId] ?? [])]; + if (!currentOrder.includes(sourcePath)) { + currentOrder.push(sourcePath); + } + if (!currentOrder.includes(destinationPath)) { + currentOrder.push(destinationPath); + } + + const sourceIndex = currentOrder.indexOf(sourcePath); + const destinationIndex = currentOrder.indexOf(destinationPath); + if (sourceIndex === -1 || destinationIndex === -1) { + return state; + } + + currentOrder.splice(sourceIndex, 1); + const insertionBase = currentOrder.indexOf(destinationPath); + const insertionIndex = edge === "top" ? insertionBase : insertionBase + 1; + currentOrder.splice(insertionIndex, 0, sourcePath); + + return { folderSortOrderByLocation: { ...state.folderSortOrderByLocation, [locationId]: currentOrder } }; + }), openMoveDialog: (locationId, relPath) => set({ moveDialog: { locationId, relPath } }), closeMoveDialog: () => set({ moveDialog: null }), })); diff --git a/src/state/types.ts b/src/state/types.ts index f3c0005..91341f2 100644 --- a/src/state/types.ts +++ b/src/state/types.ts @@ -108,6 +108,20 @@ export type WorkspaceLocationsState = { }; export type SidebarRefreshReason = "manual" | "external"; +export type SidebarDropSource = "internal" | "external"; +export type SidebarDropIntent = "into" | "between"; +export type SidebarDropTargetType = "location" | "folder" | "document"; +export type SidebarDropEdge = "top" | "bottom"; + +export type SidebarActiveDropTarget = { + source: SidebarDropSource; + locationId: number; + targetType: SidebarDropTargetType; + folderPath?: string; + relPath?: string; + edge?: SidebarDropEdge; + intent: SidebarDropIntent; +}; type MoveDialogState = { locationId: number; relPath: string }; @@ -119,6 +133,9 @@ export type WorkspaceDocumentsState = { refreshingLocationId?: number; sidebarRefreshReason: SidebarRefreshReason | null; externalDropTargetId?: number; + externalDropFolderPath?: string; + activeDropTarget: SidebarActiveDropTarget | null; + folderSortOrderByLocation: Record; moveDialog: MoveDialogState | null; }; @@ -135,7 +152,14 @@ export type WorkspaceDocumentsActions = { setDirectories: (directories: string[]) => void; setLoadingDocuments: (value: boolean) => void; setSidebarRefreshState: (locationId?: number, reason?: SidebarRefreshReason | null) => void; - setExternalDropTarget: (locationId?: number) => void; + setExternalDropTarget: (locationId?: number, folderPath?: string) => void; + setActiveDropTarget: (target: SidebarActiveDropTarget | null) => void; + reorderFolderSortOrder: ( + locationId: number, + sourcePath: string, + destinationPath: string, + edge: SidebarDropEdge, + ) => void; openMoveDialog: (locationId: number, relPath: string) => void; closeMoveDialog: () => void; };