diff --git a/frontend/src/DesktopWorkspace.css b/frontend/src/DesktopWorkspace.css index 7870e96..7725e78 100644 --- a/frontend/src/DesktopWorkspace.css +++ b/frontend/src/DesktopWorkspace.css @@ -81,6 +81,22 @@ z-index: 0 !important; } +.desk-item.is-selected { + z-index: 5; +} + +.desk-item.is-selected .desk-item__card { + box-shadow: + 0 0 0 0.18rem color-mix(in oklch, var(--accent) 45%, transparent), + 0 0 0.35rem 0 color-mix(in oklch, var(--accent) 28%, transparent), + 0 12px 28px -14px color-mix(in oklch, var(--accent) 20%, transparent), + 0 10px 24px var(--shadow-medium); +} + +.desk-item.is-selected .desk-item__title { + color: var(--accent); +} + .desk-item__tags { --tag-scale: 1; position: absolute; diff --git a/frontend/src/DesktopWorkspace.jsx b/frontend/src/DesktopWorkspace.jsx index 8486407..0f0a67d 100644 --- a/frontend/src/DesktopWorkspace.jsx +++ b/frontend/src/DesktopWorkspace.jsx @@ -11,6 +11,7 @@ import { useAssetNavigator } from './hooks/useAssetNavigator'; import { ArrowLeftIcon, ArrowRightIcon } from './ui/icons'; import { createDocumentsTableHeaderActions } from './documents/DocumentsPanel'; import createWorkspaceSurfaceConfig from './documents/workspaceHeader'; +import DetailPanel from './detail/DetailPanel'; import { clamp, formatTransform } from './desktop/math'; import { preventAll } from './desktop/events'; import useDocumentDrag from './desktop/useDocumentDrag'; @@ -565,11 +566,15 @@ const DesktopWorkspace = ({ documents = [], searchResults = null, onDocumentOpen, + onInspectDocument = null, + onDocumentPointerSelect = null, onAssignTagToDocument = null, onRemoveTagFromDocument = null, ensureAssetUrl = null, getDocumentAsset = () => null, activeTagIds = [], + selectedDocumentIds = [], + onClearSelection = null, }) => { const items = useMemo(() => (searchResults ? searchResults : documents), [documents, searchResults]); @@ -1708,6 +1713,8 @@ const syncLayoutSnapshot = useCallback(() => { pendingTagDocId, pendingRemovalTag, onDocumentOpen, + onInspectDocument, + onDocumentPointerSelect, ensureAssetUrl, getDocumentAsset, handleNavigatorSnapshot, @@ -1724,6 +1731,8 @@ const syncLayoutSnapshot = useCallback(() => { closeOverlay, overlayOriginRect, overlayOriginTransform, + selectedDocumentIds, + onClearSelection, }), [ activeTagSet, @@ -1753,6 +1762,8 @@ const syncLayoutSnapshot = useCallback(() => { layoutSnapshot, docSizeVersion, onDocumentOpen, + onInspectDocument, + onDocumentPointerSelect, openOverlayForDoc, overlayDisplay, overlayOriginRect, @@ -1766,6 +1777,8 @@ const syncLayoutSnapshot = useCallback(() => { tagDropTargetId, visibleDocIds, documentLookup, + selectedDocumentIds, + onClearSelection, ], ); @@ -1809,6 +1822,9 @@ const DesktopWorkspaceView = () => { closeOverlay, overlayOriginRect, overlayOriginTransform, + onDocumentPointerSelect, + selectedDocumentIds, + onClearSelection, } = useDesktopContext(); const { handlePointerDown, handlePointerMove, handlePointerUp, handlePointerCancel } = @@ -1818,13 +1834,25 @@ const DesktopWorkspaceView = () => { return ( <> -
+
{ + if (event.target === event.currentTarget && typeof onClearSelection === 'function') { + onClearSelection(); + } + }} + >
{ + if (event.target === event.currentTarget && typeof onClearSelection === 'function') { + onClearSelection(); + } + }} > {!allSizesReady ? (
@@ -1882,6 +1910,8 @@ const DesktopWorkspaceView = () => { if (dropActive) itemClasses.push('is-tag-target'); if (dropPending) itemClasses.push('is-tag-pending'); if (!matchesFilter) itemClasses.push('is-filtered-out'); + const isSelected = selectedDocumentIds.includes(doc.id); + if (isSelected) itemClasses.push('is-selected'); const docTagTokens = docTagKeys.join(' '); return (
{ itemRefs.current.delete(doc.id); } }} - onPointerDown={(event) => handlePointerDown(event, doc.id)} + onPointerDown={(event) => { + if (typeof onDocumentPointerSelect === 'function') { + onDocumentPointerSelect(doc.id, event); + } + handlePointerDown(event, doc.id); + }} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} @@ -1983,6 +2018,8 @@ export const createDesktopSurface = ({ renderSidebarToggle, parentBreadcrumb, onNavigateParent, + detailProps = null, + detailOpen = false, }) => { if (!workspaceProps) { return null; @@ -2007,6 +2044,7 @@ export const createDesktopSurface = ({ }); const sidebarToggle = renderSidebarToggle ? renderSidebarToggle() : null; + const detail = detailOpen && detailProps ? : null; const surfaceConfig = createWorkspaceSurfaceConfig({ key: 'workspace', variant: 'workspace', @@ -2018,10 +2056,11 @@ export const createDesktopSurface = ({ actions, breadcrumbs: workspaceProps?.breadcrumbs || null, content: , + detail, }); return { ...surfaceConfig, - supportsDetail: false, + supportsDetail: Boolean(detailProps), }; }; diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 3096809..01c0994 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -4791,6 +4791,34 @@ const AppLayout = () => { ], ); + const handleDeskInspectDocument = useCallback( + (docId) => { + if (!docId) { + return; + } + const rowKey = resolveDocumentRowKey(docId); + if (rowKey) { + applySelection([rowKey], { anchor: rowKey, interactedKeys: [rowKey] }); + } + openDetailPanel({ documentIds: [docId] }); + }, + [applySelection, openDetailPanel], + ); + + const handleDeskDocumentPointerSelect = useCallback( + (docId, event) => { + if (!docId) { + return; + } + const rowKey = resolveDocumentRowKey(docId); + if (!rowKey) { + return; + } + handleRowSelection(rowKey, event); + }, + [handleRowSelection], + ); + const deskWorkspaceProps = useMemo( () => ({ documents, @@ -4802,6 +4830,10 @@ const AppLayout = () => { onExit: handleDeskExit, onRefresh: refreshCurrentFolder, onDocumentOpen: openDocumentPreview, + onInspectDocument: handleDeskInspectDocument, + onDocumentPointerSelect: handleDeskDocumentPointerSelect, + selectedDocumentIds, + onClearSelection: clearDocumentSelection, resolveThumbnailUrl: resolveThumbnailUrlForDoc, onAssignTagToDocument: handleDocumentTagAttach, onRemoveTagFromDocument: handleTagRemove, @@ -4819,6 +4851,10 @@ const AppLayout = () => { handleDeskExit, refreshCurrentFolder, openDocumentPreview, + handleDeskInspectDocument, + handleDeskDocumentPointerSelect, + selectedDocumentIds, + clearDocumentSelection, resolveThumbnailUrlForDoc, handleDocumentTagAttach, handleTagRemove, diff --git a/frontend/src/app/useWorkspaceSurface.js b/frontend/src/app/useWorkspaceSurface.js index eaee1ba..7912f16 100644 --- a/frontend/src/app/useWorkspaceSurface.js +++ b/frontend/src/app/useWorkspaceSurface.js @@ -132,8 +132,18 @@ export const useWorkspaceSurface = ({ renderSidebarToggle, parentBreadcrumb, onNavigateParent: parentBreadcrumb ? onNavigateParent : null, + detailProps: detailPanelProps, + detailOpen: detailPanelOpen, }); - }, [viewMode, deskWorkspaceProps, renderSidebarToggle, parentBreadcrumb, onNavigateParent]); + }, [ + viewMode, + deskWorkspaceProps, + renderSidebarToggle, + parentBreadcrumb, + onNavigateParent, + detailPanelProps, + detailPanelOpen, + ]); const surface = useMemo(() => { if (showPreviewWorkspace) { diff --git a/frontend/src/desktop/useDocumentDrag.js b/frontend/src/desktop/useDocumentDrag.js index 57ce13c..5eb5704 100644 --- a/frontend/src/desktop/useDocumentDrag.js +++ b/frontend/src/desktop/useDocumentDrag.js @@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'; import { useDesktopContext } from './context'; import { preventAll } from './events'; import { clamp, formatTransform } from './math'; +import usePointerTap from '../ui/usePointerTap'; const DRAG_HYSTERESIS_PX = 4; const DRAG_HYSTERESIS_SQUARED = DRAG_HYSTERESIS_PX * DRAG_HYSTERESIS_PX; @@ -36,8 +37,33 @@ const useDocumentDrag = () => { recalcVisibleDocIds, settings, containerRef, + onDocumentOpen, + onInspectDocument, } = useDesktopContext(); + const tapHandler = usePointerTap({ + delay: 220, + onSingle: ({ data, event }) => { + if (!data || !data.docId) { + return; + } + 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); + }, + }); + const dragStateRef = useRef(null); const inertiaAnimationsRef = useRef(new Map()); const { canvasPadding, defaultCanvasWidth, defaultCanvasHeight, debugDrag } = settings; @@ -197,7 +223,10 @@ const useDocumentDrag = () => { Number.isFinite(baseScale) && baseScale > 0 ? baseScale : 1; const defaultCenterX = canvasPadding + docWidth / 2; const defaultCenterY = canvasPadding + docHeight / 2; - bringToFront(docId); + const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + if (!metaPressed) { + bringToFront(docId); + } const entry = layoutRef.current.get(docId) || null; const centerX = typeof entry?.centerX === 'number' ? entry.centerX : defaultCenterX; const centerY = typeof entry?.centerY === 'number' ? entry.centerY : defaultCenterY; @@ -338,8 +367,8 @@ const useDocumentDrag = () => { const rotatedOffsetY = state.localPointerOffsetX * sinRot + state.localPointerOffsetY * cosRot; - const desiredCenterX = pointerCanvasX - rotatedOffsetX; - const desiredCenterY = pointerCanvasY - rotatedOffsetY; + const previousCenterX = Number.isFinite(entry.centerX) ? entry.centerX : state.originCenterX; + const previousCenterY = Number.isFinite(entry.centerY) ? entry.centerY : state.originCenterY; const absCos = Math.abs(cosRot); const absSin = Math.abs(sinRot); @@ -351,6 +380,18 @@ const useDocumentDrag = () => { const maxCenterX = Math.max(minCenterX, canvasWidth - canvasPadding - rotatedHalfWidth); const minCenterY = canvasPadding + rotatedHalfHeight; const maxCenterY = Math.max(minCenterY, canvasHeight - canvasPadding - rotatedHalfHeight); + + const pointerRelativePrevX = pointerCanvasX - previousCenterX; + const pointerRelativePrevY = pointerCanvasY - previousCenterY; + const cosInversePrev = Math.cos(-rotationRad); + const sinInversePrev = Math.sin(-rotationRad); + const pointerLocalPrevX = pointerRelativePrevX * cosInversePrev - pointerRelativePrevY * sinInversePrev; + const pointerLocalPrevY = pointerRelativePrevX * sinInversePrev + pointerRelativePrevY * cosInversePrev; + const pointerInsideRelativeToPrev = + Math.abs(pointerLocalPrevX) <= halfWidth && Math.abs(pointerLocalPrevY) <= halfHeight; + + const desiredCenterX = pointerCanvasX - rotatedOffsetX; + const desiredCenterY = pointerCanvasY - rotatedOffsetY; const clampedCenterX = clamp(desiredCenterX, minCenterX, maxCenterX); const clampedCenterY = clamp(desiredCenterY, minCenterY, maxCenterY); @@ -363,19 +404,30 @@ const useDocumentDrag = () => { state.moved = true; } - let currentCenterX = clampedCenterX; - let currentCenterY = clampedCenterY; - const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY }; - layoutRef.current.set(state.docId, updated); - const collidedWithHorizontalEdge = Math.abs(desiredCenterY - clampedCenterY) > EDGE_COLLISION_THRESHOLD; const collidedWithVerticalEdge = Math.abs(desiredCenterX - clampedCenterX) > EDGE_COLLISION_THRESHOLD; const collidedWithEdge = collidedWithHorizontalEdge || collidedWithVerticalEdge; + let currentCenterX = clampedCenterX; + let currentCenterY = clampedCenterY; + if (collidedWithEdge && !pointerInsideRelativeToPrev) { + currentCenterX = previousCenterX; + currentCenterY = previousCenterY; + } + + const updated = { ...entry, centerX: currentCenterX, centerY: currentCenterY }; + layoutRef.current.set(state.docId, updated); + const offsetX = pointerCanvasX - currentCenterX; const offsetY = pointerCanvasY - currentCenterY; + const cosInverseCurrent = Math.cos(-rotationRad); + const sinInverseCurrent = Math.sin(-rotationRad); + const pointerLocalX = offsetX * cosInverseCurrent - offsetY * sinInverseCurrent; + const pointerLocalY = offsetX * sinInverseCurrent + offsetY * cosInverseCurrent; + const pointerInsideCard = + Math.abs(pointerLocalX) <= halfWidth && Math.abs(pointerLocalY) <= halfHeight; const currentTimestamp = typeof event.timeStamp === 'number' && Number.isFinite(event.timeStamp) @@ -415,6 +467,14 @@ const useDocumentDrag = () => { state.restRotation = nextRest; } dampingOverride = ANGULAR_DAMPING + EDGE_COLLISION_EXTRA_DAMPING; + if (!pointerInsideCard) { + torque = 0; + currentCenterX = entry?.centerX ?? currentCenterX; + currentCenterY = entry?.centerY ?? currentCenterY; + if (updated.centerX !== currentCenterX || updated.centerY !== currentCenterY) { + layoutRef.current.set(state.docId, { ...updated, centerX: currentCenterX, centerY: currentCenterY }); + } + } } state.lastClientX = event.clientX; @@ -522,6 +582,20 @@ const useDocumentDrag = () => { } } } + + const rotationForOffsetDeg = state.rotation || 0; + const rotationForOffsetRad = (rotationForOffsetDeg * Math.PI) / 180; + const cosInverse = Math.cos(-rotationForOffsetRad); + const sinInverse = Math.sin(-rotationForOffsetRad); + const pointerRelativeX = pointerCanvasX - currentCenterX; + const pointerRelativeY = pointerCanvasY - currentCenterY; + const updatedLocalOffsetX = pointerRelativeX * cosInverse - pointerRelativeY * sinInverse; + const updatedLocalOffsetY = pointerRelativeX * sinInverse + pointerRelativeY * cosInverse; + if (!collidedWithEdge || pointerInsideCard) { + state.localPointerOffsetX = updatedLocalOffsetX; + state.localPointerOffsetY = updatedLocalOffsetY; + } + if (debugDrag) { console.log('[desk] handlePointerMove: moved doc', state.docId, 'to', currentCenterX, currentCenterY); } @@ -563,20 +637,35 @@ const useDocumentDrag = () => { } const docId = state.docId; - bringToFront(docId); + const metaPressed = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; + if (!metaPressed) { + bringToFront(docId); + } const originInfo = { rotation: state.rotation || 0, scale: state.baseScale || 1, width: state.width, height: state.height, }; + const docKey = docId != null ? String(docId) : null; + const doc = docKey ? documentLookup.get(docKey) : null; + tapHandler(event, { + docId, + originInfo, + docTitle: doc?.title || 'document', + }); finishDrag(event.pointerId); - openOverlayForDoc(docId, originInfo); return; } finishDrag(event.pointerId); }, - [bringToFront, finishDrag, openOverlayForDoc, startInertiaAnimation], + [ + bringToFront, + documentLookup, + finishDrag, + startInertiaAnimation, + tapHandler, + ], ); const handlePointerCancel = useCallback( diff --git a/frontend/src/detail/DetailPanel.jsx b/frontend/src/detail/DetailPanel.jsx index 20d5097..53094c0 100644 --- a/frontend/src/detail/DetailPanel.jsx +++ b/frontend/src/detail/DetailPanel.jsx @@ -879,7 +879,7 @@ const DetailPanel = ({ title="Open preview" disabled={!singleHasPreview} > - + , ); } diff --git a/frontend/src/documents/workspaceHeader.js b/frontend/src/documents/workspaceHeader.js index 43a4654..b98b407 100644 --- a/frontend/src/documents/workspaceHeader.js +++ b/frontend/src/documents/workspaceHeader.js @@ -1,12 +1,9 @@ import React from 'react'; -import { ArrowUpIcon } from '../ui/icons'; export const createWorkspaceSurfaceConfig = ({ title, subtitle = null, sidebarToggle = null, - parentBreadcrumb = null, - onNavigateParent = null, actions = null, breadcrumbs = null, content = null, @@ -14,25 +11,10 @@ export const createWorkspaceSurfaceConfig = ({ variant = 'documents', key = 'documents', }) => { - const showParent = parentBreadcrumb && typeof onNavigateParent === 'function'; - - const parentControl = showParent ? ( - - ) : null; - - const leading = sidebarToggle || parentControl + const leading = sidebarToggle ? ( <> {sidebarToggle} - {parentControl} ) : null; diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 8565ca7..62037bc 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -65,6 +65,11 @@ const FolderNode = ({ className={rowClasses.join(' ')} draggable={canDrag} onClick={() => onSelect(node.id)} + onDoubleClick={() => { + if (canToggle) { + onToggle(node.id); + } + }} onDragOver={(event) => onDragOver(event, node.id)} onDragLeave={onDragLeave} onDrop={(event) => onDrop(event, node.id)} @@ -84,9 +89,11 @@ const FolderNode = ({ > {icon} - + - {node.name} + + {node.name} + {node.id !== 'root' && (
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 66aebf6..f4b02cd 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -555,6 +555,10 @@ button.danger:hover:not([disabled]) { justify-content: center; } +.icon--flip-y { + transform: scaleX(-1); +} + .visually-hidden { position: absolute; width: 1px; @@ -671,6 +675,10 @@ button.danger:hover:not([disabled]) { flex: 1 1 auto; } +.main-content__body--workspace.main-content__body--has-detail > .desk-shell { + margin-right: calc(-1 * var(--detail-panel-width)); +} + .main-content--has-detail { padding-right: var(--detail-panel-width); } @@ -1373,16 +1381,29 @@ button.danger:hover:not([disabled]) { transition: background 0.12s ease, color 0.12s ease; position: relative; overflow: hidden; + user-select: none; + -webkit-user-select: none; } -.folder-row span.name { +.folder-row .name-wrap { display: inline-flex; align-items: center; gap: 0.25rem; flex: 1; + min-width: 0; +} + +.folder-row .name-wrap .name__label { + display: block; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } +.folder-row .folder-icon-image { + flex-shrink: 0; +} + .folder-row:hover { background: var(--sidebar-hover-bg); color: var(--fg); @@ -2479,7 +2500,7 @@ button.danger:hover:not([disabled]) { } .panel.detail-panel { - position: absolute; + position: fixed; top: 0; right: 0; width: min(100vw, var(--detail-panel-width)); @@ -2491,7 +2512,7 @@ button.danger:hover:not([disabled]) { border-left: 1px solid var(--border); display: flex; flex-direction: column; - z-index: 20; + z-index: 1020; } .panel-header {