diff --git a/app/ycode/components/CenterCanvas.tsx b/app/ycode/components/CenterCanvas.tsx index 72461723..0760b25c 100644 --- a/app/ycode/components/CenterCanvas.tsx +++ b/app/ycode/components/CenterCanvas.tsx @@ -630,6 +630,7 @@ const CenterCanvas = React.memo(function CenterCanvas({ const hoveredLayerId = useEditorStore((state) => state.hoveredLayerId); const setHoveredLayerId = useEditorStore((state) => state.setHoveredLayerId); const isPreviewMode = useEditorStore((state) => state.isPreviewMode); + const activeSidebarTab = useEditorStore((state) => state.activeSidebarTab); const activeInteractionTriggerLayerId = useEditorStore((state) => state.activeInteractionTriggerLayerId); const richTextSheetLayerId = useEditorStore((state) => state.richTextSheetLayerId); const closeRichTextSheet = useEditorStore((state) => state.closeRichTextSheet); @@ -705,7 +706,7 @@ const CenterCanvas = React.memo(function CenterCanvas({ const referencedItems = useCollectionLayerStore((state) => state.referencedItems); const fetchReferencedCollectionItems = useCollectionLayerStore((state) => state.fetchReferencedCollectionItems); - const { routeType, urlState, navigateToLayers, navigateToPage, navigateToPageEdit, updateQueryParams } = useEditorUrl(); + const { urlState, navigateToLayers, navigateToPage, navigateToPageEdit, updateQueryParams } = useEditorUrl(); const components = useComponentsStore((state) => state.components); const componentDrafts = useComponentsStore((state) => state.componentDrafts); const [collectionItems, setCollectionItems] = useState>([]); @@ -1696,28 +1697,20 @@ const CenterCanvas = React.memo(function CenterCanvas({ // Handle page selection const handlePageSelect = useCallback((pageId: string) => { - // Clear selection FIRST to release locks on the current page's channel - // before switching to the new page's channel - setSelectedLayerId(null); + if (pageId === currentPageId) return; - // Set the page ID immediately for responsive UI - // The URL effect in YCodeBuilderMain uses a ref to track when we're navigating - // to prevent reverting to the old page before the URL updates + // Set to body directly so the layer sync effect won't trigger a second URL update + setSelectedLayerId('body'); setCurrentPageId(pageId); - // Navigate to the same route type but with the new page ID - // IMPORTANT: Explicitly pass 'body' as the layer to avoid carrying over invalid layer IDs from the old page - if (routeType === 'layers') { - navigateToLayers(pageId, undefined, undefined, 'body'); - } else if (routeType === 'page' && urlState.isEditing) { + if (urlState.isEditing) { navigateToPageEdit(pageId); - } else if (routeType === 'page') { + } else if (activeSidebarTab === 'pages') { navigateToPage(pageId, undefined, undefined, 'body'); } else { - // Default to layers if no route type navigateToLayers(pageId, undefined, undefined, 'body'); } - }, [setSelectedLayerId, setCurrentPageId, routeType, urlState.isEditing, navigateToLayers, navigateToPage, navigateToPageEdit]); + }, [currentPageId, setSelectedLayerId, setCurrentPageId, activeSidebarTab, urlState.isEditing, navigateToLayers, navigateToPage, navigateToPageEdit]); // Fetch referenced collection items recursively when layers with reference fields are detected useEffect(() => { @@ -2203,7 +2196,7 @@ const CenterCanvas = React.memo(function CenterCanvas({ )} {/* Selection overlay - renders outlines on top of the iframe */} - {!isPreviewMode && canvasIframeElement && ( + {!isPreviewMode && activeSidebarTab !== 'pages' && canvasIframeElement && ( diff --git a/app/ycode/components/ElementLibrary.tsx b/app/ycode/components/ElementLibrary.tsx index 98d136e3..fae8607a 100644 --- a/app/ycode/components/ElementLibrary.tsx +++ b/app/ycode/components/ElementLibrary.tsx @@ -147,7 +147,6 @@ function ElementButton({ interface ElementLibraryProps { isOpen: boolean; onClose: () => void; - defaultTab?: 'elements' | 'layouts' | 'components'; liveLayerUpdates?: UseLiveLayerUpdatesReturn | null; } @@ -275,7 +274,7 @@ async function restoreInlinedComponents( return newLayer; } -export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements', liveLayerUpdates }: ElementLibraryProps) { +export default function ElementLibrary({ isOpen, onClose, liveLayerUpdates }: ElementLibraryProps) { const { addLayerFromTemplate, updateLayer, setDraftLayers, draftsByPageId, pages } = usePagesStore(); const { currentPageId, selectedLayerId, setSelectedLayerId, editingComponentId, activeBreakpoint, pushComponentNavigation, startCanvasDrag, endCanvasDrag } = useEditorStore(); const { components, componentDrafts, updateComponentDraft, deleteComponent, getDeletePreview, loadComponentDraft, getComponentById, loadComponents } = useComponentsStore(); @@ -287,16 +286,16 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements const [deletePreviewInfo, setDeletePreviewInfo] = useState<{ pageCount: number; componentCount: number } | null>(null); const [isDeleting, setIsDeleting] = useState(false); const [activeTab, setActiveTab] = React.useState<'elements' | 'layouts' | 'components'>(() => { - // Try to load from sessionStorage first if (typeof window !== 'undefined') { const saved = sessionStorage.getItem('elementLibrary-activeTab'); if (saved && ['elements', 'layouts', 'components'].includes(saved)) { return saved as 'elements' | 'layouts' | 'components'; } } - return defaultTab; + return 'elements'; }); const [componentSearch, setComponentSearch] = useState(''); + const tabRefs = React.useRef>({}); const circularComponentIds = useMemo(() => { if (!editingComponentId) return new Set(); @@ -338,10 +337,17 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements return new Set(allCategories.filter(cat => cat !== 'Navigation' && cat !== 'Hero' && cat !== 'Blog header' && cat !== 'Blog posts')); }); - // Sync active tab when defaultTab prop changes (e.g., "Add layout" button, keyboard shortcut) + // Sync tab when explicitly requested (e.g., "Add layout" button) React.useEffect(() => { - setActiveTab(defaultTab); - }, [defaultTab]); + const handleToggle = (event: Event) => { + const tab = (event as CustomEvent<{ tab?: string }>).detail?.tab; + if (tab && ['elements', 'layouts', 'components'].includes(tab)) { + setActiveTab(tab as 'elements' | 'layouts' | 'components'); + } + }; + window.addEventListener('toggleElementLibrary', handleToggle); + return () => window.removeEventListener('toggleElementLibrary', handleToggle); + }, []); // Persist active tab to sessionStorage React.useEffect(() => { @@ -1370,13 +1376,20 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements const deleteConfirmDescription = `Are you sure you want to delete "${componentName}"? ${usageSuffix}`; - if (!isOpen) return null; - return ( -
+
{/* Tabs */} setActiveTab(value as 'elements' | 'layouts' | 'components')} + value={activeTab} onValueChange={(value) => { + const tab = value as 'elements' | 'layouts' | 'components'; + setActiveTab(tab); + tabRefs.current[tab]?.scrollTo(0, 0); + }} className="flex flex-col h-full overflow-hidden gap-0" >
@@ -1391,7 +1404,10 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements
- + { tabRefs.current.elements = el; }} className="flex flex-col divide-y overflow-y-auto flex-1 px-4 pb-4 no-scrollbar" + > {Object.entries(elementCategories).map(([categoryName, elements]) => (
@@ -1414,7 +1430,10 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements ))} - + { tabRefs.current.layouts = el; }} className="flex flex-col overflow-y-auto flex-1 px-4 pb-4 no-scrollbar" + > {getAllLayoutKeys().length === 0 ? ( No layouts available @@ -1440,8 +1459,7 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements />
- {!isCollapsed && ( -
+
{layoutKeys.map((layoutKey) => { const previewImage = getLayoutPreviewImage(layoutKey); @@ -1463,6 +1481,7 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements width={640} height={262} alt="Layout preview" + loading="eager" className="object-contain w-full h-full rounded pointer-events-none" /> )} @@ -1496,7 +1515,6 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements ); })}
- )}
); })} @@ -1504,7 +1522,10 @@ export default function ElementLibrary({ isOpen, onClose, defaultTab = 'elements )} - + { tabRefs.current.components = el; }} className="flex flex-col overflow-y-auto flex-1 px-4 pb-4 no-scrollbar" + > {components.length === 0 ? ( No components yet diff --git a/app/ycode/components/LeftSidebar.tsx b/app/ycode/components/LeftSidebar.tsx index 66ab434d..aacfe38f 100644 --- a/app/ycode/components/LeftSidebar.tsx +++ b/app/ycode/components/LeftSidebar.tsx @@ -21,7 +21,7 @@ import { usePagesStore } from '@/stores/usePagesStore'; import { resetBindingsAfterMove } from '@/lib/layer-utils'; // 5.5 Hooks -import { useEditorUrl, useEditorActions } from '@/hooks/use-editor-url'; +import { useEditorUrl } from '@/hooks/use-editor-url'; import type { EditorTab } from '@/hooks/use-editor-url'; import { useLayerLocks } from '@/hooks/use-layer-locks'; @@ -51,10 +51,8 @@ const LeftSidebar = React.memo(function LeftSidebar({ liveLayerUpdates, liveComponentUpdates, }: LeftSidebarProps) { - const { sidebarTab, urlState } = useEditorUrl(); - const { navigateToLayers, navigateToPage } = useEditorActions(); + const { sidebarTab } = useEditorUrl(); const [showElementLibrary, setShowElementLibrary] = useState(false); - const [elementLibraryTab, setElementLibraryTab] = useState<'elements' | 'layouts' | 'components'>('elements'); const [assetMessage, setAssetMessage] = useState(null); // Optimize store subscriptions - scoped to current page only @@ -71,25 +69,18 @@ const LeftSidebar = React.memo(function LeftSidebar({ const editingComponentId = useEditorStore((state) => state.editingComponentId); const setActiveSidebarTab = useEditorStore((state) => state.setActiveSidebarTab); - // Local state for instant tab switching - syncs with URL but allows immediate UI feedback - const [localActiveTab, setLocalActiveTab] = useState(sidebarTab); - - // Read the store's activeSidebarTab const storeSidebarTab = useEditorStore((state) => state.activeSidebarTab); - // Sync local tab with URL when URL changes (e.g., from navigation or page load) + // Sync URL → store only on initial mount + const hasInitializedTabRef = useRef(false); useEffect(() => { - setLocalActiveTab(sidebarTab); - setActiveSidebarTab(sidebarTab); + if (!hasInitializedTabRef.current) { + hasInitializedTabRef.current = true; + setActiveSidebarTab(sidebarTab); + } }, [sidebarTab, setActiveSidebarTab]); - // Sync local tab with store when store changes (e.g., from canvas layer click) - useEffect(() => { - if (storeSidebarTab && storeSidebarTab !== localActiveTab) { - setLocalActiveTab(storeSidebarTab); - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- localActiveTab intentionally excluded to avoid sync loops - }, [storeSidebarTab]); + const activeTab = storeSidebarTab || sidebarTab; const componentDrafts = useComponentsStore((state) => state.componentDrafts); const getComponentById = useComponentsStore((state) => state.getComponentById); @@ -101,9 +92,6 @@ const LeftSidebar = React.memo(function LeftSidebar({ const layerLocksRef = useRef(layerLocks); layerLocksRef.current = layerLocks; - // Use local state for immediate tab switching - const activeTab = localActiveTab; - // Get component layers if in edit mode const editingComponent = editingComponentId ? getComponentById(editingComponentId) : null; @@ -114,10 +102,7 @@ const LeftSidebar = React.memo(function LeftSidebar({ const tab = customEvent.detail?.tab; if (tab) { - setElementLibraryTab(tab); setShowElementLibrary(true); - // Switch to Layers tab so the element library context is correct - setLocalActiveTab('layers'); setActiveSidebarTab('layers'); } else { setShowElementLibrary((prev) => !prev); @@ -307,34 +292,18 @@ const LeftSidebar = React.memo(function LeftSidebar({ onValueChange={(value) => { const newTab = value as EditorTab; - // Immediately update local state AND store for instant UI feedback - setLocalActiveTab(newTab); setActiveSidebarTab(newTab); setShowElementLibrary(false); - // Clear layer selection when switching away from Layers tab - // This releases the lock so other users can edit - if (newTab === 'pages') { - onLayerSelect(null); + // Update URL without triggering Next.js navigation to avoid re-renders + const targetPageId = currentPageId || (pages.length > 0 ? pages[0].id : null); + if (targetPageId) { + const segment = newTab === 'layers' ? 'layers' : 'pages'; + const newPath = `/ycode/${segment}/${targetPageId}${window.location.search}`; + window.history.replaceState(null, '', newPath); } - - // Defer URL navigation to avoid blocking the UI - // startTransition marks this as a low-priority update - startTransition(() => { - if (newTab === 'layers') { - const targetPageId = currentPageId || (pages.length > 0 ? pages[0].id : null); - if (targetPageId) { - navigateToLayers(targetPageId, urlState.view || undefined, urlState.rightTab || undefined, urlState.layerId || undefined); - } - } else if (newTab === 'pages') { - const targetPageId = currentPageId || (pages.length > 0 ? pages[0].id : null); - if (targetPageId) { - navigateToPage(targetPageId, urlState.view || undefined, urlState.rightTab || undefined, urlState.layerId || undefined); - } - } - }); }} - className="h-full overflow-hidden !gap-0" + className="h-full overflow-hidden gap-0!" > Layers @@ -348,7 +317,7 @@ const LeftSidebar = React.memo(function LeftSidebar({ value="layers" className="flex flex-col min-h-0 overflow-y-auto no-scrollbar" forceMount > -
+
{editingComponentId ? 'Layers' : 'Layers'}
- {/* Element Library Slide-Out (lazy loaded) */} - {showElementLibrary && ( - - setShowElementLibrary(false)} - defaultTab={elementLibraryTab} - liveLayerUpdates={liveLayerUpdates} - /> - - )} + {/* Element Library Slide-Out (lazy loaded, always mounted to preserve state) */} + + setShowElementLibrary(false)} + liveLayerUpdates={liveLayerUpdates} + /> + ); }); diff --git a/app/ycode/components/LeftSidebarPages.tsx b/app/ycode/components/LeftSidebarPages.tsx index bae00a64..fb744cca 100644 --- a/app/ycode/components/LeftSidebarPages.tsx +++ b/app/ycode/components/LeftSidebarPages.tsx @@ -36,7 +36,8 @@ export default function LeftSidebarPages({ onPageSelect, setCurrentPageId, }: LeftSidebarPagesProps) { - const { routeType, urlState } = useEditorUrl(); + const { urlState } = useEditorUrl(); + const activeSidebarTab = useEditorStore((state) => state.activeSidebarTab); const { openPage, openPageEdit, openPageLayers, navigateToLayers, navigateToPage, navigateToPageEdit, navigateToCollections } = useEditorActions(); const [showPageSettings, setShowPageSettings] = useState(false); const [showFolderSettings, setShowFolderSettings] = useState(false); @@ -178,17 +179,7 @@ export default function LeftSidebarPages({ selectedItemIdRef.current = result.data.id; } - // Navigate to the new page based on current route type - if (routeType === 'layers') { - navigateToLayers(result.data.id, urlState.view || undefined, urlState.rightTab || undefined, urlState.layerId || undefined); - } else if (routeType === 'page' && urlState.isEditing) { - navigateToPageEdit(result.data.id); - } else if (routeType === 'page') { - navigateToPage(result.data.id, urlState.view || undefined, urlState.rightTab || undefined, urlState.layerId || undefined); - } else { - // Default to layers if no route type - navigateToLayers(result.data.id, urlState.view || undefined, urlState.rightTab || undefined, urlState.layerId || undefined); - } + navigateToNextPage(result.data.id, urlState.layerId || 'body'); // Automatically open Page settings panel for the newly created page setEditingPage(result.data); @@ -302,32 +293,18 @@ export default function LeftSidebarPages({ return; } - // Clear layer selection FIRST to release lock on current page's channel - // before switching to the new page's channel + if (pageId === currentPageId) return; + + // Set to body directly so the layer sync effect won't trigger a second URL update const { setSelectedLayerId } = useEditorStore.getState(); - setSelectedLayerId(null); + setSelectedLayerId('body'); // Immediate UI feedback - selection updates instantly setSelectedItemId(pageId); - // Preserve current query params (convert null to undefined) - // IMPORTANT: Use 'body' as the layer to avoid carrying over invalid layer IDs from the old page - const view = urlState.view || undefined; - const rightTab = urlState.rightTab || undefined; - // Defer navigation to avoid blocking UI startTransition(() => { - // Navigate to the same route type but with the new page ID - if (routeType === 'layers') { - navigateToLayers(pageId, view, rightTab, 'body'); - } else if (routeType === 'page' && urlState.isEditing) { - navigateToPageEdit(pageId); - } else if (routeType === 'page') { - navigateToPage(pageId, view, rightTab, 'body'); - } else { - // Default to layers if no route type (shouldn't happen, but safe fallback) - navigateToLayers(pageId, view, rightTab, 'body'); - } + navigateToNextPage(pageId); }); }; @@ -638,19 +615,19 @@ export default function LeftSidebarPages({ }; /** - * Navigate to a page based on current route type - * Uses 'body' as the layer to ensure a clean slate on the new page + * Navigate to a page based on current sidebar tab. + * Uses store-based tab instead of routeType since tab switches use replaceState. */ - const navigateToNextPage = (pageId: string) => { - if (routeType === 'layers') { - navigateToLayers(pageId, urlState.view || undefined, urlState.rightTab || undefined, 'body'); - } else if (routeType === 'page' && urlState.isEditing) { + const navigateToNextPage = (pageId: string, layerId = 'body') => { + const view = urlState.view || undefined; + const rightTab = urlState.rightTab || undefined; + + if (urlState.isEditing) { navigateToPageEdit(pageId); - } else if (routeType === 'page') { - navigateToPage(pageId, urlState.view || undefined, urlState.rightTab || undefined, 'body'); + } else if (activeSidebarTab === 'pages') { + navigateToPage(pageId, view, rightTab, layerId); } else { - // Default to layers if no route type - navigateToLayers(pageId, urlState.view || undefined, urlState.rightTab || undefined, 'body'); + navigateToLayers(pageId, view, rightTab, layerId); } }; diff --git a/app/ycode/components/YCodeBuilderMain.tsx b/app/ycode/components/YCodeBuilderMain.tsx index 86aee599..b5be9a4b 100644 --- a/app/ycode/components/YCodeBuilderMain.tsx +++ b/app/ycode/components/YCodeBuilderMain.tsx @@ -361,11 +361,10 @@ export default function YCodeBuilder({ children }: YCodeBuilderProps = {} as YCo const isPageOrLayersRoute = routeType === 'page' || routeType === 'layers'; const isComponentRoute = routeType === 'component'; - if ((isPageOrLayersRoute || isComponentRoute) && !urlState.isEditing && hasInitializedLayerFromUrlRef.current) { - const layerParam = selectedLayerId || undefined; + if ((isPageOrLayersRoute || isComponentRoute) && !urlState.isEditing && hasInitializedLayerFromUrlRef.current && selectedLayerId) { // Only update if the layer has actually changed from URL - if (urlState.layerId !== layerParam) { - updateQueryParams({ layer: layerParam }); + if (urlState.layerId !== selectedLayerId) { + updateQueryParams({ layer: selectedLayerId }); } } }, [selectedLayerId, routeType, updateQueryParams, urlState.layerId, urlState.isEditing, justExitedEditMode]); diff --git a/components/SelectionOverlay.tsx b/components/SelectionOverlay.tsx index 1beb3319..7be8e76b 100644 --- a/components/SelectionOverlay.tsx +++ b/components/SelectionOverlay.tsx @@ -207,23 +207,25 @@ export function SelectionOverlay({ let mutationTimeout: ReturnType | null = null; let mutationRafId: number | null = null; const mutationObserver = new MutationObserver((mutations) => { - // Check if any mutation is a structural change (element added/removed) const hasStructuralChange = mutations.some(m => m.type === 'childList'); - if (hasStructuralChange) { - hideAllOutlines(); - - if (mutationTimeout) clearTimeout(mutationTimeout); + // Cancel any pending updates to avoid double-firing + if (mutationTimeout) clearTimeout(mutationTimeout); + if (mutationRafId) { + cancelAnimationFrame(mutationRafId); + mutationRafId = null; + } - // Show outlines after DOM settles + if (hasStructuralChange) { + // Structural DOM changes: defer update to let DOM settle + // Don't hide outlines first — avoids blinking on re-selection mutationTimeout = setTimeout(() => { updateAllOutlines(isDraggingRef.current); - }, 150); + }, 50); } else { // Attribute-only changes (class/style) - defer to next frame so // Tailwind Browser CDN has time to generate CSS for new classes // and the browser can reflow before we measure dimensions - if (mutationRafId) cancelAnimationFrame(mutationRafId); mutationRafId = requestAnimationFrame(() => { mutationRafId = requestAnimationFrame(() => { updateAllOutlines(isDraggingRef.current); diff --git a/components/ui/tabs.tsx b/components/ui/tabs.tsx index 8e47a53f..9d4c473a 100644 --- a/components/ui/tabs.tsx +++ b/components/ui/tabs.tsx @@ -66,8 +66,8 @@ function TabsContent({ data-slot="tabs-content" className={cn( 'flex-1 outline-none', - // When forceMount is used, hide inactive tabs with CSS (!important to override flex) - forceMount && 'data-[state=inactive]:!hidden', + // When forceMount is used, collapse inactive tabs but keep them rendered so images preload + forceMount && 'data-[state=inactive]:flex-none! data-[state=inactive]:h-0! data-[state=inactive]:p-0! data-[state=inactive]:overflow-hidden! data-[state=inactive]:pointer-events-none!', className )} forceMount={forceMount} diff --git a/hooks/use-editor-url.ts b/hooks/use-editor-url.ts index a7770f31..f9c75dbb 100644 --- a/hooks/use-editor-url.ts +++ b/hooks/use-editor-url.ts @@ -236,6 +236,9 @@ export function useEditorUrl() { // Preserve existing query params (e.g., preview mode) const currentParams = new URLSearchParams(window.location.search); + // Remove edit param — layers view is never in edit mode + currentParams.delete('edit'); + // Update/set specific params (use provided values or current values or defaults) currentParams.set('view', view || currentParams.get('view') || 'desktop'); currentParams.set('tab', rightTab || currentParams.get('tab') || 'design'); @@ -252,6 +255,9 @@ export function useEditorUrl() { // Preserve existing query params (e.g., preview mode) const currentParams = new URLSearchParams(window.location.search); + // Remove edit param — navigating to page view means exiting edit mode + currentParams.delete('edit'); + // Update/set specific params (use provided values or current values or defaults) currentParams.set('view', view || currentParams.get('view') || 'desktop'); currentParams.set('tab', rightTab || currentParams.get('tab') || 'design'); @@ -401,13 +407,14 @@ export function useEditorUrl() { } } - // Only navigate if something actually changed + // Update URL without Next.js navigation to avoid racing with router.push calls if (hasChanges) { const query = newSearchParams.toString(); - router.replace(`${pathname}${query ? `?${query}` : ''}`); + const newUrl = `${window.location.pathname}${query ? `?${query}` : ''}`; + window.history.replaceState(null, '', newUrl); } }, - [router, pathname] + [] ); return {