| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612 |
- 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 (
- <Show when={widgetDef}>
- {(widget) => (
- <div
- ref={widgetEl}
- use:draggable={{
- disabled: props.locked || isResizing(),
- position: position(),
- bounds: "parent",
- cancel: "button, input, select, textarea, .resize-handle",
- onDragStart: handleDragStart,
- onDrag: handleDrag,
- onDragEnd: handleDragEnd,
- gpuAcceleration: true,
- applyUserSelectHack: true,
- }}
- data-widget-id={props.config.id}
- style={{
- position: "absolute",
- width: `${size().width}px`,
- height: `${size().height}px`,
- cursor: props.locked
- ? "default"
- : isDragging()
- ? "grabbing"
- : "grab",
- "user-select": "none",
- transition:
- isDragging() || isResizing() ? "none" : "box-shadow 0.2s",
- "box-shadow":
- isDragging() || isResizing()
- ? "0 8px 16px rgba(0,0,0,0.2)"
- : "0 2px 4px rgba(0,0,0,0.1)",
- "z-index": isDragging() || isResizing() ? "100" : "1",
- }}
- >
- {!props.locked && props.onRemove && (
- <button
- onClick={(e) => {
- e.stopPropagation();
- props.onRemove?.(props.config.id);
- }}
- style={{
- position: "absolute",
- top: "-8px",
- right: "-8px",
- "z-index": "10",
- padding: "0",
- "font-size": "1rem",
- background: "#ff4444",
- color: "white",
- border: "none",
- "border-radius": "50%",
- cursor: "pointer",
- width: "24px",
- height: "24px",
- display: "flex",
- "align-items": "center",
- "justify-content": "center",
- "font-weight": "bold",
- }}
- >
- ×
- </button>
- )}
- {!props.locked &&
- (!props.gridCells || props.gridCells.length === 0) && (
- <div
- class="resize-handle"
- onMouseDown={handleResizeStart}
- style={{
- position: "absolute",
- bottom: "0",
- right: "0",
- width: "20px",
- height: "20px",
- cursor: "nwse-resize",
- "z-index": "10",
- background:
- "linear-gradient(135deg, transparent 50%, rgba(0,0,0,0.2) 50%)",
- "border-bottom-right-radius": "4px",
- }}
- />
- )}
- <div style={{ width: "100%", height: "100%", overflow: "auto" }}>
- <Dynamic
- component={widget().Component}
- settings={props.config.settings}
- />
- </div>
- </div>
- )}
- </Show>
- );
- }
|