vit-design-webapp / src / lib / hooks / useAlignmentGuides.ts Blame
385 lines
65beb53 samthecodingguy Jan 29, 2026
import { useMemo } from 'react';
import type { Layer } from '@/types';
export interface AlignmentGuide {
  type: 'vertical' | 'horizontal';
  position: number;
  start: number;
  end: number;
  label?: string;
}
export interface DistanceIndicator {
  type: 'horizontal' | 'vertical';
  start: { x: number; y: number };
  end: { x: number; y: number };
  distance: number;
}
export interface SnapResult {
  x: number;
  y: number;
  guides: AlignmentGuide[];
  distanceIndicators: DistanceIndicator[];
}
const SNAP_THRESHOLD = 8; // pixels within which snapping occurs
interface GuideTarget {
  position: number;
  type: 'edge' | 'center' | 'custom';
  label?: string;
}
export interface CustomGuideInput {
  type: 'horizontal' | 'vertical';
  position: number;
}
export function calculateAlignmentGuides(
  draggingLayer: Layer,
  otherLayers: Layer[],
  canvasWidth: number,
  canvasHeight: number,
  snapEnabled: boolean,
  customGuides: CustomGuideInput[] = []
): SnapResult {
  const guides: AlignmentGuide[] = [];
  // Current layer bounds
  const layerLeft = draggingLayer.position.x;
  const layerRight = draggingLayer.position.x + draggingLayer.size.width;
  const layerTop = draggingLayer.position.y;
  const layerBottom = draggingLayer.position.y + draggingLayer.size.height;
  const layerCenterX = draggingLayer.position.x + draggingLayer.size.width / 2;
  const layerCenterY = draggingLayer.position.y + draggingLayer.size.height / 2;
  let snappedX = draggingLayer.position.x;
  let snappedY = draggingLayer.position.y;
  // Collect all vertical snap targets (x positions)
  const verticalTargets: GuideTarget[] = [
    // Canvas edges
    { position: 0, type: 'edge', label: 'Left edge' },
    { position: canvasWidth, type: 'edge', label: 'Right edge' },
    { position: canvasWidth / 2, type: 'center', label: 'Canvas center' },
  ];
  // Collect all horizontal snap targets (y positions)
  const horizontalTargets: GuideTarget[] = [
    // Canvas edges
    { position: 0, type: 'edge', label: 'Top edge' },
    { position: canvasHeight, type: 'edge', label: 'Bottom edge' },
    { position: canvasHeight / 2, type: 'center', label: 'Canvas center' },
  ];
  // Add other layers' edges and centers
  otherLayers.forEach((layer) => {
    if (!layer.visible) return;
    const left = layer.position.x;
    const right = layer.position.x + layer.size.width;
    const top = layer.position.y;
    const bottom = layer.position.y + layer.size.height;
    const centerX = layer.position.x + layer.size.width / 2;
    const centerY = layer.position.y + layer.size.height / 2;
    verticalTargets.push(
      { position: left, type: 'edge' },
      { position: right, type: 'edge' },
      { position: centerX, type: 'center' }
    );
    horizontalTargets.push(
      { position: top, type: 'edge' },
      { position: bottom, type: 'edge' },
      { position: centerY, type: 'center' }
    );
  });
  // Add custom guides
  customGuides.forEach((guide) => {
    if (guide.type === 'vertical') {
      verticalTargets.push({ position: guide.position, type: 'custom', label: 'Custom guide' });
    } else {
      horizontalTargets.push({ position: guide.position, type: 'custom', label: 'Custom guide' });
    }
  });
  // Check vertical alignment (for X snapping)
  const layerXPoints = [
    { point: layerLeft, offset: 0 },
    { point: layerCenterX, offset: draggingLayer.size.width / 2 },
    { point: layerRight, offset: draggingLayer.size.width },
  ];
  let closestVerticalDistance = SNAP_THRESHOLD + 1;
  let verticalSnapTarget: number | null = null;
  let verticalSnapOffset = 0;
  for (const { point, offset } of layerXPoints) {
    for (const target of verticalTargets) {
      const distance = Math.abs(point - target.position);
      if (distance < closestVerticalDistance) {
        closestVerticalDistance = distance;
        verticalSnapTarget = target.position;
        verticalSnapOffset = offset;
      }
    }
  }
  // Check horizontal alignment (for Y snapping)
  const layerYPoints = [
    { point: layerTop, offset: 0 },
    { point: layerCenterY, offset: draggingLayer.size.height / 2 },
    { point: layerBottom, offset: draggingLayer.size.height },
  ];
  let closestHorizontalDistance = SNAP_THRESHOLD + 1;
  let horizontalSnapTarget: number | null = null;
  let horizontalSnapOffset = 0;
  for (const { point, offset } of layerYPoints) {
    for (const target of horizontalTargets) {
      const distance = Math.abs(point - target.position);
      if (distance < closestHorizontalDistance) {
        closestHorizontalDistance = distance;
        horizontalSnapTarget = target.position;
        horizontalSnapOffset = offset;
      }
    }
  }
  // Apply snapping if within threshold
  if (snapEnabled && verticalSnapTarget !== null && closestVerticalDistance <= SNAP_THRESHOLD) {
    snappedX = verticalSnapTarget - verticalSnapOffset;
    // Add guide line
    guides.push({
      type: 'vertical',
      position: verticalSnapTarget,
      start: 0,
      end: canvasHeight,
    });
  }
  if (snapEnabled && horizontalSnapTarget !== null && closestHorizontalDistance <= SNAP_THRESHOLD) {
    snappedY = horizontalSnapTarget - horizontalSnapOffset;
    // Add guide line
    guides.push({
      type: 'horizontal',
      position: horizontalSnapTarget,
      start: 0,
      end: canvasWidth,
    });
  }
  // Even if not snapping, show guides when close (for visual feedback)
  if (!snapEnabled) {
    if (verticalSnapTarget !== null && closestVerticalDistance <= SNAP_THRESHOLD * 2) {
      guides.push({
        type: 'vertical',
        position: verticalSnapTarget,
        start: 0,
        end: canvasHeight,
      });
    }
    if (horizontalSnapTarget !== null && closestHorizontalDistance <= SNAP_THRESHOLD * 2) {
      guides.push({
        type: 'horizontal',
        position: horizontalSnapTarget,
        start: 0,
        end: canvasWidth,
      });
    }
  }
  // Calculate distance indicators to nearby layers
  const distanceIndicators: DistanceIndicator[] = [];
  const DISTANCE_THRESHOLD = 200; // Show distances within 200px
  // Use the snapped position for distance calculations
  const finalLeft = snappedX;
  const finalRight = snappedX + draggingLayer.size.width;
  const finalTop = snappedY;
  const finalBottom = snappedY + draggingLayer.size.height;
  const finalCenterY = snappedY + draggingLayer.size.height / 2;
  const finalCenterX = snappedX + draggingLayer.size.width / 2;
  otherLayers.forEach((layer) => {
    if (!layer.visible) return;
    const otherLeft = layer.position.x;
    const otherRight = layer.position.x + layer.size.width;
    const otherTop = layer.position.y;
    const otherBottom = layer.position.y + layer.size.height;
    const otherCenterY = layer.position.y + layer.size.height / 2;
    const otherCenterX = layer.position.x + layer.size.width / 2;
    // Check if layers overlap vertically (for horizontal distance)
    const verticalOverlap =
      (finalTop < otherBottom && finalBottom > otherTop);
    // Check if layers overlap horizontally (for vertical distance)
    const horizontalOverlap =
      (finalLeft < otherRight && finalRight > otherLeft);
    if (verticalOverlap) {
      // Distance from dragging layer's right to other layer's left
      if (otherLeft > finalRight) {
        const distance = otherLeft - finalRight;
        if (distance < DISTANCE_THRESHOLD) {
          const commonTop = Math.max(finalTop, otherTop);
          const commonBottom = Math.min(finalBottom, otherBottom);
          const midY = (commonTop + commonBottom) / 2;
          distanceIndicators.push({
            type: 'horizontal',
            start: { x: finalRight, y: midY },
            end: { x: otherLeft, y: midY },
            distance: Math.round(distance),
          });
        }
      }
      // Distance from other layer's right to dragging layer's left
      if (finalLeft > otherRight) {
        const distance = finalLeft - otherRight;
        if (distance < DISTANCE_THRESHOLD) {
          const commonTop = Math.max(finalTop, otherTop);
          const commonBottom = Math.min(finalBottom, otherBottom);
          const midY = (commonTop + commonBottom) / 2;
          distanceIndicators.push({
            type: 'horizontal',
            start: { x: otherRight, y: midY },
            end: { x: finalLeft, y: midY },
            distance: Math.round(distance),
          });
        }
      }
    }
    if (horizontalOverlap) {
      // Distance from dragging layer's bottom to other layer's top
      if (otherTop > finalBottom) {
        const distance = otherTop - finalBottom;
        if (distance < DISTANCE_THRESHOLD) {
          const commonLeft = Math.max(finalLeft, otherLeft);
          const commonRight = Math.min(finalRight, otherRight);
          const midX = (commonLeft + commonRight) / 2;
          distanceIndicators.push({
            type: 'vertical',
            start: { x: midX, y: finalBottom },
            end: { x: midX, y: otherTop },
            distance: Math.round(distance),
          });
        }
      }
      // Distance from other layer's bottom to dragging layer's top
      if (finalTop > otherBottom) {
        const distance = finalTop - otherBottom;
        if (distance < DISTANCE_THRESHOLD) {
          const commonLeft = Math.max(finalLeft, otherLeft);
          const commonRight = Math.min(finalRight, otherRight);
          const midX = (commonLeft + commonRight) / 2;
          distanceIndicators.push({
            type: 'vertical',
            start: { x: midX, y: otherBottom },
            end: { x: midX, y: finalTop },
            distance: Math.round(distance),
          });
        }
      }
    }
  });
  // Also show distance to canvas edges when near
  const EDGE_THRESHOLD = 100;
  // Distance to left edge
  if (finalLeft < EDGE_THRESHOLD && finalLeft > 0) {
    distanceIndicators.push({
      type: 'horizontal',
      start: { x: 0, y: finalCenterY },
      end: { x: finalLeft, y: finalCenterY },
      distance: Math.round(finalLeft),
    });
  }
  // Distance to right edge
  if (canvasWidth - finalRight < EDGE_THRESHOLD && finalRight < canvasWidth) {
    distanceIndicators.push({
      type: 'horizontal',
      start: { x: finalRight, y: finalCenterY },
      end: { x: canvasWidth, y: finalCenterY },
      distance: Math.round(canvasWidth - finalRight),
    });
  }
  // Distance to top edge
  if (finalTop < EDGE_THRESHOLD && finalTop > 0) {
    distanceIndicators.push({
      type: 'vertical',
      start: { x: finalCenterX, y: 0 },
      end: { x: finalCenterX, y: finalTop },
      distance: Math.round(finalTop),
    });
  }
  // Distance to bottom edge
  if (canvasHeight - finalBottom < EDGE_THRESHOLD && finalBottom < canvasHeight) {
    distanceIndicators.push({
      type: 'vertical',
      start: { x: finalCenterX, y: finalBottom },
      end: { x: finalCenterX, y: canvasHeight },
      distance: Math.round(canvasHeight - finalBottom),
    });
  }
  return {
    x: snappedX,
    y: snappedY,
    guides,
    distanceIndicators,
  };
}
// Hook version for use in components
export function useAlignmentGuides(
  draggingLayerId: string | null,
  layers: Layer[],
  canvasWidth: number,
  canvasHeight: number,
  snapEnabled: boolean
) {
  return useMemo(() => {
    if (!draggingLayerId) {
      return { guides: [], snapPosition: null };
    }
    const draggingLayer = layers.find((l) => l.id === draggingLayerId);
    if (!draggingLayer) {
      return { guides: [], snapPosition: null };
    }
    const otherLayers = layers.filter((l) => l.id !== draggingLayerId);
    const result = calculateAlignmentGuides(
      draggingLayer,
      otherLayers,
      canvasWidth,
      canvasHeight,
      snapEnabled
    );
    return {
      guides: result.guides,
      snapPosition: { x: result.x, y: result.y },
    };
  }, [draggingLayerId, layers, canvasWidth, canvasHeight, snapEnabled]);
}