From b8d6b6a286453ef1a4083c5b803380e9f64986c1 Mon Sep 17 00:00:00 2001 From: Kareem Abdallah Date: Wed, 27 May 2026 00:31:52 +0300 Subject: [PATCH] feat: adding a drag and drop with some animations --- package-lock.json | 25 ++- src-tauri/src/lib.rs | 81 ++++++- src/components/layout/Sidebar.tsx | 273 +++++++++++++++++++----- src/components/notes/FolderTreeView.tsx | 187 ++++++++++------ src/components/notes/NoteList.tsx | 98 +++++++-- src/components/ui/index.tsx | 6 +- src/context/NotesContext.tsx | 52 ++++- src/hooks/useFlipAnimation.ts | 100 +++++++++ src/lib/folderTree.ts | 42 +++- src/types/note.ts | 2 + 10 files changed, 714 insertions(+), 152 deletions(-) create mode 100644 src/hooks/useFlipAnimation.ts diff --git a/package-lock.json b/package-lock.json index 8cb3fc0e..49e18fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -360,6 +361,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -852,6 +854,7 @@ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" @@ -2598,6 +2601,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.20.0.tgz", "integrity": "sha512-aC9aROgia/SpJqhsXFiX9TsligL8d+oeoI8W3u00WI45s0VfsqjgeKQLDLF7Tu7hC+7F02teC84SAHuup003VQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2681,6 +2685,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.20.0.tgz", "integrity": "sha512-lBbmNek14aCjrHcBcq3PRqWfNLvC6bcRa2Osc6e/LtmXlcpype4f6n+Yx+WZ+f2uUh0UmDRCz7BEyUETEsDmlQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2849,6 +2854,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.20.0.tgz", "integrity": "sha512-+V0/gsVWAv+7vcY0MAe6D52LYTIicMSHw00wz3ISZgprSb2yQhJ4+4gurOnUrQ4Du3AnRQvxPROaofwxIQ66WQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2956,6 +2962,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.20.0.tgz", "integrity": "sha512-vaaMtQ2KnSSr8WVwgWf7NYNzPwrHx/6T0ekA5CxV8qNUEpXIaLXa5+tE7tJHWEdNR2KY3gUJ46D3lfOkxyFrBQ==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3061,6 +3068,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.20.0.tgz", "integrity": "sha512-HIsXX942w3nbxEQBlMAAR/aa6qiMBEP7CsSMxaxmTIVAmW35p6yUASw6GdV1u0o3lCZjXq2OSRMTskzIqi5uLg==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -3092,6 +3100,7 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.20.0.tgz", "integrity": "sha512-jn+2KnQZn+b+VXr8EFOJKsnjVNaA4diAEr6FOazupMt8W8ro1hfpYtZ25JL87Kao/WbMze55sd8M8BDXLUKu1A==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3281,6 +3290,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3290,6 +3300,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -3397,6 +3408,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3710,6 +3722,7 @@ "version": "11.11.1", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "peer": true, "engines": { "node": ">=12.0.0" } @@ -3766,6 +3779,7 @@ "https://github.com/sponsors/katex" ], "license": "MIT", + "peer": true, "dependencies": { "commander": "^8.3.0" }, @@ -4053,6 +4067,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "peer": true, "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", @@ -4170,6 +4185,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4332,6 +4348,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", + "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -4361,6 +4378,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -4409,6 +4427,7 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.6.tgz", "integrity": "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg==", "license": "MIT", + "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -4429,6 +4448,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -4448,6 +4468,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4636,7 +4657,8 @@ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -4800,6 +4822,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 04f54a80..dabced9c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,6 +132,10 @@ pub struct Settings { pub custom_colors_light: Option>, #[serde(rename = "customColorsDark")] pub custom_colors_dark: Option>, + #[serde(rename = "noteOrder")] + pub note_order: Option>, + #[serde(rename = "folderOrder")] + pub folder_order: Option>, } // Search result @@ -949,17 +953,19 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str }) .collect(); - // Load pinned note IDs from settings - let pinned_ids: HashSet = { + // Load pinned note IDs and note_order from settings + let (pinned_ids, note_order): (HashSet, Option>) = { let settings = state.settings.read().expect("settings read lock"); - settings + let pinned = settings .pinned_note_ids .as_ref() .map(|ids| ids.iter().cloned().collect()) - .unwrap_or_default() + .unwrap_or_default(); + let order = settings.note_order.clone(); + (pinned, order) }; - // Sort: pinned notes first (by date), then unpinned notes (by date) + // Sort: pinned notes first, then unpinned notes (sorted by note_order, fallback to modified date) notes.sort_by(|a, b| { let a_pinned = pinned_ids.contains(&a.id); let b_pinned = pinned_ids.contains(&b.id); @@ -967,7 +973,21 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str match (a_pinned, b_pinned) { (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first (false, true) => std::cmp::Ordering::Greater, // b pinned, a not -> b first - _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) + _ => { + // both same status (both pinned or both unpinned) + if let Some(ref order) = note_order { + let a_idx = order.iter().position(|id| id == &a.id); + let b_idx = order.iter().position(|id| id == &b.id); + match (a_idx, b_idx) { + (Some(i), Some(j)) => i.cmp(&j), + (Some(_), None) => std::cmp::Ordering::Less, // ordered items first + (None, Some(_)) => std::cmp::Ordering::Greater, // ordered items first + (None, None) => b.modified.cmp(&a.modified), // both unordered -> newest first + } + } else { + b.modified.cmp(&a.modified) // sort by date (newest first) + } + } } }); @@ -1485,7 +1505,7 @@ async fn rename_folder( .await .map_err(|e| e.to_string())?; - // Update pinned note IDs in settings + // Update pinned note IDs, note_order, and folder_order in settings { let mut settings = state.settings.write().expect("settings write lock"); if let Some(ref mut pinned) = settings.pinned_note_ids { @@ -1497,6 +1517,24 @@ async fn rename_folder( } } } + if let Some(ref mut order) = settings.note_order { + for id in order.iter_mut() { + if id.starts_with(&old_prefix) { + *id = format!("{}{}", new_prefix, &id[old_prefix.len()..]); + } else if *id == old_path { + *id = new_path.clone(); + } + } + } + if let Some(ref mut order) = settings.folder_order { + for path in order.iter_mut() { + if path.starts_with(&old_prefix) { + *path = format!("{}{}", new_prefix, &path[old_prefix.len()..]); + } else if *path == old_path { + *path = new_path.clone(); + } + } + } // Save settings let _ = save_settings(&folder, &settings); } @@ -1586,7 +1624,7 @@ async fn move_note( .await .map_err(|e| e.to_string())?; - // Update pinned note IDs + // Update pinned note IDs and note_order { let mut settings = state.settings.write().expect("settings write lock"); if let Some(ref mut pinned) = settings.pinned_note_ids { @@ -1596,6 +1634,13 @@ async fn move_note( } } } + if let Some(ref mut order) = settings.note_order { + for ord_id in order.iter_mut() { + if *ord_id == id { + *ord_id = new_id.clone(); + } + } + } let _ = save_settings(&folder, &settings); } @@ -1691,7 +1736,7 @@ async fn move_folder( .await .map_err(|e| e.to_string())?; - // Update pinned note IDs + // Update pinned note IDs, note_order, and folder_order { let mut settings = state.settings.write().expect("settings write lock"); if let Some(ref mut pinned) = settings.pinned_note_ids { @@ -1701,6 +1746,24 @@ async fn move_folder( } } } + if let Some(ref mut order) = settings.note_order { + for id in order.iter_mut() { + if id.starts_with(&old_prefix) { + *id = format!("{}{}", new_prefix, &id[old_prefix.len()..]); + } else if *id == path { + *id = new_path.clone(); + } + } + } + if let Some(ref mut order) = settings.folder_order { + for p in order.iter_mut() { + if p.starts_with(&old_prefix) { + *p = format!("{}{}", new_prefix, &p[old_prefix.len()..]); + } else if *p == path { + *p = new_path.clone(); + } + } + } let _ = save_settings(&folder, &settings); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d2ad1884..508b5e10 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import type { Settings } from "../../types/note"; import { toast } from "sonner"; import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; import { @@ -11,6 +12,7 @@ import { type DragEndEvent, } from "@dnd-kit/core"; import { useNotes } from "../../context/NotesContext"; +import { useTheme } from "../../context/ThemeContext"; import { NoteList } from "../notes/NoteList"; import { Footer } from "./Footer"; import { IconButton, Input } from "../ui"; @@ -27,6 +29,24 @@ import { mod, shift, isMac } from "../../lib/platform"; import * as notesService from "../../services/notes"; import { FolderNameDialog } from "../notes/FolderNameDialog"; +function reorderItems( + array: T[], + activeItem: T, + overItem: T, + position: "before" | "after" +): T[] { + const newArray = array.filter((item) => item !== activeItem); + const overIndex = newArray.indexOf(overItem); + if (overIndex === -1) return array; + + if (position === "before") { + newArray.splice(overIndex, 0, activeItem); + } else { + newArray.splice(overIndex + 1, 0, activeItem); + } + return newArray; +} + interface SidebarProps { onOpenSettings?: () => void; } @@ -42,15 +62,20 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { selectedNoteId, moveNote, moveFolder, + refreshNotes, + settings, + updateSettings, } = useNotes(); + const { reloadSettings } = useTheme(); const [searchOpen, setSearchOpen] = useState(false); const [inputValue, setInputValue] = useState(searchQuery); const [plusMenuOpen, setPlusMenuOpen] = useState(false); const [folderDialogOpen, setFolderDialogOpen] = useState(false); const [folderDialogParent, setFolderDialogParent] = useState(""); - const [foldersEnabled, setFoldersEnabled] = useState(true); + const foldersEnabled = settings?.foldersEnabled === true; const [dragLabel, setDragLabel] = useState(null); const [dragCount, setDragCount] = useState(1); + const [dragOverInfo, setDragOverInfo] = useState<{ id: string; position: "before" | "after" } | null>(null); const [multiSelectedNoteIds, setMultiSelectedNoteIds] = useState>(new Set()); const [lastClickedNoteId, setLastClickedNoteId] = useState(null); const debounceRef = useRef(null); @@ -58,12 +83,19 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { const multiSelectedRef = useRef(multiSelectedNoteIds) as RefObject>; multiSelectedRef.current = multiSelectedNoteIds; + const cursorYRef = useRef(0); + const handlePointerMove = useCallback((e: PointerEvent) => { + cursorYRef.current = e.clientY; + }, []); + // dnd-kit const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); const handleDragStart = useCallback((event: DragStartEvent) => { + setDragOverInfo(null); + window.addEventListener("pointermove", handlePointerMove, { passive: true }); const data = event.active.data.current; if (data?.type === "note") { const noteId = data.id as string; @@ -90,10 +122,63 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { } }, []); + const handleDragOver = useCallback((event: any) => { + const { active, over } = event; + if (!over) { + setDragOverInfo(null); + return; + } + + const activeData = active.data.current; + const overData = over.data.current; + if (!activeData || !overData) { + setDragOverInfo(null); + return; + } + + if (activeData.type === "note" && overData.type === "note") { + const noteId = activeData.id as string; + const overId = overData.id as string; + const activeParent = noteId.includes("/") ? noteId.substring(0, noteId.lastIndexOf("/")) : ""; + const overParent = overData.path as string; + + if (activeParent === overParent && noteId !== overId) { + const overRect = over.rect; + if (overRect) { + const cursorY = cursorYRef.current; + const overCenterY = overRect.top + overRect.height / 2; + const position = cursorY < overCenterY ? "before" : "after"; + setDragOverInfo({ id: overId, position }); + return; + } + } + } else if (activeData.type === "folder" && overData.type === "folder") { + const folderPath = activeData.path as string; + const overPath = overData.path as string; + const activeParent = folderPath.includes("/") ? folderPath.substring(0, folderPath.lastIndexOf("/")) : ""; + const overParent = overPath.includes("/") ? overPath.substring(0, overPath.lastIndexOf("/")) : ""; + + if (activeParent === overParent && folderPath !== overPath) { + const overRect = over.rect; + if (overRect) { + const cursorY = cursorYRef.current; + const overCenterY = overRect.top + overRect.height / 2; + const position = cursorY < overCenterY ? "before" : "after"; + setDragOverInfo({ id: overPath, position }); + return; + } + } + } + + setDragOverInfo(null); + }, []); + const handleDragEnd = useCallback( async (event: DragEndEvent) => { + window.removeEventListener("pointermove", handlePointerMove); setDragLabel(null); setDragCount(1); + setDragOverInfo(null); const { active, over } = event; if (!over) return; @@ -101,80 +186,158 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { const overData = over.data.current; if (!activeData || !overData) return; - const targetFolder = overData.path as string; - try { if (activeData.type === "note") { const noteId = activeData.id as string; - const selected = multiSelectedRef.current!; + const activeParent = noteId.includes("/") + ? noteId.substring(0, noteId.lastIndexOf("/")) + : ""; - // Batch move if multi-selected - if (selected.has(noteId) && selected.size > 1) { - const noteIds = Array.from(selected).filter((id) => { - const parent = id.includes("/") - ? id.substring(0, id.lastIndexOf("/")) - : ""; - return parent !== targetFolder; - }); - if (noteIds.length === 0) return; - let failures = 0; - for (const id of noteIds) { - try { - await moveNote(id, targetFolder); - } catch { - failures++; + const overType = overData.type as string; + if (overType === "note") { + const overId = overData.id as string; + const overParent = overData.path as string; + + if (activeParent === overParent) { + const currentSettings: Settings = settings || { + theme: { mode: "system" }, + noteOrder: [], + folderOrder: [], + }; + const currentOrder = currentSettings.noteOrder || []; + + const siblings = notes.filter(n => { + const p = n.id.includes("/") ? n.id.substring(0, n.id.lastIndexOf("/")) : ""; + return p === activeParent; + }).map(n => n.id); + + let newOrder = [...currentOrder]; + for (const id of siblings) { + if (!newOrder.includes(id)) { + newOrder.push(id); + } } + + const activeIndex = newOrder.indexOf(noteId); + const overIndex = newOrder.indexOf(overId); + if (activeIndex !== -1 && overIndex !== -1) { + let position: "before" | "after" = "after"; + const overRect = over.rect; + if (overRect) { + const cursorY = cursorYRef.current; + const overCenterY = overRect.top + overRect.height / 2; + position = cursorY < overCenterY ? "before" : "after"; + } else { + position = activeIndex < overIndex ? "after" : "before"; + } + newOrder = reorderItems(newOrder, noteId, overId, position); + } + + const updatedSettings = { + ...currentSettings, + noteOrder: newOrder, + }; + await updateSettings(updatedSettings); + await reloadSettings(); + await refreshNotes(); + return; + } else { + // --- MOVE note to a different parent folder --- + await moveNote(noteId, overParent); } - if (failures > 0) { - toast.error(`Failed to move ${failures} note(s)`); - } - setMultiSelectedNoteIds(new Set()); - } else { - const noteParent = noteId.includes("/") - ? noteId.substring(0, noteId.lastIndexOf("/")) - : ""; - if (noteParent === targetFolder) return; + } else if (overType === "folder") { + const targetFolder = overData.path as string; + if (activeParent === targetFolder) return; await moveNote(noteId, targetFolder); - setMultiSelectedNoteIds(new Set()); } } else if (activeData.type === "folder") { const folderPath = activeData.path as string; - if ( - targetFolder === folderPath || - targetFolder.startsWith(folderPath + "/") - ) - return; - const folderParent = folderPath.includes("/") + const activeParent = folderPath.includes("/") ? folderPath.substring(0, folderPath.lastIndexOf("/")) : ""; - if (folderParent === targetFolder) return; - await moveFolder(folderPath, targetFolder); + + const overType = overData.type as string; + if (overType === "folder") { + const overPath = overData.path as string; + const overParent = overPath.includes("/") + ? overPath.substring(0, overPath.lastIndexOf("/")) + : ""; + + if (activeParent === overParent) { + const currentSettings: Settings = settings || { + theme: { mode: "system" }, + noteOrder: [], + folderOrder: [], + }; + const currentOrder = currentSettings.folderOrder || []; + + const list = await notesService.listFolders(); + const siblings = list.filter(p => { + const parent = p.includes("/") ? p.substring(0, p.lastIndexOf("/")) : ""; + return parent === activeParent; + }); + + let newOrder = [...currentOrder]; + for (const path of siblings) { + if (!newOrder.includes(path)) { + newOrder.push(path); + } + } + + const activeIndex = newOrder.indexOf(folderPath); + const overIndex = newOrder.indexOf(overPath); + if (activeIndex !== -1 && overIndex !== -1) { + let position: "before" | "after" = "after"; + const overRect = over.rect; + if (overRect) { + const cursorY = cursorYRef.current; + const overCenterY = overRect.top + overRect.height / 2; + position = cursorY < overCenterY ? "before" : "after"; + } else { + position = activeIndex < overIndex ? "after" : "before"; + } + newOrder = reorderItems(newOrder, folderPath, overPath, position); + } + + const updatedSettings = { + ...currentSettings, + folderOrder: newOrder, + }; + await updateSettings(updatedSettings); + await reloadSettings(); + await refreshNotes(); + return; + } else { + // --- MOVE folder to a different parent folder --- + if ( + overPath === folderPath || + overPath.startsWith(folderPath + "/") + ) + return; + const folderParent = folderPath.includes("/") + ? folderPath.substring(0, folderPath.lastIndexOf("/")) + : ""; + if (folderParent === overPath) return; + await moveFolder(folderPath, overPath); + } + } } // Expand target folder so the moved item is visible + const targetFolder = overData.path as string; if (targetFolder) { window.dispatchEvent( new CustomEvent("expand-folder", { detail: targetFolder }), ); } } catch (error) { - console.error("Failed to move item:", error); - toast.error("Failed to move item"); + console.error("Failed to move/reorder item:", error); + toast.error("Failed to move/reorder item"); } }, - [moveNote, moveFolder], + [moveNote, moveFolder, notes, reloadSettings, refreshNotes, settings, updateSettings], ); - // Load folders setting - useEffect(() => { - notesService.getSettings().then((s) => { - setFoldersEnabled(s.foldersEnabled === true); - }).catch((error) => { - console.error("Failed to load settings:", error); - setFoldersEnabled(false); - }); - }, []); - // Sync input with search query useEffect(() => { setInputValue(searchQuery); @@ -307,8 +470,13 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { setDragLabel(null)} + onDragCancel={() => { + window.removeEventListener("pointermove", handlePointerMove); + setDragLabel(null); + setDragOverInfo(null); + }} >
{/* Drag region */} @@ -417,6 +585,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { setMultiSelectedNoteIds={setMultiSelectedNoteIds} lastClickedNoteId={lastClickedNoteId} setLastClickedNoteId={setLastClickedNoteId} + dragOverInfo={dragOverInfo} />
@@ -435,7 +604,7 @@ export function Sidebar({ onOpenSettings }: SidebarProps) { {/* Drag overlay — floating label while dragging */} - + {dragLabel && (
diff --git a/src/components/notes/FolderTreeView.tsx b/src/components/notes/FolderTreeView.tsx index 3f7998a3..fbc2580e 100644 --- a/src/components/notes/FolderTreeView.tsx +++ b/src/components/notes/FolderTreeView.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState, useEffect, useRef, memo } from "react"; +import { useFlipAnimation } from "../../hooks/useFlipAnimation"; import * as ContextMenu from "@radix-ui/react-context-menu"; import { useDroppable, useDraggable } from "@dnd-kit/core"; import { useNotes } from "../../context/NotesContext"; @@ -75,6 +76,7 @@ interface FileItemProps { onDelete: (id: string) => void; onMoveToParent?: (id: string, targetFolder: string) => void; focusedItemKey?: string | null; + dragOverInfo?: { id: string; position: "before" | "after" } | null; } const FileItem = memo(function FileItem({ @@ -90,6 +92,7 @@ const FileItem = memo(function FileItem({ onDelete, onMoveToParent, focusedItemKey, + dragOverInfo, }: FileItemProps) { const itemRef = useRef(null); const handleClick = useCallback( @@ -113,9 +116,9 @@ const FileItem = memo(function FileItem({ data: { type: "note", id: note.id }, }); - const { setNodeRef: setDropRef, isOver } = useDroppable({ + const { setNodeRef: setDropRef } = useDroppable({ id: `drop-note:${note.id}`, - data: { type: "folder", path: noteParentFolder }, + data: { type: "note", id: note.id, path: noteParentFolder }, }); useEffect(() => { @@ -143,43 +146,66 @@ const FileItem = memo(function FileItem({ } }, [note.id]); + const isTargetBefore = dragOverInfo?.id === note.id && dragOverInfo.position === "before"; + const isTargetAfter = dragOverInfo?.id === note.id && dragOverInfo.position === "after"; + return ( -
{ - setDragRef(el); - setDropRef(el); - (itemRef as React.MutableRefObject).current = - el; - }} - {...attributes} - {...listeners} - className={`flex items-center gap-1.5 py-1.5 cursor-pointer rounded-md select-none transition-colors ${ - isDragging - ? "opacity-40" - : isOver - ? "bg-accent/10 ring-1 ring-accent" +
+ {/* Sleek drop indicator line */} +
+ {/* Rounded dot on the left edge */} +
+
+ +
{ + setDragRef(el); + setDropRef(el); + (itemRef as React.MutableRefObject).current = + el; + }} + {...attributes} + {...listeners} + className={`relative flex items-center gap-1.5 py-1.5 cursor-pointer rounded-md select-none transition-[opacity,background-color,scale] duration-200 group/fileitem ${ + isDragging + ? "opacity-30 scale-[0.97] pointer-events-none" : isSelected && (!focusedItemKey || focusedItemKey === `note:${note.id}`) ? "bg-bg-muted group-focus/notelist:ring-1 group-focus/notelist:ring-text-muted" : isMultiSelected ? "bg-bg-muted" : "hover:bg-bg-muted" - }`} - style={{ paddingLeft: `${depth * 12 + 8}px`, paddingRight: "8px" }} - onClick={handleClick} - role="button" - tabIndex={-1} - > - {isPinned ? ( - - ) : ( - - )} - - {cleanTitle(note.title)} - + }`} + style={{ paddingLeft: `${depth * 12 + 8}px`, paddingRight: "8px" }} + onClick={handleClick} + role="button" + tabIndex={-1} + > + {/* Sleek edge grab pill bar, visible on hover */} +
+ {isPinned ? ( + + ) : ( + + )} + + {cleanTitle(note.title)} + +
@@ -267,6 +293,7 @@ interface FolderItemProps { onDeleteNote: (id: string) => void; onMoveNoteToParent: (id: string, targetFolder: string) => void; onMoveFolderToParent: (path: string, targetParent: string) => void; + dragOverInfo?: { id: string; position: "before" | "after" } | null; } const FolderItemComponent = memo(function FolderItem({ @@ -289,6 +316,7 @@ const FolderItemComponent = memo(function FolderItem({ onDeleteNote, onMoveNoteToParent, onMoveFolderToParent, + dragOverInfo, }: FolderItemProps) { const isCollapsed = collapsedFolders.has(folder.path); const noteCount = countNotesInFolder(folder); @@ -309,46 +337,76 @@ const FolderItemComponent = memo(function FolderItem({ data: { type: "folder", path: folder.path }, }); - const { setNodeRef: setDropRef, isOver } = useDroppable({ + const { setNodeRef: setDropRef } = useDroppable({ id: `drop-folder:${folder.path}`, data: { type: "folder", path: folder.path }, }); + const isTargetBefore = dragOverInfo?.id === folder.path && dragOverInfo.position === "before"; + const isTargetAfter = dragOverInfo?.id === folder.path && dragOverInfo.position === "after"; + + const childrenRef = useFlipAnimation(folder); + return ( -
-
+ {/* Sleek drop indicator line */} +
+ {/* Rounded dot on the left edge */} +
+
+ +
{ + setDragRef(el); + setDropRef(el); + }} + {...attributes} + {...listeners} + className={`relative flex items-center gap-1.5 py-1.5 cursor-pointer rounded-md select-none transition-colors group/folderitem ${ + isFocused ? "bg-bg-muted/50 ring-1 ring-text-muted/30" : "hover:bg-bg-muted" - }`} - style={{ paddingLeft: `${depth * 12 + 8}px`, paddingRight: "8px" }} - onClick={handleClick} - role="button" - tabIndex={-1} - > - {isCollapsed ? ( - - ) : ( - - )} - - {folder.name} - + }`} + style={{ paddingLeft: `${depth * 12 + 8}px`, paddingRight: "8px" }} + onClick={handleClick} + role="button" + tabIndex={-1} + > + {/* Sleek edge grab pill bar, visible on hover */} +
+ {isCollapsed ? ( + + ) : ( + + )} + + {folder.name} + +
{!isCollapsed && ( -
+
{folder.children.map((child) => ( ))} {folder.notes.map((note) => ( @@ -388,6 +447,7 @@ const FolderItemComponent = memo(function FolderItem({ onDelete={onDeleteNote} onMoveToParent={onMoveNoteToParent} focusedItemKey={focusedItemKey} + dragOverInfo={dragOverInfo} /> ))} {isEmpty && ( @@ -480,6 +540,7 @@ interface FolderTreeViewProps { setMultiSelectedNoteIds: React.Dispatch>>; lastClickedNoteId: string | null; setLastClickedNoteId: React.Dispatch>; + dragOverInfo?: { id: string; position: "before" | "after" } | null; } export function FolderTreeView({ @@ -489,6 +550,7 @@ export function FolderTreeView({ setMultiSelectedNoteIds, lastClickedNoteId, setLastClickedNoteId, + dragOverInfo, }: FolderTreeViewProps) { const { notes, @@ -519,7 +581,7 @@ export function FolderTreeView({ const [noteToDelete, setNoteToDelete] = useState(null); const [isDeleting, setIsDeleting] = useState(false); const [knownFolders, setKnownFolders] = useState([]); - const containerRef = useRef(null); + // containerRef is declared below visibleItems to avoid TDZ errors // Load known folders from disk (includes empty folders) useEffect(() => { @@ -535,8 +597,8 @@ export function FolderTreeView({ }, [collapsedFolders]); const tree = useMemo( - () => buildFolderTree(notes, pinnedIds, knownFolders), - [notes, pinnedIds, knownFolders], + () => buildFolderTree(notes, pinnedIds, knownFolders, _settings?.noteOrder, _settings?.folderOrder), + [notes, pinnedIds, knownFolders, _settings?.noteOrder, _settings?.folderOrder], ); const handleToggleCollapse = useCallback((path: string) => { @@ -667,6 +729,8 @@ export function FolderTreeView({ [tree, pinnedIds, collapsedFolders], ); + const containerRef = useFlipAnimation(visibleItems); + // Visible note IDs in order (for Shift+Click range computation) const visibleNoteIds = useMemo( () => @@ -871,6 +935,7 @@ export function FolderTreeView({ onDuplicate={duplicateNote} onDelete={openDeleteNoteDialog} focusedItemKey={focusedItemKey} + dragOverInfo={dragOverInfo} /> ))} @@ -897,6 +962,7 @@ export function FolderTreeView({ onDeleteNote={openDeleteNoteDialog} onMoveNoteToParent={moveNote} onMoveFolderToParent={moveFolder} + dragOverInfo={dragOverInfo} /> ))} @@ -915,6 +981,7 @@ export function FolderTreeView({ onDuplicate={duplicateNote} onDelete={openDeleteNoteDialog} focusedItemKey={focusedItemKey} + dragOverInfo={dragOverInfo} /> ))}
diff --git a/src/components/notes/NoteList.tsx b/src/components/notes/NoteList.tsx index 3e761f39..9cd73ecd 100644 --- a/src/components/notes/NoteList.tsx +++ b/src/components/notes/NoteList.tsx @@ -1,4 +1,6 @@ import { useCallback, useMemo, memo, useEffect, useRef, useState } from "react"; +import { useFlipAnimation } from "../../hooks/useFlipAnimation"; +import { useDraggable, useDroppable } from "@dnd-kit/core"; import { invoke } from "@tauri-apps/api/core"; import * as ContextMenu from "@radix-ui/react-context-menu"; import { useNotes } from "../../context/NotesContext"; @@ -21,7 +23,7 @@ import { CopyIcon, TrashIcon, } from "../icons"; -import type { Settings } from "../../types/note"; + const menuItemClass = "px-3 py-1.5 text-sm text-text cursor-pointer outline-none hover:bg-bg-muted focus:bg-bg-muted flex items-center gap-2 rounded-sm"; @@ -110,7 +112,11 @@ export const NoteItem = memo(function NoteItem({
+ {/* Sleek edge grab pill bar, visible on hover */} +
+ + {/* Sleek drop indicator line */} +
+ {/* Rounded dot on the left edge */} +
+
+ +
{ + setDragRef(el); + setDropRef(el); + }} + {...attributes} + {...listeners} + className={`transition-[opacity,background-color,scale] duration-200 ${ + isDragging + ? "opacity-30 scale-[0.97] pointer-events-none" + : "" + }`} + > + +
+
+ ); +}); + interface NoteListProps { multiSelectedNoteIds: Set; setMultiSelectedNoteIds: React.Dispatch>>; lastClickedNoteId: string | null; setLastClickedNoteId: React.Dispatch>; + dragOverInfo?: { id: string; position: "before" | "after" } | null; } export function NoteList({ @@ -238,6 +307,7 @@ export function NoteList({ setMultiSelectedNoteIds, lastClickedNoteId, setLastClickedNoteId, + dragOverInfo, }: NoteListProps) { const { notes, @@ -250,24 +320,14 @@ export function NoteList({ isLoading, searchQuery, searchResults, + settings, + refreshSettings, } = useNotes(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [noteToDelete, setNoteToDelete] = useState(null); - const [settings, setSettings] = useState(null); - const containerRef = useRef(null); + // containerRef is declared below displayItems to avoid TDZ errors - // Load settings when notes change - useEffect(() => { - notesService - .getSettings() - .then(setSettings) - .catch((error) => { - console.error("Failed to load settings:", error); - }); - }, [notes]); - - // Calculate pinned IDs set for efficient lookup const pinnedIds = useMemo( () => new Set(settings?.pinnedNoteIds || []), [settings] @@ -290,10 +350,6 @@ export function NoteList({ setDeleteDialogOpen(true); }, []); - const refreshSettings = useCallback(() => { - notesService.getSettings().then(setSettings); - }, []); - // Memoize display items to prevent recalculation on every render const displayItems = useMemo(() => { if (searchQuery.trim()) { @@ -307,6 +363,8 @@ export function NoteList({ return notes; }, [searchQuery, searchResults, notes]); + const containerRef = useFlipAnimation(displayItems); + // Listen for focus request from editor (when Escape is pressed) useEffect(() => { const handleFocusNoteList = () => { @@ -368,6 +426,7 @@ export function NoteList({ setMultiSelectedNoteIds={setMultiSelectedNoteIds} lastClickedNoteId={lastClickedNoteId} setLastClickedNoteId={setLastClickedNoteId} + dragOverInfo={dragOverInfo} /> {/* Delete confirmation dialog */} @@ -404,7 +463,7 @@ export function NoteList({ className="group/notelist flex flex-col gap-1 p-1.5 outline-none" > {displayItems.map((item) => ( - ))}
diff --git a/src/components/ui/index.tsx b/src/components/ui/index.tsx index 8de365e7..f1d6d7e0 100644 --- a/src/components/ui/index.tsx +++ b/src/components/ui/index.tsx @@ -136,7 +136,7 @@ interface ListItemProps { isSelected?: boolean; isPinned?: boolean; onClick?: () => void; - /** Optional status icon to display next to meta */ + className?: string; } export function ListItem({ @@ -147,6 +147,7 @@ export function ListItem({ isPinned = false, onClick, onContextMenu, + className, }: ListItemProps & { onContextMenu?: (e: React.MouseEvent) => void }) { // Clean subtitle: treat whitespace-only or   as empty const cleanSubtitle = subtitle @@ -166,7 +167,8 @@ export function ListItem({ "focus:outline-none focus-visible:outline-none", isSelected ? "bg-bg-muted group-focus/notelist:ring-1 group-focus/notelist:ring-text-muted" - : "hover:bg-bg-muted" + : "hover:bg-bg-muted", + className )} >
diff --git a/src/context/NotesContext.tsx b/src/context/NotesContext.tsx index 7ad2ea11..b00d310a 100644 --- a/src/context/NotesContext.tsx +++ b/src/context/NotesContext.tsx @@ -9,7 +9,7 @@ import { type ReactNode, } from "react"; import { listen } from "@tauri-apps/api/event"; -import type { Note, NoteMetadata } from "../types/note"; +import type { Note, NoteMetadata, Settings } from "../types/note"; import * as notesService from "../services/notes"; import type { SearchResult } from "../services/notes"; @@ -27,6 +27,7 @@ interface NotesDataContextValue { isSearching: boolean; hasExternalChanges: boolean; reloadVersion: number; + settings: Settings | null; } // Actions context: stable references, rarely causes re-renders @@ -51,6 +52,8 @@ interface NotesActionsContextValue { renameFolder: (oldPath: string, newName: string) => Promise; moveNote: (id: string, targetFolder: string) => Promise; moveFolder: (path: string, targetParent: string) => Promise; + updateSettings: (newSettings: Settings) => Promise; + refreshSettings: () => Promise; } const NotesDataContext = createContext(null); @@ -84,6 +87,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { const selectRequestIdRef = useRef(0); // Monotonic counter to ignore stale async search responses const searchRequestIdRef = useRef(0); + const [settings, setSettings] = useState(null); // Tracks the ID of a newly created note so Editor can focus its title. const pendingNewNoteIdRef = useRef(null); @@ -97,6 +101,28 @@ export function NotesProvider({ children }: { children: ReactNode }) { } }, [notesFolder]); + const refreshSettings = useCallback(async () => { + try { + const s = await notesService.getSettings(); + setSettings(s); + } catch (err) { + console.error("Failed to load settings in NotesContext:", err); + } + }, []); + + const updateSettings = useCallback(async (newSettings: Settings) => { + // Synchronously update local state for absolute instant feedback! + setSettings(newSettings); + try { + await notesService.updateSettings(newSettings); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update settings"); + // Rollback or reload if update fails + const s = await notesService.getSettings(); + setSettings(s); + } + }, []); + // Debounced refresh - coalesces rapid saves into a single refresh const scheduleRefresh = useCallback(() => { if (refreshTimeoutRef.current) { @@ -314,14 +340,17 @@ export function NotesProvider({ children }: { children: ReactNode }) { ...currentSettings, pinnedNoteIds: [...pinnedIds, id], }; + // Synchronously update setting state for immediate UI feedback + setSettings(updatedSettings); await notesService.updateSettings(updatedSettings); await refreshNotes(); } } catch (err) { setError(err instanceof Error ? err.message : "Failed to pin note"); + await refreshSettings(); } }, - [refreshNotes] + [refreshNotes, refreshSettings] ); const unpinNote = useCallback( @@ -334,13 +363,16 @@ export function NotesProvider({ children }: { children: ReactNode }) { ...currentSettings, pinnedNoteIds: pinnedIds.filter((pinId) => pinId !== id), }; + // Synchronously update setting state for immediate UI feedback + setSettings(updatedSettings); await notesService.updateSettings(updatedSettings); await refreshNotes(); } catch (err) { setError(err instanceof Error ? err.message : "Failed to unpin note"); + await refreshSettings(); } }, - [refreshNotes] + [refreshNotes, refreshSettings] ); const createNoteInFolder = useCallback( @@ -603,6 +635,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { try { const folder = await notesService.getNotesFolder(); setNotesFolderState(folder); + await refreshSettings(); if (folder) { const notesList = await notesService.listNotes(); setNotes(notesList); @@ -616,7 +649,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { } } init(); - }, []); + }, [refreshSettings]); // Listen for file change events and notify if current note changed externally useEffect(() => { @@ -673,12 +706,13 @@ export function NotesProvider({ children }: { children: ReactNode }) { }; }, [selectNote, refreshNotes]); - // Refresh notes when folder changes + // Refresh notes and settings when folder changes useEffect(() => { if (notesFolder) { refreshNotes(); + refreshSettings(); } - }, [notesFolder, refreshNotes]); + }, [notesFolder, refreshNotes, refreshSettings]); // Memoize data context value to prevent unnecessary re-renders const dataValue = useMemo( @@ -694,6 +728,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { isSearching, hasExternalChanges, reloadVersion, + settings, }), [ notes, @@ -707,6 +742,7 @@ export function NotesProvider({ children }: { children: ReactNode }) { isSearching, hasExternalChanges, reloadVersion, + settings, ] ); @@ -733,6 +769,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { renameFolder: renameFolderAction, moveNote: moveNoteAction, moveFolder: moveFolderAction, + updateSettings, + refreshSettings, }), [ selectNote, @@ -755,6 +793,8 @@ export function NotesProvider({ children }: { children: ReactNode }) { renameFolderAction, moveNoteAction, moveFolderAction, + updateSettings, + refreshSettings, ] ); diff --git a/src/hooks/useFlipAnimation.ts b/src/hooks/useFlipAnimation.ts new file mode 100644 index 00000000..8006d64d --- /dev/null +++ b/src/hooks/useFlipAnimation.ts @@ -0,0 +1,100 @@ +import { useRef, useLayoutEffect } from "react"; + +/** + * A lightweight hook to perform smooth FLIP (First, Last, Invert, Play) reordering animations + * on direct children of a container ref when a dependency changes. + */ +export function useFlipAnimation(_dependency: any) { + const containerRef = useRef(null); + const rectsRef = useRef>(new Map()); + + useLayoutEffect(() => { + if (!containerRef.current) return; + const children = Array.from(containerRef.current.children) as HTMLElement[]; + + // 1. Measure all children in their NEW DOM positions (bypassing any active FLIP transforms) + const currentRects = new Map(); + for (const child of children) { + const id = child.getAttribute("data-id") || child.id; + if (id) { + const originalTransform = child.style.transform; + const originalTransition = child.style.transition; + const isAnimating = !!(child as any)._flipCleanup; + + if (isAnimating) { + // Temporarily disable both transition and transform to query the static layout position + child.style.transition = "none"; + child.style.transform = "none"; + currentRects.set(id, child.getBoundingClientRect()); + child.style.transition = originalTransition; + child.style.transform = originalTransform; + } else if (originalTransform && originalTransform !== "none") { + child.style.transform = "none"; + currentRects.set(id, child.getBoundingClientRect()); + child.style.transform = originalTransform; + } else { + currentRects.set(id, child.getBoundingClientRect()); + } + } + } + + // 2. Run the FLIP animation for elements that changed position since the last commit + for (const child of children) { + const id = child.getAttribute("data-id") || child.id; + if (!id) continue; + + let firstRect = rectsRef.current.get(id); + const lastRect = currentRects.get(id); + + if (firstRect && lastRect) { + // If there is an active transition from a previous commit, clean it up first. + // We measure the current mid-flight visual position BEFORE cleaning up + // to ensure a perfectly seamless transition from the interrupted state. + const isAnimating = !!(child as any)._flipCleanup; + if (isAnimating) { + firstRect = child.getBoundingClientRect(); + (child as any)._flipCleanup(); + } + + const deltaY = firstRect.top - lastRect.top; + const deltaX = firstRect.left - lastRect.left; + + if (deltaY !== 0 || deltaX !== 0) { + // Disable transitions temporarily + const originalTransition = child.style.transition; + child.style.transition = "none"; + child.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0)`; + + // Trigger reflow/repaint to apply the invert state + child.offsetHeight; + + // Smoothly animate back to the new DOM position (Play) + child.style.transition = "transform 350ms cubic-bezier(0.16, 1, 0.3, 1)"; + child.style.transform = "translate3d(0, 0, 0)"; + + const cleanup = () => { + child.style.transition = originalTransition; + child.style.transform = ""; + child.removeEventListener("transitionend", onTransitionEnd); + (child as any)._flipCleanup = null; + }; + + const onTransitionEnd = (e: TransitionEvent) => { + if (e.propertyName === "transform") { + cleanup(); + } + }; + + child.addEventListener("transitionend", onTransitionEnd); + (child as any)._flipCleanup = cleanup; + } + } + } + + // 3. Save the untransformed (new layout) positions for the next commit + rectsRef.current = currentRects; + }); // Runs on every commit to keep track of the latest stable positions + + return containerRef; +} + diff --git a/src/lib/folderTree.ts b/src/lib/folderTree.ts index a4a2d48d..c6c94acb 100644 --- a/src/lib/folderTree.ts +++ b/src/lib/folderTree.ts @@ -9,6 +9,8 @@ export function buildFolderTree( notes: NoteMetadata[], pinnedIds: Set, knownFolders?: string[], + noteOrder?: string[], + folderOrder?: string[], ): FolderTreeData { const rootNotes: NoteMetadata[] = []; const folderMap = new Map(); @@ -52,11 +54,28 @@ export function buildFolderTree( } function sortNode(node: FolderNode) { - node.children.sort((a, b) => a.name.localeCompare(b.name)); + node.children.sort((a, b) => { + if (folderOrder) { + const aIdx = folderOrder.indexOf(a.path); + const bIdx = folderOrder.indexOf(b.path); + if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; + if (aIdx !== -1) return -1; + if (bIdx !== -1) return 1; + } + return a.name.localeCompare(b.name); + }); node.notes.sort((a, b) => { const ap = pinnedIds.has(a.id); const bp = pinnedIds.has(b.id); if (ap !== bp) return ap ? -1 : 1; + + if (noteOrder) { + const aIdx = noteOrder.indexOf(a.id); + const bIdx = noteOrder.indexOf(b.id); + if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; + if (aIdx !== -1) return -1; + if (bIdx !== -1) return 1; + } return b.modified - a.modified; }); node.children.forEach(sortNode); @@ -65,14 +84,31 @@ export function buildFolderTree( const topLevelFolders = Array.from(folderMap.values()).filter( (f) => !f.path.includes("/"), ); - topLevelFolders.sort((a, b) => a.name.localeCompare(b.name)); + topLevelFolders.sort((a, b) => { + if (folderOrder) { + const aIdx = folderOrder.indexOf(a.path); + const bIdx = folderOrder.indexOf(b.path); + if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; + if (aIdx !== -1) return -1; + if (bIdx !== -1) return 1; + } + return a.name.localeCompare(b.name); + }); topLevelFolders.forEach(sortNode); - // Sort root notes: pinned first, then by modified desc + // Sort root notes: pinned first, then by custom order, fallback to modified desc rootNotes.sort((a, b) => { const ap = pinnedIds.has(a.id); const bp = pinnedIds.has(b.id); if (ap !== bp) return ap ? -1 : 1; + + if (noteOrder) { + const aIdx = noteOrder.indexOf(a.id); + const bIdx = noteOrder.indexOf(b.id); + if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx; + if (aIdx !== -1) return -1; + if (bIdx !== -1) return 1; + } return b.modified - a.modified; }); diff --git a/src/types/note.ts b/src/types/note.ts index 37addc1b..9b2cff93 100644 --- a/src/types/note.ts +++ b/src/types/note.ts @@ -59,6 +59,8 @@ export interface Settings { ignoredPatterns?: string[]; customColorsLight?: CustomColors; customColorsDark?: CustomColors; + noteOrder?: string[]; + folderOrder?: string[]; } export interface FolderNode {