mirror of
https://github.com/delibae/claude-prism.git
synced 2026-08-28 05:14:59 +00:00
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:
parent
4803561aa2
commit
0d59255edb
5 changed files with 191 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue