,
// so show visual feedback via the DOM only (doesn't affect export)
const placeholderEl = placeholder.getEl();
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 = `
` +
`
${icon}
` +
`
${label}
` +
`
Video will play in Preview
` +
`
`;
placeholderEl.style.display = 'flex';
}
}
}
// 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'
},
{
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');
}
});
}
},
{
type: 'button',
label: '',
text: 'Apply Video',
full: true,
command: (editor) => {
const selected = editor.getSelected();
if (!selected) return;
const url = selected.getAttributes().videoUrl;
if (!url) {
alert('Please enter a Video URL first');
return;
}
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'
},
{
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');
}
});
}
},
{
type: 'button',
label: '',
text: 'Apply Video',
full: true,
command: (editor) => {
const selected = editor.getSelected();
if (!selected) return;
const url = selected.getAttributes().videoUrl;
if (!url) {
alert('Please enter a Video URL first');
return;
}
console.log('Apply Video button clicked, URL:', url);
// Find the bg-video-wrapper child
const videoWrapper = selected.components().find(c =>
c.getAttributes()['data-bg-video'] === 'true'
);
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;
const url = selected.getAttributes().fileUrl;
const height = selected.getAttributes().frameHeight || 600;
if (!url) {
alert('Please enter a File URL first');
return;
}
const iframe = selected.components().find(c => c.getClasses().includes('file-embed-frame'));
const placeholder = selected.components().find(c => c.getClasses().includes('file-embed-placeholder'));
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;
}
}
}
if (placeholder) {
placeholder.addStyle({ display: 'none' });
const el = placeholder.getEl();
if (el) el.style.display = 'none';
}
}
}
]
},
init() {
// Make child elements non-selectable so clicks bubble to wrapper
this.components().forEach(child => {
child.set({
selectable: false,
hoverable: false,
editable: false,
draggable: false,
droppable: false,
badgable: false,
layerable: false,
removable: false
});
});
}
}
});
// Logo Component with image support
editor.DomComponents.addType('site-logo', {
isComponent: el => el.classList && el.classList.contains('site-logo'),
model: {
defaults: {
traits: [
{
type: 'text',
label: 'Logo Text',
name: 'logoText',
placeholder: 'SiteName'
},
{
type: 'text',
label: 'Logo Image URL',
name: 'logoImage',
placeholder: 'https://example.com/logo.png'
},
{
type: 'select',
label: 'Logo Mode',
name: 'logoMode',
options: [
{ id: 'text', name: 'Text Only' },
{ id: 'image', name: 'Image Only' },
{ id: 'both', name: 'Image + Text' }
]
},
{
type: 'button',
label: '',
text: 'Apply Logo',
full: true,
command: (editor) => {
const selected = editor.getSelected();
if (!selected) return;
const attrs = selected.getAttributes();
const mode = attrs.logoMode || 'text';
const text = attrs.logoText || 'SiteName';
const imageUrl = attrs.logoImage || '';
// Clear existing children
selected.components().reset();
if (mode === 'image' && imageUrl) {
selected.components().add({
tagName: 'img',
attributes: { src: imageUrl, alt: text },
style: { height: '40px', width: 'auto' }
});
} else if (mode === 'both' && imageUrl) {
selected.components().add({
tagName: 'img',
attributes: { src: imageUrl, alt: text },
style: { height: '40px', width: 'auto' }
});
selected.components().add({
tagName: 'span',
style: { 'font-size': '20px', 'font-weight': '700', 'color': '#1f2937', 'font-family': 'Inter, sans-serif' },
content: text
});
} else {
// Text mode (default icon + text)
selected.components().add({
tagName: 'div',
style: { width: '40px', height: '40px', background: 'linear-gradient(135deg,#3b82f6 0%,#8b5cf6 100%)', 'border-radius': '8px', display: 'flex', 'align-items': 'center', 'justify-content': 'center' },
components: [{ tagName: 'span', style: { color: '#fff', 'font-weight': '700', 'font-size': '18px', 'font-family': 'Inter,sans-serif' }, content: text.charAt(0).toUpperCase() }]
});
selected.components().add({
tagName: 'span',
style: { 'font-size': '20px', 'font-weight': '700', 'color': '#1f2937', 'font-family': 'Inter, sans-serif' },
content: text
});
}
}
}
]
}
}
});
// ==========================================
// Device Switching
// ==========================================
const deviceButtons = {
desktop: document.getElementById('device-desktop'),
tablet: document.getElementById('device-tablet'),
mobile: document.getElementById('device-mobile')
};
function setDevice(device) {
// Update button states
Object.values(deviceButtons).forEach(btn => btn.classList.remove('active'));
deviceButtons[device].classList.add('active');
// Set device in editor
const deviceMap = {
desktop: 'Desktop',
tablet: 'Tablet',
mobile: 'Mobile'
};
editor.setDevice(deviceMap[device]);
// Force canvas refresh
editor.refresh();
}
deviceButtons.desktop.addEventListener('click', () => setDevice('desktop'));
deviceButtons.tablet.addEventListener('click', () => setDevice('tablet'));
deviceButtons.mobile.addEventListener('click', () => setDevice('mobile'));
// ==========================================
// Undo/Redo
// ==========================================
document.getElementById('btn-undo').addEventListener('click', () => {
editor.UndoManager.undo();
});
document.getElementById('btn-redo').addEventListener('click', () => {
editor.UndoManager.redo();
});
// ==========================================
// Clear Canvas
// ==========================================
document.getElementById('btn-clear').addEventListener('click', () => {
if (confirm('Are you sure you want to clear the canvas and reset the project? This will delete all saved data and cannot be undone.')) {
editor.DomComponents.clear();
editor.CssComposer.clear();
// Clear localStorage to prevent reloading old content
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY + '-preview');
alert('Canvas cleared! Refresh the page for a clean start.');
}
});
// ==========================================
// Preview
// ==========================================
document.getElementById('btn-preview').addEventListener('click', () => {
// Get the HTML and CSS
const html = editor.getHtml();
const css = editor.getCss();
// Store for preview page
localStorage.setItem(STORAGE_KEY + '-preview', JSON.stringify({ html, css }));
// Open preview
window.open('preview.html', '_blank');
});
// ==========================================
// Panel Tabs
// ==========================================
// Left panel tabs (Blocks / Pages / Layers)
document.querySelectorAll('.panel-left .panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
const panel = tab.dataset.panel;
// Update tab states
document.querySelectorAll('.panel-left .panel-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Show/hide panels
document.getElementById('blocks-container').style.display = panel === 'blocks' ? 'block' : 'none';
document.getElementById('pages-container').style.display = panel === 'pages' ? 'block' : 'none';
document.getElementById('layers-container').style.display = panel === 'layers' ? 'block' : 'none';
document.getElementById('assets-container').style.display = panel === 'assets' ? 'block' : 'none';
});
});
// Right panel tabs (Styles / Settings)
document.querySelectorAll('.panel-right .panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
const panel = tab.dataset.panel;
// Update tab states
document.querySelectorAll('.panel-right .panel-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
// Show/hide panels
document.getElementById('styles-container').style.display = panel === 'styles' ? 'block' : 'none';
document.getElementById('traits-container').style.display = panel === 'traits' ? 'block' : 'none';
const headContainer = document.getElementById('head-elements-container');
if (headContainer) headContainer.style.display = panel === 'head' ? 'block' : 'none';
});
});
// ==========================================
// Style Mode Toggle (Guided / Advanced)
// ==========================================
const modeGuided = document.getElementById('mode-guided');
const modeAdvanced = document.getElementById('mode-advanced');
const guidedStyles = document.getElementById('guided-styles');
const advancedStyles = document.getElementById('advanced-styles');
modeGuided.addEventListener('click', () => {
modeGuided.classList.add('active');
modeAdvanced.classList.remove('active');
guidedStyles.style.display = 'flex';
advancedStyles.style.display = 'none';
});
modeAdvanced.addEventListener('click', () => {
modeAdvanced.classList.add('active');
modeGuided.classList.remove('active');
advancedStyles.style.display = 'block';
guidedStyles.style.display = 'none';
});
// ==========================================
// Context-Aware Guided Style Controls
// ==========================================
// Element type definitions
const ELEMENT_TYPES = {
TEXT: ['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label'],
LINK: ['a'],
DIVIDER: ['hr'],
CONTAINER: ['div', 'section', 'article', 'header', 'footer', 'nav', 'main', 'aside'],
MEDIA: ['img', 'video', 'iframe'],
FORM: ['form', 'input', 'textarea', 'select', 'button']
};
// Get element type category
function getElementType(tagName) {
const tag = tagName?.toLowerCase();
if (ELEMENT_TYPES.TEXT.includes(tag)) return 'text';
if (ELEMENT_TYPES.LINK.includes(tag)) return 'link';
if (ELEMENT_TYPES.DIVIDER.includes(tag)) return 'divider';
if (ELEMENT_TYPES.MEDIA.includes(tag)) return 'media';
if (ELEMENT_TYPES.FORM.includes(tag)) return 'form';
if (ELEMENT_TYPES.CONTAINER.includes(tag)) return 'container';
return 'other';
}
// Check if element is button-like (styled link)
function isButtonLike(component) {
if (!component) return false;
const tagName = component.get('tagName')?.toLowerCase();
if (tagName !== 'a') return false;
const styles = component.getStyle();
// Check if it has button-like styling
return styles['display'] === 'inline-block' ||
styles['padding'] ||
styles['background'] ||
styles['background-color'];
}
// UI Section references
const sections = {
noSelection: document.getElementById('no-selection-msg'),
link: document.getElementById('section-link'),
textColor: document.getElementById('section-text-color'),
headingLevel: document.getElementById('section-heading-level'),
htmlEditorToggle: document.getElementById('section-html-editor-toggle'),
htmlEditor: document.getElementById('section-html-editor'),
bgColor: document.getElementById('section-bg-color'),
bgGradient: document.getElementById('section-bg-gradient'),
bgImage: document.getElementById('section-bg-image'),
overlay: document.getElementById('section-overlay'),
dividerColor: document.getElementById('section-divider-color'),
font: document.getElementById('section-font'),
textSize: document.getElementById('section-text-size'),
fontWeight: document.getElementById('section-font-weight'),
spacing: document.getElementById('section-spacing'),
radius: document.getElementById('section-radius'),
thickness: document.getElementById('section-thickness'),
buttonStyle: document.getElementById('section-button-style'),
navLinks: document.getElementById('section-nav-links')
};
// Link input elements
const linkUrlInput = document.getElementById('link-url-input');
const linkNewTabCheckbox = document.getElementById('link-new-tab');
// Background image elements
const bgImageUrlInput = document.getElementById('bg-image-url');
const bgSizeSelect = document.getElementById('bg-size');
const bgPositionSelect = document.getElementById('bg-position');
const removeBgImageBtn = document.getElementById('remove-bg-image');
// Overlay elements
const overlayOpacitySlider = document.getElementById('overlay-opacity');
const overlayOpacityValue = document.getElementById('overlay-opacity-value');
// Navigation elements
const syncNavPagesBtn = document.getElementById('sync-nav-pages');
const addNavLinkBtn = document.getElementById('add-nav-link');
const navLinksList = document.getElementById('nav-links-list');
// Current overlay state
let currentOverlayColor = '0,0,0';
let currentOverlayOpacity = 50;
// Check if element is an overlay
function isOverlay(component) {
if (!component) return false;
const classes = component.getClasses();
return classes.includes('bg-overlay');
}
// Check if element is a section with background
function isSectionWithBg(component) {
if (!component) return false;
const attrs = component.getAttributes();
return attrs['data-bg-section'] === 'true' || attrs['data-video-section'] === 'true';
}
// Check if element is a navigation
function isNavigation(component) {
if (!component) return false;
const tagName = component.get('tagName')?.toLowerCase();
const classes = component.getClasses();
return tagName === 'nav' || classes.includes('site-navbar');
}
// Hide all context sections
function hideAllSections() {
Object.values(sections).forEach(section => {
if (section) section.style.display = 'none';
});
}
// Show sections based on element type
function showSectionsForElement(component) {
hideAllSections();
if (!component) {
sections.noSelection.style.display = 'block';
return;
}
const tagName = component.get('tagName');
const elementType = getElementType(tagName);
const isButton = isButtonLike(component);
// Check for special element types first
if (isOverlay(component)) {
// Overlay element - show only overlay controls
sections.overlay.style.display = 'block';
loadOverlayValues(component);
return;
}
if (isNavigation(component)) {
// Navigation element - show navigation controls
sections.navLinks.style.display = 'block';
sections.bgColor.style.display = 'block';
sections.spacing.style.display = 'block';
loadNavLinks(component);
return;
}
if (isSectionWithBg(component)) {
// Section with background - show background image controls
sections.bgImage.style.display = 'block';
sections.spacing.style.display = 'block';
loadBgImageValues(component);
return;
}
// Show relevant sections based on element type
switch (elementType) {
case 'text':
sections.textColor.style.display = 'block';
sections.font.style.display = 'block';
sections.textSize.style.display = 'block';
sections.fontWeight.style.display = 'block';
// Show heading level selector for headings
const currentTag = component.get('tagName')?.toLowerCase();
if (currentTag && currentTag.match(/^h[1-6]$/)) {
sections.headingLevel.style.display = 'block';
updateHeadingLevelButtons(currentTag);
}
break;
case 'link':
sections.link.style.display = 'block';
if (isButton) {
sections.buttonStyle.style.display = 'block';
sections.radius.style.display = 'block';
sections.spacing.style.display = 'block';
}
sections.textColor.style.display = 'block';
sections.font.style.display = 'block';
sections.textSize.style.display = 'block';
sections.fontWeight.style.display = 'block';
// Load current link values
loadLinkValues(component);
break;
case 'divider':
sections.dividerColor.style.display = 'block';
sections.thickness.style.display = 'block';
break;
case 'container':
sections.bgColor.style.display = 'block';
sections.bgGradient.style.display = 'block';
sections.bgImage.style.display = 'block';
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
loadBgImageValues(component);
break;
case 'media':
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
break;
case 'form':
if (tagName?.toLowerCase() === 'button') {
sections.buttonStyle.style.display = 'block';
sections.link.style.display = 'block';
}
sections.bgColor.style.display = 'block';
sections.textColor.style.display = 'block';
sections.font.style.display = 'block';
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
break;
default:
// Show common controls for unknown elements
sections.bgColor.style.display = 'block';
sections.spacing.style.display = 'block';
sections.radius.style.display = 'block';
}
// Always show HTML editor toggle button for any selected element
sections.htmlEditorToggle.style.display = 'block';
}
// Load link values into the input
function loadLinkValues(component) {
if (!component) return;
const attrs = component.getAttributes();
linkUrlInput.value = attrs.href || '';
linkNewTabCheckbox.checked = attrs.target === '_blank';
}
// Update heading level buttons to show active state
function updateHeadingLevelButtons(currentTag) {
const buttons = sections.headingLevel.querySelectorAll('.heading-level-btn');
buttons.forEach(btn => {
const level = btn.getAttribute('data-level');
if (level === currentTag) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
// HTML Editor elements
const htmlEditorTextarea = document.getElementById('html-editor-textarea');
const htmlEditorApply = document.getElementById('html-editor-apply');
const htmlEditorCancel = document.getElementById('html-editor-cancel');
const htmlEditorToggleBtn = document.getElementById('html-editor-toggle-btn');
const htmlEditorClose = document.getElementById('html-editor-close');
let originalHtml = '';
let currentEditingComponent = null;
// Load HTML into editor
function loadHtmlEditor(component) {
if (!component) return;
currentEditingComponent = component;
// Get the HTML of the selected component
const html = component.toHTML();
htmlEditorTextarea.value = html;
originalHtml = html;
}
// Show HTML editor
function showHtmlEditor() {
const selected = editor.getSelected();
if (!selected) return;
loadHtmlEditor(selected);
sections.htmlEditor.style.display = 'block';
sections.htmlEditorToggle.style.display = 'none';
}
// Hide HTML editor
function hideHtmlEditor() {
sections.htmlEditor.style.display = 'none';
sections.htmlEditorToggle.style.display = 'block';
currentEditingComponent = null;
}
// Toggle button click
htmlEditorToggleBtn.addEventListener('click', showHtmlEditor);
// Close button click
htmlEditorClose.addEventListener('click', hideHtmlEditor);
// Apply HTML changes
htmlEditorApply.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected) return;
const newHtml = htmlEditorTextarea.value.trim();
try {
// Replace the component with new HTML
const parent = selected.parent();
const index = parent.components().indexOf(selected);
// Remove old component
selected.remove();
// Add new component from HTML
parent.append(newHtml, { at: index });
// Select the new component
const newComponent = parent.components().at(index);
if (newComponent) {
editor.select(newComponent);
}
// Hide editor after applying
hideHtmlEditor();
} catch (error) {
alert('Invalid HTML: ' + error.message);
htmlEditorTextarea.value = originalHtml;
}
});
// Cancel HTML changes
htmlEditorCancel.addEventListener('click', () => {
htmlEditorTextarea.value = originalHtml;
hideHtmlEditor();
});
// Default font sizes for each heading level
const headingSizes = {
h1: '48px',
h2: '36px',
h3: '28px',
h4: '24px',
h5: '20px',
h6: '18px'
};
// Handle heading level button clicks
function setupHeadingLevelButtons() {
const buttons = sections.headingLevel.querySelectorAll('.heading-level-btn');
buttons.forEach(btn => {
btn.addEventListener('click', () => {
const newLevel = btn.getAttribute('data-level');
const selected = editor.getSelected();
if (!selected) return;
// Change the tag name
selected.set('tagName', newLevel);
// Update font size to match heading level
const defaultSize = headingSizes[newLevel];
if (defaultSize) {
selected.addStyle({ 'font-size': defaultSize });
}
// Update button states
updateHeadingLevelButtons(newLevel);
});
});
}
// Load background image values
function loadBgImageValues(component) {
if (!component) return;
const styles = component.getStyle();
const bgImage = styles['background-image'] || '';
const bgSize = styles['background-size'] || 'cover';
const bgPosition = styles['background-position'] || 'center';
// Extract URL from background-image
const urlMatch = bgImage.match(/url\(['"]?([^'"]+)['"]?\)/);
bgImageUrlInput.value = urlMatch ? urlMatch[1] : '';
bgSizeSelect.value = bgSize;
bgPositionSelect.value = bgPosition;
}
// Load overlay values
function loadOverlayValues(component) {
if (!component) return;
const styles = component.getStyle();
const bg = styles['background'] || '';
// Parse rgba value
const rgbaMatch = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+),?\s*([\d.]+)?\)/);
if (rgbaMatch) {
currentOverlayColor = `${rgbaMatch[1]},${rgbaMatch[2]},${rgbaMatch[3]}`;
currentOverlayOpacity = rgbaMatch[4] ? Math.round(parseFloat(rgbaMatch[4]) * 100) : 100;
overlayOpacitySlider.value = currentOverlayOpacity;
overlayOpacityValue.textContent = currentOverlayOpacity + '%';
// Update active color button
document.querySelectorAll('.overlay-color').forEach(btn => {
btn.classList.toggle('active', btn.dataset.color === currentOverlayColor);
});
}
}
// Load navigation links
function loadNavLinks(component) {
if (!component) return;
// Find the nav-links container within the nav
const linksContainer = component.components().find(c => {
const classes = c.getClasses();
return classes.includes('nav-links');
});
if (!linksContainer) {
navLinksList.innerHTML = '
No links container found
';
return;
}
// Get all link components
const links = linksContainer.components().filter(c => c.get('tagName')?.toLowerCase() === 'a');
// Clear and rebuild list
navLinksList.innerHTML = '';
links.forEach((link, index) => {
const item = document.createElement('div');
item.className = 'nav-link-item';
const textSpan = document.createElement('span');
textSpan.className = 'nav-link-text';
textSpan.textContent = link.getEl()?.textContent || 'Link';
const deleteBtn = document.createElement('button');
deleteBtn.className = 'nav-link-delete';
deleteBtn.innerHTML = '
';
deleteBtn.title = 'Remove link';
deleteBtn.addEventListener('click', () => {
link.remove();
loadNavLinks(component);
});
item.appendChild(textSpan);
item.appendChild(deleteBtn);
navLinksList.appendChild(item);
});
if (links.length === 0) {
navLinksList.innerHTML = '
No links in navigation
';
}
}
// Helper to apply style to selected component
function applyStyle(property, value) {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({ [property]: value });
}
}
// ==========================================
// Color Preset Handlers
// ==========================================
// Text color presets
document.querySelectorAll('.color-preset.text-color').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('color', btn.dataset.color);
document.querySelectorAll('.text-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Background color presets
document.querySelectorAll('.color-preset.bg-color').forEach(btn => {
btn.addEventListener('click', () => {
const selected = editor.getSelected();
if (selected) {
// Remove gradient when applying solid color
selected.addStyle({
'background-color': btn.dataset.color,
'background-image': 'none',
'background': btn.dataset.color
});
}
document.querySelectorAll('.bg-color').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.gradient-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Gradient presets
document.querySelectorAll('.gradient-preset').forEach(btn => {
btn.addEventListener('click', () => {
const gradient = btn.dataset.gradient;
const selected = editor.getSelected();
if (selected) {
if (gradient === 'none') {
// Remove gradient
selected.addStyle({
'background-image': 'none',
'background': ''
});
} else {
// Apply gradient
selected.addStyle({
'background': gradient,
'background-image': gradient
});
}
}
document.querySelectorAll('.gradient-preset').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.bg-color').forEach(b => b.classList.remove('active'));
if (gradient !== 'none') {
btn.classList.add('active');
}
});
});
// Divider color presets
document.querySelectorAll('.color-preset.divider-color').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('border-top-color', btn.dataset.color);
document.querySelectorAll('.divider-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Button background color presets
document.querySelectorAll('.color-preset.btn-bg-color').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('background-color', btn.dataset.color);
// Also update text color for contrast
const color = btn.dataset.color;
if (color === '#ffffff' || color === '#f9fafb') {
applyStyle('color', '#1f2937');
} else {
applyStyle('color', '#ffffff');
}
document.querySelectorAll('.btn-bg-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ==========================================
// Font Preset Handlers
// ==========================================
document.querySelectorAll('.font-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('font-family', btn.dataset.font);
document.querySelectorAll('.font-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Font weight presets
document.querySelectorAll('.weight-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('font-weight', btn.dataset.weight);
document.querySelectorAll('.weight-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Text size presets
document.querySelectorAll('.size-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('font-size', btn.dataset.size);
document.querySelectorAll('.size-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Spacing presets
document.querySelectorAll('.spacing-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('padding', btn.dataset.padding);
document.querySelectorAll('.spacing-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Border radius presets
document.querySelectorAll('.radius-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('border-radius', btn.dataset.radius);
document.querySelectorAll('.radius-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Line thickness presets (for dividers/hr)
document.querySelectorAll('.thickness-preset').forEach(btn => {
btn.addEventListener('click', () => {
applyStyle('border-top-width', btn.dataset.thickness);
document.querySelectorAll('.thickness-preset').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// ==========================================
// Heading Level Controls
// ==========================================
setupHeadingLevelButtons();
// ==========================================
// Background Image Controls
// ==========================================
// Update background image URL
const updateBgImage = debounce(() => {
const selected = editor.getSelected();
if (selected) {
const url = bgImageUrlInput.value.trim();
if (url) {
selected.addStyle({ 'background-image': `url(${url})` });
} else {
selected.addStyle({ 'background-image': 'none' });
}
}
}, 300);
bgImageUrlInput.addEventListener('input', updateBgImage);
// Background size
bgSizeSelect.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({ 'background-size': bgSizeSelect.value });
}
});
// Background position
bgPositionSelect.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({ 'background-position': bgPositionSelect.value });
}
});
// Remove background image
removeBgImageBtn.addEventListener('click', () => {
const selected = editor.getSelected();
if (selected) {
selected.addStyle({
'background-image': 'none',
'background-color': '#ffffff'
});
bgImageUrlInput.value = '';
}
});
// ==========================================
// Overlay Controls
// ==========================================
// Overlay color presets
document.querySelectorAll('.overlay-color').forEach(btn => {
btn.addEventListener('click', () => {
currentOverlayColor = btn.dataset.color;
const selected = editor.getSelected();
if (selected) {
const opacity = currentOverlayOpacity / 100;
selected.addStyle({ 'background': `rgba(${currentOverlayColor},${opacity})` });
}
document.querySelectorAll('.overlay-color').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
});
});
// Overlay opacity slider
overlayOpacitySlider.addEventListener('input', () => {
currentOverlayOpacity = parseInt(overlayOpacitySlider.value);
overlayOpacityValue.textContent = currentOverlayOpacity + '%';
const selected = editor.getSelected();
if (selected && isOverlay(selected)) {
const opacity = currentOverlayOpacity / 100;
selected.addStyle({ 'background': `rgba(${currentOverlayColor},${opacity})` });
}
});
// ==========================================
// Navigation Controls
// ==========================================
// Sync navigation with pages
syncNavPagesBtn.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected || !isNavigation(selected)) return;
// Find the nav-links container
const linksContainer = selected.components().find(c => {
const classes = c.getClasses();
return classes.includes('nav-links');
});
if (!linksContainer) {
alert('Navigation structure not recognized. Please use the Navigation block.');
return;
}
// Get CTA button if exists (keep it)
const existingLinks = linksContainer.components();
let ctaLink = null;
existingLinks.forEach(link => {
const classes = link.getClasses();
if (classes.includes('nav-cta')) {
ctaLink = link.clone();
}
});
// Clear existing links
linksContainer.components().reset();
// Add links for each page
pages.forEach((page, index) => {
linksContainer.components().add({
tagName: 'a',
attributes: { href: page.slug === 'index' ? '#' : `#${page.slug}` },
style: {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: page.name
});
});
// Re-add CTA if existed
if (ctaLink) {
linksContainer.components().add(ctaLink);
}
// Refresh the links list UI
loadNavLinks(selected);
});
// Add new link to navigation
addNavLinkBtn.addEventListener('click', () => {
const selected = editor.getSelected();
if (!selected || !isNavigation(selected)) return;
// Find the nav-links container
const linksContainer = selected.components().find(c => {
const classes = c.getClasses();
return classes.includes('nav-links');
});
if (!linksContainer) {
alert('Navigation structure not recognized. Please use the Navigation block.');
return;
}
// Find position to insert (before CTA if exists)
const links = linksContainer.components();
let insertIndex = links.length;
links.forEach((link, index) => {
const classes = link.getClasses();
if (classes.includes('nav-cta')) {
insertIndex = index;
}
});
// Add new link
linksContainer.components().add({
tagName: 'a',
attributes: { href: '#' },
style: {
'color': '#4b5563',
'text-decoration': 'none',
'font-size': '15px',
'font-family': 'Inter, sans-serif'
},
content: 'New Link'
}, { at: insertIndex });
// Refresh the links list UI
loadNavLinks(selected);
});
// ==========================================
// Link Editing
// ==========================================
// Debounce helper
function debounce(fn, delay) {
let timeoutId;
return (...args) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), delay);
};
}
// Update link href
const updateLinkHref = debounce(() => {
const selected = editor.getSelected();
if (selected && (selected.get('tagName')?.toLowerCase() === 'a' || selected.get('tagName')?.toLowerCase() === 'button')) {
selected.addAttributes({ href: linkUrlInput.value });
}
}, 300);
linkUrlInput.addEventListener('input', updateLinkHref);
// Update link target
linkNewTabCheckbox.addEventListener('change', () => {
const selected = editor.getSelected();
if (selected && selected.get('tagName')?.toLowerCase() === 'a') {
if (linkNewTabCheckbox.checked) {
selected.addAttributes({ target: '_blank', rel: 'noopener noreferrer' });
} else {
selected.removeAttributes('target');
selected.removeAttributes('rel');
}
}
});
// ==========================================
// Save Status Indicator
// ==========================================
editor.on('storage:start', () => {
saveStatus.classList.add('saving');
saveStatus.classList.remove('saved');
statusText.textContent = 'Saving...';
});
editor.on('storage:end', () => {
saveStatus.classList.remove('saving');
saveStatus.classList.add('saved');
statusText.textContent = 'Saved';
});
editor.on('storage:error', (err) => {
saveStatus.classList.remove('saving');
statusText.textContent = 'Error saving';
console.error('Storage error:', err);
});
// ==========================================
// Keyboard Shortcuts
// ==========================================
document.addEventListener('keydown', (e) => {
// Only handle if not typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Ctrl/Cmd + Z = Undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
editor.UndoManager.undo();
}
// Ctrl/Cmd + Shift + Z = Redo (or Ctrl + Y)
if ((e.ctrlKey || e.metaKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
e.preventDefault();
editor.UndoManager.redo();
}
// Delete/Backspace = Remove selected
if (e.key === 'Delete' || e.key === 'Backspace') {
const selected = editor.getSelected();
if (selected && !e.target.isContentEditable) {
e.preventDefault();
selected.remove();
}
}
// Escape = Deselect
if (e.key === 'Escape') {
editor.select(null);
}
});
// ==========================================
// Update guided controls when selection changes
// ==========================================
// Video Section Trait Change Listener
// ==========================================
// Global listener for video URL trait changes
editor.on('component:update', (component) => {
// Check if this is a video section
const attrs = component.getAttributes();
if (attrs && attrs['data-video-section'] === 'true') {
const videoUrl = attrs.videoUrl;
if (videoUrl) {
console.log('Video section updated with URL:', videoUrl);
// Find the bg-video-wrapper child
const videoWrapper = component.components().find(c =>
c.getAttributes()['data-bg-video'] === 'true'
);
if (videoWrapper) {
console.log('Applying video URL to wrapper');
videoWrapper.addAttributes({ videoUrl: videoUrl });
applyVideoUrl(videoWrapper, videoUrl);
} else {
console.error('Video wrapper not found!');
}
}
}
});
// ==========================================
editor.on('component:selected', (component) => {
// Show context-aware UI sections
showSectionsForElement(component);
if (!component) return;
const styles = component.getStyle();
// Reset all active states
document.querySelectorAll('.color-preset, .size-preset, .spacing-preset, .radius-preset, .thickness-preset, .font-preset, .weight-preset, .gradient-preset')
.forEach(btn => btn.classList.remove('active'));
// Set active text color
const textColor = styles['color'];
if (textColor) {
document.querySelectorAll('.text-color').forEach(btn => {
if (btn.dataset.color === textColor) btn.classList.add('active');
});
}
// Set active background color or gradient
const bgColor = styles['background-color'];
const bgImage = styles['background-image'] || styles['background'];
// Check for gradient first
if (bgImage && bgImage.includes('gradient')) {
document.querySelectorAll('.gradient-preset').forEach(btn => {
// Normalize gradient strings for comparison
const btnGradient = btn.dataset.gradient?.replace(/\s/g, '');
const elGradient = bgImage.replace(/\s/g, '');
if (btnGradient && elGradient.includes(btnGradient.replace(/\s/g, ''))) {
btn.classList.add('active');
}
});
} else if (bgColor) {
document.querySelectorAll('.bg-color, .btn-bg-color').forEach(btn => {
if (btn.dataset.color === bgColor) btn.classList.add('active');
});
}
// Set active divider color
const borderTopColor = styles['border-top-color'];
if (borderTopColor) {
document.querySelectorAll('.divider-color').forEach(btn => {
if (btn.dataset.color === borderTopColor) btn.classList.add('active');
});
}
// Set active font family
const fontFamily = styles['font-family'];
if (fontFamily) {
document.querySelectorAll('.font-preset').forEach(btn => {
if (fontFamily.includes(btn.dataset.font.split(',')[0].trim())) {
btn.classList.add('active');
}
});
}
// Set active font weight
const fontWeight = styles['font-weight'];
if (fontWeight) {
document.querySelectorAll('.weight-preset').forEach(btn => {
if (btn.dataset.weight === fontWeight) btn.classList.add('active');
});
}
// Set active font size
const fontSize = styles['font-size'];
if (fontSize) {
document.querySelectorAll('.size-preset').forEach(btn => {
if (btn.dataset.size === fontSize) btn.classList.add('active');
});
}
// Set active padding
const padding = styles['padding'];
if (padding) {
document.querySelectorAll('.spacing-preset').forEach(btn => {
if (btn.dataset.padding === padding) btn.classList.add('active');
});
}
// Set active border radius
const borderRadius = styles['border-radius'];
if (borderRadius) {
document.querySelectorAll('.radius-preset').forEach(btn => {
if (btn.dataset.radius === borderRadius) btn.classList.add('active');
});
}
// Set active thickness
const borderTopWidth = styles['border-top-width'];
if (borderTopWidth) {
document.querySelectorAll('.thickness-preset').forEach(btn => {
if (btn.dataset.thickness === borderTopWidth) btn.classList.add('active');
});
}
});
// Handle deselection
editor.on('component:deselected', () => {
showSectionsForElement(null);
});
// ==========================================
// Context Menu
// ==========================================
const contextMenu = document.getElementById('context-menu');
let clipboard = null; // Store copied component
// Hide context menu
function hideContextMenu() {
contextMenu.classList.remove('visible');
}
// Show context menu at position
function showContextMenu(x, y) {
const selected = editor.getSelected();
if (!selected) return;
// Update disabled states
const pasteItem = contextMenu.querySelector('[data-action="paste"]');
if (pasteItem) {
pasteItem.classList.toggle('disabled', !clipboard);
}
// Position the menu
contextMenu.style.left = x + 'px';
contextMenu.style.top = y + 'px';
// Make sure menu doesn't go off screen
contextMenu.classList.add('visible');
const rect = contextMenu.getBoundingClientRect();
if (rect.right > window.innerWidth) {
contextMenu.style.left = (x - rect.width) + 'px';
}
if (rect.bottom > window.innerHeight) {
contextMenu.style.top = (y - rect.height) + 'px';
}
}
// Listen for right-click on canvas
editor.on('component:selected', (component) => {
if (!component) return;
// Get the component's element in the canvas
const el = component.getEl();
if (!el) return;
// Remove any existing listener
el.removeEventListener('contextmenu', handleContextMenu);
// Add context menu listener
el.addEventListener('contextmenu', handleContextMenu);
});
function handleContextMenu(e) {
e.preventDefault();
e.stopPropagation();
const selected = editor.getSelected();
if (!selected) return;
// Get canvas iframe offset
const canvas = editor.Canvas;
const canvasEl = canvas.getElement();
const iframe = canvasEl.querySelector('iframe');
const iframeRect = iframe.getBoundingClientRect();
// Calculate position relative to main window
const x = e.clientX + iframeRect.left;
const y = e.clientY + iframeRect.top;
showContextMenu(x, y);
}
// Close context menu when clicking elsewhere
document.addEventListener('click', hideContextMenu);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') hideContextMenu();
});
// Handle context menu actions
contextMenu.addEventListener('click', (e) => {
const item = e.target.closest('.context-menu-item');
if (!item || item.classList.contains('disabled')) return;
const action = item.dataset.action;
const selected = editor.getSelected();
if (!selected && action !== 'paste') {
hideContextMenu();
return;
}
switch (action) {
case 'edit':
// Trigger inline editing if available
if (selected.get('editable') !== false) {
const el = selected.getEl();
if (el) {
el.setAttribute('contenteditable', 'true');
el.focus();
}
}
break;
case 'duplicate':
const parent = selected.parent();
if (parent) {
const index = parent.components().indexOf(selected);
const clone = selected.clone();
parent.components().add(clone, { at: index + 1 });
editor.select(clone);
}
break;
case 'copy':
clipboard = selected.clone();
break;
case 'paste':
if (clipboard) {
const targetParent = selected.parent() || editor.getWrapper();
if (targetParent) {
const index = selected ? targetParent.components().indexOf(selected) + 1 : undefined;
const pasted = clipboard.clone();
targetParent.components().add(pasted, { at: index });
editor.select(pasted);
}
}
break;
case 'move-up':
const parentUp = selected.parent();
if (parentUp) {
const components = parentUp.components();
const indexUp = components.indexOf(selected);
if (indexUp > 0) {
components.remove(selected);
components.add(selected, { at: indexUp - 1 });
editor.select(selected);
}
}
break;
case 'move-down':
const parentDown = selected.parent();
if (parentDown) {
const components = parentDown.components();
const indexDown = components.indexOf(selected);
if (indexDown < components.length - 1) {
components.remove(selected);
components.add(selected, { at: indexDown + 1 });
editor.select(selected);
}
}
break;
case 'select-parent':
const parentEl = selected.parent();
if (parentEl && parentEl.get('type') !== 'wrapper') {
editor.select(parentEl);
}
break;
case 'wrap':
const wrapParent = selected.parent();
if (wrapParent) {
const wrapIndex = wrapParent.components().indexOf(selected);
// Create wrapper div
const wrapper = wrapParent.components().add({
tagName: 'div',
style: { padding: '20px' },
components: []
}, { at: wrapIndex });
// Move selected into wrapper
selected.move(wrapper, {});
editor.select(wrapper);
}
break;
case 'delete':
selected.remove();
break;
case 'delete-section':
// Find the topmost parent section/container (not wrapper)
{
let sectionTarget = selected;
let sectionParent = sectionTarget.parent();
while (sectionParent && sectionParent.get('type') !== 'wrapper') {
sectionTarget = sectionParent;
sectionParent = sectionTarget.parent();
}
if (confirm('Delete this entire section and all its children?')) {
sectionTarget.remove();
}
}
break;
}
hideContextMenu();
});
// Add keyboard shortcuts for copy/paste/duplicate
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
const selected = editor.getSelected();
if (!selected) return;
// Ctrl/Cmd + C = Copy
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
clipboard = selected.clone();
}
// Ctrl/Cmd + V = Paste
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && clipboard) {
e.preventDefault();
const targetParent = selected.parent() || editor.getWrapper();
if (targetParent) {
const index = targetParent.components().indexOf(selected) + 1;
const pasted = clipboard.clone();
targetParent.components().add(pasted, { at: index });
editor.select(pasted);
}
}
// Ctrl/Cmd + D = Duplicate
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault();
const parent = selected.parent();
if (parent) {
const index = parent.components().indexOf(selected);
const clone = selected.clone();
parent.components().add(clone, { at: index + 1 });
editor.select(clone);
}
}
});
// ==========================================
// Add default content if empty
// ==========================================
editor.on('load', () => {
const components = editor.getComponents();
if (components.length === 0) {
// Add a starter section
editor.addComponents(`
Welcome to Site Builder
Drag and drop components from the left panel to build your website. Click on any element to edit its content and style.
Get Started
`);
}
});
// ==========================================
// 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 = '
';
// 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 = '
';
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 = '
';
actions.appendChild(deleteBtn);
}
item.appendChild(icon);
item.appendChild(info);
item.appendChild(actions);
// Add click handler for switching pages
item.addEventListener('click', (e) => {
if (e.target.closest('.page-action-btn')) return;
switchToPage(page.id);
});
// Add action button handlers
editBtn.addEventListener('click', (e) => {
e.stopPropagation();
openEditModal(page.id);
});
const deleteBtn = actions.querySelector('[data-action="delete"]');
if (deleteBtn) {
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deletePage(page.id);
});
}
return item;
}
// Render pages list using safe DOM methods
function renderPagesList() {
pagesList.innerHTML = '';
pages.forEach(page => {
pagesList.appendChild(createPageItem(page));
});
}
// Open modal for new page
function openNewPageModal() {
editingPageId = null;
modalTitle.textContent = 'Add New Page';
pageNameInput.value = '';
pageSlugInput.value = '';
modalDelete.style.display = 'none';
modalSave.textContent = 'Add Page';
pageModal.classList.add('visible');
pageNameInput.focus();
}
// Open modal for editing page
function openEditModal(pageId) {
const page = pages.find(p => p.id === pageId);
if (!page) return;
editingPageId = pageId;
modalTitle.textContent = 'Edit Page';
pageNameInput.value = page.name;
pageSlugInput.value = page.slug;
modalDelete.style.display = pages.length > 1 ? 'inline-block' : 'none';
modalSave.textContent = 'Save Changes';
pageModal.classList.add('visible');
pageNameInput.focus();
}
// Close modal
function closeModal() {
pageModal.classList.remove('visible');
editingPageId = null;
}
// Save page (new or edit)
function savePage() {
const name = pageNameInput.value.trim();
let slug = pageSlugInput.value.trim();
if (!name) {
alert('Please enter a page name');
return;
}
// Generate slug if empty
if (!slug) {
slug = slugify(name);
} else {
slug = slugify(slug);
}
// Ensure slug is unique
const existingPage = pages.find(p => p.slug === slug && p.id !== editingPageId);
if (existingPage) {
slug = slug + '-' + Date.now();
}
if (editingPageId) {
// Update existing page
const page = pages.find(p => p.id === editingPageId);
if (page) {
page.name = name;
page.slug = slug;
}
} else {
// Create new page
const newPage = {
id: generateId(),
name: name,
slug: slug,
html: '',
css: ''
};
pages.push(newPage);
// Save current page before switching
saveCurrentPageContent();
// Switch to new page
currentPageId = newPage.id;
editor.DomComponents.clear();
editor.CssComposer.clear();
}
savePages();
renderPagesList();
closeModal();
}
// Delete page
function deletePage(pageId) {
if (pages.length <= 1) {
alert('Cannot delete the last page');
return;
}
if (!confirm('Are you sure you want to delete this page? This cannot be undone.')) {
return;
}
const index = pages.findIndex(p => p.id === pageId);
if (index > -1) {
pages.splice(index, 1);
savePages();
// If deleting current page, switch to first page
if (pageId === currentPageId) {
currentPageId = pages[0].id;
loadPageContent(currentPageId);
} else {
renderPagesList();
}
}
closeModal();
}
// Auto-save current page on changes
editor.on('storage:end', () => {
saveCurrentPageContent();
});
// Auto-generate slug from name
pageNameInput.addEventListener('input', () => {
if (!editingPageId) {
pageSlugInput.value = slugify(pageNameInput.value);
}
});
// Modal event handlers
addPageBtn.addEventListener('click', openNewPageModal);
modalClose.addEventListener('click', closeModal);
modalCancel.addEventListener('click', closeModal);
modalSave.addEventListener('click', savePage);
modalDelete.addEventListener('click', () => {
if (editingPageId) {
deletePage(editingPageId);
}
});
// Close modal on overlay click
pageModal.addEventListener('click', (e) => {
if (e.target === pageModal) {
closeModal();
}
});
// Close modal on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && pageModal.classList.contains('visible')) {
closeModal();
}
});
// Initialize pages
loadPages();
// ==========================================
// Update Preview to include all pages
// ==========================================
// Override preview button to save all pages data
document.getElementById('btn-preview').addEventListener('click', () => {
// Save current page first
saveCurrentPageContent();
// Store all pages for preview
localStorage.setItem(STORAGE_KEY + '-preview', JSON.stringify({
pages: pages,
currentPageId: currentPageId
}));
// Open preview
window.open('preview.html', '_blank');
});
// Make editor accessible globally for debugging
window.editor = editor;
window.sitePages = pages;
// ==========================================
// Feature: Delete Section (parent + children)
// ==========================================
// Added in context menu handler below
// ==========================================
// Feature: Link Type Selector (URL / Page / Anchor)
// ==========================================
const linkTypeSelect = document.getElementById('link-type-select');
const linkUrlGroup = document.getElementById('link-url-group');
const linkPageGroup = document.getElementById('link-page-group');
const linkAnchorGroup = document.getElementById('link-anchor-group');
const linkPageSelect = document.getElementById('link-page-select');
const linkAnchorSelect = document.getElementById('link-anchor-select');
// Populate page dropdown
function populatePageDropdown() {
linkPageSelect.innerHTML = '';
pages.forEach(page => {
const opt = document.createElement('option');
opt.value = page.slug === 'index' ? '#' : page.slug + '.html';
opt.textContent = page.name;
linkPageSelect.appendChild(opt);
});
}
// Find all anchors on current page
function findAnchors() {
const anchors = [];
function walk(component) {
const id = component.getAttributes().id;
if (id) anchors.push(id);
component.components().forEach(walk);
}
editor.getWrapper().components().forEach(walk);
return anchors;
}
// Populate anchor dropdown
function populateAnchorDropdown() {
linkAnchorSelect.innerHTML = '
';
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 = `
${asset.type === 'video' ? '🎬' : '📄'}
${asset.name}
`;
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 = '
';
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(/
]*data-anchor="true"[^>]*>[\s\S]*?<\/div>/g, '');
html = html.replace(/
]*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
? '
\n
\n
\n '
: '';
return `
${page.name}
${fontsLink}${headCode ? headCode + '\n ' : ''}
Skip to main content
${page.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 = `
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 = `
❌ No templates available
${e.message}
Make sure HTTP server is running
`;
}
}
}
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 = `
Start from Scratch
Begin with a blank page and build your own design.
`;
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;
const pageCount = t.pages ? t.pages.length : 0;
const pageBadge = pageCount > 0 ? `
${pageCount} pages` : '';
card.innerHTML = `

${pageBadge}
${t.name}
${t.description}
${t.tags.slice(0, 3).map(tag => `${tag}`).join('')}
${t.colors.map(c => `
`).join('')}
`;
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;
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.';
}
}
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;
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);
}
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();
})();