100%
`; } return html; } function renderTexte() { return `

Texte

Ajoutez du texte à votre design

Combinaisons

Polices

${['Fraunces','Playfair Display','Abril Fatface','Lora','Bricolage Grotesque','DM Sans','Inter Tight','Caveat','Dancing Script','Pacifico','Bebas Neue','Space Mono'] .map(f => ``).join('')}
`; } function renderImages() { return `

Images

Importez vos visuels

Importer une image
JPG, PNG, GIF, SVG
${state.uploads.length > 0 ? `

Vos importations

${state.uploads.map(u => `
`).join('')}
` : ''}

Photos suggérées

${STOCK_PHOTOS.map(s => `
`).join('')}
`; } function renderCouleur() { const palettes = [ { name: 'Parisien', colors: ['#1A1612','#C8442C','#B8923D','#F7F2E8','#2D4A6B','#FFFFFF'] }, { name: 'Provence', colors: ['#7B5A8C','#D4A574','#A8C97F','#E8DCC4','#5E4B3E','#FFFFFF'] }, { name: 'Riviera', colors: ['#1B4965','#62B6CB','#BEE9E8','#CAE9FF','#5FA8D3','#FFFFFF'] }, { name: 'Bordeaux', colors: ['#5B1A1A','#8B2635','#C73E1D','#F4A261','#E9C46A','#FFFFFF'] }, { name: 'Cathédrale', colors: ['#2B2D42','#8D99AE','#EDF2F4','#EF233C','#D90429','#FFFFFF'] }, { name: 'Camargue', colors: ['#264653','#2A9D8F','#E9C46A','#F4A261','#E76F51','#FFFFFF'] }, ]; const page = currentPage(); const bg = (page.bg || '#ffffff').startsWith('linear') ? '#ffffff' : (page.bg || '#ffffff'); return `

Couleur

Palettes, dégradés & arrière-plan

Fond de page

Dégradés

${GRADIENTS.map((g, i) => `
${g.name}
`).join('')}
${palettes.map(p => `

${p.name}

${p.colors.map(c => `
`).join('')}
`).join('')} `; } function renderEffets() { const el = selectedEl(); if (!el) { return `

Effets

Sélectionnez un élément pour appliquer des effets

Aucun élément sélectionné

Cliquez sur un objet de votre canevas pour révéler les effets disponibles.

`; } const eff = el.effects || defaultEffects(); return `

Effets

Sublimez votre élément

Ombre portée
${eff.shadow.enabled ? `
X ${eff.shadow.x}px
Y ${eff.shadow.y}px
Flou ${eff.shadow.blur}px
Couleur
` : ''}
Flou ${eff.blur}px
${el.type === 'image' ? `
Filtre photo
${Object.entries(FILTERS).map(([k, v]) => `
${k}
`).join('')}
` : ''} `; } function renderCommunaute() { const isAnon = state.user.name === 'Anonyme'; let html = `

Communauté

Les designs des utilisateurs de Toile

${isAnon ? `

Choisissez un pseudo

Pour publier vos créations dans la communauté.

` : ''}
Chargement…
`; return html; } function renderFormat() { return `

Format

${Object.keys(FORMATS).length}+ dimensions disponibles

${Object.entries(FORMAT_CATEGORIES).map(([cat, formats]) => `

${cat}

${Object.entries(formats).map(([key, f]) => ` `).join('')}
`).join('')}

Personnalisé

