WidgetRenderer.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. import { getWidget } from "../widgets/registry";
  2. import { createSignal, Show, onMount } from "solid-js";
  3. import { Dynamic } from "solid-js/web";
  4. import { createDraggable } from "@neodrag/solid";
  5. type DragEventData = {
  6. offsetX: number;
  7. offsetY: number;
  8. event: MouseEvent | TouchEvent;
  9. rootNode: HTMLElement;
  10. currentNode: HTMLElement;
  11. };
  12. interface WidgetRendererProps {
  13. config: WidgetConfig;
  14. onPositionUpdate?: (id: string, position: { x: number; y: number }) => void;
  15. onSizeUpdate?: (id: string, size: { width: number; height: number }) => void;
  16. onRemove?: (id: string) => void;
  17. onSnapToCell?: (widgetId: string, cellId: string | null) => void;
  18. onCellHover?: (cellId: string | null) => void;
  19. onDragStateChange?: (isDragging: boolean) => void;
  20. gridCells?: GridCell[];
  21. locked?: boolean;
  22. }
  23. // ============================================================================
  24. // SNAP SYSTEM - Reimplemented from scratch
  25. // ============================================================================
  26. interface Point {
  27. x: number;
  28. translate?: string;
  29. y: number;
  30. }
  31. interface Rect {
  32. x: number;
  33. y: number;
  34. width: number;
  35. height: number;
  36. }
  37. interface SnapResult {
  38. snapped: boolean;
  39. targetCell: GridCell | null;
  40. position: Point;
  41. size?: { width: number; height: number };
  42. }
  43. /**
  44. * Configuration for the snap system
  45. */
  46. const SNAP_CONFIG = {
  47. // Distance threshold for magnetic snapping (in grid pixels)
  48. // Large threshold makes snapping more forgiving
  49. threshold: 500,
  50. // Minimum overlap ratio required to consider snapping (0-1)
  51. minOverlap: 0.15,
  52. // Whether to snap during drag (magnetic) or only on drop
  53. magneticSnap: true,
  54. // Whether to resize widget to fit cell when snapping
  55. resizeOnSnap: true,
  56. };
  57. /**
  58. * Calculate the center point of a rectangle
  59. */
  60. function getRectCenter(rect: Rect): Point {
  61. return {
  62. x: rect.x + rect.width / 2,
  63. y: rect.y + rect.height / 2,
  64. };
  65. }
  66. /**
  67. * Calculate distance between two points
  68. */
  69. function getDistance(p1: Point, p2: Point): number {
  70. const dx = p2.x - p1.x;
  71. const dy = p2.y - p1.y;
  72. return Math.sqrt(dx * dx + dy * dy);
  73. }
  74. /**
  75. * Check if a point is inside a rectangle
  76. */
  77. function isPointInRect(point: Point, rect: Rect): boolean {
  78. return (
  79. point.x >= rect.x &&
  80. point.x <= rect.x + rect.width &&
  81. point.y >= rect.y &&
  82. point.y <= rect.y + rect.height
  83. );
  84. }
  85. /**
  86. * Calculate overlap percentage between two rectangles (0-1)
  87. */
  88. function getOverlapRatio(rect1: Rect, rect2: Rect): number {
  89. const xOverlap = Math.max(
  90. 0,
  91. Math.min(rect1.x + rect1.width, rect2.x + rect2.width) -
  92. Math.max(rect1.x, rect2.x),
  93. );
  94. const yOverlap = Math.max(
  95. 0,
  96. Math.min(rect1.y + rect1.height, rect2.y + rect2.height) -
  97. Math.max(rect1.y, rect2.y),
  98. );
  99. const overlapArea = xOverlap * yOverlap;
  100. const rect1Area = rect1.width * rect1.height;
  101. return rect1Area > 0 ? overlapArea / rect1Area : 0;
  102. }
  103. /**
  104. * Find the best cell to snap to based on widget position
  105. * Strategy: Use center point proximity with overlap as tiebreaker
  106. */
  107. function findSnapTarget(
  108. widgetRect: Rect,
  109. cells: GridCell[],
  110. threshold: number,
  111. ): GridCell | null {
  112. if (cells.length === 0) return null;
  113. const widgetCenter = getRectCenter(widgetRect);
  114. let bestCell: GridCell | null = null;
  115. let bestOverlap = 0;
  116. console.log("[Snap Debug] Finding target:", {
  117. widgetRect,
  118. widgetCenter,
  119. cellsCount: cells.length,
  120. threshold,
  121. minOverlap: SNAP_CONFIG.minOverlap,
  122. });
  123. for (const cell of cells) {
  124. const cellCenter = getRectCenter(cell);
  125. const distance = getDistance(widgetCenter, cellCenter);
  126. // Only consider cells within threshold distance
  127. if (distance > threshold) continue;
  128. // Calculate overlap - this is now the PRIMARY metric
  129. const overlap = getOverlapRatio(widgetRect, cell);
  130. console.log("[Snap Debug] Checking cell:", {
  131. cellId: cell.id,
  132. cellCenter,
  133. distance: distance.toFixed(1),
  134. overlap: overlap.toFixed(3),
  135. withinThreshold: distance <= threshold,
  136. meetsMinOverlap: overlap >= SNAP_CONFIG.minOverlap,
  137. });
  138. // Only snap if there's meaningful overlap
  139. if (overlap < SNAP_CONFIG.minOverlap) continue;
  140. // Choose cell with highest overlap (most intuitive for users)
  141. // If overlap is equal, prefer the one with closer center
  142. if (
  143. overlap > bestOverlap ||
  144. (overlap === bestOverlap &&
  145. bestCell &&
  146. distance < getDistance(widgetCenter, getRectCenter(bestCell)))
  147. ) {
  148. bestOverlap = overlap;
  149. bestCell = cell;
  150. console.log("[Snap Debug] New best cell:", {
  151. cellId: cell.id,
  152. overlap: overlap.toFixed(3),
  153. distance: distance.toFixed(1),
  154. });
  155. }
  156. }
  157. console.log("[Snap Debug] Final best cell:", {
  158. cellId: bestCell?.id || "none",
  159. overlap: bestOverlap.toFixed(3),
  160. });
  161. return bestCell;
  162. }
  163. /**
  164. * Calculate snap result for a widget at given position
  165. */
  166. function calculateSnap(
  167. widgetPosition: Point,
  168. widgetSize: { width: number; height: number },
  169. cells: GridCell[],
  170. enableSnap: boolean,
  171. ): SnapResult {
  172. if (!enableSnap || cells.length === 0) {
  173. return {
  174. snapped: false,
  175. targetCell: null,
  176. position: widgetPosition,
  177. };
  178. }
  179. const widgetRect: Rect = {
  180. x: widgetPosition.x,
  181. y: widgetPosition.y,
  182. width: widgetSize.width,
  183. height: widgetSize.height,
  184. };
  185. const targetCell = findSnapTarget(widgetRect, cells, SNAP_CONFIG.threshold);
  186. if (targetCell) {
  187. return {
  188. snapped: true,
  189. targetCell,
  190. position: { x: targetCell.x, y: targetCell.y },
  191. size: SNAP_CONFIG.resizeOnSnap
  192. ? { width: targetCell.width, height: targetCell.height }
  193. : undefined,
  194. };
  195. }
  196. return {
  197. snapped: false,
  198. targetCell: null,
  199. position: widgetPosition,
  200. };
  201. }
  202. // ============================================================================
  203. // WIDGET RENDERER COMPONENT
  204. // ============================================================================
  205. export function WidgetRenderer(props: WidgetRendererProps) {
  206. const widgetDef = getWidget(props.config.type);
  207. const { draggable } = createDraggable();
  208. const [isDragging, setIsDragging] = createSignal(false);
  209. const [isResizing, setIsResizing] = createSignal(false);
  210. let parentEl: HTMLElement | null = null;
  211. let widgetEl: HTMLElement | undefined;
  212. let resizeStartSize: { width: number; height: number } = {
  213. width: 0,
  214. height: 0,
  215. };
  216. let resizeStartPos: { x: number; y: number } = { x: 0, y: 0 };
  217. // Track drag start position for offset calculation
  218. let dragStartWidgetPos: Point | null = null;
  219. let dragStartPointerPos: Point | null = null;
  220. const position = () => props.config.position || { x: 10, y: 10 };
  221. const size = () => props.config.size || { width: 200, height: 150 };
  222. onMount(() => {
  223. parentEl = document.querySelector("[data-grid-container]") as HTMLElement;
  224. });
  225. /**
  226. * Parse CSS transform scale from element
  227. */
  228. const getContainerScale = (): number => {
  229. if (!parentEl) return 1;
  230. const transform = window.getComputedStyle(parentEl).transform;
  231. if (!transform || transform === "none") return 1;
  232. try {
  233. // Parse matrix(a, b, c, d, tx, ty) - scale is 'a'
  234. const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
  235. if (matrixMatch) {
  236. const values = matrixMatch[1]
  237. .split(",")
  238. .map((v) => parseFloat(v.trim()));
  239. return values[0] || 1;
  240. }
  241. // Parse matrix3d - scale is first value
  242. const matrix3dMatch = transform.match(/matrix3d\(([^)]+)\)/);
  243. if (matrix3dMatch) {
  244. const values = matrix3dMatch[1]
  245. .split(",")
  246. .map((v) => parseFloat(v.trim()));
  247. return values[0] || 1;
  248. }
  249. } catch {
  250. return 1;
  251. }
  252. return 1;
  253. };
  254. /**
  255. * Convert screen coordinates to grid coordinates
  256. */
  257. const screenToGrid = (screenX: number, screenY: number): Point | null => {
  258. if (!parentEl) return null;
  259. const rect = parentEl.getBoundingClientRect();
  260. const scale = getContainerScale();
  261. return {
  262. x: (screenX - rect.left) / scale,
  263. y: (screenY - rect.top) / scale,
  264. };
  265. };
  266. /**
  267. * Get current widget position in grid coordinates from DOM
  268. */
  269. const getWidgetGridPosition = (): Point => {
  270. if (!widgetEl || !parentEl) return position();
  271. const widgetRect = widgetEl.getBoundingClientRect();
  272. const parentRect = parentEl.getBoundingClientRect();
  273. const scale = getContainerScale();
  274. return {
  275. x: (widgetRect.left - parentRect.left) / scale,
  276. y: (widgetRect.top - parentRect.top) / scale,
  277. };
  278. };
  279. const handleDragStart = (data?: DragEventData) => {
  280. setIsDragging(true);
  281. props.onDragStateChange?.(true);
  282. if (!data || !data.event) return;
  283. // Extract clientX/clientY from the event
  284. const event = data.event;
  285. const clientX =
  286. "clientX" in event
  287. ? event.clientX
  288. : (event as TouchEvent).touches[0].clientX;
  289. const clientY =
  290. "clientY" in event
  291. ? event.clientY
  292. : (event as TouchEvent).touches[0].clientY;
  293. console.log("[Snap Debug] DRAG START", {
  294. widgetId: props.config.id,
  295. clientX,
  296. clientY,
  297. gridCellsCount: props.gridCells?.length || 0,
  298. });
  299. dragStartPointerPos = screenToGrid(clientX, clientY);
  300. dragStartWidgetPos = getWidgetGridPosition();
  301. console.log("[Snap Debug] Drag start positions:", {
  302. dragStartPointerPos,
  303. dragStartWidgetPos,
  304. });
  305. };
  306. const handleDrag = (data: DragEventData) => {
  307. if (!props.gridCells || props.gridCells.length === 0) {
  308. props.onCellHover?.(null);
  309. return;
  310. }
  311. if (!data.event) return;
  312. // Extract clientX/clientY from the event
  313. const event = data.event;
  314. const clientX =
  315. "clientX" in event
  316. ? event.clientX
  317. : (event as TouchEvent).touches[0].clientX;
  318. const clientY =
  319. "clientY" in event
  320. ? event.clientY
  321. : (event as TouchEvent).touches[0].clientY;
  322. // Calculate current widget position
  323. const pointerPos = screenToGrid(clientX, clientY);
  324. if (!pointerPos || !dragStartPointerPos || !dragStartWidgetPos) return;
  325. const offset = {
  326. x: pointerPos.x - dragStartPointerPos.x,
  327. y: pointerPos.y - dragStartPointerPos.y,
  328. };
  329. const currentPos = {
  330. x: dragStartWidgetPos.x + offset.x,
  331. y: dragStartWidgetPos.y + offset.y,
  332. };
  333. // Check for snap target
  334. const snapResult = calculateSnap(
  335. currentPos,
  336. size(),
  337. props.gridCells,
  338. SNAP_CONFIG.magneticSnap,
  339. );
  340. console.log("[Snap Debug] Drag:", {
  341. widgetId: props.config.id,
  342. currentPos,
  343. widgetSize: size(),
  344. cellsCount: props.gridCells.length,
  345. snapResult: {
  346. snapped: snapResult.snapped,
  347. targetCellId: snapResult.targetCell?.id,
  348. },
  349. });
  350. // Update hover state
  351. props.onCellHover?.(snapResult.targetCell?.id || null);
  352. };
  353. const handleDragEnd = (data: DragEventData) => {
  354. setIsDragging(false);
  355. props.onDragStateChange?.(false);
  356. props.onCellHover?.(null);
  357. if (!data.event) {
  358. // Fallback: no coordinate info
  359. dragStartWidgetPos = null;
  360. dragStartPointerPos = null;
  361. return;
  362. }
  363. // Extract clientX/clientY from the event
  364. const event = data.event;
  365. const clientX =
  366. "clientX" in event
  367. ? event.clientX
  368. : (event as TouchEvent).touches[0]?.clientX;
  369. const clientY =
  370. "clientY" in event
  371. ? event.clientY
  372. : (event as TouchEvent).touches[0]?.clientY;
  373. const pointerPos = screenToGrid(clientX, clientY);
  374. if (!pointerPos || !dragStartPointerPos || !dragStartWidgetPos) {
  375. dragStartWidgetPos = null;
  376. dragStartPointerPos = null;
  377. return;
  378. }
  379. // Calculate final position
  380. const offset = {
  381. x: pointerPos.x - dragStartPointerPos.x,
  382. y: pointerPos.y - dragStartPointerPos.y,
  383. };
  384. const finalPos = {
  385. x: dragStartWidgetPos.x + offset.x,
  386. y: dragStartWidgetPos.y + offset.y,
  387. };
  388. // Attempt to snap
  389. const snapResult = calculateSnap(
  390. finalPos,
  391. size(),
  392. props.gridCells || [],
  393. true,
  394. );
  395. // Apply position update
  396. props.onPositionUpdate?.(props.config.id, {
  397. x: Math.round(snapResult.position.x),
  398. y: Math.round(snapResult.position.y),
  399. });
  400. // Apply size update if snapped
  401. if (snapResult.snapped && snapResult.size) {
  402. props.onSizeUpdate?.(props.config.id, {
  403. width: Math.round(snapResult.size.width),
  404. height: Math.round(snapResult.size.height),
  405. });
  406. }
  407. // Notify parent about snap state
  408. props.onSnapToCell?.(props.config.id, snapResult.targetCell?.id || null);
  409. // Reset drag tracking
  410. dragStartWidgetPos = null;
  411. dragStartPointerPos = null;
  412. };
  413. const handleResizeStart = (e: MouseEvent) => {
  414. e.preventDefault();
  415. e.stopPropagation();
  416. setIsResizing(true);
  417. resizeStartSize = size();
  418. resizeStartPos = { x: e.clientX, y: e.clientY };
  419. const handleResizeMove = (moveEvent: MouseEvent) => {
  420. const deltaX = moveEvent.clientX - resizeStartPos.x;
  421. const deltaY = moveEvent.clientY - resizeStartPos.y;
  422. const newWidth = Math.max(150, resizeStartSize.width + deltaX);
  423. const newHeight = Math.max(100, resizeStartSize.height + deltaY);
  424. props.onSizeUpdate?.(props.config.id, {
  425. width: Math.round(newWidth),
  426. height: Math.round(newHeight),
  427. });
  428. };
  429. const handleResizeEnd = () => {
  430. setIsResizing(false);
  431. document.removeEventListener("mousemove", handleResizeMove);
  432. document.removeEventListener("mouseup", handleResizeEnd);
  433. };
  434. document.addEventListener("mousemove", handleResizeMove);
  435. document.addEventListener("mouseup", handleResizeEnd);
  436. };
  437. return (
  438. <Show when={widgetDef}>
  439. {(widget) => (
  440. <div
  441. ref={widgetEl}
  442. use:draggable={{
  443. disabled: props.locked || isResizing(),
  444. position: position(),
  445. bounds: "parent",
  446. cancel: "button, input, select, textarea, .resize-handle",
  447. onDragStart: handleDragStart,
  448. onDrag: handleDrag,
  449. onDragEnd: handleDragEnd,
  450. gpuAcceleration: true,
  451. applyUserSelectHack: true,
  452. }}
  453. data-widget-id={props.config.id}
  454. style={{
  455. position: "absolute",
  456. width: `${size().width}px`,
  457. height: `${size().height}px`,
  458. cursor: props.locked
  459. ? "default"
  460. : isDragging()
  461. ? "grabbing"
  462. : "grab",
  463. "user-select": "none",
  464. transition:
  465. isDragging() || isResizing() ? "none" : "box-shadow 0.2s",
  466. "box-shadow":
  467. isDragging() || isResizing()
  468. ? "0 8px 16px rgba(0,0,0,0.2)"
  469. : "0 2px 4px rgba(0,0,0,0.1)",
  470. "z-index": isDragging() || isResizing() ? "100" : "1",
  471. }}
  472. >
  473. {!props.locked && props.onRemove && (
  474. <button
  475. onClick={(e) => {
  476. e.stopPropagation();
  477. props.onRemove?.(props.config.id);
  478. }}
  479. style={{
  480. position: "absolute",
  481. top: "-8px",
  482. right: "-8px",
  483. "z-index": "10",
  484. padding: "0",
  485. "font-size": "1rem",
  486. background: "#ff4444",
  487. color: "white",
  488. border: "none",
  489. "border-radius": "50%",
  490. cursor: "pointer",
  491. width: "24px",
  492. height: "24px",
  493. display: "flex",
  494. "align-items": "center",
  495. "justify-content": "center",
  496. "font-weight": "bold",
  497. }}
  498. >
  499. ×
  500. </button>
  501. )}
  502. {!props.locked &&
  503. (!props.gridCells || props.gridCells.length === 0) && (
  504. <div
  505. class="resize-handle"
  506. onMouseDown={handleResizeStart}
  507. style={{
  508. position: "absolute",
  509. bottom: "0",
  510. right: "0",
  511. width: "20px",
  512. height: "20px",
  513. cursor: "nwse-resize",
  514. "z-index": "10",
  515. background:
  516. "linear-gradient(135deg, transparent 50%, rgba(0,0,0,0.2) 50%)",
  517. "border-bottom-right-radius": "4px",
  518. }}
  519. />
  520. )}
  521. <div style={{ width: "100%", height: "100%", overflow: "auto" }}>
  522. <Dynamic
  523. component={widget().Component}
  524. settings={props.config.settings}
  525. />
  526. </div>
  527. </div>
  528. )}
  529. </Show>
  530. );
  531. }