feat: add PDF page navigation, folder deletion, and dirty file indicator

- Add Page Up/Down buttons and editable page indicator to PDF preview toolbar
- Track current visible page via scroll with rAF throttling
- Implement proper recursive folder deletion in sidebar and document store
- Replace text asterisk with blue dot indicator for modified files in sidebar
- Extract shared scrollToPage helper to deduplicate scroll math in pdf-viewer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Minsoo Kim 2026-03-10 22:08:58 +09:00
parent 4803561aa2
commit 0d59255edb
5 changed files with 191 additions and 22 deletions

View file

@ -11,6 +11,8 @@ import {
HistoryIcon,
MousePointerClickIcon,
CrosshairIcon,
ChevronUpIcon,
ChevronDownIcon,
} from "lucide-react";
import { writeFile, mkdir, exists } from "@tauri-apps/plugin-fs";
import { join } from "@tauri-apps/api/path";
@ -83,6 +85,10 @@ export function PdfPreview() {
const [pdfError, setPdfError] = useState<string | null>(null);
const [numPages, setNumPages] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const [pageInputValue, setPageInputValue] = useState<string>("1");
const [isEditingPage, setIsEditingPage] = useState(false);
const scrollToPageRef = useRef<((page: number) => void) | null>(null);
const [scale, setScale] = useState<number>(1.0);
const [captureMode, setCaptureMode] = useState(false);
const [fitMode, setFitMode] = useState<FitMode>(null);
@ -368,6 +374,29 @@ export function PdfPreview() {
await writeFile(filePath, new Uint8Array(pdfData));
};
const handleCurrentPageChange = useCallback((page: number) => {
setCurrentPage((prev) => {
if (prev === page) return prev;
if (!isEditingPage) setPageInputValue(String(page));
return page;
});
}, [isEditingPage]);
const goToPage = useCallback((page: number) => {
const clamped = Math.max(1, Math.min(numPages, page));
scrollToPageRef.current?.(clamped);
}, [numPages]);
const handlePageInputCommit = useCallback(() => {
setIsEditingPage(false);
const parsed = parseInt(pageInputValue, 10);
if (!isNaN(parsed) && parsed >= 1 && parsed <= numPages) {
goToPage(parsed);
} else {
setPageInputValue(String(currentPage));
}
}, [pageInputValue, numPages, currentPage, goToPage]);
const handleLoadSuccess = (pages: number) => setNumPages(pages);
const handleScaleChange = (newScale: number) => { setFitMode(null); setScale(newScale); };
@ -563,6 +592,8 @@ export function PdfPreview() {
onTextSelect={isActive ? handleTextSelect : undefined}
onFirstPageSize={isActive ? (w, h) => setFirstPageSize({ width: w, height: h }) : undefined}
onContainerResize={isActive ? (w, h) => setContainerSize({ width: w, height: h }) : undefined}
onCurrentPageChange={isActive ? handleCurrentPageChange : undefined}
scrollToPageRef={isActive ? scrollToPageRef : undefined}
captureMode={isActive ? captureMode : false}
onCapture={isActive ? handleCapture : undefined}
onCancelCapture={isActive ? () => setCaptureMode(false) : undefined}
@ -608,7 +639,40 @@ export function PdfPreview() {
<div className="flex items-center gap-1">
{pdfData && (
<>
<span className="mr-1.5 text-muted-foreground text-xs">{numPages} {numPages === 1 ? "page" : "pages"}</span>
<Button variant="ghost" size="icon" className="size-7" onClick={() => goToPage(currentPage - 1)} disabled={currentPage <= 1} title="Page Up">
<ChevronUpIcon className="size-3.5" />
</Button>
{isEditingPage ? (
<input
autoFocus
type="text"
inputMode="numeric"
className="h-6 w-8 rounded border border-border bg-background text-center text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
value={pageInputValue}
onChange={(e) => setPageInputValue(e.target.value)}
onBlur={handlePageInputCommit}
onKeyDown={(e) => {
if (e.key === "Enter") handlePageInputCommit();
if (e.key === "Escape") {
setIsEditingPage(false);
setPageInputValue(String(currentPage));
}
}}
/>
) : (
<button
className="flex h-6 min-w-[2rem] items-center justify-center rounded px-1 text-xs text-muted-foreground tabular-nums hover:bg-muted"
onClick={() => { setIsEditingPage(true); setPageInputValue(String(currentPage)); }}
title="Click to jump to page"
>
{currentPage}
</button>
)}
<span className="text-muted-foreground text-xs">/ {numPages}</span>
<Button variant="ghost" size="icon" className="size-7" onClick={() => goToPage(currentPage + 1)} disabled={currentPage >= numPages} title="Page Down">
<ChevronDownIcon className="size-3.5" />
</Button>
<div className="mx-1 h-4 w-px bg-border" />
<Button variant="ghost" size="icon" className="size-7" onClick={zoomOut} disabled={scale <= 0.25}><MinusIcon className="size-3.5" /></Button>
<Button variant="ghost" size="icon" className="size-7" onClick={zoomIn} disabled={scale >= 4}><PlusIcon className="size-3.5" /></Button>
<Select

View file

@ -44,6 +44,8 @@ interface PdfViewerProps {
onTextSelect?: (selection: PdfTextSelection | null) => void;
onFirstPageSize?: (width: number, height: number) => void;
onContainerResize?: (width: number, height: number) => void;
onCurrentPageChange?: (page: number) => void;
scrollToPageRef?: React.RefObject<((page: number) => void) | null>;
captureMode?: boolean;
onCapture?: (result: CaptureResult) => void;
onCancelCapture?: () => void;
@ -62,6 +64,8 @@ export function PdfViewer({
onTextSelect,
onFirstPageSize,
onContainerResize,
onCurrentPageChange,
scrollToPageRef,
captureMode = false,
onCapture,
onCancelCapture,
@ -133,6 +137,18 @@ export function PdfViewer({
return 1;
}
/** Scroll the container so the given page is at the top (with 16px offset). */
function scrollToPage(container: HTMLElement, page: number): boolean {
const pageEl = container.querySelector(
`[data-page-number="${page}"]`,
) as HTMLElement | null;
if (!pageEl) return false;
const containerRect = container.getBoundingClientRect();
const pageRect = pageEl.getBoundingClientRect();
container.scrollTop += pageRect.top - containerRect.top - 16;
return true;
}
const prevRootFileIdRef = useRef<string | undefined>(undefined);
// Save scroll position when rootFileId is about to change
@ -170,14 +186,7 @@ export function PdfViewer({
if (targetPage > 0) {
requestAnimationFrame(() => {
const container = containerRef.current;
const pageEl = container?.querySelector(
`[data-page-number="${targetPage}"]`,
) as HTMLElement | null;
if (pageEl && container) {
const containerRect = container.getBoundingClientRect();
const pageRect = pageEl.getBoundingClientRect();
container.scrollTop += pageRect.top - containerRect.top - 16;
}
if (container) scrollToPage(container, targetPage);
});
}
}
@ -204,9 +213,7 @@ export function PdfViewer({
`[data-page-number="${targetPage}"]`,
) as HTMLElement | null;
if (pageEl && pageEl.clientHeight > 0) {
const containerRect = container.getBoundingClientRect();
const pageRect = pageEl.getBoundingClientRect();
container.scrollTop += pageRect.top - containerRect.top - 16;
scrollToPage(container, targetPage);
if (contentRef.current) contentRef.current.style.minHeight = "";
} else {
requestAnimationFrame(() => attempt(remaining - 1));
@ -421,6 +428,41 @@ export function PdfViewer({
};
}, [captureMode]);
// Track current visible page on scroll
const currentPageChangeRef = useRef(onCurrentPageChange);
currentPageChangeRef.current = onCurrentPageChange;
useEffect(() => {
const container = containerRef.current;
if (!container || !isActive) return;
let rafId = 0;
const handleScroll = () => {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(() => {
const cb = currentPageChangeRef.current;
if (cb) cb(getVisiblePage());
});
};
container.addEventListener("scroll", handleScroll, { passive: true });
// Fire initial value
handleScroll();
return () => {
container.removeEventListener("scroll", handleScroll);
cancelAnimationFrame(rafId);
};
}, [pageSizes, isActive]);
// Expose scrollToPage via ref
useEffect(() => {
if (!scrollToPageRef) return;
scrollToPageRef.current = (page: number) => {
const container = containerRef.current;
if (container) scrollToPage(container, page);
};
return () => { if (scrollToPageRef) scrollToPageRef.current = null; };
}, [scrollToPageRef, pageSizes]);
// Dismiss selection toolbar on scroll
useEffect(() => {
const container = containerRef.current;

View file

@ -192,6 +192,7 @@ export function Sidebar() {
const activeFileId = useDocumentStore((s) => s.activeFileId);
const setActiveFile = useDocumentStore((s) => s.setActiveFile);
const deleteFile = useDocumentStore((s) => s.deleteFile);
const deleteFolder = useDocumentStore((s) => s.deleteFolder);
const renameFile = useDocumentStore((s) => s.renameFile);
const createNewFile = useDocumentStore((s) => s.createNewFile);
const createFolder = useDocumentStore((s) => s.createFolder);
@ -623,6 +624,7 @@ export function Sidebar() {
onImport={handleImport}
onRename={openRenameDialog}
onDelete={deleteFile}
onDeleteFolder={deleteFolder}
fileCount={files.length}
nativeDragOver={nativeDragOver}
/>
@ -881,6 +883,7 @@ interface FileTreeNodeProps {
onImport: (folder?: string) => void;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
onDeleteFolder: (folderPath: string) => void;
fileCount: number;
nativeDragOver?: string | null;
}
@ -897,6 +900,7 @@ function FileTreeNode({
onImport,
onRename,
onDelete,
onDeleteFolder,
fileCount,
nativeDragOver,
}: FileTreeNodeProps) {
@ -942,12 +946,7 @@ function FileTreeNode({
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={() => {
const filesToDelete = node.children
.filter((c) => c.type === "file" && c.file)
.map((c) => c.file!.id);
for (const id of filesToDelete) onDelete(id);
}}
onClick={() => onDeleteFolder(node.relativePath)}
>
<Trash2Icon className="mr-2 size-4" />
Delete
@ -970,6 +969,7 @@ function FileTreeNode({
onImport={onImport}
onRename={onRename}
onDelete={onDelete}
onDeleteFolder={onDeleteFolder}
fileCount={fileCount}
nativeDragOver={nativeDragOver}
/>
@ -998,10 +998,10 @@ function FileTreeNode({
}}
>
{getFileIcon(file)}
<span className="truncate">
{node.name}
{file.isDirty && <span className="ml-1 text-muted-foreground">*</span>}
</span>
<span className="min-w-0 flex-1 truncate">{node.name}</span>
{file.isDirty && (
<span className="ml-auto shrink-0 size-2 rounded-full bg-blue-500" title="Modified" />
)}
</button>
</ContextMenuTrigger>
<ContextMenuContent>

View file

@ -229,6 +229,10 @@ export async function deleteFileFromDisk(absolutePath: string): Promise<void> {
await remove(absolutePath);
}
export async function deleteFolderFromDisk(absolutePath: string): Promise<void> {
await remove(absolutePath, { recursive: true });
}
export async function renameFileOnDisk(
oldPath: string,
newPath: string,

View file

@ -7,6 +7,7 @@ import {
createFileOnDisk,
copyFileToProject,
deleteFileFromDisk,
deleteFolderFromDisk,
renameFileOnDisk,
getUniqueTargetName,
createDirectory,
@ -59,6 +60,7 @@ interface DocumentState {
setActiveFile: (id: string) => void;
addFile: (file: Omit<ProjectFile, "id" | "isDirty">) => string;
deleteFile: (id: string) => void;
deleteFolder: (folderPath: string) => Promise<void>;
renameFile: (id: string, name: string) => void;
updateFileContent: (id: string, content: string) => void;
updateImageDataUrl: (id: string, dataUrl: string) => void;
@ -322,6 +324,63 @@ export const useDocumentStore = create<DocumentState>()((set, get) => ({
});
},
deleteFolder: async (folderPath) => {
const state = get();
if (!state.projectRoot) return;
const prefix = folderPath + "/";
const filesToRemove = state.files.filter(
(f) => f.relativePath.startsWith(prefix),
);
const remainingFiles = state.files.filter(
(f) => !f.relativePath.startsWith(prefix),
);
// Must keep at least one file
if (remainingFiles.length === 0) return;
// Delete folder from disk (recursive)
try {
const absPath = await join(state.projectRoot, folderPath);
await deleteFolderFromDisk(absPath);
} catch (e) {
console.error("Failed to delete folder from disk:", e);
}
// Clean caches
const pdfCache = new Map(state.pdfCache);
const compileErrorCache = new Map(state.compileErrorCache);
const lastCompiledGenerations = new Map(state.lastCompiledGenerations);
for (const f of filesToRemove) {
pdfCache.delete(f.id);
compileErrorCache.delete(f.id);
lastCompiledGenerations.delete(f.id);
}
const removedIds = new Set(filesToRemove.map((f) => f.id));
const newActiveId = removedIds.has(state.activeFileId)
? remainingFiles[0].id
: state.activeFileId;
const switchingActive = newActiveId !== state.activeFileId;
const newRootId = switchingActive ? resolveTexRoot(newActiveId, remainingFiles) : undefined;
// Remove folder from folders list
const newFolders = state.folders.filter(
(f) => f !== folderPath && !f.startsWith(prefix),
); // include exact match since folders list contains folder paths directly
set({
files: remainingFiles,
folders: newFolders,
activeFileId: newActiveId,
pdfCache,
compileErrorCache,
lastCompiledGenerations,
...(switchingActive && newRootId ? {
pdfData: pdfCache.get(newRootId) ?? null,
compileError: compileErrorCache.get(newRootId) ?? null,
} : {}),
});
},
renameFile: async (id, name) => {
const state = get();
const file = state.files.find((f) => f.id === id);