diff --git a/src/app.tsx b/src/app.tsx index 0beea02..551a5de 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -111,6 +111,7 @@ export function App() { activeTabId, switchTab, closeTab, + reorderTabs, rootPath, setRootPath, saveStatus, @@ -177,12 +178,10 @@ export function App() { if (activePath && isPathWithin(activePath, path)) { startNewBuffer(); } - setFavorites((prev) => prev.filter((favorite) => !isPathWithin(favorite, path))); }, [ activePath, folders, isPathWithin, - setFavorites, setFolders, setRootPath, startNewBuffer, @@ -307,7 +306,8 @@ export function App() { const exportToPdf = useCallback(async () => { try { - await exportPreviewToPdf({ source, activePath }); + const documentName = tabs.find((tab) => tab.id === activeTabId)?.title; + await exportPreviewToPdf({ source, activePath, documentName }); } catch (err) { const message = err instanceof PdfExportError ? err.message @@ -315,7 +315,7 @@ export function App() { console.error("marka.md: pdf export failed", err); setLoadError({ message }); } - }, [source, activePath, setLoadError, t]); + }, [source, activePath, tabs, activeTabId, setLoadError, t]); const toggleFullscreen = useCallback(async () => { @@ -435,6 +435,14 @@ export function App() { setStagedPaths([]); }, [rootPath]); + // Keep the webview document title in sync with the active file. On Windows the + // native Ctrl+P prints the app window, and the browser derives the save-as-PDF + // default name from document.title — so this drives the exported file name. + useEffect(() => { + const tabTitle = tabs.find((tab) => tab.id === activeTabId)?.title; + document.title = (tabTitle ?? "untitled").replace(/\.md$/i, ""); + }, [tabs, activeTabId]); + useEffect(() => { let cancelled = false; void getVersion() @@ -905,6 +913,7 @@ export function App() { activeTabId={activeTabId} onSelect={switchTab} onClose={handleCloseTab} + onReorder={reorderTabs} onContextMenu={(e, path) => handleContextMenu(e, { path, name: basename(path), isDir: false })} /> {editorOnly ? ( diff --git a/src/components/editor/open-tabs.tsx b/src/components/editor/open-tabs.tsx index 61cd1b2..ea1c7b8 100644 --- a/src/components/editor/open-tabs.tsx +++ b/src/components/editor/open-tabs.tsx @@ -1,4 +1,11 @@ -import { useEffect, useRef, type WheelEvent, type MouseEvent as ReactMouseEvent } from "react"; +import { + useEffect, + useRef, + useState, + type WheelEvent, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; import { X } from "lucide-react"; import { Icon } from "@/components/primitives"; import type { FileTab } from "@/hooks/use-file-session"; @@ -9,12 +16,23 @@ type OpenTabsProps = { activeTabId: string; onSelect: (id: string) => void; onClose: (id: string) => void; + onReorder?: (from: number, to: number) => void; onContextMenu?: (e: React.MouseEvent, path: string) => void; }; -export function OpenTabs({ tabs, activeTabId, onSelect, onClose, onContextMenu }: OpenTabsProps) { +// px the pointer must travel before a press becomes a reorder drag +const DRAG_THRESHOLD = 4; + +export function OpenTabs({ tabs, activeTabId, onSelect, onClose, onReorder, onContextMenu }: OpenTabsProps) { const { t } = useI18n(); const listRef = useRef(null); + // active drag gesture; null when idle. Pointer-event based (not HTML5 DnD) + // because Tauri's dragDropEnabled intercepts native drag on WebView2. + const dragRef = useRef<{ fromIndex: number; startX: number; pointerId: number; moved: boolean } | null>(null); + // when a drag just ended, swallow the synthetic click so it doesn't select + const suppressClickRef = useRef(false); + const [fromIndex, setFromIndex] = useState(null); + const [overIndex, setOverIndex] = useState(null); useEffect(() => { const active = listRef.current?.querySelector(".mdv-tab.is-active"); @@ -30,24 +48,85 @@ export function OpenTabs({ tabs, activeTabId, onSelect, onClose, onContextMenu } el.scrollLeft += event.deltaY; }; + // which tab index the pointer X currently sits over (by element midpoints) + const indexAtX = (clientX: number): number | null => { + const el = listRef.current; + if (!el) return null; + const tabEls = Array.from(el.querySelectorAll(".mdv-tab")); + if (tabEls.length === 0) return null; + for (let i = 0; i < tabEls.length; i++) { + const rect = tabEls[i].getBoundingClientRect(); + if (clientX < rect.left + rect.width / 2) return i; + } + return tabEls.length - 1; + }; + + const onTabPointerDown = (e: ReactPointerEvent, index: number) => { + if (!onReorder || e.button !== 0) return; + suppressClickRef.current = false; + dragRef.current = { fromIndex: index, startX: e.clientX, pointerId: e.pointerId, moved: false }; + }; + + const onListPointerMove = (e: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + if (!drag.moved) { + if (Math.abs(e.clientX - drag.startX) < DRAG_THRESHOLD) return; + drag.moved = true; + setFromIndex(drag.fromIndex); + try { + listRef.current?.setPointerCapture(drag.pointerId); + } catch { + // capture unavailable — drag still tracks via move events + } + } + setOverIndex(indexAtX(e.clientX)); + }; + + const endDrag = (e: ReactPointerEvent) => { + const drag = dragRef.current; + if (!drag) return; + if (drag.moved) { + suppressClickRef.current = true; + const target = indexAtX(e.clientX); + if (onReorder && target !== null && target !== drag.fromIndex) { + onReorder(drag.fromIndex, target); + } + } + try { + listRef.current?.releasePointerCapture(drag.pointerId); + } catch { + // already released + } + dragRef.current = null; + setFromIndex(null); + setOverIndex(null); + }; + return (
- {tabs.map((tab) => { + {tabs.map((tab, tabIndex) => { const active = tab.id === activeTabId; const dirty = tab.source !== tab.savedContent; + const dragging = fromIndex === tabIndex; + const isDragOver = overIndex === tabIndex && fromIndex !== null && fromIndex !== tabIndex; return (
onTabPointerDown(e, tabIndex)} onContextMenu={tab.path && onContextMenu ? (e: ReactMouseEvent) => { e.preventDefault(); onContextMenu(e, tab.path!); @@ -56,7 +135,13 @@ export function OpenTabs({ tabs, activeTabId, onSelect, onClose, onContextMenu }