added ability to drag apps wherever on desktop theme
This commit is contained in:
parent
d4ea37f29e
commit
cd1766619d
3 changed files with 566 additions and 83 deletions
|
|
@ -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">
|
||||
|
|
|
|||
412
src/app/components/useDesktopIcons.ts
Normal file
412
src/app/components/useDesktopIcons.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue