Files
site-builder/craft/src/panels/left/BlocksPanel.tsx
T

296 lines
14 KiB
TypeScript
Raw Normal View History

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: <Heading text="Your Heading" level="h2" style={{ fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', color: '#1f2937', marginBottom: '16px' }} /> },
{ id: 'text', label: 'Text', icon: 'fa-paragraph',
component: <TextBlock text="Add your text here. Click to edit." style={{ fontSize: '16px', lineHeight: '1.6', color: '#4b5563', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'button', label: 'Button', icon: 'fa-square',
component: <ButtonLink text="Click Me" href="#" style={{ display: 'inline-block', padding: '14px 32px', background: '#3b82f6', color: '#ffffff', textDecoration: 'none', borderRadius: '8px', fontWeight: '600', fontSize: '16px', fontFamily: 'Inter, sans-serif' }} /> },
{ id: 'logo', label: 'Logo', icon: 'fa-bookmark', component: <Logo /> },
{ id: 'menu', label: 'Menu', icon: 'fa-bars', component: <Menu /> },
{ id: 'footer', label: 'Footer', icon: 'fa-window-minimize', component: <Footer /> },
{ id: 'divider', label: 'Divider', icon: 'fa-minus', component: <Divider /> },
{ id: 'spacer', label: 'Spacer', icon: 'fa-arrows-v', component: <Spacer /> },
{ id: 'icon', label: 'Icon', icon: 'fa-star', component: <Icon icon="fa-star" size="48px" color="#3b82f6" /> },
{ id: 'star-rating', label: 'Star Rating', icon: 'fa-star-half-o', component: <StarRating /> },
{ id: 'social-links', label: 'Social Links', icon: 'fa-share-alt', component: <SocialLinks /> },
{ id: 'html-block', label: 'HTML', icon: 'fa-code', component: <HtmlBlock code="<div>Custom HTML</div>" /> },
],
},
{
id: 'layout',
label: 'Layout',
blocks: [
{ id: 'section', label: 'Section', icon: 'fa-window-maximize',
component: <Section style={{ padding: '60px 20px', backgroundColor: '#ffffff' }} /> },
{ id: 'container', label: 'Container', icon: 'fa-square-o',
component: <Container style={{ padding: '20px', minHeight: '100px' }} /> },
{ id: 'columns-1', label: '1 Column', icon: 'fa-stop',
component: <ColumnLayout columns={1} split="100" /> },
{ id: 'columns-2', label: '2 Columns', icon: 'fa-th-large',
component: <ColumnLayout columns={2} split="50-50" /> },
{ id: 'columns-3', label: '3 Columns', icon: 'fa-th',
component: <ColumnLayout columns={3} split="33-33-33" /> },
{ id: 'columns-4', label: '4 Columns', icon: 'fa-th',
component: <ColumnLayout columns={4} split="25-25-25-25" /> },
{ id: 'columns-5', label: '5 Columns', icon: 'fa-th',
component: <ColumnLayout columns={5} split="20-20-20-20-20" /> },
{ id: 'columns-6', label: '6 Columns', icon: 'fa-th',
component: <ColumnLayout columns={6} split="16-16-16-16-16-16" /> },
{ id: 'sidebar-left', label: 'Sidebar Left', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="30-70" /> },
{ id: 'sidebar-right', label: 'Sidebar Right', icon: 'fa-columns',
component: <ColumnLayout columns={2} split="70-30" /> },
{ id: 'bg-section', label: 'BG Section', icon: 'fa-picture-o', component: <BackgroundSection /> },
],
},
{
id: 'sections',
label: 'Sections',
blocks: [
{ id: 'hero-simple', label: 'Hero', icon: 'fa-star', component: <HeroSimple /> },
{ id: 'features-grid', label: 'Features', icon: 'fa-th-large', component: <FeaturesGrid /> },
{ id: 'cta-section', label: 'CTA', icon: 'fa-bullhorn', component: <CallToAction /> },
{ id: 'accordion', label: 'Accordion', icon: 'fa-list', component: <Accordion /> },
{ id: 'tabs', label: 'Tabs', icon: 'fa-folder-o', component: <Tabs /> },
{ id: 'pricing-table', label: 'Pricing', icon: 'fa-usd', component: <PricingTable /> },
{ id: 'gallery', label: 'Gallery', icon: 'fa-th', component: <Gallery /> },
{ id: 'countdown', label: 'Countdown', icon: 'fa-clock-o',
component: <Countdown targetDate={new Date(Date.now() + 30 * 86400000).toISOString()} /> },
{ id: 'testimonials', label: 'Testimonials', icon: 'fa-quote-left',
component: <Testimonials testimonials={[{ quote: "Great service!", name: "John", title: "CEO", rating: 5 }]} layout="grid" columns={3} /> },
{ id: 'content-slider', label: 'Slider', icon: 'fa-sliders', component: <ContentSlider /> },
{ id: 'number-counter', label: 'Counters', icon: 'fa-sort-numeric-asc', component: <NumberCounter /> },
],
},
{
id: 'media',
label: 'Media',
blocks: [
{ id: 'image', label: 'Image', icon: 'fa-image',
component: <ImageBlock alt="Image" style={{ maxWidth: '100%', height: 'auto', display: 'block', borderRadius: '8px' }} /> },
{ id: 'video', label: 'Video', icon: 'fa-play-circle',
component: <VideoBlock videoUrl="" isBackground={false} /> },
{ id: 'map-embed', label: 'Map', icon: 'fa-map-marker',
component: <MapEmbed address="New York, NY" zoom={14} height="400px" /> },
],
},
{
id: 'forms',
label: 'Forms',
blocks: [
{ id: 'contact-form', label: 'Contact Form', icon: 'fa-envelope', component: <ContactForm /> },
{ id: 'subscribe-form', label: 'Subscribe', icon: 'fa-paper-plane', component: <SubscribeForm /> },
{ id: 'form-container', label: 'Form', icon: 'fa-wpforms', component: <FormContainer /> },
{ id: 'input-field', label: 'Input', icon: 'fa-i-cursor', component: <InputField /> },
{ id: 'textarea-field', label: 'Textarea', icon: 'fa-align-left', component: <TextareaField /> },
{ id: 'form-button', label: 'Submit', icon: 'fa-paper-plane', component: <FormButton /> },
],
},
];
export const BlocksPanel: React.FC = () => {
const { connectors, actions, query } = useEditor();
const isMobile = useIsMobile();
const { closeSheet } = useMobileChrome();
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
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 (
<div>
{categories.map((category) => {
const isCollapsed = collapsed[category.id];
return (
<div key={category.id} className="block-category">
<div
className={`block-category-header ${isCollapsed ? 'collapsed' : ''}`}
onClick={() => toggleCategory(category.id)}
>
<span>{category.label}</span>
<i className="fa fa-chevron-down chevron" />
</div>
<div className={`block-category-items ${isCollapsed ? 'collapsed' : ''}`}>
<div className="block-grid">
{category.blocks.map((block) => (
<div
key={block.id}
className="block-item"
ref={(ref) => { 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}`}
>
<i className={`fa ${block.icon} block-item-icon`} />
<span className="block-item-label">{block.label}</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
);
};