mirror of
https://github.com/layer-systems/website.git
synced 2026-09-12 05:33:12 +02:00
feat: customizable desktop and mobile icon layouts (#40)
* feat: add customizable icon layouts * Address PR #40 review feedback on icon layout and drag interactions Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -11,6 +11,8 @@ import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { desktopApps } from '@/os/registry';
|
||||
import { useIconLayout } from '@/os/useIconLayout';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import { npubOf } from '@/lib/nostrUtils';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -42,6 +44,8 @@ export default function SettingsApp({ setTitle }: AppProps) {
|
||||
<Separator />
|
||||
<MediaSection />
|
||||
<Separator />
|
||||
<IconLayoutSection />
|
||||
<Separator />
|
||||
<SessionSection />
|
||||
</div>
|
||||
</AppBody>
|
||||
@@ -49,6 +53,32 @@ export default function SettingsApp({ setTitle }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function IconLayoutSection() {
|
||||
const { toast } = useToast();
|
||||
const appIds = desktopApps().map((app) => app.id);
|
||||
// Reset uses a deterministic, measurement-free registry order. The desktop
|
||||
// clamps it to its actual surface on render; mobile reflows into its columns.
|
||||
const { reset } = useIconLayout(appIds, { columns: 8, rows: 16 });
|
||||
|
||||
const resetAndToast = (profile: 'desktop' | 'mobile' | 'both') => {
|
||||
reset(profile);
|
||||
toast({ title: profile === 'both' ? 'All icon layouts reset' : `${profile === 'desktop' ? 'Desktop' : 'Mobile'} icon layout reset` });
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Home screen layout"
|
||||
description="Arrange icons by dragging them. On a keyboard, press Space to pick up an icon, use the arrow keys to move it, then press Enter to drop."
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => resetAndToast('desktop')}>Reset desktop</Button>
|
||||
<Button variant="outline" onClick={() => resetAndToast('mobile')}>Reset mobile</Button>
|
||||
<Button variant="outline" onClick={() => resetAndToast('both')}>Reset both</Button>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -11,6 +11,20 @@ import { WindowLayer } from './WindowLayer';
|
||||
import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { desktopApps } from '@/os/registry';
|
||||
import { MENUBAR_HEIGHT } from '@/os/layout';
|
||||
import { swapDesktopSlots, type DesktopSlot, type GridGeometry } from '@/os/iconLayout';
|
||||
import { useIconLayout } from '@/os/useIconLayout';
|
||||
|
||||
const CELL_WIDTH = 96;
|
||||
const CELL_HEIGHT = 92;
|
||||
const SURFACE_PADDING = 12;
|
||||
const DRAG_THRESHOLD = 6;
|
||||
|
||||
function geometryFor(width: number, height: number): GridGeometry {
|
||||
return {
|
||||
columns: Math.max(1, Math.floor((width - SURFACE_PADDING * 2) / CELL_WIDTH)),
|
||||
rows: Math.max(1, Math.floor((height - SURFACE_PADDING * 2) / CELL_HEIGHT)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop surface: dot-grid wallpaper, the app icons, and the layer the
|
||||
@@ -20,9 +34,102 @@ import { MENUBAR_HEIGHT } from '@/os/layout';
|
||||
export function Desktop() {
|
||||
const { openApp, windows, minimizeAll, closeAll } = useWindowManager();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [surfaceSize, setSurfaceSize] = useState(() => ({ width: window.innerWidth, height: window.innerHeight - MENUBAR_HEIGHT }));
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [candidate, setCandidate] = useState<{ col: number; row: number } | null>(null);
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [pickedLayout, setPickedLayout] = useState<DesktopSlot[] | null>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const pointerStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
|
||||
const apps = desktopApps();
|
||||
const geometry = useMemo(() => geometryFor(surfaceSize.width, surfaceSize.height), [surfaceSize]);
|
||||
const { layout, setDesktop, reset } = useIconLayout(apps.map((app) => app.id), geometry);
|
||||
const slots = layout.desktop;
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setSurfaceSize({ width: window.innerWidth, height: window.innerHeight - MENUBAR_HEIGHT });
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const cellAt = useCallback((clientX: number, clientY: number) => ({
|
||||
col: Math.max(0, Math.min(geometry.columns - 1, Math.round((clientX - SURFACE_PADDING - 40) / CELL_WIDTH))),
|
||||
row: Math.max(0, Math.min(geometry.rows - 1, Math.round((clientY - MENUBAR_HEIGHT - SURFACE_PADDING - 38) / CELL_HEIGHT))),
|
||||
}), [geometry]);
|
||||
|
||||
const move = useCallback((id: string, target: { col: number; row: number }) => {
|
||||
setDesktop((current) => swapDesktopSlots(current, id, target));
|
||||
const occupied = slots.find((slot) => slot.col === target.col && slot.row === target.row && slot.id !== id);
|
||||
setAnnouncement(occupied ? `${id} swapped positions with ${occupied.id}.` : `${id} moved to column ${target.col + 1}, row ${target.row + 1}.`);
|
||||
}, [setDesktop, slots]);
|
||||
|
||||
const onPointerMove = useCallback((event: PointerEvent) => {
|
||||
const active = pointerStart.current;
|
||||
if (!active) return;
|
||||
if (!active.moved && Math.hypot(event.clientX - active.x, event.clientY - active.y) < DRAG_THRESHOLD) return;
|
||||
active.moved = true;
|
||||
setDragging(active.id);
|
||||
setCandidate(cellAt(event.clientX, event.clientY));
|
||||
}, [cellAt]);
|
||||
const finishPointer = useCallback((event: PointerEvent) => {
|
||||
const active = pointerStart.current;
|
||||
if (active?.moved) {
|
||||
const target = cellAt(event.clientX, event.clientY);
|
||||
move(active.id, target);
|
||||
setSelected(active.id);
|
||||
}
|
||||
pointerStart.current = null;
|
||||
setDragging(null);
|
||||
setCandidate(null);
|
||||
}, [cellAt, move]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', finishPointer);
|
||||
window.addEventListener('pointercancel', finishPointer);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', finishPointer);
|
||||
window.removeEventListener('pointercancel', finishPointer);
|
||||
};
|
||||
}, [finishPointer, onPointerMove]);
|
||||
|
||||
const iconKeyDown = (id: string, event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
const slot = slots.find((item) => item.id === id);
|
||||
if (!slot) return;
|
||||
if (event.key === 'Escape' && picked) {
|
||||
event.preventDefault();
|
||||
if (pickedLayout) setDesktop(() => pickedLayout);
|
||||
setPicked(null);
|
||||
setPickedLayout(null);
|
||||
setAnnouncement('Move cancelled and original position restored.');
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || (event.key === 'Enter' && picked === id)) {
|
||||
event.preventDefault();
|
||||
if (picked === id) {
|
||||
setPicked(null);
|
||||
setPickedLayout(null);
|
||||
setAnnouncement(`${id} dropped at column ${slot.col + 1}, row ${slot.row + 1}.`);
|
||||
} else {
|
||||
setPicked(id);
|
||||
setPickedLayout(slots);
|
||||
setAnnouncement(`${id} picked up. Use arrow keys to move, Enter to drop, Escape to cancel.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const offsets: Record<string, { col: number; row: number }> = {
|
||||
ArrowLeft: { col: -1, row: 0 }, ArrowRight: { col: 1, row: 0 }, ArrowUp: { col: 0, row: -1 }, ArrowDown: { col: 0, row: 1 },
|
||||
};
|
||||
const offset = offsets[event.key];
|
||||
if (!offset || !picked) return;
|
||||
event.preventDefault();
|
||||
const target = { col: Math.max(0, Math.min(geometry.columns - 1, slot.col + offset.col)), row: Math.max(0, Math.min(geometry.rows - 1, slot.row + offset.row)) };
|
||||
move(id, target);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<main
|
||||
@@ -32,19 +139,32 @@ export function Desktop() {
|
||||
if (event.target === event.currentTarget) setSelected(null);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="grid grid-flow-col content-start gap-1 p-3"
|
||||
style={{ gridTemplateRows: 'repeat(auto-fill, minmax(84px, max-content))', maxHeight: '100%' }}
|
||||
>
|
||||
{apps.map((app) => (
|
||||
<DesktopIcon
|
||||
key={app.id}
|
||||
app={app}
|
||||
selected={selected === app.id}
|
||||
onSelect={() => setSelected(app.id)}
|
||||
onOpen={() => openApp(app.id)}
|
||||
/>
|
||||
))}
|
||||
<div className="absolute inset-0" aria-label="Desktop app grid">
|
||||
{apps.map((app) => {
|
||||
const slot = slots.find((item) => item.id === app.id);
|
||||
if (!slot) return null;
|
||||
const isCandidate = dragging === app.id && candidate;
|
||||
const displaySlot = isCandidate ? candidate : slot;
|
||||
return (
|
||||
<DesktopIcon
|
||||
key={app.id}
|
||||
app={app}
|
||||
selected={selected === app.id}
|
||||
dragging={dragging === app.id}
|
||||
pickedUp={picked === app.id}
|
||||
tabIndex={0}
|
||||
style={{ position: 'absolute', left: SURFACE_PADDING + displaySlot.col * CELL_WIDTH, top: SURFACE_PADDING + displaySlot.row * CELL_HEIGHT, zIndex: dragging === app.id ? 2 : 1 }}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
pointerStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false };
|
||||
setSelected(app.id);
|
||||
}}
|
||||
onKeyDown={(event) => iconKeyDown(app.id, event)}
|
||||
onSelect={() => { if (!pointerStart.current?.moved) setSelected(app.id); }}
|
||||
onOpen={() => openApp(app.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<WindowLayer />
|
||||
@@ -54,6 +174,9 @@ export function Desktop() {
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuItem onSelect={() => openApp('feed')}>Open Feed</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => openApp('settings')}>Open Settings</ContextMenuItem>
|
||||
<ContextMenuItem onSelect={() => { reset('desktop'); setAnnouncement('Desktop icon layout reset.'); }}>
|
||||
Reset desktop icon layout
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem disabled={windows.length === 0} onSelect={minimizeAll}>
|
||||
Minimize all windows
|
||||
@@ -62,6 +185,8 @@ export function Desktop() {
|
||||
Close all windows
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
</ContextMenu>
|
||||
<span id="icon-layout-status" className="sr-only" aria-live="polite">{announcement}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,17 +6,40 @@ interface DesktopIconProps {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onOpen: () => void;
|
||||
onPointerDown?: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
onKeyDown?: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
tabIndex?: number;
|
||||
dragging?: boolean;
|
||||
pickedUp?: boolean;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function DesktopIcon({ app, selected, onSelect, onOpen }: DesktopIconProps) {
|
||||
export function DesktopIcon({
|
||||
app,
|
||||
selected,
|
||||
onSelect,
|
||||
onOpen,
|
||||
onPointerDown,
|
||||
onKeyDown,
|
||||
tabIndex,
|
||||
dragging,
|
||||
pickedUp,
|
||||
style,
|
||||
}: DesktopIconProps) {
|
||||
const Icon = app.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-icon-id={app.id}
|
||||
tabIndex={tabIndex}
|
||||
style={style}
|
||||
onPointerDown={onPointerDown}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onOpen}
|
||||
onKeyDown={(event) => {
|
||||
onKeyDown?.(event);
|
||||
if (event.defaultPrevented) return;
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onOpen();
|
||||
@@ -24,10 +47,13 @@ export function DesktopIcon({ app, selected, onSelect, onOpen }: DesktopIconProp
|
||||
}}
|
||||
aria-label={`${app.title} — ${app.description}`}
|
||||
className={cn(
|
||||
'group flex w-20 flex-col items-center gap-1.5 rounded-lg p-2 text-center transition-colors',
|
||||
'group flex w-20 flex-col items-center gap-1.5 rounded-lg p-2 text-center transition-[background-color,transform,box-shadow] motion-reduce:transition-none',
|
||||
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
||||
selected ? 'bg-primary/15' : 'hover:bg-foreground/5',
|
||||
selected || pickedUp ? 'bg-primary/15' : 'hover:bg-foreground/5',
|
||||
dragging && 'scale-105 cursor-grabbing shadow-lg',
|
||||
)}
|
||||
aria-pressed={pickedUp || undefined}
|
||||
aria-describedby={pickedUp ? 'icon-layout-status' : undefined}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ChevronLeft, LayoutGrid, Zap } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
@@ -16,6 +16,9 @@ import { useWindowManager } from '@/os/useWindowManager';
|
||||
import { desktopApps, getApp } from '@/os/registry';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AppParams } from '@/os/types';
|
||||
import { useIconLayout } from '@/os/useIconLayout';
|
||||
|
||||
const MOBILE_DRAG_THRESHOLD = 8;
|
||||
|
||||
/**
|
||||
* On a phone the window metaphor only gets in the way, so the same apps and
|
||||
@@ -155,15 +158,138 @@ export function MobileAppShell() {
|
||||
}
|
||||
|
||||
function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
const apps = desktopApps();
|
||||
const [columns, setColumns] = useState(() => window.innerWidth < 480 ? 3 : 4);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState<string | null>(null);
|
||||
const [picked, setPicked] = useState<string | null>(null);
|
||||
const [pickedOrder, setPickedOrder] = useState<string[] | null>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
const dragStart = useRef<{ id: string; x: number; y: number; moved: boolean } | null>(null);
|
||||
const suppressClick = useRef(false);
|
||||
const targetRef = useRef<string | null>(null);
|
||||
const { layout, setMobile } = useIconLayout(apps.map((app) => app.id), { columns, rows: Math.max(8, Math.ceil(apps.length / columns) + 4) });
|
||||
const byId = useMemo(() => new Map(apps.map((app) => [app.id, app])), [apps]);
|
||||
const orderedApps = layout.mobile.map((id) => byId.get(id)).filter((app): app is NonNullable<typeof app> => Boolean(app));
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setColumns(window.innerWidth < 480 ? 3 : 4);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const reorder = useCallback((id: string, beforeId: string | null) => {
|
||||
setMobile((order) => {
|
||||
const without = order.filter((item) => item !== id);
|
||||
const index = beforeId ? without.indexOf(beforeId) : without.length;
|
||||
const next = [...without];
|
||||
next.splice(index < 0 ? next.length : index, 0, id);
|
||||
return next;
|
||||
});
|
||||
const position = beforeId ? Math.max(1, layout.mobile.indexOf(beforeId) + 1) : layout.mobile.length;
|
||||
setAnnouncement(`${id} moved to position ${position}.`);
|
||||
}, [layout.mobile, setAnnouncement, setMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const active = dragStart.current;
|
||||
if (!active) return;
|
||||
if (!active.moved && Math.hypot(event.clientX - active.x, event.clientY - active.y) < MOBILE_DRAG_THRESHOLD) return;
|
||||
active.moved = true;
|
||||
setDragging(active.id);
|
||||
const hit = document.elementFromPoint(event.clientX, event.clientY)?.closest<HTMLElement>('[data-home-icon-id]');
|
||||
const nextTarget = hit?.dataset.homeIconId ?? null;
|
||||
targetRef.current = nextTarget;
|
||||
setTarget(nextTarget);
|
||||
};
|
||||
const onEnd = () => {
|
||||
const active = dragStart.current;
|
||||
if (active?.moved) {
|
||||
suppressClick.current = true;
|
||||
const target = targetRef.current;
|
||||
reorder(active.id, target && target !== active.id ? target : null);
|
||||
}
|
||||
dragStart.current = null;
|
||||
setDragging(null);
|
||||
targetRef.current = null;
|
||||
setTarget(null);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onEnd);
|
||||
window.addEventListener('pointercancel', onEnd);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onEnd);
|
||||
window.removeEventListener('pointercancel', onEnd);
|
||||
};
|
||||
}, [reorder]);
|
||||
|
||||
const onKeyDown = (id: string, event: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||
const index = layout.mobile.indexOf(id);
|
||||
if (event.key === 'Escape' && picked) {
|
||||
event.preventDefault();
|
||||
if (pickedOrder) setMobile(() => pickedOrder);
|
||||
setPicked(null);
|
||||
setPickedOrder(null);
|
||||
setAnnouncement('Move cancelled and original position restored.');
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || (event.key === 'Enter' && picked === id)) {
|
||||
event.preventDefault();
|
||||
if (picked === id) {
|
||||
setPicked(null);
|
||||
setPickedOrder(null);
|
||||
setAnnouncement(`${id} dropped at position ${index + 1}.`);
|
||||
} else {
|
||||
setPicked(id);
|
||||
setPickedOrder(layout.mobile);
|
||||
setAnnouncement(`${id} picked up. Use arrow keys to reorder, Enter to drop, Escape to cancel.`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!picked) return;
|
||||
const offset = event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowRight' ? 1 : event.key === 'ArrowUp' ? -columns : event.key === 'ArrowDown' ? columns : 0;
|
||||
if (!offset) return;
|
||||
event.preventDefault();
|
||||
const nextIndex = Math.max(0, Math.min(layout.mobile.length - 1, index + offset));
|
||||
setMobile((order) => {
|
||||
const without = order.filter((item) => item !== id);
|
||||
const targetIndex = Math.max(0, Math.min(without.length, nextIndex));
|
||||
const next = [...without];
|
||||
next.splice(targetIndex, 0, id);
|
||||
return next;
|
||||
});
|
||||
setAnnouncement(`${id} moved to position ${nextIndex + 1}.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="os-desktop-surface h-full overflow-y-auto p-6">
|
||||
<div className="grid grid-cols-3 gap-4 sm:grid-cols-4">
|
||||
{desktopApps().map((app) => (
|
||||
<div className="grid grid-cols-3 gap-4 min-[480px]:grid-cols-4">
|
||||
{orderedApps.map((app) => (
|
||||
<button
|
||||
key={app.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(app.id)}
|
||||
className="flex flex-col items-center gap-2 rounded-xl p-2 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95"
|
||||
data-home-icon-id={app.id}
|
||||
aria-pressed={picked === app.id || undefined}
|
||||
aria-describedby={picked === app.id ? 'mobile-icon-layout-status' : undefined}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
dragStart.current = { id: app.id, x: event.clientX, y: event.clientY, moved: false };
|
||||
}}
|
||||
onClick={() => {
|
||||
if (suppressClick.current) {
|
||||
suppressClick.current = false;
|
||||
return;
|
||||
}
|
||||
onOpen(app.id);
|
||||
}}
|
||||
onKeyDown={(event) => onKeyDown(app.id, event)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-2 rounded-xl p-2 transition-[transform,background-color,box-shadow] motion-reduce:transition-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring active:scale-95',
|
||||
dragging === app.id && 'scale-105 bg-primary/15 shadow-lg',
|
||||
target === app.id && dragging !== app.id && 'bg-primary/10 ring-2 ring-primary/50',
|
||||
picked === app.id && 'bg-primary/15',
|
||||
)}
|
||||
>
|
||||
<span className="flex size-14 items-center justify-center rounded-2xl border border-os-window-border bg-background shadow-sm">
|
||||
<app.icon className="size-7 text-primary" aria-hidden />
|
||||
@@ -172,6 +298,7 @@ function HomeScreen({ onOpen }: { onOpen: (id: string) => void }) {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span id="mobile-icon-layout-status" className="sr-only" aria-live="polite">{announcement}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
129
src/os/iconLayout.test.ts
Normal file
129
src/os/iconLayout.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultDesktopLayout, reconcileDesktopLayout, reconcileMobileLayout, swapDesktopSlots } from './iconLayout';
|
||||
|
||||
const geometry = { columns: 4, rows: 4 };
|
||||
|
||||
describe('defaultDesktopLayout', () => {
|
||||
it('places apps in row-major order starting from the first cell', () => {
|
||||
const slots = defaultDesktopLayout(['a', 'b', 'c'], geometry);
|
||||
expect(slots).toEqual([
|
||||
{ id: 'a', col: 0, row: 0 },
|
||||
{ id: 'b', col: 1, row: 0 },
|
||||
{ id: 'c', col: 2, row: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps overflow icons on the last row when there are more apps than cells', () => {
|
||||
const ids = Array.from({ length: 18 }, (_, i) => `app-${i}`);
|
||||
const slots = defaultDesktopLayout(ids, geometry);
|
||||
for (const slot of slots) {
|
||||
expect(slot.row).toBeLessThan(geometry.rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileDesktopLayout', () => {
|
||||
it('drops unknown ids and keeps known ones in place', () => {
|
||||
const saved = [
|
||||
{ id: 'a', col: 0, row: 0 },
|
||||
{ id: 'stale', col: 1, row: 0 },
|
||||
];
|
||||
const reconciled = reconcileDesktopLayout(saved, ['a'], geometry);
|
||||
expect(reconciled).toEqual([{ id: 'a', col: 0, row: 0 }]);
|
||||
});
|
||||
|
||||
it('clamps out-of-bounds coordinates to the grid', () => {
|
||||
const saved = [{ id: 'a', col: 99, row: 99 }];
|
||||
const reconciled = reconcileDesktopLayout(saved, ['a'], geometry);
|
||||
expect(reconciled[0].col).toBeLessThan(geometry.columns);
|
||||
expect(reconciled[0].row).toBeLessThan(geometry.rows);
|
||||
});
|
||||
|
||||
it('resolves collisions by moving the later slot to the next free cell', () => {
|
||||
const saved = [
|
||||
{ id: 'a', col: 0, row: 0 },
|
||||
{ id: 'b', col: 0, row: 0 },
|
||||
];
|
||||
const reconciled = reconcileDesktopLayout(saved, ['a', 'b'], geometry);
|
||||
const a = reconciled.find((slot) => slot.id === 'a');
|
||||
const b = reconciled.find((slot) => slot.id === 'b');
|
||||
expect(a).toEqual({ id: 'a', col: 0, row: 0 });
|
||||
expect(b).not.toEqual({ id: 'b', col: 0, row: 0 });
|
||||
});
|
||||
|
||||
it('appends new apps to the first free cell after known ones', () => {
|
||||
const saved = [{ id: 'a', col: 0, row: 0 }];
|
||||
const reconciled = reconcileDesktopLayout(saved, ['a', 'b'], geometry);
|
||||
expect(reconciled).toEqual([
|
||||
{ id: 'a', col: 0, row: 0 },
|
||||
{ id: 'b', col: 1, row: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops duplicate saved entries for the same id', () => {
|
||||
const saved = [
|
||||
{ id: 'a', col: 0, row: 0 },
|
||||
{ id: 'a', col: 1, row: 0 },
|
||||
];
|
||||
const reconciled = reconcileDesktopLayout(saved, ['a'], geometry);
|
||||
expect(reconciled).toEqual([{ id: 'a', col: 0, row: 0 }]);
|
||||
});
|
||||
|
||||
it('keeps overflow icons within the grid when there are more apps than cells', () => {
|
||||
const ids = Array.from({ length: 18 }, (_, i) => `app-${i}`);
|
||||
const reconciled = reconcileDesktopLayout([], ids, geometry);
|
||||
for (const slot of reconciled) {
|
||||
expect(slot.row).toBeLessThan(geometry.rows);
|
||||
expect(slot.col).toBeLessThan(geometry.columns);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconcileMobileLayout', () => {
|
||||
it('keeps known ids in their saved order and drops unknown ones', () => {
|
||||
const ordered = reconcileMobileLayout(['b', 'stale', 'a'], ['a', 'b']);
|
||||
expect(ordered).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('appends new apps that were not in the saved order', () => {
|
||||
const ordered = reconcileMobileLayout(['a'], ['a', 'b']);
|
||||
expect(ordered).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('drops duplicate saved entries for the same id', () => {
|
||||
const ordered = reconcileMobileLayout(['a', 'a', 'b'], ['a', 'b']);
|
||||
expect(ordered).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('swapDesktopSlots', () => {
|
||||
it('swaps the moved icon with the icon occupying the target cell', () => {
|
||||
const slots = [
|
||||
{ id: 'a', col: 0, row: 0 },
|
||||
{ id: 'b', col: 1, row: 0 },
|
||||
];
|
||||
const next = swapDesktopSlots(slots, 'a', { col: 1, row: 0 });
|
||||
expect(next).toEqual([
|
||||
{ id: 'a', col: 1, row: 0 },
|
||||
{ id: 'b', col: 0, row: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('moves the icon to an empty cell without disturbing others', () => {
|
||||
const slots = [
|
||||
{ id: 'a', col: 0, row: 0 },
|
||||
{ id: 'b', col: 1, row: 0 },
|
||||
];
|
||||
const next = swapDesktopSlots(slots, 'a', { col: 2, row: 0 });
|
||||
expect(next).toEqual([
|
||||
{ id: 'a', col: 2, row: 0 },
|
||||
{ id: 'b', col: 1, row: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns the original slots unchanged when the id is not found', () => {
|
||||
const slots = [{ id: 'a', col: 0, row: 0 }];
|
||||
const next = swapDesktopSlots(slots, 'missing', { col: 1, row: 0 });
|
||||
expect(next).toBe(slots);
|
||||
});
|
||||
});
|
||||
133
src/os/iconLayout.ts
Normal file
133
src/os/iconLayout.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const STORAGE_KEY = 'nostr:icon-layout';
|
||||
const VERSION = 1;
|
||||
|
||||
export interface DesktopSlot {
|
||||
id: string;
|
||||
col: number;
|
||||
row: number;
|
||||
}
|
||||
|
||||
export interface IconLayout {
|
||||
desktop: DesktopSlot[];
|
||||
mobile: string[];
|
||||
}
|
||||
|
||||
export interface GridGeometry {
|
||||
columns: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
const SlotSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
col: z.number().int().min(0),
|
||||
row: z.number().int().min(0),
|
||||
});
|
||||
|
||||
const LayoutSchema = z.object({
|
||||
version: z.literal(VERSION),
|
||||
desktop: z.array(SlotSchema).max(200),
|
||||
mobile: z.array(z.string().min(1)).max(200),
|
||||
});
|
||||
|
||||
function firstFree(occupied: Set<string>, geometry: GridGeometry): Omit<DesktopSlot, 'id'> {
|
||||
for (let row = 0; row < geometry.rows; row += 1) {
|
||||
for (let col = 0; col < geometry.columns; col += 1) {
|
||||
if (!occupied.has(`${col}:${row}`)) return { col, row };
|
||||
}
|
||||
}
|
||||
// A very small viewport can temporarily have fewer cells than apps. Keep
|
||||
// the remaining icons in the last stable row so they stay on-screen.
|
||||
return { col: 0, row: Math.max(0, geometry.rows - 1) };
|
||||
}
|
||||
|
||||
export function defaultDesktopLayout(ids: string[], geometry: GridGeometry): DesktopSlot[] {
|
||||
const occupied = new Set<string>();
|
||||
return ids.map((id) => {
|
||||
const slot = firstFree(occupied, geometry);
|
||||
occupied.add(`${slot.col}:${slot.row}`);
|
||||
return { id, ...slot };
|
||||
});
|
||||
}
|
||||
|
||||
/** Drops unknown IDs, clamps coordinates, resolves collisions, then adds new apps. */
|
||||
export function reconcileDesktopLayout(
|
||||
saved: DesktopSlot[],
|
||||
ids: string[],
|
||||
geometry: GridGeometry,
|
||||
): DesktopSlot[] {
|
||||
const eligible = new Set(ids);
|
||||
const seen = new Set<string>();
|
||||
const occupied = new Set<string>();
|
||||
const reconciled: DesktopSlot[] = [];
|
||||
|
||||
for (const slot of saved) {
|
||||
if (!eligible.has(slot.id) || seen.has(slot.id)) continue;
|
||||
seen.add(slot.id);
|
||||
const col = Math.min(slot.col, Math.max(0, geometry.columns - 1));
|
||||
const row = Math.min(slot.row, Math.max(0, geometry.rows - 1));
|
||||
const key = `${col}:${row}`;
|
||||
const position = occupied.has(key) ? firstFree(occupied, geometry) : { col, row };
|
||||
occupied.add(`${position.col}:${position.row}`);
|
||||
reconciled.push({ id: slot.id, ...position });
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) continue;
|
||||
const position = firstFree(occupied, geometry);
|
||||
occupied.add(`${position.col}:${position.row}`);
|
||||
reconciled.push({ id, ...position });
|
||||
}
|
||||
return reconciled;
|
||||
}
|
||||
|
||||
export function reconcileMobileLayout(saved: string[], ids: string[]): string[] {
|
||||
const eligible = new Set(ids);
|
||||
const seen = new Set<string>();
|
||||
const ordered = saved.filter((id) => eligible.has(id) && !seen.has(id) && (seen.add(id), true));
|
||||
return [...ordered, ...ids.filter((id) => !seen.has(id))];
|
||||
}
|
||||
|
||||
export function loadIconLayout(ids: string[], geometry: GridGeometry): IconLayout {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
|
||||
const parsed = LayoutSchema.parse(JSON.parse(raw));
|
||||
return {
|
||||
desktop: reconcileDesktopLayout(parsed.desktop, ids, geometry),
|
||||
mobile: reconcileMobileLayout(parsed.mobile, ids),
|
||||
};
|
||||
} catch {
|
||||
return { desktop: defaultDesktopLayout(ids, geometry), mobile: [...ids] };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveIconLayout(layout: IconLayout): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: VERSION, ...layout }));
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing; the in-memory layout remains usable.
|
||||
}
|
||||
}
|
||||
|
||||
export function resetIconLayout(profile: 'desktop' | 'mobile' | 'both'): void {
|
||||
try {
|
||||
if (profile === 'both') localStorage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// The caller still resets its in-memory state.
|
||||
}
|
||||
}
|
||||
|
||||
export function swapDesktopSlots(slots: DesktopSlot[], id: string, target: Omit<DesktopSlot, 'id'>): DesktopSlot[] {
|
||||
const source = slots.find((slot) => slot.id === id);
|
||||
if (!source) return slots;
|
||||
const displaced = slots.find((slot) => slot.col === target.col && slot.row === target.row && slot.id !== id);
|
||||
return slots.map((slot) => {
|
||||
if (slot.id === id) return { ...slot, ...target };
|
||||
if (slot.id === displaced?.id) return { ...slot, col: source.col, row: source.row };
|
||||
return slot;
|
||||
});
|
||||
}
|
||||
|
||||
export const iconLayoutStorageKey = STORAGE_KEY;
|
||||
81
src/os/useIconLayout.ts
Normal file
81
src/os/useIconLayout.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
defaultDesktopLayout,
|
||||
iconLayoutStorageKey,
|
||||
loadIconLayout,
|
||||
reconcileDesktopLayout,
|
||||
reconcileMobileLayout,
|
||||
resetIconLayout,
|
||||
saveIconLayout,
|
||||
type DesktopSlot,
|
||||
type GridGeometry,
|
||||
type IconLayout,
|
||||
} from './iconLayout';
|
||||
|
||||
const SAVE_DELAY = 250;
|
||||
|
||||
export function useIconLayout(ids: string[], geometry: GridGeometry) {
|
||||
const idsKey = ids.join('|');
|
||||
const [layout, setLayout] = useState<IconLayout>(() => loadIconLayout(ids, geometry));
|
||||
const stableIds = useMemo(() => (idsKey ? idsKey.split('|') : []), [idsKey]);
|
||||
const stableGeometry = useMemo(
|
||||
() => ({ columns: geometry.columns, rows: geometry.rows }),
|
||||
[geometry.columns, geometry.rows],
|
||||
);
|
||||
const normalized = useMemo<IconLayout>(() => ({
|
||||
desktop: reconcileDesktopLayout(layout.desktop, stableIds, stableGeometry),
|
||||
mobile: reconcileMobileLayout(layout.mobile, stableIds),
|
||||
}), [layout, stableGeometry, stableIds]);
|
||||
|
||||
const dirty = useRef(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!dirty.current) return;
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(() => {
|
||||
dirty.current = false;
|
||||
saveIconLayout(normalized);
|
||||
}, SAVE_DELAY);
|
||||
return () => clearTimeout(saveTimer.current);
|
||||
}, [normalized]);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== iconLayoutStorageKey) return;
|
||||
setLayout(loadIconLayout(stableIds, stableGeometry));
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
const onReset = () => setLayout(loadIconLayout(stableIds, stableGeometry));
|
||||
window.addEventListener('icon-layout-reset', onReset);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener('icon-layout-reset', onReset);
|
||||
};
|
||||
}, [stableGeometry, stableIds]);
|
||||
|
||||
const setDesktop = useCallback((updater: (slots: DesktopSlot[]) => DesktopSlot[]) => {
|
||||
dirty.current = true;
|
||||
setLayout((current) => ({
|
||||
...current,
|
||||
desktop: updater(reconcileDesktopLayout(current.desktop, stableIds, stableGeometry)),
|
||||
}));
|
||||
}, [stableGeometry, stableIds]);
|
||||
const setMobile = useCallback((updater: (order: string[]) => string[]) => {
|
||||
dirty.current = true;
|
||||
setLayout((current) => ({ ...current, mobile: updater(reconcileMobileLayout(current.mobile, stableIds)) }));
|
||||
}, [stableIds]);
|
||||
const reset = useCallback((profile: 'desktop' | 'mobile' | 'both') => {
|
||||
resetIconLayout(profile);
|
||||
const next = {
|
||||
desktop: profile === 'mobile' ? normalized.desktop : defaultDesktopLayout(stableIds, stableGeometry),
|
||||
mobile: profile === 'desktop' ? normalized.mobile : [...stableIds],
|
||||
};
|
||||
dirty.current = false;
|
||||
clearTimeout(saveTimer.current);
|
||||
saveIconLayout(next);
|
||||
setLayout(next);
|
||||
window.dispatchEvent(new Event('icon-layout-reset'));
|
||||
}, [normalized.desktop, normalized.mobile, stableGeometry, stableIds]);
|
||||
|
||||
return useMemo(() => ({ layout: normalized, setDesktop, setMobile, reset }), [normalized, reset, setDesktop, setMobile]);
|
||||
}
|
||||
Reference in New Issue
Block a user