import React, { useCallback, useState } from 'react';
import { useEditor } from '@craftjs/core';
import { useIsMobile } from '../../hooks/useIsMobile';
import { useMobileChrome } from '../../state/MobileChromeContext';
import { Container } from '../../components/layout/Container';
import { Section } from '../../components/layout/Section';
import { ColumnLayout } from '../../components/layout/ColumnLayout';
import { BackgroundSection } from '../../components/layout/BackgroundSection';
import { Heading } from '../../components/basic/Heading';
import { TextBlock } from '../../components/basic/TextBlock';
import { ButtonLink } from '../../components/basic/ButtonLink';
import { Logo } from '../../components/basic/Logo';
import { Menu } from '../../components/basic/Menu';
import { Footer } from '../../components/basic/Footer';
import { Divider } from '../../components/basic/Divider';
import { Spacer } from '../../components/basic/Spacer';
import { Icon } from '../../components/basic/Icon';
import { HtmlBlock } from '../../components/basic/HtmlBlock';
import { ImageBlock } from '../../components/media/ImageBlock';
import { VideoBlock } from '../../components/media/VideoBlock';
import { MapEmbed } from '../../components/media/MapEmbed';
import { HeroSimple } from '../../components/sections/HeroSimple';
import { FeaturesGrid } from '../../components/sections/FeaturesGrid';
import { CallToAction } from '../../components/sections/CallToAction';
import { Countdown } from '../../components/sections/Countdown';
import { Testimonials } from '../../components/sections/Testimonials';
import { Accordion } from '../../components/sections/Accordion';
import { Tabs } from '../../components/sections/Tabs';
import { PricingTable } from '../../components/sections/PricingTable';
import { Gallery } from '../../components/sections/Gallery';
import { ContentSlider } from '../../components/sections/ContentSlider';
import { NumberCounter } from '../../components/sections/NumberCounter';
import { FormContainer } from '../../components/forms/FormContainer';
import { InputField } from '../../components/forms/InputField';
import { TextareaField } from '../../components/forms/TextareaField';
import { FormButton } from '../../components/forms/FormButton';
import { ContactForm } from '../../components/forms/ContactForm';
import { SubscribeForm } from '../../components/forms/SubscribeForm';
import { StarRating } from '../../components/basic/StarRating';
import { SocialLinks } from '../../components/basic/SocialLinks';
interface BlockDef {
id: string;
label: string;
icon: string;
component: React.ReactElement;
}
interface CategoryDef {
id: string;
label: string;
blocks: BlockDef[];
}
const categories: CategoryDef[] = [
{
id: 'basic',
label: 'Basic',
blocks: [
{ id: 'heading', label: 'Heading', icon: 'fa-header',
component: },
{ id: 'text', label: 'Text', icon: 'fa-paragraph',
component: },
{ id: 'button', label: 'Button', icon: 'fa-square',
component: },
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', component: },
{ id: 'menu', label: 'Menu', icon: 'fa-bars', component:
},
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', component: },
{ id: 'divider', label: 'Divider', icon: 'fa-minus', component: },
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', component: },
{ id: 'icon', label: 'Icon', icon: 'fa-star', component: },
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', component: },
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', component: },
{ id: 'html-block', label: 'HTML', icon: 'fa-code', component: },
],
},
{
id: 'layout',
label: 'Layout',
blocks: [
{ id: 'section', label: 'Section', icon: 'fa-window-maximize',
component: },
{ id: 'container', label: 'Container', icon: 'fa-square-o',
component: },
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop',
component: },
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large',
component: },
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th',
component: },
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th',
component: },
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th',
component: },
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th',
component: },
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns',
component: },
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns',
component: },
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', component: },
],
},
{
id: 'sections',
label: 'Sections',
blocks: [
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', component: },
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', component: },
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', component: },
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', component: },
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', component: },
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', component: },
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', component: },
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o',
component: },
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left',
component: },
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', component: },
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', component: },
],
},
{
id: 'media',
label: 'Media',
blocks: [
{ id: 'image', label: 'Image', icon: 'fa-image',
component: },
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
component: },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
component: },
],
},
{
id: 'forms',
label: 'Forms',
blocks: [
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', component: },
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', component: },
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', component: },
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', component: },
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', component: },
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', component: },
],
},
];
export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
const isMobile = useIsMobile();
const { closeSheet } = useMobileChrome();
const [collapsed, setCollapsed] = useState>(() => {
const initial: Record = {};
categories.forEach((cat, index) => {
initial[cat.id] = index !== 0; // First category open
});
return initial;
});
const toggleCategory = (categoryId: string) => {
setCollapsed((prev) => ({ ...prev, [categoryId]: !prev[categoryId] }));
};
/** Default insertion target when there's no usable selection to anchor
* to: the first real Craft.js canvas in the document (falls back to
* ROOT). Extracted from the pre-existing onDoubleClick handler so
* tap-to-add (mobile, item 3) and double-click (desktop, unchanged) share
* the exact same fallback. */
const findDefaultCanvasId = useCallback((): string => {
try {
const serialized = JSON.parse(query.serialize());
const nodeIds = Object.keys(serialized);
for (const id of nodeIds) {
if (serialized[id].isCanvas && id !== 'ROOT') return id;
}
} catch {
// Fall through to ROOT below.
}
return 'ROOT';
}, [query]);
/**
* Builds a fresh node tree for `block` and inserts it into the canvas,
* returning the new node's id (or null on failure). Shared by desktop's
* double-click (unchanged behavior/position: always appended to
* `findDefaultCanvasId()`) and mobile's tap-to-add (item 3), which instead
* prefers inserting as a sibling right after the current selection --
* `insertAfterSelection: true` only from the mobile tap handler below.
*/
const addBlockNode = useCallback((block: BlockDef, insertAfterSelection: boolean): string | null => {
try {
const tree = query.parseReactElement(React.cloneElement(block.component)).toNodeTree();
let inserted = false;
if (insertAfterSelection) {
try {
const selectedIds = query.getEvent('selected').all();
const selectedId = selectedIds.length > 0 ? selectedIds[0] : null;
if (selectedId && selectedId !== 'ROOT') {
const selectedNode = query.node(selectedId).get();
const parentId = selectedNode?.data?.parent;
if (parentId) {
const parent = query.node(parentId).get();
const siblings: string[] = parent?.data?.nodes || [];
const idx = siblings.indexOf(selectedId);
if (idx !== -1) {
actions.addNodeTree(tree, parentId, idx + 1);
inserted = true;
}
}
}
} catch {
// Selection isn't a valid sibling target (e.g. lives in a
// linkedNodes slot) -- fall through to the default canvas below.
}
}
if (!inserted) {
actions.addNodeTree(tree, findDefaultCanvasId());
}
return tree.rootNodeId;
} catch (e) {
console.error('Failed to add block:', e);
return null;
}
}, [query, actions, findDefaultCanvasId]);
/**
* Mobile tap-to-add (item 3): a single tap on a block tile inserts it,
* closes the Blocks sheet, then selects the new node and scrolls it into
* view so the user immediately sees it (and gets the selection toolbar).
* The select/scroll step is deferred two animation frames past the
* `addNodeTree` call -- Craft.js's own state update (and thus the new
* node's real DOM element) lands asynchronously after this handler
* returns, so `query.node(id).get().dom` isn't populated yet if read
* synchronously here.
*/
const handleTapToAdd = useCallback((block: BlockDef) => {
const newId = addBlockNode(block, true);
closeSheet();
if (!newId) return;
requestAnimationFrame(() => {
try {
actions.selectNode(newId);
} catch {
// Node may have failed to mount -- nothing to select.
}
requestAnimationFrame(() => {
try {
query.node(newId).get()?.dom?.scrollIntoView({ behavior: 'smooth', block: 'center' });
} catch {
// Best-effort scroll -- not fatal if the node/DOM isn't found.
}
});
});
}, [addBlockNode, closeSheet, actions, query]);
return (
{categories.map((category) => {
const isCollapsed = collapsed[category.id];
return (
toggleCategory(category.id)}
>
{category.label}
{category.blocks.map((block) => (
{ if (ref) connectors.create(ref, block.component); }}
onDoubleClick={() => addBlockNode(block, false)}
onClick={isMobile ? () => handleTapToAdd(block) : undefined}
title={isMobile ? `Tap to add ${block.label}` : `Drag or double-click to add ${block.label}`}
>
{block.label}
))}
);
})}
);
};