Guardado
0 / ∞
D
https:// mi-landing .netlify.app
100%
https://mi-landing.netlify.app
Suelta el bloque aquí Se añadirá al final de la página

Canvas vacío

Arrastra bloques del panel izquierdo o carga una plantilla para empezar a editar.

<\/body><\/html>`, 'captacion': ` {{hero-prospeccion}} {{beneficios}} {{testimonios}} {{countdown}} {{formulario-leads}} {{cta-final}} <\/body><\/html>` }; // Resuelve los placeholders {{block-name}} en una plantilla function resolveTemplate(tpl) { return tpl.replace(/\{\{([^}]+)\}\}/g, (_, key) => { return BLOCKS_HTML[key.trim()] || ''; }); } // ── Inicializar GrapesJS ────────────────────────────── function initGrapesJS() { // Configuración mínima pero completa editor = grapesjs.init({ // GrapesJS controla el iframe; nosotros le pasamos el contenedor container: '#gjs', storageManager: false, assetManager: { assets: [] }, fromElement: true, // Sin panel de bloque nativo (usamos el nuestro del sidebar) blockManager: { appendTo: '#gjs-blocks-hidden' }, // Sin panel de estilo nativo styleManager: { appendTo: '#gjs-styles-hidden' }, // Sin layers nativo layerManager: { appendTo: '#gjs-layers-hidden' }, // Sin panel de traits nativo traitManager: { appendTo: '#gjs-traits-hidden' }, panels: { defaults: [] }, deviceManager: { devices: [ { name: 'Desktop', width: '', // full width } ] }, // Canvas: inyectamos nuestro CSS + Google Fonts dentro del iframe canvas: { styles: [ 'https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap', 'https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.0.2/css/bootstrap.min.css', 'https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css' ], scripts: [] }, // Toolbar del componente seleccionado showToolbar: true, // Inline editing — doble clic en texto para editar richTextEditor: { actions: ['bold', 'italic', 'underline', 'link', 'strikethrough'] }, // Permitir selección y edición de cualquier elemento allowScripts: 1, protectedCss: '' }); editor.on('load', () => { editorReady = true; console.log('Editor listo'); }); // Inyectar CSS de Farmasi + marcar editor listo editor.on('load', () => { try { const head = editor.Canvas.getDocument().head; // Inyectar Google Fonts + CSS base head.insertAdjacentHTML('beforeend', '' + '' + '' ); } catch(_) {} editorReady = true; document.getElementById('canvas-empty').classList.add('hidden'); refreshLayers(); injectDragIntoGjsIframe(); showToast('Editor listo 🦅 — arrastra bloques o carga una plantilla', 'success'); }); // Cuando el usuario selecciona un componente → actualizar panel de propiedades editor.on('component:selected', (component) => { if (!component) return; onComponentSelected(component); }); // Cuando deselecciona editor.on('component:deselected', () => { document.getElementById('panel-no-selection').classList.remove('hidden'); document.getElementById('panel-props-active').classList.add('hidden'); }); // Cuando cambia el HTML → marcar como "no guardado" editor.on('change', () => { setSaveStatus('unsaved'); }); // Registrar los 8 bloques Farmasi en el block manager de GrapesJS registerFarmasiBlocks(); return editor; } // ── Registrar bloques en GrapesJS ───────────────────── function registerFarmasiBlocks() { const bm = editor.BlockManager; const blockDefs = [ { id: 'hero-venta', label: 'Hero Venta', category: 'Hero' }, { id: 'hero-prospeccion', label: 'Hero Prospección', category: 'Hero' }, { id: 'beneficios', label: 'Beneficios', category: 'Contenido' }, { id: 'testimonios', label: 'Testimonios', category: 'Contenido' }, { id: 'producto-precio', label: 'Producto + Precio',category: 'Farmasi MX' }, { id: 'countdown', label: 'Countdown', category: 'Farmasi MX' }, { id: 'formulario-leads', label: 'Captura Leads', category: 'Conversión' }, { id: 'cta-final', label: 'CTA WhatsApp', category: 'Conversión' }, ]; blockDefs.forEach(({ id, label, category }) => { bm.add(id, { label, category, content: BLOCKS_HTML[id], attributes: { class: 'gjs-block-section' } }); }); } // ── Cargar plantilla en el editor ───────────────────── function loadTemplate(tplKey) { if (!editorReady) { showToast('Cargando editor… reintentando ⏳', 'info'); let attempts = 0; const retry = setInterval(() => { attempts++; if (editorReady) { clearInterval(retry); loadTemplate(tplKey); } else if (attempts > 16) { clearInterval(retry); showToast('El editor tardó demasiado. Recarga la página.', 'error'); } }, 500); return; } let html; if (tplKey === 'elvis') { // Elvis se carga como iframe separado, no por GrapesJS // Se abre en preview externo loadElvisTemplate(); return; } if (!TEMPLATES[tplKey]) { showToast('Plantilla no encontrada', 'error'); return; } html = resolveTemplate(TEMPLATES[tplKey]); editor.setComponents(html); editor.setStyle(FARMASI_CSS); document.getElementById('canvas-empty').classList.add('hidden'); refreshLayers(); setSaveStatus('unsaved'); showToast('Plantilla cargada ✅', 'success'); } // Elvis: carga directo en el iframe (sin GrapesJS, solo lectura editable) function loadElvisTemplate() { showToast('Cargando plantilla Elvis…', 'info'); // El archivo está en el proyecto; lo cargamos con fetch relativo o lo pedimos al usuario // Por ahora mostramos instrucción clara showToast('Carga el archivo Elvis_Inquebrantable.html desde el botón "Cargar" del toolbar', 'info'); } // ── Insertar bloque desde sidebar (drag fallback = click) ── function insertBlockById(blockId) { if (!editorReady) { let att = 0; const r = setInterval(() => { att++; if (editorReady) { clearInterval(r); insertBlockById(blockId); } else if (att > 16) { clearInterval(r); showToast('Editor no listo aún. Recarga la página.', 'error'); } }, 500); return; } const html = BLOCKS_HTML[blockId]; if (!html) return; // Append al final del body del canvas const wrapper = editor.getWrapper(); editor.addComponents(html); document.getElementById('canvas-empty').classList.add('hidden'); refreshLayers(); setSaveStatus('unsaved'); showToast('Bloque añadido ✅', 'success'); } // SECCIÓN 2C — PANEL PROPIEDADES + ACCIONES + CARGA/DESCARGA // ── Estado del componente seleccionado ─────────────── let selectedComponent = null; // ── Cuando GrapesJS selecciona un componente ───────── function onComponentSelected(component) { selectedComponent = component; // Mostrar panel de propiedades document.getElementById('panel-no-selection').classList.add('hidden'); document.getElementById('panel-props-active').classList.remove('hidden'); // Tag y nombre del elemento const tag = component.get('tagName') || 'div'; document.getElementById('sel-tag').textContent = tag.toUpperCase(); // Nombre descriptivo: usa el ID, clase principal o tipo const classes = component.getClasses(); const name = classes.length > 0 ? classes[0].replace('bloque-', '').replace(/-/g, ' ') : tag; document.getElementById('sel-name').textContent = name; // Leer estilos actuales y reflejar en el panel syncPanelFromComponent(component); } // ── Leer estilos del componente → actualizar controles ── function syncPanelFromComponent(component) { const style = component.getStyle(); // Color de texto const color = style['color'] || '#FFFFFF'; document.getElementById('prop-color').value = hexNormalize(color); document.getElementById('swatch-color').style.background = color; document.getElementById('prop-color-hex').value = hexNormalize(color).toUpperCase(); // Color de fondo const bg = style['background-color'] || style['background'] || '#080808'; const bgHex = bg.startsWith('#') ? bg : '#080808'; document.getElementById('prop-bg-color').value = bgHex; document.getElementById('swatch-bg').style.background = bgHex; document.getElementById('prop-bg-hex').value = bgHex.toUpperCase(); // Opacidad const opacity = parseFloat(style['opacity'] || 1) * 100; document.getElementById('prop-opacity').value = opacity; document.getElementById('val-opacity').textContent = Math.round(opacity) + '%'; // Padding top / bottom const pt = parseInt(style['padding-top'] || style['padding'] || 80); const pb = parseInt(style['padding-bottom'] || style['padding'] || 80); document.getElementById('prop-pt').value = isNaN(pt) ? 80 : pt; document.getElementById('val-pt').textContent = (isNaN(pt) ? 80 : pt) + 'px'; document.getElementById('prop-pb').value = isNaN(pb) ? 80 : pb; document.getElementById('val-pb').textContent = (isNaN(pb) ? 80 : pb) + 'px'; // Font size const fs = parseInt(style['font-size'] || 16); document.getElementById('prop-font-size').value = isNaN(fs) ? 16 : fs; // Font weight const fw = style['font-weight'] || '400'; const fwSel = document.getElementById('prop-font-weight'); if (fwSel) fwSel.value = fw; // Imagen src (si el componente es img) const src = component.get('src') || component.getAttributes()['src'] || ''; document.getElementById('prop-img-url').value = src; // Alineación const align = style['text-align'] || 'left'; document.querySelectorAll('.prop-align-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.align === align); }); } // ── Aplicar cambio de propiedad al componente seleccionado ── function applyStyleToSelected(prop, value) { if (!selectedComponent || !editor) return; const styleMap = { 'color': 'color', 'backgroundColor': 'background-color', 'opacity': 'opacity', 'paddingTop': 'padding-top', 'paddingBottom': 'padding-bottom', 'fontSize': 'font-size', 'fontWeight': 'font-weight', 'fontFamily': 'font-family', 'textAlign': 'text-align' }; const cssProp = styleMap[prop] || prop; selectedComponent.addStyle({ [cssProp]: value }); setSaveStatus('unsaved'); } // ── Aplicar src de imagen ───────────────────────────── function applyImageSrc(src) { if (!selectedComponent || !editor) return; // Si el componente seleccionado es una imagen if (selectedComponent.get('tagName') === 'img') { selectedComponent.set('src', src); selectedComponent.addAttributes({ src }); } else { // Buscar la primera img hija const img = selectedComponent.find('img')[0]; if (img) { img.set('src', src); img.addAttributes({ src }); } } setSaveStatus('unsaved'); } // ── Escuchar eventos del panel de propiedades ───────── function bindPanelEvents() { // Escuchar el evento personalizado emitido por los controles del Bloque 1 document.addEventListener('propiedadCambiada', (e) => { const { tipo, valor } = e.detail; if (tipo === 'src') { applyImageSrc(valor); } else { applyStyleToSelected(tipo, valor); } }); // Font family const fontSel = document.getElementById('prop-font'); if (fontSel) fontSel.addEventListener('change', function() { applyStyleToSelected('fontFamily', this.value); }); // Font size input libre const fontSizeInput = document.getElementById('prop-font-size'); if (fontSizeInput) fontSizeInput.addEventListener('change', function() { const val = parseInt(this.value); if (!isNaN(val) && val > 0) applyStyleToSelected('fontSize', val + 'px'); }); // Image URL input const imgUrl = document.getElementById('prop-img-url'); if (imgUrl) imgUrl.addEventListener('change', function() { applyImageSrc(this.value.trim()); }); // Image file upload desde panel const imgFile = document.getElementById('prop-img-file'); if (imgFile) imgFile.addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { const dataUrl = ev.target.result; document.getElementById('prop-img-url').value = dataUrl.substring(0, 60) + '…'; applyImageSrc(dataUrl); addAssetToPanel(dataUrl, file.name); }; reader.readAsDataURL(file); }); } // ── Acciones sobre bloques ──────────────────────────── function duplicateBlock() { if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; } selectedComponent.clone(); refreshLayers(); setSaveStatus('unsaved'); showToast('Bloque duplicado ✅', 'success'); } function moveBlockUp() { if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; } const parent = selectedComponent.parent(); if (!parent) return; const idx = parent.components().indexOf(selectedComponent); if (idx <= 0) { showToast('Ya está en la primera posición', 'info'); return; } selectedComponent.move(parent, { at: idx - 1 }); refreshLayers(); setSaveStatus('unsaved'); } function moveBlockDown() { if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; } const parent = selectedComponent.parent(); if (!parent) return; const total = parent.components().length; const idx = parent.components().indexOf(selectedComponent); if (idx >= total - 1) { showToast('Ya está en la última posición', 'info'); return; } selectedComponent.move(parent, { at: idx + 1 }); refreshLayers(); setSaveStatus('unsaved'); } function deleteBlock() { if (!selectedComponent) { showToast('Selecciona un bloque primero', 'info'); return; } if (!confirm('¿Eliminar este bloque? No se puede deshacer.')) return; selectedComponent.remove(); selectedComponent = null; document.getElementById('panel-no-selection').classList.remove('hidden'); document.getElementById('panel-props-active').classList.add('hidden'); refreshLayers(); setSaveStatus('unsaved'); showToast('Bloque eliminado', 'info'); } // ── Capas (Layers) ──────────────────────────────────── function refreshLayers() { if (!editor || !editorReady) return; const container = document.getElementById('layers-list'); const wrapper = editor.getWrapper(); if (!wrapper) return; const items = wrapper.components(); if (items.length === 0) { container.innerHTML = '
Sin bloques aún
'; return; } container.innerHTML = ''; items.forEach((comp, i) => { const tag = comp.get('tagName') || 'div'; const classes = comp.getClasses(); const name = classes.find(c => c.startsWith('bloque-')) ? classes.find(c => c.startsWith('bloque-')).replace('bloque-', '').replace(/-/g, ' ') : tag + ' ' + (i + 1); const item = document.createElement('div'); item.className = 'sb-layer-item'; item.innerHTML = ` ${name} ${tag.toUpperCase()} `; item.addEventListener('click', () => { editor.select(comp); // Scroll al componente en el canvas comp.view.el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); container.appendChild(item); }); } // ── Cargar HTML externo desde archivo ───────────────── function triggerLoadHTML() { // El label wrapper activa el input directamente — no necesita JS const inp = document.getElementById('load-html-input'); if (inp) inp.click(); } function setupLoadHTMLInput() { const input = document.getElementById('load-html-input'); input.addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; if (!file.name.endsWith('.html')) { showToast('Solo se aceptan archivos .html', 'error'); return; } const reader = new FileReader(); reader.onload = (ev) => { const html = ev.target.result; loadHTMLIntoEditor(html, file.name); }; reader.readAsText(file); // Resetear el input para permitir recargar el mismo archivo input.value = ''; }); } function loadHTMLIntoEditor(rawHTML, filename) { if (!editorReady) { showToast('El editor aún no está listo', 'error'); return; } // Extraer el body let content = rawHTML; const bodyMatch = rawHTML.match(/]*>([\s\S]*?)<\/body>/i); if (bodyMatch) content = bodyMatch[1]; // Extraer TODOS los estilos del archivo (style tags + link tags) let extractedCSS = ''; const styleMatches = rawHTML.match(/]*>([\s\S]*?)<\/style>/gi) || []; styleMatches.forEach(tag => { const inner = tag.match(/]*>([\s\S]*?)<\/style>/i); if (inner) extractedCSS += inner[1] + '\n'; }); // Cargar componentes editor.setComponents(content); // Aplicar CSS base Farmasi + CSS del archivo en el canvas const combinedCSS = FARMASI_CSS + '\n' + extractedCSS; editor.setStyle(combinedCSS); // También inyectar directamente en el head del iframe de GrapesJS // para que las fuentes y variables CSS funcionen try { const iframeHead = editor.Canvas.getDocument().head; // Quitar estilos previos inyectados iframeHead.querySelectorAll('[data-editor-injected]').forEach(el => el.remove()); if (extractedCSS) { const styleEl = iframeHead.ownerDocument.createElement('style'); styleEl.setAttribute('data-editor-injected', '1'); styleEl.textContent = extractedCSS; iframeHead.appendChild(styleEl); } } catch(_) {} // Actualizar nombre del proyecto con el nombre del archivo const nameInput = document.getElementById('project-name-input'); const suggestedName = filename.replace('.html', '').replace(/_/g, '-').replace(/([A-Z])/g, ' $1').trim(); nameInput.value = suggestedName; // Sugerir slug en Netlify const slug = filename.replace('.html', '').replace(/_/g, '-').toLowerCase().replace(/[^a-z0-9-]/g, ''); syncUrlBar(slug); document.getElementById('pub-slug-input').value = slug; document.getElementById('canvas-empty').classList.add('hidden'); refreshLayers(); setSaveStatus('unsaved'); showToast('HTML cargado: ' + filename + ' ✅', 'success'); } // ── Descargar HTML editado ──────────────────────────── function descargarHTML() { if (!editorReady) { showToast('El editor aún no tiene contenido', 'info'); return; } const html = buildFinalHTML(); const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const slug = document.getElementById('pub-slug-input').value || 'mi-landing'; a.download = slug + '.html'; a.click(); URL.revokeObjectURL(url); showToast('HTML descargado ✅', 'success'); } // ── Construir HTML final completo ───────────────────── function buildFinalHTML() { const body = editor.getHtml(); const css = editor.getCss(); const projectName = document.getElementById('project-name-input').value || 'Mi Landing'; const META_CHARSET = 'charset=UTF-8'; const FONT_FAMILY = 'Plus Jakarta Sans'; return ` ${escapeHtml(projectName)} ${body} // Countdown automático (function() { var targets = document.querySelectorAll('[data-cd]'); if (!targets.length) return; var endTime = localStorage.getItem('cd_end'); if (!endTime) { endTime = Date.now() + (2 * 24 * 60 * 60 * 1000); // 2 días localStorage.setItem('cd_end', endTime); } function update() { var diff = Math.max(0, endTime - Date.now()); var d = Math.floor(diff / 86400000); var h = Math.floor((diff % 86400000) / 3600000); var m = Math.floor((diff % 3600000) / 60000); var s = Math.floor((diff % 60000) / 1000); document.querySelectorAll('[data-cd="days"]').forEach(el => el.textContent = String(d).padStart(2,'0')); document.querySelectorAll('[data-cd="hours"]').forEach(el => el.textContent = String(h).padStart(2,'0')); document.querySelectorAll('[data-cd="mins"]').forEach(el => el.textContent = String(m).padStart(2,'0')); document.querySelectorAll('[data-cd="secs"]').forEach(el => el.textContent = String(s).padStart(2,'0')); } update(); setInterval(update, 1000); })(); <\/script> `; } // ── Vista previa ────────────────────────────────────── function openPreview() { if (!editorReady) { showToast('No hay contenido para previsualizar', 'info'); return; } const html = buildFinalHTML(); // Crear un iframe de preview en un modal — evita el COOP bloqueado let overlay = document.getElementById('preview-overlay'); if (!overlay) { overlay = document.createElement('div'); overlay.id = 'preview-overlay'; overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.92);z-index:9999;display:flex;flex-direction:column;'; overlay.innerHTML = `
🦅 Preview — Vista previa de la landing
`; document.body.appendChild(overlay); } // Escribir HTML directamente en el iframe — sin blob URL, sin COOP const iframe = overlay.querySelector('#preview-iframe'); const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; iframeDoc.open(); iframeDoc.write(html); iframeDoc.close(); showToast('Preview abierto ✅', 'success'); } // ── Assets panel ───────────────────────────────────── function addAssetToPanel(dataUrl, name) { const grid = document.getElementById('assets-grid'); // Limpiar el placeholder si existe const placeholder = grid.querySelector('[style*="color:var"]'); if (placeholder) placeholder.remove(); const thumb = document.createElement('div'); thumb.style.cssText = 'background:#141414;border:1px solid var(--border);border-radius:6px;overflow:hidden;cursor:pointer;aspect-ratio:1'; thumb.innerHTML = ``; thumb.addEventListener('click', () => { applyImageSrc(dataUrl); showToast('Imagen aplicada ✅', 'success'); }); grid.prepend(thumb); } function triggerAssetUpload() { document.getElementById('asset-upload-input').click(); } function setupAssetUpload() { const input = document.getElementById('asset-upload-input'); input.addEventListener('change', function(e) { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (ev) => { addAssetToPanel(ev.target.result, file.name); showToast('Imagen añadida a activos ✅', 'success'); }; reader.readAsDataURL(file); input.value = ''; }); } // ── Estado de guardado ──────────────────────────────── function setSaveStatus(state) { const dot = document.getElementById('save-dot'); const label = document.getElementById('save-label'); if (state === 'saved') { dot.className = 'tb-save-dot saved'; label.textContent = 'Guardado'; } else if (state === 'saving') { dot.className = 'tb-save-dot saving'; label.textContent = 'Guardando…'; } else { dot.className = 'tb-save-dot'; label.textContent = 'Sin guardar'; } } // ── Escape HTML helper ──────────────────────────────── function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── Normalizar color hex ────────────────────────────── function hexNormalize(color) { if (!color) return '#ffffff'; if (color.startsWith('#')) return color.length === 4 ? '#' + color[1]+color[1]+color[2]+color[2]+color[3]+color[3] : color; // rgb(r,g,b) → hex const m = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); if (m) { return '#' + [m[1],m[2],m[3]] .map(n => parseInt(n).toString(16).padStart(2,'0')) .join(''); } return '#ffffff'; } // SECCIÓN 2D — DRAG & DROP + COUNTDOWN + INIT PRINCIPAL // ── Drag & drop del sidebar al canvas ──────────────── function setupDragAndDrop() { const blockCards = document.querySelectorAll('.sb-block-card'); const dropOverlay = document.getElementById('drop-overlay'); const canvasViewport = document.getElementById('canvas-viewport'); let draggedBlockId = null; blockCards.forEach(card => { card.setAttribute('draggable', 'true'); card.addEventListener('dragstart', (e) => { draggedBlockId = card.dataset.block; e.dataTransfer.setData('text/plain', draggedBlockId); e.dataTransfer.effectAllowed = 'copy'; card.classList.add('dragging'); }); card.addEventListener('dragend', () => { card.classList.remove('dragging'); dropOverlay.classList.remove('show'); }); // Click fallback — funciona siempre, incluso sin drag support card.addEventListener('click', () => { const blockId = card.dataset.block; if (blockId) insertBlockById(blockId); }); }); // ── Eventos en el viewport del documento principal ── canvasViewport.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; dropOverlay.classList.add('show'); }); canvasViewport.addEventListener('dragleave', (e) => { if (!canvasViewport.contains(e.relatedTarget)) { dropOverlay.classList.remove('show'); } }); canvasViewport.addEventListener('drop', (e) => { e.preventDefault(); dropOverlay.classList.remove('show'); const blockId = e.dataTransfer.getData('text/plain') || draggedBlockId; if (blockId) insertBlockById(blockId); draggedBlockId = null; }); // El canvas viewport ya tiene dragover/drop registrados arriba. // La inyección en el iframe de GrapesJS se hace desde editor.on('load') // via injectDragIntoGjsIframe() — ver initGrapesJS(). } // ── Inyectar drag en iframe de GrapesJS (llamado desde editor.on load) ── function injectDragIntoGjsIframe() { if (!editor) return; const dropOverlay = document.getElementById('drop-overlay'); try { const gjsFrame = editor.Canvas.getFrameEl(); if (!gjsFrame) return; const iframeDoc = gjsFrame.contentDocument || gjsFrame.contentWindow.document; iframeDoc.addEventListener('dragover', (e) => { e.preventDefault(); if (dropOverlay) dropOverlay.classList.add('show'); }); iframeDoc.addEventListener('dragleave', () => { if (dropOverlay) dropOverlay.classList.remove('show'); }); iframeDoc.addEventListener('drop', (e) => { e.preventDefault(); if (dropOverlay) dropOverlay.classList.remove('show'); const blockId = e.dataTransfer ? e.dataTransfer.getData('text/plain') : ''; if (blockId) insertBlockById(blockId); }); } catch (_) {} } // ── Countdown en el canvas (se activa post-carga) ───── // El countdown real va en el HTML descargado (buildFinalHTML) // En el canvas solo actualizamos los spans cada segundo let countdownInterval = null; function startCanvasCountdown() { if (countdownInterval) clearInterval(countdownInterval); // Usar 2 días a partir de ahora como referencia const endTime = Date.now() + (2 * 24 * 60 * 60 * 1000); function updateCanvas() { if (!editor || !editorReady) return; const diff = Math.max(0, endTime - Date.now()); const d = Math.floor(diff / 86400000); const h = Math.floor((diff % 86400000) / 3600000); const m = Math.floor((diff % 3600000) / 60000); const s = Math.floor((diff % 60000) / 1000); const vals = { days: d, hours: h, mins: m, secs: s }; // Acceder al DOM dentro del iframe de GrapesJS try { const iframeDoc = editor.Canvas.getDocument(); Object.entries(vals).forEach(([key, val]) => { iframeDoc.querySelectorAll('[data-cd="' + key + '"]').forEach(el => { el.textContent = String(val).padStart(2, '0'); }); }); } catch (_) { // Canvas aún no listo — silenciar } } updateCanvas(); countdownInterval = setInterval(updateCanvas, 1000); } // ── Auto-resize del iframe con el contenido ─────────── function setupIframeAutoResize() { if (!editor) return; editor.on('component:add', () => resizeIframe()); editor.on('component:remove', () => resizeIframe()); editor.on('load', () => { resizeIframe(); startCanvasCountdown(); }); } function resizeIframe() { if (!editor || !editorReady) return; try { const doc = editor.Canvas.getDocument(); const height = Math.max(600, doc.body.scrollHeight + 40); editor.Canvas.getFrameEl().style.height = height + 'px'; const gjsCanvas = document.getElementById('gjs-canvas'); if (gjsCanvas) gjsCanvas.style.minHeight = height + 'px'; } catch (_) {} } // ── Slug: sync avanzada ─────────────────────────────── // Ya definida en Bloque 1 (syncUrlBar), extendemos aquí: function suggestSlugFromName(name) { // Elvis Hernández → elvis-hernandez return name .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') // remover tildes .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .substring(0, 40); } // Al cambiar el nombre del proyecto, sugerir slug function bindProjectNameToSlug() { const nameInput = document.getElementById('project-name-input'); if (!nameInput) return; nameInput.addEventListener('input', function() { // Solo sugerir si el slug no ha sido editado manualmente const slugInput = document.getElementById('pub-slug-input'); if (!slugInput) return; const suggested = suggestSlugFromName(this.value); if (suggested) { slugInput.value = suggested; syncUrlBar(suggested); } }); } // ── Inicialización principal (DOMContentLoaded) ─────── function initEditor() { // 1. Cargar GrapesJS desde CDN (ya debe estar en el HTML) // Verificar que grapesjs está disponible if (typeof grapesjs === 'undefined') { console.error('GrapesJS no está cargado. Verifica el CDN.'); showToast('Error: Editor no pudo cargar. Recarga la página.', 'error'); return; } // 2+3. GrapesJS container ya existe en el HTML como #gjs-canvas // Inicializar directamente initGrapesJS(); // 4. Setup de eventos setupDragAndDrop(); setupIframeAutoResize(); bindPanelEvents(); setupLoadHTMLInput(); setupAssetUpload(); bindProjectNameToSlug(); // 5. Ocultar containers ocultos de GrapesJS nativo ['#gjs-blocks-hidden','#gjs-styles-hidden','#gjs-layers-hidden','#gjs-traits-hidden'].forEach(sel => { const el = document.querySelector(sel); if (el) el.style.display = 'none'; }); // 6. Mostrar toast de bienvenida // Toast movido a editor.on('load') en initGrapesJS para dispararse cuando realmente esté listo } // ── Entry point ─────────────────────────────────────── // Se ejecuta solo cuando el DOM está listo y el usuario está autenticado // (La autenticación de Firebase del Bloque 3 llama a initEditor() post-login)