frontmatter in skills files
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
import type { ReactNode, PointerEvent as ReactPointerEvent, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Columns, Grid2x2, Move } from 'lucide-react';
|
||||
import { cn } from 'helpers/cn';
|
||||
|
||||
// --- Public Types ---
|
||||
|
||||
export type LayoutMode = 'free' | 'spectacle' | 'hyprland';
|
||||
export type FreePosition = { x: number; y: number; w: number; h: number };
|
||||
export type SpectacleZone = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
|
||||
export type WorkspaceState = {
|
||||
mode: LayoutMode;
|
||||
free: Record<string, FreePosition>;
|
||||
spectacle: Record<string, SpectacleZone>;
|
||||
hyprland: { master: string; stack: string[] };
|
||||
};
|
||||
|
||||
type WorkspaceOnChange = (update: WorkspaceState | ((prev: WorkspaceState) => WorkspaceState)) => void;
|
||||
|
||||
type WorkspaceProps = {
|
||||
widgets: Record<string, ReactNode>;
|
||||
state: WorkspaceState;
|
||||
onChange: WorkspaceOnChange;
|
||||
};
|
||||
|
||||
// --- Defaults Helper ---
|
||||
|
||||
const SPECTACLE_ZONES: SpectacleZone[] = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
|
||||
|
||||
export function createWorkspaceDefaults(widgetIds: string[]): WorkspaceState {
|
||||
return {
|
||||
mode: 'spectacle',
|
||||
free: Object.fromEntries(
|
||||
widgetIds.map((id, i) => [
|
||||
id,
|
||||
{ x: 40 + (i % 2) * 440, y: 40 + Math.floor(i / 2) * 320, w: 420, h: 300 },
|
||||
]),
|
||||
),
|
||||
spectacle: Object.fromEntries(
|
||||
widgetIds.map((id, i) => [id, SPECTACLE_ZONES[i % SPECTACLE_ZONES.length]!]),
|
||||
),
|
||||
hyprland: {
|
||||
master: widgetIds[0]!,
|
||||
stack: widgetIds.slice(1),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Internal Layout Hook ---
|
||||
|
||||
function useWorkspaceLayout(widgetIds: string[], state: WorkspaceState, onChange: WorkspaceOnChange) {
|
||||
const [zOrder, setZOrder] = useState<string[]>(() => [...widgetIds]);
|
||||
const [dragOverride, setDragOverride] = useState<{ id: string; pos: FreePosition } | null>(null);
|
||||
|
||||
const setMode = useCallback(
|
||||
(mode: LayoutMode) => onChange((prev) => ({ ...prev, mode })),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const setFreePositions = useCallback(
|
||||
(positions: Record<string, FreePosition>) =>
|
||||
onChange((prev) => ({ ...prev, mode: 'free' as const, free: positions })),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const updateFreePosition = useCallback(
|
||||
(id: string, pos: Partial<FreePosition>) =>
|
||||
onChange((prev) => {
|
||||
const base = prev.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
||||
const updated: FreePosition = { ...base, ...pos };
|
||||
return { ...prev, free: { ...prev.free, [id]: updated } };
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const swapZones = useCallback(
|
||||
(a: string, b: string) => {
|
||||
if (a === b) return;
|
||||
onChange((prev) => {
|
||||
const zoneA = prev.spectacle[a];
|
||||
const zoneB = prev.spectacle[b];
|
||||
if (!zoneA || !zoneB) return prev;
|
||||
return { ...prev, spectacle: { ...prev.spectacle, [a]: zoneB, [b]: zoneA } };
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const promoteMaster = useCallback(
|
||||
(id: string) =>
|
||||
onChange((prev) => {
|
||||
if (prev.hyprland.master === id) return prev;
|
||||
const oldMaster = prev.hyprland.master;
|
||||
return {
|
||||
...prev,
|
||||
hyprland: {
|
||||
master: id,
|
||||
stack: prev.hyprland.stack.map((s) => (s === id ? oldMaster : s)),
|
||||
},
|
||||
};
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const bringToFront = useCallback(
|
||||
(id: string) =>
|
||||
setZOrder((prev) => {
|
||||
if (prev[prev.length - 1] === id) return prev;
|
||||
return [...prev.filter((w) => w !== id), id];
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
mode: state.mode,
|
||||
setMode,
|
||||
setFreePositions,
|
||||
free: state.free,
|
||||
updateFreePosition,
|
||||
spectacle: state.spectacle,
|
||||
swapZones,
|
||||
hyprland: state.hyprland,
|
||||
promoteMaster,
|
||||
zOrder,
|
||||
bringToFront,
|
||||
dragOverride,
|
||||
setDragOverride,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Workspace Component ---
|
||||
|
||||
export const Workspace = ({ widgets, state, onChange }: WorkspaceProps) => {
|
||||
const widgetIds = Object.keys(widgets);
|
||||
const layout = useWorkspaceLayout(widgetIds, state, onChange);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleModeChange = (newMode: LayoutMode) => {
|
||||
if (newMode === 'free' && canvasRef.current) {
|
||||
const canvasRect = canvasRef.current.getBoundingClientRect();
|
||||
const positions: Record<string, FreePosition> = {};
|
||||
for (const id of widgetIds) {
|
||||
const wrapper = canvasRef.current.querySelector<HTMLElement>(`[data-widget-id="${id}"]`);
|
||||
const el = (wrapper?.firstElementChild as HTMLElement | null) ?? wrapper;
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
positions[id] = {
|
||||
x: rect.left - canvasRect.left,
|
||||
y: rect.top - canvasRect.top,
|
||||
w: rect.width,
|
||||
h: rect.height,
|
||||
};
|
||||
} else {
|
||||
positions[id] = layout.free[id] ?? { x: 0, y: 0, w: 420, h: 300 };
|
||||
}
|
||||
}
|
||||
layout.setFreePositions(positions);
|
||||
} else {
|
||||
layout.setMode(newMode);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={canvasRef} className="relative h-full w-full overflow-hidden">
|
||||
{layout.mode === 'free' && (
|
||||
<FreeLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
||||
)}
|
||||
{layout.mode === 'spectacle' && (
|
||||
<SpectacleLayout widgets={widgets} widgetIds={widgetIds} layout={layout} canvasRef={canvasRef} />
|
||||
)}
|
||||
{layout.mode === 'hyprland' && <HyprlandLayout widgets={widgets} layout={layout} />}
|
||||
<ModeSwitcher mode={layout.mode} onChange={handleModeChange} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Sub-component Props ---
|
||||
|
||||
type LayoutProps = {
|
||||
widgets: Record<string, ReactNode>;
|
||||
widgetIds: string[];
|
||||
layout: ReturnType<typeof useWorkspaceLayout>;
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
// --- Free Mode ---
|
||||
|
||||
const FreeLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
||||
const dragRef = useRef<{
|
||||
id: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
w: number;
|
||||
h: number;
|
||||
} | null>(null);
|
||||
|
||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
layout.bringToFront(id);
|
||||
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
if (ev.detail === 2) return;
|
||||
|
||||
const pos = layout.free[id];
|
||||
if (!pos) return;
|
||||
dragRef.current = {
|
||||
id,
|
||||
startX: ev.clientX,
|
||||
startY: ev.clientY,
|
||||
originX: pos.x,
|
||||
originY: pos.y,
|
||||
w: pos.w,
|
||||
h: pos.h,
|
||||
};
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const d = dragRef.current;
|
||||
if (!d || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current.getBoundingClientRect();
|
||||
const newX = Math.max(0, Math.min(d.originX + ev.clientX - d.startX, canvas.width - d.w));
|
||||
const newY = Math.max(0, Math.min(d.originY + ev.clientY - d.startY, canvas.height - 40));
|
||||
layout.setDragOverride({ id: d.id, pos: { x: newX, y: newY, w: d.w, h: d.h } });
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (!d) return;
|
||||
|
||||
if (layout.dragOverride?.id === d.id) {
|
||||
layout.updateFreePosition(d.id, layout.dragOverride.pos);
|
||||
layout.setDragOverride(null);
|
||||
}
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{widgetIds.map((id) => {
|
||||
const pos = layout.dragOverride?.id === id ? layout.dragOverride.pos : layout.free[id];
|
||||
if (!pos) return null;
|
||||
const zIndex = layout.zOrder.indexOf(id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-widget-id={id}
|
||||
className="absolute"
|
||||
style={{ left: pos.x, top: pos.y, width: pos.w, height: pos.h, zIndex }}
|
||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Spectacle Mode ---
|
||||
|
||||
const ZONE_GRID_AREA: Record<SpectacleZone, string> = {
|
||||
'top-left': '1 / 1 / 2 / 2',
|
||||
'top-right': '1 / 2 / 2 / 3',
|
||||
'bottom-left': '2 / 1 / 3 / 2',
|
||||
'bottom-right': '2 / 2 / 3 / 3',
|
||||
};
|
||||
|
||||
const SpectacleLayout = ({ widgets, widgetIds, layout, canvasRef }: LayoutProps) => {
|
||||
const [dragTarget, setDragTarget] = useState<SpectacleZone | null>(null);
|
||||
const dragRef = useRef<{ id: string } | null>(null);
|
||||
|
||||
const getZoneFromPoint = (clientX: number, clientY: number): SpectacleZone | null => {
|
||||
if (!canvasRef.current) return null;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
if (x < 0 || y < 0 || x > rect.width || y > rect.height) return null;
|
||||
const col = x < rect.width / 2 ? 'left' : 'right';
|
||||
const row = y < rect.height / 2 ? 'top' : 'bottom';
|
||||
return `${row}-${col}` as SpectacleZone;
|
||||
};
|
||||
|
||||
const widgetByZone: Record<string, string> = {};
|
||||
for (const id of widgetIds) {
|
||||
const zone = layout.spectacle[id];
|
||||
if (zone) widgetByZone[zone] = id;
|
||||
}
|
||||
|
||||
const onPointerDown = (id: string, ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
if (ev.detail === 2) return;
|
||||
|
||||
dragRef.current = { id };
|
||||
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const onPointerMove = (ev: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return;
|
||||
const zone = getZoneFromPoint(ev.clientX, ev.clientY);
|
||||
setDragTarget(zone && zone !== layout.spectacle[dragRef.current.id] ? zone : null);
|
||||
};
|
||||
|
||||
const onPointerUp = () => {
|
||||
const d = dragRef.current;
|
||||
if (d && dragTarget) {
|
||||
const occupant = widgetByZone[dragTarget];
|
||||
if (occupant && occupant !== d.id) {
|
||||
layout.swapZones(d.id, occupant);
|
||||
}
|
||||
}
|
||||
setDragTarget(null);
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid h-full w-full gap-2 p-2"
|
||||
style={{ gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr' }}
|
||||
>
|
||||
{widgetIds.map((id) => {
|
||||
const zone = layout.spectacle[id];
|
||||
if (!zone) return null;
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
data-widget-id={id}
|
||||
className="h-full w-full overflow-hidden"
|
||||
style={{ gridArea: ZONE_GRID_AREA[zone] }}
|
||||
onPointerDown={(ev) => onPointerDown(id, ev)}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{dragTarget && (
|
||||
<div
|
||||
className="pointer-events-none z-10 rounded-lg border-2 border-duck-teal/30 bg-duck-teal/10"
|
||||
style={{ gridArea: ZONE_GRID_AREA[dragTarget] }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Hyprland Mode ---
|
||||
|
||||
const HyprlandLayout = ({ widgets, layout }: Omit<LayoutProps, 'canvasRef' | 'widgetIds'>) => {
|
||||
const onDoubleClick = (id: string, ev: ReactMouseEvent<HTMLDivElement>) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
if (!target.closest('[data-widget-header]') || target.closest('button')) return;
|
||||
layout.promoteMaster(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full gap-2 p-2">
|
||||
<div className="min-w-0 flex-1" data-widget-id={layout.hyprland.master}>
|
||||
{widgets[layout.hyprland.master]}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||
{layout.hyprland.stack.map((id) => (
|
||||
<div key={id} className="min-h-0 flex-1" data-widget-id={id} onDoubleClick={(ev) => onDoubleClick(id, ev)}>
|
||||
{widgets[id]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Mode Switcher ---
|
||||
|
||||
const MODE_OPTIONS: { mode: LayoutMode; icon: typeof Move; label: string }[] = [
|
||||
{ mode: 'spectacle', icon: Grid2x2, label: 'Snap' },
|
||||
{ mode: 'free', icon: Move, label: 'Free' },
|
||||
{ mode: 'hyprland', icon: Columns, label: 'Auto' },
|
||||
];
|
||||
|
||||
const ModeSwitcher = ({ mode, onChange }: { mode: LayoutMode; onChange: (m: LayoutMode) => void }) => (
|
||||
<div className="absolute bottom-4 right-4 z-10 flex items-center gap-1 rounded-full border border-border bg-background/80 p-1 backdrop-blur-sm">
|
||||
{MODE_OPTIONS.map(({ mode: m, icon: Icon, label }) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => onChange(m)}
|
||||
title={label}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition-all cursor-pointer',
|
||||
mode === m ? 'bg-white text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
Reference in New Issue
Block a user