Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import React, { ReactNode, useRef, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { updateSlideImage, updateSlideIcon, updateImageProperties } from '@/store/slices/presentationGeneration';
import { updateSlideImage, updateSlideIcon, updateImageProperties, updateSlideArrangement } from '@/store/slices/presentationGeneration';
import ImageEditor from './ImageEditor';
import IconsEditor from './IconsEditor';

Expand All @@ -12,6 +12,8 @@ interface EditableLayoutWrapperProps {
slideData: any;
isEditMode?: boolean;
properties?: any;
isArrangeMode?: boolean;
arrangeCommand?: { type: 'reset' | null; nonce: number };

}

Expand All @@ -29,12 +31,228 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
slideIndex,
slideData,
properties,
isArrangeMode = false,
arrangeCommand,

}) => {
const dispatch = useDispatch();
const containerRef = useRef<HTMLDivElement>(null);
const [editableElements, setEditableElements] = useState<EditableElement[]>([]);
const [activeEditor, setActiveEditor] = useState<EditableElement | null>(null);
const arrangementRef = useRef<Record<string, { x: number; y: number }>>({});
const dragCleanupRef = useRef<(() => void)[]>([]);
const SNAP_SIZE = 12;

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));

const getArrangementFromProps = () => {
return (properties && properties.__arrangement && typeof properties.__arrangement === 'object')
? properties.__arrangement
: {};
};

const getDomPathId = (el: HTMLElement, root: HTMLElement) => {
const path: string[] = [];
let current: HTMLElement | null = el;
while (current && current !== root) {
const parentEl: HTMLElement | null = current.parentElement;
if (!parentEl) break;
const index = Array.from(parentEl.children).indexOf(current);
path.unshift(`${current.tagName.toLowerCase()}-${index}`);
current = parentEl;
}
return `auto-${path.join('_')}`;
};

const isContainerKeywordClass = (className: string) => {
const keywords = ['card', 'chart', 'metric', 'kpi', 'tile', 'panel', 'box', 'section', 'block', 'item', 'bullet'];
const cls = (className || '').toLowerCase();
return keywords.some((key) => cls.includes(key));
};

const getElementArea = (el: HTMLElement) => {
const rect = el.getBoundingClientRect();
return rect.width * rect.height;
};

const shouldAutoAnchorElement = (el: HTMLElement, stage: HTMLElement) => {
if (el.hasAttribute('data-rearrange-id')) return false;
if (el.closest('.tiptap-text-editor, [contenteditable="true"], [data-editable-processed], [data-sonner-toaster]')) return false;

const rect = el.getBoundingClientRect();
const stageRect = stage.getBoundingClientRect();
if (rect.width < 60 || rect.height < 24) return false;

// Skip full-slide wrappers and layout roots
if (rect.width > stageRect.width * 0.9 && rect.height > stageRect.height * 0.9) return false;

const tag = el.tagName.toLowerCase();
if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'img', 'table', 'blockquote', 'figure', 'svg', 'canvas'].includes(tag)) {
return true;
}

// For generic containers, require semantic class or chart-like children
if (['div', 'section', 'article', 'aside'].includes(tag)) {
if (isContainerKeywordClass(el.className)) return true;
if (el.querySelector('.recharts-wrapper, .recharts-surface, svg, canvas')) return true;
}

return false;
};

const ensureRearrangeAnchors = () => {
if (!containerRef.current) return;
const stage = containerRef.current;

// Recompute auto-generated anchors each cycle so we can pick the most granular
// draggable nodes for the current DOM.
stage.querySelectorAll<HTMLElement>('[data-rearrange-auto="true"]').forEach((el) => {
el.removeAttribute('data-rearrange-id');
el.removeAttribute('data-rearrange-auto');
});

const candidates = Array.from(
stage.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6, p, li, img, table, blockquote, figure, svg, canvas, div, section, article, aside')
)
.filter((el) => shouldAutoAnchorElement(el, stage))
// Prefer smaller/leaf elements first. This avoids anchoring large wrappers
// that move groups of children together.
.sort((a, b) => getElementArea(a) - getElementArea(b));

candidates.forEach((el) => {
if (!shouldAutoAnchorElement(el, stage)) return;

// Skip nested anchors: if a parent already contains anchored descendants,
// keep descendants only to allow individual movement.
if (el.querySelector('[data-rearrange-id]')) return;

// Skip if this node is inside an existing (manual or auto) anchored ancestor.
if (el.parentElement?.closest('[data-rearrange-id]')) return;

el.setAttribute('data-rearrange-id', getDomPathId(el, stage));
el.setAttribute('data-rearrange-auto', 'true');
});
};

const applyArrangementToElement = (element: HTMLElement, x = 0, y = 0) => {
element.style.transform = `translate(${x}px, ${y}px)`;
element.style.willChange = 'transform';
};

const applyArrangementToAllElements = () => {
if (!containerRef.current) return;
ensureRearrangeAnchors();
const arrangement = getArrangementFromProps();
arrangementRef.current = arrangement;
const rearrangeElements = containerRef.current.querySelectorAll<HTMLElement>('[data-rearrange-id]');
rearrangeElements.forEach((el) => {
const id = el.dataset.rearrangeId;
if (!id) return;
const pos = arrangement[id] || { x: 0, y: 0 };
applyArrangementToElement(el, pos.x, pos.y);
});
};

