`;
}
return html;
}
function renderTexte() {
return `
Texte
Ajoutez du texte à votre design
Ajouter un texte
Combinaisons
Titre élégant
Sous-titre raffiné
Texte de paragraphe
Note manuscrite
Polices
${['Fraunces','Playfair Display','Abril Fatface','Lora','Bricolage Grotesque','DM Sans','Inter Tight','Caveat','Dancing Script','Pacifico','Bebas Neue','Space Mono']
.map(f => `${f} `).join('')}
`;
}
function renderImages() {
return `
Images
Importez vos visuels
${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
${eff.shadow.enabled ? `
` : ''}
${el.type === 'image' ? `
Filtre photo
${Object.entries(FILTERS).map(([k, v]) => `
`).join('')}
` : ''}
`;
}
function renderCommunaute() {
const isAnon = state.user.name === 'Anonyme';
let html = `
Communauté
Les designs des utilisateurs de Toile
${isAnon ? `
` : ''}
Récents
Populaires
`;
return html;
}
function renderFormat() {
return `
Format
${Object.keys(FORMATS).length}+ dimensions disponibles
${Object.entries(FORMAT_CATEGORIES).map(([cat, formats]) => `
${cat}
`).join('')}
Personnalisé
Appliquer
`;
}
// ─────────────────────────────────────────────────────────────
// 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: 'MarieDesigner
' },
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: '🥐 Croissantsmaison
' },
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: '' },
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: 'JoyeuxNoë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 ? `
` : ''}
${allSameGroup ? 'Re-grouper' : 'Grouper (Ctrl+G)'}
${gid ? `Dégrouper (Ctrl+Maj+G) ` : ''}
Dupliquer (Ctrl+D)
Supprimer (Suppr)
`;
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 = `
`;
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
${fonts.map(f => `${f} `).join('')}
`;
} else if (el.type === 'shape') {
const fillVal = (el.fillType === 'gradient') ? '#888888' : el.fill;
html += `
${el.shape !== 'line' ? `
` : ''}
${el.shape === 'rect' ? `
` : ''}
`;
} else if (el.type === 'icon') {
html += `
`;
} else if (el.type === 'image') {
html += `
`;
}
html += `
Supprimer l'élément
`;
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 `
`;
}).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 ? `
Synchroniser avec Google Drive
Synchroniser avec OneDrive
— ou —
Modifier mon pseudo
Se déconnecter
` : `
Continuer avec Google
Continuer avec Microsoft
Continuer avec Apple
FC
FranceConnect
— ou —
Recevoir un lien magique par email
Continuer en anonyme
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);
Vos préférences
Esquize n'utilise aucun cookie publicitaire . Seuls les cookies de session sont obligatoires. Vous pouvez accepter les statistiques d'usage anonymisées qui nous aident à améliorer le produit. En savoir plus
Tout accepter
Refuser les stats
Personnaliser
Passer le tutoriel
Suivant →
Historique des versions
✕