This commit is contained in:
2025-11-02 15:03:23 +01:00
parent 8f86a71c39
commit d6a6d1233c
10 changed files with 630 additions and 19 deletions
+17
View File
@@ -1959,6 +1959,7 @@ checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7"
dependencies = [
"bytemuck",
"byteorder-lite",
"image-webp",
"moxcms",
"num-traits",
"png",
@@ -1966,6 +1967,16 @@ dependencies = [
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "indexmap"
version = "2.11.4"
@@ -2695,6 +2706,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.32.0"
+1 -1
View File
@@ -36,7 +36,7 @@ sha2 = "0.10"
hex = "0.4"
bytes = "1.5"
async-trait = "0.1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
pdfium-render = "0.8"
mime_guess = "2.0"
tempfile = "3.10"
+4 -4
View File
@@ -274,7 +274,7 @@ impl JobHandler for GenerateThumbnailsJob {
.put_object(
&s3_key,
image.image_bytes.clone(),
Some("image/png".into()),
Some("image/webp".into()),
None,
)
.await
@@ -315,7 +315,7 @@ impl JobHandler for GenerateThumbnailsJob {
.put_object(
&s3_key,
image.image_bytes.clone(),
Some("image/png".into()),
Some("image/webp".into()),
None,
)
.await
@@ -609,7 +609,7 @@ fn encode_dynamic_image(image: image::DynamicImage) -> Result<GeneratedImage, St
let (width, height) = image.dimensions();
let mut cursor = Cursor::new(Vec::new());
image
.write_to(&mut cursor, ImageFormat::Png)
.write_to(&mut cursor, ImageFormat::WebP)
.map_err(|err| err.to_string())?;
Ok(GeneratedImage {
image_bytes: cursor.into_inner(),
@@ -660,7 +660,7 @@ fn persist_assets_metadata(
id: asset.asset_id,
document_version_id: context.version.id,
asset_type: asset.asset_type.to_string(),
mime_type: "image/png".to_string(),
mime_type: "image/webp".to_string(),
metadata: json!({
"generated_at": Utc::now().to_rfc3339(),
}),
+94
View File
@@ -120,6 +120,100 @@
box-shadow: 2px 2px 4px var(--shadow-medium);
}
.desk-help-overlay {
position: fixed;
inset: 0;
z-index: 1400;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(1.5rem, 4vw, 3rem);
pointer-events: none;
}
.desk-help-overlay__backdrop {
position: absolute;
inset: 0;
background: var(--surface-overlay);
backdrop-filter: blur(8px);
pointer-events: auto;
}
.desk-help-overlay__content {
position: relative;
width: min(640px, 92vw);
max-height: min(80vh, 640px);
background: var(--surface);
border: 1px solid var(--border);
box-shadow: 0 24px 64px var(--shadow-strong);
border-radius: 1.25rem;
display: flex;
flex-direction: column;
pointer-events: auto;
overflow: hidden;
}
.desk-help-overlay__header,
.desk-help-overlay__footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
}
.desk-help-overlay__header {
border-bottom: 1px solid var(--border);
}
.desk-help-overlay__header h2 {
margin: 0;
font-size: 1.15rem;
font-weight: 600;
}
.desk-help-overlay__body {
padding: 1rem 1.25rem 1.5rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
line-height: 1.55;
}
.desk-help-overlay__body p {
margin: 0;
color: var(--muted);
}
.desk-help-overlay__list {
margin: 0;
padding-left: 1.1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.desk-help-overlay__list li {
color: var(--fg);
}
.desk-help-overlay__body kbd {
display: inline-block;
padding: 0.15rem 0.4rem;
border-radius: 0.4rem;
border: 1px solid var(--border-strong);
background: var(--surface-subtle);
font-size: 0.85em;
line-height: 1;
font-family: inherit;
}
.desk-help-overlay__footer {
border-top: 1px solid var(--border);
justify-content: flex-end;
gap: 0.5rem;
}
.tag-chip--draggable:active {
cursor: grabbing;
}
+368 -3
View File
@@ -6,9 +6,10 @@ import React, {
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { resolveDocumentAssetUrl, createAssetView } from './asset_manager';
import { useAssetNavigator } from './hooks/useAssetNavigator';
import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons';
import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from './ui/icons';
import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel';
import createWorkspaceSurfaceConfig from './documents/workspaceHeader';
import DetailPanel from './detail/DetailPanel';
@@ -33,6 +34,10 @@ const DEFAULT_CANVAS_HEIGHT = 680;
const CARD_MIN = 240;
const CARD_MAX = 340;
const TAG_REMOVE_DISTANCE = 160;
const STACK_HIT_EPSILON = 4;
const STACK_CENTER_TOLERANCE = 0.35;
const STACK_CENTER_MIN = 32;
const STACK_ROTATION_TOLERANCE = 15;
const DEBUG_DRAG = false;
const DEBUG_FOCUS = true;
@@ -568,6 +573,7 @@ const DesktopWorkspace = ({
onDocumentOpen,
onInspectDocument = null,
onDocumentPointerSelect = null,
onDocumentStackSelect = null,
onAssignTagToDocument = null,
onRemoveTagFromDocument = null,
ensureAssetUrl = null,
@@ -575,6 +581,8 @@ const DesktopWorkspace = ({
activeTagIds = [],
selectedDocumentIds = [],
onClearSelection = null,
helpOpen = false,
onHelpClose = null,
}) => {
const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]);
@@ -1715,6 +1723,7 @@ const syncLayoutSnapshot = useCallback(() => {
onDocumentOpen,
onInspectDocument,
onDocumentPointerSelect,
onDocumentStackSelect,
ensureAssetUrl,
getDocumentAsset,
handleNavigatorSnapshot,
@@ -1779,12 +1788,14 @@ const syncLayoutSnapshot = useCallback(() => {
documentLookup,
selectedDocumentIds,
onClearSelection,
onDocumentStackSelect,
],
);
return (
<DesktopProvider value={contextValue}>
<DesktopWorkspaceView />
<DesktopHelpOverlay open={helpOpen} onClose={onHelpClose} />
</DesktopProvider>
);
};
@@ -1823,15 +1834,245 @@ const DesktopWorkspaceView = () => {
overlayOriginRect,
overlayOriginTransform,
onDocumentPointerSelect,
onDocumentStackSelect,
selectedDocumentIds,
onClearSelection,
documentLookup,
} = useDesktopContext();
const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } =
useDocumentDrag();
const resolveStackDocIds = useCallback(
(event, targetDocId = null) => {
const container = containerRef.current;
if (!container || !event) {
return [];
}
const rect = container.getBoundingClientRect();
const pointerCanvasX = event.clientX - rect.left;
const pointerCanvasY = event.clientY - rect.top;
if (!Number.isFinite(pointerCanvasX) || !Number.isFinite(pointerCanvasY)) {
return [];
}
const hits = [];
items.forEach((doc) => {
if (!doc?.id) {
return;
}
const layout = layoutSnapshot.get(doc.id) ?? layoutRef.current.get(doc.id);
if (!layout) {
return;
}
const sizeInfo = ensureDocumentSize(doc);
if (!sizeInfo) {
return;
}
const { width, height } = sizeInfo;
if (!width || !height) {
return;
}
if (activeTagSet.size) {
const docTagKeys = Array.isArray(doc.tags)
? doc.tags.map((tag) => (tag ? tag.id : null)).filter(Boolean)
: [];
if (!docTagKeys.some((key) => activeTagSet.has(key))) {
return;
}
}
const centerX = Number(layout.centerX);
const centerY = Number(layout.centerY);
if (!Number.isFinite(centerX) || !Number.isFinite(centerY)) {
return;
}
const rotationDeg = Number(layout.rotation) || 0;
const rotationRad = (rotationDeg * Math.PI) / 180;
const dx = pointerCanvasX - centerX;
const dy = pointerCanvasY - centerY;
const cosRotation = Math.cos(-rotationRad);
const sinRotation = Math.sin(-rotationRad);
const localX = dx * cosRotation - dy * sinRotation;
const localY = dx * sinRotation + dy * cosRotation;
const halfWidth = width / 2;
const halfHeight = height / 2;
if (
Math.abs(localX) <= halfWidth + STACK_HIT_EPSILON
&& Math.abs(localY) <= halfHeight + STACK_HIT_EPSILON
) {
const docKey = String(doc.id);
if (!hits.some((entry) => entry.id === docKey)) {
hits.push({
id: docKey,
z: Number.isFinite(layout.z) ? layout.z : 0,
});
}
}
});
if (!hits.length) {
return [];
}
hits.sort((a, b) => (b.z ?? 0) - (a.z ?? 0));
const targetKey = targetDocId != null ? String(targetDocId) : hits[0].id;
const orderedIds = hits.map((entry) => entry.id);
if (targetKey) {
const targetIndex = orderedIds.indexOf(targetKey);
if (targetIndex > 0) {
const [targetEntry] = orderedIds.splice(targetIndex, 1);
orderedIds.unshift(targetEntry);
}
}
const primaryKey = orderedIds[0];
if (!primaryKey) {
return orderedIds;
}
const primaryDoc = documentLookup.get(primaryKey) || null;
const primaryLayout = primaryDoc
? layoutSnapshot.get(primaryDoc.id) ?? layoutRef.current.get(primaryKey)
: null;
const primarySize = primaryDoc ? ensureDocumentSize(primaryDoc) : null;
if (!primaryLayout || !primarySize) {
return orderedIds;
}
const primaryCenterX = Number(primaryLayout.centerX);
const primaryCenterY = Number(primaryLayout.centerY);
const primaryRotation = Number(primaryLayout.rotation) || 0;
if (!Number.isFinite(primaryCenterX) || !Number.isFinite(primaryCenterY)) {
return orderedIds;
}
const centerTolX = Math.max(primarySize.width * STACK_CENTER_TOLERANCE, STACK_CENTER_MIN);
const centerTolY = Math.max(primarySize.height * STACK_CENTER_TOLERANCE, STACK_CENTER_MIN);
const filteredIds = [];
orderedIds.forEach((docKey, index) => {
if (!docKey) {
return;
}
if (index === 0 || docKey === targetKey) {
filteredIds.push(docKey);
return;
}
const candidateDoc = documentLookup.get(docKey) || null;
if (!candidateDoc) {
return;
}
const candidateLayout = layoutSnapshot.get(candidateDoc.id) ?? layoutRef.current.get(docKey);
if (!candidateLayout) {
return;
}
const candidateSize = ensureDocumentSize(candidateDoc);
if (!candidateSize) {
return;
}
const candidateCenterX = Number(candidateLayout.centerX);
const candidateCenterY = Number(candidateLayout.centerY);
if (!Number.isFinite(candidateCenterX) || !Number.isFinite(candidateCenterY)) {
return;
}
const dx = Math.abs(candidateCenterX - primaryCenterX);
const dy = Math.abs(candidateCenterY - primaryCenterY);
if (dx > centerTolX || dy > centerTolY) {
return;
}
const candidateRotation = Number(candidateLayout.rotation) || 0;
const rotationDiffRaw = Math.abs(candidateRotation - primaryRotation) % 360;
const rotationDiff = rotationDiffRaw > 180 ? 360 - rotationDiffRaw : rotationDiffRaw;
if (rotationDiff > STACK_ROTATION_TOLERANCE) {
return;
}
const sizeRatio = candidateSize.width && primarySize.width
? Math.min(candidateSize.width, primarySize.width) / Math.max(candidateSize.width, primarySize.width)
: 1;
const heightRatio = candidateSize.height && primarySize.height
? Math.min(candidateSize.height, primarySize.height) / Math.max(candidateSize.height, primarySize.height)
: 1;
if (sizeRatio < 0.55 || heightRatio < 0.55) {
return;
}
filteredIds.push(docKey);
});
return filteredIds;
},
[
activeTagSet,
ensureDocumentSize,
items,
layoutRef,
layoutSnapshot,
containerRef,
documentLookup,
],
);
const allSizesReady = items.every((doc) => ensureDocumentSize(doc));
useEffect(() => {
if (typeof window === 'undefined' || typeof onClearSelection !== 'function') {
return undefined;
}
const handleKeyDown = (event) => {
if (!event || event.defaultPrevented) {
return;
}
const key = event.key;
if (!(key === ' ' || key === 'Space' || key === 'Spacebar')) {
return;
}
if (!selectedDocumentIds || selectedDocumentIds.length === 0) {
return;
}
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName ? target.tagName.toLowerCase() : '';
if (
target.isContentEditable
|| tag === 'input'
|| tag === 'textarea'
|| tag === 'select'
|| tag === 'button'
) {
return;
}
}
event.preventDefault();
onClearSelection();
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [onClearSelection, selectedDocumentIds]);
return (
<>
<div
@@ -1931,8 +2172,27 @@ const DesktopWorkspaceView = () => {
}}
onPointerDown={(event) => {
const alreadySelected = selectedDocumentIds.includes(doc.id);
const metaOrCtrlOnly =
(event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey;
let stackDocIds = null;
if (
typeof onDocumentPointerSelect === 'function'
metaOrCtrlOnly
&& selectedDocumentIds.length === 0
&& typeof onDocumentStackSelect === 'function'
) {
const hits = resolveStackDocIds(event, doc.id);
if (Array.isArray(hits) && hits.length > 0) {
stackDocIds = hits;
onDocumentStackSelect(hits, event);
}
}
const stackHandled = Array.isArray(stackDocIds) && stackDocIds.length > 0;
if (
!stackHandled
&& typeof onDocumentPointerSelect === 'function'
&& (!alreadySelected
|| event.metaKey
|| event.ctrlKey
@@ -1941,7 +2201,7 @@ const DesktopWorkspaceView = () => {
) {
onDocumentPointerSelect(doc.id, event);
}
handlePointerDown(event, doc.id);
handlePointerDown(event, doc.id, { stackDocIds });
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
@@ -2021,6 +2281,110 @@ const DesktopWorkspaceView = () => {
export default DesktopWorkspace;
const DesktopHelpOverlay = ({ open = false, onClose = null }) => {
const portalTarget = typeof document !== 'undefined' ? document.body : null;
const closeButtonRef = useRef(null);
const previousFocusRef = useRef(null);
const handleClose = useCallback(() => {
if (typeof onClose === 'function') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (!open || typeof window === 'undefined') {
return undefined;
}
const handleKeyDown = (event) => {
if (!event) {
return;
}
if (event.key === 'Escape') {
event.preventDefault();
handleClose();
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [open, handleClose]);
useEffect(() => {
if (!open) {
const previous = previousFocusRef.current;
if (previous && typeof previous.focus === 'function') {
previous.focus();
}
previousFocusRef.current = null;
return;
}
if (typeof document !== 'undefined') {
previousFocusRef.current = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
}
if (closeButtonRef.current && typeof closeButtonRef.current.focus === 'function') {
closeButtonRef.current.focus();
}
}, [open]);
if (!open || !portalTarget) {
return null;
}
return createPortal(
<div className="desk-help-overlay" role="dialog" aria-modal="true" aria-labelledby="desk-help-title">
<div className="desk-help-overlay__backdrop" onClick={handleClose} />
<div className="desk-help-overlay__content">
<div className="desk-help-overlay__header">
<h2 id="desk-help-title">Desk view tips</h2>
<button
type="button"
className="icon-button"
onClick={handleClose}
aria-label="Close desk view tips"
ref={closeButtonRef}
>
<CloseIcon />
</button>
</div>
<div className="desk-help-overlay__body">
<p>Use the desk as a freeform workspace for triage and quick comparisons.</p>
<ul className="desk-help-overlay__list">
<li><strong>Single-click</strong> a document to open it in the detail panel.</li>
<li><strong>Double-click</strong> to open the zoomed preview.</li>
<li>
<strong>Drag</strong> selected cards to reposition them; build a selection with
{' '}
<kbd>Cmd</kbd>/<kbd>Ctrl</kbd>
{' '}+ click or Shift-click.
</li>
<li>
<strong>Cmd/Ctrl + click</strong> with an empty selection scoops up the stack under
{' '}the pointer.
</li>
<li><strong>Space</strong> clears the current selection.</li>
<li>
<strong>Drag tags</strong> from the sidebar onto a card to assign them, or fling a
{' '}tag away to remove it.
</li>
</ul>
</div>
<div className="desk-help-overlay__footer">
<button type="button" className="button primary" onClick={handleClose}>
Got it
</button>
</div>
</div>
</div>,
portalTarget,
);
};
export const createDesktopSurface = ({
workspaceProps,
renderSidebarToggle,
@@ -2049,6 +2413,7 @@ export const createDesktopSurface = ({
viewMode: viewMode || 'desk',
onViewModeChange,
onRefresh,
onShowDeskHelp: viewMode === 'desk' ? workspaceProps?.onOpenHelp : null,
});
const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null;
+45
View File
@@ -198,6 +198,7 @@ const AppLayout = () => {
const stored = window.localStorage.getItem('papercrate_view_mode');
return stored === 'grid' || stored === 'desk' ? stored : 'list';
});
const [deskHelpOpen, setDeskHelpOpen] = useState(false);
const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode);
useEffect(() => {
@@ -205,6 +206,12 @@ const AppLayout = () => {
lastNonDeskViewRef.current = documentsViewMode;
}
}, [documentsViewMode]);
useEffect(() => {
if (documentsViewMode !== 'desk' && deskHelpOpen) {
setDeskHelpOpen(false);
}
}, [documentsViewMode, deskHelpOpen]);
const initialRowSelection = [];
const tokenRef = useRef(token);
const refreshPromiseRef = useRef(null);
@@ -4819,6 +4826,36 @@ const AppLayout = () => {
[handleRowSelection],
);
const handleDeskDocumentStackSelect = useCallback(
(docIds) => {
if (!Array.isArray(docIds) || docIds.length === 0) {
return;
}
const rowKeys = docIds
.map((id) => resolveDocumentRowKey(id))
.filter(Boolean);
if (!rowKeys.length) {
return;
}
applySelection(rowKeys, {
anchor: rowKeys[0],
interactedKeys: rowKeys,
});
},
[applySelection],
);
const handleDeskHelpOpen = useCallback(() => {
setDeskHelpOpen(true);
}, []);
const handleDeskHelpClose = useCallback(() => {
setDeskHelpOpen(false);
}, []);
const deskWorkspaceProps = useMemo(
() => ({
documents,
@@ -4832,6 +4869,10 @@ const AppLayout = () => {
onDocumentOpen: openDocumentPreview,
onInspectDocument: handleDeskInspectDocument,
onDocumentPointerSelect: handleDeskDocumentPointerSelect,
onDocumentStackSelect: handleDeskDocumentStackSelect,
onOpenHelp: handleDeskHelpOpen,
helpOpen: deskHelpOpen,
onHelpClose: handleDeskHelpClose,
selectedDocumentIds,
onClearSelection: clearDocumentSelection,
resolveThumbnailUrl: resolveThumbnailUrlForDoc,
@@ -4853,6 +4894,10 @@ const AppLayout = () => {
openDocumentPreview,
handleDeskInspectDocument,
handleDeskDocumentPointerSelect,
handleDeskDocumentStackSelect,
handleDeskHelpOpen,
handleDeskHelpClose,
deskHelpOpen,
selectedDocumentIds,
clearDocumentSelection,
resolveThumbnailUrlForDoc,
+28 -9
View File
@@ -97,18 +97,21 @@ const useDocumentDrag = () => {
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
return;
}
openOverlayForDoc(data.docId, data.originInfo);
},
onDouble: ({ data }) => {
if (!data || !data.docId) {
return;
}
if (typeof onInspectDocument === 'function') {
onInspectDocument(data.docId);
return;
}
onDocumentOpen?.(data.docId);
},
onDouble: ({ data, event }) => {
if (!data || !data.docId) {
return;
}
if (event && (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)) {
return;
}
openOverlayForDoc(data.docId, data.originInfo);
},
});
const dragStateRef = useRef(null);
@@ -247,7 +250,7 @@ const useDocumentDrag = () => {
);
const handlePointerDown = useCallback(
(event, docIdInput) => {
(event, docIdInput, options = {}) => {
if (debugDrag) {
console.log(
'[desk] handlePointerDown fired for doc',
@@ -275,15 +278,31 @@ const useDocumentDrag = () => {
return;
}
const stackDocIdsOption = Array.isArray(options?.stackDocIds)
? options.stackDocIds
.map((value) => (value != null ? String(value) : null))
.filter(Boolean)
: null;
let selectionIds = Array.isArray(selectedDocumentIds)
? selectedDocumentIds.map((id) => String(id))
: [];
if (stackDocIdsOption && stackDocIdsOption.length) {
selectionIds = stackDocIdsOption;
}
const metaOrCtrl = event.metaKey || event.ctrlKey;
if (metaOrCtrl && !selectionIds.includes(docKey)) {
if (!stackDocIdsOption && metaOrCtrl && !selectionIds.includes(docKey)) {
selectionIds = [...selectionIds, docKey];
}
let groupDocIds = [];
if (selectionIds.includes(docKey) && selectionIds.length > 1) {
if (stackDocIdsOption && stackDocIdsOption.length) {
groupDocIds = stackDocIdsOption.filter((id, index, array) => {
const unique = array.indexOf(id) === index;
return unique && documentLookup.has(id);
});
} else if (selectionIds.includes(docKey) && selectionIds.length > 1) {
groupDocIds = selectionIds
.map((id) => String(id))
.filter((id, index, array) => array.indexOf(id) === index && documentLookup.has(id));
+13
View File
@@ -5,6 +5,7 @@ import {
IconFileStack,
RefreshIcon,
MinusVerticalIcon,
InfoIcon,
} from '../ui/icons';
import BreadcrumbTrail from '../ui/BreadcrumbTrail';
import createWorkspaceSurfaceConfig from './workspaceHeader';
@@ -526,6 +527,7 @@ export const createDocumentsTableHeaderActions = ({
viewMode,
onViewModeChange,
onRefresh,
onShowDeskHelp = null,
}) => {
const isListView = viewMode === 'list';
const isGridView = viewMode === 'grid';
@@ -574,6 +576,17 @@ export const createDocumentsTableHeaderActions = ({
>
<RefreshIcon />
</button>
{isDeskView && typeof onShowDeskHelp === 'function' ? (
<button
type="button"
className="icon-button"
onClick={onShowDeskHelp}
aria-label="Show desk view tips"
title="Show desk view tips"
>
<InfoIcon />
</button>
) : null}
</>
);
};
+50 -2
View File
@@ -7,6 +7,8 @@ import React, {
useEffect,
} from 'react';
const SIDEBAR_COLLAPSE_STORAGE_KEY = 'papercrate_sidebar_collapsed';
const SidebarContext = createContext(null);
const THEME_STORAGE_KEY = 'papercrate_theme_settings';
@@ -71,8 +73,26 @@ const loadInitialThemeSettings = () => {
return { neutralHue: neutralHueValue, mode: modeValue };
};
const loadInitialCollapsedState = (defaultValue) => {
if (typeof window === 'undefined') {
return Boolean(defaultValue);
}
try {
const stored = window.sessionStorage.getItem(SIDEBAR_COLLAPSE_STORAGE_KEY);
if (stored === '1' || stored === 'true') {
return true;
}
if (stored === '0' || stored === 'false') {
return false;
}
} catch (error) {
console.warn('[sidebar] failed to read collapse state', error);
}
return Boolean(defaultValue);
};
export const SidebarProvider = ({ initialCollapsed = false, children }) => {
const [collapsed, setCollapsed] = useState(initialCollapsed);
const [collapsed, setCollapsedState] = useState(() => loadInitialCollapsedState(initialCollapsed));
const initialTheme = useMemo(() => loadInitialThemeSettings(), []);
const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue);
const [themeMode, setThemeModeState] = useState(initialTheme.mode);
@@ -141,6 +161,25 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
setThemeModeState(THEME_MODES[nextIndex]);
}, [themeMode]);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
window.sessionStorage.setItem(SIDEBAR_COLLAPSE_STORAGE_KEY, collapsed ? '1' : '0');
} catch (error) {
console.warn('[sidebar] failed to persist collapse state', error);
}
}, [collapsed]);
const setCollapsed = useCallback((value) => {
if (typeof value === 'function') {
setCollapsedState((prev) => Boolean(value(prev)));
return;
}
setCollapsedState(Boolean(value));
}, []);
const contextValue = useMemo(
() => ({
collapsed,
@@ -154,7 +193,16 @@ export const SidebarProvider = ({ initialCollapsed = false, children }) => {
themeModes: THEME_MODES,
defaultNeutralHue: DEFAULT_NEUTRAL_HUE,
}),
[collapsed, neutralHue, setNeutralHue, resetNeutralHue, themeMode, setThemeMode, cycleThemeMode],
[
collapsed,
setCollapsed,
neutralHue,
setNeutralHue,
resetNeutralHue,
themeMode,
setThemeMode,
cycleThemeMode,
],
);
return <SidebarContext.Provider value={contextValue}>{children}</SidebarContext.Provider>;
+10
View File
@@ -29,6 +29,7 @@ import {
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconLayoutSidebarRightCollapse,
IconInfoCircle,
} from '@tabler/icons-react';
import FolderSvg from '../assets/folder.svg';
@@ -158,6 +159,15 @@ export const SidebarExpandIcon = ({ className, size = '1em', stroke = 1.6, ...re
/>
);
export const InfoIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconInfoCircle
className={composeClassName('icon', className)}
size={size}
stroke={stroke}
{...rest}
/>
);
export const DetailPanelCollapseIcon = ({ className, size = '1em', stroke = 1.6, ...rest }) => (
<IconLayoutSidebarRightCollapse
className={composeClassName('icon', className)}