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 };
}

View file

@ -93,6 +93,18 @@ html[data-device="phone"] .skeuo-mobile-root { display: block; }
cursor: default;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95);
transition: background-color 0.12s ease, border-color 0.12s ease;
/* Icons are dragged by our own pointer handlers. Without this, the browser's
native image/link drag hijacks the gesture (it fires pointercancel), which
broke dragging for icons whose glyph is an <img> - the friend shortcuts. */
-webkit-user-drag: none;
user-select: none;
}
.win-icon img,
.win-icon-glyph img,
.win-icon-label {
-webkit-user-drag: none;
user-drag: none;
pointer-events: none;
}
.win-icon:hover,
.win-icon.selected {
@ -151,6 +163,45 @@ a.win-icon {
opacity: 0.92;
}
/* The three default stacks are laid out once so the grid columns can be read
off them. They stay in the DOM (keeping the same metrics) but must never be
seen or clicked - the visible icons all come from .win-icon-layer. */
.win-icons.win-measure {
visibility: hidden;
pointer-events: none;
z-index: 0;
}
/* Icon grid layer: every icon is absolutely positioned on the grid. The layer
itself is click-through so empty-desktop clicks still reach the desktop
(closing the launcher / clearing the selection). */
.win-icon-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
}
.win-icon-layer .win-icon {
position: absolute;
pointer-events: auto;
}
/* Translucent copy of the icon that follows the cursor while dragging. */
.win-icon-ghost {
position: fixed;
z-index: 9999;
margin: 0;
pointer-events: none;
cursor: grabbing;
transform-origin: top left;
opacity: 0.9;
border-color: rgba(61, 174, 233, 0.9) !important;
background: rgba(61, 174, 233, 0.22) !important;
box-shadow: 0 12px 26px rgba(0, 0, 0, 0.55);
filter: drop-shadow(0 6px 12px rgba(0, 0, 0, 0.45));
will-change: transform;
}
/* Window layer */
.win-layer {
position: absolute;