Largeur
Hauteur
`; } // ───────────────────────────────────────────────────────────── // PANEL EVENTS // ───────────────────────────────────────────────────────────── function attachPanelEvents() { document.querySelectorAll('.subtab[data-subtab]').forEach(b => { b.addEventListener('click', () => { currentSubtab = b.dataset.subtab; renderPanel(); }); }); document.querySelectorAll('.template-card').forEach(c => { c.addEventListener('click', () => { const t = TEMPLATES[+c.dataset.template]; snapshot(); state.format = t.format; const fmt = FORMATS[t.format]; const newPage = { id: uid(), width: fmt.w, height: fmt.h, bg: t.bg, elements: t.elements.map(e => ({...e, id: uid(), text: e.text ? e.text.replace(/\\n/g, '\n') : e.text, effects: e.effects || defaultEffects()})) }; state.pages = [newPage]; state.currentPageId = newPage.id; state.selectedId = null; state.zoom = computeFitZoom(); render(); toast('Modèle appliqué'); }); }); document.querySelectorAll('.shape-card[data-shape]').forEach(c => { c.addEventListener('click', () => addShape(c.dataset.shape)); }); document.querySelectorAll('.shape-card[data-icon]').forEach(c => { c.addEventListener('click', () => { const [cat, name] = c.dataset.icon.split('|'); addIcon(ICONS[cat][name], cat, name); }); }); document.querySelectorAll('.shape-card[data-frame]').forEach(c => { c.addEventListener('click', () => { const frames = window._FRAMES; const f = frames[+c.dataset.frame]; const page = currentPage(); addElement({ type: 'shape', shape: f.shape, width: Math.min(400, page.width*0.6), height: Math.min(400, page.width*0.6), fill: 'transparent', fillType: 'solid', gradient: null, stroke: '#1A1612', strokeWidth: f.strokeWidth, borderRadius: f.borderRadius || 0, dashed: f.dashed || false, }); }); }); document.querySelectorAll('.emoji-btn').forEach(b => { b.addEventListener('click', () => addEmoji(b.dataset.emoji)); }); document.querySelectorAll('[data-add-text]').forEach(b => { b.addEventListener('click', () => addText({ text: 'Votre texte', fontSize: 48 })); }); document.querySelectorAll('[data-text-preset]').forEach(b => { b.addEventListener('click', () => { const p = b.dataset.textPreset; if (p === 'h1') addText({ text: 'Titre élégant', fontFamily: 'Fraunces', fontSize: 72, fontWeight: 600, width: 600, height: 100 }); else if (p === 'h2') addText({ text: 'Sous-titre raffiné', fontFamily: 'Fraunces', fontSize: 42, fontWeight: 500, width: 500, height: 70 }); else if (p === 'body') addText({ text: 'Texte de paragraphe', fontFamily: 'Bricolage Grotesque', fontSize: 22, fontWeight: 400, width: 400, height: 50 }); else if (p === 'script') addText({ text: 'Note manuscrite', fontFamily: 'Caveat', fontSize: 64, fontWeight: 700, width: 500, height: 90 }); }); }); document.querySelectorAll('[data-font]').forEach(b => { b.addEventListener('click', () => addText({ text: b.dataset.font, fontFamily: b.dataset.font, fontSize: 56, width: 500, height: 90 })); }); const uz = document.getElementById('uploadZone'); const fi = document.getElementById('fileInput'); if (uz && fi) { uz.addEventListener('click', () => fi.click()); fi.addEventListener('change', (e) => { for (const file of e.target.files) { const reader = new FileReader(); reader.onload = (ev) => { const img = new Image(); img.onload = () => { state.uploads.push({ src: ev.target.result, w: img.width, h: img.height }); renderPanel(); addImage(ev.target.result, img.width, img.height); }; img.src = ev.target.result; }; reader.readAsDataURL(file); } }); } document.querySelectorAll('.uploaded-img').forEach(u => { u.addEventListener('click', () => addImage(u.dataset.src, +u.dataset.w, +u.dataset.h)); }); document.querySelectorAll('.color-swatch').forEach(s => { s.addEventListener('click', () => { const c = s.dataset.color; const el = selectedEl(); if (el) { snapshot(); if (el.type === 'text') el.color = c; else if (el.type === 'shape') { el.fill = c; el.fillType = 'solid'; } else if (el.type === 'icon') el.color = c; render(); } else { snapshot(); currentPage().bg = c; render(); } }); }); document.querySelectorAll('.gradient-card').forEach(g => { g.addEventListener('click', () => applyGradient(GRADIENTS[+g.dataset.gradient])); }); const pageBg = document.getElementById('pageBg'); if (pageBg) { pageBg.addEventListener('input', (e) => { currentPage().bg = e.target.value; document.getElementById('pageBgText').value = e.target.value.toUpperCase(); renderCanvas(); }); pageBg.addEventListener('change', () => snapshot()); } // Effects const shadowToggle = document.getElementById('shadowToggle'); if (shadowToggle) { shadowToggle.addEventListener('click', () => { const el = selectedEl(); if (!el) return; snapshot(); el.effects.shadow.enabled = !el.effects.shadow.enabled; render(); }); } ['shadowX','shadowY','shadowBlur'].forEach(id => { const inp = document.getElementById(id); if (inp) { inp.addEventListener('input', e => { const el = selectedEl(); if (!el) return; const k = id.replace('shadow', '').toLowerCase(); el.effects.shadow[k] = +e.target.value; renderCanvas(); const lbl = inp.previousElementSibling.querySelector('.prop-value'); if (lbl) lbl.textContent = e.target.value + 'px'; }); inp.addEventListener('change', () => snapshot()); } }); const sc = document.getElementById('shadowColor'); if (sc) { sc.addEventListener('input', e => { const el = selectedEl(); if (!el) return; el.effects.shadow.color = e.target.value; document.getElementById('shadowColorText').value = e.target.value.toUpperCase(); renderCanvas(); }); sc.addEventListener('change', () => snapshot()); } const eb = document.getElementById('effectBlur'); if (eb) { eb.addEventListener('input', e => { const el = selectedEl(); if (!el) return; el.effects.blur = +e.target.value; renderCanvas(); const lbl = eb.previousElementSibling.querySelector('.prop-value'); if (lbl) lbl.textContent = e.target.value + 'px'; }); eb.addEventListener('change', () => snapshot()); } document.querySelectorAll('[data-filter]').forEach(f => { f.addEventListener('click', () => { const el = selectedEl(); if (!el) return; snapshot(); el.effects.filter = f.dataset.filter; render(); }); }); // Format document.querySelectorAll('.format-btn').forEach(b => { b.addEventListener('click', () => { snapshot(); state.format = b.dataset.format; const f = FORMATS[b.dataset.format]; currentPage().width = f.w; currentPage().height = f.h; state.zoom = computeFitZoom(); render(); }); }); const applyCustom = document.getElementById('applyCustom'); if (applyCustom) { applyCustom.addEventListener('click', () => { const w = +document.getElementById('customW').value; const h = +document.getElementById('customH').value; if (w > 0 && h > 0) { snapshot(); currentPage().width = w; currentPage().height = h; state.zoom = computeFitZoom(); render(); } }); } // Community const setNameBtn = document.getElementById('setNameBtn'); if (setNameBtn) setNameBtn.addEventListener('click', promptUserName); document.querySelectorAll('[data-sort]').forEach(b => { b.addEventListener('click', () => { document.querySelectorAll('[data-sort]').forEach(x => x.classList.remove('active')); b.classList.add('active'); renderCommunityFeed(b.dataset.sort); }); }); } // ───────────────────────────────────────────────────────────── // STOCK PHOTOS (placeholders dégradés) // ───────────────────────────────────────────────────────────── function gradientPhoto(c1, c2, c3) { const svg = ``; return 'data:image/svg+xml;base64,' + btoa(svg); } const STOCK_PHOTOS = [ { url: gradientPhoto('#FFB997','#F67E7D','#843B62') }, { url: gradientPhoto('#A8DADC','#457B9D','#1D3557') }, { url: gradientPhoto('#F4A261','#E76F51','#264653') }, { url: gradientPhoto('#E0BBE4','#957DAD','#D291BC') }, { url: gradientPhoto('#06D6A0','#118AB2','#073B4C') }, { url: gradientPhoto('#FFE5D9','#FFCAD4','#F4ACB7') }, ]; // ───────────────────────────────────────────────────────────── // TEMPLATES // ───────────────────────────────────────────────────────────── const TEMPLATES = [ { name: 'Affiche poétique', format: 'instagram-post', bg: '#F7F2E8', preview: { bg: '#F7F2E8', html: '
Vivre,
simplement
' }, elements: [ { type: 'text', text: 'Vivre,', x: 140, y: 380, width: 800, height: 200, fontFamily: 'Fraunces', fontSize: 180, fontWeight: 700, color: '#1A1612', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'simplement.', x: 140, y: 580, width: 800, height: 200, fontFamily: 'Fraunces', fontSize: 140, fontWeight: 400, color: '#C8442C', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'shape', shape: 'line', x: 440, y: 800, width: 200, height: 2, fill: '#1A1612', fillType: 'solid', stroke: 'none', strokeWidth: 0, opacity: 1, borderRadius: 0, rotation: 0 }, { type: 'text', text: 'TOILE — 2026', x: 340, y: 950, width: 400, height: 40, fontFamily: 'Space Mono', fontSize: 18, fontWeight: 400, color: '#8A7E6D', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, ] }, { name: 'Story moderne', format: 'instagram-story', bg: '#1A1612', preview: { bg: '#1A1612', html: '
NOUVEAU
' }, elements: [ { type: 'shape', shape: 'rect', x: 90, y: 280, width: 900, height: 6, fill: '#C8442C', fillType: 'solid', stroke: 'none', strokeWidth: 0, opacity: 1, borderRadius: 0, rotation: 0 }, { type: 'text', text: 'NOUVEAU', x: 90, y: 320, width: 900, height: 180, fontFamily: 'Bebas Neue', fontSize: 200, fontWeight: 400, color: '#F7F2E8', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'collection', x: 90, y: 540, width: 900, height: 120, fontFamily: 'Fraunces', fontSize: 100, fontWeight: 400, color: '#C8442C', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Découvrez nos pièces de printemps', x: 90, y: 1420, width: 900, height: 80, fontFamily: 'Bricolage Grotesque', fontSize: 36, fontWeight: 400, color: '#F7F2E8', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 0.8 }, { type: 'text', text: '↓ swipe up', x: 90, y: 1780, width: 900, height: 60, fontFamily: 'Space Mono', fontSize: 28, fontWeight: 400, color: '#C8442C', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, ] }, { name: 'Carte de visite', format: 'business-card', bg: '#F7F2E8', preview: { bg: '#F7F2E8', html: '
Marie
Designer
' }, elements: [ { type: 'text', text: 'Marie Dubois', x: 30, y: 80, width: 300, height: 60, fontFamily: 'Fraunces', fontSize: 38, fontWeight: 600, color: '#1A1612', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Designer graphique', x: 30, y: 130, width: 300, height: 30, fontFamily: 'Bricolage Grotesque', fontSize: 16, fontWeight: 400, color: '#C8442C', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'shape', shape: 'line', x: 30, y: 180, width: 60, height: 2, fill: '#C8442C', fillType: 'solid', stroke: 'none', strokeWidth: 0, opacity: 1, borderRadius: 0, rotation: 0 }, { type: 'text', text: 'marie@studio.fr\\n06 12 34 56 78\\nstudio-marie.fr', x: 30, y: 210, width: 300, height: 80, fontFamily: 'Space Mono', fontSize: 12, fontWeight: 400, color: '#3D352B', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, ] }, { name: 'Faire-part', format: 'a4-portrait', bg: '#FAF6EC', preview: { bg: '#FAF6EC', html: '
Léa
&
Thomas
' }, elements: [ { type: 'text', text: '✦', x: 357, y: 150, width: 80, height: 80, fontFamily: 'Fraunces', fontSize: 60, fontWeight: 400, color: '#B8923D', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Léa', x: 197, y: 260, width: 400, height: 140, fontFamily: 'Caveat', fontSize: 120, fontWeight: 700, color: '#1A1612', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: '&', x: 297, y: 400, width: 200, height: 60, fontFamily: 'Fraunces', fontSize: 36, fontWeight: 400, color: '#B8923D', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Thomas', x: 147, y: 460, width: 500, height: 140, fontFamily: 'Caveat', fontSize: 120, fontWeight: 700, color: '#1A1612', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'shape', shape: 'line', x: 297, y: 640, width: 200, height: 1, fill: '#B8923D', fillType: 'solid', stroke: 'none', strokeWidth: 0, opacity: 1, borderRadius: 0, rotation: 0 }, { type: 'text', text: 'ont la joie de vous inviter à célébrer leur union', x: 97, y: 670, width: 600, height: 50, fontFamily: 'Fraunces', fontSize: 18, fontWeight: 400, color: '#3D352B', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Le samedi 14 juin 2026', x: 97, y: 760, width: 600, height: 50, fontFamily: 'Fraunces', fontSize: 28, fontWeight: 600, color: '#1A1612', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Château de Saint-Émilion', x: 97, y: 810, width: 600, height: 40, fontFamily: 'Fraunces', fontSize: 18, fontWeight: 400, color: '#3D352B', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, ] }, { name: 'Festival vibrant', format: 'poster', bg: '#C8442C', preview: { bg: '#C8442C', html: '
JAZZ
FESTIVAL
' }, elements: [ { type: 'text', text: 'JAZZ', x: 90, y: 200, width: 1011, height: 280, fontFamily: 'Bebas Neue', fontSize: 320, fontWeight: 400, color: '#F7F2E8', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'FESTIVAL', x: 90, y: 480, width: 1011, height: 280, fontFamily: 'Bebas Neue', fontSize: 320, fontWeight: 400, color: '#F7F2E8', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'de Montmartre', x: 90, y: 780, width: 1011, height: 100, fontFamily: 'Fraunces', fontSize: 72, fontWeight: 400, color: '#1A1612', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'shape', shape: 'circle', x: 495, y: 950, width: 200, height: 200, fill: '#1A1612', fillType: 'solid', stroke: 'none', strokeWidth: 0, opacity: 1, borderRadius: 0, rotation: 0 }, { type: 'text', text: '15.16.17\\nMAI', x: 495, y: 990, width: 200, height: 120, fontFamily: 'Bebas Neue', fontSize: 48, fontWeight: 400, color: '#F7F2E8', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'PLACE DU TERTRE · PARIS 18E', x: 90, y: 1500, width: 1011, height: 60, fontFamily: 'Space Mono', fontSize: 32, fontWeight: 700, color: '#1A1612', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, ] }, { name: 'Recette gourmande', format: 'instagram-post', bg: '#FFF9F0', preview: { bg: '#FFF9F0', html: '
🥐
Croissants
maison
' }, elements: [ { type: 'text', text: '🥐', x: 440, y: 120, width: 200, height: 200, fontFamily: 'system-ui', fontSize: 160, fontWeight: 400, color: '#1A1612', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'La recette', x: 140, y: 340, width: 800, height: 60, fontFamily: 'Lora', fontSize: 32, fontWeight: 400, color: '#8B2635', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'des croissants', x: 140, y: 400, width: 800, height: 140, fontFamily: 'Lora', fontSize: 96, fontWeight: 600, color: '#5B1A1A', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'maison', x: 140, y: 540, width: 800, height: 140, fontFamily: 'Caveat', fontSize: 110, fontWeight: 700, color: '#C73E1D', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'shape', shape: 'line', x: 490, y: 720, width: 100, height: 1, fill: '#8B2635', fillType: 'solid', stroke: 'none', strokeWidth: 0, opacity: 1, borderRadius: 0, rotation: 0 }, { type: 'text', text: '500 g de farine · 10 g de sel\\n50 g de sucre · 10 g de levure\\n300 ml de lait · 250 g de beurre', x: 140, y: 760, width: 800, height: 180, fontFamily: 'Lora', fontSize: 28, fontWeight: 400, color: '#3D352B', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, ] }, { name: 'CV élégant', format: 'cv', bg: '#FFFFFF', preview: { bg: '#FFFFFF', html: '
JEAN
MARTIN
Designer UX
' }, elements: [ { type: 'text', text: 'JEAN', x: 50, y: 60, width: 400, height: 80, fontFamily: 'Fraunces', fontSize: 56, fontWeight: 700, color: '#1A1612', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'MARTIN', x: 50, y: 120, width: 400, height: 80, fontFamily: 'Fraunces', fontSize: 56, fontWeight: 700, color: '#C8442C', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'shape', shape: 'line', x: 50, y: 220, width: 80, height: 3, fill: '#C8442C', fillType: 'solid', stroke: 'none', strokeWidth: 0, opacity: 1, borderRadius: 0, rotation: 0 }, { type: 'text', text: 'Designer UX · Paris', x: 50, y: 240, width: 400, height: 30, fontFamily: 'Bricolage Grotesque', fontSize: 16, fontWeight: 400, color: '#3D352B', textAlign: 'left', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'EXPÉRIENCE', x: 50, y: 320, width: 300, height: 25, fontFamily: 'Bricolage Grotesque', fontSize: 12, fontWeight: 700, color: '#1A1612', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Senior Designer · Studio Paris\\n2022 — Aujourd\\'hui', x: 50, y: 350, width: 350, height: 50, fontFamily: 'Fraunces', fontSize: 14, fontWeight: 400, color: '#3D352B', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'FORMATION', x: 50, y: 450, width: 300, height: 25, fontFamily: 'Bricolage Grotesque', fontSize: 12, fontWeight: 700, color: '#1A1612', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Master Design — ENSAD\\nLicence Arts Visuels — Sorbonne', x: 50, y: 480, width: 350, height: 50, fontFamily: 'Fraunces', fontSize: 14, fontWeight: 400, color: '#3D352B', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'jean.martin@email.fr\\n06 12 34 56 78\\nlinkedin.com/in/jeanmartin', x: 50, y: 1000, width: 350, height: 70, fontFamily: 'Space Mono', fontSize: 11, fontWeight: 400, color: '#8A7E6D', textAlign: 'left', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, ] }, { name: 'Carte de vœux', format: 'greeting-card', bg: '#1B4965', preview: { bg: '#1B4965', html: '
Joyeux
Noël ✦
' }, elements: [ { type: 'text', text: '✦ ✦ ✦', x: 425, y: 80, width: 200, height: 40, fontFamily: 'Fraunces', fontSize: 24, fontWeight: 400, color: '#B8923D', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Joyeux', x: 75, y: 180, width: 900, height: 140, fontFamily: 'Caveat', fontSize: 120, fontWeight: 700, color: '#FFE5D9', textAlign: 'center', fontStyle: 'normal', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'Noël', x: 75, y: 320, width: 900, height: 160, fontFamily: 'Fraunces', fontSize: 140, fontWeight: 700, color: '#FFB997', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 1 }, { type: 'text', text: 'et meilleurs vœux pour la nouvelle année', x: 75, y: 510, width: 900, height: 50, fontFamily: 'Fraunces', fontSize: 22, fontWeight: 400, color: '#FFE5D9', textAlign: 'center', fontStyle: 'italic', textDecoration: 'none', rotation: 0, opacity: 0.9 }, ] }, ]; // ───────────────────────────────────────────────────────────── // CANVAS RENDERING // ───────────────────────────────────────────────────────────── function renderCanvas() { const stack = document.getElementById('canvasStack'); stack.innerHTML = ''; for (const page of state.pages) { const wrap = document.createElement('div'); wrap.className = 'page-wrap'; const label = document.createElement('div'); label.className = 'page-label'; label.textContent = `${page.width} × ${page.height} px`; wrap.appendChild(label); const pageEl = document.createElement('div'); pageEl.className = 'page' + (page.id === state.currentPageId ? ' active' : ''); pageEl.style.width = (page.width * state.zoom) + 'px'; pageEl.style.height = (page.height * state.zoom) + 'px'; pageEl.style.background = page.bg; pageEl.dataset.pageId = page.id; for (const el of page.elements) { const node = renderElement(el, page); pageEl.appendChild(node); } pageEl.addEventListener('mousedown', (e) => { if (e.target === pageEl) { state.currentPageId = page.id; if (!editingText) state.selectedId = null; render(); } }); wrap.appendChild(pageEl); stack.appendChild(wrap); } } function buildFilterCSS(el) { const eff = el.effects || defaultEffects(); const parts = []; if (eff.shadow && eff.shadow.enabled) { const s = eff.shadow; const z = state.zoom; parts.push(`drop-shadow(${s.x*z}px ${s.y*z}px ${s.blur*z}px ${s.color})`); } if (eff.blur > 0) parts.push(`blur(${eff.blur * state.zoom}px)`); if (eff.filter && eff.filter !== 'aucun' && FILTERS[eff.filter]) parts.push(FILTERS[eff.filter]); return parts.join(' '); } function renderElement(el, page) { const node = document.createElement('div'); node.className = 'el' + (el.id === state.selectedId ? ' selected' : ''); node.dataset.id = el.id; const z = state.zoom; node.style.left = (el.x * z) + 'px'; node.style.top = (el.y * z) + 'px'; node.style.width = (el.width * z) + 'px'; node.style.height = (el.height * z) + 'px'; node.style.transform = `rotate(${el.rotation || 0}deg)`; node.style.opacity = el.opacity ?? 1; const filterCSS = buildFilterCSS(el); if (filterCSS) node.style.filter = filterCSS; if (el.type === 'text') { node.classList.add('el-text'); node.style.fontFamily = `'${el.fontFamily}', sans-serif`; node.style.fontSize = (el.fontSize * z) + 'px'; node.style.fontWeight = el.fontWeight; node.style.fontStyle = el.fontStyle; node.style.textDecoration = el.textDecoration; node.style.color = el.color; node.style.textAlign = el.textAlign; node.style.whiteSpace = 'pre-wrap'; node.textContent = el.text; node.addEventListener('dblclick', (e) => { e.stopPropagation(); editingText = true; node.contentEditable = 'true'; node.focus(); const range = document.createRange(); range.selectNodeContents(node); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(range); }); node.addEventListener('blur', () => { if (node.contentEditable === 'true') { node.contentEditable = 'false'; editingText = false; snapshot(); el.text = node.textContent; render(); } }); } else if (el.type === 'shape') { node.classList.add('el-shape'); node.innerHTML = renderShapeSVG(el); } else if (el.type === 'icon') { node.classList.add('el-icon'); const iconColor = safeCssColor(el.color); const iconSw = Math.max(0.1, Math.min(20, Number(el.strokeWidth) || 1.5)); // el.svg vient de ICONS internes (constant), pas de user input node.innerHTML = `${el.svg || ''}`; } else if (el.type === 'image') { node.classList.add('el-image'); node.style.borderRadius = ((el.borderRadius || 0) * z) + 'px'; node.style.overflow = 'hidden'; const img = document.createElement('img'); img.src = el.src; img.crossOrigin = 'anonymous'; node.appendChild(img); } node.addEventListener('mousedown', (e) => { if (node.contentEditable === 'true') return; e.stopPropagation(); state.currentPageId = page.id; if (e.shiftKey) { // toggle if (state.selectedIds.includes(el.id)) { state.selectedIds = state.selectedIds.filter(id => id !== el.id); } else { state.selectedIds = [...state.selectedIds, ...expandGroupSelection(el.id)]; } } else if (state.selectedIds.includes(el.id) && state.selectedIds.length > 1) { // already part of multi-sel, keep } else { state.selectedIds = expandGroupSelection(el.id); } render(); startDrag(e, el, page); }); const isSel = state.selectedIds.includes(el.id); if (isSel) node.classList.add('selected'); // handles seulement si UN seul élément sélectionné (sinon bbox de groupe) if (isSel && state.selectedIds.length === 1) { ['tl','tr','bl','br'].forEach(pos => { const h = document.createElement('div'); h.className = 'handle ' + pos; h.addEventListener('mousedown', (e) => { e.stopPropagation(); startResize(e, el, pos); }); node.appendChild(h); }); const rot = document.createElement('div'); rot.className = 'handle rot'; rot.addEventListener('mousedown', (e) => { e.stopPropagation(); startRotate(e, el, node); }); node.appendChild(rot); } return node; } function renderShapeSVG(el) { const w = el.width, h = el.height; let fill = el.fill; let gradDef = ''; if (el.fillType === 'gradient' && el.gradient) { const gid = 'grad_' + el.id; const stops = el.gradient.colors.map((c, i) => ``).join(''); const angle = el.gradient.angle || 135; const rad = (angle - 90) * Math.PI / 180; const x1 = 50 + Math.cos(rad + Math.PI) * 50; const y1 = 50 + Math.sin(rad + Math.PI) * 50; const x2 = 50 + Math.cos(rad) * 50; const y2 = 50 + Math.sin(rad) * 50; gradDef = `${stops}`; fill = `url(#${gid})`; } const stroke = el.stroke && el.stroke !== 'none' ? el.stroke : 'none'; const sw = el.strokeWidth || 0; const dash = el.dashed ? `stroke-dasharray="${sw*2},${sw}"` : ''; let path = ''; if (el.shape === 'rect') path = ``; else if (el.shape === 'circle') path = ``; else if (el.shape === 'triangle') path = ``; else if (el.shape === 'star') { const cx = w/2, cy = h/2, r1 = Math.min(w,h)/2 - sw/2, r2 = r1 * 0.45; let pts = []; for (let i = 0; i < 10; i++) { const a = (Math.PI/5) * i - Math.PI/2; const r = i % 2 === 0 ? r1 : r2; pts.push(`${cx + r*Math.cos(a)},${cy + r*Math.sin(a)}`); } path = ``; } else if (el.shape === 'diamond') path = ``; else if (el.shape === 'hexagon' || el.shape === 'pentagon') { const sides = el.shape === 'hexagon' ? 6 : 5; const cx = w/2, cy = h/2, r = Math.min(w,h)/2 - sw/2; let pts = []; for (let i = 0; i < sides; i++) { const a = (Math.PI*2/sides)*i - Math.PI/2; pts.push(`${cx + r*Math.cos(a)},${cy + r*Math.sin(a)}`); } path = ``; } else if (el.shape === 'heart') { path = ``; } else if (el.shape === 'arrow') { path = ``; } else if (el.shape === 'speech') { path = ``; } else if (el.shape === 'cross') { path = ``; } else if (el.shape === 'line') { path = ``; } return `${gradDef}${path}`; } // ───────────────────────────────────────────────────────────── // DRAG / RESIZE / ROTATE // ───────────────────────────────────────────────────────────── function startDrag(e, el, page) { const startX = e.clientX, startY = e.clientY; const z = state.zoom; // capture positions initiales de tous les sélectionnés const targets = selectedEls().length > 1 ? selectedEls() : [el]; const starts = targets.map(t => ({ el: t, x: t.x, y: t.y })); let moved = false; function move(ev) { const dx = (ev.clientX - startX) / z; const dy = (ev.clientY - startY) / z; if (!moved && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) { snapshot(); moved = true; } for (const s of starts) { s.el.x = s.x + dx; s.el.y = s.y + dy; } renderCanvas(); renderProperties(); } function up() { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); } document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); } function startResize(e, el, corner) { snapshot(); const startX = e.clientX, startY = e.clientY; const elX = el.x, elY = el.y, elW = el.width, elH = el.height; const z = state.zoom; const aspect = elW / elH; function move(ev) { let dx = (ev.clientX - startX) / z; let dy = (ev.clientY - startY) / z; let nw = elW, nh = elH, nx = elX, ny = elY; if (corner === 'br') { nw = Math.max(20, elW + dx); nh = Math.max(20, elH + dy); } else if (corner === 'bl') { nw = Math.max(20, elW - dx); nx = elX + (elW - nw); nh = Math.max(20, elH + dy); } else if (corner === 'tr') { nw = Math.max(20, elW + dx); nh = Math.max(20, elH - dy); ny = elY + (elH - nh); } else if (corner === 'tl') { nw = Math.max(20, elW - dx); nx = elX + (elW - nw); nh = Math.max(20, elH - dy); ny = elY + (elH - nh); } if (ev.shiftKey) { nh = nw / aspect; } el.width = nw; el.height = nh; el.x = nx; el.y = ny; if (el.type === 'text') el.fontSize = Math.max(8, el.fontSize * (nw / elW)); renderCanvas(); renderProperties(); } function up() { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); } document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); } function startRotate(e, el, node) { snapshot(); const rect = node.getBoundingClientRect(); const cx = rect.left + rect.width/2; const cy = rect.top + rect.height/2; const startA = Math.atan2(e.clientY - cy, e.clientX - cx); const elR = el.rotation || 0; function move(ev) { const a = Math.atan2(ev.clientY - cy, ev.clientX - cx); let deg = (a - startA) * 180 / Math.PI + elR; if (ev.shiftKey) deg = Math.round(deg / 15) * 15; el.rotation = deg; renderCanvas(); renderProperties(); } function up() { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); } document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); } // ───────────────────────────────────────────────────────────── // PROPERTIES PANEL // ───────────────────────────────────────────────────────────── function renderProperties() { const main = document.getElementById('main'); const props = document.getElementById('properties'); const sels = selectedEls(); if (sels.length === 0) { main.classList.add('no-properties'); return; } main.classList.remove('no-properties'); // ─── Panneau multi-sélection / groupe ─── if (sels.length > 1) { const gid = groupOf(sels[0].id); const allSameGroup = gid && sels.every(s => groupOf(s.id) === gid) && state.groups[gid].children.length === sels.length; const groupName = allSameGroup ? state.groups[gid].name : ''; props.innerHTML = `
${allSameGroup ? 'Groupe' : 'Sélection multiple'} (${sels.length} éléments)
${allSameGroup ? `` : ''}
${gid ? `` : ''}
Alignement
`; const gni = document.getElementById('groupNameInput'); if (gni) gni.addEventListener('change', e => renameSelectedGroup(e.target.value)); document.getElementById('grpGroup')?.addEventListener('click', groupSelected); document.getElementById('grpUngroup')?.addEventListener('click', ungroupSelected); document.getElementById('grpDup')?.addEventListener('click', duplicateSelected); document.getElementById('grpDel')?.addEventListener('click', deleteSelected); document.querySelectorAll('[data-align-multi]').forEach(b => b.addEventListener('click', () => alignMultiple(b.dataset.alignMulti))); return; } const el = sels[0]; let html = `
Position & taille
X
Y
L
H
Rotation ${Math.round(el.rotation || 0)}°
Opacité ${Math.round((el.opacity ?? 1)*100)}%
`; if (el.type === 'text') { const fonts = ['Fraunces','Playfair Display','Abril Fatface','Lora','Bricolage Grotesque','DM Sans','Inter Tight','Caveat','Dancing Script','Pacifico','Bebas Neue','Space Mono']; html += `
Police
Taille ${Math.round(el.fontSize)}px
Style
Alignement
Couleur
`; } else if (el.type === 'shape') { const fillVal = (el.fillType === 'gradient') ? '#888888' : el.fill; html += `
Remplissage ${el.fillType === 'gradient' ? '(dégradé)' : ''}
${el.fillType === 'gradient' ? '' : ''}
${el.shape !== 'line' ? `
Contour ${el.strokeWidth || 0}px
` : ''} ${el.shape === 'rect' ? `
Arrondi ${el.borderRadius || 0}px
` : ''} `; } else if (el.type === 'icon') { html += `
Couleur
Épaisseur ${el.strokeWidth}
`; } else if (el.type === 'image') { html += `
Arrondi ${el.borderRadius || 0}px
`; } html += `
Calque
`; props.innerHTML = html; attachPropsEvents(el); } function attachPropsEvents(el) { const $ = id => document.getElementById(id); const update = (key, val) => { el[key] = val; renderCanvas(); }; $('propX')?.addEventListener('change', e => { snapshot(); update('x', +e.target.value); }); $('propY')?.addEventListener('change', e => { snapshot(); update('y', +e.target.value); }); $('propW')?.addEventListener('change', e => { snapshot(); update('width', +e.target.value); }); $('propH')?.addEventListener('change', e => { snapshot(); update('height', +e.target.value); }); $('propRot')?.addEventListener('input', e => update('rotation', +e.target.value)); $('propRot')?.addEventListener('change', () => { snapshot(); renderProperties(); }); $('propOpacity')?.addEventListener('input', e => update('opacity', e.target.value/100)); $('propOpacity')?.addEventListener('change', () => { snapshot(); renderProperties(); }); if (el.type === 'text') { $('propFont')?.addEventListener('change', e => { snapshot(); update('fontFamily', e.target.value); renderProperties(); }); $('propFontSize')?.addEventListener('input', e => { update('fontSize', +e.target.value); renderProperties(); }); $('propFontSize')?.addEventListener('change', () => snapshot()); $('propBold')?.addEventListener('click', () => { snapshot(); update('fontWeight', el.fontWeight >= 600 ? 400 : 700); renderProperties(); }); $('propItalic')?.addEventListener('click', () => { snapshot(); update('fontStyle', el.fontStyle === 'italic' ? 'normal' : 'italic'); renderProperties(); }); $('propUnderline')?.addEventListener('click', () => { snapshot(); update('textDecoration', el.textDecoration === 'underline' ? 'none' : 'underline'); renderProperties(); }); $('propUpper')?.addEventListener('click', () => { snapshot(); update('text', el.text.toUpperCase()); renderProperties(); }); document.querySelectorAll('[data-align]').forEach(b => b.addEventListener('click', () => { snapshot(); update('textAlign', b.dataset.align); renderProperties(); })); $('propColor')?.addEventListener('input', e => { update('color', e.target.value); $('propColorText').value = e.target.value.toUpperCase(); }); $('propColor')?.addEventListener('change', () => snapshot()); $('propColorText')?.addEventListener('change', e => { snapshot(); update('color', e.target.value); if($('propColor')) $('propColor').value = e.target.value; }); } else if (el.type === 'shape') { $('propFill')?.addEventListener('input', e => { el.fillType = 'solid'; el.fill = e.target.value; renderCanvas(); renderProperties(); }); $('propFill')?.addEventListener('change', () => snapshot()); $('clearGrad')?.addEventListener('click', () => { snapshot(); el.fillType = 'solid'; el.fill = '#C8442C'; render(); }); $('propStrokeW')?.addEventListener('input', e => { el.strokeWidth = +e.target.value; if (!el.stroke || el.stroke === 'none') el.stroke = '#1A1612'; renderCanvas(); renderProperties(); }); $('propStrokeW')?.addEventListener('change', () => snapshot()); $('propStroke')?.addEventListener('input', e => { update('stroke', e.target.value); $('propStrokeText').value = e.target.value.toUpperCase(); }); $('propStroke')?.addEventListener('change', () => snapshot()); $('propRadius')?.addEventListener('input', e => { update('borderRadius', +e.target.value); renderProperties(); }); $('propRadius')?.addEventListener('change', () => snapshot()); } else if (el.type === 'icon') { $('propColor')?.addEventListener('input', e => { update('color', e.target.value); $('propColorText').value = e.target.value.toUpperCase(); }); $('propColor')?.addEventListener('change', () => snapshot()); $('propIconStroke')?.addEventListener('input', e => { update('strokeWidth', +e.target.value); renderProperties(); }); $('propIconStroke')?.addEventListener('change', () => snapshot()); } else if (el.type === 'image') { $('propRadius')?.addEventListener('input', e => { update('borderRadius', +e.target.value); renderProperties(); }); $('propRadius')?.addEventListener('change', () => snapshot()); } $('propBringFront')?.addEventListener('click', bringFront); $('propSendBack')?.addEventListener('click', sendBack); $('propDelete')?.addEventListener('click', deleteSelected); } function bringFront() { const el = selectedEl(); if (!el) return; snapshot(); const page = currentPage(); const i = page.elements.findIndex(e => e.id === el.id); if (i < page.elements.length - 1) { page.elements.splice(i, 1); page.elements.push(el); render(); } } function sendBack() { const el = selectedEl(); if (!el) return; snapshot(); const page = currentPage(); const i = page.elements.findIndex(e => e.id === el.id); if (i > 0) { page.elements.splice(i, 1); page.elements.unshift(el); render(); } } function deleteSelected() { if (state.selectedIds.length === 0) return; snapshot(); const ids = new Set(state.selectedIds); currentPage().elements = currentPage().elements.filter(e => !ids.has(e.id)); // nettoyer groupes for (const gid in state.groups) { state.groups[gid].children = state.groups[gid].children.filter(id => !ids.has(id)); if (state.groups[gid].children.length < 2) delete state.groups[gid]; } state.selectedIds = []; render(); } function duplicateSelected() { const els = selectedEls(); if (els.length === 0) return; snapshot(); const newIds = []; const idMap = {}; for (const el of els) { const copy = JSON.parse(JSON.stringify(el)); copy.id = uid(); copy.x += 20; copy.y += 20; idMap[el.id] = copy.id; currentPage().elements.push(copy); newIds.push(copy.id); } // dupliquer aussi les groupes qui contiennent tous les éléments copiés const oldIds = new Set(els.map(e => e.id)); for (const gid in state.groups) { const g = state.groups[gid]; if (g.children.every(id => oldIds.has(id))) { const newGid = 'grp_' + uid(); state.groups[newGid] = { name: g.name + ' (copie)', children: g.children.map(id => idMap[id]) }; } } state.selectedIds = newIds; render(); } function groupSelected() { if (state.selectedIds.length < 2) { toast('Sélectionnez au moins 2 éléments'); return; } // si tous déjà dans un même groupe, rien à faire const gids = new Set(state.selectedIds.map(groupOf).filter(Boolean)); if (gids.size === 1 && state.groups[[...gids][0]].children.length === state.selectedIds.length) { toast('Déjà groupés'); return; } snapshot(); // retirer les éléments de leurs anciens groupes for (const id of state.selectedIds) { const og = groupOf(id); if (og) { state.groups[og].children = state.groups[og].children.filter(x => x !== id); if (state.groups[og].children.length < 2) delete state.groups[og]; } } const newGid = 'grp_' + uid(); state.groups[newGid] = { name: 'Groupe ' + (Object.keys(state.groups).length + 1), children: state.selectedIds.slice() }; toast('Éléments groupés ✓'); render(); } function ungroupSelected() { const gids = new Set(state.selectedIds.map(groupOf).filter(Boolean)); if (gids.size === 0) { toast('Aucun groupe sélectionné'); return; } snapshot(); for (const gid of gids) delete state.groups[gid]; toast('Groupe(s) dissous ✓'); render(); } function renameSelectedGroup(newName) { if (state.selectedIds.length < 1) return; const gid = groupOf(state.selectedIds[0]); if (!gid) return; snapshot(); state.groups[gid].name = String(newName || '').slice(0, 60); render(); } function alignMultiple(mode) { const els = selectedEls(); if (els.length < 2) return; snapshot(); if (mode === 'left') { const x = Math.min(...els.map(e => e.x)); els.forEach(e => e.x = x); } else if (mode === 'right') { const x = Math.max(...els.map(e => e.x + e.width)); els.forEach(e => e.x = x - e.width); } else if (mode === 'center-h') { const cx = els.reduce((s, e) => s + (e.x + e.width/2), 0) / els.length; els.forEach(e => e.x = cx - e.width/2); } else if (mode === 'top') { const y = Math.min(...els.map(e => e.y)); els.forEach(e => e.y = y); } else if (mode === 'bottom') { const y = Math.max(...els.map(e => e.y + e.height)); els.forEach(e => e.y = y - e.height); } else if (mode === 'center-v') { const cy = els.reduce((s, e) => s + (e.y + e.height/2), 0) / els.length; els.forEach(e => e.y = cy - e.height/2); } render(); } // ───────────────────────────────────────────────────────────── // PAGES BAR // ───────────────────────────────────────────────────────────── function renderPagesBar() { const bar = document.getElementById('pagesBar'); bar.innerHTML = ''; state.pages.forEach((p, i) => { const tab = document.createElement('div'); tab.className = 'page-tab' + (p.id === state.currentPageId ? ' active' : ''); tab.innerHTML = `
Page ${i+1}${state.pages.length > 1 ? `` : ''}`; tab.addEventListener('click', (e) => { if (e.target.dataset.del) return; state.currentPageId = p.id; state.selectedId = null; render(); }); bar.appendChild(tab); }); bar.querySelectorAll('[data-del]').forEach(b => { b.addEventListener('click', (e) => { e.stopPropagation(); if (state.pages.length === 1) return; snapshot(); state.pages = state.pages.filter(p => p.id !== b.dataset.del); if (state.currentPageId === b.dataset.del) state.currentPageId = state.pages[0].id; render(); }); }); const addBtn = document.createElement('button'); addBtn.className = 'add-page-btn'; addBtn.innerHTML = '+ Ajouter une page'; addBtn.addEventListener('click', () => { snapshot(); const p = currentPage(); const newPage = { id: uid(), width: p.width, height: p.height, bg: p.bg, elements: [] }; state.pages.push(newPage); state.currentPageId = newPage.id; render(); }); bar.appendChild(addBtn); } // ───────────────────────────────────────────────────────────── // COMMUNITY (storage shared) // ───────────────────────────────────────────────────────────── async function loadCommunityFeed() { try { const list = await window.storage.list('design:', true); const keys = list && list.keys ? list.keys : []; const designs = []; for (const k of keys.slice(0, 40)) { try { const r = await window.storage.get(k, true); if (r && r.value) designs.push(JSON.parse(r.value)); } catch(e) {} } if (designs.length === 0) { await seedCommunityIfEmpty(); return loadCommunityFeed(); } communityFeed = designs; renderCommunityFeed('recent'); } catch(e) { console.error(e); const feed = document.getElementById('communityFeed'); if (feed) feed.innerHTML = '
La communauté n\'est pas disponible dans cet environnement.
'; } } async function seedCommunityIfEmpty() { const seeds = [ { title: 'Affiche Été 2026', author: 'Camille B.', design: TEMPLATES[0], likes: 47 }, { title: 'Carte de visite minimaliste', author: 'Studio Bleu', design: TEMPLATES[2], likes: 23 }, { title: 'Jazz Festival', author: 'Léo D.', design: TEMPLATES[4], likes: 89 }, { title: 'Faire-part bohème', author: 'Marine T.', design: TEMPLATES[3], likes: 156 }, { title: 'Recette croissants', author: 'Chef Henri', design: TEMPLATES[5], likes: 312 }, { title: 'Vœux d\'hiver', author: 'Alice V.', design: TEMPLATES[7], likes: 78 }, ]; for (const s of seeds) { const id = uid(); const design = { id, title: s.title, author: s.author, format: s.design.format, bg: s.design.bg, elements: s.design.elements, preview: s.design.preview, likes: s.likes, createdAt: Date.now() - Math.random()*1000*60*60*24*14, }; try { await window.storage.set('design:' + id, JSON.stringify(design), true); } catch(e) { console.error(e); } } } function renderCommunityFeed(sort = 'recent') { const feed = document.getElementById('communityFeed'); if (!feed) return; let designs = [...communityFeed]; if (sort === 'popular') designs.sort((a,b) => (b.likes||0) - (a.likes||0)); else designs.sort((a,b) => (b.createdAt||0) - (a.createdAt||0)); if (designs.length === 0) { feed.innerHTML = '
Aucun design publié pour le moment. Soyez le premier !
'; return; } feed.innerHTML = designs.map(d => { const liked = state.likedIds.includes(d.id); const safeAuthor = escapeAttr(String(d.author || '?').slice(0, 30)); const safeTitle = escapeAttr(String(d.title || 'Sans titre').slice(0, 60)); const safeId = escapeAttr(String(d.id || '').slice(0, 32)); const safeBg = safeCssColor(d.bg); const initial = safeAuthor.charAt(0).toUpperCase(); // preview.html n'est PAS rendu brut — on le reconstruit côté lecteur depuis les données structurées const safePreview = renderSafePreview(d); return `
${safePreview}
${initial}
${safeTitle}
par ${safeAuthor}
`; }).join(''); feed.querySelectorAll('[data-like]').forEach(b => { b.addEventListener('click', e => { e.stopPropagation(); toggleLike(b.dataset.like); }); }); feed.querySelectorAll('[data-remix]').forEach(b => { b.addEventListener('click', () => remixDesign(b.dataset.remix)); }); } async function toggleLike(designId) { const liked = state.likedIds.includes(designId); try { const r = await window.storage.get('design:' + designId, true); if (!r || !r.value) return; const d = JSON.parse(r.value); d.likes = (d.likes || 0) + (liked ? -1 : 1); if (d.likes < 0) d.likes = 0; await window.storage.set('design:' + designId, JSON.stringify(d), true); if (liked) state.likedIds = state.likedIds.filter(id => id !== designId); else state.likedIds.push(designId); await saveProfile(); const local = communityFeed.find(x => x.id === designId); if (local) local.likes = d.likes; renderCommunityFeed(document.querySelector('[data-sort].active')?.dataset.sort || 'recent'); } catch(e) { console.error(e); } } function remixDesign(designId) { const d = communityFeed.find(x => x.id === designId); if (!d) return; snapshot(); state.format = d.format; const fmt = FORMATS[d.format] || { w: 1080, h: 1080 }; const newPage = { id: uid(), width: fmt.w, height: fmt.h, bg: d.bg, elements: d.elements.map(e => ({ ...e, id: uid(), text: e.text ? e.text.replace(/\\n/g, '\n') : e.text, effects: e.effects || defaultEffects(), })) }; state.pages = [newPage]; state.currentPageId = newPage.id; state.selectedId = null; state.zoom = computeFitZoom(); currentTab = 'modeles'; document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active')); document.querySelector('[data-tab="modeles"]').classList.add('active'); state.docName = 'Remix de ' + d.title; document.getElementById('docName').value = state.docName; renderPanel(); render(); toast(`Remix de "${d.title}" — à vous de jouer !`); } async function publishDesign() { if (state.user.name === 'Anonyme') { await promptUserName(); if (state.user.name === 'Anonyme') return; } const res = await modal( 'Publier dans la communauté', `

Donnez un titre à votre création.

Votre design sera visible et remixable par tous les utilisateurs de Toile.

`, [{ label: 'Annuler', primary: false }, { label: 'Publier', primary: true }] ); if (!res || res.index !== 1) return; const title = String(res.value || state.docName).slice(0, 60); const page = currentPage(); if (!page) return; const design = { id: uid(), title, author: String(state.user.name || 'Anonyme').slice(0, 30), format: state.format, bg: page.bg, docWidth: page.width, docHeight: page.height, // On ne stocke QUE les types/champs nécessaires au preview (white-list) elements: page.elements.map(el => ({ type: el.type, x: Number(el.x), y: Number(el.y), width: Number(el.width), height: Number(el.height), text: el.type === 'text' ? String(el.text || '').slice(0, 200) : undefined, fontFamily: el.fontFamily, fontSize: Number(el.fontSize), fontWeight: el.fontWeight, fontStyle: el.fontStyle, textAlign: el.textAlign, color: el.color, fill: el.fill, borderRadius: Number(el.borderRadius) || 0, })), likes: 0, createdAt: Date.now(), }; try { await window.storage.set('design:' + design.id, JSON.stringify(design), true); toast('✓ Design publié dans la communauté !'); if (currentTab === 'communaute') loadCommunityFeed(); } catch(e) { console.error(e); toast('Erreur lors de la publication'); } } function generatePreviewHtml(page) { // Generate a simplified inline preview for the feed card const scale = 200 / Math.max(page.width, page.height); const items = page.elements.slice(0, 8).map(el => { if (el.type === 'text') { return `
${el.text.slice(0,40)}
`; } return ''; }).join(''); return { bg: page.bg, html: `
${items}
` }; } async function promptUserName() { openAuthModal(); } function openAuthModal() { // ferme si déjà ouverte const existing = document.querySelector('.auth-modal-backdrop'); if (existing) { existing.remove(); return; } const isAuthed = state.authProvider && state.user.name !== 'Anonyme'; const html = `

${isAuthed ? 'Mon compte' : 'Se connecter à Toile'}

${isAuthed ? `Connecté en tant que ${escapeAttr(state.user.name)} via ${state.authProvider}` : 'Vos designs restent chez vous. Aucune donnée n\'est stockée sur nos serveurs.'}

${isAuthed ? `
— ou —
` : `
— ou —

En continuant, vous acceptez que Toile communique avec le fournisseur choisi pour vous authentifier. Vos designs ne sont jamais transmis à Toile et restent stockés sur votre appareil ou votre Drive personnel.

`}
`; document.body.insertAdjacentHTML('beforeend', html); document.getElementById('authClose').addEventListener('click', () => document.getElementById('authBackdrop').remove()); document.getElementById('authBackdrop').addEventListener('click', (e) => { if (e.target.id === 'authBackdrop') e.currentTarget.remove(); }); document.querySelectorAll('[data-auth]').forEach(b => b.addEventListener('click', () => handleAuth(b.dataset.auth))); document.getElementById('syncDriveBtn')?.addEventListener('click', () => connectStorage('gdrive')); document.getElementById('syncOneDriveBtn')?.addEventListener('click', () => connectStorage('onedrive')); document.getElementById('changeNameBtn')?.addEventListener('click', async () => { document.getElementById('authBackdrop').remove(); await promptUserNameInner(); }); document.getElementById('logoutBtn')?.addEventListener('click', logout); } async function handleAuth(provider) { document.getElementById('authBackdrop')?.remove(); if (provider === 'anonymous') { await promptUserNameInner(); return; } if (provider === 'email') { const res = await modal('Lien magique', `

Saisissez votre email. Vous recevrez un lien pour vous connecter sans mot de passe.

`, [{ label: 'Annuler' }, { label: 'Envoyer', primary: true }]); if (!res || res.index !== 1) return; const email = (res.value || '').trim(); if (!email.includes('@')) { toast('Email invalide'); return; } toast('Email envoyé (mode démo — back-end à configurer)'); state.user.name = email.split('@')[0]; state.authProvider = 'email'; await saveProfile(); updateUserChip(); return; } // OAuth providers — en démo, on simule. Voir TOILE-ARCHITECTURE.md §4 pour le back-end. const providerNames = { google: 'Google', microsoft: 'Microsoft', apple: 'Apple', franceconnect: 'FranceConnect' }; // Mode démo : appelle window.toileAuth.start(provider) si défini par le loader embed, sinon stub if (window.toileAuth && typeof window.toileAuth.start === 'function') { try { const profile = await window.toileAuth.start(provider); state.user.name = profile.name || providerNames[provider]; state.user.email = profile.email; state.authProvider = provider; await saveProfile(); updateUserChip(); toast(`Connecté via ${providerNames[provider]} ✓`); } catch (err) { toast('Connexion annulée'); } } else { // Stub : simule une connexion locale toast(`Connexion ${providerNames[provider]} (démo — configurez le back-end)`); state.user.name = providerNames[provider] + ' Demo'; state.authProvider = provider; await saveProfile(); updateUserChip(); } } function connectStorage(target) { document.getElementById('authBackdrop')?.remove(); if (window.toileStorage && typeof window.toileStorage.connect === 'function') { window.toileStorage.connect(target).then(() => toast(`${target} connecté ✓`)).catch(() => toast('Connexion impossible')); } else { toast(`Sync ${target} : configurez le back-end (voir doc §3.3)`); } } function logout() { document.getElementById('authBackdrop')?.remove(); state.user = { name: 'Anonyme', id: uid() }; state.authProvider = null; saveProfile(); updateUserChip(); toast('Déconnecté'); } async function promptUserNameInner() { const res = await modal( 'Bienvenue sur Toile', `

Choisissez un pseudo qui s'affichera sur vos publications.

`, [{ label: 'Plus tard', primary: false }, { label: 'Valider', primary: true }] ); if (!res || res.index !== 1) return; const name = (res.value || '').trim(); if (!name) return; state.user.name = name; state.authProvider = state.authProvider || 'anonymous'; await saveProfile(); updateUserChip(); if (currentTab === 'communaute') renderPanel(); toast(`Bienvenue, ${name} !`); } async function saveProfile() { try { await window.storage.set('profile', JSON.stringify({ name: state.user.name, id: state.user.id, likedIds: state.likedIds })); } catch(e) {} } async function loadProfile() { try { const r = await window.storage.get('profile'); if (r && r.value) { const p = JSON.parse(r.value); state.user.name = p.name || 'Anonyme'; state.user.id = p.id || state.user.id; state.likedIds = p.likedIds || []; } } catch(e) {} updateUserChip(); } function updateUserChip() { const av = document.getElementById('userAvatar'); const nm = document.getElementById('userName'); if (av) av.textContent = (state.user.name || '?').charAt(0).toUpperCase(); if (nm) nm.textContent = state.user.name; } // ───────────────────────────────────────────────────────────── // EXPORT — multi-format // ───────────────────────────────────────────────────────────── function sanitizeFilename(s) { return String(s || 'projet').replace(/[^a-z0-9\-_]/gi, '_').slice(0, 60); } async function renderPagesAtFullSize() { const oldSelected = state.selectedId; const oldZoom = state.zoom; state.selectedId = null; state.zoom = 1; renderCanvas(); await new Promise(r => setTimeout(r, 120)); return { oldSelected, oldZoom }; } function restoreAfterExport(ctx) { state.zoom = ctx.oldZoom; state.selectedId = ctx.oldSelected; render(); } async function exportImage(fmt) { toast('Génération en cours…'); const ctx = await renderPagesAtFullSize(); const pages = document.querySelectorAll('.page'); for (let i = 0; i < pages.length; i++) { const pageData = state.pages[i]; try { const canvas = await html2canvas(pages[i], { backgroundColor: pageData.bg.startsWith('linear') ? null : pageData.bg, scale: 2, useCORS: true, logging: false, width: pageData.width, height: pageData.height, }); const link = document.createElement('a'); const ext = fmt === 'jpeg' ? 'jpg' : 'png'; link.download = `${sanitizeFilename(state.docName)}${pages.length > 1 ? '_p' + (i+1) : ''}.${ext}`; link.href = canvas.toDataURL(`image/${fmt}`, fmt === 'jpeg' ? 0.92 : undefined); link.click(); } catch (err) { console.error(err); } } restoreAfterExport(ctx); toast('Téléchargement terminé ✓'); } async function exportPDF() { if (!window.jspdf) { toast('PDF indisponible'); return; } toast('Génération du PDF…'); const ctx = await renderPagesAtFullSize(); const { jsPDF } = window.jspdf; const first = state.pages[0]; const orientation = first.width > first.height ? 'landscape' : 'portrait'; // Format A4 en mm par défaut ; px pour les autres const pdf = new jsPDF({ orientation, unit: 'px', format: [first.width, first.height], hotfixes: ['px_scaling'] }); const pageEls = document.querySelectorAll('.page'); for (let i = 0; i < pageEls.length; i++) { const pd = state.pages[i]; try { const canvas = await html2canvas(pageEls[i], { backgroundColor: pd.bg.startsWith('linear') ? null : pd.bg, scale: 2, useCORS: true, logging: false, width: pd.width, height: pd.height, }); const img = canvas.toDataURL('image/jpeg', 0.92); if (i > 0) pdf.addPage([pd.width, pd.height], pd.width > pd.height ? 'landscape' : 'portrait'); pdf.addImage(img, 'JPEG', 0, 0, pd.width, pd.height); } catch (err) { console.error(err); } } pdf.save(`${sanitizeFilename(state.docName)}.pdf`); restoreAfterExport(ctx); toast('PDF enregistré ✓'); } function exportSVG() { // Génère un SVG simple par page (texte + formes + images). Effets complexes approximés. const p = currentPage(); if (!p) return; const parts = [``]; parts.push(``); for (const el of p.elements) { const t = `transform="translate(${el.x},${el.y}) rotate(${el.rotation||0} ${el.width/2} ${el.height/2})" opacity="${el.opacity??1}"`; if (el.type === 'shape') { if (el.shape === 'rect' || el.shape === 'square') { parts.push(``); } else if (el.shape === 'circle') { parts.push(``); } else { parts.push(``); } } else if (el.type === 'text') { const lines = String(el.text||'').split('\n'); const lh = (el.fontSize || 40) * (el.lineHeight || 1.2); const anchor = el.textAlign === 'center' ? 'middle' : el.textAlign === 'right' ? 'end' : 'start'; const tx = el.textAlign === 'center' ? el.width/2 : el.textAlign === 'right' ? el.width : 0; const tspans = lines.map((ln, i) => `${escapeAttr(ln)}`).join(''); parts.push(`${tspans}`); } else if (el.type === 'image' && el.src) { parts.push(``); } else if (el.type === 'icon') { parts.push(`${el.iconSvg||''}`); } } parts.push(''); const blob = new Blob([parts.join('')], { type: 'image/svg+xml' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${sanitizeFilename(state.docName)}.svg`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); toast('SVG enregistré ✓'); } function exportToileFile() { const payload = { _format: 'toile/v1', _created: new Date().toISOString(), _modified: new Date().toISOString(), doc: { name: state.docName, format: state.format, pages: state.pages, groups: state.groups || {}, } }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${sanitizeFilename(state.docName)}.toile`; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 1000); toast('Fichier .toile enregistré ✓'); } function importToileFile(e) { const file = e.target.files[0]; if (!file) return; if (file.size > 50 * 1024 * 1024) { toast('Fichier trop volumineux'); return; } const reader = new FileReader(); reader.onload = (ev) => { try { const data = JSON.parse(ev.target.result); if (!data || !data.doc || !Array.isArray(data.doc.pages)) throw new Error('Format invalide'); // Validation basique for (const p of data.doc.pages) { if (typeof p.width !== 'number' || typeof p.height !== 'number') throw new Error('Page invalide'); if (!Array.isArray(p.elements)) throw new Error('Éléments invalides'); } snapshot(); state.docName = String(data.doc.name || 'Projet').slice(0, 100); state.format = data.doc.format || 'custom'; state.pages = data.doc.pages; state.currentPageId = data.doc.pages[0].id; state.groups = data.doc.groups || {}; state.selectedId = null; document.getElementById('docName').value = state.docName; state.zoom = computeFitZoom(); render(); toast('Fichier ouvert ✓'); } catch (err) { toast('Fichier invalide'); console.error(err); } }; reader.onerror = () => toast('Erreur de lecture'); reader.readAsText(file); e.target.value = ''; // reset } async function exportPNG() { return exportImage('png'); } // ───────────────────────────────────────────────────────────── // MAIN RENDER & EVENTS // ───────────────────────────────────────────────────────────── function render() { renderCanvas(); renderPagesBar(); renderProperties(); document.getElementById('zoomDisplay').textContent = Math.round(state.zoom * 100) + '%'; document.getElementById('undoBtn').disabled = history.length === 0; document.getElementById('redoBtn').disabled = future.length === 0; } function computeFitZoom() { const scroll = document.getElementById('canvasScroll'); const page = currentPage(); if (!scroll || !page) return 0.4; const availW = scroll.clientWidth - 80; const availH = scroll.clientHeight - 120; return Math.min(availW / page.width, availH / page.height, 1); } document.querySelectorAll('.tab-btn').forEach(b => { b.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active')); b.classList.add('active'); currentTab = b.dataset.tab; currentSubtab = null; renderPanel(); }); }); document.getElementById('undoBtn').addEventListener('click', undo); document.getElementById('redoBtn').addEventListener('click', redo); // ─── Menu de téléchargement multi-format ─── const dlBtn = document.getElementById('downloadBtn'); const dlMenu = document.getElementById('downloadMenu'); dlBtn.addEventListener('click', (e) => { e.stopPropagation(); dlMenu.style.display = dlMenu.style.display === 'block' ? 'none' : 'block'; }); document.addEventListener('click', (e) => { if (!e.target.closest('#downloadMenu') && !e.target.closest('#downloadBtn')) { dlMenu.style.display = 'none'; } }); document.querySelectorAll('.dl-opt').forEach(b => { b.addEventListener('mouseenter', () => b.style.background = 'var(--cream)'); b.addEventListener('mouseleave', () => b.style.background = ''); b.addEventListener('click', () => { dlMenu.style.display = 'none'; const f = b.dataset.dl; if (f === 'png') exportImage('png'); else if (f === 'jpg') exportImage('jpeg'); else if (f === 'pdf') exportPDF(); else if (f === 'svg') exportSVG(); else if (f === 'toile') exportToileFile(); }); }); // ─── Save / Open .toile ─── document.getElementById('saveFileBtn').addEventListener('click', exportToileFile); document.getElementById('openFileBtn').addEventListener('click', () => document.getElementById('fileOpenInput').click()); document.getElementById('fileOpenInput').addEventListener('change', importToileFile); document.getElementById('publishBtn').addEventListener('click', publishDesign); document.getElementById('userChip').addEventListener('click', promptUserName); document.getElementById('zoomIn').addEventListener('click', () => { state.zoom = Math.min(2, state.zoom * 1.2); render(); }); document.getElementById('zoomOut').addEventListener('click', () => { state.zoom = Math.max(0.05, state.zoom / 1.2); render(); }); document.getElementById('docName').addEventListener('input', (e) => { state.docName = e.target.value; }); document.getElementById('dupBtn').addEventListener('click', duplicateSelected); document.getElementById('frontBtn').addEventListener('click', bringFront); document.getElementById('backBtn').addEventListener('click', sendBack); document.getElementById('deleteBtn').addEventListener('click', deleteSelected); document.getElementById('alignLeft').addEventListener('click', () => { const el = selectedEl(); if (!el) return; snapshot(); el.x = 0; render(); }); document.getElementById('alignCenter').addEventListener('click', () => { const el = selectedEl(); if (!el) return; snapshot(); el.x = currentPage().width/2 - el.width/2; render(); }); document.getElementById('alignRight').addEventListener('click', () => { const el = selectedEl(); if (!el) return; snapshot(); el.x = currentPage().width - el.width; render(); }); document.addEventListener('keydown', (e) => { if (editingText) return; if (['INPUT','SELECT','TEXTAREA'].includes(e.target.tagName)) return; if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.shiftKey && e.key === 'z'))) { e.preventDefault(); redo(); } else if ((e.ctrlKey || e.metaKey) && e.key === 'd') { e.preventDefault(); duplicateSelected(); } else if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); exportToileFile(); } else if ((e.ctrlKey || e.metaKey) && e.key === 'o') { e.preventDefault(); document.getElementById('fileOpenInput').click(); } else if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'g' || e.key === 'G')) { e.preventDefault(); ungroupSelected(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'g' || e.key === 'G')) { e.preventDefault(); groupSelected(); } else if ((e.ctrlKey || e.metaKey) && (e.key === 'a' || e.key === 'A')) { e.preventDefault(); const p = currentPage(); if (p) { state.selectedIds = p.elements.map(el => el.id); render(); } } else if (e.key === 'Delete' || e.key === 'Backspace') { if (state.selectedId) { e.preventDefault(); deleteSelected(); } } else if (e.key === 'Escape') { state.selectedId = null; render(); } }); window.addEventListener('resize', () => render()); // ───────────────────────────────────────────────────────────── // INIT // ───────────────────────────────────────────────────────────── async function init() { await loadProfile(); const t = TEMPLATES[0]; state.format = t.format; const fmt = FORMATS[t.format]; state.pages = [{ id: uid(), width: fmt.w, height: fmt.h, bg: t.bg, elements: t.elements.map(e => ({ ...e, id: uid(), text: e.text ? e.text.replace(/\\n/g, '\n') : e.text, effects: e.effects || defaultEffects(), })) }]; state.currentPageId = state.pages[0].id; state.zoom = computeFitZoom(); renderPanel(); render(); } setTimeout(init, 50);