mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
refactor(editor): give the file library the normal chrome
It still didn't look like a view. It suppressed the workbench bar, forced the file sidebar collapsed and swapped its toggle for a back arrow, and added a resizable folder tree as a third top-level column that no other view has. Now it uses the top bar and the centre area the viewer and the file editor use, and appears beside them in the view switcher. The folder tree moves into the file sidebar as a Folders section under the files it holds, so folder navigation sits with the rest of the file chrome instead of in a column of its own - which is what lets the panel go entirely, resize machinery and all. The section above it was "PDF Library", which is now just "Files". Uploading is the one thing that stays view-aware: in the library it files a document away without opening it, so the sidebar's own path - which picks a view to open an upload in - is left in place everywhere else. Mobile keeps its full-viewport branch. The slider it would otherwise sit in is a 100vw horizontal-scroll container that truncated buttons and left a stray side-scroll surface on touch.
This commit is contained in:
@@ -4239,10 +4239,10 @@ dropHint = "Open files to get started"
|
||||
dropToAdd = "Drop files to add"
|
||||
duplicateFailed = "Could not duplicate file"
|
||||
expand = "Expand sidebar"
|
||||
folders = "Folders"
|
||||
googleDrive = "Google Drive"
|
||||
googleDriveDisabled = "Google Drive is not configured"
|
||||
leaveMyFiles = "Leave File library"
|
||||
library = "PDF Library"
|
||||
library = "Files"
|
||||
myFiles = "File library"
|
||||
noFiles = "No files yet"
|
||||
openFileManager = "Browse all files & folders"
|
||||
@@ -4351,7 +4351,6 @@ removeVersion = "Remove this version"
|
||||
rename = "Rename"
|
||||
renamed = "Renamed"
|
||||
renameFolder = "Rename folder"
|
||||
resizeFolderTree = "Resize folder tree (arrow keys, Shift for bigger steps; double-click to auto-fit)"
|
||||
save = "Save"
|
||||
saveToServer = "Save to server"
|
||||
saveToServerDisabledHint = "Saving to the server isn't enabled on this server. Ask your admin to enable it."
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
/* Secondary navigator panel that slides out from the main FileSidebar
|
||||
when the user enters the My Files workbench.
|
||||
|
||||
Pattern:
|
||||
- Outer panel is a flex item whose `width` transitions. The inner
|
||||
content keeps a fixed natural width and the outer `overflow: hidden`
|
||||
clips it during the slide so the user sees the panel grow from the
|
||||
edge rather than the content shrinking.
|
||||
- Inner content separately fades + nudges in for an attentive feel.
|
||||
|
||||
Styled to mirror the main FileSidebar - same toolbar background, same
|
||||
section-header treatment, same icon weight - so the two read as one
|
||||
unified surface. */
|
||||
|
||||
.folder-tree-panel {
|
||||
width: 0;
|
||||
flex-shrink: 0;
|
||||
background: var(--c-bg-raised);
|
||||
border-right: 0 solid var(--c-border-subtle);
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.folder-tree-panel[data-active="true"] {
|
||||
width: var(--folder-tree-panel-width, 16rem);
|
||||
border-right-width: 1px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.folder-tree-panel-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
transform: translateX(-1rem);
|
||||
/* Only fade/slide; width is driven by the inline custom property so
|
||||
* dragging the resizer doesn't animate. */
|
||||
transition:
|
||||
opacity 0.18s ease,
|
||||
transform 0.26s cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
}
|
||||
|
||||
.folder-tree-panel[data-active="true"] .folder-tree-panel-inner {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
transition-delay: 0.04s;
|
||||
}
|
||||
|
||||
/* Drag handle on the right edge. */
|
||||
.folder-tree-panel-resizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.folder-tree-panel-resizer:hover,
|
||||
.folder-tree-panel-resizer:focus-visible {
|
||||
background: color-mix(in srgb, var(--c-primary) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Section header - matches .file-sidebar-section-header in FileSidebar.css */
|
||||
.folder-tree-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 18px 6px 18px;
|
||||
margin-top: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-tree-panel-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--c-text-subtle);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Tree rows - mirror .file-sidebar-action-row from FileSidebar.css so the
|
||||
slide-out folder navigator reads as a continuation of the main sidebar's
|
||||
design language. Same row height, padding, font weight, muted icon
|
||||
treatment. The hover/active state uses the same --hover-bg pill that
|
||||
the sidebar's other rows do, with no heavy accent bar. */
|
||||
|
||||
.files-page-tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
|
||||
.files-page-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 4px;
|
||||
margin: 0 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-node:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
.files-page-tree-node.is-active {
|
||||
background: var(--c-hover);
|
||||
color: var(--c-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.files-page-tree-node.is-drop-target {
|
||||
background: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px var(--c-primary);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.files-page-tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-toggle svg {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.files-page-tree-spacer {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--c-text-subtle);
|
||||
margin-left: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.files-page-tree-icon svg {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.files-page-tree-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.files-page-tree-count {
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* Cap the user width on narrow viewports so the tree can't squeeze
|
||||
* out the file grid. The custom property is still honoured but capped. */
|
||||
.folder-tree-panel[data-active="true"] {
|
||||
width: min(var(--folder-tree-panel-width, 14rem), 14rem);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
/** Folder tree navigator panel rendered next to FileSidebar on /files. */
|
||||
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar";
|
||||
import { useFilesPage } from "@app/contexts/FilesPageContext";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { FolderId, FolderRecord } from "@app/types/folder";
|
||||
import {
|
||||
MIN_WIDTH,
|
||||
MAX_WIDTH,
|
||||
clamp,
|
||||
computeAutoFitWidth,
|
||||
loadPersistedWidth,
|
||||
savePersistedWidth,
|
||||
} from "@app/components/filesPage/folderTreeWidth";
|
||||
|
||||
import "@app/components/filesPage/FolderTreePanel.css";
|
||||
|
||||
interface FolderTreePanelProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export function FolderTreePanel({ active }: FolderTreePanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
fileCountsByFolder,
|
||||
openNewFolderDialog,
|
||||
openRenameFolderDialog,
|
||||
promptDeleteFolder,
|
||||
moveFilesTo,
|
||||
} = useFilesPage();
|
||||
const folders = useFolders();
|
||||
const rootLabel = t("filesPage.allFiles", "All files");
|
||||
|
||||
const [width, setWidth] = useState<number>(() => {
|
||||
const persisted = loadPersistedWidth();
|
||||
return persisted ?? 256;
|
||||
});
|
||||
const userSetRef = useRef<boolean>(loadPersistedWidth() !== null);
|
||||
|
||||
// Auto-fit to the longest folder name on first render and whenever the
|
||||
// folder list grows; skipped once the user manually resizes.
|
||||
useEffect(() => {
|
||||
if (userSetRef.current) return;
|
||||
const auto = computeAutoFitWidth(folders.folders, rootLabel);
|
||||
setWidth(auto);
|
||||
}, [folders.folders, rootLabel]);
|
||||
|
||||
const dragStateRef = useRef<{
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
} | null>(null);
|
||||
|
||||
const onMouseMove = useCallback((e: MouseEvent) => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
const next = clamp(state.startWidth + (e.clientX - state.startX));
|
||||
setWidth(next);
|
||||
}, []);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
const state = dragStateRef.current;
|
||||
if (!state) return;
|
||||
dragStateRef.current = null;
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.body.style.removeProperty("cursor");
|
||||
document.body.style.removeProperty("user-select");
|
||||
userSetRef.current = true;
|
||||
setWidth((current) => {
|
||||
savePersistedWidth(current);
|
||||
return current;
|
||||
});
|
||||
}, [onMouseMove]);
|
||||
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
dragStateRef.current = { startX: e.clientX, startWidth: width };
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
},
|
||||
[onMouseMove, onMouseUp, width],
|
||||
);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const step = e.shiftKey ? 32 : 8;
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowLeft") next = clamp(width - step);
|
||||
else if (e.key === "ArrowRight") next = clamp(width + step);
|
||||
else if (e.key === "Home") next = MIN_WIDTH;
|
||||
else if (e.key === "End") next = MAX_WIDTH;
|
||||
if (next === null) return;
|
||||
e.preventDefault();
|
||||
userSetRef.current = true;
|
||||
setWidth(next);
|
||||
savePersistedWidth(next);
|
||||
},
|
||||
[width],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="folder-tree-panel"
|
||||
data-active={String(active)}
|
||||
aria-hidden={!active}
|
||||
style={
|
||||
active
|
||||
? ({
|
||||
"--folder-tree-panel-width": `${width}px`,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div className="folder-tree-panel-inner">
|
||||
<div className="folder-tree-panel-header">
|
||||
<span className="folder-tree-panel-title">
|
||||
{t("fileSidebar.myFiles", "File library")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FolderTreeSidebar
|
||||
fileCounts={fileCountsByFolder}
|
||||
onRequestNewFolder={openNewFolderDialog}
|
||||
onRenameFolder={(folder: FolderRecord) =>
|
||||
openRenameFolderDialog(folder)
|
||||
}
|
||||
onDeleteFolder={promptDeleteFolder}
|
||||
onMoveFilesIntoFolder={async (
|
||||
targetId: FolderId | null,
|
||||
fileIds: FileId[],
|
||||
) => {
|
||||
if (fileIds.length === 0) return;
|
||||
await moveFilesTo(fileIds, targetId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{active && (
|
||||
<div
|
||||
className="folder-tree-panel-resizer"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={MIN_WIDTH}
|
||||
aria-valuemax={MAX_WIDTH}
|
||||
aria-valuenow={width}
|
||||
aria-label={t(
|
||||
"filesPage.resizeFolderTree",
|
||||
"Resize folder tree (arrow keys, Shift for bigger steps)",
|
||||
)}
|
||||
tabIndex={0}
|
||||
onMouseDown={onMouseDown}
|
||||
onKeyDown={onKeyDown}
|
||||
onDoubleClick={() => {
|
||||
const auto = computeAutoFitWidth(folders.folders, rootLabel);
|
||||
userSetRef.current = false;
|
||||
setWidth(auto);
|
||||
savePersistedWidth(auto);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/* Folder tree rows. They sit in the file sidebar's Folders section, so they take the
|
||||
same row height, padding and muted icon treatment as .file-sidebar-action-row, and
|
||||
the same hover pill rather than an accent bar. */
|
||||
|
||||
.files-page-tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
|
||||
.files-page-tree-node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 14px;
|
||||
border-radius: 4px;
|
||||
margin: 0 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--c-text-muted);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
transition: background-color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-node:hover {
|
||||
background: var(--c-hover);
|
||||
}
|
||||
|
||||
.files-page-tree-node.is-active {
|
||||
background: var(--c-hover);
|
||||
color: var(--c-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.files-page-tree-node.is-drop-target {
|
||||
background: color-mix(in srgb, var(--c-primary) 12%, transparent);
|
||||
box-shadow: inset 0 0 0 1px var(--c-primary);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
.files-page-tree-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--c-text-subtle);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-toggle svg {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.files-page-tree-spacer {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.files-page-tree-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--c-text-subtle);
|
||||
margin-left: 8px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.files-page-tree-icon svg {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.files-page-tree-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.files-page-tree-count {
|
||||
color: var(--c-text-subtle);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
/* Cap the user width on narrow viewports so the tree can't squeeze
|
||||
* out the file grid. The custom property is still honoured but capped. */
|
||||
.folder-tree-panel[data-active="true"] {
|
||||
width: min(var(--folder-tree-panel-width, 14rem), 14rem);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
serialiseFilesPageDragPayload,
|
||||
} from "@app/components/filesPage/dragDrop";
|
||||
import { useDropTarget } from "@app/components/filesPage/useDropTarget";
|
||||
import "@app/components/filesPage/FolderTreeSidebar.css";
|
||||
|
||||
/**
|
||||
* Hard cap on folder-tree render depth. The backend already enforces an
|
||||
@@ -54,14 +55,9 @@ interface FolderTreeSidebarProps {
|
||||
) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// This component is always rendered inside FolderTreePanel, which supplies
|
||||
// its own <aside> chrome and "New folder at root" toolbar control. An
|
||||
// earlier `embed` prop selected between an embedded list and a standalone
|
||||
// aside+header layout; the standalone layout was unused and its "New
|
||||
// folder at root" ActionIcon was not gated by `serverReachable`, so if
|
||||
// anyone re-wired the component into a non-embed surface they'd ship an
|
||||
// always-enabled mutation button against a possibly-offline server.
|
||||
// Deleted to remove the trap.
|
||||
// A list, not a surface: the section it sits in supplies the heading and chrome. It
|
||||
// carries no "New folder at root" control of its own - one that skipped the
|
||||
// serverReachable check used to ship an always-enabled mutation button here.
|
||||
export function FolderTreeSidebar({
|
||||
fileCounts,
|
||||
onRequestNewFolder,
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { FolderRecord } from "@app/types/folder";
|
||||
|
||||
const STORAGE_KEY = "stirling-folder-tree-width";
|
||||
export const MIN_WIDTH = 210;
|
||||
export const MAX_WIDTH = 480;
|
||||
const DEFAULT_WIDTH = 272;
|
||||
|
||||
const ROW_FONT =
|
||||
'14px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
const COUNT_FONT =
|
||||
'12px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
|
||||
/** Per-row chrome: left padding + toggle + icon + name margin + count margin + right padding. */
|
||||
const ROW_CHROME = 14 + 16 + 18 + 8 + 12 + 8 + 14;
|
||||
const INDENT_PER_LEVEL = 16;
|
||||
|
||||
function loadCanvas(): CanvasRenderingContext2D | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const canvas = document.createElement("canvas");
|
||||
return canvas.getContext("2d");
|
||||
}
|
||||
|
||||
function depthOf(
|
||||
folder: FolderRecord,
|
||||
byId: Map<string, FolderRecord>,
|
||||
): number {
|
||||
let depth = 0;
|
||||
let cursor: FolderRecord | undefined = folder;
|
||||
while (cursor && cursor.parentFolderId) {
|
||||
depth += 1;
|
||||
cursor = byId.get(cursor.parentFolderId);
|
||||
if (depth > 50) break;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/** Width needed to fit the longest folder row, clamped to [MIN, MAX]. */
|
||||
export function computeAutoFitWidth(
|
||||
folders: FolderRecord[],
|
||||
rootLabel: string,
|
||||
): number {
|
||||
const ctx = loadCanvas();
|
||||
if (!ctx) return DEFAULT_WIDTH;
|
||||
const byId = new Map(folders.map((f) => [f.id as string, f]));
|
||||
let maxName = 0;
|
||||
let maxDepth = 0;
|
||||
ctx.font = ROW_FONT;
|
||||
const measure = (name: string) => Math.ceil(ctx.measureText(name).width);
|
||||
maxName = Math.max(maxName, measure(rootLabel));
|
||||
for (const f of folders) {
|
||||
const w = measure(f.name);
|
||||
const d = depthOf(f, byId);
|
||||
if (w > maxName) maxName = w;
|
||||
if (d > maxDepth) maxDepth = d;
|
||||
}
|
||||
ctx.font = COUNT_FONT;
|
||||
// 4 digits covers typical counts (9999).
|
||||
const countWidth = Math.ceil(ctx.measureText("9999").width);
|
||||
const width = ROW_CHROME + maxDepth * INDENT_PER_LEVEL + maxName + countWidth;
|
||||
return clamp(width);
|
||||
}
|
||||
|
||||
export function clamp(width: number): number {
|
||||
if (Number.isNaN(width)) return DEFAULT_WIDTH;
|
||||
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.round(width)));
|
||||
}
|
||||
|
||||
export function loadPersistedWidth(): number | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
const raw = window.localStorage?.getItem(STORAGE_KEY);
|
||||
if (raw == null) return null;
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return null;
|
||||
return clamp(parsed);
|
||||
}
|
||||
|
||||
export function savePersistedWidth(width: number): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage?.setItem(STORAGE_KEY, String(clamp(width)));
|
||||
} catch {
|
||||
// localStorage can throw in private mode or when over quota; ignore.
|
||||
}
|
||||
}
|
||||
@@ -88,12 +88,13 @@ export default function Workbench() {
|
||||
const activeCustomView = customWorkbenchViews.find(
|
||||
(v) => v.workbenchId === currentView,
|
||||
);
|
||||
const topControlsAvailable =
|
||||
currentView !== "myFiles" && !activeCustomView?.hideTopControls;
|
||||
const topControlsAvailable = !activeCustomView?.hideTopControls;
|
||||
const hasWorkbenchContent =
|
||||
hasFiles ||
|
||||
fileIds.length > 0 ||
|
||||
!isBaseWorkbench(currentView) ||
|
||||
// The library browses stored files, so it has content of its own with none open.
|
||||
currentView === "myFiles" ||
|
||||
// Shared signing drives the viewer from the sidebar with no file in context.
|
||||
(currentView === "viewer" && !!signingOverlay?.file);
|
||||
// Reading hides the bar; the rail's Reader entry is the way back.
|
||||
|
||||
@@ -72,6 +72,9 @@ import {
|
||||
clearWatchedFolderDraggedFileIds,
|
||||
} from "@app/components/watchedFolders/watchedFolderDragState";
|
||||
import { WATCHED_FOLDERS_ENABLED } from "@app/constants/featureFlags";
|
||||
import { FolderTreeSidebar } from "@app/components/filesPage/FolderTreeSidebar";
|
||||
import { useFilesPage } from "@app/contexts/FilesPageContext";
|
||||
import type { FolderId, FolderRecord } from "@app/types/folder";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import "@app/components/shared/FileSidebar.css";
|
||||
|
||||
@@ -184,6 +187,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
const { state } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const filesPage = useFilesPage();
|
||||
const { setCustomWorkbenchViewData, customWorkbenchViews } =
|
||||
useToolWorkflow();
|
||||
const { workbench: currentWorkbench, selectedTool } = useNavigationState();
|
||||
@@ -1182,7 +1186,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<div className="file-sidebar-files-section sidebar-content-fade">
|
||||
<div className="file-sidebar-section-header">
|
||||
<span className="file-sidebar-section-label">
|
||||
{t("fileSidebar.library", "PDF Library")}
|
||||
{t("fileSidebar.library", "Files")}
|
||||
</span>
|
||||
<FileSidebarGroupControls stubs={allFileStubs} />
|
||||
<ActionIcon
|
||||
@@ -1317,6 +1321,34 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folders, under the files they hold. This was a panel of its own beside
|
||||
the workbench; the library is an ordinary view now, so the navigation
|
||||
for it belongs with the rest of the file chrome. */}
|
||||
{!collapsed && (
|
||||
<div className="file-sidebar-folders-section sidebar-content-fade">
|
||||
<div className="file-sidebar-section-header">
|
||||
<span className="file-sidebar-section-label">
|
||||
{t("fileSidebar.folders", "Folders")}
|
||||
</span>
|
||||
</div>
|
||||
<FolderTreeSidebar
|
||||
fileCounts={filesPage.fileCountsByFolder}
|
||||
onRequestNewFolder={filesPage.openNewFolderDialog}
|
||||
onRenameFolder={(folder: FolderRecord) =>
|
||||
filesPage.openRenameFolderDialog(folder)
|
||||
}
|
||||
onDeleteFolder={filesPage.promptDeleteFolder}
|
||||
onMoveFilesIntoFolder={async (
|
||||
targetId: FolderId | null,
|
||||
fileIds: FileId[],
|
||||
) => {
|
||||
if (fileIds.length === 0) return;
|
||||
await filesPage.moveFilesTo(fileIds, targetId);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</NavSurface>
|
||||
</div>
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "@app/types/workbenchBar";
|
||||
import InsertDriveFileOutlinedIcon from "@mui/icons-material/InsertDriveFileOutlined";
|
||||
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
|
||||
import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined";
|
||||
import WorkbenchBarDesktopActions from "@app/components/shared/workbenchBar/WorkbenchBarDesktopActions";
|
||||
import WorkbenchBarMobileActions from "@app/components/shared/workbenchBar/WorkbenchBarMobileActions";
|
||||
import WorkbenchBarToolbarHandle from "@app/components/shared/workbenchBar/WorkbenchBarToolbarHandle";
|
||||
@@ -413,6 +414,11 @@ export default function WorkbenchBar({
|
||||
label: t("workbenchBar.activeFiles", "Active Files"),
|
||||
icon: <FolderOutlinedIcon fontSize="small" />,
|
||||
},
|
||||
{
|
||||
value: "myFiles",
|
||||
label: t("fileSidebar.myFiles", "File library"),
|
||||
icon: <FolderOpenOutlinedIcon fontSize="small" />,
|
||||
},
|
||||
...(selectedTool === "multiTool"
|
||||
? [
|
||||
{
|
||||
@@ -519,7 +525,7 @@ export default function WorkbenchBar({
|
||||
<div className="workbench-bar-divider" />
|
||||
</>
|
||||
)}
|
||||
{(hasFiles || isCustomView) && (
|
||||
{(hasFiles || isCustomView || currentView === "myFiles") && (
|
||||
<SegmentedControl<WorkbenchType>
|
||||
className="workbench-bar-views"
|
||||
size="sm"
|
||||
|
||||
@@ -27,7 +27,6 @@ import { isApplyingRestoredView } from "@app/services/workbenchSession";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
|
||||
|
||||
import RightSidebar from "@app/components/tools/RightSidebar";
|
||||
@@ -57,7 +56,6 @@ import {
|
||||
} from "@app/contexts/FilesPageContext";
|
||||
import { useFolders } from "@app/contexts/FolderContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
|
||||
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
|
||||
|
||||
import { Button } from "@app/ui/Button";
|
||||
@@ -265,21 +263,6 @@ export default function HomePage() {
|
||||
}
|
||||
}, [navigationState.workbench, location.pathname, navigate]);
|
||||
|
||||
// Auto-collapse the FileSidebar while on /files; restore the user's persisted
|
||||
// preference on leave. Auto-collapse doesn't write to storage so deep-linking
|
||||
// to /files won't overwrite what the user actually chose.
|
||||
const prevWorkbenchRef = useRef(navigationState.workbench);
|
||||
useEffect(() => {
|
||||
const prev = prevWorkbenchRef.current;
|
||||
const curr = navigationState.workbench;
|
||||
if (curr === "myFiles" && prev !== "myFiles") {
|
||||
if (!fileSidebarCollapsed) setFileSidebarCollapsed(true);
|
||||
} else if (curr !== "myFiles" && prev === "myFiles") {
|
||||
setFileSidebarCollapsed(readPersistedSidebarCollapsed());
|
||||
}
|
||||
prevWorkbenchRef.current = curr;
|
||||
// fileSidebarCollapsed read as snapshot on transition only.
|
||||
}, [navigationState.workbench]);
|
||||
// Imperative, so the toggle still works while reading. Never persisted: not a preference.
|
||||
const prevReaderModeRef = useRef(readerMode);
|
||||
useEffect(() => {
|
||||
@@ -718,30 +701,14 @@ export default function HomePage() {
|
||||
bg="var(--c-bg)"
|
||||
>
|
||||
<div className="workspace-frame">
|
||||
<MyFilesAwareFileSidebar
|
||||
<FolderAwareFileSidebar
|
||||
ref={quickAccessRef}
|
||||
accountHoisted
|
||||
toggleAriaLabel={
|
||||
navigationState.workbench === "myFiles"
|
||||
? t("fileSidebar.leaveMyFiles", "Leave File library")
|
||||
: undefined
|
||||
}
|
||||
toggleIcon={
|
||||
navigationState.workbench === "myFiles" ? (
|
||||
<ArrowBackIcon />
|
||||
) : undefined
|
||||
}
|
||||
active={navigationState.workbench === "myFiles"}
|
||||
// Forced: a deep link to /files has no transition to collapse on.
|
||||
collapsed={
|
||||
navigationState.workbench === "myFiles" ||
|
||||
fileSidebarCollapsed
|
||||
}
|
||||
collapsed={fileSidebarCollapsed}
|
||||
onToggleCollapse={handleSidebarToggle}
|
||||
onOpenSettings={() => setConfigModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<FolderTreePanel active={navigationState.workbench === "myFiles"} />
|
||||
<Workbench />
|
||||
{!hideToolPanel && <RightSidebar />}
|
||||
<FileManager selectedTool={selectedTool} />
|
||||
@@ -756,29 +723,15 @@ export default function HomePage() {
|
||||
);
|
||||
}
|
||||
|
||||
interface MyFilesAwareFileSidebarProps extends FileSidebarProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/** Wraps FileSidebar with /files-aware overrides when `active`. */
|
||||
const MyFilesAwareFileSidebar = forwardRef<
|
||||
HTMLDivElement,
|
||||
MyFilesAwareFileSidebarProps
|
||||
>(function MyFilesAwareFileSidebar(props, ref) {
|
||||
const { active, ...rest } = props;
|
||||
if (!active) {
|
||||
return <FileSidebar ref={ref} {...rest} />;
|
||||
}
|
||||
return <MyFilesSidebarOverrides ref={ref} {...rest} />;
|
||||
});
|
||||
|
||||
const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
function MyFilesSidebarOverrides(props, ref) {
|
||||
/** Uploads land in the folder you have open, and New folder sits with the tree. */
|
||||
const FolderAwareFileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
function FolderAwareFileSidebar(props, ref) {
|
||||
const { t } = useTranslation();
|
||||
const filesPage = useFilesPage();
|
||||
const folders = useFolders();
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
const browsingLibrary = useNavigationState().workbench === "myFiles";
|
||||
const handleUpload = useCallback(
|
||||
async (files: File[]) => {
|
||||
const added = await addFiles(files, { skipWorkspaceDispatch: true });
|
||||
@@ -805,8 +758,11 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
|
||||
<FileSidebar
|
||||
ref={ref}
|
||||
{...props}
|
||||
onUploadFiles={handleUpload}
|
||||
onPickGoogleDriveFiles={handleUpload}
|
||||
// Only in the library: filing something away there should not open it, while
|
||||
// the sidebar's own path picks a view to open an upload in - which is what
|
||||
// you want everywhere else.
|
||||
onUploadFiles={browsingLibrary ? handleUpload : undefined}
|
||||
onPickGoogleDriveFiles={browsingLibrary ? handleUpload : undefined}
|
||||
extraAction={{
|
||||
icon: <CreateNewFolderIcon />,
|
||||
label: t("filesPage.newFolder", "New folder"),
|
||||
|
||||
@@ -822,10 +822,10 @@ test.describe("Files page", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Folder tree panel resize", () => {
|
||||
test.describe("Folder tree", () => {
|
||||
test.use({ autoGoto: false });
|
||||
|
||||
test("Resize handle is present and keyboard-adjustable", async ({
|
||||
test("lives in the file sidebar, not a panel of its own", async ({
|
||||
page,
|
||||
}) => {
|
||||
await stubStorageApis(page);
|
||||
@@ -833,27 +833,14 @@ test.describe("Files page", () => {
|
||||
{ id: "alpha", name: "alpha.pdf", remoteStorageId: null },
|
||||
]);
|
||||
await gotoFilesPage(page);
|
||||
const handle = page.locator(".folder-tree-panel-resizer").first();
|
||||
await expect(handle).toBeVisible();
|
||||
const before = await page.evaluate(() => {
|
||||
const el = document.querySelector(
|
||||
".folder-tree-panel[data-active='true']",
|
||||
) as HTMLElement | null;
|
||||
return el?.getBoundingClientRect().width ?? 0;
|
||||
});
|
||||
await handle.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const after = await page.evaluate(() => {
|
||||
const el = document.querySelector(
|
||||
".folder-tree-panel[data-active='true']",
|
||||
) as HTMLElement | null;
|
||||
return el?.getBoundingClientRect().width ?? 0;
|
||||
});
|
||||
// Four 8px steps = +32px.
|
||||
expect(after).toBeGreaterThanOrEqual(before + 24);
|
||||
|
||||
const tree = page.getByRole("tree", { name: /Folders/i });
|
||||
await expect(tree).toBeVisible();
|
||||
await expect(
|
||||
tree.getByRole("treeitem", { name: /All files/i }),
|
||||
).toBeVisible();
|
||||
// The library is an ordinary view now, so it adds no column of its own.
|
||||
await expect(page.locator(".folder-tree-panel")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user