feat: Implement keyboard navigation for desktop layout items with occlusion and Z-order awareness.
This commit is contained in:
@@ -206,20 +206,125 @@ const DesktopWorkspaceContent: React.FC<DesktopWorkspaceProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWindowKeyDown = (e: KeyboardEvent) => {
|
const handleWindowKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Space preview logic
|
||||||
if (e.code === 'Space' && selectedDocumentIds.length > 0) {
|
if (e.code === 'Space' && selectedDocumentIds.length > 0) {
|
||||||
// Preview the last selected document
|
|
||||||
const lastId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
const lastId = selectedDocumentIds[selectedDocumentIds.length - 1];
|
||||||
const doc = items.find(i => String(i.id) === lastId);
|
const doc = items.find(i => String(i.id) === lastId);
|
||||||
if (doc) {
|
if (doc) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openPreview(doc);
|
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);
|
window.addEventListener('keydown', handleWindowKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleWindowKeyDown);
|
return () => window.removeEventListener('keydown', handleWindowKeyDown);
|
||||||
}, [selectedDocumentIds, items, openPreview]);
|
}, [selectedDocumentIds, items, openPreview, layoutStore, handleSelectionChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -143,11 +143,82 @@ export class LayoutCard implements LayoutCardState {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return corners.map(p => ({
|
return corners.map(p => ({
|
||||||
x: (p.x * cos - p.y * sin) + this.x,
|
x: (p.x * cos - p.y * sin) + this._centerX,
|
||||||
y: (p.x * sin + p.y * cos) + this.y
|
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 }[] {
|
private getAxes(): { x: number; y: number }[] {
|
||||||
const rad = (this.rotation * Math.PI) / 180;
|
const rad = (this.rotation * Math.PI) / 180;
|
||||||
const cos = Math.cos(rad);
|
const cos = Math.cos(rad);
|
||||||
|
|||||||
Reference in New Issue
Block a user