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 : [
2026-04-26 21:31:58 -07:00
// Custom plugin to fix image rendering (must be first)
function whpImagePlugin ( editor ) {
// Override the image component type so real images render
// instead of GrapesJS's SVG placeholder
editor . DomComponents . addType ( 'image' , {
model : {
defaults : {
tagName : 'img' ,
type : 'image' ,
resizable : { ratioDefault : 1 },
traits : [ 'alt' ],
src : '' ,
void : true ,
},
},
view : {
tagName : 'img' ,
events : {},
onRender ({ el , model }) {
const src = model . getAttributes (). src || model . get ( 'src' ) || '' ;
if ( src ) {
el . src = src ;
}
// On error: show a subtle visual indicator, NOT the GrapesJS SVG
el . onerror = function () {
this . style . outline = '2px dashed #f59e0b' ;
this . style . minHeight = '60px' ;
this . style . minWidth = '100px' ;
this . style . background = 'rgba(245,158,11,0.1)' ;
};
el . onload = function () {
this . style . outline = '' ;
this . style . minHeight = '' ;
this . style . minWidth = '' ;
this . style . background = '' ;
};
},
},
});
},
2026-02-28 19:25:42 +00:00
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: 480px) {
.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: 6px;
min-height: 28px;
border: 1px dashed #9ca3af;
padding: 4px 8px;
background: rgba(59,130,246,0.05);
border-radius: 4px;
}
.editor-anchor .anchor-icon {
font-size: 14px;
color: #6b7280;
line-height: 1;
}
.editor-anchor .anchor-name-input {
border: none;
background: transparent;
color: #374151;
font-size: 12px;
font-family: Inter, sans-serif;
font-weight: 500;
padding: 2px 4px;
outline: none;
min-width: 80px;
}
.editor-anchor .anchor-name-input:focus {
background: rgba(255,255,255,0.5);
border-radius: 2px;
}
` )
],
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</span>
</div>
<span style="font-size:20px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;">SiteName</span>
</a>` ,
attributes : { class : 'fa fa-bookmark' }
});
// Navigation Menu (will be updated dynamically based on pages)
blockManager . add ( 'navbar' , {
2026-04-26 21:31:58 -07:00
label : 'Menu' ,
category : 'Basic' ,
2026-02-28 19:25:42 +00:00
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' ,
2026-04-26 21:31:58 -07:00
attributes : { href : '/' },
2026-02-28 19:25:42 +00:00
style : {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
},
content : 'Home'
},
{
tagName : 'a' ,
2026-04-26 21:31:58 -07:00
attributes : { href : 'about' },
2026-02-28 19:25:42 +00:00
style : {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
},
content : 'About'
},
{
tagName : 'a' ,
2026-04-26 21:31:58 -07:00
attributes : { href : 'contact' },
2026-02-28 19:25:42 +00:00
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></iframe>
<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></video>
<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;">▶</div>
<div style="font-size:12px;opacity:0.7;">Click this section, then add Video URL in Settings →</div>
</div>
</div>
</div>
<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;"></div>
<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</h2>
<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>
</div>
</section>` ,
attributes : { class : 'fa fa-play-circle' }
});
// Footer
blockManager . add ( 'footer' , {
label : 'Footer' ,
2026-04-26 21:31:58 -07:00
category : 'Basic' ,
2026-02-28 19:25:42 +00:00
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>
</div>
<p style="font-size:14px;font-family:Inter,sans-serif;">© 2024 Your Company. All rights reserved.</p>
</div>
</footer>` ,
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;"></div>
</div>` ,
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;"></div>
<div class="cell" data-gjs-draggable=".row" style="flex:1;min-height:75px;padding:10px;"></div>
</div>` ,
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;"></div>
<div class="cell" data-gjs-draggable=".row" style="flex:1;min-height:75px;padding:10px;"></div>
<div class="cell" data-gjs-draggable=".row" style="flex:1;min-height:75px;padding:10px;"></div>
</div>` ,
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;"></div>
<div class="cell" data-gjs-draggable=".row" style="flex:1;min-height:75px;padding:10px;"></div>
<div class="cell" data-gjs-draggable=".row" style="flex:1;min-height:75px;padding:10px;"></div>
<div class="cell" data-gjs-draggable=".row" style="flex:1;min-height:75px;padding:10px;"></div>
</div>` ,
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;"></div>
<div class="cell" data-gjs-draggable=".row" style="flex-basis:70%;min-height:75px;padding:10px;"></div>
</div>` ,
attributes : { class : 'gjs-fonts gjs-f-b37' }
});
// Image Block
blockManager . add ( 'image-block' , {
label : 'Image' ,
category : 'Media' ,
2026-04-26 21:31:58 -07:00
content : `<img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='400' fill='%23e2e8f0'%3E%3Crect width='800' height='400' fill='%23f1f5f9' rx='8'/%3E%3Ctext x='400' y='190' text-anchor='middle' font-family='sans-serif' font-size='24' fill='%2394a3b8'%3EClick to change image%3C/text%3E%3Ctext x='400' y='225' text-anchor='middle' font-family='sans-serif' font-size='14' fill='%23cbd5e1'%3EDrag an image here or select from assets%3C/text%3E%3C/svg%3E" style="max-width:100%;height:auto;display:block;border-radius:8px;" alt="Image">` ,
2026-02-28 19:25:42 +00:00
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></iframe>
<video class="video-player" style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;display:none;" controls></video>
<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;">▶</div>
<div style="font-size:14px;opacity:0.8;">Select container & add Video URL in Settings</div>
<div style="font-size:12px;opacity:0.6;margin-top:8px;">Supports YouTube, Vimeo, or direct video files</div>
</div>
</div>
</div>` ,
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);"></div>
<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</h1>
<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>
</div>
</section>` ,
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></iframe>
<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></video>
<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;">▶</div>
<div style="font-size:12px;opacity:0.7;">Click this section, then add Video URL in Settings →</div>
</div>
</div>
</div>
<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;"></div>
<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</h1>
<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>
</div>
</section>` ,
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</h1>
<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>
</div>
</section>` ,
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</h2>
<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;"></div>
<h3 style="font-size:20px;font-weight:600;margin-bottom:12px;color:#1f2937;font-family:Inter,sans-serif;">Feature One</h3>
<p style="color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;">Description of your first amazing feature goes here.</p>
</div>
<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;"></div>
<h3 style="font-size:20px;font-weight:600;margin-bottom:12px;color:#1f2937;font-family:Inter,sans-serif;">Feature Two</h3>
<p style="color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;">Description of your second amazing feature goes here.</p>
</div>
<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;"></div>
<h3 style="font-size:20px;font-weight:600;margin-bottom:12px;color:#1f2937;font-family:Inter,sans-serif;">Feature Three</h3>
<p style="color:#6b7280;line-height:1.6;font-family:Inter,sans-serif;">Description of your third amazing feature goes here.</p>
</div>
</div>
</div>
</section>` ,
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</h2>
<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;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
</div>
<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</div>
<div>
<div style="font-weight:600;color:#1f2937;font-family:Inter,sans-serif;">John Doe</div>
<div style="font-size:14px;color:#6b7280;font-family:Inter,sans-serif;">CEO, Company Inc</div>
</div>
</div>
</div>
<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;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
</div>
<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</div>
<div>
<div style="font-weight:600;color:#1f2937;font-family:Inter,sans-serif;">Jane Smith</div>
<div style="font-size:14px;color:#6b7280;font-family:Inter,sans-serif;">Designer, Studio Co</div>
</div>
</div>
</div>
<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;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
<span style="color:#f59e0b;font-size:20px;">★</span>
</div>
<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</div>
<div>
<div style="font-weight:600;color:#1f2937;font-family:Inter,sans-serif;">Mike Brown</div>
<div style="font-size:14px;color:#6b7280;font-family:Inter,sans-serif;">Founder, StartupXYZ</div>
</div>
</div>
</div>
</div>
</div>
</section>` ,
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</h2>
<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</h3>
<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</span>
<span style="font-size:16px;color:#6b7280;font-family:Inter,sans-serif;">/month</span>
</div>
<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</li>
<li style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;">✓ Basic Support</li>
<li style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;">✓ 1GB Storage</li>
<li style="padding:12px 0;color:#374151;font-family:Inter,sans-serif;">✓ Community Access</li>
</ul>
<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>
</div>
<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</div>
<h3 style="font-size:24px;font-weight:600;margin-bottom:8px;color:#fff;font-family:Inter,sans-serif;">Professional</h3>
<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</span>
<span style="font-size:16px;color:rgba(255,255,255,0.8);font-family:Inter,sans-serif;">/month</span>
</div>
<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</li>
<li style="padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.2);color:#fff;font-family:Inter,sans-serif;">✓ Priority Support</li>
<li style="padding:12px 0;border-bottom:1px solid rgba(255,255,255,0.2);color:#fff;font-family:Inter,sans-serif;">✓ 10GB Storage</li>
<li style="padding:12px 0;color:#fff;font-family:Inter,sans-serif;">✓ Advanced Analytics</li>
</ul>
<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>
</div>
<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</h3>
<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</span>
<span style="font-size:16px;color:#6b7280;font-family:Inter,sans-serif;">/month</span>
</div>
<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</li>
<li style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;">✓ Dedicated Support</li>
<li style="padding:12px 0;border-bottom:1px solid #e5e7eb;color:#374151;font-family:Inter,sans-serif;">✓ Unlimited Storage</li>
<li style="padding:12px 0;color:#374151;font-family:Inter,sans-serif;">✓ Custom Integrations</li>
</ul>
<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>
</div>
</div>
</div>
</section>` ,
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</h2>
<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;">📍</div>
<div>
<div style="font-weight:600;color:#1f2937;font-family:Inter,sans-serif;">Address</div>
<div style="color:#6b7280;font-family:Inter,sans-serif;">123 Business Street, City, ST 12345</div>
</div>
</div>
<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;">📧</div>
<div>
<div style="font-weight:600;color:#1f2937;font-family:Inter,sans-serif;">Email</div>
<div style="color:#6b7280;font-family:Inter,sans-serif;">hello@example.com</div>
</div>
</div>
<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;">📞</div>
<div>
<div style="font-weight:600;color:#1f2937;font-family:Inter,sans-serif;">Phone</div>
<div style="color:#6b7280;font-family:Inter,sans-serif;">(555) 123-4567</div>
</div>
</div>
</div>
</div>
<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</label>
<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;">
</div>
<div style="margin-bottom:20px;">
<label style="display:block;font-weight:500;margin-bottom:8px;color:#374151;font-family:Inter,sans-serif;">Email</label>
<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;">
</div>
<div style="margin-bottom:20px;">
<label style="display:block;font-weight:500;margin-bottom:8px;color:#374151;font-family:Inter,sans-serif;">Message</label>
<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;"></textarea>
</div>
<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</button>
</form>
</div>
</div>
</div>
</section>` ,
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?</h2>
<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>
</div>
</div>
</section>` ,
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">⚓</span>
<input type="text" class="anchor-name-input" value="anchor-1" placeholder="anchor-name" />
</div>` ,
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"></iframe>
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"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="12" y1="18" x2="12" y2="12"></line><polyline points="9 15 12 18 15 15"></polyline></svg>
<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</div>
<div class="file-download-hint" style="font-size:12px;color:#6b7280;margin-top:2px;">Click to download</div>
</div>
<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"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
</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;">📄</div>
<div style="font-size:14px;">Select this element, then enter File URL in Settings</div>
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</div>
2026-02-28 19:25:42 +00:00
</div>
</div>
</div>` ,
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</h2>
<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;">
</div>
<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;">
</div>
<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;">
</div>
<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;">
</div>
<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;">
</div>
<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;">
</div>
</div>
</div>
</section>` ,
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</h2>
<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?</summary>
<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>
</details>
<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?</summary>
<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>
</details>
<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?</summary>
<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, 9am-5pm EST.</p>
</details>
<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?</summary>
<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>
</details>
</div>
</div>
</section>` ,
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;">10K+</div>
<div style="font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;">Happy Customers</div>
</div>
<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+</div>
<div style="font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;">Projects Completed</div>
</div>
<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%</div>
<div style="font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;">Satisfaction Rate</div>
</div>
<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</div>
<div style="font-size:16px;color:#9ca3af;font-family:Inter,sans-serif;">Support Available</div>
</div>
</div>
</div>
</section>` ,
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</h2>
<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</div>
<h3 style="font-size:20px;font-weight:600;color:#1f2937;margin-bottom:4px;font-family:Inter,sans-serif;">Alex Johnson</h3>
<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>
</div>
<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</div>
<h3 style="font-size:20px;font-weight:600;color:#1f2937;margin-bottom:4px;font-family:Inter,sans-serif;">Sarah Kim</h3>
<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>
</div>
<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</div>
<h3 style="font-size:20px;font-weight:600;color:#1f2937;margin-bottom:4px;font-family:Inter,sans-serif;">Mike Patel</h3>
<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>
</div>
</div>
</div>
</section>` ,
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</h2>
<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</button>
</form>
<p style="font-size:13px;color:#9ca3af;margin-top:16px;font-family:Inter,sans-serif;">No spam, unsubscribe anytime.</p>
</div>
</section>` ,
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</div>
<div style="font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;">Company 2</div>
<div style="font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;">Company 3</div>
<div style="font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;">Company 4</div>
<div style="font-size:24px;font-weight:700;color:#1f2937;font-family:Inter,sans-serif;">Company 5</div>
</div>
</div>
</section>` ,
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 }));
2026-04-26 21:31:58 -07:00
// Open preview (pass site_id in WHP mode so Back button works)
const previewUrl = ( window . WHP_CONFIG )
? 'preview.html?site_id=' + WHP_CONFIG . siteId
: 'preview.html' ;
window . open ( previewUrl , '_blank' );
2026-02-28 19:25:42 +00:00
});
// ==========================================
// 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' ),
2026-04-26 21:31:58 -07:00
imageSrc : document . getElementById ( 'section-image-src' ),
videoSrc : document . getElementById ( 'section-video-src' ),
alignment : document . getElementById ( 'section-alignment' ),
2026-02-28 19:25:42 +00:00
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' ;
2026-04-26 21:31:58 -07:00
// If it's a video section, also show video controls
const attrs = component . getAttributes ();
if ( attrs [ 'data-video-section' ] === 'true' ) {
sections . videoSrc . style . display = 'block' ;
loadVideoSrcValues ( component );
}
2026-02-28 19:25:42 +00:00
loadBgImageValues ( component );
return ;
}
2026-04-26 21:31:58 -07:00
// Video wrapper (regular video block)
if ( component . getAttributes ()[ 'data-video-wrapper' ] === 'true' ) {
sections . videoSrc . style . display = 'block' ;
sections . spacing . style . display = 'block' ;
sections . radius . style . display = 'block' ;
loadVideoSrcValues ( component );
return ;
}
2026-02-28 19:25:42 +00:00
// 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' ;
2026-04-26 21:31:58 -07:00
sections . alignment . style . display = 'block' ;
2026-02-28 19:25:42 +00:00
// 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 );
}
2026-04-26 21:31:58 -07:00
updateAlignmentButtons ( component );
2026-02-28 19:25:42 +00:00
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' ;
2026-04-26 21:31:58 -07:00
sections . alignment . style . display = 'block' ;
updateAlignmentButtons ( component );
2026-02-28 19:25:42 +00:00
// 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' ;
2026-04-26 21:31:58 -07:00
sections . alignment . style . display = 'block' ;
2026-02-28 19:25:42 +00:00
sections . spacing . style . display = 'block' ;
sections . radius . style . display = 'block' ;
loadBgImageValues ( component );
2026-04-26 21:31:58 -07:00
updateAlignmentButtons ( component );
2026-02-28 19:25:42 +00:00
break ;
case 'media' :
2026-04-26 21:31:58 -07:00
const mediaTag = component . get ( 'tagName' ) ? . toLowerCase ();
if ( mediaTag === 'img' ) {
sections . imageSrc . style . display = 'block' ;
loadImageSrcValues ( component );
}
sections . alignment . style . display = 'block' ;
2026-02-28 19:25:42 +00:00
sections . spacing . style . display = 'block' ;
sections . radius . style . display = 'block' ;
2026-04-26 21:31:58 -07:00
updateAlignmentButtons ( component );
2026-02-28 19:25:42 +00:00
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 ;
2026-04-26 21:31:58 -07:00
const linksContainer = component . components (). find ( c => c . getClasses (). includes ( 'nav-links' ));
2026-02-28 19:25:42 +00:00
if ( ! linksContainer ) {
2026-04-26 21:31:58 -07:00
navLinksList . textContent = 'No links container found' ;
2026-02-28 19:25:42 +00:00
return ;
}
const links = linksContainer . components (). filter ( c => c . get ( 'tagName' ) ? . toLowerCase () === 'a' );
2026-04-26 21:31:58 -07:00
navLinksList . textContent = '' ;
if ( links . length === 0 ) {
const msg = document . createElement ( 'p' );
msg . className = 'no-links-msg' ;
msg . textContent = 'No links yet. Use the buttons above to add links.' ;
navLinksList . appendChild ( msg );
return ;
}
2026-02-28 19:25:42 +00:00
2026-04-26 21:31:58 -07:00
let dragSrcIndex = null ;
2026-02-28 19:25:42 +00:00
links . forEach (( link , index ) => {
const item = document . createElement ( 'div' );
item . className = 'nav-link-item' ;
2026-04-26 21:31:58 -07:00
item . draggable = true ;
item . dataset . index = index ;
item . style . cssText = 'display:flex;flex-direction:column;gap:4px;padding:8px;background:#1e1e2a;border-radius:6px;margin-bottom:4px;border:1px solid transparent;transition:border-color 0.15s;' ;
const isCta = link . getClasses (). includes ( 'nav-cta' );
const href = link . getAttributes (). href || '#' ;
const text = link . getEl () ? . textContent || link . components () ? . at ( 0 ) ? . get ( 'content' ) || 'Link' ;
// Drag events for reordering
item . addEventListener ( 'dragstart' , ( e ) => {
dragSrcIndex = index ;
item . style . opacity = '0.4' ;
e . dataTransfer . effectAllowed = 'move' ;
});
item . addEventListener ( 'dragend' , () => { item . style . opacity = '1' ; });
item . addEventListener ( 'dragover' , ( e ) => {
e . preventDefault ();
e . dataTransfer . dropEffect = 'move' ;
item . style . borderColor = '#3b82f6' ;
});
item . addEventListener ( 'dragleave' , () => { item . style . borderColor = 'transparent' ; });
item . addEventListener ( 'drop' , ( e ) => {
e . preventDefault ();
item . style . borderColor = 'transparent' ;
if ( dragSrcIndex === null || dragSrcIndex === index ) return ;
// Reorder in GrapesJS model
const movedLink = links [ dragSrcIndex ];
const targetPos = index ;
movedLink . move ( linksContainer , { at : targetPos });
dragSrcIndex = null ;
loadNavLinks ( component );
});
2026-02-28 19:25:42 +00:00
2026-04-26 21:31:58 -07:00
// Row 1: drag handle + text input + delete
const row1 = document . createElement ( 'div' );
row1 . style . cssText = 'display:flex;align-items:center;gap:4px;' ;
const dragHandle = document . createElement ( 'span' );
dragHandle . style . cssText = 'cursor:grab;color:#71717a;font-size:14px;padding:0 2px;user-select:none;' ;
dragHandle . textContent = '\u2630' ; // hamburger icon ☰
dragHandle . title = 'Drag to reorder' ;
const textInput = document . createElement ( 'input' );
textInput . type = 'text' ;
textInput . value = text ;
textInput . className = 'guided-input' ;
textInput . style . cssText = 'flex:1;padding:4px 8px;font-size:12px;' ;
textInput . placeholder = 'Link text' ;
textInput . addEventListener ( 'change' , () => {
const el = link . getEl ();
if ( el ) el . textContent = textInput . value ;
link . components ( textInput . value );
});
2026-02-28 19:25:42 +00:00
const deleteBtn = document . createElement ( 'button' );
2026-04-26 21:31:58 -07:00
deleteBtn . style . cssText = 'background:none;border:none;color:#ef4444;cursor:pointer;padding:2px 4px;font-size:14px;line-height:1;' ;
deleteBtn . textContent = '\u00d7' ;
2026-02-28 19:25:42 +00:00
deleteBtn . title = 'Remove link' ;
deleteBtn . addEventListener ( 'click' , () => {
link . remove ();
loadNavLinks ( component );
});
2026-04-26 21:31:58 -07:00
if ( isCta ) {
const ctaBadge = document . createElement ( 'span' );
ctaBadge . style . cssText = 'font-size:9px;background:#3b82f6;color:#fff;padding:2px 6px;border-radius:3px;white-space:nowrap;' ;
ctaBadge . textContent = 'CTA' ;
row1 . appendChild ( ctaBadge );
}
row1 . appendChild ( dragHandle );
row1 . appendChild ( textInput );
row1 . appendChild ( deleteBtn );
// Row 2: URL selector
const row2 = document . createElement ( 'div' );
row2 . style . cssText = 'display:flex;align-items:center;gap:4px;padding-left:22px;' ;
const urlSelect = document . createElement ( 'select' );
urlSelect . className = 'guided-input' ;
urlSelect . style . cssText = 'flex:1;padding:3px 6px;font-size:11px;' ;
const customOpt = document . createElement ( 'option' );
customOpt . value = '__custom__' ;
customOpt . textContent = 'Custom URL...' ;
urlSelect . appendChild ( customOpt );
const anchorOpt = document . createElement ( 'option' );
anchorOpt . value = '__anchor__' ;
anchorOpt . textContent = 'Anchor on page (#)...' ;
urlSelect . appendChild ( anchorOpt );
pages . forEach ( p => {
const opt = document . createElement ( 'option' );
opt . value = p . slug === 'index' ? '/' : p . slug ;
opt . textContent = p . name + ' (page)' ;
const matchSlugs = [ opt . value , p . slug + '.html' , p . slug === 'index' ? 'index.html' : null , p . slug === 'index' ? '#' : null ]. filter ( Boolean );
if ( matchSlugs . includes ( href )) opt . selected = true ;
urlSelect . appendChild ( opt );
});
const allPageSlugs = pages . flatMap ( p => {
const s = p . slug === 'index' ? '/' : p . slug ;
return [ s , p . slug + '.html' , p . slug === 'index' ? 'index.html' : null , p . slug === 'index' ? '#' : null ]. filter ( Boolean );
});
const isAnchor = href . startsWith ( '#' ) && href !== '#' ;
const isCustom = ! isAnchor && ! allPageSlugs . includes ( href );
const urlInput = document . createElement ( 'input' );
urlInput . type = 'text' ;
urlInput . className = 'guided-input' ;
urlInput . style . cssText = 'flex:1;padding:3px 8px;font-size:11px;display:' + (( isCustom || isAnchor ) ? 'block' : 'none' ) + ';' ;
urlInput . placeholder = isAnchor ? '#section-id' : 'https://...' ;
urlInput . value = ( isCustom || isAnchor ) ? href : '' ;
if ( isCustom ) urlSelect . value = '__custom__' ;
if ( isAnchor ) urlSelect . value = '__anchor__' ;
urlSelect . addEventListener ( 'change' , () => {
if ( urlSelect . value === '__custom__' ) {
urlInput . style . display = 'block' ;
urlInput . placeholder = 'https://...' ;
urlInput . value = '' ;
urlInput . focus ();
} else if ( urlSelect . value === '__anchor__' ) {
urlInput . style . display = 'block' ;
urlInput . placeholder = '#section-id' ;
urlInput . value = '#' ;
urlInput . focus ();
} else {
urlInput . style . display = 'none' ;
link . addAttributes ({ href : urlSelect . value });
// Remove target blank for internal links
link . removeAttributes ( 'target' );
link . removeAttributes ( 'rel' );
}
});
urlInput . addEventListener ( 'change' , () => {
const val = urlInput . value . trim ();
if ( val ) {
link . addAttributes ({ href : val });
if ( val . startsWith ( 'http://' ) || val . startsWith ( 'https://' )) {
link . addAttributes ({ target : '_blank' , rel : 'noopener' });
} else {
link . removeAttributes ( 'target' );
link . removeAttributes ( 'rel' );
}
}
});
row2 . appendChild ( urlSelect );
row2 . appendChild ( urlInput );
item . appendChild ( row1 );
item . appendChild ( row2 );
2026-02-28 19:25:42 +00:00
navLinksList . appendChild ( item );
});
}
// 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' );
});
});
2026-04-26 21:31:58 -07:00
// Alignment presets
document . querySelectorAll ( '.alignment-preset' ). forEach ( btn => {
btn . addEventListener ( 'click' , () => {
const selected = editor . getSelected ();
if ( ! selected ) return ;
const align = btn . dataset . align ;
const elementType = getElementType ( selected . get ( 'tagName' ));
if ( elementType === 'container' ) {
// For containers, set text-align for child content AND flex alignment
applyStyle ( 'text-align' , align );
// Also set align-items for flex containers
const currentDisplay = selected . getStyle ()[ 'display' ];
if ( currentDisplay === 'flex' || currentDisplay === 'inline-flex' ) {
const justifyMap = { left : 'flex-start' , center : 'center' , right : 'flex-end' , justify : 'space-between' };
applyStyle ( 'justify-content' , justifyMap [ align ] || align );
applyStyle ( 'align-items' , 'center' );
}
} else if ( elementType === 'media' ) {
// For images/media, center via parent's text-align or margin auto
const parent = selected . parent ();
if ( parent ) {
parent . addStyle ({ 'text-align' : align });
}
if ( align === 'center' ) {
applyStyle ( 'margin-left' , 'auto' );
applyStyle ( 'margin-right' , 'auto' );
applyStyle ( 'display' , 'block' );
} else if ( align === 'left' ) {
applyStyle ( 'margin-left' , '0' );
applyStyle ( 'margin-right' , 'auto' );
} else if ( align === 'right' ) {
applyStyle ( 'margin-left' , 'auto' );
applyStyle ( 'margin-right' , '0' );
}
} else {
// For text elements, set text-align
applyStyle ( 'text-align' , align );
}
document . querySelectorAll ( '.alignment-preset' ). forEach ( b => b . classList . remove ( 'active' ));
btn . classList . add ( 'active' );
});
});
// Helper to highlight the active alignment button based on current component styles
function updateAlignmentButtons ( component ) {
if ( ! component ) return ;
const styles = component . getStyle ();
const currentAlign = styles [ 'text-align' ] || '' ;
document . querySelectorAll ( '.alignment-preset' ). forEach ( btn => {
btn . classList . toggle ( 'active' , btn . dataset . align === currentAlign );
});
}
// Image source controls
const imageSrcInput = document . getElementById ( 'image-src-input' );
const imageAltInput = document . getElementById ( 'image-alt-input' );
const imageSrcUpload = document . getElementById ( 'image-src-upload' );
const imageSrcBrowse = document . getElementById ( 'image-src-browse' );
const imageSrcUrl = document . getElementById ( 'image-src-url' );
function loadImageSrcValues ( component ) {
if ( ! component ) return ;
const attrs = component . getAttributes ();
const src = attrs . src || '' ;
// Show a friendly name instead of the full proxy URL
const filename = src . includes ( 'filename=' ) ? decodeURIComponent ( src . split ( 'filename=' ). pop (). split ( '&' )[ 0 ]) : src . split ( '/' ). pop ();
imageSrcInput . value = filename || 'No image set' ;
imageAltInput . value = attrs . alt || '' ;
}
if ( imageAltInput ) {
imageAltInput . addEventListener ( 'change' , () => {
const selected = editor . getSelected ();
if ( selected && selected . get ( 'tagName' ) ? . toLowerCase () === 'img' ) {
selected . addAttributes ({ alt : imageAltInput . value });
}
});
}
if ( imageSrcUpload ) {
imageSrcUpload . addEventListener ( 'click' , () => {
const fileInput = document . createElement ( 'input' );
fileInput . type = 'file' ;
fileInput . accept = 'image/*' ;
fileInput . addEventListener ( 'change' , async () => {
const file = fileInput . files [ 0 ];
if ( ! file ) return ;
const selected = editor . getSelected ();
if ( ! selected ) return ;
if ( window . assetManager && window . assetManager . serverAvailable ) {
const asset = await window . assetManager . _uploadFileToServer ( file , 'image' );
if ( asset && asset . url ) {
selected . addAttributes ({ src : asset . url });
selected . set ( 'src' , asset . url );
loadImageSrcValues ( selected );
}
}
});
fileInput . click ();
});
}
if ( imageSrcBrowse ) {
imageSrcBrowse . addEventListener ( 'click' , async () => {
if ( window . assetManager ) {
const asset = await window . assetManager . openBrowser ( 'image' );
if ( asset && asset . url ) {
const selected = editor . getSelected ();
if ( selected ) {
selected . addAttributes ({ src : asset . url });
selected . set ( 'src' , asset . url );
loadImageSrcValues ( selected );
}
}
}
});
}
if ( imageSrcUrl ) {
imageSrcUrl . addEventListener ( 'click' , () => {
const selected = editor . getSelected ();
if ( ! selected ) return ;
const current = selected . getAttributes (). src || '' ;
const url = prompt ( 'Enter image URL:' , current );
if ( url !== null && url . trim ()) {
selected . addAttributes ({ src : url . trim () });
selected . set ( 'src' , url . trim ());
loadImageSrcValues ( selected );
}
});
}
// Video source controls
const videoSrcInput = document . getElementById ( 'video-src-input' );
const videoSrcApply = document . getElementById ( 'video-src-apply' );
const videoSrcBrowse = document . getElementById ( 'video-src-browse' );
const videoSrcStatus = document . getElementById ( 'video-src-status' );
function loadVideoSrcValues ( component ) {
if ( ! component ) return ;
const attrs = component . getAttributes ();
const url = attrs . videoUrl || attrs . videourl || '' ;
if ( videoSrcInput ) videoSrcInput . value = url ;
if ( videoSrcStatus ) {
if ( url ) {
const result = convertToEmbedUrl ( url );
const typeLabel = result ? ( result . type === 'youtube' ? 'YouTube' : result . type === 'vimeo' ? 'Vimeo' : 'Direct video' ) : 'Unknown' ;
videoSrcStatus . textContent = typeLabel + ' detected' ;
} else {
videoSrcStatus . textContent = 'Paste a YouTube, Vimeo, or .mp4 URL' ;
}
}
}
function applyVideoAndRefreshCanvas ( component , url ) {
applyVideoUrl ( component , url );
// Try to render the video/iframe in the editor canvas
const frame = document . querySelector ( '.gjs-frame' );
if ( ! frame || ! frame . contentDocument ) return ;
const el = component . getEl ();
if ( ! el ) return ;
const result = convertToEmbedUrl ( url );
if ( ! result ) return ;
if ( result . type === 'file' ) {
// Direct video -- show HTML5 video element
let videoEl = el . querySelector ( 'video' );
if ( ! videoEl ) {
videoEl = frame . contentDocument . createElement ( 'video' );
videoEl . style . cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;' ;
videoEl . controls = true ;
el . appendChild ( videoEl );
}
videoEl . src = result . url ;
videoEl . style . display = 'block' ;
// Hide iframe and placeholder
const iframeEl = el . querySelector ( 'iframe' );
if ( iframeEl ) iframeEl . style . display = 'none' ;
const ph = el . querySelector ( '.video-placeholder, .bg-video-placeholder' );
if ( ph ) ph . style . display = 'none' ;
} else {
// YouTube/Vimeo -- show iframe
let iframeEl = el . querySelector ( 'iframe' );
if ( ! iframeEl ) {
iframeEl = frame . contentDocument . createElement ( 'iframe' );
iframeEl . style . cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;border:none;' ;
iframeEl . setAttribute ( 'allow' , 'accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture' );
iframeEl . setAttribute ( 'allowfullscreen' , '' );
el . appendChild ( iframeEl );
}
iframeEl . src = result . url ;
iframeEl . style . display = 'block' ;
// Hide video and placeholder
const videoEl = el . querySelector ( 'video' );
if ( videoEl ) videoEl . style . display = 'none' ;
const ph = el . querySelector ( '.video-placeholder, .bg-video-placeholder' );
if ( ph ) ph . style . display = 'none' ;
}
}
if ( videoSrcApply ) {
videoSrcApply . addEventListener ( 'click' , () => {
const selected = editor . getSelected ();
if ( ! selected ) return ;
const url = videoSrcInput . value . trim ();
if ( ! url ) return ;
selected . addAttributes ({ videoUrl : url });
applyVideoAndRefreshCanvas ( selected , url );
loadVideoSrcValues ( selected );
});
}
if ( videoSrcInput ) {
videoSrcInput . addEventListener ( 'keydown' , ( e ) => {
if ( e . key === 'Enter' ) {
e . preventDefault ();
videoSrcApply ? . click ();
}
});
}
if ( videoSrcBrowse ) {
videoSrcBrowse . addEventListener ( 'click' , async () => {
if ( ! window . assetManager ) return ;
const asset = await window . assetManager . openBrowser ( 'video' );
if ( asset && asset . url ) {
const selected = editor . getSelected ();
if ( ! selected ) return ;
videoSrcInput . value = asset . url ;
selected . addAttributes ({ videoUrl : asset . url });
applyVideoAndRefreshCanvas ( selected , asset . url );
loadVideoSrcValues ( selected );
}
});
}
2026-02-28 19:25:42 +00:00
// 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
2026-04-26 21:31:58 -07:00
// Helper: find nav-links container and CTA insert position
function getNavLinksContainer ( navComponent ) {
const linksContainer = navComponent . components (). find ( c => c . getClasses (). includes ( 'nav-links' ));
if ( ! linksContainer ) return null ;
const links = linksContainer . components ();
let insertIndex = links . length ;
links . forEach (( link , index ) => {
if ( link . getClasses (). includes ( 'nav-cta' )) insertIndex = index ;
});
return { container : linksContainer , insertIndex };
}
const defaultLinkStyle = {
'color' : '#4b5563' ,
'text-decoration' : 'none' ,
'font-size' : '15px' ,
'font-family' : 'Inter, sans-serif'
};
// Sync navigation with pages -- creates links to actual page files
2026-02-28 19:25:42 +00:00
syncNavPagesBtn . addEventListener ( 'click' , () => {
const selected = editor . getSelected ();
if ( ! selected || ! isNavigation ( selected )) return ;
2026-04-26 21:31:58 -07:00
const nav = getNavLinksContainer ( selected );
if ( ! nav ) { alert ( 'Navigation structure not recognized.' ); return ; }
2026-02-28 19:25:42 +00:00
2026-04-26 21:31:58 -07:00
// Preserve CTA
2026-02-28 19:25:42 +00:00
let ctaLink = null ;
2026-04-26 21:31:58 -07:00
nav . container . components (). forEach ( link => {
if ( link . getClasses (). includes ( 'nav-cta' )) ctaLink = link . clone ();
2026-02-28 19:25:42 +00:00
});
2026-04-26 21:31:58 -07:00
nav . container . components (). reset ();
2026-02-28 19:25:42 +00:00
2026-04-26 21:31:58 -07:00
// Add a link for each page using clean slugs (no .html)
pages . forEach ( page => {
const href = page . slug === 'index' ? '/' : page . slug ;
nav . container . components (). add ({
2026-02-28 19:25:42 +00:00
tagName : 'a' ,
2026-04-26 21:31:58 -07:00
attributes : { href },
style : { ... defaultLinkStyle },
2026-02-28 19:25:42 +00:00
content : page . name
});
});
2026-04-26 21:31:58 -07:00
if ( ctaLink ) nav . container . components (). add ( ctaLink );
2026-02-28 19:25:42 +00:00
loadNavLinks ( selected );
});
2026-04-26 21:31:58 -07:00
// Add page link
2026-02-28 19:25:42 +00:00
addNavLinkBtn . addEventListener ( 'click' , () => {
const selected = editor . getSelected ();
if ( ! selected || ! isNavigation ( selected )) return ;
2026-04-26 21:31:58 -07:00
const nav = getNavLinksContainer ( selected );
if ( ! nav ) return ;
2026-02-28 19:25:42 +00:00
2026-04-26 21:31:58 -07:00
nav . container . components (). add ({
2026-02-28 19:25:42 +00:00
tagName : 'a' ,
2026-04-26 21:31:58 -07:00
attributes : { href : '/' },
style : { ... defaultLinkStyle },
2026-02-28 19:25:42 +00:00
content : 'New Link'
2026-04-26 21:31:58 -07:00
}, { at : nav . insertIndex });
2026-02-28 19:25:42 +00:00
loadNavLinks ( selected );
});
2026-04-26 21:31:58 -07:00
// Add external link
const addNavExternalBtn = document . getElementById ( 'add-nav-external' );
if ( addNavExternalBtn ) {
addNavExternalBtn . addEventListener ( 'click' , () => {
const selected = editor . getSelected ();
if ( ! selected || ! isNavigation ( selected )) return ;
const nav = getNavLinksContainer ( selected );
if ( ! nav ) return ;
nav . container . components (). add ({
tagName : 'a' ,
attributes : { href : 'https://' , target : '_blank' , rel : 'noopener' },
style : { ... defaultLinkStyle },
content : 'External Link'
}, { at : nav . insertIndex });
loadNavLinks ( selected );
});
}
2026-02-28 19:25:42 +00:00
// ==========================================
// 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
2026-04-26 21:31:58 -07:00
document . querySelectorAll ( '.color-preset, .size-preset, .spacing-preset, .radius-preset, .thickness-preset, .font-preset, .weight-preset, .gradient-preset, .alignment-preset' )
2026-02-28 19:25:42 +00:00
. 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</h1>
<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>
</div>
</section>
` );
}
});
// ==========================================
// 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
}));
2026-04-26 21:31:58 -07:00
// Open preview (pass site_id in WHP mode so Back button works)
const previewUrl = ( window . WHP_CONFIG )
? 'preview.html?site_id=' + WHP_CONFIG . siteId
: 'preview.html' ;
window . open ( previewUrl , '_blank' );
2026-02-28 19:25:42 +00:00
});
// Make editor accessible globally for debugging
window . editor = editor ;
window . sitePages = pages ;
2026-04-26 21:31:58 -07:00
// ==========================================
// WHP Mode: Asset Manager integration
// ==========================================
const isWHP = !! window . WHP_CONFIG ;
if ( isWHP ) {
// Override asset manager upload URL to use WHP API
editor . AssetManager . getConfig (). upload = WHP_CONFIG . apiUrl + '?action=upload_asset&site_id=' + WHP_CONFIG . siteId ;
editor . AssetManager . getConfig (). uploadName = 'file' ;
// Custom headers for CSRF protection
editor . AssetManager . getConfig (). headers = {
'X-CSRF-Token' : WHP_CONFIG . csrfToken
};
// Load existing assets from WHP API
fetch ( WHP_CONFIG . apiUrl + '?action=list_assets&site_id=' + WHP_CONFIG . siteId )
. then ( r => r . json ())
. then ( data => {
if ( data . success && data . assets ) {
data . assets . forEach ( asset => {
const isImage = asset . type && ( asset . type === 'image' || asset . type . startsWith ( 'image/' ));
editor . AssetManager . add ({
src : asset . url ,
name : asset . name ,
type : isImage ? 'image' : 'file'
});
});
}
})
. catch ( e => console . log ( 'Failed to load assets:' , e ));
}
2026-02-28 19:25:42 +00:00
// ==========================================
// 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 } </title>
${ 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: 2px solid #3b82f6; outline-offset: 2px; }
:focus:not(:focus-visible) { outline: none; }
/* Touch-friendly: min tap targets */
a, button, input, select, textarea { min-height: 44px; }
button, [type="submit"] { cursor: pointer; touch-action: manipulation; }
/* Responsive columns */
@media (max-width: 768px) {
.row { flex-direction: column !important; }
.row .cell { flex-basis: 100% !important; width: 100% !important; }
section { padding-left: 16px !important; padding-right: 16px !important; }
}
@media (max-width: 480px) {
.row { flex-direction: column !important; }
.row .cell { flex-basis: 100% !important; width: 100% !important; }
h1 { font-size: 32px !important; }
h2 { font-size: 28px !important; }
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !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;
}
</style>
</head>
<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 || '' }
</main>
</body>
</html>` ;
}
// 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"></polyline>
</svg>
Copied!
` ;
exportCopyBtn . style . background = '#10b981' ;
setTimeout (() => {
exportCopyBtn . innerHTML = originalText ;
exportCopyBtn . style . background = '' ;
}, 2000 );
// Show instructions
alert ( `✅ HTML copied to clipboard!\n\nNext steps:\n1. Open Notepad (or any text editor)\n2. Paste (Ctrl+V)\n3. Save as " ${ currentPage . slug } .html"\n4. Open the saved file in your browser\n\nThis 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>
</div>
` ;
}
}
}
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"></rect>
<line x1="12" y1="8" x2="12" y2="16"></line>
<line x1="8" y1="12" x2="16" y2="12"></line>
</svg>
<span style="font-size:12px;">Blank Canvas</span>
</div>
</div>
<div class="template-card-info">
<div class="template-card-name">Start from Scratch</div>
<div class="template-card-desc">Begin with a blank page and build your own design.</div>
</div>
` ;
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 }
</div>
2026-02-28 19:25:42 +00:00
<div class="template-card-info">
<div class="template-card-name"> ${ t . name } </div>
<div class="template-card-desc"> ${ t . description } </div>
<div class="template-card-tags">
${ t . tags . slice ( 0 , 3 ). map ( tag => `<span class="template-tag"> ${ tag } </span>` ). join ( '' ) }
</div>
<div class="template-card-colors">
${ t . colors . map ( c => `<div class="template-color-dot" style="background: ${ c } "></div>` ). join ( '' ) }
</div>
</div>
` ;
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 ();
})();