From c53bea709382d9b96db614ce4c691a7a7547a89e Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Fri, 28 Nov 2025 21:06:48 +0100 Subject: [PATCH] feat: Implement keyboard navigation for desktop layout items with occlusion and Z-order awareness. --- frontend/src/desktop/DesktopWorkspace.tsx | 109 +++++++++++++++++++++- frontend/src/desktop/LayoutSystem.ts | 75 ++++++++++++++- 2 files changed, 180 insertions(+), 4 deletions(-) diff --git a/frontend/src/desktop/DesktopWorkspace.tsx b/frontend/src/desktop/DesktopWorkspace.tsx index a830aee..c806acc 100644 --- a/frontend/src/desktop/DesktopWorkspace.tsx +++ b/frontend/src/desktop/DesktopWorkspace.tsx @@ -206,20 +206,125 @@ const DesktopWorkspaceContent: React.FC = ({ useEffect(() => { const handleWindowKeyDown = (e: KeyboardEvent) => { + // Space preview logic if (e.code === 'Space' && selectedDocumentIds.length > 0) { - // Preview the last selected document const lastId = selectedDocumentIds[selectedDocumentIds.length - 1]; const doc = items.find(i => String(i.id) === lastId); if (doc) { e.preventDefault(); openPreview(doc); + return; + } + } + + // Navigation logic + if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { + e.preventDefault(); + + const layoutItems = Array.from(layoutStore.items.values()) as LayoutCard[]; + if (layoutItems.length === 0) return; + + let activeCard = null; + if (selectedDocumentIds.length > 0) { + // Use the last selected item as the anchor + const lastId = selectedDocumentIds[selectedDocumentIds.length - 1]; + activeCard = layoutStore.items.get(lastId); + } + + // If no selection or active card not found, select the top-most item + if (!activeCard) { + const topMost = layoutItems.reduce((prev, current) => (prev.z > current.z ? prev : current)); + handleSelectionChange([topMost.id]); + return; + } + + const cx = activeCard.centerX; + const cy = activeCard.centerY; + + let bestCandidate = null; + let minScore = Infinity; + + for (const candidate of layoutItems) { + if (candidate.id === activeCard.id) continue; + + const dx = candidate.centerX - cx; + const dy = candidate.centerY - cy; + + let valid = false; + let primaryDist = 0; + let offAxisDist = 0; + + switch (e.key) { + case 'ArrowRight': + if (dx > 0 && dx > Math.abs(dy)) { + valid = true; + primaryDist = dx; + offAxisDist = Math.abs(dy); + } + break; + case 'ArrowLeft': + if (dx < 0 && -dx > Math.abs(dy)) { + valid = true; + primaryDist = -dx; + offAxisDist = Math.abs(dy); + } + break; + case 'ArrowDown': + if (dy > 0 && dy > Math.abs(dx)) { + valid = true; + primaryDist = dy; + offAxisDist = Math.abs(dx); + } + break; + case 'ArrowUp': + if (dy < 0 && -dy > Math.abs(dx)) { + valid = true; + primaryDist = -dy; + offAxisDist = Math.abs(dx); + } + break; + } + + if (valid) { + // Weighted score: favor items closer in the primary direction, penalize off-axis + // We use a multiplier for off-axis distance to prefer "straighter" lines + // Reduced off-axis weight to favor directional distance (grid-like behavior) + let score = primaryDist + (offAxisDist * 0.2); + + // Z-Order Bonus: Subtract a small value based on Z-index to favor higher items + // Assuming max Z is around 10000, 0.1 gives a max bonus of 1000, which is significant but less than primary distance usually + score -= (candidate.z * 0.05); + + // Obstruction Penalty: Check if the candidate is obstructed + // If less than 5% is visible, treat as obstructed + if (candidate.getVisibleFraction() < 0.05) { + score += 5000; // Huge penalty for obstructed items + } + + if (score < minScore) { + minScore = score; + bestCandidate = candidate; + } + } + } + + if (bestCandidate) { + if (e.shiftKey) { + // Additive selection + const newSelection = new Set(selectedDocumentIds); + newSelection.add(bestCandidate.id); + handleSelectionChange(Array.from(newSelection)); + } else { + // Replace selection + handleSelectionChange([bestCandidate.id]); + } } } }; window.addEventListener('keydown', handleWindowKeyDown); return () => window.removeEventListener('keydown', handleWindowKeyDown); - }, [selectedDocumentIds, items, openPreview]); + }, [selectedDocumentIds, items, openPreview, layoutStore, handleSelectionChange]); return ( <> diff --git a/frontend/src/desktop/LayoutSystem.ts b/frontend/src/desktop/LayoutSystem.ts index eaa60f5..deaeb0a 100644 --- a/frontend/src/desktop/LayoutSystem.ts +++ b/frontend/src/desktop/LayoutSystem.ts @@ -143,11 +143,82 @@ export class LayoutCard implements LayoutCardState { ]; return corners.map(p => ({ - x: (p.x * cos - p.y * sin) + this.x, - y: (p.x * sin + p.y * cos) + this.y + x: (p.x * cos - p.y * sin) + this._centerX, + y: (p.x * sin + p.y * cos) + this._centerY })); } + containsPoint(x: number, y: number): boolean { + // Translate point to local space relative to center + const dx = x - this._centerX; + const dy = y - this._centerY; + + // Rotate point by -rotation to align with AABB + const rad = (-this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + + const localX = dx * cos - dy * sin; + const localY = dx * sin + dy * cos; + + const hw = this.width / 2; + const hh = this.height / 2; + + return localX >= -hw && localX <= hw && localY >= -hh && localY <= hh; + } + + getVisibleFraction(): number { + const samplesX = 4; + const samplesY = 4; + const totalSamples = samplesX * samplesY; + let visibleSamples = 0; + + // Get potential occluders (higher Z-index) + const occluders = Array.from(this.store.items.values()).filter(other => + other.id !== this.id && other.z > this.z + ); + + if (occluders.length === 0) return 1.0; + + const rad = (this.rotation * Math.PI) / 180; + const cos = Math.cos(rad); + const sin = Math.sin(rad); + const hw = this.width / 2; + const hh = this.height / 2; + + // Sample points across the card surface + for (let i = 0; i < samplesX; i++) { + for (let j = 0; j < samplesY; j++) { + // Normalized coordinates [-1, 1] + const nx = (i / (samplesX - 1)) * 2 - 1; + const ny = (j / (samplesY - 1)) * 2 - 1; + + // Local coordinates + const lx = nx * hw * 0.9; // 0.9 to avoid edge cases + const ly = ny * hh * 0.9; + + // World coordinates + const wx = (lx * cos - ly * sin) + this._centerX; + const wy = (lx * sin + ly * cos) + this._centerY; + + // Check occlusion + let isOccluded = false; + for (const occluder of occluders) { + if (occluder.containsPoint(wx, wy)) { + isOccluded = true; + break; + } + } + + if (!isOccluded) { + visibleSamples++; + } + } + } + + return visibleSamples / totalSamples; + } + private getAxes(): { x: number; y: number }[] { const rad = (this.rotation * Math.PI) / 180; const cos = Math.cos(rad);