various
This commit is contained in:
Generated
+17
@@ -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
@@ -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"
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user