desktop selection

This commit is contained in:
2025-11-02 13:12:36 +01:00
parent 5bf64fc5f3
commit 9ddaa59155
9 changed files with 240 additions and 40 deletions
+16
View File
@@ -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;
+42 -3
View File
@@ -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 (
<>
<div className="desk-shell">
<div
className="desk-shell"
onPointerDown={(event) => {
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
onClearSelection();
}
}}
>
<div
className="desk-canvas"
ref={containerRef}
onDragOver={handleCanvasDragOver}
onDragLeave={handleCanvasDragLeave}
onDrop={handleCanvasDrop}
onPointerDown={(event) => {
if (event.target === event.currentTarget && typeof onClearSelection === 'function') {
onClearSelection();
}
}}
>
{!allSizesReady ? (
<div className="desk-empty">
@@ -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 (
<div
@@ -1899,7 +1929,12 @@ const DesktopWorkspaceView = () => {
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 ? <DetailPanel {...detailProps} /> : null;
const surfaceConfig = createWorkspaceSurfaceConfig({
key: 'workspace',
variant: 'workspace',
@@ -2018,10 +2056,11 @@ export const createDesktopSurface = ({
actions,
breadcrumbs: workspaceProps?.breadcrumbs || null,
content: <DesktopWorkspace {...workspaceProps} />,
detail,
});
return {
...surfaceConfig,
supportsDetail: false,
supportsDetail: Boolean(detailProps),
};
};
+36
View File
@@ -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,
+11 -1
View File
@@ -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) {
+100 -11
View File
@@ -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(
+1 -1
View File
@@ -879,7 +879,7 @@ const DetailPanel = ({
title="Open preview"
disabled={!singleHasPreview}
>
<WindowMaximizeIcon />
<WindowMaximizeIcon className="icon--flip-y" />
</button>,
);
}
+1 -19
View File
@@ -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 ? (
<button
type="button"
className="icon-button"
onClick={onNavigateParent}
aria-label="Go to parent folder"
title="Go to parent folder"
>
<ArrowUpIcon />
</button>
) : null;
const leading = sidebarToggle || parentControl
const leading = sidebarToggle
? (
<>
{sidebarToggle}
{parentControl}
</>
)
: null;
+9 -2
View File
@@ -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}
</span>
<span className="name">
<span className="name-wrap">
<FolderIcon className="folder-icon-image" size={16} />
{node.name}
<span className="name__label" title={node.name}>
{node.name}
</span>
</span>
{node.id !== 'root' && (
<div className="folder-row__actions">
+24 -3
View File
@@ -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 {