import { getWidget } from "../widgets/registry"; import { createSignal, Show, onMount } from "solid-js"; import { Dynamic } from "solid-js/web"; import { createDraggable } from "@neodrag/solid"; type DragEventData = { offsetX: number; offsetY: number; event: MouseEvent | TouchEvent; rootNode: HTMLElement; currentNode: HTMLElement; }; interface WidgetRendererProps { config: WidgetConfig; onPositionUpdate?: (id: string, position: { x: number; y: number }) => void; onSizeUpdate?: (id: string, size: { width: number; height: number }) => void; onRemove?: (id: string) => void; onSnapToCell?: (widgetId: string, cellId: string | null) => void; onCellHover?: (cellId: string | null) => void; onDragStateChange?: (isDragging: boolean) => void; gridCells?: GridCell[]; locked?: boolean; } // ============================================================================ // SNAP SYSTEM - Reimplemented from scratch // ============================================================================ interface Point { x: number; translate?: string; y: number; } interface Rect { x: number; y: number; width: number; height: number; } interface SnapResult { snapped: boolean; targetCell: GridCell | null; position: Point; size?: { width: number; height: number }; } /** * Configuration for the snap system */ const SNAP_CONFIG = { // Distance threshold for magnetic snapping (in grid pixels) // Large threshold makes snapping more forgiving threshold: 500, // Minimum overlap ratio required to consider snapping (0-1) minOverlap: 0.15, // Whether to snap during drag (magnetic) or only on drop magneticSnap: true, // Whether to resize widget to fit cell when snapping resizeOnSnap: true, }; /** * Calculate the center point of a rectangle */ function getRectCenter(rect: Rect): Point { return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, }; } /** * Calculate distance between two points */ function getDistance(p1: Point, p2: Point): number { const dx = p2.x - p1.x; const dy = p2.y - p1.y; return Math.sqrt(dx * dx + dy * dy); } /** * Check if a point is inside a rectangle */ function isPointInRect(point: Point, rect: Rect): boolean { return ( point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height ); } /** * Calculate overlap percentage between two rectangles (0-1) */ function getOverlapRatio(rect1: Rect, rect2: Rect): number { const xOverlap = Math.max( 0, Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - Math.max(rect1.x, rect2.x), ); const yOverlap = Math.max( 0, Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - Math.max(rect1.y, rect2.y), ); const overlapArea = xOverlap * yOverlap; const rect1Area = rect1.width * rect1.height; return rect1Area > 0 ? overlapArea / rect1Area : 0; } /** * Find the best cell to snap to based on widget position * Strategy: Use center point proximity with overlap as tiebreaker */ function findSnapTarget( widgetRect: Rect, cells: GridCell[], threshold: number, ): GridCell | null { if (cells.length === 0) return null; const widgetCenter = getRectCenter(widgetRect); let bestCell: GridCell | null = null; let bestOverlap = 0; console.log("[Snap Debug] Finding target:", { widgetRect, widgetCenter, cellsCount: cells.length, threshold, minOverlap: SNAP_CONFIG.minOverlap, }); for (const cell of cells) { const cellCenter = getRectCenter(cell); const distance = getDistance(widgetCenter, cellCenter); // Only consider cells within threshold distance if (distance > threshold) continue; // Calculate overlap - this is now the PRIMARY metric const overlap = getOverlapRatio(widgetRect, cell); console.log("[Snap Debug] Checking cell:", { cellId: cell.id, cellCenter, distance: distance.toFixed(1), overlap: overlap.toFixed(3), withinThreshold: distance <= threshold, meetsMinOverlap: overlap >= SNAP_CONFIG.minOverlap, }); // Only snap if there's meaningful overlap if (overlap < SNAP_CONFIG.minOverlap) continue; // Choose cell with highest overlap (most intuitive for users) // If overlap is equal, prefer the one with closer center if ( overlap > bestOverlap || (overlap === bestOverlap && bestCell && distance < getDistance(widgetCenter, getRectCenter(bestCell))) ) { bestOverlap = overlap; bestCell = cell; console.log("[Snap Debug] New best cell:", { cellId: cell.id, overlap: overlap.toFixed(3), distance: distance.toFixed(1), }); } } console.log("[Snap Debug] Final best cell:", { cellId: bestCell?.id || "none", overlap: bestOverlap.toFixed(3), }); return bestCell; } /** * Calculate snap result for a widget at given position */ function calculateSnap( widgetPosition: Point, widgetSize: { width: number; height: number }, cells: GridCell[], enableSnap: boolean, ): SnapResult { if (!enableSnap || cells.length === 0) { return { snapped: false, targetCell: null, position: widgetPosition, }; } const widgetRect: Rect = { x: widgetPosition.x, y: widgetPosition.y, width: widgetSize.width, height: widgetSize.height, }; const targetCell = findSnapTarget(widgetRect, cells, SNAP_CONFIG.threshold); if (targetCell) { return { snapped: true, targetCell, position: { x: targetCell.x, y: targetCell.y }, size: SNAP_CONFIG.resizeOnSnap ? { width: targetCell.width, height: targetCell.height } : undefined, }; } return { snapped: false, targetCell: null, position: widgetPosition, }; } // ============================================================================ // WIDGET RENDERER COMPONENT // ============================================================================ export function WidgetRenderer(props: WidgetRendererProps) { const widgetDef = getWidget(props.config.type); const { draggable } = createDraggable(); const [isDragging, setIsDragging] = createSignal(false); const [isResizing, setIsResizing] = createSignal(false); let parentEl: HTMLElement | null = null; let widgetEl: HTMLElement | undefined; let resizeStartSize: { width: number; height: number } = { width: 0, height: 0, }; let resizeStartPos: { x: number; y: number } = { x: 0, y: 0 }; // Track drag start position for offset calculation let dragStartWidgetPos: Point | null = null; let dragStartPointerPos: Point | null = null; const position = () => props.config.position || { x: 10, y: 10 }; const size = () => props.config.size || { width: 200, height: 150 }; onMount(() => { parentEl = document.querySelector("[data-grid-container]") as HTMLElement; }); /** * Parse CSS transform scale from element */ const getContainerScale = (): number => { if (!parentEl) return 1; const transform = window.getComputedStyle(parentEl).transform; if (!transform || transform === "none") return 1; try { // Parse matrix(a, b, c, d, tx, ty) - scale is 'a' const matrixMatch = transform.match(/matrix\(([^)]+)\)/); if (matrixMatch) { const values = matrixMatch[1] .split(",") .map((v) => parseFloat(v.trim())); return values[0] || 1; } // Parse matrix3d - scale is first value const matrix3dMatch = transform.match(/matrix3d\(([^)]+)\)/); if (matrix3dMatch) { const values = matrix3dMatch[1] .split(",") .map((v) => parseFloat(v.trim())); return values[0] || 1; } } catch { return 1; } return 1; }; /** * Convert screen coordinates to grid coordinates */ const screenToGrid = (screenX: number, screenY: number): Point | null => { if (!parentEl) return null; const rect = parentEl.getBoundingClientRect(); const scale = getContainerScale(); return { x: (screenX - rect.left) / scale, y: (screenY - rect.top) / scale, }; }; /** * Get current widget position in grid coordinates from DOM */ const getWidgetGridPosition = (): Point => { if (!widgetEl || !parentEl) return position(); const widgetRect = widgetEl.getBoundingClientRect(); const parentRect = parentEl.getBoundingClientRect(); const scale = getContainerScale(); return { x: (widgetRect.left - parentRect.left) / scale, y: (widgetRect.top - parentRect.top) / scale, }; }; const handleDragStart = (data?: DragEventData) => { setIsDragging(true); props.onDragStateChange?.(true); if (!data || !data.event) return; // Extract clientX/clientY from the event const event = data.event; const clientX = "clientX" in event ? event.clientX : (event as TouchEvent).touches[0].clientX; const clientY = "clientY" in event ? event.clientY : (event as TouchEvent).touches[0].clientY; console.log("[Snap Debug] DRAG START", { widgetId: props.config.id, clientX, clientY, gridCellsCount: props.gridCells?.length || 0, }); dragStartPointerPos = screenToGrid(clientX, clientY); dragStartWidgetPos = getWidgetGridPosition(); console.log("[Snap Debug] Drag start positions:", { dragStartPointerPos, dragStartWidgetPos, }); }; const handleDrag = (data: DragEventData) => { if (!props.gridCells || props.gridCells.length === 0) { props.onCellHover?.(null); return; } if (!data.event) return; // Extract clientX/clientY from the event const event = data.event; const clientX = "clientX" in event ? event.clientX : (event as TouchEvent).touches[0].clientX; const clientY = "clientY" in event ? event.clientY : (event as TouchEvent).touches[0].clientY; // Calculate current widget position const pointerPos = screenToGrid(clientX, clientY); if (!pointerPos || !dragStartPointerPos || !dragStartWidgetPos) return; const offset = { x: pointerPos.x - dragStartPointerPos.x, y: pointerPos.y - dragStartPointerPos.y, }; const currentPos = { x: dragStartWidgetPos.x + offset.x, y: dragStartWidgetPos.y + offset.y, }; // Check for snap target const snapResult = calculateSnap( currentPos, size(), props.gridCells, SNAP_CONFIG.magneticSnap, ); console.log("[Snap Debug] Drag:", { widgetId: props.config.id, currentPos, widgetSize: size(), cellsCount: props.gridCells.length, snapResult: { snapped: snapResult.snapped, targetCellId: snapResult.targetCell?.id, }, }); // Update hover state props.onCellHover?.(snapResult.targetCell?.id || null); }; const handleDragEnd = (data: DragEventData) => { setIsDragging(false); props.onDragStateChange?.(false); props.onCellHover?.(null); if (!data.event) { // Fallback: no coordinate info dragStartWidgetPos = null; dragStartPointerPos = null; return; } // Extract clientX/clientY from the event const event = data.event; const clientX = "clientX" in event ? event.clientX : (event as TouchEvent).touches[0]?.clientX; const clientY = "clientY" in event ? event.clientY : (event as TouchEvent).touches[0]?.clientY; const pointerPos = screenToGrid(clientX, clientY); if (!pointerPos || !dragStartPointerPos || !dragStartWidgetPos) { dragStartWidgetPos = null; dragStartPointerPos = null; return; } // Calculate final position const offset = { x: pointerPos.x - dragStartPointerPos.x, y: pointerPos.y - dragStartPointerPos.y, }; const finalPos = { x: dragStartWidgetPos.x + offset.x, y: dragStartWidgetPos.y + offset.y, }; // Attempt to snap const snapResult = calculateSnap( finalPos, size(), props.gridCells || [], true, ); // Apply position update props.onPositionUpdate?.(props.config.id, { x: Math.round(snapResult.position.x), y: Math.round(snapResult.position.y), }); // Apply size update if snapped if (snapResult.snapped && snapResult.size) { props.onSizeUpdate?.(props.config.id, { width: Math.round(snapResult.size.width), height: Math.round(snapResult.size.height), }); } // Notify parent about snap state props.onSnapToCell?.(props.config.id, snapResult.targetCell?.id || null); // Reset drag tracking dragStartWidgetPos = null; dragStartPointerPos = null; }; const handleResizeStart = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); setIsResizing(true); resizeStartSize = size(); resizeStartPos = { x: e.clientX, y: e.clientY }; const handleResizeMove = (moveEvent: MouseEvent) => { const deltaX = moveEvent.clientX - resizeStartPos.x; const deltaY = moveEvent.clientY - resizeStartPos.y; const newWidth = Math.max(150, resizeStartSize.width + deltaX); const newHeight = Math.max(100, resizeStartSize.height + deltaY); props.onSizeUpdate?.(props.config.id, { width: Math.round(newWidth), height: Math.round(newHeight), }); }; const handleResizeEnd = () => { setIsResizing(false); document.removeEventListener("mousemove", handleResizeMove); document.removeEventListener("mouseup", handleResizeEnd); }; document.addEventListener("mousemove", handleResizeMove); document.addEventListener("mouseup", handleResizeEnd); }; return ( {(widget) => (
{!props.locked && props.onRemove && ( )} {!props.locked && (!props.gridCells || props.gridCells.length === 0) && (
)}
)} ); }