added ability to drag apps wherever on desktop theme

This commit is contained in:
Martin Dvořák 2026-09-23 23:55:30 +02:00
commit cd1766619d
3 changed files with 566 additions and 83 deletions

View file

@ -6,7 +6,7 @@ import { APPS, APP_BY_ID, createWebsiteApp, type AppDef } from "./apps";
import WinWindow, { type WinState } from "./WinWindow";
import Clock from "./Clock";
import { useTheme } from "./useTheme";
import { useDragOrder } from "./useDragOrder";
import { useDesktopIcons } from "./useDesktopIcons";
import { WALLPAPER, friends } from "../data";
const TASKBAR_HEIGHT = 48;
@ -24,6 +24,16 @@ interface Shortcut {
image?: string;
}
interface IconEntry {
id: string;
label: string;
title: string;
glyph: ReactNode;
color?: string;
image?: string;
onOpen: () => void;
}
const SERVICE_SHORTCUTS: Shortcut[] = [
{ id: "svc-proxmox", icon: "🖥️", label: "Proxmox", appId: "alacritty", color: "#1257a0", desc: "Server Management" },
{ id: "svc-ai", icon: "🦾", label: "Llama.cpp", url: "https://ai.dvorakmortonxd.dev/", color: "#157347", desc: "Local AI Server" },
@ -43,6 +53,13 @@ const FRIEND_SHORTCUTS: Shortcut[] = friends.map((f, i) => ({
desc: f.desc,
}));
/** Stable list of every desktop icon, used to seed the placement grid. */
const ICON_KEYS = [
...LEFT_APPS.map((a) => a.id),
...SERVICE_SHORTCUTS.map((s) => s.id),
...FRIEND_SHORTCUTS.map((f) => f.id),
];
export default function WindowsDesktop() {
const { theme, toggle } = useTheme();
@ -67,13 +84,13 @@ export default function WindowsDesktop() {
const [closingIds, setClosingIds] = useState<number[]>([]);
const [minimizingIds, setMinimizingIds] = useState<number[]>([]);
const [leftApps, setLeftApps] = useState(LEFT_APPS);
const [serviceItems, setServiceItems] = useState(SERVICE_SHORTCUTS);
const [friendItems, setFriendItems] = useState(FRIEND_SHORTCUTS);
const leftApps = LEFT_APPS;
const serviceItems = SERVICE_SHORTCUTS;
const friendItems = FRIEND_SHORTCUTS;
const leftDrag = useDragOrder(setLeftApps);
const serviceDrag = useDragOrder(setServiceItems);
const friendDrag = useDragOrder(setFriendItems);
const desktopRef = useRef<HTMLDivElement>(null);
const { positions, dragId, onPointerDown, consumeDrag } =
useDesktopIcons(desktopRef, ICON_KEYS);
const [dynamicApps, setDynamicApps] = useState<Record<string, AppDef>>({});
const getApp = (id: string): AppDef | undefined => APP_BY_ID[id] ?? dynamicApps[id];
@ -236,13 +253,79 @@ export default function WindowsDesktop() {
const wallpaper = `url('${WALLPAPER}')`;
const iconClick = (drag: ReturnType<typeof useDragOrder>, id: string, action: () => void) => {
if (drag.consumeDragged()) return;
const iconClick = (id: string, action: () => void) => {
if (consumeDrag()) return;
activateIcon(id, action);
};
const leftEntries: IconEntry[] = leftApps.map((app) => ({
id: app.id,
label: app.title,
title: `Open ${app.title}`,
glyph: app.icon,
color: app.color,
onOpen: () => openApp(app.id),
}));
const serviceEntries: IconEntry[] = serviceItems.map((s) => ({
id: s.id,
label: s.label,
title: s.label,
glyph: s.icon,
onOpen: () => openShortcut(s),
}));
const friendEntries: IconEntry[] = friendItems.map((f) => ({
id: f.id,
label: f.label,
title: f.label,
glyph: "🫂",
image: f.image,
onOpen: () => openShortcut(f),
}));
const allEntries = [...leftEntries, ...serviceEntries, ...friendEntries];
const renderIcon = (entry: IconEntry, interactive = true) => {
const pos = positions[entry.id];
return (
<button
key={entry.id}
type="button"
data-drag-id={entry.id}
className={
"win-icon" +
(selectedIcon === entry.id ? " selected" : "") +
(dragId === entry.id ? " dragging" : "")
}
style={interactive && pos ? { left: pos.x, top: pos.y } : undefined}
onPointerDown={interactive ? onPointerDown(entry.id) : undefined}
onClick={interactive ? () => iconClick(entry.id, entry.onOpen) : undefined}
tabIndex={interactive ? undefined : -1}
title={entry.title}
>
<span
className="win-icon-glyph"
style={entry.color ? { background: entry.color } : undefined}
>
{entry.image ? (
<Image
src={entry.image}
alt={entry.label}
width={38}
height={38}
className="win-icon-img"
draggable={false}
/>
) : (
entry.glyph
)}
</span>
<span className="win-icon-label">{entry.label}</span>
</button>
);
};
return (
<div
ref={desktopRef}
className="win-desktop"
style={{ backgroundImage: wallpaper }}
onPointerDown={(e) => {
@ -254,83 +337,20 @@ export default function WindowsDesktop() {
>
<div className="win-wallpaper-tint" aria-hidden="true" />
{/* Desktop icons */}
<div className="win-icons" onPointerDown={(e) => e.stopPropagation()}>
{leftApps.map((app) => (
<button
key={app.id}
type="button"
data-drag-id={app.id}
className={
"win-icon" +
(selectedIcon === app.id ? " selected" : "") +
(leftDrag.dragId === app.id ? " dragging" : "")
}
onPointerDown={leftDrag.onPointerDown(app.id)}
onClick={() => iconClick(leftDrag, app.id, () => openApp(app.id))}
title={`Open ${app.title}`}
>
<span className="win-icon-glyph" style={{ background: app.color }}>
{app.icon}
</span>
<span className="win-icon-label">{app.title}</span>
</button>
))}
{/* Desktop icons. The three stacks below are rendered once so the grid
can be measured from them, then the same icons are shown as an
absolutely positioned grid. */}
<div className="win-icons win-measure" aria-hidden="true">
{leftEntries.map((e) => renderIcon(e, false))}
</div>
<div className="win-icons win-icons-tr win-measure" aria-hidden="true">
{serviceEntries.map((e) => renderIcon(e, false))}
</div>
<div className="win-icons win-icons-br win-measure" aria-hidden="true">
{friendEntries.map((e) => renderIcon(e, false))}
</div>
{/* Services - top right */}
<div
className="win-icons win-icons-tr"
onPointerDown={(e) => e.stopPropagation()}
>
{serviceItems.map((s) => (
<button
key={s.id}
type="button"
data-drag-id={s.id}
className={
"win-icon" +
(selectedIcon === s.id ? " selected" : "") +
(serviceDrag.dragId === s.id ? " dragging" : "")
}
onPointerDown={serviceDrag.onPointerDown(s.id)}
title={s.label}
onClick={() => iconClick(serviceDrag, s.id, () => openShortcut(s))}
>
<span className="win-icon-glyph">{s.icon}</span>
<span className="win-icon-label">{s.label}</span>
</button>
))}
</div>
{/* Friends - bottom right */}
<div
className="win-icons win-icons-br"
onPointerDown={(e) => e.stopPropagation()}
>
{friendItems.map((f) => (
<button
key={f.id}
type="button"
data-drag-id={f.id}
className={
"win-icon" +
(selectedIcon === f.id ? " selected" : "") +
(friendDrag.dragId === f.id ? " dragging" : "")
}
onPointerDown={friendDrag.onPointerDown(f.id)}
title={f.label}
onClick={() => iconClick(friendDrag, f.id, () => openShortcut(f))}
>
<span className="win-icon-glyph">
{f.image && (
<Image src={f.image} alt={f.label} width={38} height={38} className="win-icon-img" />
)}
</span>
<span className="win-icon-label">{f.label}</span>
</button>
))}
</div>
<div className="win-icon-layer">{allEntries.map((e) => renderIcon(e))}</div>
{/* Windows */}
<div className="win-layer">

View file

@ -0,0 +1,412 @@
"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject,
} from "react";
export interface IconPosition {
x: number;
y: number;
}
/** Fallback cell size, used before the real icons have been measured. */
const DEFAULT_CELL_WIDTH = 100;
const DEFAULT_CELL_HEIGHT = 100;
/** Matches the `gap` on the launcher flex columns (see .win-icons). */
const GRID_GAP = 4;
const DRAG_THRESHOLD = 6;
const TASKBAR_HEIGHT = 48;
interface Cell {
col: number;
row: number;
}
/**
* Grid placement for desktop icons, like the Windows / KDE Plasma desktop.
*
* The grid is derived from the launcher layout itself: the columns are read
* straight off the default `.win-icons` stacks, so the first column has the
* same x as the icon stack you see on load. Every icon is assigned a cell, the
* default layout being the cells the stacks already occupy, and the desktop
* then renders that same grid from the start. Dragging just moves an icon
* between cells, so the grid never changes shape when you start dragging.
*
* A cell holds at most one icon: dropping onto an occupied cell steals it and
* the displaced icon is bumped to the nearest free cell.
*
* While dragging, a cloned copy of the icon ("ghost") follows the cursor like
* the translucent drag image on a real desktop. The ghost is created
* imperatively and moved through `transform`, so pointer moves do not trigger
* React re-renders.
*/
export function useDesktopIcons(
rootRef: RefObject<HTMLElement | null>,
iconKeys: string[]
) {
const [positions, setPositions] = useState<Record<string, IconPosition>>({});
const [dragId, setDragId] = useState<string | null>(null);
const cellRef = useRef({ w: DEFAULT_CELL_WIDTH, h: DEFAULT_CELL_HEIGHT });
const gridRef = useRef<{ cols: number; rows: number; originX: number; originY: number }>({
cols: 1,
rows: 1,
originX: 10,
originY: 12,
});
const suppressRef = useRef(false);
const clampCell = useCallback((col: number, row: number): Cell => {
const g = gridRef.current;
return {
col: Math.max(0, Math.min(g.cols - 1, col)),
row: Math.max(0, Math.min(g.rows - 1, row)),
};
}, []);
/**
* Work out the grid from where the launcher stacks actually sit. The icon
* width/height (plus the flex gap) is the cell pitch, and the left/top stack
* offsets give column 0 / row 0. Falls back to sensible values before mount.
*/
const measure = useCallback(() => {
const root = rootRef.current;
if (!root) return;
const rootRect = root.getBoundingClientRect();
const stacks = Array.from(
root.querySelectorAll<HTMLElement>(".win-icons")
).filter((el) => el.querySelector("[data-drag-id]"));
if (!stacks.length) return;
// Each stack renders from its own top offset; the left stack defines row 0,
// the top-right stack shares it and the bottom-right stack defines the last
// row band. Use the smallest top as the grid origin.
let cellW = 0;
let cellH = 0;
let originX = Infinity;
let originY = Infinity;
stacks.forEach((stack) => {
const stackRect = stack.getBoundingClientRect();
const first = stack.querySelector<HTMLElement>("[data-drag-id]");
const icons = Array.from(
stack.querySelectorAll<HTMLElement>("[data-drag-id]")
).filter((el) => !el.parentElement?.closest("[data-drag-id]"));
if (first) {
originX = Math.min(originX, first.getBoundingClientRect().left - rootRect.left);
}
// Rows are anchored to the icon tops in the left-most stack only.
if (stack.classList.contains("win-icons") && stackRect.left <= rootRect.left + 40) {
originY = Math.min(originY, stackRect.top - rootRect.top);
}
icons.forEach((el) => {
const r = el.getBoundingClientRect();
cellW = Math.max(cellW, r.width);
cellH = Math.max(cellH, r.height);
});
});
if (!Number.isFinite(originX)) originX = 10;
if (!Number.isFinite(originY)) originY = 12;
if (cellW <= 0 || cellH <= 0) return;
const pitchX = cellW + GRID_GAP;
const pitchY = cellH + GRID_GAP;
const width = root.clientWidth || window.innerWidth;
const height = (root.clientHeight || window.innerHeight) - TASKBAR_HEIGHT;
cellRef.current = { w: cellW, h: cellH };
gridRef.current = {
cols: Math.max(1, Math.floor((width - originX) / pitchX)),
rows: Math.max(1, Math.floor((height - originY) / pitchY)),
originX,
originY,
};
}, [rootRef]);
const posToCell = useCallback(
(p: IconPosition): Cell => {
const { w: cellW, h: cellH } = cellRef.current;
const g = gridRef.current;
return clampCell(
Math.round((p.x - g.originX) / (cellW + GRID_GAP)),
Math.round((p.y - g.originY) / (cellH + GRID_GAP))
);
},
[clampCell]
);
const cellToPos = useCallback((c: Cell): IconPosition => {
const { w: cellW, h: cellH } = cellRef.current;
const g = gridRef.current;
return {
x: g.originX + c.col * (cellW + GRID_GAP),
y: g.originY + c.row * (cellH + GRID_GAP),
};
}, []);
/** Nearest cell that no other icon occupies, scanning outward by distance. */
const nearestFreeCell = useCallback(
(from: Cell, taken: Set<string>): Cell | null => {
const g = gridRef.current;
let best: Cell | null = null;
let bestDist = Infinity;
for (let row = 0; row < g.rows; row++) {
for (let col = 0; col < g.cols; col++) {
if (taken.has(`${col},${row}`)) continue;
const dist = Math.abs(col - from.col) + Math.abs(row - from.row);
if (dist < bestDist) {
bestDist = dist;
best = { col, row };
if (dist === 0) return best;
}
}
}
return best;
},
[]
);
/**
* Force the one-icon-per-cell invariant. Icons keep the cell they are closest
* to, in a stable order; any icon whose cell is already taken is bumped to the
* nearest free cell. Every mutation path runs through this, so two icons can
* never end up stacked, even if their stored coordinates drift.
*/
const normalize = useCallback(
(
entries: [string, IconPosition][],
priority: string[] = []
): Record<string, IconPosition> => {
const rank = (key: string) => {
const i = priority.indexOf(key);
return i < 0 ? Number.MAX_SAFE_INTEGER : i;
};
// Sort by the requested priority first, then by position, so the order is
// deterministic and the dragged icon wins its cell.
const ordered = [...entries].sort(([ka, a], [kb, b]) => {
const d = rank(ka) - rank(kb);
if (d !== 0) return d;
return a.y - b.y || a.x - b.x;
});
const used = new Set<string>();
const out: Record<string, IconPosition> = {};
for (const [key, p] of ordered) {
const preferred = posToCell(p);
let cell = preferred;
if (used.has(`${cell.col},${cell.row}`)) {
const free = nearestFreeCell(preferred, used);
if (free) cell = free;
}
used.add(`${cell.col},${cell.row}`);
out[key] = cellToPos(cell);
}
return out;
},
[posToCell, nearestFreeCell, cellToPos]
);
/** Assign each icon the cell it already occupies in the launcher layout. */
const defaultCells = useCallback(
(keys: string[]): Record<string, IconPosition> => {
const root = rootRef.current;
const { w: cellW, h: cellH } = cellRef.current;
const g = gridRef.current;
const out: Record<string, IconPosition> = {};
if (!root) return out;
const stacks = Array.from(
root.querySelectorAll<HTMLElement>(".win-icons")
).filter((el) => el.querySelector("[data-drag-id]"));
const pitchX = cellW + GRID_GAP;
const pitchY = cellH + GRID_GAP;
const rootRect = root.getBoundingClientRect();
// Natural cell for each icon: the one its stack actually renders it in.
stacks.forEach((stack) => {
stack.querySelectorAll<HTMLElement>("[data-drag-id]").forEach((el) => {
const id = el.dataset.dragId;
if (!id) return;
const r = el.getBoundingClientRect();
out[id] = cellToPos(
clampCell(
Math.round((r.left - rootRect.left - g.originX) / pitchX),
Math.round((r.top - rootRect.top - g.originY) / pitchY)
)
);
});
});
// Any icon the stacks missed starts from the top-left.
keys.forEach((key) => {
if (!out[key]) out[key] = cellToPos({ col: 0, row: 0 });
});
// Resolve ties in the launcher's own reading order (left, services,
// friends) so the familiar stacks win their cells before anyone is bumped.
const order = stacks.flatMap((stack) =>
Array.from(stack.querySelectorAll<HTMLElement>("[data-drag-id]"))
.map((el) => el.dataset.dragId)
.filter((x): x is string => Boolean(x))
);
return normalize(Object.entries(out), order);
},
[rootRef, clampCell, cellToPos, normalize]
);
/** Point on the desktop -> the grid cell under it. */
const cellAtPoint = useCallback(
(x: number, y: number): Cell => {
const { w: cellW, h: cellH } = cellRef.current;
const g = gridRef.current;
return clampCell(
Math.floor((x - g.originX + GRID_GAP / 2) / (cellW + GRID_GAP)),
Math.floor((y - g.originY + GRID_GAP / 2) / (cellH + GRID_GAP))
);
},
[clampCell]
);
// Seed the grid from the launcher layout once, before the browser paints.
// The icons therefore start on exactly the grid they will be dragged on.
//
// This deliberately sets state from a layout effect: the grid can only be
// measured from real DOM nodes, so it cannot be derived before mount, and
// running before paint means the user never sees the pre-grid frame.
//
// `iconKeys` must be a stable array from the caller. It is the only
// dependency, so this runs once and never re-seeds a grid the user has
// already rearranged.
useLayoutEffect(() => {
measure();
// eslint-disable-next-line react-hooks/set-state-in-effect
setPositions(defaultCells(iconKeys));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [iconKeys]);
const onPointerDown =
(id: string) => (e: ReactPointerEvent<HTMLElement>) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
const root = rootRef.current;
if (!root) return;
const el = e.currentTarget;
const startX = e.clientX;
const startY = e.clientY;
let ghost: HTMLElement | null = null;
let offsetX = 0;
let offsetY = 0;
let lastX = startX;
let lastY = startY;
let active = false;
const move = (ev: PointerEvent) => {
if (!active) {
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) {
return;
}
active = true;
suppressRef.current = true;
// Build the drag ghost from the real icon so it looks identical.
const rect = el.getBoundingClientRect();
offsetX = startX - rect.left;
offsetY = startY - rect.top;
ghost = el.cloneNode(true) as HTMLElement;
ghost.classList.add("win-icon-ghost");
ghost.classList.remove("selected", "dragging");
ghost.style.left = "0";
ghost.style.top = "0";
ghost.style.width = `${rect.width}px`;
ghost.style.height = `${rect.height}px`;
ghost.style.transform = `translate(${rect.left}px, ${rect.top}px) scale(1.06)`;
document.body.appendChild(ghost);
document.body.style.cursor = "grabbing";
setDragId(id);
}
lastX = ev.clientX;
lastY = ev.clientY;
if (ghost) {
ghost.style.transform = `translate(${ev.clientX - offsetX}px, ${
ev.clientY - offsetY
}px) scale(1.06)`;
}
};
const finish = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
if (!active) return;
active = false;
const rootRect = root.getBoundingClientRect();
const dropPoint = {
x: lastX - offsetX - rootRect.left,
y: lastY - offsetY - rootRect.top,
};
// Snap by the icon's centre so a drop registers on the cell you aimed at.
const cell = cellAtPoint(
dropPoint.x + cellRef.current.w / 2,
dropPoint.y + cellRef.current.h / 2
);
setPositions((prev) => {
const next: Record<string, IconPosition> = {
...prev,
[id]: cellToPos(cell),
};
// The dragged icon wins the cell it was dropped on; whatever was
// there is bumped outward by normalize(), along with any icon that
// would otherwise share a cell.
return normalize(Object.entries(next), [id]);
});
ghost?.remove();
ghost = null;
document.body.style.cursor = "";
setDragId(null);
// The click that follows a drag is swallowed so it does not launch.
window.setTimeout(() => {
suppressRef.current = false;
}, 300);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish);
window.addEventListener("pointercancel", finish);
};
/** Returns true (once) when a drag just happened, so the click is ignored. */
const consumeDrag = useCallback(() => {
if (suppressRef.current) {
suppressRef.current = false;
return true;
}
return false;
}, []);
// Re-flow onto the grid when the viewport changes. Icons keep their relative
// order; anything that no longer fits is bumped to the nearest free cell.
useEffect(() => {
const onResize = () => {
measure();
setPositions((prev) => {
if (!Object.keys(prev).length) return prev;
return normalize(Object.entries(prev));
});
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [measure, normalize]);
return { positions, dragId, onPointerDown, consumeDrag };
}