diff --git a/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx b/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx index 3b2d123..8ea9910 100644 --- a/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/EditableLayoutWrapper.tsx @@ -26,6 +26,30 @@ interface EditableElement { element: HTMLImageElement | SVGElement; } +interface AlignmentGuide { + id: string; + x1: number; + y1: number; + x2: number; + y2: number; + label?: string; + labelX?: number; + labelY?: number; + color?: string; +} + +interface ElementFrame { + id: string; + left: number; + top: number; + right: number; + bottom: number; + centerX: number; + centerY: number; + width: number; + height: number; +} + const EditableLayoutWrapper: React.FC = ({ children, slideIndex, @@ -39,9 +63,11 @@ const EditableLayoutWrapper: React.FC = ({ const containerRef = useRef(null); const [editableElements, setEditableElements] = useState([]); const [activeEditor, setActiveEditor] = useState(null); + const [alignmentGuides, setAlignmentGuides] = useState([]); const arrangementRef = useRef>({}); const dragCleanupRef = useRef<(() => void)[]>([]); const SNAP_SIZE = 12; + const ALIGN_THRESHOLD = 8; const snapToGrid = (value: number) => Math.round(value / SNAP_SIZE) * SNAP_SIZE; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); @@ -135,6 +161,32 @@ const EditableLayoutWrapper: React.FC = ({ }); }; + const getElementFrameInStage = (element: HTMLElement, stageRect: DOMRect): ElementFrame | null => { + const id = element.dataset.rearrangeId; + if (!id) return null; + const rect = element.getBoundingClientRect(); + const origin = arrangementRef.current[id] || { x: 0, y: 0 }; + + const left = rect.left - stageRect.left - origin.x; + const top = rect.top - stageRect.top - origin.y; + const width = rect.width; + const height = rect.height; + const right = left + width; + const bottom = top + height; + + return { + id, + left, + top, + right, + bottom, + centerX: left + width / 2, + centerY: top + height / 2, + width, + height, + }; + }; + const applyArrangementToElement = (element: HTMLElement, x = 0, y = 0) => { element.style.transform = `translate(${x}px, ${y}px)`; element.style.willChange = 'transform'; @@ -165,6 +217,7 @@ const EditableLayoutWrapper: React.FC = ({ const removeArrangeListeners = () => { dragCleanupRef.current.forEach((cleanup) => cleanup()); dragCleanupRef.current = []; + setAlignmentGuides([]); if (!containerRef.current) return; const rearrangeElements = containerRef.current.querySelectorAll('[data-rearrange-id]'); @@ -217,6 +270,11 @@ const EditableLayoutWrapper: React.FC = ({ const minY = -baseTop; const maxY = stageRectNow.height - elementRect.height - baseTop; + const otherFrames: ElementFrame[] = Array.from(rearrangeElements) + .filter((candidate) => candidate !== el) + .map((candidate) => getElementFrameInStage(candidate, stageRectNow)) + .filter((frame): frame is ElementFrame => Boolean(frame)); + el.style.cursor = 'grabbing'; el.style.zIndex = '60'; @@ -224,10 +282,200 @@ const EditableLayoutWrapper: React.FC = ({ const dx = moveEvent.clientX - startX; const dy = moveEvent.clientY - startY; - const nextX = snapToGrid(clamp(origin.x + dx, minX, maxX)); - const nextY = snapToGrid(clamp(origin.y + dy, minY, maxY)); + let nextX = snapToGrid(clamp(origin.x + dx, minX, maxX)); + let nextY = snapToGrid(clamp(origin.y + dy, minY, maxY)); + + const buildMovingFrame = (x: number, y: number): ElementFrame => { + const left = baseLeft + x; + const top = baseTop + y; + const right = left + elementRect.width; + const bottom = top + elementRect.height; + return { + id: rearrangeId, + left, + top, + right, + bottom, + centerX: left + elementRect.width / 2, + centerY: top + elementRect.height / 2, + width: elementRect.width, + height: elementRect.height, + }; + }; + + const guides: AlignmentGuide[] = []; + let moving = buildMovingFrame(nextX, nextY); + + type SnapCandidate = { delta: number; target: number; label: string; color?: string }; + let bestSnapX: SnapCandidate | undefined; + let bestSnapY: SnapCandidate | undefined; + + const trySnapX = (target: number, source: number, label: string, color?: string) => { + const delta = target - source; + if (Math.abs(delta) > ALIGN_THRESHOLD) return; + if (!bestSnapX || Math.abs(delta) < Math.abs(bestSnapX.delta)) { + bestSnapX = { delta, target, label, color }; + } + }; + + const trySnapY = (target: number, source: number, label: string, color?: string) => { + const delta = target - source; + if (Math.abs(delta) > ALIGN_THRESHOLD) return; + if (!bestSnapY || Math.abs(delta) < Math.abs(bestSnapY.delta)) { + bestSnapY = { delta, target, label, color }; + } + }; + + // Stage edge/center guides + trySnapX(0, moving.left, 'edge'); + trySnapX(stageRectNow.width, moving.right, 'edge'); + trySnapX(stageRectNow.width / 2, moving.centerX, 'center', '#22C55E'); + + trySnapY(0, moving.top, 'edge'); + trySnapY(stageRectNow.height, moving.bottom, 'edge'); + trySnapY(stageRectNow.height / 2, moving.centerY, 'center', '#22C55E'); + + // Other-element edge/center guides + otherFrames.forEach((other) => { + trySnapX(other.left, moving.left, 'edge'); + trySnapX(other.right, moving.right, 'edge'); + trySnapX(other.centerX, moving.centerX, 'center', '#22C55E'); + trySnapX(other.right, moving.left, 'edge'); + trySnapX(other.left, moving.right, 'edge'); + + trySnapY(other.top, moving.top, 'edge'); + trySnapY(other.bottom, moving.bottom, 'edge'); + trySnapY(other.centerY, moving.centerY, 'center', '#22C55E'); + trySnapY(other.bottom, moving.top, 'edge'); + trySnapY(other.top, moving.bottom, 'edge'); + }); + + if (bestSnapX !== undefined) { + const snapX: SnapCandidate = bestSnapX; + nextX = snapToGrid(clamp(nextX + snapX.delta, minX, maxX)); + guides.push({ + id: `snap-x-${snapX.target}`, + x1: snapX.target, + y1: 0, + x2: snapX.target, + y2: stageRectNow.height, + label: snapX.label, + labelX: snapX.target + 6, + labelY: 14, + color: snapX.color || '#8B5CF6', + }); + } + + if (bestSnapY !== undefined) { + const snapY: SnapCandidate = bestSnapY; + nextY = snapToGrid(clamp(nextY + snapY.delta, minY, maxY)); + guides.push({ + id: `snap-y-${snapY.target}`, + x1: 0, + y1: snapY.target, + x2: stageRectNow.width, + y2: snapY.target, + label: snapY.label, + labelX: 6, + labelY: Math.max(14, snapY.target - 6), + color: snapY.color || '#8B5CF6', + }); + } + + moving = buildMovingFrame(nextX, nextY); + + // Equal spacing (horizontal) + const leftNeighbor = [...otherFrames] + .filter((f) => f.right <= moving.left) + .sort((a, b) => b.right - a.right)[0]; + const rightNeighbor = [...otherFrames] + .filter((f) => f.left >= moving.right) + .sort((a, b) => a.left - b.left)[0]; + + if (leftNeighbor && rightNeighbor) { + const leftGap = moving.left - leftNeighbor.right; + const rightGap = rightNeighbor.left - moving.right; + const diff = leftGap - rightGap; + + if (leftGap >= 0 && rightGap >= 0 && Math.abs(diff) <= ALIGN_THRESHOLD * 2) { + const adjust = -diff / 2; + nextX = snapToGrid(clamp(nextX + adjust, minX, maxX)); + moving = buildMovingFrame(nextX, nextY); + + const y = clamp(moving.top - 8, 10, stageRectNow.height - 10); + const gapValue = Math.round((Math.abs(moving.left - leftNeighbor.right) + Math.abs(rightNeighbor.left - moving.right)) / 2); + + guides.push( + { + id: `equal-h-left-${leftNeighbor.id}`, + x1: leftNeighbor.right, + y1: y, + x2: moving.left, + y2: y, + label: `${gapValue}px`, + labelX: (leftNeighbor.right + moving.left) / 2, + labelY: y - 4, + color: '#10B981', + }, + { + id: `equal-h-right-${rightNeighbor.id}`, + x1: moving.right, + y1: y, + x2: rightNeighbor.left, + y2: y, + color: '#10B981', + } + ); + } + } + + // Equal spacing (vertical) + const topNeighbor = [...otherFrames] + .filter((f) => f.bottom <= moving.top) + .sort((a, b) => b.bottom - a.bottom)[0]; + const bottomNeighbor = [...otherFrames] + .filter((f) => f.top >= moving.bottom) + .sort((a, b) => a.top - b.top)[0]; + + if (topNeighbor && bottomNeighbor) { + const topGap = moving.top - topNeighbor.bottom; + const bottomGap = bottomNeighbor.top - moving.bottom; + const diff = topGap - bottomGap; + + if (topGap >= 0 && bottomGap >= 0 && Math.abs(diff) <= ALIGN_THRESHOLD * 2) { + const adjust = -diff / 2; + nextY = snapToGrid(clamp(nextY + adjust, minY, maxY)); + moving = buildMovingFrame(nextX, nextY); + + const x = clamp(moving.left - 8, 10, stageRectNow.width - 10); + const gapValue = Math.round((Math.abs(moving.top - topNeighbor.bottom) + Math.abs(bottomNeighbor.top - moving.bottom)) / 2); + + guides.push( + { + id: `equal-v-top-${topNeighbor.id}`, + x1: x, + y1: topNeighbor.bottom, + x2: x, + y2: moving.top, + label: `${gapValue}px`, + labelX: x + 6, + labelY: (topNeighbor.bottom + moving.top) / 2, + color: '#10B981', + }, + { + id: `equal-v-bottom-${bottomNeighbor.id}`, + x1: x, + y1: moving.bottom, + x2: x, + y2: bottomNeighbor.top, + color: '#10B981', + } + ); + } + } applyArrangementToElement(el, nextX, nextY); + setAlignmentGuides(guides); arrangementRef.current = { ...arrangementRef.current, [rearrangeId]: { x: nextX, y: nextY }, @@ -237,6 +485,7 @@ const EditableLayoutWrapper: React.FC = ({ const onMouseUp = () => { el.style.cursor = 'grab'; el.style.zIndex = el.dataset.baseZIndex || ''; + setAlignmentGuides([]); window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mouseup', onMouseUp); persistArrangement({ ...arrangementRef.current }); @@ -679,9 +928,49 @@ const EditableLayoutWrapper: React.FC = ({ }; return ( -
+
{children} + {isArrangeMode && alignmentGuides.length > 0 && ( + + )} + {/* Render ImageEditor when an image is being edited */} {activeEditor && activeEditor.type === 'image' && (