const persistArrangement = (nextArrangement: Record<string, { x: number; y: number }>) => {
arrangementRef.current = nextArrangement;
dispatch(updateSlideArrangement({
slideIndex,
arrangement: nextArrangement,
}));
};

const removeArrangeListeners = () => {
dragCleanupRef.current.forEach((cleanup) => cleanup());
dragCleanupRef.current = [];
if (!containerRef.current) return;

const rearrangeElements = containerRef.current.querySelectorAll<HTMLElement>('[data-rearrange-id]');
rearrangeElements.forEach((el) => {
el.style.cursor = '';
el.style.userSelect = '';
el.style.zIndex = el.dataset.baseZIndex || '';
});
};

const setupArrangeMode = () => {
if (!containerRef.current || !isArrangeMode) return;

const stage = containerRef.current;
ensureRearrangeAnchors();
const stageRect = stage.getBoundingClientRect();
const arrangement = getArrangementFromProps();
arrangementRef.current = arrangement;

const rearrangeElements = stage.querySelectorAll<HTMLElement>('[data-rearrange-id]');
rearrangeElements.forEach((el) => {
const rearrangeId = el.dataset.rearrangeId;
if (!rearrangeId) return;

if (el.dataset.baseZIndex === undefined) {
el.dataset.baseZIndex = el.style.zIndex || '';
}

const current = arrangementRef.current[rearrangeId] || { x: 0, y: 0 };
applyArrangementToElement(el, current.x, current.y);
el.style.cursor = 'grab';
el.style.userSelect = 'none';

const onMouseDown = (event: MouseEvent) => {
if (!isArrangeMode || event.button !== 0) return;
event.preventDefault();
event.stopPropagation();

const stageRectNow = stage.getBoundingClientRect();
const elementRect = el.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
const origin = arrangementRef.current[rearrangeId] || { x: 0, y: 0 };

const baseLeft = elementRect.left - stageRectNow.left - origin.x;
const baseTop = elementRect.top - stageRectNow.top - origin.y;

const minX = -baseLeft;
const maxX = stageRectNow.width - elementRect.width - baseLeft;
const minY = -baseTop;
const maxY = stageRectNow.height - elementRect.height - baseTop;

el.style.cursor = 'grabbing';
el.style.zIndex = '60';

const onMouseMove = (moveEvent: MouseEvent) => {
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));

applyArrangementToElement(el, nextX, nextY);
arrangementRef.current = {
...arrangementRef.current,
[rearrangeId]: { x: nextX, y: nextY },
};
};

const onMouseUp = () => {
el.style.cursor = 'grab';
el.style.zIndex = el.dataset.baseZIndex || '';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
persistArrangement({ ...arrangementRef.current });
};

window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};

el.addEventListener('mousedown', onMouseDown);
dragCleanupRef.current.push(() => {
el.removeEventListener('mousedown', onMouseDown);
});
});
};


/**
* Recursively searches for ALL image/icon data paths in the slide data structure
Expand Down Expand Up @@ -202,6 +420,7 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({

// Add click handler directly to the image
const clickHandler = (e: Event) => {
if (isArrangeMode) return;
e.preventDefault();
e.stopPropagation();
setActiveEditor(editableElement);
Expand Down Expand Up @@ -277,6 +496,7 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({

// Add click handler directly to the svg
const clickHandler = (e: Event) => {
if (isArrangeMode) return;
e.preventDefault();
e.stopPropagation();
setActiveEditor(editableElement);
Expand Down Expand Up @@ -334,13 +554,36 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
useEffect(() => {
const timer = setTimeout(() => {
findAndProcessImages();
applyArrangementToAllElements();
}, 400);

return () => {
clearTimeout(timer);
cleanupElements();
};
}, [slideData, children]);
}, [slideData, children, isArrangeMode]);

useEffect(() => {
applyArrangementToAllElements();
}, [properties, slideData]);

useEffect(() => {
removeArrangeListeners();
if (isArrangeMode) {
setupArrangeMode();
}

return () => {
removeArrangeListeners();
};
}, [isArrangeMode, properties, slideData]);

useEffect(() => {
if (!arrangeCommand || !arrangeCommand.type) return;
if (arrangeCommand.type === 'reset') {
applyArrangementToAllElements();
}
}, [arrangeCommand?.nonce]);

// Re-run when container content changes
useEffect(() => {
Expand Down Expand Up @@ -369,7 +612,7 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
});

return () => observer.disconnect();
}, [slideData]);
}, [slideData, isArrangeMode]);

/**
* Handles closing the active editor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,20 @@ import { Loader2 } from "lucide-react";



export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEditMode: boolean, theme?: any, enableEditMode?: boolean }) => {
export const V1ContentRender = ({
slide,
isEditMode,
theme,
isArrangeMode = false,
arrangeCommand,
}: {
slide: any,
isEditMode: boolean,
theme?: any,
enableEditMode?: boolean,
isArrangeMode?: boolean,
arrangeCommand?: { type: "reset" | null; nonce: number }
}) => {
const dispatch = useDispatch();
const containerRef = useRef<HTMLDivElement | null>(null);

Expand Down Expand Up @@ -92,6 +105,8 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
slideIndex={slide.index}
slideData={slide.content}
properties={slide.properties}
isArrangeMode={isArrangeMode}
arrangeCommand={arrangeCommand}
>
<TiptapTextReplacer
key={slide.id}
Expand Down
Loading
Loading