2026-02-28 19:25:42 +00:00
/ * *
* Site Builder - GrapesJS Editor Configuration
* /
( function ( ) {
'use strict' ;
// Storage key for localStorage
const STORAGE _KEY = 'sitebuilder-project' ;
// Initialize GrapesJS Editor
const editor = grapesjs . init ( {
container : '#gjs' ,
height : '100%' ,
width : 'auto' ,
fromElement : false ,
// Storage configuration - localStorage
storageManager : {
type : 'local' ,
autosave : true ,
autoload : true ,
stepsBeforeSave : 1 ,
options : {
local : {
key : STORAGE _KEY
}
}
} ,
// Device manager for responsive preview
deviceManager : {
devices : [
{
name : 'Desktop' ,
width : ''
} ,
{
name : 'Tablet' ,
width : '768px' ,
widthMedia : '992px'
} ,
{
name : 'Mobile' ,
width : '375px' ,
widthMedia : '480px'
}
]
} ,
// Layer manager configuration
layerManager : {
appendTo : '#layers-container'
} ,
// Block manager configuration
blockManager : {
appendTo : '#blocks-container'
} ,
// Style manager configuration
styleManager : {
appendTo : '#advanced-styles' ,
sectors : [
{
name : 'Dimension' ,
open : false ,
properties : [
'width' , 'min-width' , 'max-width' ,
'height' , 'min-height' , 'max-height' ,
'padding' , 'padding-top' , 'padding-right' , 'padding-bottom' , 'padding-left' ,
'margin' , 'margin-top' , 'margin-right' , 'margin-bottom' , 'margin-left'
]
} ,
{
name : 'Typography' ,
open : false ,
properties : [
{
property : 'font-family' ,
type : 'select' ,
options : [
{ id : 'Inter, sans-serif' , label : 'Inter' } ,
{ id : 'Roboto, sans-serif' , label : 'Roboto' } ,
{ id : 'Open Sans, sans-serif' , label : 'Open Sans' } ,
{ id : 'Poppins, sans-serif' , label : 'Poppins' } ,
{ id : 'Montserrat, sans-serif' , label : 'Montserrat' } ,
{ id : 'Playfair Display, serif' , label : 'Playfair Display' } ,
{ id : 'Merriweather, serif' , label : 'Merriweather' } ,
{ id : 'Source Code Pro, monospace' , label : 'Source Code Pro' } ,
{ id : 'Arial, Helvetica, sans-serif' , label : 'Arial' } ,
{ id : 'Georgia, serif' , label : 'Georgia' } ,
{ id : 'Times New Roman, serif' , label : 'Times New Roman' }
]
} ,
'font-size' ,
{
property : 'font-weight' ,
type : 'select' ,
options : [
{ id : '100' , label : 'Thin (100)' } ,
{ id : '200' , label : 'Extra Light (200)' } ,
{ id : '300' , label : 'Light (300)' } ,
{ id : '400' , label : 'Normal (400)' } ,
{ id : '500' , label : 'Medium (500)' } ,
{ id : '600' , label : 'Semi Bold (600)' } ,
{ id : '700' , label : 'Bold (700)' } ,
{ id : '800' , label : 'Extra Bold (800)' } ,
{ id : '900' , label : 'Black (900)' }
]
} ,
{
property : 'letter-spacing' ,
type : 'number' ,
units : [ 'px' , 'em' , 'rem' ] ,
default : '0' ,
step : 0.1
} ,
{
property : 'line-height' ,
type : 'number' ,
units : [ 'px' , 'em' , '%' , '' ] ,
default : 'normal' ,
step : 0.1
} ,
{
property : 'text-align' ,
type : 'radio' ,
options : [
{ id : 'left' , label : 'Left' , className : 'fa fa-align-left' } ,
{ id : 'center' , label : 'Center' , className : 'fa fa-align-center' } ,
{ id : 'right' , label : 'Right' , className : 'fa fa-align-right' } ,
{ id : 'justify' , label : 'Justify' , className : 'fa fa-align-justify' }
]
} ,
'text-decoration' , 'text-transform' , 'color'
]
} ,
{
name : 'Background' ,
open : false ,
properties : [
'background-color' , 'background-image' , 'background-repeat' ,
'background-position' , 'background-size' , 'background-attachment'
]
} ,
{
name : 'Border' ,
open : false ,
properties : [
'border-radius' , 'border-radius-top-left' , 'border-radius-top-right' ,
'border-radius-bottom-left' , 'border-radius-bottom-right' ,
'border' , 'border-width' , 'border-style' , 'border-color'
]
} ,
{
name : 'Effects' ,
open : false ,
properties : [
'opacity' , 'box-shadow' , 'transition'
]
} ,
{
name : 'Layout' ,
open : false ,
properties : [
'display' , 'position' , 'top' , 'right' , 'bottom' , 'left' ,
'flex-direction' , 'flex-wrap' , 'justify-content' , 'align-items' ,
'align-content' , 'gap' , 'order' , 'flex-basis' , 'flex-grow' , 'flex-shrink'
]
}
]
} ,
// Trait manager configuration
traitManager : {
appendTo : '#traits-container'
} ,
// Selector manager (CSS classes)
selectorManager : {
appendTo : '#advanced-styles' ,
componentFirst : true
} ,
// Plugins - using global function references from CDN scripts
plugins : [
window [ 'gjs-blocks-basic' ] ,
window [ 'grapesjs-preset-webpage' ] ,
window [ 'grapesjs-plugin-forms' ] ,
window [ 'grapesjs-style-gradient' ]
] . filter ( Boolean ) , // Filter out any undefined plugins
pluginsOpts : {
[ window [ 'gjs-blocks-basic' ] ] : {
flexGrid : true ,
blocks : [ 'column1' , 'column2' , 'column3' , 'column3-7' , 'text' , 'link' , 'map' ]
} ,
[ window [ 'grapesjs-preset-webpage' ] ] : {
modalImportTitle : 'Import Template' ,
modalImportLabel : '<div style="margin-bottom: 10px;">Paste HTML/CSS here</div>' ,
modalImportContent : '' ,
importViewerRecords : true ,
textCleanCanvas : 'Are you sure you want to clear the canvas?' ,
showStylesOnChange : true ,
useCustomTheme : false ,
blocks : [ 'link-block' , 'quote' , 'text-basic' ]
} ,
[ window [ 'grapesjs-plugin-forms' ] ] : {
blocks : [ 'form' , 'input' , 'textarea' , 'select' , 'button' , 'label' , 'checkbox' , 'radio' ]
} ,
[ window [ 'grapesjs-style-gradient' ] ] : {
colorPicker : 'default'
}
} ,
// Canvas configuration
canvas : {
styles : [
// Google Fonts - Popular choices
'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;500;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap' ,
// Responsive images and base styles injected inline
'data:text/css,' + encodeURIComponent ( `
/* Responsive images */
img {
max - width : 100 % ;
height : auto ;
}
/* Responsive video containers */
video {
max - width : 100 % ;
height : auto ;
}
/* Responsive columns on mobile */
@ media ( max - width : 480 px ) {
. row {
flex - direction : column ! important ;
}
. row . cell {
flex - basis : 100 % ! important ;
width : 100 % ! important ;
}
}
/* Editor-only anchor visualization */
. editor - anchor {
position : relative ;
display : inline - flex ;
align - items : center ;
gap : 6 px ;
min - height : 28 px ;
border : 1 px dashed # 9 ca3af ;
padding : 4 px 8 px ;
background : rgba ( 59 , 130 , 246 , 0.05 ) ;
border - radius : 4 px ;
}
. editor - anchor . anchor - icon {
font - size : 14 px ;
color : # 6 b7280 ;
line - height : 1 ;
}
. editor - anchor . anchor - name - input {
border : none ;
background : transparent ;
color : # 374151 ;
font - size : 12 px ;
font - family : Inter , sans - serif ;
font - weight : 500 ;
padding : 2 px 4 px ;
outline : none ;
min - width : 80 px ;
}
. editor - anchor . anchor - name - input : focus {
background : rgba ( 255 , 255 , 255 , 0.5 ) ;
border - radius : 2 px ;
}
` )
] ,
scripts : [ ]
}
} ) ;
// ==========================================
// UI Elements
// ==========================================
const saveStatus = document . getElementById ( 'save-status' ) ;
const statusText = saveStatus . querySelector ( '.status-text' ) ;
// ==========================================
// Add Custom Blocks (Columns, Hero, etc.)
// ==========================================
const blockManager = editor . BlockManager ;
2026-03-01 14:13:02 -08:00
// Remove plugin-provided Image/Video blocks that duplicate the Media section's
// custom blocks (which have browse-assets support and proper wrappers)
blockManager . remove ( 'image' ) ;
blockManager . remove ( 'video' ) ;
2026-02-28 19:25:42 +00:00
// Section Block
blockManager . add ( 'section' , {
label : 'Section' ,
category : 'Layout' ,
content : {
tagName : 'section' ,
style : {
'padding' : '60px 20px' ,
'min-height' : '200px' ,
'background-color' : '#ffffff'
} ,
components : [
{
tagName : 'div' ,
style : {
'max-width' : '1200px' ,
'margin' : '0 auto'
} ,
components : [ ]
}
]
} ,
attributes : { class : 'fa fa-columns' }
} ) ;
// Logo Block
blockManager . add ( 'logo' , {
label : 'Logo' ,
category : 'Layout' ,
content : ` <a href="#" class="site-logo" style="display:inline-flex;align-items:center;gap:10px;text-decoration:none;">
< div style = "width:40px;height:40px;background:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 100%);border-radius:8px;display:flex;align-items:center;justify-content:center;" >
< span style = "color:#fff;font-weight:700;font-size:18px;font-family:Inter,sans-serif;" > S < / s p a n >
< / d i v >
< span style = "font-size:20px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > SiteName < / s p a n >
< / a > ` ,
attributes : { class : 'fa fa-bookmark' }
} ) ;
// Navigation Menu (will be updated dynamically based on pages)
blockManager . add ( 'navbar' , {
label : 'Navigation' ,
category : 'Layout' ,
content : {
type : 'navbar' ,
tagName : 'nav' ,
attributes : { class : 'site-navbar' , 'data-dynamic-nav' : 'true' } ,
style : {
'display' : 'flex' ,
'align-items' : 'center' ,
'justify-content' : 'space-between' ,
'padding' : '16px 24px' ,
'background' : '#ffffff' ,
'border-bottom' : '1px solid #e5e7eb'
} ,
components : [
{
tagName : 'a' ,
attributes : { href : '#' , class : 'nav-logo' } ,
style : {
'font-size' : '20px' ,
'font-weight' : '700' ,
'color' : '#1f2937' ,
'text-decoration' : 'none' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Logo'
} ,
{
tagName : 'div' ,
attributes : { class : 'nav-links' } ,
style : {
'display' : 'flex' ,
'gap' : '24px' ,
'align-items' : 'center'
} ,
components : [
{
tagName : 'a' ,
attributes : { href : '#' } ,
style : {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Home'
} ,
{
tagName : 'a' ,
attributes : { href : '#' } ,
style : {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'About'
} ,
{
tagName : 'a' ,
attributes : { href : '#' } ,
style : {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Contact'
} ,
{
tagName : 'a' ,
attributes : { href : '#' , class : 'nav-cta' } ,
style : {
'display' : 'inline-block' ,
'padding' : '10px 20px' ,
'background' : '#3b82f6' ,
'color' : '#fff' ,
'text-decoration' : 'none' ,
'border-radius' : '6px' ,
'font-size' : '14px' ,
'font-weight' : '500' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Get Started'
}
]
}
]
} ,
attributes : { class : 'fa fa-bars' }
} ) ;
// Section with Background (Image/Video + Overlay)
blockManager . add ( 'section-bg' , {
label : 'Section (Background)' ,
category : 'Layout' ,
content : {
tagName : 'section' ,
attributes : { class : 'section-with-bg' , 'data-bg-section' : 'true' } ,
style : {
'position' : 'relative' ,
'min-height' : '400px' ,
'display' : 'flex' ,
'align-items' : 'center' ,
'justify-content' : 'center' ,
'background-image' : 'url(https://images.unsplash.com/photo-1557683316-973673baf926?w=1920)' ,
'background-size' : 'cover' ,
'background-position' : 'center' ,
'padding' : '60px 20px' ,
'overflow' : 'hidden'
} ,
components : [
// Overlay
{
tagName : 'div' ,
attributes : { class : 'bg-overlay' } ,
style : {
'position' : 'absolute' ,
'top' : '0' ,
'left' : '0' ,
'right' : '0' ,
'bottom' : '0' ,
'background' : 'rgba(0,0,0,0.5)' ,
'z-index' : '1'
} ,
selectable : true ,
hoverable : true
} ,
// Content container
{
tagName : 'div' ,
attributes : { class : 'bg-content' } ,
style : {
'position' : 'relative' ,
'z-index' : '2' ,
'max-width' : '800px' ,
'text-align' : 'center'
} ,
components : [
{
tagName : 'h2' ,
style : {
'color' : '#ffffff' ,
'font-size' : '36px' ,
'font-weight' : '700' ,
'margin-bottom' : '16px' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Your Heading Here'
} ,
{
tagName : 'p' ,
style : {
'color' : 'rgba(255,255,255,0.9)' ,
'font-size' : '18px' ,
'line-height' : '1.6' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Add your content here. This section supports background images and videos with customizable overlay.'
}
]
}
]
} ,
attributes : { class : 'fa fa-image' }
} ) ;
// Section with Video Background
blockManager . add ( 'section-video-bg' , {
label : 'Section (Video BG)' ,
category : 'Layout' ,
content : ` <section class="section-with-video-bg" data-video-section="true" style="position:relative;min-height:400px;display:flex;align-items:center;justify-content:center;padding:60px 20px;overflow:hidden;">
< div class = "bg-video-wrapper" data - bg - video = "true" style = "position:absolute;top:0;left:0;right:0;bottom:0;z-index:0;overflow:hidden;" >
< iframe class = "bg-video-frame" style = "position:absolute;top:50%;left:50%;width:100vw;height:56.25vw;min-height:100%;min-width:177.77vh;transform:translate(-50%,-50%);border:none;display:none;" src = "" frameborder = "0" allow = "accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" referrerpolicy = "strict-origin-when-cross-origin" allowfullscreen > < / i f r a m e >
< video class = "bg-video-player" style = "position:absolute;top:50%;left:50%;min-width:100%;min-height:100%;transform:translate(-50%,-50%);object-fit:cover;display:none;" autoplay muted loop playsinline > < / v i d e o >
< div class = "bg-video-placeholder" style = "position:absolute;top:0;left:0;right:0;bottom:0;background:#1a1a2e;display:flex;align-items:center;justify-content:center;color:#fff;font-family:Inter,sans-serif;" >
< div style = "text-align:center;" >
< div style = "font-size:32px;margin-bottom:8px;" > ▶ < / d i v >
< div style = "font-size:12px;opacity:0.7;" > Click this section , then add Video URL in Settings → < / d i v >
< / d i v >
< / d i v >
< / d i v >
< div class = "bg-overlay" style = "position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:1;" > < / d i v >
< div class = "bg-content" style = "position:relative;z-index:2;max-width:800px;text-align:center;" >
< h2 style = "color:#ffffff;font-size:36px;font-weight:700;margin-bottom:16px;font-family:Inter,sans-serif;" > Video Background < / h 2 >
< p style = "color:rgba(255,255,255,0.9);font-size:18px;line-height:1.6;font-family:Inter,sans-serif;" > This section has a looping video background with an overlay . < / p >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-play-circle' }
} ) ;
// Footer
blockManager . add ( 'footer' , {
label : 'Footer' ,
category : 'Layout' ,
content : ` <footer style="padding:40px 20px;background:#1f2937;color:#9ca3af;text-align:center;">
< div style = "max-width:1200px;margin:0 auto;" >
< div style = "display:flex;justify-content:center;gap:24px;margin-bottom:20px;" >
< a href = "#" style = "color:#9ca3af;text-decoration:none;font-size:14px;font-family:Inter,sans-serif;" > Privacy Policy < / a >
< a href = "#" style = "color:#9ca3af;text-decoration:none;font-size:14px;font-family:Inter,sans-serif;" > Terms of Service < / a >
< a href = "#" style = "color:#9ca3af;text-decoration:none;font-size:14px;font-family:Inter,sans-serif;" > Contact < / a >
< / d i v >
< p style = "font-size:14px;font-family:Inter,sans-serif;" > © 2024 Your Company . All rights reserved . < / p >
< / d i v >
< / f o o t e r > ` ,
attributes : { class : 'fa fa-window-minimize' }
} ) ;
// Column Layouts
blockManager . add ( 'column-1' , {
label : '1 Column' ,
category : 'Layout' ,
content : ` <div class="row" data-gjs-droppable=".cell" data-gjs-resizable='{"tl":0,"tc":0,"tr":0,"cl":0,"cr":0,"bl":0,"br":0}' style="display:flex;flex-wrap:wrap;padding:10px;">
< div class = "cell" data - gjs - draggable = ".row" data - gjs - resizable = '{"tl":0,"tc":0,"tr":0,"cl":0,"cr":1,"bl":0,"br":0}' style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< / d i v > ` ,
attributes : { class : 'gjs-fonts gjs-f-b1' }
} ) ;
blockManager . add ( 'column-2' , {
label : '2 Columns' ,
category : 'Layout' ,
content : ` <div class="row" data-gjs-droppable=".cell" style="display:flex;flex-wrap:wrap;padding:10px;">
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< / d i v > ` ,
attributes : { class : 'gjs-fonts gjs-f-b2' }
} ) ;
blockManager . add ( 'column-3' , {
label : '3 Columns' ,
category : 'Layout' ,
content : ` <div class="row" data-gjs-droppable=".cell" style="display:flex;flex-wrap:wrap;padding:10px;">
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< / d i v > ` ,
attributes : { class : 'gjs-fonts gjs-f-b3' }
} ) ;
blockManager . add ( 'column-4' , {
label : '4 Columns' ,
category : 'Layout' ,
content : ` <div class="row" data-gjs-droppable=".cell" style="display:flex;flex-wrap:wrap;padding:10px;">
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< div class = "cell" data - gjs - draggable = ".row" style = "flex:1;min-height:75px;padding:10px;" > < / d i v >
< / d i v > ` ,
attributes : { class : 'gjs-fonts gjs-f-b4' }
} ) ;
blockManager . add ( 'column-3-7' , {
label : '2 Columns 3/7' ,
category : 'Layout' ,
content : ` <div class="row" data-gjs-droppable=".cell" style="display:flex;flex-wrap:wrap;padding:10px;">
< div class = "cell" data - gjs - draggable = ".row" style = "flex-basis:30%;min-height:75px;padding:10px;" > < / d i v >
< div class = "cell" data - gjs - draggable = ".row" style = "flex-basis:70%;min-height:75px;padding:10px;" > < / d i v >
< / d i v > ` ,
attributes : { class : 'gjs-fonts gjs-f-b37' }
} ) ;
// Image Block
blockManager . add ( 'image-block' , {
label : 'Image' ,
category : 'Media' ,
content : '<img src="https://via.placeholder.com/800x400/3b82f6/ffffff?text=Click+to+change+image" style="max-width:100%;height:auto;display:block;border-radius:8px;" alt="Image">' ,
attributes : { class : 'fa fa-image' }
} ) ;
// Unified Video Block (YouTube, Vimeo, or direct file)
blockManager . add ( 'video-block' , {
label : 'Video' ,
category : 'Media' ,
content : ` <div class="video-wrapper" data-video-wrapper="true" style="position:relative;padding-bottom:56.25%;height:0;overflow:hidden;max-width:100%;border-radius:8px;background:#1a1a2e;">
< iframe class = "video-frame" style = "position:absolute;top:0;left:0;width:100%;height:100%;border:none;display:none;" src = "" title = "Video" frameborder = "0" allow = "accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy = "strict-origin-when-cross-origin" allowfullscreen > < / i f r a m e >
< video class = "video-player" style = "position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:none;" controls > < / v i d e o >
< div class = "video-placeholder" style = "position:absolute;top:0;left:0;right:0;bottom:0;display:flex;align-items:center;justify-content:center;color:#fff;text-align:center;font-family:Inter,sans-serif;padding:20px;" >
< div >
< div style = "font-size:48px;margin-bottom:10px;" > ▶ < / d i v >
< div style = "font-size:14px;opacity:0.8;" > Select container & add Video URL in Settings < / d i v >
< div style = "font-size:12px;opacity:0.6;margin-top:8px;" > Supports YouTube , Vimeo , or direct video files < / d i v >
< / d i v >
< / d i v >
< / d i v > ` ,
attributes : { class : 'fa fa-play-circle' }
} ) ;
// Hero with Image Background
blockManager . add ( 'hero-image' , {
label : 'Hero (Image)' ,
category : 'Sections' ,
content : ` <section style="min-height:500px;display:flex;align-items:center;justify-content:center;background-image:url('https://images.unsplash.com/photo-1557683316-973673baf926?w=1920');background-size:cover;background-position:center;position:relative;padding:60px 20px;text-align:center;">
< div style = "position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);" > < / d i v >
< div style = "position:relative;z-index:1;max-width:800px;" >
< h1 style = "color:#fff;font-size:48px;font-weight:700;margin-bottom:20px;font-family:Inter,sans-serif;" > Your Headline Here < / h 1 >
< p style = "color:rgba(255,255,255,0.9);font-size:20px;line-height:1.6;margin-bottom:30px;font-family:Inter,sans-serif;" > Add your compelling subheadline or description text here to engage your visitors . < / p >
< a href = "#" style = "display:inline-block;padding:16px 40px;background:#3b82f6;color:#fff;font-size:18px;font-weight:600;text-decoration:none;border-radius:8px;font-family:Inter,sans-serif;" > Get Started < / a >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-image' }
} ) ;
// Hero with Video Background
blockManager . add ( 'hero-video' , {
label : 'Hero (Video)' ,
category : 'Sections' ,
2026-03-01 14:13:02 -08:00
content : ` <section class="section-with-video-bg" data-video-section="true" style="position:relative;min-height:500px;display:flex;align-items:center;justify-content:center;padding:60px 20px;overflow:hidden;">
< div class = "bg-video-wrapper" data - bg - video = "true" style = "position:absolute;top:0;left:0;right:0;bottom:0;z-index:0;overflow:hidden;" >
< iframe class = "bg-video-frame" style = "position:absolute;top:50%;left:50%;width:100vw;height:56.25vw;min-height:100%;min-width:177.77vh;transform:translate(-50%,-50%);border:none;display:none;" src = "" frameborder = "0" allow = "accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture" referrerpolicy = "strict-origin-when-cross-origin" allowfullscreen > < / i f r a m e >
< video class = "bg-video-player" style = "position:absolute;top:50%;left:50%;min-width:100%;min-height:100%;transform:translate(-50%,-50%);object-fit:cover;display:none;" autoplay muted loop playsinline > < / v i d e o >
< div class = "bg-video-placeholder" style = "position:absolute;top:0;left:0;right:0;bottom:0;background:#1a1a2e;display:flex;align-items:center;justify-content:center;color:#fff;font-family:Inter,sans-serif;" >
< div style = "text-align:center;" >
< div style = "font-size:32px;margin-bottom:8px;" > ▶ < / d i v >
< div style = "font-size:12px;opacity:0.7;" > Click this section , then add Video URL in Settings → < / d i v >
< / d i v >
< / d i v >
< / d i v >
< div class = "bg-overlay" style = "position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:1;" > < / d i v >
< div class = "bg-content" style = "position:relative;z-index:2;max-width:800px;text-align:center;" >
2026-02-28 19:25:42 +00:00
< h1 style = "color:#fff;font-size:48px;font-weight:700;margin-bottom:20px;font-family:Inter,sans-serif;" > Video Background Hero < / h 1 >
< p style = "color:rgba(255,255,255,0.9);font-size:20px;line-height:1.6;margin-bottom:30px;font-family:Inter,sans-serif;" > Create stunning video backgrounds for your hero sections . < / p >
< a href = "#" style = "display:inline-block;padding:16px 40px;background:#10b981;color:#fff;font-size:18px;font-weight:600;text-decoration:none;border-radius:8px;font-family:Inter,sans-serif;" > Learn More < / a >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-play-circle' }
} ) ;
// Simple Hero Section
blockManager . add ( 'hero-simple' , {
label : 'Hero (Simple)' ,
category : 'Sections' ,
content : ` <section style="min-height:400px;display:flex;align-items:center;justify-content:center;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);padding:60px 20px;text-align:center;">
< div style = "max-width:600px;" >
< h1 style = "color:#fff;font-size:48px;font-weight:700;margin-bottom:20px;font-family:Inter,sans-serif;" > Welcome < / h 1 >
< p style = "color:rgba(255,255,255,0.9);font-size:18px;line-height:1.6;margin-bottom:30px;font-family:Inter,sans-serif;" > Your introductory text goes here . < / p >
< a href = "#" style = "display:inline-block;padding:14px 32px;background:#fff;color:#667eea;font-size:16px;font-weight:600;text-decoration:none;border-radius:8px;font-family:Inter,sans-serif;" > Call to Action < / a >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-star' }
} ) ;
// Features Section
blockManager . add ( 'features-section' , {
label : 'Features Grid' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#f9fafb;">
< div style = "max-width:1200px;margin:0 auto;" >
< h2 style = "text-align:center;font-size:36px;font-weight:700;margin-bottom:50px;color:#1f2937;font-family:Inter,sans-serif;" > Features < / h 2 >
< div style = "display:flex;flex-wrap:wrap;gap:30px;justify-content:center;" >
< div style = "flex:1;min-width:280px;max-width:350px;padding:30px;background:#fff;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.1);" >
< div style = "width:50px;height:50px;background:#3b82f6;border-radius:10px;margin-bottom:20px;" > < / d i v >
< h3 style = "font-size:20px;font-weight:600;margin-bottom:12px;color:#1f2937;font-family:Inter,sans-serif;" > Feature One < / h 3 >
< p style = "color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;" > Description of your first amazing feature goes here . < / p >
< / d i v >
< div style = "flex:1;min-width:280px;max-width:350px;padding:30px;background:#fff;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.1);" >
< div style = "width:50px;height:50px;background:#10b981;border-radius:10px;margin-bottom:20px;" > < / d i v >
< h3 style = "font-size:20px;font-weight:600;margin-bottom:12px;color:#1f2937;font-family:Inter,sans-serif;" > Feature Two < / h 3 >
< p style = "color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;" > Description of your second amazing feature goes here . < / p >
< / d i v >
< div style = "flex:1;min-width:280px;max-width:350px;padding:30px;background:#fff;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.1);" >
< div style = "width:50px;height:50px;background:#f59e0b;border-radius:10px;margin-bottom:20px;" > < / d i v >
< h3 style = "font-size:20px;font-weight:600;margin-bottom:12px;color:#1f2937;font-family:Inter,sans-serif;" > Feature Three < / h 3 >
< p style = "color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;" > Description of your third amazing feature goes here . < / p >
< / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-th-large' }
} ) ;
// Testimonials Section
blockManager . add ( 'testimonials-section' , {
label : 'Testimonials' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#ffffff;">
< div style = "max-width:1200px;margin:0 auto;" >
< h2 style = "text-align:center;font-size:36px;font-weight:700;margin-bottom:16px;color:#1f2937;font-family:Inter,sans-serif;" > What People Say < / h 2 >
< p style = "text-align:center;font-size:18px;color:#6b7280;margin-bottom:50px;max-width:600px;margin-left:auto;margin-right:auto;font-family:Inter,sans-serif;" > Hear from our satisfied customers < / p >
< div style = "display:flex;flex-wrap:wrap;gap:30px;justify-content:center;" >
< div style = "flex:1;min-width:300px;max-width:380px;padding:30px;background:#f9fafb;border-radius:12px;" >
< div style = "display:flex;gap:4px;margin-bottom:16px;" >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< / d i v >
< p style = "color:#374151;line-height:1.7;font-size:16px;margin-bottom:20px;font-family:Inter,sans-serif;font-style:italic;" > "This product has completely transformed how we work. The results speak for themselves." < / p >
< div style = "display:flex;align-items:center;gap:12px;" >
< div style = "width:48px;height:48px;background:#3b82f6;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-family:Inter,sans-serif;" > JD < / d i v >
< div >
< div style = "font-weight:600;color:#1f2937;font-family:Inter,sans-serif;" > John Doe < / d i v >
< div style = "font-size:14px;color:#6b7280;font-family:Inter,sans-serif;" > CEO , Company Inc < / d i v >
< / d i v >
< / d i v >
< / d i v >
< div style = "flex:1;min-width:300px;max-width:380px;padding:30px;background:#f9fafb;border-radius:12px;" >
< div style = "display:flex;gap:4px;margin-bottom:16px;" >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< / d i v >
< p style = "color:#374151;line-height:1.7;font-size:16px;margin-bottom:20px;font-family:Inter,sans-serif;font-style:italic;" > "Exceptional quality and outstanding customer service. I couldn't be happier with my experience." < / p >
< div style = "display:flex;align-items:center;gap:12px;" >
< div style = "width:48px;height:48px;background:#10b981;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-family:Inter,sans-serif;" > JS < / d i v >
< div >
< div style = "font-weight:600;color:#1f2937;font-family:Inter,sans-serif;" > Jane Smith < / d i v >
< div style = "font-size:14px;color:#6b7280;font-family:Inter,sans-serif;" > Designer , Studio Co < / d i v >
< / d i v >
< / d i v >
< / d i v >
< div style = "flex:1;min-width:300px;max-width:380px;padding:30px;background:#f9fafb;border-radius:12px;" >
< div style = "display:flex;gap:4px;margin-bottom:16px;" >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< span style = "color:#f59e0b;font-size:20px;" > ★ < / s p a n >
< / d i v >
< p style = "color:#374151;line-height:1.7;font-size:16px;margin-bottom:20px;font-family:Inter,sans-serif;font-style:italic;" > "A game-changer for our business. The ROI has been incredible from day one." < / p >
< div style = "display:flex;align-items:center;gap:12px;" >
< div style = "width:48px;height:48px;background:#8b5cf6;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:600;font-family:Inter,sans-serif;" > MB < / d i v >
< div >
< div style = "font-weight:600;color:#1f2937;font-family:Inter,sans-serif;" > Mike Brown < / d i v >
< div style = "font-size:14px;color:#6b7280;font-family:Inter,sans-serif;" > Founder , StartupXYZ < / d i v >
< / d i v >
< / d i v >
< / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-comments' }
} ) ;
// Pricing Section
blockManager . add ( 'pricing-section' , {
label : 'Pricing Table' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#f9fafb;">
< div style = "max-width:1200px;margin:0 auto;" >
< h2 style = "text-align:center;font-size:36px;font-weight:700;margin-bottom:16px;color:#1f2937;font-family:Inter,sans-serif;" > Simple Pricing < / h 2 >
< p style = "text-align:center;font-size:18px;color:#6b7280;margin-bottom:50px;font-family:Inter,sans-serif;" > Choose the plan that ' s right for you < / p >
< div style = "display:flex;flex-wrap:wrap;gap:30px;justify-content:center;align-items:stretch;" >
< div style = "flex:1;min-width:280px;max-width:350px;padding:40px 30px;background:#fff;border-radius:16px;border:2px solid #e5e7eb;text-align:center;" >
< h3 style = "font-size:24px;font-weight:600;margin-bottom:8px;color:#1f2937;font-family:Inter,sans-serif;" > Starter < / h 3 >
< p style = "font-size:14px;color:#6b7280;margin-bottom:24px;font-family:Inter,sans-serif;" > Perfect for individuals < / p >
< div style = "margin-bottom:24px;" >
< span style = "font-size:48px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > $9 < / s p a n >
< span style = "font-size:16px;color:#6b7280;font-family:Inter,sans-serif;" > / m o n t h < / s p a n >
< / d i v >
< ul style = "list-style:none;padding:0;margin:0 0 30px 0;text-align:left;" >
< li style = "padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;" > ✓ 5 Projects < / l i >
< li style = "padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;" > ✓ Basic Support < / l i >
< li style = "padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;" > ✓ 1 GB Storage < / l i >
< li style = "padding:12px 0;color:#374151;font-family:Inter,sans-serif;" > ✓ Community Access < / l i >
< / u l >
< a href = "#" style = "display:block;padding:14px 24px;background:#fff;color:#3b82f6;font-size:16px;font-weight:600;text-decoration:none;border-radius:8px;border:2px solid #3b82f6;font-family:Inter,sans-serif;" > Get Started < / a >
< / d i v >
< div style = "flex:1;min-width:280px;max-width:350px;padding:40px 30px;background:#3b82f6;border-radius:16px;text-align:center;transform:scale(1.05);" >
< div style = "background:#2563eb;color:#fff;font-size:12px;font-weight:600;padding:6px 16px;border-radius:20px;display:inline-block;margin-bottom:16px;font-family:Inter,sans-serif;" > MOST POPULAR < / d i v >
< h3 style = "font-size:24px;font-weight:600;margin-bottom:8px;color:#fff;font-family:Inter,sans-serif;" > Professional < / h 3 >
< p style = "font-size:14px;color:rgba(255,255,255,0.8);margin-bottom:24px;font-family:Inter,sans-serif;" > Best for growing teams < / p >
< div style = "margin-bottom:24px;" >
< span style = "font-size:48px;font-weight:700;color:#fff;font-family:Inter,sans-serif;" > $29 < / s p a n >
< span style = "font-size:16px;color:rgba(255,255,255,0.8);font-family:Inter,sans-serif;" > / m o n t h < / s p a n >
< / d i v >
< ul style = "list-style:none;padding:0;margin:0 0 30px 0;text-align:left;" >
< li style = "padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.2);color:#fff;font-family:Inter,sans-serif;" > ✓ Unlimited Projects < / l i >
< li style = "padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.2);color:#fff;font-family:Inter,sans-serif;" > ✓ Priority Support < / l i >
< li style = "padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.2);color:#fff;font-family:Inter,sans-serif;" > ✓ 10 GB Storage < / l i >
< li style = "padding:12px 0;color:#fff;font-family:Inter,sans-serif;" > ✓ Advanced Analytics < / l i >
< / u l >
< a href = "#" style = "display:block;padding:14px 24px;background:#fff;color:#3b82f6;font-size:16px;font-weight:600;text-decoration:none;border-radius:8px;font-family:Inter,sans-serif;" > Get Started < / a >
< / d i v >
< div style = "flex:1;min-width:280px;max-width:350px;padding:40px 30px;background:#fff;border-radius:16px;border:2px solid #e5e7eb;text-align:center;" >
< h3 style = "font-size:24px;font-weight:600;margin-bottom:8px;color:#1f2937;font-family:Inter,sans-serif;" > Enterprise < / h 3 >
< p style = "font-size:14px;color:#6b7280;margin-bottom:24px;font-family:Inter,sans-serif;" > For large organizations < / p >
< div style = "margin-bottom:24px;" >
< span style = "font-size:48px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > $99 < / s p a n >
< span style = "font-size:16px;color:#6b7280;font-family:Inter,sans-serif;" > / m o n t h < / s p a n >
< / d i v >
< ul style = "list-style:none;padding:0;margin:0 0 30px 0;text-align:left;" >
< li style = "padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;" > ✓ Everything in Pro < / l i >
< li style = "padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;" > ✓ Dedicated Support < / l i >
< li style = "padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;" > ✓ Unlimited Storage < / l i >
< li style = "padding:12px 0;color:#374151;font-family:Inter,sans-serif;" > ✓ Custom Integrations < / l i >
< / u l >
< a href = "#" style = "display:block;padding:14px 24px;background:#fff;color:#3b82f6;font-size:16px;font-weight:600;text-decoration:none;border-radius:8px;border:2px solid #3b82f6;font-family:Inter,sans-serif;" > Contact Sales < / a >
< / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-credit-card' }
} ) ;
// Contact Section
blockManager . add ( 'contact-section' , {
label : 'Contact Section' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#ffffff;">
< div style = "max-width:1200px;margin:0 auto;" >
< div style = "display:flex;flex-wrap:wrap;gap:60px;" >
< div style = "flex:1;min-width:300px;" >
< h2 style = "font-size:36px;font-weight:700;margin-bottom:16px;color:#1f2937;font-family:Inter,sans-serif;" > Get in Touch < / h 2 >
< p style = "font-size:18px;color:#6b7280;line-height:1.7;margin-bottom:30px;font-family:Inter,sans-serif;" > Have questions ? We 'd love to hear from you. Send us a message and we' ll respond as soon as possible . < / p >
< div style = "margin-bottom:24px;" >
< div style = "display:flex;align-items:center;gap:16px;margin-bottom:20px;" >
< div style = "width:48px;height:48px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#3b82f6;font-size:20px;" > 📍 < / d i v >
< div >
< div style = "font-weight:600;color:#1f2937;font-family:Inter,sans-serif;" > Address < / d i v >
< div style = "color:#6b7280;font-family:Inter,sans-serif;" > 123 Business Street , City , ST 12345 < / d i v >
< / d i v >
< / d i v >
< div style = "display:flex;align-items:center;gap:16px;margin-bottom:20px;" >
< div style = "width:48px;height:48px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#3b82f6;font-size:20px;" > 📧 < / d i v >
< div >
< div style = "font-weight:600;color:#1f2937;font-family:Inter,sans-serif;" > Email < / d i v >
< div style = "color:#6b7280;font-family:Inter,sans-serif;" > hello @ example . com < / d i v >
< / d i v >
< / d i v >
< div style = "display:flex;align-items:center;gap:16px;" >
< div style = "width:48px;height:48px;background:#eff6ff;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#3b82f6;font-size:20px;" > 📞 < / d i v >
< div >
< div style = "font-weight:600;color:#1f2937;font-family:Inter,sans-serif;" > Phone < / d i v >
< div style = "color:#6b7280;font-family:Inter,sans-serif;" > ( 555 ) 123 - 4567 < / d i v >
< / d i v >
< / d i v >
< / d i v >
< / d i v >
< div style = "flex:1;min-width:300px;background:#f9fafb;padding:40px;border-radius:16px;" >
< form >
< div style = "margin-bottom:20px;" >
< label style = "display:block;font-weight:500;margin-bottom:8px;color:#374151;font-family:Inter,sans-serif;" > Name < / l a b e l >
< input type = "text" placeholder = "Your name" style = "width:100%;padding:14px 16px;border:1px solid #d1d5db;border-radius:8px;font-size:16px;font-family:Inter,sans-serif;box-sizing:border-box;" >
< / d i v >
< div style = "margin-bottom:20px;" >
< label style = "display:block;font-weight:500;margin-bottom:8px;color:#374151;font-family:Inter,sans-serif;" > Email < / l a b e l >
< input type = "email" placeholder = "your@email.com" style = "width:100%;padding:14px 16px;border:1px solid #d1d5db;border-radius:8px;font-size:16px;font-family:Inter,sans-serif;box-sizing:border-box;" >
< / d i v >
< div style = "margin-bottom:20px;" >
< label style = "display:block;font-weight:500;margin-bottom:8px;color:#374151;font-family:Inter,sans-serif;" > Message < / l a b e l >
< textarea placeholder = "How can we help?" rows = "4" style = "width:100%;padding:14px 16px;border:1px solid #d1d5db;border-radius:8px;font-size:16px;font-family:Inter,sans-serif;resize:vertical;box-sizing:border-box;" > < / t e x t a r e a >
< / d i v >
< button type = "submit" style = "width:100%;padding:16px 24px;background:#3b82f6;color:#fff;font-size:16px;font-weight:600;border:none;border-radius:8px;cursor:pointer;font-family:Inter,sans-serif;" > Send Message < / b u t t o n >
< / f o r m >
< / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-envelope' }
} ) ;
// Call to Action Section
blockManager . add ( 'cta-section' , {
label : 'Call to Action' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:linear-gradient(135deg,#3b82f6 0%,#8b5cf6 100%);">
< div style = "max-width:800px;margin:0 auto;text-align:center;" >
< h2 style = "font-size:40px;font-weight:700;margin-bottom:16px;color:#fff;font-family:Inter,sans-serif;" > Ready to Get Started ? < / h 2 >
< p style = "font-size:20px;color:rgba(255,255,255,0.9);line-height:1.6;margin-bottom:40px;font-family:Inter,sans-serif;" > Join thousands of satisfied customers and take your business to the next level . < / p >
< div style = "display:flex;gap:16px;justify-content:center;flex-wrap:wrap;" >
< a href = "#" style = "display:inline-block;padding:16px 40px;background:#fff;color:#3b82f6;font-size:18px;font-weight:600;text-decoration:none;border-radius:8px;font-family:Inter,sans-serif;" > Start Free Trial < / a >
< a href = "#" style = "display:inline-block;padding:16px 40px;background:transparent;color:#fff;font-size:18px;font-weight:600;text-decoration:none;border-radius:8px;border:2px solid rgba(255,255,255,0.5);font-family:Inter,sans-serif;" > Learn More < / a >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-bullhorn' }
} ) ;
// Text Block
blockManager . add ( 'text-block' , {
label : 'Text' ,
category : 'Basic' ,
content : '<p style="font-family:Inter,sans-serif;font-size:16px;line-height:1.6;color:#374151;">Insert your text here. You can edit this content directly.</p>' ,
attributes : { class : 'gjs-fonts gjs-f-text' }
} ) ;
// Heading Block
blockManager . add ( 'heading' , {
label : 'Heading' ,
category : 'Basic' ,
content : '<h2 style="font-family:Inter,sans-serif;font-size:32px;font-weight:700;color:#1f2937;">Heading</h2>' ,
attributes : { class : 'fa fa-header' }
} ) ;
// Button Block
blockManager . add ( 'button-block' , {
label : 'Button' ,
category : 'Basic' ,
content : '<a href="#" style="display:inline-block;padding:12px 24px;background:#3b82f6;color:#fff;font-size:16px;font-weight:500;text-decoration:none;border-radius:6px;font-family:Inter,sans-serif;">Button</a>' ,
attributes : { class : 'fa fa-link' }
} ) ;
// Divider Block - Resizable with height control
blockManager . add ( 'divider' , {
label : 'Divider' ,
category : 'Basic' ,
content : {
tagName : 'hr' ,
style : {
'border' : 'none' ,
'border-top' : '2px solid #e5e7eb' ,
'margin' : '20px 0' ,
'width' : '100%' ,
'height' : '0'
} ,
resizable : {
tl : 0 , tc : 0 , tr : 0 ,
cl : 1 , cr : 1 ,
bl : 0 , bc : 0 , br : 0 ,
keyWidth : 'width'
}
} ,
attributes : { class : 'fa fa-minus' }
} ) ;
// Spacer Block
blockManager . add ( 'spacer' , {
label : 'Spacer' ,
category : 'Basic' ,
content : '<div style="height:50px;"></div>' ,
attributes : { class : 'fa fa-arrows-alt-v' }
} ) ;
// Anchor Point Block
blockManager . add ( 'anchor-point' , {
label : 'Anchor Point' ,
category : 'Basic' ,
content : ` <div data-anchor="true" id="anchor-1" class="editor-anchor">
< span class = "anchor-icon" > ⚓ < / s p a n >
< input type = "text" class = "anchor-name-input" value = "anchor-1" placeholder = "anchor-name" / >
< / d i v > ` ,
attributes : { class : 'fa fa-anchor' }
} ) ;
// PDF / File Embed Block
blockManager . add ( 'file-embed' , {
label : 'File / PDF' ,
category : 'Media' ,
content : ` <div class="file-embed-wrapper" data-file-embed="true" style="width:100%;max-width:800px;margin:0 auto;">
< iframe class = "file-embed-frame" src = "" style = "width:100%;height:600px;border:1px solid #e5e7eb;border-radius:8px;display:none;" title = "Embedded file" > < / i f r a m e >
2026-03-01 14:13:02 -08:00
< a class = "file-download-card" href = "#" download style = "display:none;text-decoration:none;color:inherit;border:1px solid #e5e7eb;border-radius:8px;padding:20px 24px;background:#f9fafb;font-family:Inter,sans-serif;align-items:center;gap:16px;" >
< svg style = "width:40px;height:40px;flex-shrink:0;" viewBox = "0 0 24 24" fill = "none" stroke = "#6b7280" stroke - width = "1.5" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" > < / p a t h > < p o l y l i n e p o i n t s = " 1 4 2 1 4 8 2 0 8 " > < / p o l y l i n e > < l i n e x 1 = " 1 2 " y 1 = " 1 8 " x 2 = " 1 2 " y 2 = " 1 2 " > < / l i n e > < p o l y l i n e p o i n t s = " 9 1 5 1 2 1 8 1 5 1 5 " > < / p o l y l i n e > < / s v g >
< div style = "flex:1;min-width:0;" >
< div class = "file-download-name" style = "font-size:15px;font-weight:500;color:#1f2937;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" > File < / d i v >
< div class = "file-download-hint" style = "font-size:12px;color:#6b7280;margin-top:2px;" > Click to download < / d i v >
< / d i v >
< svg style = "width:24px;height:24px;flex-shrink:0;" viewBox = "0 0 24 24" fill = "none" stroke = "#3b82f6" stroke - width = "2" stroke - linecap = "round" stroke - linejoin = "round" > < path d = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" > < / p a t h > < p o l y l i n e p o i n t s = " 7 1 0 1 2 1 5 1 7 1 0 " > < / p o l y l i n e > < l i n e x 1 = " 1 2 " y 1 = " 1 5 " x 2 = " 1 2 " y 2 = " 3 " > < / l i n e > < / s v g >
< / a >
2026-02-28 19:25:42 +00:00
< div class = "file-embed-placeholder" style = "width:100%;height:600px;border:2px dashed #d1d5db;border-radius:8px;display:flex;align-items:center;justify-content:center;background:#f9fafb;font-family:Inter,sans-serif;color:#6b7280;" >
< div style = "text-align:center;" >
< div style = "font-size:48px;margin-bottom:10px;" > 📄 < / d i v >
< div style = "font-size:14px;" > Select this element , then enter File URL in Settings < / d i v >
2026-03-01 14:13:02 -08:00
< div style = "font-size:12px;margin-top:4px;opacity:0.7;" > Supports PDF , DOC , and other files < / d i v >
2026-02-28 19:25:42 +00:00
< / d i v >
< / d i v >
< / d i v > ` ,
attributes : { class : 'fa fa-file-pdf' }
} ) ;
// Text Box Block (for overlaying on backgrounds)
blockManager . add ( 'text-box' , {
label : 'Text Box' ,
category : 'Basic' ,
content : {
tagName : 'div' ,
attributes : { class : 'text-box' } ,
style : {
'padding' : '24px' ,
'background' : 'rgba(255,255,255,0.95)' ,
'border-radius' : '8px' ,
'max-width' : '600px' ,
'box-shadow' : '0 4px 6px rgba(0,0,0,0.1)'
} ,
components : [
{
tagName : 'h3' ,
style : {
'color' : '#1f2937' ,
'font-size' : '24px' ,
'font-weight' : '600' ,
'margin-bottom' : '12px' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Text Box Title'
} ,
{
tagName : 'p' ,
style : {
'color' : '#4b5563' ,
'font-size' : '16px' ,
'line-height' : '1.6' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'Add your content here. This text box can be placed over images or video backgrounds.'
}
]
} ,
attributes : { class : 'fa fa-file-text' }
} ) ;
// ==========================================
// Enhanced Block Library
// ==========================================
// Image Gallery
blockManager . add ( 'image-gallery' , {
label : 'Image Gallery' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#ffffff;" role="region" aria-label="Image Gallery">
< div style = "max-width:1200px;margin:0 auto;" >
< h2 style = "text-align:center;font-size:36px;font-weight:700;margin-bottom:50px;color:#1f2937;font-family:Inter,sans-serif;" > Gallery < / h 2 >
< div style = "display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px;" >
< div style = "border-radius:12px;overflow:hidden;aspect-ratio:4/3;background:#e5e7eb;position:relative;" >
< img src = "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=600&q=80" alt = "Gallery image 1" loading = "lazy" style = "width:100%;height:100%;object-fit:cover;" >
< / d i v >
< div style = "border-radius:12px;overflow:hidden;aspect-ratio:4/3;background:#e5e7eb;position:relative;" >
< img src = "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=600&q=80" alt = "Gallery image 2" loading = "lazy" style = "width:100%;height:100%;object-fit:cover;" >
< / d i v >
< div style = "border-radius:12px;overflow:hidden;aspect-ratio:4/3;background:#e5e7eb;position:relative;" >
< img src = "https://images.unsplash.com/photo-1447752875215-b2761acb3c5d?w=600&q=80" alt = "Gallery image 3" loading = "lazy" style = "width:100%;height:100%;object-fit:cover;" >
< / d i v >
< div style = "border-radius:12px;overflow:hidden;aspect-ratio:4/3;background:#e5e7eb;position:relative;" >
< img src = "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=600&q=80" alt = "Gallery image 4" loading = "lazy" style = "width:100%;height:100%;object-fit:cover;" >
< / d i v >
< div style = "border-radius:12px;overflow:hidden;aspect-ratio:4/3;background:#e5e7eb;position:relative;" >
< img src = "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=600&q=80" alt = "Gallery image 5" loading = "lazy" style = "width:100%;height:100%;object-fit:cover;" >
< / d i v >
< div style = "border-radius:12px;overflow:hidden;aspect-ratio:4/3;background:#e5e7eb;position:relative;" >
< img src = "https://images.unsplash.com/photo-1472214103451-9374bd1c798e?w=600&q=80" alt = "Gallery image 6" loading = "lazy" style = "width:100%;height:100%;object-fit:cover;" >
< / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-th' }
} ) ;
// FAQ Accordion
blockManager . add ( 'faq-section' , {
label : 'FAQ Accordion' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#f9fafb;" role="region" aria-label="Frequently Asked Questions">
< div style = "max-width:800px;margin:0 auto;" >
< h2 style = "text-align:center;font-size:36px;font-weight:700;margin-bottom:50px;color:#1f2937;font-family:Inter,sans-serif;" > Frequently Asked Questions < / h 2 >
< div style = "display:flex;flex-direction:column;gap:16px;" >
< details style = "background:#ffffff;border-radius:12px;padding:24px;border:1px solid #e5e7eb;" >
< summary style = "font-size:18px;font-weight:600;color:#1f2937;cursor:pointer;font-family:Inter,sans-serif;" > What is your return policy ? < / s u m m a r y >
< p style = "margin-top:16px;color:#6b7280;line-height:1.7;font-family:Inter,sans-serif;" > We offer a 30 - day money - back guarantee on all purchases . If you ' re not satisfied , contact our support team for a full refund . < / p >
< / d e t a i l s >
< details style = "background:#ffffff;border-radius:12px;padding:24px;border:1px solid #e5e7eb;" >
< summary style = "font-size:18px;font-weight:600;color:#1f2937;cursor:pointer;font-family:Inter,sans-serif;" > How long does shipping take ? < / s u m m a r y >
< p style = "margin-top:16px;color:#6b7280;line-height:1.7;font-family:Inter,sans-serif;" > Standard shipping takes 5 - 7 business days . Express shipping is available for 2 - 3 business day delivery . < / p >
< / d e t a i l s >
< details style = "background:#ffffff;border-radius:12px;padding:24px;border:1px solid #e5e7eb;" >
< summary style = "font-size:18px;font-weight:600;color:#1f2937;cursor:pointer;font-family:Inter,sans-serif;" > Do you offer customer support ? < / s u m m a r y >
< p style = "margin-top:16px;color:#6b7280;line-height:1.7;font-family:Inter,sans-serif;" > Yes ! Our support team is available 24 / 7 via email and live chat . Phone support is available Mon - Fri , 9 am - 5 pm EST . < / p >
< / d e t a i l s >
< details style = "background:#ffffff;border-radius:12px;padding:24px;border:1px solid #e5e7eb;" >
< summary style = "font-size:18px;font-weight:600;color:#1f2937;cursor:pointer;font-family:Inter,sans-serif;" > Can I cancel my subscription ? < / s u m m a r y >
< p style = "margin-top:16px;color:#6b7280;line-height:1.7;font-family:Inter,sans-serif;" > You can cancel your subscription at any time from your account settings . No cancellation fees apply . < / p >
< / d e t a i l s >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-question-circle' }
} ) ;
// Stats/Counter Section
blockManager . add ( 'stats-section' , {
label : 'Stats Counter' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:linear-gradient(135deg,#1f2937 0%,#111827 100%);" role="region" aria-label="Statistics">
< div style = "max-width:1200px;margin:0 auto;" >
< div style = "display:flex;flex-wrap:wrap;gap:40px;justify-content:center;text-align:center;" >
< div style = "flex:1;min-width:200px;" >
< div style = "font-size:48px;font-weight:700;color:#3b82f6;margin-bottom:8px;font-family:Inter,sans-serif;" > 10 K + < / d i v >
< div style = "font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;" > Happy Customers < / d i v >
< / d i v >
< div style = "flex:1;min-width:200px;" >
< div style = "font-size:48px;font-weight:700;color:#10b981;margin-bottom:8px;font-family:Inter,sans-serif;" > 500 + < / d i v >
< div style = "font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;" > Projects Completed < / d i v >
< / d i v >
< div style = "flex:1;min-width:200px;" >
< div style = "font-size:48px;font-weight:700;color:#f59e0b;margin-bottom:8px;font-family:Inter,sans-serif;" > 99 % < / d i v >
< div style = "font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;" > Satisfaction Rate < / d i v >
< / d i v >
< div style = "flex:1;min-width:200px;" >
< div style = "font-size:48px;font-weight:700;color:#ec4899;margin-bottom:8px;font-family:Inter,sans-serif;" > 24 / 7 < / d i v >
< div style = "font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;" > Support Available < / d i v >
< / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-bar-chart' }
} ) ;
// Team Grid
blockManager . add ( 'team-section' , {
label : 'Team Grid' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#ffffff;" role="region" aria-label="Our Team">
< div style = "max-width:1200px;margin:0 auto;" >
< h2 style = "text-align:center;font-size:36px;font-weight:700;margin-bottom:16px;color:#1f2937;font-family:Inter,sans-serif;" > Meet Our Team < / h 2 >
< p style = "text-align:center;font-size:18px;color:#6b7280;margin-bottom:50px;max-width:600px;margin-left:auto;margin-right:auto;font-family:Inter,sans-serif;" > The talented people behind our success < / p >
< div style = "display:flex;flex-wrap:wrap;gap:30px;justify-content:center;" >
< div style = "flex:1;min-width:250px;max-width:300px;text-align:center;" >
< div style = "width:120px;height:120px;border-radius:50%;background:linear-gradient(135deg,#3b82f6,#8b5cf6);margin:0 auto 20px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:36px;font-weight:700;font-family:Inter,sans-serif;" > AJ < / d i v >
< h3 style = "font-size:20px;font-weight:600;color:#1f2937;margin-bottom:4px;font-family:Inter,sans-serif;" > Alex Johnson < / h 3 >
< p style = "font-size:14px;color:#3b82f6;margin-bottom:12px;font-family:Inter,sans-serif;" > CEO & Founder < / p >
< p style = "font-size:14px;color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;" > Visionary leader with 15 + years of experience . < / p >
< / d i v >
< div style = "flex:1;min-width:250px;max-width:300px;text-align:center;" >
< div style = "width:120px;height:120px;border-radius:50%;background:linear-gradient(135deg,#10b981,#059669);margin:0 auto 20px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:36px;font-weight:700;font-family:Inter,sans-serif;" > SK < / d i v >
< h3 style = "font-size:20px;font-weight:600;color:#1f2937;margin-bottom:4px;font-family:Inter,sans-serif;" > Sarah Kim < / h 3 >
< p style = "font-size:14px;color:#3b82f6;margin-bottom:12px;font-family:Inter,sans-serif;" > Lead Designer < / p >
< p style = "font-size:14px;color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;" > Award - winning designer creating beautiful experiences . < / p >
< / d i v >
< div style = "flex:1;min-width:250px;max-width:300px;text-align:center;" >
< div style = "width:120px;height:120px;border-radius:50%;background:linear-gradient(135deg,#f59e0b,#d97706);margin:0 auto 20px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:36px;font-weight:700;font-family:Inter,sans-serif;" > MP < / d i v >
< h3 style = "font-size:20px;font-weight:600;color:#1f2937;margin-bottom:4px;font-family:Inter,sans-serif;" > Mike Patel < / h 3 >
< p style = "font-size:14px;color:#3b82f6;margin-bottom:12px;font-family:Inter,sans-serif;" > CTO < / p >
< p style = "font-size:14px;color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;" > Full - stack engineer building scalable systems . < / p >
< / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-users' }
} ) ;
// Newsletter Signup
blockManager . add ( 'newsletter-section' , {
label : 'Newsletter' ,
category : 'Sections' ,
content : ` <section style="padding:80px 20px;background:#f9fafb;" role="region" aria-label="Newsletter Signup">
< div style = "max-width:600px;margin:0 auto;text-align:center;" >
< h2 style = "font-size:32px;font-weight:700;margin-bottom:16px;color:#1f2937;font-family:Inter,sans-serif;" > Stay in the Loop < / h 2 >
< p style = "font-size:18px;color:#6b7280;margin-bottom:32px;line-height:1.6;font-family:Inter,sans-serif;" > Subscribe to our newsletter for the latest updates and exclusive offers . < / p >
< form style = "display:flex;gap:12px;max-width:480px;margin:0 auto;flex-wrap:wrap;justify-content:center;" >
< input type = "email" placeholder = "Enter your email" required aria - label = "Email address" style = "flex:1;min-width:240px;padding:14px 20px;border:2px solid #e5e7eb;border-radius:8px;font-size:16px;font-family:Inter,sans-serif;outline:none;box-sizing:border-box;" >
< button type = "submit" style = "padding:14px 32px;background:#3b82f6;color:#fff;font-size:16px;font-weight:600;border:none;border-radius:8px;cursor:pointer;font-family:Inter,sans-serif;white-space:nowrap;" > Subscribe < / b u t t o n >
< / f o r m >
< p style = "font-size:13px;color:#9ca3af;margin-top:16px;font-family:Inter,sans-serif;" > No spam , unsubscribe anytime . < / p >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-newspaper' }
} ) ;
// Logo Cloud / Trusted By
blockManager . add ( 'logo-cloud' , {
label : 'Logo Cloud' ,
category : 'Sections' ,
content : ` <section style="padding:60px 20px;background:#ffffff;" role="region" aria-label="Trusted by leading companies">
< div style = "max-width:1200px;margin:0 auto;text-align:center;" >
< p style = "font-size:14px;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:2px;margin-bottom:32px;font-family:Inter,sans-serif;" > Trusted by leading companies < / p >
< div style = "display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:40px;opacity:0.5;" >
< div style = "font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > Company 1 < / d i v >
< div style = "font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > Company 2 < / d i v >
< div style = "font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > Company 3 < / d i v >
< div style = "font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > Company 4 < / d i v >
< div style = "font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;" > Company 5 < / d i v >
< / d i v >
< / d i v >
< / s e c t i o n > ` ,
attributes : { class : 'fa fa-building' }
} ) ;
// ==========================================
// Custom Component Types for Better Editing
// ==========================================
// Helper to convert YouTube/Vimeo URLs to embed format
function convertToEmbedUrl ( url , isBackground = false ) {
if ( ! url ) return null ;
// YouTube: youtube.com/watch?v=ID or youtu.be/ID
const youtubeMatch = url . match ( /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/ ) ;
if ( youtubeMatch ) {
const videoId = youtubeMatch [ 1 ] ;
// Use youtube-nocookie.com to avoid Error 153 (referrer requirements)
// The referrerpolicy attribute in the iframe handles the referrer header
if ( isBackground ) {
// Background video: autoplay, muted, looped, no controls
return {
type : 'youtube' ,
url : ` https://www.youtube-nocookie.com/embed/ ${ videoId } ?autoplay=1&mute=1&loop=1&playlist= ${ videoId } &controls=0&modestbranding=1&rel=0&showinfo=0 `
} ;
} else {
// Regular video: user controls, no autoplay
return {
type : 'youtube' ,
url : ` https://www.youtube-nocookie.com/embed/ ${ videoId } ?rel=0 `
} ;
}
}
// Vimeo: vimeo.com/ID or player.vimeo.com/video/ID
const vimeoMatch = url . match ( /(?:vimeo\.com\/|player\.vimeo\.com\/video\/)(\d+)/ ) ;
if ( vimeoMatch ) {
if ( isBackground ) {
// Background video parameters
return { type : 'vimeo' , url : ` https://player.vimeo.com/video/ ${ vimeoMatch [ 1 ] } ?muted=1&loop=1&background=1&autoplay=1 ` } ;
} else {
// Regular video with controls
return { type : 'vimeo' , url : ` https://player.vimeo.com/video/ ${ vimeoMatch [ 1 ] } ` } ;
}
}
// Direct video file
if ( url . match ( /\.(mp4|webm|ogg|mov)(\?.*)?$/i ) ) {
return { type : 'file' , url : url } ;
}
// Assume it's an embed URL if nothing else matches
return { type : 'embed' , url : url } ;
}
// Helper to apply video URL to a video wrapper component
function applyVideoUrl ( component , url ) {
if ( ! url ) return ;
// Detect if this is a background video by checking for bg-video classes
const iframe = component . components ( ) . find ( c => c . getClasses ( ) . includes ( 'video-frame' ) || c . getClasses ( ) . includes ( 'bg-video-frame' ) ) ;
const isBackground = iframe && iframe . getClasses ( ) . includes ( 'bg-video-frame' ) ;
const result = convertToEmbedUrl ( url , isBackground ) ;
if ( ! result ) return ;
console . log ( 'Applying video URL:' , url ) ;
console . log ( 'Converted to:' , result . url ) ;
console . log ( 'Video type:' , result . type ) ;
console . log ( 'Is background:' , isBackground ) ;
// Find child elements (iframe already found above)
const video = component . components ( ) . find ( c => c . getClasses ( ) . includes ( 'video-player' ) || c . getClasses ( ) . includes ( 'bg-video-player' ) ) ;
const placeholder = component . components ( ) . find ( c => c . getClasses ( ) . includes ( 'video-placeholder' ) || c . getClasses ( ) . includes ( 'bg-video-placeholder' ) ) ;
if ( result . type === 'file' ) {
// Use HTML5 video
if ( video ) {
video . addAttributes ( { src : result . url } ) ;
video . addStyle ( { display : 'block' } ) ;
const videoEl = video . getEl ( ) ;
if ( videoEl ) {
videoEl . src = result . url ;
videoEl . style . display = 'block' ;
}
}
if ( iframe ) {
iframe . addStyle ( { display : 'none' } ) ;
const iframeEl = iframe . getEl ( ) ;
if ( iframeEl ) iframeEl . style . display = 'none' ;
}
} else {
// Use iframe for YouTube/Vimeo/embeds
if ( iframe ) {
iframe . addAttributes ( { src : result . url } ) ;
iframe . addStyle ( { display : 'block' } ) ;
const iframeEl = iframe . getEl ( ) ;
if ( iframeEl ) {
iframeEl . src = result . url ;
iframeEl . style . display = 'block' ;
}
}
if ( video ) {
video . addStyle ( { display : 'none' } ) ;
const videoEl = video . getEl ( ) ;
if ( videoEl ) videoEl . style . display = 'none' ;
}
}
2026-03-01 14:13:02 -08:00
// Hide placeholder in the model (so preview/export shows the video, not the placeholder)
2026-02-28 19:25:42 +00:00
if ( placeholder ) {
placeholder . addStyle ( { display : 'none' } ) ;
2026-03-01 14:13:02 -08:00
// In the editor canvas, GrapesJS renders <video>/<iframe> as <div>,
// so show visual feedback via the DOM only (doesn't affect export)
2026-02-28 19:25:42 +00:00
const placeholderEl = placeholder . getEl ( ) ;
2026-03-01 14:13:02 -08:00
if ( placeholderEl ) {
const filename = url . split ( '/' ) . pop ( ) . split ( '?' ) [ 0 ] ;
const icon = result . type === 'file' ? '🎬' : result . type === 'youtube' ? '▶️' : '🎥' ;
const label = result . type === 'file' ? filename : result . type === 'youtube' ? 'YouTube Video' : result . type === 'vimeo' ? 'Vimeo Video' : 'Embedded Video' ;
placeholderEl . innerHTML = ` <div style="text-align:center;"> ` +
` <div style="font-size:32px;margin-bottom:8px;"> ${ icon } </div> ` +
` <div style="font-size:13px;font-weight:600;"> ${ label } </div> ` +
` <div style="font-size:11px;opacity:0.6;margin-top:4px;">Video will play in Preview</div> ` +
` </div> ` ;
placeholderEl . style . display = 'flex' ;
}
2026-02-28 19:25:42 +00:00
}
}
// Video Wrapper Component (for Video block)
editor . DomComponents . addType ( 'video-wrapper' , {
isComponent : el => el . getAttribute && el . getAttribute ( 'data-video-wrapper' ) === 'true' ,
model : {
defaults : {
traits : [
{
type : 'text' ,
label : 'Video URL' ,
name : 'videoUrl' ,
placeholder : 'YouTube, Vimeo, or .mp4 URL'
} ,
2026-03-01 14:13:02 -08:00
{
type : 'button' ,
label : '' ,
text : '📁 Browse Video Assets' ,
full : true ,
command : ( editor ) => {
if ( ! window . assetManager ) return ;
window . assetManager . openBrowser ( 'video' ) . then ( asset => {
if ( ! asset ) return ;
const selected = editor . getSelected ( ) ;
if ( selected ) {
selected . addAttributes ( { videoUrl : asset . url } ) ;
selected . trigger ( 'change:attributes:videoUrl' ) ;
}
} ) ;
}
} ,
2026-02-28 19:25:42 +00:00
{
type : 'button' ,
label : '' ,
text : 'Apply Video' ,
full : true ,
command : ( editor ) => {
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
const url = selected . getAttributes ( ) . videoUrl ;
if ( ! url ) {
alert ( 'Please enter a Video URL first' ) ;
return ;
}
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
console . log ( 'Apply Video button clicked (regular video), URL:' , url ) ;
applyVideoUrl ( selected , url ) ;
alert ( 'Video applied! If you see an error, the video owner may have disabled embedding.' ) ;
}
}
]
} ,
init ( ) {
this . on ( 'change:attributes:videoUrl' , this . onVideoUrlChange ) ;
// Make child elements non-selectable so clicks bubble to wrapper
this . components ( ) . forEach ( child => {
child . set ( {
selectable : false ,
hoverable : false ,
editable : false ,
draggable : false ,
droppable : false ,
badgable : false ,
layerable : true // Still show in layers panel
} ) ;
} ) ;
} ,
onVideoUrlChange ( ) {
const url = this . getAttributes ( ) . videoUrl ;
applyVideoUrl ( this , url ) ;
}
}
} ) ;
// Background Video Wrapper Component (for Section Video BG)
// NOTE: No traits here! Users should set Video URL on the parent section element.
editor . DomComponents . addType ( 'bg-video-wrapper' , {
isComponent : el => el . getAttribute && el . getAttribute ( 'data-bg-video' ) === 'true' ,
model : {
defaults : {
draggable : false , // Prevent moving the video wrapper independently
selectable : false , // Don't let users select it directly
hoverable : false , // Don't highlight on hover
traits : [ ] // No traits - configured via parent section
} ,
init ( ) {
// Listen for videoUrl attribute changes (set by parent section)
this . on ( 'change:attributes:videoUrl' , this . onVideoUrlChange ) ;
} ,
onVideoUrlChange ( ) {
const url = this . getAttributes ( ) . videoUrl ;
applyVideoUrl ( this , url ) ;
}
}
} ) ;
// Video Section Component (outer section with video background)
editor . DomComponents . addType ( 'video-section' , {
isComponent : el => el . getAttribute && el . getAttribute ( 'data-video-section' ) === 'true' ,
model : {
defaults : {
traits : [
{
type : 'text' ,
label : 'Video URL' ,
name : 'videoUrl' ,
placeholder : 'YouTube, Vimeo, or .mp4 URL'
} ,
2026-03-01 14:13:02 -08:00
{
type : 'button' ,
label : '' ,
text : '📁 Browse Video Assets' ,
full : true ,
command : ( editor ) => {
if ( ! window . assetManager ) return ;
window . assetManager . openBrowser ( 'video' ) . then ( asset => {
if ( ! asset ) return ;
const selected = editor . getSelected ( ) ;
if ( selected ) {
selected . addAttributes ( { videoUrl : asset . url } ) ;
selected . trigger ( 'change:attributes:videoUrl' ) ;
}
} ) ;
}
} ,
2026-02-28 19:25:42 +00:00
{
type : 'button' ,
label : '' ,
text : 'Apply Video' ,
full : true ,
command : ( editor ) => {
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
const url = selected . getAttributes ( ) . videoUrl ;
if ( ! url ) {
alert ( 'Please enter a Video URL first' ) ;
return ;
}
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
console . log ( 'Apply Video button clicked, URL:' , url ) ;
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
// Find the bg-video-wrapper child
2026-03-01 14:13:02 -08:00
const videoWrapper = selected . components ( ) . find ( c =>
2026-02-28 19:25:42 +00:00
c . getAttributes ( ) [ 'data-bg-video' ] === 'true'
) ;
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
if ( videoWrapper ) {
videoWrapper . addAttributes ( { videoUrl : url } ) ;
applyVideoUrl ( videoWrapper , url ) ;
alert ( 'Video applied! If you see an error, the video owner may have disabled embedding.' ) ;
} else {
alert ( 'Error: Video wrapper not found' ) ;
}
}
}
]
} ,
init ( ) {
// Listen for attribute changes
this . on ( 'change:attributes:videoUrl' , ( ) => {
const url = this . getAttributes ( ) . videoUrl ;
if ( ! url ) return ;
console . log ( 'Video URL changed:' , url ) ;
// Find the bg-video-wrapper child and apply the video URL to it
const videoWrapper = this . components ( ) . find ( c =>
c . getAttributes ( ) [ 'data-bg-video' ] === 'true'
) ;
console . log ( 'Video wrapper found:' , ! ! videoWrapper ) ;
if ( videoWrapper ) {
videoWrapper . addAttributes ( { videoUrl : url } ) ;
applyVideoUrl ( videoWrapper , url ) ;
}
} ) ;
// Make child elements non-selectable so clicking them selects the parent section
setTimeout ( ( ) => {
this . components ( ) . forEach ( child => {
// Skip the content layer (users should be able to edit text)
const classes = child . getClasses ( ) ;
if ( ! classes . includes ( 'bg-content' ) ) {
child . set ( {
selectable : false ,
hoverable : false ,
editable : false
} ) ;
}
} ) ;
} , 100 ) ;
}
}
} ) ;
// Anchor Point Component
editor . DomComponents . addType ( 'anchor-point' , {
isComponent : el => el . getAttribute && el . getAttribute ( 'data-anchor' ) === 'true' ,
model : {
defaults : {
traits : [
{
type : 'text' ,
label : 'Anchor Name' ,
name : 'id' ,
placeholder : 'e.g. about-us'
}
]
} ,
init ( ) {
// Make child elements (icon and input) non-selectable
// This prevents users from accidentally selecting/deleting them
this . components ( ) . forEach ( child => {
child . set ( {
selectable : false ,
hoverable : false ,
editable : false ,
draggable : false ,
droppable : false ,
badgable : false ,
layerable : false ,
removable : false
} ) ;
} ) ;
}
}
} ) ;
// File Embed Component
editor . DomComponents . addType ( 'file-embed' , {
isComponent : el => el . getAttribute && el . getAttribute ( 'data-file-embed' ) === 'true' ,
model : {
defaults : {
traits : [
{
type : 'text' ,
label : 'File URL' ,
name : 'fileUrl' ,
placeholder : 'https://example.com/file.pdf'
} ,
{
type : 'number' ,
label : 'Height (px)' ,
name : 'frameHeight' ,
placeholder : '600' ,
default : 600
} ,
{
type : 'button' ,
label : '' ,
text : 'Apply File' ,
full : true ,
command : ( editor ) => {
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
const url = selected . getAttributes ( ) . fileUrl ;
const height = selected . getAttributes ( ) . frameHeight || 600 ;
if ( ! url ) {
alert ( 'Please enter a File URL first' ) ;
return ;
}
2026-03-01 14:13:02 -08:00
2026-02-28 19:25:42 +00:00
const iframe = selected . components ( ) . find ( c => c . getClasses ( ) . includes ( 'file-embed-frame' ) ) ;
const placeholder = selected . components ( ) . find ( c => c . getClasses ( ) . includes ( 'file-embed-placeholder' ) ) ;
2026-03-01 14:13:02 -08:00
const downloadCard = selected . components ( ) . find ( c => c . getClasses ( ) . includes ( 'file-download-card' ) ) ;
const isPdf = url . match ( /\.pdf(\?.*)?$/i ) ;
if ( isPdf ) {
// PDF: embed in iframe
if ( iframe ) {
iframe . addAttributes ( { src : url } ) ;
iframe . addStyle ( { display : 'block' , height : height + 'px' } ) ;
const el = iframe . getEl ( ) ;
if ( el ) { el . src = url ; el . style . display = 'block' ; el . style . height = height + 'px' ; }
}
if ( downloadCard ) {
downloadCard . addStyle ( { display : 'none' } ) ;
const el = downloadCard . getEl ( ) ;
if ( el ) el . style . display = 'none' ;
}
} else {
// Non-PDF: show download card with file name
if ( iframe ) {
iframe . addStyle ( { display : 'none' } ) ;
const el = iframe . getEl ( ) ;
if ( el ) el . style . display = 'none' ;
}
if ( downloadCard ) {
const fileName = decodeURIComponent ( url . split ( '/' ) . pop ( ) . split ( '?' ) [ 0 ] ) || 'File' ;
downloadCard . addAttributes ( { href : url } ) ;
downloadCard . addStyle ( { display : 'flex' } ) ;
const nameEl = downloadCard . components ( ) . find ( c => {
const inner = c . components ( ) ;
return inner && inner . find && inner . find ( ic => ic . getClasses ( ) . includes ( 'file-download-name' ) ) ;
} ) ;
if ( nameEl ) {
const nameSpan = nameEl . components ( ) . find ( ic => ic . getClasses ( ) . includes ( 'file-download-name' ) ) ;
if ( nameSpan ) {
nameSpan . components ( fileName ) ;
const el = nameSpan . getEl ( ) ;
if ( el ) el . textContent = fileName ;
}
}
const cardEl = downloadCard . getEl ( ) ;
if ( cardEl ) {
cardEl . href = url ;
cardEl . style . display = 'flex' ;
const nameNode = cardEl . querySelector ( '.file-download-name' ) ;
if ( nameNode ) nameNode . textContent = fileName ;
}
2026-02-28 19:25:42 +00:00
}
}
if ( placeholder ) {
placeholder . addStyle ( { display : 'none' } ) ;
const el = placeholder . getEl ( ) ;
if ( el ) el . style . display = 'none' ;
}
}
}
]
} ,
init ( ) {
// Make child elements non-selectable so clicks bubble to wrapper
this . components ( ) . forEach ( child => {
child . set ( {
selectable : false ,
hoverable : false ,
editable : false ,
draggable : false ,
droppable : false ,
badgable : false ,
layerable : false ,
removable : false
} ) ;
} ) ;
}
}
} ) ;
// Logo Component with image support
editor . DomComponents . addType ( 'site-logo' , {
isComponent : el => el . classList && el . classList . contains ( 'site-logo' ) ,
model : {
defaults : {
traits : [
{
type : 'text' ,
label : 'Logo Text' ,
name : 'logoText' ,
placeholder : 'SiteName'
} ,
{
type : 'text' ,
label : 'Logo Image URL' ,
name : 'logoImage' ,
placeholder : 'https://example.com/logo.png'
} ,
{
type : 'select' ,
label : 'Logo Mode' ,
name : 'logoMode' ,
options : [
{ id : 'text' , name : 'Text Only' } ,
{ id : 'image' , name : 'Image Only' } ,
{ id : 'both' , name : 'Image + Text' }
]
} ,
{
type : 'button' ,
label : '' ,
text : 'Apply Logo' ,
full : true ,
command : ( editor ) => {
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
const attrs = selected . getAttributes ( ) ;
const mode = attrs . logoMode || 'text' ;
const text = attrs . logoText || 'SiteName' ;
const imageUrl = attrs . logoImage || '' ;
// Clear existing children
selected . components ( ) . reset ( ) ;
if ( mode === 'image' && imageUrl ) {
selected . components ( ) . add ( {
tagName : 'img' ,
attributes : { src : imageUrl , alt : text } ,
style : { height : '40px' , width : 'auto' }
} ) ;
} else if ( mode === 'both' && imageUrl ) {
selected . components ( ) . add ( {
tagName : 'img' ,
attributes : { src : imageUrl , alt : text } ,
style : { height : '40px' , width : 'auto' }
} ) ;
selected . components ( ) . add ( {
tagName : 'span' ,
style : { 'font-size' : '20px' , 'font-weight' : '700' , 'color' : '#1f2937' , 'font-family' : 'Inter, sans-serif' } ,
content : text
} ) ;
} else {
// Text mode (default icon + text)
selected . components ( ) . add ( {
tagName : 'div' ,
style : { width : '40px' , height : '40px' , background : 'linear-gradient(135deg,#3b82f6 0%,#8b5cf6 100%)' , 'border-radius' : '8px' , display : 'flex' , 'align-items' : 'center' , 'justify-content' : 'center' } ,
components : [ { tagName : 'span' , style : { color : '#fff' , 'font-weight' : '700' , 'font-size' : '18px' , 'font-family' : 'Inter,sans-serif' } , content : text . charAt ( 0 ) . toUpperCase ( ) } ]
} ) ;
selected . components ( ) . add ( {
tagName : 'span' ,
style : { 'font-size' : '20px' , 'font-weight' : '700' , 'color' : '#1f2937' , 'font-family' : 'Inter, sans-serif' } ,
content : text
} ) ;
}
}
}
]
}
}
} ) ;
// ==========================================
// Device Switching
// ==========================================
const deviceButtons = {
desktop : document . getElementById ( 'device-desktop' ) ,
tablet : document . getElementById ( 'device-tablet' ) ,
mobile : document . getElementById ( 'device-mobile' )
} ;
function setDevice ( device ) {
// Update button states
Object . values ( deviceButtons ) . forEach ( btn => btn . classList . remove ( 'active' ) ) ;
deviceButtons [ device ] . classList . add ( 'active' ) ;
// Set device in editor
const deviceMap = {
desktop : 'Desktop' ,
tablet : 'Tablet' ,
mobile : 'Mobile'
} ;
editor . setDevice ( deviceMap [ device ] ) ;
// Force canvas refresh
editor . refresh ( ) ;
}
deviceButtons . desktop . addEventListener ( 'click' , ( ) => setDevice ( 'desktop' ) ) ;
deviceButtons . tablet . addEventListener ( 'click' , ( ) => setDevice ( 'tablet' ) ) ;
deviceButtons . mobile . addEventListener ( 'click' , ( ) => setDevice ( 'mobile' ) ) ;
// ==========================================
// Undo/Redo
// ==========================================
document . getElementById ( 'btn-undo' ) . addEventListener ( 'click' , ( ) => {
editor . UndoManager . undo ( ) ;
} ) ;
document . getElementById ( 'btn-redo' ) . addEventListener ( 'click' , ( ) => {
editor . UndoManager . redo ( ) ;
} ) ;
// ==========================================
// Clear Canvas
// ==========================================
document . getElementById ( 'btn-clear' ) . addEventListener ( 'click' , ( ) => {
if ( confirm ( 'Are you sure you want to clear the canvas and reset the project? This will delete all saved data and cannot be undone.' ) ) {
editor . DomComponents . clear ( ) ;
editor . CssComposer . clear ( ) ;
// Clear localStorage to prevent reloading old content
localStorage . removeItem ( STORAGE _KEY ) ;
localStorage . removeItem ( STORAGE _KEY + '-preview' ) ;
alert ( 'Canvas cleared! Refresh the page for a clean start.' ) ;
}
} ) ;
// ==========================================
// Preview
// ==========================================
document . getElementById ( 'btn-preview' ) . addEventListener ( 'click' , ( ) => {
// Get the HTML and CSS
const html = editor . getHtml ( ) ;
const css = editor . getCss ( ) ;
// Store for preview page
localStorage . setItem ( STORAGE _KEY + '-preview' , JSON . stringify ( { html , css } ) ) ;
// Open preview
window . open ( 'preview.html' , '_blank' ) ;
} ) ;
// ==========================================
// Panel Tabs
// ==========================================
// Left panel tabs (Blocks / Pages / Layers)
document . querySelectorAll ( '.panel-left .panel-tab' ) . forEach ( tab => {
tab . addEventListener ( 'click' , ( ) => {
const panel = tab . dataset . panel ;
// Update tab states
document . querySelectorAll ( '.panel-left .panel-tab' ) . forEach ( t => t . classList . remove ( 'active' ) ) ;
tab . classList . add ( 'active' ) ;
// Show/hide panels
document . getElementById ( 'blocks-container' ) . style . display = panel === 'blocks' ? 'block' : 'none' ;
document . getElementById ( 'pages-container' ) . style . display = panel === 'pages' ? 'block' : 'none' ;
document . getElementById ( 'layers-container' ) . style . display = panel === 'layers' ? 'block' : 'none' ;
document . getElementById ( 'assets-container' ) . style . display = panel === 'assets' ? 'block' : 'none' ;
} ) ;
} ) ;
// Right panel tabs (Styles / Settings)
document . querySelectorAll ( '.panel-right .panel-tab' ) . forEach ( tab => {
tab . addEventListener ( 'click' , ( ) => {
const panel = tab . dataset . panel ;
// Update tab states
document . querySelectorAll ( '.panel-right .panel-tab' ) . forEach ( t => t . classList . remove ( 'active' ) ) ;
tab . classList . add ( 'active' ) ;
// Show/hide panels
document . getElementById ( 'styles-container' ) . style . display = panel === 'styles' ? 'block' : 'none' ;
document . getElementById ( 'traits-container' ) . style . display = panel === 'traits' ? 'block' : 'none' ;
const headContainer = document . getElementById ( 'head-elements-container' ) ;
if ( headContainer ) headContainer . style . display = panel === 'head' ? 'block' : 'none' ;
} ) ;
} ) ;
// ==========================================
// Style Mode Toggle (Guided / Advanced)
// ==========================================
const modeGuided = document . getElementById ( 'mode-guided' ) ;
const modeAdvanced = document . getElementById ( 'mode-advanced' ) ;
const guidedStyles = document . getElementById ( 'guided-styles' ) ;
const advancedStyles = document . getElementById ( 'advanced-styles' ) ;
modeGuided . addEventListener ( 'click' , ( ) => {
modeGuided . classList . add ( 'active' ) ;
modeAdvanced . classList . remove ( 'active' ) ;
guidedStyles . style . display = 'flex' ;
advancedStyles . style . display = 'none' ;
} ) ;
modeAdvanced . addEventListener ( 'click' , ( ) => {
modeAdvanced . classList . add ( 'active' ) ;
modeGuided . classList . remove ( 'active' ) ;
advancedStyles . style . display = 'block' ;
guidedStyles . style . display = 'none' ;
} ) ;
// ==========================================
// Context-Aware Guided Style Controls
// ==========================================
// Element type definitions
const ELEMENT _TYPES = {
TEXT : [ 'span' , 'p' , 'h1' , 'h2' , 'h3' , 'h4' , 'h5' , 'h6' , 'label' ] ,
LINK : [ 'a' ] ,
DIVIDER : [ 'hr' ] ,
CONTAINER : [ 'div' , 'section' , 'article' , 'header' , 'footer' , 'nav' , 'main' , 'aside' ] ,
MEDIA : [ 'img' , 'video' , 'iframe' ] ,
FORM : [ 'form' , 'input' , 'textarea' , 'select' , 'button' ]
} ;
// Get element type category
function getElementType ( tagName ) {
const tag = tagName ? . toLowerCase ( ) ;
if ( ELEMENT _TYPES . TEXT . includes ( tag ) ) return 'text' ;
if ( ELEMENT _TYPES . LINK . includes ( tag ) ) return 'link' ;
if ( ELEMENT _TYPES . DIVIDER . includes ( tag ) ) return 'divider' ;
if ( ELEMENT _TYPES . MEDIA . includes ( tag ) ) return 'media' ;
if ( ELEMENT _TYPES . FORM . includes ( tag ) ) return 'form' ;
if ( ELEMENT _TYPES . CONTAINER . includes ( tag ) ) return 'container' ;
return 'other' ;
}
// Check if element is button-like (styled link)
function isButtonLike ( component ) {
if ( ! component ) return false ;
const tagName = component . get ( 'tagName' ) ? . toLowerCase ( ) ;
if ( tagName !== 'a' ) return false ;
const styles = component . getStyle ( ) ;
// Check if it has button-like styling
return styles [ 'display' ] === 'inline-block' ||
styles [ 'padding' ] ||
styles [ 'background' ] ||
styles [ 'background-color' ] ;
}
// UI Section references
const sections = {
noSelection : document . getElementById ( 'no-selection-msg' ) ,
link : document . getElementById ( 'section-link' ) ,
textColor : document . getElementById ( 'section-text-color' ) ,
headingLevel : document . getElementById ( 'section-heading-level' ) ,
htmlEditorToggle : document . getElementById ( 'section-html-editor-toggle' ) ,
htmlEditor : document . getElementById ( 'section-html-editor' ) ,
bgColor : document . getElementById ( 'section-bg-color' ) ,
bgGradient : document . getElementById ( 'section-bg-gradient' ) ,
bgImage : document . getElementById ( 'section-bg-image' ) ,
overlay : document . getElementById ( 'section-overlay' ) ,
dividerColor : document . getElementById ( 'section-divider-color' ) ,
font : document . getElementById ( 'section-font' ) ,
textSize : document . getElementById ( 'section-text-size' ) ,
fontWeight : document . getElementById ( 'section-font-weight' ) ,
spacing : document . getElementById ( 'section-spacing' ) ,
radius : document . getElementById ( 'section-radius' ) ,
thickness : document . getElementById ( 'section-thickness' ) ,
buttonStyle : document . getElementById ( 'section-button-style' ) ,
navLinks : document . getElementById ( 'section-nav-links' )
} ;
// Link input elements
const linkUrlInput = document . getElementById ( 'link-url-input' ) ;
const linkNewTabCheckbox = document . getElementById ( 'link-new-tab' ) ;
// Background image elements
const bgImageUrlInput = document . getElementById ( 'bg-image-url' ) ;
const bgSizeSelect = document . getElementById ( 'bg-size' ) ;
const bgPositionSelect = document . getElementById ( 'bg-position' ) ;
const removeBgImageBtn = document . getElementById ( 'remove-bg-image' ) ;
// Overlay elements
const overlayOpacitySlider = document . getElementById ( 'overlay-opacity' ) ;
const overlayOpacityValue = document . getElementById ( 'overlay-opacity-value' ) ;
// Navigation elements
const syncNavPagesBtn = document . getElementById ( 'sync-nav-pages' ) ;
const addNavLinkBtn = document . getElementById ( 'add-nav-link' ) ;
const navLinksList = document . getElementById ( 'nav-links-list' ) ;
// Current overlay state
let currentOverlayColor = '0,0,0' ;
let currentOverlayOpacity = 50 ;
// Check if element is an overlay
function isOverlay ( component ) {
if ( ! component ) return false ;
const classes = component . getClasses ( ) ;
return classes . includes ( 'bg-overlay' ) ;
}
// Check if element is a section with background
function isSectionWithBg ( component ) {
if ( ! component ) return false ;
const attrs = component . getAttributes ( ) ;
return attrs [ 'data-bg-section' ] === 'true' || attrs [ 'data-video-section' ] === 'true' ;
}
// Check if element is a navigation
function isNavigation ( component ) {
if ( ! component ) return false ;
const tagName = component . get ( 'tagName' ) ? . toLowerCase ( ) ;
const classes = component . getClasses ( ) ;
return tagName === 'nav' || classes . includes ( 'site-navbar' ) ;
}
// Hide all context sections
function hideAllSections ( ) {
Object . values ( sections ) . forEach ( section => {
if ( section ) section . style . display = 'none' ;
} ) ;
}
// Show sections based on element type
function showSectionsForElement ( component ) {
hideAllSections ( ) ;
if ( ! component ) {
sections . noSelection . style . display = 'block' ;
return ;
}
const tagName = component . get ( 'tagName' ) ;
const elementType = getElementType ( tagName ) ;
const isButton = isButtonLike ( component ) ;
// Check for special element types first
if ( isOverlay ( component ) ) {
// Overlay element - show only overlay controls
sections . overlay . style . display = 'block' ;
loadOverlayValues ( component ) ;
return ;
}
if ( isNavigation ( component ) ) {
// Navigation element - show navigation controls
sections . navLinks . style . display = 'block' ;
sections . bgColor . style . display = 'block' ;
sections . spacing . style . display = 'block' ;
loadNavLinks ( component ) ;
return ;
}
if ( isSectionWithBg ( component ) ) {
// Section with background - show background image controls
sections . bgImage . style . display = 'block' ;
sections . spacing . style . display = 'block' ;
loadBgImageValues ( component ) ;
return ;
}
// Show relevant sections based on element type
switch ( elementType ) {
case 'text' :
sections . textColor . style . display = 'block' ;
sections . font . style . display = 'block' ;
sections . textSize . style . display = 'block' ;
sections . fontWeight . style . display = 'block' ;
// Show heading level selector for headings
const currentTag = component . get ( 'tagName' ) ? . toLowerCase ( ) ;
if ( currentTag && currentTag . match ( /^h[1-6]$/ ) ) {
sections . headingLevel . style . display = 'block' ;
updateHeadingLevelButtons ( currentTag ) ;
}
break ;
case 'link' :
sections . link . style . display = 'block' ;
if ( isButton ) {
sections . buttonStyle . style . display = 'block' ;
sections . radius . style . display = 'block' ;
sections . spacing . style . display = 'block' ;
}
sections . textColor . style . display = 'block' ;
sections . font . style . display = 'block' ;
sections . textSize . style . display = 'block' ;
sections . fontWeight . style . display = 'block' ;
// Load current link values
loadLinkValues ( component ) ;
break ;
case 'divider' :
sections . dividerColor . style . display = 'block' ;
sections . thickness . style . display = 'block' ;
break ;
case 'container' :
sections . bgColor . style . display = 'block' ;
sections . bgGradient . style . display = 'block' ;
sections . bgImage . style . display = 'block' ;
sections . spacing . style . display = 'block' ;
sections . radius . style . display = 'block' ;
loadBgImageValues ( component ) ;
break ;
case 'media' :
sections . spacing . style . display = 'block' ;
sections . radius . style . display = 'block' ;
break ;
case 'form' :
if ( tagName ? . toLowerCase ( ) === 'button' ) {
sections . buttonStyle . style . display = 'block' ;
sections . link . style . display = 'block' ;
}
sections . bgColor . style . display = 'block' ;
sections . textColor . style . display = 'block' ;
sections . font . style . display = 'block' ;
sections . spacing . style . display = 'block' ;
sections . radius . style . display = 'block' ;
break ;
default :
// Show common controls for unknown elements
sections . bgColor . style . display = 'block' ;
sections . spacing . style . display = 'block' ;
sections . radius . style . display = 'block' ;
}
// Always show HTML editor toggle button for any selected element
sections . htmlEditorToggle . style . display = 'block' ;
}
// Load link values into the input
function loadLinkValues ( component ) {
if ( ! component ) return ;
const attrs = component . getAttributes ( ) ;
linkUrlInput . value = attrs . href || '' ;
linkNewTabCheckbox . checked = attrs . target === '_blank' ;
}
// Update heading level buttons to show active state
function updateHeadingLevelButtons ( currentTag ) {
const buttons = sections . headingLevel . querySelectorAll ( '.heading-level-btn' ) ;
buttons . forEach ( btn => {
const level = btn . getAttribute ( 'data-level' ) ;
if ( level === currentTag ) {
btn . classList . add ( 'active' ) ;
} else {
btn . classList . remove ( 'active' ) ;
}
} ) ;
}
// HTML Editor elements
const htmlEditorTextarea = document . getElementById ( 'html-editor-textarea' ) ;
const htmlEditorApply = document . getElementById ( 'html-editor-apply' ) ;
const htmlEditorCancel = document . getElementById ( 'html-editor-cancel' ) ;
const htmlEditorToggleBtn = document . getElementById ( 'html-editor-toggle-btn' ) ;
const htmlEditorClose = document . getElementById ( 'html-editor-close' ) ;
let originalHtml = '' ;
let currentEditingComponent = null ;
// Load HTML into editor
function loadHtmlEditor ( component ) {
if ( ! component ) return ;
currentEditingComponent = component ;
// Get the HTML of the selected component
const html = component . toHTML ( ) ;
htmlEditorTextarea . value = html ;
originalHtml = html ;
}
// Show HTML editor
function showHtmlEditor ( ) {
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
loadHtmlEditor ( selected ) ;
sections . htmlEditor . style . display = 'block' ;
sections . htmlEditorToggle . style . display = 'none' ;
}
// Hide HTML editor
function hideHtmlEditor ( ) {
sections . htmlEditor . style . display = 'none' ;
sections . htmlEditorToggle . style . display = 'block' ;
currentEditingComponent = null ;
}
// Toggle button click
htmlEditorToggleBtn . addEventListener ( 'click' , showHtmlEditor ) ;
// Close button click
htmlEditorClose . addEventListener ( 'click' , hideHtmlEditor ) ;
// Apply HTML changes
htmlEditorApply . addEventListener ( 'click' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
const newHtml = htmlEditorTextarea . value . trim ( ) ;
try {
// Replace the component with new HTML
const parent = selected . parent ( ) ;
const index = parent . components ( ) . indexOf ( selected ) ;
// Remove old component
selected . remove ( ) ;
// Add new component from HTML
parent . append ( newHtml , { at : index } ) ;
// Select the new component
const newComponent = parent . components ( ) . at ( index ) ;
if ( newComponent ) {
editor . select ( newComponent ) ;
}
// Hide editor after applying
hideHtmlEditor ( ) ;
} catch ( error ) {
alert ( 'Invalid HTML: ' + error . message ) ;
htmlEditorTextarea . value = originalHtml ;
}
} ) ;
// Cancel HTML changes
htmlEditorCancel . addEventListener ( 'click' , ( ) => {
htmlEditorTextarea . value = originalHtml ;
hideHtmlEditor ( ) ;
} ) ;
// Default font sizes for each heading level
const headingSizes = {
h1 : '48px' ,
h2 : '36px' ,
h3 : '28px' ,
h4 : '24px' ,
h5 : '20px' ,
h6 : '18px'
} ;
// Handle heading level button clicks
function setupHeadingLevelButtons ( ) {
const buttons = sections . headingLevel . querySelectorAll ( '.heading-level-btn' ) ;
buttons . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
const newLevel = btn . getAttribute ( 'data-level' ) ;
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
// Change the tag name
selected . set ( 'tagName' , newLevel ) ;
// Update font size to match heading level
const defaultSize = headingSizes [ newLevel ] ;
if ( defaultSize ) {
selected . addStyle ( { 'font-size' : defaultSize } ) ;
}
// Update button states
updateHeadingLevelButtons ( newLevel ) ;
} ) ;
} ) ;
}
// Load background image values
function loadBgImageValues ( component ) {
if ( ! component ) return ;
const styles = component . getStyle ( ) ;
const bgImage = styles [ 'background-image' ] || '' ;
const bgSize = styles [ 'background-size' ] || 'cover' ;
const bgPosition = styles [ 'background-position' ] || 'center' ;
// Extract URL from background-image
const urlMatch = bgImage . match ( /url\(['"]?([^'"]+)['"]?\)/ ) ;
bgImageUrlInput . value = urlMatch ? urlMatch [ 1 ] : '' ;
bgSizeSelect . value = bgSize ;
bgPositionSelect . value = bgPosition ;
}
// Load overlay values
function loadOverlayValues ( component ) {
if ( ! component ) return ;
const styles = component . getStyle ( ) ;
const bg = styles [ 'background' ] || '' ;
// Parse rgba value
const rgbaMatch = bg . match ( /rgba?\((\d+),\s*(\d+),\s*(\d+),?\s*([\d.]+)?\)/ ) ;
if ( rgbaMatch ) {
currentOverlayColor = ` ${ rgbaMatch [ 1 ] } , ${ rgbaMatch [ 2 ] } , ${ rgbaMatch [ 3 ] } ` ;
currentOverlayOpacity = rgbaMatch [ 4 ] ? Math . round ( parseFloat ( rgbaMatch [ 4 ] ) * 100 ) : 100 ;
overlayOpacitySlider . value = currentOverlayOpacity ;
overlayOpacityValue . textContent = currentOverlayOpacity + '%' ;
// Update active color button
document . querySelectorAll ( '.overlay-color' ) . forEach ( btn => {
btn . classList . toggle ( 'active' , btn . dataset . color === currentOverlayColor ) ;
} ) ;
}
}
// Load navigation links
function loadNavLinks ( component ) {
if ( ! component ) return ;
// Find the nav-links container within the nav
const linksContainer = component . components ( ) . find ( c => {
const classes = c . getClasses ( ) ;
return classes . includes ( 'nav-links' ) ;
} ) ;
if ( ! linksContainer ) {
navLinksList . innerHTML = '<p class="no-links-msg">No links container found</p>' ;
return ;
}
// Get all link components
const links = linksContainer . components ( ) . filter ( c => c . get ( 'tagName' ) ? . toLowerCase ( ) === 'a' ) ;
// Clear and rebuild list
navLinksList . innerHTML = '' ;
links . forEach ( ( link , index ) => {
const item = document . createElement ( 'div' ) ;
item . className = 'nav-link-item' ;
const textSpan = document . createElement ( 'span' ) ;
textSpan . className = 'nav-link-text' ;
textSpan . textContent = link . getEl ( ) ? . textContent || 'Link' ;
const deleteBtn = document . createElement ( 'button' ) ;
deleteBtn . className = 'nav-link-delete' ;
deleteBtn . innerHTML = '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>' ;
deleteBtn . title = 'Remove link' ;
deleteBtn . addEventListener ( 'click' , ( ) => {
link . remove ( ) ;
loadNavLinks ( component ) ;
} ) ;
item . appendChild ( textSpan ) ;
item . appendChild ( deleteBtn ) ;
navLinksList . appendChild ( item ) ;
} ) ;
if ( links . length === 0 ) {
navLinksList . innerHTML = '<p class="no-links-msg">No links in navigation</p>' ;
}
}
// Helper to apply style to selected component
function applyStyle ( property , value ) {
const selected = editor . getSelected ( ) ;
if ( selected ) {
selected . addStyle ( { [ property ] : value } ) ;
}
}
// ==========================================
// Color Preset Handlers
// ==========================================
// Text color presets
document . querySelectorAll ( '.color-preset.text-color' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'color' , btn . dataset . color ) ;
document . querySelectorAll ( '.text-color' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Background color presets
document . querySelectorAll ( '.color-preset.bg-color' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected ) {
// Remove gradient when applying solid color
selected . addStyle ( {
'background-color' : btn . dataset . color ,
'background-image' : 'none' ,
'background' : btn . dataset . color
} ) ;
}
document . querySelectorAll ( '.bg-color' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
document . querySelectorAll ( '.gradient-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Gradient presets
document . querySelectorAll ( '.gradient-preset' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
const gradient = btn . dataset . gradient ;
const selected = editor . getSelected ( ) ;
if ( selected ) {
if ( gradient === 'none' ) {
// Remove gradient
selected . addStyle ( {
'background-image' : 'none' ,
'background' : ''
} ) ;
} else {
// Apply gradient
selected . addStyle ( {
'background' : gradient ,
'background-image' : gradient
} ) ;
}
}
document . querySelectorAll ( '.gradient-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
document . querySelectorAll ( '.bg-color' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
if ( gradient !== 'none' ) {
btn . classList . add ( 'active' ) ;
}
} ) ;
} ) ;
// Divider color presets
document . querySelectorAll ( '.color-preset.divider-color' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'border-top-color' , btn . dataset . color ) ;
document . querySelectorAll ( '.divider-color' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Button background color presets
document . querySelectorAll ( '.color-preset.btn-bg-color' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'background-color' , btn . dataset . color ) ;
// Also update text color for contrast
const color = btn . dataset . color ;
if ( color === '#ffffff' || color === '#f9fafb' ) {
applyStyle ( 'color' , '#1f2937' ) ;
} else {
applyStyle ( 'color' , '#ffffff' ) ;
}
document . querySelectorAll ( '.btn-bg-color' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// ==========================================
// Font Preset Handlers
// ==========================================
document . querySelectorAll ( '.font-preset' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'font-family' , btn . dataset . font ) ;
document . querySelectorAll ( '.font-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Font weight presets
document . querySelectorAll ( '.weight-preset' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'font-weight' , btn . dataset . weight ) ;
document . querySelectorAll ( '.weight-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Text size presets
document . querySelectorAll ( '.size-preset' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'font-size' , btn . dataset . size ) ;
document . querySelectorAll ( '.size-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Spacing presets
document . querySelectorAll ( '.spacing-preset' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'padding' , btn . dataset . padding ) ;
document . querySelectorAll ( '.spacing-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Border radius presets
document . querySelectorAll ( '.radius-preset' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'border-radius' , btn . dataset . radius ) ;
document . querySelectorAll ( '.radius-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Line thickness presets (for dividers/hr)
document . querySelectorAll ( '.thickness-preset' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
applyStyle ( 'border-top-width' , btn . dataset . thickness ) ;
document . querySelectorAll ( '.thickness-preset' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// ==========================================
// Heading Level Controls
// ==========================================
setupHeadingLevelButtons ( ) ;
// ==========================================
// Background Image Controls
// ==========================================
// Update background image URL
const updateBgImage = debounce ( ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected ) {
const url = bgImageUrlInput . value . trim ( ) ;
if ( url ) {
selected . addStyle ( { 'background-image' : ` url( ${ url } ) ` } ) ;
} else {
selected . addStyle ( { 'background-image' : 'none' } ) ;
}
}
} , 300 ) ;
bgImageUrlInput . addEventListener ( 'input' , updateBgImage ) ;
// Background size
bgSizeSelect . addEventListener ( 'change' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected ) {
selected . addStyle ( { 'background-size' : bgSizeSelect . value } ) ;
}
} ) ;
// Background position
bgPositionSelect . addEventListener ( 'change' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected ) {
selected . addStyle ( { 'background-position' : bgPositionSelect . value } ) ;
}
} ) ;
// Remove background image
removeBgImageBtn . addEventListener ( 'click' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected ) {
selected . addStyle ( {
'background-image' : 'none' ,
'background-color' : '#ffffff'
} ) ;
bgImageUrlInput . value = '' ;
}
} ) ;
// ==========================================
// Overlay Controls
// ==========================================
// Overlay color presets
document . querySelectorAll ( '.overlay-color' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
currentOverlayColor = btn . dataset . color ;
const selected = editor . getSelected ( ) ;
if ( selected ) {
const opacity = currentOverlayOpacity / 100 ;
selected . addStyle ( { 'background' : ` rgba( ${ currentOverlayColor } , ${ opacity } ) ` } ) ;
}
document . querySelectorAll ( '.overlay-color' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
} ) ;
} ) ;
// Overlay opacity slider
overlayOpacitySlider . addEventListener ( 'input' , ( ) => {
currentOverlayOpacity = parseInt ( overlayOpacitySlider . value ) ;
overlayOpacityValue . textContent = currentOverlayOpacity + '%' ;
const selected = editor . getSelected ( ) ;
if ( selected && isOverlay ( selected ) ) {
const opacity = currentOverlayOpacity / 100 ;
selected . addStyle ( { 'background' : ` rgba( ${ currentOverlayColor } , ${ opacity } ) ` } ) ;
}
} ) ;
// ==========================================
// Navigation Controls
// ==========================================
// Sync navigation with pages
syncNavPagesBtn . addEventListener ( 'click' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( ! selected || ! isNavigation ( selected ) ) return ;
// Find the nav-links container
const linksContainer = selected . components ( ) . find ( c => {
const classes = c . getClasses ( ) ;
return classes . includes ( 'nav-links' ) ;
} ) ;
if ( ! linksContainer ) {
alert ( 'Navigation structure not recognized. Please use the Navigation block.' ) ;
return ;
}
// Get CTA button if exists (keep it)
const existingLinks = linksContainer . components ( ) ;
let ctaLink = null ;
existingLinks . forEach ( link => {
const classes = link . getClasses ( ) ;
if ( classes . includes ( 'nav-cta' ) ) {
ctaLink = link . clone ( ) ;
}
} ) ;
// Clear existing links
linksContainer . components ( ) . reset ( ) ;
// Add links for each page
pages . forEach ( ( page , index ) => {
linksContainer . components ( ) . add ( {
tagName : 'a' ,
attributes : { href : page . slug === 'index' ? '#' : ` # ${ page . slug } ` } ,
style : {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
} ,
content : page . name
} ) ;
} ) ;
// Re-add CTA if existed
if ( ctaLink ) {
linksContainer . components ( ) . add ( ctaLink ) ;
}
// Refresh the links list UI
loadNavLinks ( selected ) ;
} ) ;
// Add new link to navigation
addNavLinkBtn . addEventListener ( 'click' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( ! selected || ! isNavigation ( selected ) ) return ;
// Find the nav-links container
const linksContainer = selected . components ( ) . find ( c => {
const classes = c . getClasses ( ) ;
return classes . includes ( 'nav-links' ) ;
} ) ;
if ( ! linksContainer ) {
alert ( 'Navigation structure not recognized. Please use the Navigation block.' ) ;
return ;
}
// Find position to insert (before CTA if exists)
const links = linksContainer . components ( ) ;
let insertIndex = links . length ;
links . forEach ( ( link , index ) => {
const classes = link . getClasses ( ) ;
if ( classes . includes ( 'nav-cta' ) ) {
insertIndex = index ;
}
} ) ;
// Add new link
linksContainer . components ( ) . add ( {
tagName : 'a' ,
attributes : { href : '#' } ,
style : {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
} ,
content : 'New Link'
} , { at : insertIndex } ) ;
// Refresh the links list UI
loadNavLinks ( selected ) ;
} ) ;
// ==========================================
// Link Editing
// ==========================================
// Debounce helper
function debounce ( fn , delay ) {
let timeoutId ;
return ( ... args ) => {
clearTimeout ( timeoutId ) ;
timeoutId = setTimeout ( ( ) => fn ( ... args ) , delay ) ;
} ;
}
// Update link href
const updateLinkHref = debounce ( ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected && ( selected . get ( 'tagName' ) ? . toLowerCase ( ) === 'a' || selected . get ( 'tagName' ) ? . toLowerCase ( ) === 'button' ) ) {
selected . addAttributes ( { href : linkUrlInput . value } ) ;
}
} , 300 ) ;
linkUrlInput . addEventListener ( 'input' , updateLinkHref ) ;
// Update link target
linkNewTabCheckbox . addEventListener ( 'change' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected && selected . get ( 'tagName' ) ? . toLowerCase ( ) === 'a' ) {
if ( linkNewTabCheckbox . checked ) {
selected . addAttributes ( { target : '_blank' , rel : 'noopener noreferrer' } ) ;
} else {
selected . removeAttributes ( 'target' ) ;
selected . removeAttributes ( 'rel' ) ;
}
}
} ) ;
// ==========================================
// Save Status Indicator
// ==========================================
editor . on ( 'storage:start' , ( ) => {
saveStatus . classList . add ( 'saving' ) ;
saveStatus . classList . remove ( 'saved' ) ;
statusText . textContent = 'Saving...' ;
} ) ;
editor . on ( 'storage:end' , ( ) => {
saveStatus . classList . remove ( 'saving' ) ;
saveStatus . classList . add ( 'saved' ) ;
statusText . textContent = 'Saved' ;
} ) ;
editor . on ( 'storage:error' , ( err ) => {
saveStatus . classList . remove ( 'saving' ) ;
statusText . textContent = 'Error saving' ;
console . error ( 'Storage error:' , err ) ;
} ) ;
// ==========================================
// Keyboard Shortcuts
// ==========================================
document . addEventListener ( 'keydown' , ( e ) => {
// Only handle if not typing in an input
if ( e . target . tagName === 'INPUT' || e . target . tagName === 'TEXTAREA' ) return ;
// Ctrl/Cmd + Z = Undo
if ( ( e . ctrlKey || e . metaKey ) && e . key === 'z' && ! e . shiftKey ) {
e . preventDefault ( ) ;
editor . UndoManager . undo ( ) ;
}
// Ctrl/Cmd + Shift + Z = Redo (or Ctrl + Y)
if ( ( e . ctrlKey || e . metaKey ) && ( ( e . key === 'z' && e . shiftKey ) || e . key === 'y' ) ) {
e . preventDefault ( ) ;
editor . UndoManager . redo ( ) ;
}
// Delete/Backspace = Remove selected
if ( e . key === 'Delete' || e . key === 'Backspace' ) {
const selected = editor . getSelected ( ) ;
if ( selected && ! e . target . isContentEditable ) {
e . preventDefault ( ) ;
selected . remove ( ) ;
}
}
// Escape = Deselect
if ( e . key === 'Escape' ) {
editor . select ( null ) ;
}
} ) ;
// ==========================================
// Update guided controls when selection changes
// ==========================================
// Video Section Trait Change Listener
// ==========================================
// Global listener for video URL trait changes
editor . on ( 'component:update' , ( component ) => {
// Check if this is a video section
const attrs = component . getAttributes ( ) ;
if ( attrs && attrs [ 'data-video-section' ] === 'true' ) {
const videoUrl = attrs . videoUrl ;
if ( videoUrl ) {
console . log ( 'Video section updated with URL:' , videoUrl ) ;
// Find the bg-video-wrapper child
const videoWrapper = component . components ( ) . find ( c =>
c . getAttributes ( ) [ 'data-bg-video' ] === 'true'
) ;
if ( videoWrapper ) {
console . log ( 'Applying video URL to wrapper' ) ;
videoWrapper . addAttributes ( { videoUrl : videoUrl } ) ;
applyVideoUrl ( videoWrapper , videoUrl ) ;
} else {
console . error ( 'Video wrapper not found!' ) ;
}
}
}
} ) ;
// ==========================================
editor . on ( 'component:selected' , ( component ) => {
// Show context-aware UI sections
showSectionsForElement ( component ) ;
if ( ! component ) return ;
const styles = component . getStyle ( ) ;
// Reset all active states
document . querySelectorAll ( '.color-preset, .size-preset, .spacing-preset, .radius-preset, .thickness-preset, .font-preset, .weight-preset, .gradient-preset' )
. forEach ( btn => btn . classList . remove ( 'active' ) ) ;
// Set active text color
const textColor = styles [ 'color' ] ;
if ( textColor ) {
document . querySelectorAll ( '.text-color' ) . forEach ( btn => {
if ( btn . dataset . color === textColor ) btn . classList . add ( 'active' ) ;
} ) ;
}
// Set active background color or gradient
const bgColor = styles [ 'background-color' ] ;
const bgImage = styles [ 'background-image' ] || styles [ 'background' ] ;
// Check for gradient first
if ( bgImage && bgImage . includes ( 'gradient' ) ) {
document . querySelectorAll ( '.gradient-preset' ) . forEach ( btn => {
// Normalize gradient strings for comparison
const btnGradient = btn . dataset . gradient ? . replace ( /\s/g , '' ) ;
const elGradient = bgImage . replace ( /\s/g , '' ) ;
if ( btnGradient && elGradient . includes ( btnGradient . replace ( /\s/g , '' ) ) ) {
btn . classList . add ( 'active' ) ;
}
} ) ;
} else if ( bgColor ) {
document . querySelectorAll ( '.bg-color, .btn-bg-color' ) . forEach ( btn => {
if ( btn . dataset . color === bgColor ) btn . classList . add ( 'active' ) ;
} ) ;
}
// Set active divider color
const borderTopColor = styles [ 'border-top-color' ] ;
if ( borderTopColor ) {
document . querySelectorAll ( '.divider-color' ) . forEach ( btn => {
if ( btn . dataset . color === borderTopColor ) btn . classList . add ( 'active' ) ;
} ) ;
}
// Set active font family
const fontFamily = styles [ 'font-family' ] ;
if ( fontFamily ) {
document . querySelectorAll ( '.font-preset' ) . forEach ( btn => {
if ( fontFamily . includes ( btn . dataset . font . split ( ',' ) [ 0 ] . trim ( ) ) ) {
btn . classList . add ( 'active' ) ;
}
} ) ;
}
// Set active font weight
const fontWeight = styles [ 'font-weight' ] ;
if ( fontWeight ) {
document . querySelectorAll ( '.weight-preset' ) . forEach ( btn => {
if ( btn . dataset . weight === fontWeight ) btn . classList . add ( 'active' ) ;
} ) ;
}
// Set active font size
const fontSize = styles [ 'font-size' ] ;
if ( fontSize ) {
document . querySelectorAll ( '.size-preset' ) . forEach ( btn => {
if ( btn . dataset . size === fontSize ) btn . classList . add ( 'active' ) ;
} ) ;
}
// Set active padding
const padding = styles [ 'padding' ] ;
if ( padding ) {
document . querySelectorAll ( '.spacing-preset' ) . forEach ( btn => {
if ( btn . dataset . padding === padding ) btn . classList . add ( 'active' ) ;
} ) ;
}
// Set active border radius
const borderRadius = styles [ 'border-radius' ] ;
if ( borderRadius ) {
document . querySelectorAll ( '.radius-preset' ) . forEach ( btn => {
if ( btn . dataset . radius === borderRadius ) btn . classList . add ( 'active' ) ;
} ) ;
}
// Set active thickness
const borderTopWidth = styles [ 'border-top-width' ] ;
if ( borderTopWidth ) {
document . querySelectorAll ( '.thickness-preset' ) . forEach ( btn => {
if ( btn . dataset . thickness === borderTopWidth ) btn . classList . add ( 'active' ) ;
} ) ;
}
} ) ;
// Handle deselection
editor . on ( 'component:deselected' , ( ) => {
showSectionsForElement ( null ) ;
} ) ;
// ==========================================
// Context Menu
// ==========================================
const contextMenu = document . getElementById ( 'context-menu' ) ;
let clipboard = null ; // Store copied component
// Hide context menu
function hideContextMenu ( ) {
contextMenu . classList . remove ( 'visible' ) ;
}
// Show context menu at position
function showContextMenu ( x , y ) {
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
// Update disabled states
const pasteItem = contextMenu . querySelector ( '[data-action="paste"]' ) ;
if ( pasteItem ) {
pasteItem . classList . toggle ( 'disabled' , ! clipboard ) ;
}
// Position the menu
contextMenu . style . left = x + 'px' ;
contextMenu . style . top = y + 'px' ;
// Make sure menu doesn't go off screen
contextMenu . classList . add ( 'visible' ) ;
const rect = contextMenu . getBoundingClientRect ( ) ;
if ( rect . right > window . innerWidth ) {
contextMenu . style . left = ( x - rect . width ) + 'px' ;
}
if ( rect . bottom > window . innerHeight ) {
contextMenu . style . top = ( y - rect . height ) + 'px' ;
}
}
// Listen for right-click on canvas
editor . on ( 'component:selected' , ( component ) => {
if ( ! component ) return ;
// Get the component's element in the canvas
const el = component . getEl ( ) ;
if ( ! el ) return ;
// Remove any existing listener
el . removeEventListener ( 'contextmenu' , handleContextMenu ) ;
// Add context menu listener
el . addEventListener ( 'contextmenu' , handleContextMenu ) ;
} ) ;
function handleContextMenu ( e ) {
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
// Get canvas iframe offset
const canvas = editor . Canvas ;
const canvasEl = canvas . getElement ( ) ;
const iframe = canvasEl . querySelector ( 'iframe' ) ;
const iframeRect = iframe . getBoundingClientRect ( ) ;
// Calculate position relative to main window
const x = e . clientX + iframeRect . left ;
const y = e . clientY + iframeRect . top ;
showContextMenu ( x , y ) ;
}
// Close context menu when clicking elsewhere
document . addEventListener ( 'click' , hideContextMenu ) ;
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' ) hideContextMenu ( ) ;
} ) ;
// Handle context menu actions
contextMenu . addEventListener ( 'click' , ( e ) => {
const item = e . target . closest ( '.context-menu-item' ) ;
if ( ! item || item . classList . contains ( 'disabled' ) ) return ;
const action = item . dataset . action ;
const selected = editor . getSelected ( ) ;
if ( ! selected && action !== 'paste' ) {
hideContextMenu ( ) ;
return ;
}
switch ( action ) {
case 'edit' :
// Trigger inline editing if available
if ( selected . get ( 'editable' ) !== false ) {
const el = selected . getEl ( ) ;
if ( el ) {
el . setAttribute ( 'contenteditable' , 'true' ) ;
el . focus ( ) ;
}
}
break ;
case 'duplicate' :
const parent = selected . parent ( ) ;
if ( parent ) {
const index = parent . components ( ) . indexOf ( selected ) ;
const clone = selected . clone ( ) ;
parent . components ( ) . add ( clone , { at : index + 1 } ) ;
editor . select ( clone ) ;
}
break ;
case 'copy' :
clipboard = selected . clone ( ) ;
break ;
case 'paste' :
if ( clipboard ) {
const targetParent = selected . parent ( ) || editor . getWrapper ( ) ;
if ( targetParent ) {
const index = selected ? targetParent . components ( ) . indexOf ( selected ) + 1 : undefined ;
const pasted = clipboard . clone ( ) ;
targetParent . components ( ) . add ( pasted , { at : index } ) ;
editor . select ( pasted ) ;
}
}
break ;
case 'move-up' :
const parentUp = selected . parent ( ) ;
if ( parentUp ) {
const components = parentUp . components ( ) ;
const indexUp = components . indexOf ( selected ) ;
if ( indexUp > 0 ) {
components . remove ( selected ) ;
components . add ( selected , { at : indexUp - 1 } ) ;
editor . select ( selected ) ;
}
}
break ;
case 'move-down' :
const parentDown = selected . parent ( ) ;
if ( parentDown ) {
const components = parentDown . components ( ) ;
const indexDown = components . indexOf ( selected ) ;
if ( indexDown < components . length - 1 ) {
components . remove ( selected ) ;
components . add ( selected , { at : indexDown + 1 } ) ;
editor . select ( selected ) ;
}
}
break ;
case 'select-parent' :
const parentEl = selected . parent ( ) ;
if ( parentEl && parentEl . get ( 'type' ) !== 'wrapper' ) {
editor . select ( parentEl ) ;
}
break ;
case 'wrap' :
const wrapParent = selected . parent ( ) ;
if ( wrapParent ) {
const wrapIndex = wrapParent . components ( ) . indexOf ( selected ) ;
// Create wrapper div
const wrapper = wrapParent . components ( ) . add ( {
tagName : 'div' ,
style : { padding : '20px' } ,
components : [ ]
} , { at : wrapIndex } ) ;
// Move selected into wrapper
selected . move ( wrapper , { } ) ;
editor . select ( wrapper ) ;
}
break ;
case 'delete' :
selected . remove ( ) ;
break ;
case 'delete-section' :
// Find the topmost parent section/container (not wrapper)
{
let sectionTarget = selected ;
let sectionParent = sectionTarget . parent ( ) ;
while ( sectionParent && sectionParent . get ( 'type' ) !== 'wrapper' ) {
sectionTarget = sectionParent ;
sectionParent = sectionTarget . parent ( ) ;
}
if ( confirm ( 'Delete this entire section and all its children?' ) ) {
sectionTarget . remove ( ) ;
}
}
break ;
}
hideContextMenu ( ) ;
} ) ;
// Add keyboard shortcuts for copy/paste/duplicate
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . target . tagName === 'INPUT' || e . target . tagName === 'TEXTAREA' || e . target . isContentEditable ) return ;
const selected = editor . getSelected ( ) ;
if ( ! selected ) return ;
// Ctrl/Cmd + C = Copy
if ( ( e . ctrlKey || e . metaKey ) && e . key === 'c' ) {
clipboard = selected . clone ( ) ;
}
// Ctrl/Cmd + V = Paste
if ( ( e . ctrlKey || e . metaKey ) && e . key === 'v' && clipboard ) {
e . preventDefault ( ) ;
const targetParent = selected . parent ( ) || editor . getWrapper ( ) ;
if ( targetParent ) {
const index = targetParent . components ( ) . indexOf ( selected ) + 1 ;
const pasted = clipboard . clone ( ) ;
targetParent . components ( ) . add ( pasted , { at : index } ) ;
editor . select ( pasted ) ;
}
}
// Ctrl/Cmd + D = Duplicate
if ( ( e . ctrlKey || e . metaKey ) && e . key === 'd' ) {
e . preventDefault ( ) ;
const parent = selected . parent ( ) ;
if ( parent ) {
const index = parent . components ( ) . indexOf ( selected ) ;
const clone = selected . clone ( ) ;
parent . components ( ) . add ( clone , { at : index + 1 } ) ;
editor . select ( clone ) ;
}
}
} ) ;
// ==========================================
// Add default content if empty
// ==========================================
editor . on ( 'load' , ( ) => {
const components = editor . getComponents ( ) ;
if ( components . length === 0 ) {
// Add a starter section
editor . addComponents ( `
< section style = "min-height: 400px; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 60px 20px; text-align: center;" >
< div style = "max-width: 600px;" >
< h1 style = "color: #fff; font-size: 48px; font-weight: 700; margin-bottom: 20px; font-family: Inter, sans-serif;" > Welcome to Site Builder < / h 1 >
< p style = "color: rgba(255,255,255,0.9); font-size: 18px; line-height: 1.6; margin-bottom: 30px; font-family: Inter, sans-serif;" > Drag and drop components from the left panel to build your website . Click on any element to edit its content and style . < / p >
< a href = "#" style = "display: inline-block; padding: 14px 32px; background: #fff; color: #667eea; font-size: 16px; font-weight: 600; text-decoration: none; border-radius: 8px; font-family: Inter, sans-serif;" > Get Started < / a >
< / d i v >
< / s e c t i o n >
` );
}
} ) ;
// ==========================================
// Anchor Name Input Sync
// ==========================================
// Sync anchor input field with ID attribute
editor . on ( 'component:mount' , ( component ) => {
if ( component . get ( 'attributes' ) ? . [ 'data-anchor' ] ) {
setupAnchorInput ( component ) ;
}
} ) ;
function setupAnchorInput ( component ) {
const view = component . getEl ( ) ;
if ( ! view ) return ;
const input = view . querySelector ( '.anchor-name-input' ) ;
if ( ! input ) return ;
// Sync input value with component ID
const updateInputFromId = ( ) => {
const id = component . getId ( ) ;
if ( input . value !== id ) {
input . value = id ;
}
} ;
// Sync component ID with input value
const updateIdFromInput = ( ) => {
const newId = input . value . trim ( ) ;
if ( newId && newId !== component . getId ( ) ) {
// Sanitize ID (replace spaces with hyphens, remove special chars)
const sanitizedId = newId
. toLowerCase ( )
. replace ( /\s+/g , '-' )
. replace ( /[^a-z0-9-_]/g , '' ) ;
if ( sanitizedId ) {
component . setId ( sanitizedId ) ;
component . set ( 'attributes' , {
... component . get ( 'attributes' ) ,
id : sanitizedId
} ) ;
input . value = sanitizedId ;
}
}
} ;
// Prevent GrapesJS from intercepting keyboard events
const stopPropagation = ( e ) => {
e . stopPropagation ( ) ;
} ;
input . addEventListener ( 'keydown' , stopPropagation ) ;
input . addEventListener ( 'keyup' , stopPropagation ) ;
input . addEventListener ( 'keypress' , stopPropagation ) ;
// Prevent component deletion on backspace
input . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Backspace' || e . key === 'Delete' ) {
e . stopImmediatePropagation ( ) ;
}
} , true ) ; // Use capture phase
// Initial sync
updateInputFromId ( ) ;
// Listen for changes
input . addEventListener ( 'input' , updateIdFromInput ) ;
input . addEventListener ( 'blur' , updateIdFromInput ) ;
// Listen for ID changes from trait manager
component . on ( 'change:attributes:id' , updateInputFromId ) ;
}
// ==========================================
// Page Management
// ==========================================
const PAGES _STORAGE _KEY = 'sitebuilder-pages' ;
const pagesList = document . getElementById ( 'pages-list' ) ;
const addPageBtn = document . getElementById ( 'add-page-btn' ) ;
const pageModal = document . getElementById ( 'page-modal' ) ;
const modalTitle = document . getElementById ( 'modal-title' ) ;
const pageNameInput = document . getElementById ( 'page-name' ) ;
const pageSlugInput = document . getElementById ( 'page-slug' ) ;
const modalSave = document . getElementById ( 'modal-save' ) ;
const modalCancel = document . getElementById ( 'modal-cancel' ) ;
const modalClose = document . getElementById ( 'modal-close' ) ;
const modalDelete = document . getElementById ( 'modal-delete' ) ;
let pages = [ ] ;
let currentPageId = null ;
let editingPageId = null ;
// Generate unique ID
function generateId ( ) {
return 'page_' + Date . now ( ) + '_' + Math . random ( ) . toString ( 36 ) . substr ( 2 , 9 ) ;
}
// Generate slug from name
function slugify ( text ) {
return text . toLowerCase ( )
. replace ( /[^\w\s-]/g , '' )
. replace ( /\s+/g , '-' )
. replace ( /-+/g , '-' )
. trim ( ) ;
}
// Escape HTML to prevent XSS
function escapeHtml ( text ) {
const div = document . createElement ( 'div' ) ;
div . textContent = text ;
return div . innerHTML ;
}
// Load pages from storage
function loadPages ( ) {
const stored = localStorage . getItem ( PAGES _STORAGE _KEY ) ;
if ( stored ) {
try {
pages = JSON . parse ( stored ) ;
} catch ( e ) {
pages = [ ] ;
}
}
// Create default home page if no pages exist
if ( pages . length === 0 ) {
pages = [ {
id : generateId ( ) ,
name : 'Home' ,
slug : 'index' ,
html : '' ,
css : ''
} ] ;
savePages ( ) ;
}
// Set current page to first page if not set
if ( ! currentPageId ) {
currentPageId = pages [ 0 ] . id ;
}
renderPagesList ( ) ;
loadPageContent ( currentPageId ) ;
}
// Save pages to storage
function savePages ( ) {
localStorage . setItem ( PAGES _STORAGE _KEY , JSON . stringify ( pages ) ) ;
}
// Save current page content
function saveCurrentPageContent ( ) {
if ( ! currentPageId ) return ;
const page = pages . find ( p => p . id === currentPageId ) ;
if ( page ) {
page . html = editor . getHtml ( ) ;
page . css = editor . getCss ( ) ;
savePages ( ) ;
}
}
// Load page content into editor
function loadPageContent ( pageId ) {
const page = pages . find ( p => p . id === pageId ) ;
if ( ! page ) return ;
currentPageId = pageId ;
// Clear current content and load page content
editor . DomComponents . clear ( ) ;
editor . CssComposer . clear ( ) ;
if ( page . html ) {
editor . setComponents ( page . html ) ;
}
if ( page . css ) {
editor . setStyle ( page . css ) ;
}
renderPagesList ( ) ;
}
// Switch to a different page
function switchToPage ( pageId ) {
if ( pageId === currentPageId ) return ;
// Save current page first
saveCurrentPageContent ( ) ;
// Load new page
loadPageContent ( pageId ) ;
}
// Create a single page item element
function createPageItem ( page ) {
const item = document . createElement ( 'div' ) ;
item . className = 'page-item' + ( page . id === currentPageId ? ' active' : '' ) ;
item . dataset . pageId = page . id ;
// Create icon
const icon = document . createElement ( 'div' ) ;
icon . className = 'page-item-icon' ;
icon . innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>' ;
// Create info section
const info = document . createElement ( 'div' ) ;
info . className = 'page-item-info' ;
const nameEl = document . createElement ( 'div' ) ;
nameEl . className = 'page-item-name' ;
nameEl . textContent = page . name ; // Safe: uses textContent
const slugEl = document . createElement ( 'div' ) ;
slugEl . className = 'page-item-slug' ;
slugEl . textContent = '/' + page . slug ; // Safe: uses textContent
info . appendChild ( nameEl ) ;
info . appendChild ( slugEl ) ;
// Create actions
const actions = document . createElement ( 'div' ) ;
actions . className = 'page-item-actions' ;
// Edit button
const editBtn = document . createElement ( 'button' ) ;
editBtn . className = 'page-action-btn' ;
editBtn . dataset . action = 'edit' ;
editBtn . title = 'Edit' ;
editBtn . innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>' ;
actions . appendChild ( editBtn ) ;
// Delete button (only if more than one page)
if ( pages . length > 1 ) {
const deleteBtn = document . createElement ( 'button' ) ;
deleteBtn . className = 'page-action-btn danger' ;
deleteBtn . dataset . action = 'delete' ;
deleteBtn . title = 'Delete' ;
deleteBtn . innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>' ;
actions . appendChild ( deleteBtn ) ;
}
item . appendChild ( icon ) ;
item . appendChild ( info ) ;
item . appendChild ( actions ) ;
// Add click handler for switching pages
item . addEventListener ( 'click' , ( e ) => {
if ( e . target . closest ( '.page-action-btn' ) ) return ;
switchToPage ( page . id ) ;
} ) ;
// Add action button handlers
editBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
openEditModal ( page . id ) ;
} ) ;
const deleteBtn = actions . querySelector ( '[data-action="delete"]' ) ;
if ( deleteBtn ) {
deleteBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
deletePage ( page . id ) ;
} ) ;
}
return item ;
}
// Render pages list using safe DOM methods
function renderPagesList ( ) {
pagesList . innerHTML = '' ;
pages . forEach ( page => {
pagesList . appendChild ( createPageItem ( page ) ) ;
} ) ;
}
// Open modal for new page
function openNewPageModal ( ) {
editingPageId = null ;
modalTitle . textContent = 'Add New Page' ;
pageNameInput . value = '' ;
pageSlugInput . value = '' ;
modalDelete . style . display = 'none' ;
modalSave . textContent = 'Add Page' ;
pageModal . classList . add ( 'visible' ) ;
pageNameInput . focus ( ) ;
}
// Open modal for editing page
function openEditModal ( pageId ) {
const page = pages . find ( p => p . id === pageId ) ;
if ( ! page ) return ;
editingPageId = pageId ;
modalTitle . textContent = 'Edit Page' ;
pageNameInput . value = page . name ;
pageSlugInput . value = page . slug ;
modalDelete . style . display = pages . length > 1 ? 'inline-block' : 'none' ;
modalSave . textContent = 'Save Changes' ;
pageModal . classList . add ( 'visible' ) ;
pageNameInput . focus ( ) ;
}
// Close modal
function closeModal ( ) {
pageModal . classList . remove ( 'visible' ) ;
editingPageId = null ;
}
// Save page (new or edit)
function savePage ( ) {
const name = pageNameInput . value . trim ( ) ;
let slug = pageSlugInput . value . trim ( ) ;
if ( ! name ) {
alert ( 'Please enter a page name' ) ;
return ;
}
// Generate slug if empty
if ( ! slug ) {
slug = slugify ( name ) ;
} else {
slug = slugify ( slug ) ;
}
// Ensure slug is unique
const existingPage = pages . find ( p => p . slug === slug && p . id !== editingPageId ) ;
if ( existingPage ) {
slug = slug + '-' + Date . now ( ) ;
}
if ( editingPageId ) {
// Update existing page
const page = pages . find ( p => p . id === editingPageId ) ;
if ( page ) {
page . name = name ;
page . slug = slug ;
}
} else {
// Create new page
const newPage = {
id : generateId ( ) ,
name : name ,
slug : slug ,
html : '' ,
css : ''
} ;
pages . push ( newPage ) ;
// Save current page before switching
saveCurrentPageContent ( ) ;
// Switch to new page
currentPageId = newPage . id ;
editor . DomComponents . clear ( ) ;
editor . CssComposer . clear ( ) ;
}
savePages ( ) ;
renderPagesList ( ) ;
closeModal ( ) ;
}
// Delete page
function deletePage ( pageId ) {
if ( pages . length <= 1 ) {
alert ( 'Cannot delete the last page' ) ;
return ;
}
if ( ! confirm ( 'Are you sure you want to delete this page? This cannot be undone.' ) ) {
return ;
}
const index = pages . findIndex ( p => p . id === pageId ) ;
if ( index > - 1 ) {
pages . splice ( index , 1 ) ;
savePages ( ) ;
// If deleting current page, switch to first page
if ( pageId === currentPageId ) {
currentPageId = pages [ 0 ] . id ;
loadPageContent ( currentPageId ) ;
} else {
renderPagesList ( ) ;
}
}
closeModal ( ) ;
}
// Auto-save current page on changes
editor . on ( 'storage:end' , ( ) => {
saveCurrentPageContent ( ) ;
} ) ;
// Auto-generate slug from name
pageNameInput . addEventListener ( 'input' , ( ) => {
if ( ! editingPageId ) {
pageSlugInput . value = slugify ( pageNameInput . value ) ;
}
} ) ;
// Modal event handlers
addPageBtn . addEventListener ( 'click' , openNewPageModal ) ;
modalClose . addEventListener ( 'click' , closeModal ) ;
modalCancel . addEventListener ( 'click' , closeModal ) ;
modalSave . addEventListener ( 'click' , savePage ) ;
modalDelete . addEventListener ( 'click' , ( ) => {
if ( editingPageId ) {
deletePage ( editingPageId ) ;
}
} ) ;
// Close modal on overlay click
pageModal . addEventListener ( 'click' , ( e ) => {
if ( e . target === pageModal ) {
closeModal ( ) ;
}
} ) ;
// Close modal on Escape
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' && pageModal . classList . contains ( 'visible' ) ) {
closeModal ( ) ;
}
} ) ;
// Initialize pages
loadPages ( ) ;
// ==========================================
// Update Preview to include all pages
// ==========================================
// Override preview button to save all pages data
document . getElementById ( 'btn-preview' ) . addEventListener ( 'click' , ( ) => {
// Save current page first
saveCurrentPageContent ( ) ;
// Store all pages for preview
localStorage . setItem ( STORAGE _KEY + '-preview' , JSON . stringify ( {
pages : pages ,
currentPageId : currentPageId
} ) ) ;
// Open preview
window . open ( 'preview.html' , '_blank' ) ;
} ) ;
// Make editor accessible globally for debugging
window . editor = editor ;
window . sitePages = pages ;
// ==========================================
// Feature: Delete Section (parent + children)
// ==========================================
// Added in context menu handler below
// ==========================================
// Feature: Link Type Selector (URL / Page / Anchor)
// ==========================================
const linkTypeSelect = document . getElementById ( 'link-type-select' ) ;
const linkUrlGroup = document . getElementById ( 'link-url-group' ) ;
const linkPageGroup = document . getElementById ( 'link-page-group' ) ;
const linkAnchorGroup = document . getElementById ( 'link-anchor-group' ) ;
const linkPageSelect = document . getElementById ( 'link-page-select' ) ;
const linkAnchorSelect = document . getElementById ( 'link-anchor-select' ) ;
// Populate page dropdown
function populatePageDropdown ( ) {
linkPageSelect . innerHTML = '' ;
pages . forEach ( page => {
const opt = document . createElement ( 'option' ) ;
opt . value = page . slug === 'index' ? '#' : page . slug + '.html' ;
opt . textContent = page . name ;
linkPageSelect . appendChild ( opt ) ;
} ) ;
}
// Find all anchors on current page
function findAnchors ( ) {
const anchors = [ ] ;
function walk ( component ) {
const id = component . getAttributes ( ) . id ;
if ( id ) anchors . push ( id ) ;
component . components ( ) . forEach ( walk ) ;
}
editor . getWrapper ( ) . components ( ) . forEach ( walk ) ;
return anchors ;
}
// Populate anchor dropdown
function populateAnchorDropdown ( ) {
linkAnchorSelect . innerHTML = '<option value="">-- Select Anchor --</option>' ;
findAnchors ( ) . forEach ( id => {
const opt = document . createElement ( 'option' ) ;
opt . value = '#' + id ;
opt . textContent = id ;
linkAnchorSelect . appendChild ( opt ) ;
} ) ;
}
// Handle link type change
if ( linkTypeSelect ) {
linkTypeSelect . addEventListener ( 'change' , ( ) => {
const type = linkTypeSelect . value ;
linkUrlGroup . style . display = type === 'url' ? 'block' : 'none' ;
linkPageGroup . style . display = type === 'page' ? 'block' : 'none' ;
linkAnchorGroup . style . display = type === 'anchor' ? 'block' : 'none' ;
if ( type === 'page' ) populatePageDropdown ( ) ;
if ( type === 'anchor' ) populateAnchorDropdown ( ) ;
} ) ;
linkPageSelect . addEventListener ( 'change' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected ) selected . addAttributes ( { href : linkPageSelect . value } ) ;
} ) ;
linkAnchorSelect . addEventListener ( 'change' , ( ) => {
const selected = editor . getSelected ( ) ;
if ( selected && linkAnchorSelect . value ) {
selected . addAttributes ( { href : linkAnchorSelect . value } ) ;
}
} ) ;
}
// ==========================================
// Feature: Asset Manager (inline - delegates to server when available)
// ==========================================
const ASSETS _STORAGE _KEY = 'sitebuilder-assets' ;
const assetUploadBtn = document . getElementById ( 'asset-upload-btn' ) ;
const assetUploadInput = document . getElementById ( 'asset-upload-input' ) ;
const assetUrlInput = document . getElementById ( 'asset-url-input' ) ;
const assetAddUrlBtn = document . getElementById ( 'asset-add-url-btn' ) ;
const assetsGrid = document . getElementById ( 'assets-grid' ) ;
let assets = [ ] ;
let editorServerAvailable = false ;
// Check if server API is available
async function checkEditorServer ( ) {
try {
const resp = await fetch ( '/api/health' ) ;
if ( resp . ok ) {
const data = await resp . json ( ) ;
editorServerAvailable = data . status === 'ok' ;
}
} catch ( e ) {
editorServerAvailable = false ;
}
}
async function loadAssets ( ) {
await checkEditorServer ( ) ;
if ( editorServerAvailable ) {
try {
const resp = await fetch ( '/api/assets' ) ;
if ( resp . ok ) {
const data = await resp . json ( ) ;
if ( data . success && Array . isArray ( data . assets ) ) {
assets = data . assets ;
// Save lightweight metadata index to localStorage (no file contents)
saveAssetsMetadata ( ) ;
renderAssets ( ) ;
return ;
}
}
} catch ( e ) {
console . warn ( 'Failed to load assets from server:' , e . message ) ;
}
}
// Fallback: load from localStorage (metadata only, filter out base64)
try {
assets = JSON . parse ( localStorage . getItem ( ASSETS _STORAGE _KEY ) || '[]' ) ;
assets = assets . filter ( a => ! a . url || ! a . url . startsWith ( 'data:' ) ) ;
} catch ( e ) { assets = [ ] ; }
renderAssets ( ) ;
}
function saveAssetsMetadata ( ) {
// Save only metadata (no file contents) to localStorage
try {
const metadata = assets . map ( a => ( {
id : a . id , name : a . name , url : a . url ,
type : a . type , size : a . size , added : a . added
} ) ) ;
localStorage . setItem ( ASSETS _STORAGE _KEY , JSON . stringify ( metadata ) ) ;
} catch ( e ) {
console . warn ( 'Could not cache asset metadata to localStorage:' , e . message ) ;
}
}
function saveAssets ( ) {
saveAssetsMetadata ( ) ;
}
async function uploadFileToServer ( file ) {
const formData = new FormData ( ) ;
formData . append ( 'file' , file ) ;
const resp = await fetch ( '/api/assets/upload' , { method : 'POST' , body : formData } ) ;
if ( ! resp . ok ) {
const errData = await resp . json ( ) . catch ( ( ) => ( { error : 'Upload failed' } ) ) ;
throw new Error ( errData . error || 'Upload failed' ) ;
}
const data = await resp . json ( ) ;
if ( data . success && data . assets && data . assets . length > 0 ) {
return data . assets [ 0 ] ;
}
throw new Error ( 'No asset returned from server' ) ;
}
async function deleteAssetFromServer ( asset ) {
if ( asset && asset . url && asset . url . startsWith ( '/storage/assets/' ) ) {
const filename = asset . id || asset . filename || asset . url . split ( '/' ) . pop ( ) ;
try {
await fetch ( '/api/assets/' + encodeURIComponent ( filename ) , { method : 'DELETE' } ) ;
} catch ( e ) {
console . warn ( 'Server delete failed:' , e . message ) ;
}
}
}
function renderAssets ( ) {
if ( ! assetsGrid ) return ;
assetsGrid . innerHTML = '' ;
assets . forEach ( ( asset , i ) => {
const item = document . createElement ( 'div' ) ;
item . style . cssText = 'position:relative;border-radius:6px;overflow:hidden;border:1px solid #2d2d3a;cursor:pointer;aspect-ratio:1;background:#16161a;display:flex;align-items:center;justify-content:center;' ;
if ( asset . type === 'image' ) {
const img = document . createElement ( 'img' ) ;
img . src = asset . url ;
img . style . cssText = 'width:100%;height:100%;object-fit:cover;' ;
img . alt = asset . name ;
item . appendChild ( img ) ;
} else {
const icon = document . createElement ( 'div' ) ;
icon . style . cssText = 'text-align:center;color:#71717a;font-size:11px;padding:8px;' ;
icon . innerHTML = ` <div style="font-size:24px;margin-bottom:4px;"> ${ asset . type === 'video' ? '🎬' : '📄' } </div><div style="word-break:break-all;"> ${ asset . name } </div> ` ;
item . appendChild ( icon ) ;
}
// Delete button
const del = document . createElement ( 'button' ) ;
del . style . cssText = 'position:absolute;top:4px;right:4px;background:rgba(0,0,0,0.7);border:none;color:#fff;width:20px;height:20px;border-radius:50%;cursor:pointer;font-size:12px;display:flex;align-items:center;justify-content:center;' ;
del . textContent = '\u00d7' ;
del . addEventListener ( 'click' , async ( e ) => {
e . stopPropagation ( ) ;
if ( editorServerAvailable ) {
await deleteAssetFromServer ( assets [ i ] ) ;
}
assets . splice ( i , 1 ) ;
saveAssets ( ) ;
renderAssets ( ) ;
} ) ;
item . appendChild ( del ) ;
// Click to copy URL
item . addEventListener ( 'click' , ( ) => {
navigator . clipboard . writeText ( asset . url ) . then ( ( ) => {
item . style . outline = '2px solid #3b82f6' ;
setTimeout ( ( ) => item . style . outline = '' , 1000 ) ;
} ) ;
} ) ;
assetsGrid . appendChild ( item ) ;
} ) ;
}
if ( assetUploadBtn ) {
assetUploadBtn . addEventListener ( 'click' , ( ) => assetUploadInput . click ( ) ) ;
assetUploadInput . addEventListener ( 'change' , async ( e ) => {
const files = Array . from ( e . target . files ) ;
for ( const file of files ) {
const type = file . type . startsWith ( 'image' ) ? 'image' : file . type . startsWith ( 'video' ) ? 'video' : 'file' ;
if ( editorServerAvailable ) {
// Upload to server (no base64)
try {
const serverAsset = await uploadFileToServer ( file ) ;
assets . push ( serverAsset ) ;
saveAssets ( ) ;
renderAssets ( ) ;
if ( serverAsset . type === 'image' ) {
editor . AssetManager . add ( { src : serverAsset . url , name : serverAsset . name } ) ;
}
} catch ( err ) {
alert ( 'Upload failed: ' + err . message ) ;
}
} else {
// No server available - show error for file uploads
alert ( 'File upload requires server.py to be running.\n\nStart it with: python3 server.py\n\nYou can still add assets by pasting external URLs.' ) ;
break ;
}
}
assetUploadInput . value = '' ;
} ) ;
}
if ( assetAddUrlBtn ) {
assetAddUrlBtn . addEventListener ( 'click' , ( ) => {
const url = assetUrlInput . value . trim ( ) ;
if ( ! url ) return ;
const name = url . split ( '/' ) . pop ( ) || 'asset' ;
const type = url . match ( /\.(jpg|jpeg|png|gif|webp|svg)(\?.*)?$/i ) ? 'image' :
url . match ( /\.(mp4|webm|ogg|mov)(\?.*)?$/i ) ? 'video' : 'file' ;
assets . push ( { name , url , type , id : 'asset_' + Date . now ( ) , added : Date . now ( ) } ) ;
saveAssets ( ) ;
renderAssets ( ) ;
if ( type === 'image' ) editor . AssetManager . add ( { src : url , name } ) ;
assetUrlInput . value = '' ;
} ) ;
}
loadAssets ( ) ;
// ==========================================
// Feature: Head Elements & Site-wide CSS
// ==========================================
const HEAD _CODE _KEY = 'sitebuilder-head-code' ;
const SITEWIDE _CSS _KEY = 'sitebuilder-sitewide-css' ;
const headCodeTextarea = document . getElementById ( 'head-code-textarea' ) ;
const headCodeApply = document . getElementById ( 'head-code-apply' ) ;
const sitwideCssTextarea = document . getElementById ( 'sitewide-css-textarea' ) ;
const sitewideCssApply = document . getElementById ( 'sitewide-css-apply' ) ;
// Load saved head code
if ( headCodeTextarea ) {
headCodeTextarea . value = localStorage . getItem ( HEAD _CODE _KEY ) || '' ;
}
if ( sitwideCssTextarea ) {
sitwideCssTextarea . value = localStorage . getItem ( SITEWIDE _CSS _KEY ) || '' ;
}
if ( headCodeApply ) {
headCodeApply . addEventListener ( 'click' , ( ) => {
localStorage . setItem ( HEAD _CODE _KEY , headCodeTextarea . value ) ;
alert ( 'Head code saved! It will be included in exports.' ) ;
} ) ;
}
if ( sitewideCssApply ) {
sitewideCssApply . addEventListener ( 'click' , ( ) => {
localStorage . setItem ( SITEWIDE _CSS _KEY , sitwideCssTextarea . value ) ;
// Apply to canvas immediately
const frame = editor . Canvas . getFrameEl ( ) ;
if ( frame && frame . contentDocument ) {
let style = frame . contentDocument . getElementById ( 'sitewide-css' ) ;
if ( ! style ) {
style = frame . contentDocument . createElement ( 'style' ) ;
style . id = 'sitewide-css' ;
frame . contentDocument . head . appendChild ( style ) ;
}
style . textContent = sitwideCssTextarea . value ;
}
alert ( 'Site-wide CSS saved and applied!' ) ;
} ) ;
}
// ==========================================
// Page HTML Editor Modal
// ==========================================
const pageCodeModal = document . getElementById ( 'page-code-modal' ) ;
const pageCodeModalClose = document . getElementById ( 'page-code-modal-close' ) ;
const pageCodeTextarea = document . getElementById ( 'page-code-textarea' ) ;
const pageCodeApply = document . getElementById ( 'page-code-apply' ) ;
const pageCodeCancel = document . getElementById ( 'page-code-cancel' ) ;
// Open page HTML editor
document . getElementById ( 'btn-view-code' ) . addEventListener ( 'click' , ( ) => {
const html = editor . getHtml ( ) ;
const css = editor . getCss ( ) ;
// Show HTML in textarea
pageCodeTextarea . value = html ;
pageCodeModal . classList . add ( 'visible' ) ;
} ) ;
// Close modal
function closePageCodeModal ( ) {
pageCodeModal . classList . remove ( 'visible' ) ;
}
pageCodeModalClose . addEventListener ( 'click' , closePageCodeModal ) ;
pageCodeCancel . addEventListener ( 'click' , closePageCodeModal ) ;
pageCodeModal . addEventListener ( 'click' , ( e ) => {
if ( e . target === pageCodeModal ) closePageCodeModal ( ) ;
} ) ;
// Apply HTML changes
pageCodeApply . addEventListener ( 'click' , ( ) => {
try {
const newHtml = pageCodeTextarea . value . trim ( ) ;
// Clear current components
editor . DomComponents . clear ( ) ;
// Set new HTML
editor . setComponents ( newHtml ) ;
// Close modal
closePageCodeModal ( ) ;
// Deselect all
editor . select ( null ) ;
} catch ( error ) {
alert ( 'Invalid HTML: ' + error . message ) ;
}
} ) ;
// ==========================================
// Export Functionality
// ==========================================
const exportModal = document . getElementById ( 'export-modal' ) ;
const exportModalClose = document . getElementById ( 'export-modal-close' ) ;
const exportModalCancel = document . getElementById ( 'export-modal-cancel' ) ;
const exportDownloadBtn = document . getElementById ( 'export-download' ) ;
const exportPagesList = document . getElementById ( 'export-pages-list' ) ;
const exportMinify = document . getElementById ( 'export-minify' ) ;
const exportIncludeFonts = document . getElementById ( 'export-include-fonts' ) ;
// Open export modal
document . getElementById ( 'btn-export' ) . addEventListener ( 'click' , ( ) => {
// Save current page first
saveCurrentPageContent ( ) ;
// Populate pages list
renderExportPagesList ( ) ;
exportModal . classList . add ( 'visible' ) ;
} ) ;
// Close export modal
function closeExportModal ( ) {
exportModal . classList . remove ( 'visible' ) ;
}
exportModalClose . addEventListener ( 'click' , closeExportModal ) ;
exportModalCancel . addEventListener ( 'click' , closeExportModal ) ;
exportModal . addEventListener ( 'click' , ( e ) => {
if ( e . target === exportModal ) closeExportModal ( ) ;
} ) ;
// Render pages list in export modal
function renderExportPagesList ( ) {
exportPagesList . innerHTML = '' ;
pages . forEach ( page => {
const item = document . createElement ( 'div' ) ;
item . className = 'export-page-item' ;
const info = document . createElement ( 'div' ) ;
info . className = 'export-page-info' ;
const icon = document . createElement ( 'div' ) ;
icon . className = 'export-page-icon' ;
icon . innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>' ;
const textDiv = document . createElement ( 'div' ) ;
const nameEl = document . createElement ( 'div' ) ;
nameEl . className = 'export-page-name' ;
nameEl . textContent = page . name ;
const fileEl = document . createElement ( 'div' ) ;
fileEl . className = 'export-page-file' ;
fileEl . textContent = page . slug + '.html' ;
textDiv . appendChild ( nameEl ) ;
textDiv . appendChild ( fileEl ) ;
info . appendChild ( icon ) ;
info . appendChild ( textDiv ) ;
item . appendChild ( info ) ;
exportPagesList . appendChild ( item ) ;
} ) ;
}
// Generate HTML template for a page
function generatePageHtml ( page , includeFonts , minifyCss ) {
let css = page . css || '' ;
let html = page . html || '' ;
// Remove editor-only anchor elements completely (with nested content)
html = html . replace ( /<div[^>]*data-anchor="true"[^>]*>[\s\S]*?<\/div>/g , '' ) ;
html = html . replace ( /<div[^>]*class="editor-anchor"[^>]*>[\s\S]*?<\/div>/g , '' ) ;
// Minify CSS if requested
if ( minifyCss && css ) {
css = css
. replace ( /\/\*[\s\S]*?\*\//g , '' ) // Remove comments
. replace ( /\s+/g , ' ' ) // Collapse whitespace
. replace ( /\s*([{};:,>+~])\s*/g , '$1' ) // Remove space around punctuation
. trim ( ) ;
}
// Remove editor-anchor CSS rules from page CSS
css = css . replace ( /\.editor-anchor[^}]*}/g , '' ) ;
const headCode = localStorage . getItem ( HEAD _CODE _KEY ) || '' ;
const sitewideCss = localStorage . getItem ( SITEWIDE _CSS _KEY ) || '' ;
const fontsLink = includeFonts
? '<link rel="preconnect" href="https://fonts.googleapis.com">\n <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>\n <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Roboto:wght@300;400;500;700&family=Open+Sans:wght@300;400;500;600;700&family=Poppins:wght@300;400;500;600;700&family=Montserrat:wght@300;400;500;600;700&family=Playfair+Display:wght@400;500;600;700&family=Merriweather:wght@300;400;700&family=Source+Code+Pro:wght@400;500;600&display=swap" rel="stylesheet">\n '
: '' ;
return ` <!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< meta name = "color-scheme" content = "light" >
< title > $ { page . name } < / t i t l e >
$ { fontsLink } $ { headCode ? headCode + '\n ' : '' } < style >
/* Reset & Base */
* { margin : 0 ; padding : 0 ; box - sizing : border - box ; }
body { font - family : Inter , - apple - system , BlinkMacSystemFont , 'Segoe UI' , Roboto , sans - serif ; line - height : 1.5 ; - webkit - font - smoothing : antialiased ; }
img , video { max - width : 100 % ; height : auto ; }
img { display : block ; }
a { color : inherit ; }
/* Accessibility: focus visible */
: focus - visible { outline : 2 px solid # 3 b82f6 ; outline - offset : 2 px ; }
: focus : not ( : focus - visible ) { outline : none ; }
/* Touch-friendly: min tap targets */
a , button , input , select , textarea { min - height : 44 px ; }
button , [ type = "submit" ] { cursor : pointer ; touch - action : manipulation ; }
/* Responsive columns */
@ media ( max - width : 768 px ) {
. row { flex - direction : column ! important ; }
. row . cell { flex - basis : 100 % ! important ; width : 100 % ! important ; }
section { padding - left : 16 px ! important ; padding - right : 16 px ! important ; }
}
@ media ( max - width : 480 px ) {
. row { flex - direction : column ! important ; }
. row . cell { flex - basis : 100 % ! important ; width : 100 % ! important ; }
h1 { font - size : 32 px ! important ; }
h2 { font - size : 28 px ! important ; }
}
/* Reduced motion */
@ media ( prefers - reduced - motion : reduce ) {
* , * : : before , * : : after { animation - duration : 0.01 ms ! important ; transition - duration : 0.01 ms ! important ; }
}
/* Site-wide Styles */
$ { sitewideCss }
/* Page Styles */
$ { css }
/* Failsafe: Hide any editor-only elements that slipped through */
. editor - anchor ,
[ data - anchor = "true" ] {
display : none ! important ;
visibility : hidden ! important ;
position : absolute ! important ;
width : 0 ! important ;
height : 0 ! important ;
overflow : hidden ! important ;
}
< / s t y l e >
< / h e a d >
< body >
< a href = "#main-content" style = "position:absolute;left:-9999px;top:auto;width:1px;height:1px;overflow:hidden;" onfocus = "this.style.position='static';this.style.width='auto';this.style.height='auto';" > Skip to main content < / a >
< main id = "main-content" >
$ { page . html || '' }
< / m a i n >
< / b o d y >
< / h t m l > ` ;
}
// Copy HTML to clipboard (bypasses Windows security warnings)
const exportCopyBtn = document . getElementById ( 'export-copy-html' ) ;
exportCopyBtn . addEventListener ( 'click' , async ( ) => {
const includeFonts = exportIncludeFonts . checked ;
const minifyCss = exportMinify . checked ;
// Save current page first
saveCurrentPageContent ( ) ;
// Get current page
const currentPage = pages . find ( p => p . id === currentPageId ) ;
if ( ! currentPage ) {
alert ( 'No page to export!' ) ;
return ;
}
// Generate HTML
const html = generatePageHtml ( currentPage , includeFonts , minifyCss ) ;
// Copy to clipboard
try {
await navigator . clipboard . writeText ( html ) ;
// Show success feedback
const originalText = exportCopyBtn . innerHTML ;
exportCopyBtn . innerHTML = `
< svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "2" >
< polyline points = "20 6 9 17 4 12" > < / p o l y l i n e >
< / s v g >
Copied !
` ;
exportCopyBtn . style . background = '#10b981' ;
setTimeout ( ( ) => {
exportCopyBtn . innerHTML = originalText ;
exportCopyBtn . style . background = '' ;
} , 2000 ) ;
// Show instructions
alert ( ` ✅ HTML copied to clipboard! \n \n Next steps: \n 1. Open Notepad (or any text editor) \n 2. Paste (Ctrl+V) \n 3. Save as " ${ currentPage . slug } .html" \n 4. Open the saved file in your browser \n \n This bypasses Windows security warnings! ` ) ;
} catch ( err ) {
console . error ( 'Copy failed:' , err ) ;
alert ( 'Failed to copy to clipboard. Make sure you\'re using a modern browser with clipboard permissions.' ) ;
}
} ) ;
// Download as ZIP using JSZip (loaded dynamically)
exportDownloadBtn . addEventListener ( 'click' , async ( ) => {
const includeFonts = exportIncludeFonts . checked ;
const minifyCss = exportMinify . checked ;
// Save current page first
saveCurrentPageContent ( ) ;
// Check if JSZip is available, if not, load it
if ( typeof JSZip === 'undefined' ) {
// Load JSZip dynamically
const script = document . createElement ( 'script' ) ;
script . src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js' ;
script . onload = ( ) => createAndDownloadZip ( includeFonts , minifyCss ) ;
script . onerror = ( ) => {
// Fallback: download single page if JSZip fails to load
alert ( 'Could not load ZIP library. Downloading current page only.' ) ;
downloadSinglePage ( pages . find ( p => p . id === currentPageId ) , includeFonts , minifyCss ) ;
} ;
document . head . appendChild ( script ) ;
} else {
createAndDownloadZip ( includeFonts , minifyCss ) ;
}
} ) ;
// Create ZIP and trigger download
async function createAndDownloadZip ( includeFonts , minifyCss ) {
const zip = new JSZip ( ) ;
// Add each page as HTML file
pages . forEach ( page => {
const html = generatePageHtml ( page , includeFonts , minifyCss ) ;
const filename = page . slug + '.html' ;
zip . file ( filename , html ) ;
} ) ;
// Generate and download ZIP
const content = await zip . generateAsync ( { type : 'blob' } ) ;
const url = URL . createObjectURL ( content ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = 'site-export.zip' ;
document . body . appendChild ( a ) ;
a . click ( ) ;
document . body . removeChild ( a ) ;
URL . revokeObjectURL ( url ) ;
closeExportModal ( ) ;
}
// Fallback: download single page as HTML
function downloadSinglePage ( page , includeFonts , minifyCss ) {
if ( ! page ) return ;
const html = generatePageHtml ( page , includeFonts , minifyCss ) ;
const blob = new Blob ( [ html ] , { type : 'text/html' } ) ;
const url = URL . createObjectURL ( blob ) ;
const a = document . createElement ( 'a' ) ;
a . href = url ;
a . download = page . slug + '.html' ;
document . body . appendChild ( a ) ;
a . click ( ) ;
document . body . removeChild ( a ) ;
URL . revokeObjectURL ( url ) ;
closeExportModal ( ) ;
}
// ==========================================
// Template System
// ==========================================
let templateIndex = [ ] ;
let pendingTemplateId = null ;
// Load template index via fetch (requires HTTP server)
async function loadTemplateIndex ( ) {
try {
const resp = await fetch ( 'templates/index.json' ) ;
if ( ! resp . ok ) throw new Error ( ` HTTP ${ resp . status } : ${ resp . statusText } ` ) ;
templateIndex = await resp . json ( ) ;
console . log ( 'Loaded' , templateIndex . length , 'templates from templates/index.json' ) ;
renderTemplateGrid ( templateIndex ) ;
} catch ( e ) {
console . error ( 'Could not load templates:' , e ) ;
const grid = document . getElementById ( 'templates-grid' ) ;
if ( grid ) {
grid . innerHTML = `
< div style = "padding:24px;text-align:center;color:#71717a;" >
< p style = "font-size:14px;margin-bottom:8px;" > ❌ No templates available < / p >
< p style = "font-size:12px;" > $ { e . message } < / p >
< p style = "font-size:11px;margin-top:8px;opacity:0.7;" > Make sure HTTP server is running < / p >
< / d i v >
` ;
}
}
}
function renderTemplateGrid ( templates ) {
const grid = document . getElementById ( 'templates-grid' ) ;
if ( ! grid ) return ;
grid . innerHTML = '' ;
// Add "Start from Blank" card
const blankCard = document . createElement ( 'div' ) ;
blankCard . className = 'template-card' ;
blankCard . innerHTML = `
< div style = "width:100%;height:140px;background:#16161a;display:flex;align-items:center;justify-content:center;" >
< div style = "text-align:center;color:#71717a;" >
< svg width = "32" height = "32" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke - width = "1.5" style = "margin:0 auto 8px;display:block;" >
< rect x = "3" y = "3" width = "18" height = "18" rx = "2" ry = "2" > < / r e c t >
< line x1 = "12" y1 = "8" x2 = "12" y2 = "16" > < / l i n e >
< line x1 = "8" y1 = "12" x2 = "16" y2 = "12" > < / l i n e >
< / s v g >
< span style = "font-size:12px;" > Blank Canvas < / s p a n >
< / d i v >
< / d i v >
< div class = "template-card-info" >
< div class = "template-card-name" > Start from Scratch < / d i v >
< div class = "template-card-desc" > Begin with a blank page and build your own design . < / d i v >
< / d i v >
` ;
blankCard . addEventListener ( 'click' , ( ) => {
if ( confirm ( 'Clear the canvas and start fresh?' ) ) {
editor . DomComponents . clear ( ) ;
editor . CssComposer . clear ( ) ;
}
} ) ;
grid . appendChild ( blankCard ) ;
templates . forEach ( t => {
const card = document . createElement ( 'div' ) ;
card . className = 'template-card' ;
card . dataset . category = t . category ;
2026-03-01 14:13:02 -08:00
const pageCount = t . pages ? t . pages . length : 0 ;
const pageBadge = pageCount > 0 ? ` <span style="position:absolute;top:8px;right:8px;background:rgba(59,130,246,0.9);color:#fff;font-size:11px;font-weight:600;padding:3px 8px;border-radius:4px;font-family:Inter,sans-serif;"> ${ pageCount } pages</span> ` : '' ;
2026-02-28 19:25:42 +00:00
card . innerHTML = `
2026-03-01 14:13:02 -08:00
< div style = "position:relative;" >
< img class = "template-card-thumb" src = "templates/thumbnails/${t.id}.svg" alt = "${t.name}" onerror = "this.style.background='#2d2d3a'" >
$ { pageBadge }
< / d i v >
2026-02-28 19:25:42 +00:00
< div class = "template-card-info" >
< div class = "template-card-name" > $ { t . name } < / d i v >
< div class = "template-card-desc" > $ { t . description } < / d i v >
< div class = "template-card-tags" >
$ { t . tags . slice ( 0 , 3 ) . map ( tag => ` <span class="template-tag"> ${ tag } </span> ` ) . join ( '' ) }
< / d i v >
< div class = "template-card-colors" >
$ { t . colors . map ( c => ` <div class="template-color-dot" style="background: ${ c } "></div> ` ) . join ( '' ) }
< / d i v >
< / d i v >
` ;
card . addEventListener ( 'click' , ( ) => showTemplateConfirm ( t ) ) ;
grid . appendChild ( card ) ;
} ) ;
}
function showTemplateConfirm ( template ) {
pendingTemplateId = template . id ;
const modal = document . getElementById ( 'template-modal' ) ;
document . getElementById ( 'template-modal-title' ) . textContent = template . name ;
document . getElementById ( 'template-modal-desc' ) . textContent = template . description + '\n\nUse case: ' + template . useCase ;
2026-03-01 14:13:02 -08:00
const warning = document . getElementById ( 'template-modal-warning' ) ;
if ( warning ) {
if ( template . pages && template . pages . length > 0 ) {
warning . textContent = '⚠️ This will replace ALL pages in your project with ' + template . pages . length + ' pages from this template.' ;
} else {
warning . textContent = '⚠️ This will replace all content on your current page.' ;
}
}
2026-02-28 19:25:42 +00:00
modal . style . display = 'flex' ;
}
// Templates browser modal handlers
const templatesBrowserModal = document . getElementById ( 'templates-browser-modal' ) ;
const templatesBrowserClose = document . getElementById ( 'templates-browser-close' ) ;
const btnTemplates = document . getElementById ( 'btn-templates' ) ;
function openTemplatesBrowser ( ) {
if ( templatesBrowserModal ) {
templatesBrowserModal . style . display = 'flex' ;
// Reload templates when opened
if ( templateIndex . length > 0 ) {
renderTemplateGrid ( templateIndex ) ;
}
}
}
function closeTemplatesBrowser ( ) {
if ( templatesBrowserModal ) {
templatesBrowserModal . style . display = 'none' ;
}
}
if ( btnTemplates ) {
btnTemplates . addEventListener ( 'click' , openTemplatesBrowser ) ;
}
if ( templatesBrowserClose ) {
templatesBrowserClose . addEventListener ( 'click' , closeTemplatesBrowser ) ;
}
if ( templatesBrowserModal ) {
// Close on background click
templatesBrowserModal . addEventListener ( 'click' , ( e ) => {
if ( e . target === templatesBrowserModal ) {
closeTemplatesBrowser ( ) ;
}
} ) ;
// Close on ESC key
document . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Escape' && templatesBrowserModal . style . display === 'flex' ) {
closeTemplatesBrowser ( ) ;
}
} ) ;
}
// Template modal events
const templateModal = document . getElementById ( 'template-modal' ) ;
const templateModalClose = document . getElementById ( 'template-modal-close' ) ;
const templateModalCancel = document . getElementById ( 'template-modal-cancel' ) ;
const templateModalConfirm = document . getElementById ( 'template-modal-confirm' ) ;
function closeTemplateModal ( ) {
templateModal . style . display = 'none' ;
pendingTemplateId = null ;
}
if ( templateModalClose ) templateModalClose . addEventListener ( 'click' , closeTemplateModal ) ;
if ( templateModalCancel ) templateModalCancel . addEventListener ( 'click' , closeTemplateModal ) ;
if ( templateModal ) templateModal . addEventListener ( 'click' , ( e ) => {
if ( e . target === templateModal ) closeTemplateModal ( ) ;
} ) ;
if ( templateModalConfirm ) {
templateModalConfirm . addEventListener ( 'click' , async ( ) => {
if ( ! pendingTemplateId ) return ;
const template = templateIndex . find ( t => t . id === pendingTemplateId ) ;
if ( ! template ) return ;
try {
templateModalConfirm . textContent = 'Loading...' ;
templateModalConfirm . disabled = true ;
2026-03-01 14:13:02 -08:00
if ( template . pages && template . pages . length > 0 ) {
// Multi-page template: fetch all page HTML files in parallel
const fetches = template . pages . map ( async ( p ) => {
const resp = await fetch ( 'templates/' + p . file ) ;
if ( ! resp . ok ) throw new Error ( ` HTTP ${ resp . status } : ${ resp . statusText } ( ${ p . file } ) ` ) ;
return resp . text ( ) ;
} ) ;
const htmls = await Promise . all ( fetches ) ;
// Save current page content before clearing
saveCurrentPageContent ( ) ;
// Build new pages array from template
pages = template . pages . map ( ( p , i ) => ( {
id : generateId ( ) ,
name : p . name ,
slug : p . slug ,
html : htmls [ i ] ,
css : ''
} ) ) ;
// Set current page to first page and save
currentPageId = pages [ 0 ] . id ;
savePages ( ) ;
// Load first page into editor
editor . DomComponents . clear ( ) ;
editor . CssComposer . clear ( ) ;
editor . setComponents ( pages [ 0 ] . html ) ;
renderPagesList ( ) ;
} else {
// Single-page template: existing behavior
const resp = await fetch ( 'templates/' + template . file ) ;
if ( ! resp . ok ) throw new Error ( ` HTTP ${ resp . status } : ${ resp . statusText } ` ) ;
const html = await resp . text ( ) ;
// Clear canvas and load template HTML
editor . DomComponents . clear ( ) ;
editor . CssComposer . clear ( ) ;
editor . setComponents ( html ) ;
}
2026-02-28 19:25:42 +00:00
closeTemplateModal ( ) ;
closeTemplatesBrowser ( ) ; // Also close the templates browser
// Show success notification
const status = document . getElementById ( 'save-status' ) ;
if ( status ) {
const statusText = status . querySelector ( '.status-text' ) ;
const statusDot = status . querySelector ( '.status-dot' ) ;
if ( statusText ) statusText . textContent = 'Template loaded!' ;
if ( statusDot ) statusDot . style . background = '#10b981' ;
setTimeout ( ( ) => {
if ( statusText ) statusText . textContent = 'Saved' ;
if ( statusDot ) statusDot . style . background = '' ;
} , 2000 ) ;
}
} catch ( e ) {
console . error ( 'Failed to load template:' , e ) ;
alert ( 'Failed to load template: ' + e . message + '\n\nPlease check the console for details.' ) ;
} finally {
templateModalConfirm . textContent = 'Use Template' ;
templateModalConfirm . disabled = false ;
}
} ) ;
}
// Template filter buttons
document . querySelectorAll ( '.template-filter-btn' ) . forEach ( btn => {
btn . addEventListener ( 'click' , ( ) => {
document . querySelectorAll ( '.template-filter-btn' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
btn . classList . add ( 'active' ) ;
const cat = btn . dataset . category ;
if ( cat === 'all' ) {
renderTemplateGrid ( templateIndex ) ;
} else {
renderTemplateGrid ( templateIndex . filter ( t => t . category === cat ) ) ;
}
} ) ;
} ) ;
// Load templates on init
loadTemplateIndex ( ) ;
} ) ( ) ;