feat: finish the homepage plugin, move service links and canvas out of core (B19)

This commit is contained in:
LukeGus committed 2026-09-24 15:07:25 -05:00
1 parent 21937a6b02
commit d4ec444558
256 files changed
+27365 -9077

No files matched your search

@@ -0,0 +1,63 @@
import { useEffect, useRef } from "react";
interface Props {
pan: { x: number; y: number };
zoom: number;
}
export function CanvasDotBackground({ pan, zoom }: Props) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.offsetWidth;
const h = canvas.offsetHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
// Read dot color from CSS variable for theme support
const dotColor =
getComputedStyle(document.documentElement)
.getPropertyValue("--color-muted-foreground")
.trim() || "#6b7280";
// Effective grid spacing in screen pixels
const spacing = 30 * zoom;
// Skip rendering dots that are too close together or too far apart
if (spacing < 8 || spacing > 200) return;
// Offset so dots align with the canvas pan position
const offsetX = ((pan.x % spacing) + spacing) % spacing;
const offsetY = ((pan.y % spacing) + spacing) % spacing;
const dotRadius = Math.max(0.8, Math.min(1.5, zoom * 1.2));
ctx.fillStyle = dotColor;
ctx.globalAlpha = 0.35;
for (let x = offsetX; x < w; x += spacing) {
for (let y = offsetY; y < h; y += spacing) {
ctx.beginPath();
ctx.arc(x, y, dotRadius, 0, Math.PI * 2);
ctx.fill();
}
}
}, [pan, zoom]);
return (
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full pointer-events-none"
style={{ zIndex: 0 }}
/>
);
}
@@ -0,0 +1,39 @@
export function screenToCanvas(
screenX: number,
screenY: number,
pan: { x: number; y: number },
zoom: number,
): { x: number; y: number } {
return {
x: (screenX - pan.x) / zoom,
y: (screenY - pan.y) / zoom,
};
}
export function canvasToScreen(
canvasX: number,
canvasY: number,
pan: { x: number; y: number },
zoom: number,
): { x: number; y: number } {
return {
x: canvasX * zoom + pan.x,
y: canvasY * zoom + pan.y,
};
}
/** Pan value that keeps the canvas point (gridX, gridY) under (mouseX, mouseY) after zoom changes. */
export function zoomAroundPoint(
mouseX: number,
mouseY: number,
pan: { x: number; y: number },
oldZoom: number,
newZoom: number,
): { x: number; y: number } {
const gridX = (mouseX - pan.x) / oldZoom;
const gridY = (mouseY - pan.y) / oldZoom;
return {
x: mouseX - gridX * newZoom,
y: mouseY - gridY * newZoom,
};
}
@@ -0,0 +1,9 @@
import { GRID_SIZE } from "../types.js";
export function snapToGrid(value: number): number {
return Math.round(value / GRID_SIZE) * GRID_SIZE;
}
export function snapToGridFloor(value: number): number {
return Math.floor(value / GRID_SIZE) * GRID_SIZE;
}
@@ -0,0 +1,66 @@
import { useCallback, useRef } from "react";
import type { CanvasWidget, DragState } from "../types.js";
import { snapToGrid } from "./snapToGrid";
interface UseBoxDragOptions {
widgets: CanvasWidget[];
zoom: number;
getZoom: () => number;
onWidgetMove: (id: number, x: number, y: number) => void;
onDragStart: (id: number) => void;
onDragEnd: () => void;
}
export function useBoxDrag({
widgets,
getZoom,
onWidgetMove,
onDragStart,
onDragEnd,
}: UseBoxDragOptions) {
const dragRef = useRef<DragState | null>(null);
const startDrag = useCallback(
(e: React.MouseEvent, widget: CanvasWidget) => {
e.stopPropagation();
e.preventDefault();
dragRef.current = {
widgetId: widget.id,
startMouseX: e.clientX,
startMouseY: e.clientY,
startWidgetX: widget.x,
startWidgetY: widget.y,
};
onDragStart(widget.id);
const handleMove = (ev: MouseEvent) => {
if (!dragRef.current) return;
const zoom = getZoom();
const dx = (ev.clientX - dragRef.current.startMouseX) / zoom;
const dy = (ev.clientY - dragRef.current.startMouseY) / zoom;
const newX = snapToGrid(dragRef.current.startWidgetX + dx);
const newY = snapToGrid(dragRef.current.startWidgetY + dy);
const moving = widgets.find((w) => w.id === dragRef.current!.widgetId);
if (!moving) return;
if (newX !== moving.x || newY !== moving.y) {
onWidgetMove(moving.id, newX, newY);
}
};
const handleUp = () => {
dragRef.current = null;
onDragEnd();
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
window.addEventListener("mousemove", handleMove);
window.addEventListener("mouseup", handleUp);
},
[widgets, getZoom, onWidgetMove, onDragStart, onDragEnd],
);
return { startDrag };
}
@@ -0,0 +1,81 @@
import { useCallback, useRef } from "react";
import { GRID_SIZE, type CanvasWidget, type ResizeState } from "../types.js";
import { snapToGrid } from "./snapToGrid";
import { getWidgetType } from "../widgets/WidgetRegistry";
interface UseBoxResizeOptions {
widgets: CanvasWidget[];
getZoom: () => number;
onWidgetResize: (id: number, w: number, h: number) => void;
onResizeStart: (id: number) => void;
onResizeEnd: () => void;
}
export function useBoxResize({
widgets,
getZoom,
onWidgetResize,
onResizeStart,
onResizeEnd,
}: UseBoxResizeOptions) {
const resizeRef = useRef<ResizeState | null>(null);
const startResize = useCallback(
(e: React.MouseEvent, widget: CanvasWidget) => {
e.stopPropagation();
e.preventDefault();
resizeRef.current = {
widgetId: widget.id,
startMouseX: e.clientX,
startMouseY: e.clientY,
startW: widget.w,
startH: widget.h,
};
onResizeStart(widget.id);
const handleMove = (ev: MouseEvent) => {
if (!resizeRef.current) return;
const zoom = getZoom();
const dx = (ev.clientX - resizeRef.current.startMouseX) / zoom;
const dy = (ev.clientY - resizeRef.current.startMouseY) / zoom;
const newW = Math.max(
GRID_SIZE * 2,
snapToGrid(resizeRef.current.startW + dx),
);
const newH = Math.max(
GRID_SIZE * 2,
snapToGrid(resizeRef.current.startH + dy),
);
const moving = widgets.find(
(w) => w.id === resizeRef.current!.widgetId,
);
if (!moving) return;
const typeDef = getWidgetType(moving.typeId);
const minW = typeDef?.minSize.w ?? GRID_SIZE * 4;
const minH = typeDef?.minSize.h ?? GRID_SIZE * 4;
const clampedW = Math.max(minW, newW);
const clampedH = Math.max(minH, newH);
if (clampedW !== moving.w || clampedH !== moving.h) {
onWidgetResize(moving.id, clampedW, clampedH);
}
};
const handleUp = () => {
resizeRef.current = null;
onResizeEnd();
window.removeEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
};
window.addEventListener("mousemove", handleMove);
window.removeEventListener("mouseup", handleUp);
window.addEventListener("mouseup", handleUp);
},
[widgets, getZoom, onWidgetResize, onResizeStart, onResizeEnd],
);
return { startResize };
}
@@ -0,0 +1,85 @@
import { useCallback, useRef } from "react";
import { MIN_ZOOM, MAX_ZOOM } from "../types.js";
import { zoomAroundPoint } from "./canvasGeometry";
interface UseCanvasInputOptions {
isDraggingWidget: boolean;
isResizing: boolean;
isContextMenuVisible: boolean;
onPanChange: (pan: { x: number; y: number }) => void;
onZoomChange: (zoom: number, pan: { x: number; y: number }) => void;
getPan: () => { x: number; y: number };
getZoom: () => number;
containerRef: React.RefObject<HTMLDivElement | null>;
}
export function useCanvasInput({
isDraggingWidget,
isResizing,
isContextMenuVisible,
onPanChange,
onZoomChange,
getPan,
getZoom,
containerRef,
}: UseCanvasInputOptions) {
const isPanningRef = useRef(false);
const lastMouseRef = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// Only pan on the background (not on widgets), left button
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest("[data-widget]")) return;
if (isDraggingWidget || isResizing || isContextMenuVisible) return;
isPanningRef.current = true;
lastMouseRef.current = { x: e.clientX, y: e.clientY };
e.preventDefault();
},
[isDraggingWidget, isResizing, isContextMenuVisible],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isPanningRef.current) return;
const dx = e.clientX - lastMouseRef.current.x;
const dy = e.clientY - lastMouseRef.current.y;
lastMouseRef.current = { x: e.clientX, y: e.clientY };
const pan = getPan();
onPanChange({ x: pan.x + dx, y: pan.y + dy });
},
[getPan, onPanChange],
);
const handleMouseUp = useCallback(() => {
isPanningRef.current = false;
}, []);
const handleWheel = useCallback(
(e: React.WheelEvent) => {
e.preventDefault();
const zoom = getZoom();
const pan = getPan();
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Multiplicative zoom so each step is proportional regardless of current zoom level.
// Normalize deltaY to avoid trackpad sending huge values.
const rawDelta = Math.sign(e.deltaY) * Math.min(Math.abs(e.deltaY), 100);
const factor = 1 - rawDelta * 0.001;
const newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom * factor));
if (newZoom === zoom) return;
const newPan = zoomAroundPoint(mouseX, mouseY, pan, zoom, newZoom);
onZoomChange(newZoom, newPan);
},
[getZoom, getPan, containerRef, onZoomChange],
);
return { handleMouseDown, handleMouseMove, handleMouseUp, handleWheel };
}