544 lines
20 KiB
JavaScript
544 lines
20 KiB
JavaScript
// StorySketch Application Logic
|
|
// Paste or type a story. The same path splits it into four panels, draws matching
|
|
// SVG scenes, and paints the comic. The preloaded sample is real input on that path.
|
|
// Copy puts a PNG of the finished comic on the clipboard; embed copies the same image.
|
|
|
|
class StorySketch {
|
|
constructor() {
|
|
this.storyInput = document.getElementById('story-input');
|
|
this.wordCountEl = document.getElementById('word-count');
|
|
this.previewArea = document.getElementById('preview-area');
|
|
this.copyButton = document.getElementById('copy-button');
|
|
this.exportImageBtn = document.getElementById('export-image-btn');
|
|
this.copyEmbedBtn = document.getElementById('copy-embed-btn');
|
|
this.panelEditor = document.getElementById('panel-editor');
|
|
this.sampleStory = "Once upon a time, a little rabbit named Luna loved to explore. One sunny morning, she hopped into the forest and found a sparkling pond. As she gazed at her reflection, a wise old turtle appeared. He smiled and said, 'The greatest adventures begin with a single hop.' Luna smiled back and hopped home, feeling brave.";
|
|
|
|
this.panelTextareas = [
|
|
document.getElementById('panel-1-text'),
|
|
document.getElementById('panel-2-text'),
|
|
document.getElementById('panel-3-text'),
|
|
document.getElementById('panel-4-text')
|
|
];
|
|
|
|
this.sceneSelects = [
|
|
document.getElementById('panel-1-scene'),
|
|
document.getElementById('panel-2-scene'),
|
|
document.getElementById('panel-3-scene'),
|
|
document.getElementById('panel-4-scene')
|
|
];
|
|
|
|
this.currentPanels = [];
|
|
this.syncingEditor = false;
|
|
this.comicWidth = 800;
|
|
this.comicHeight = 520;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
const boot = (story) => {
|
|
this.storyInput.value = story;
|
|
this.generateComic(story);
|
|
};
|
|
|
|
const pending = window.Time && typeof window.Time.load === 'function' ? window.Time.load() : null;
|
|
Promise.resolve(pending).then((data) => {
|
|
if (data && typeof data === 'object' && typeof data.story === 'string' && data.story.trim()) {
|
|
boot(data.story);
|
|
} else {
|
|
boot(this.sampleStory);
|
|
}
|
|
}).catch(() => {
|
|
boot(this.sampleStory);
|
|
});
|
|
|
|
this.storyInput.addEventListener('input', () => {
|
|
this.updateWordCount(this.storyInput.value);
|
|
this.debounceGenerate();
|
|
});
|
|
|
|
if (this.copyButton) {
|
|
this.copyButton.addEventListener('click', () => {
|
|
this.copyComic();
|
|
});
|
|
}
|
|
|
|
if (this.exportImageBtn) {
|
|
this.exportImageBtn.addEventListener('click', () => {
|
|
this.exportAsImage();
|
|
});
|
|
}
|
|
|
|
if (this.copyEmbedBtn) {
|
|
this.copyEmbedBtn.addEventListener('click', () => {
|
|
this.copyEmbedCode();
|
|
});
|
|
}
|
|
|
|
this.panelTextareas.forEach((textarea, index) => {
|
|
if (!textarea) return;
|
|
textarea.addEventListener('input', () => {
|
|
this.updatePanelFromEditor(index);
|
|
});
|
|
});
|
|
|
|
this.sceneSelects.forEach((select, index) => {
|
|
if (!select) return;
|
|
select.addEventListener('change', () => {
|
|
this.updatePanelFromEditor(index);
|
|
});
|
|
});
|
|
}
|
|
|
|
debounceTimer = null;
|
|
debounceGenerate() {
|
|
clearTimeout(this.debounceTimer);
|
|
this.debounceTimer = setTimeout(() => {
|
|
this.generateComic(this.storyInput.value);
|
|
}, 180);
|
|
}
|
|
|
|
wordCount(text) {
|
|
const trimmed = String(text || '').trim();
|
|
if (!trimmed) return 0;
|
|
return trimmed.split(/\s+/).length;
|
|
}
|
|
|
|
updateWordCount(text) {
|
|
if (this.wordCountEl) this.wordCountEl.textContent = String(this.wordCount(text));
|
|
}
|
|
|
|
generateComic(storyText) {
|
|
const words = String(storyText || '').trim().split(/\s+/).filter(Boolean);
|
|
if (words.length > 150) {
|
|
storyText = words.slice(0, 150).join(' ');
|
|
this.storyInput.value = storyText;
|
|
}
|
|
this.updateWordCount(storyText);
|
|
this.currentPanels = this.splitIntoPanels(storyText);
|
|
this.updatePanelEditor();
|
|
this.renderComic(this.currentPanels);
|
|
}
|
|
|
|
splitIntoPanels(text) {
|
|
const empty = () => Array.from({ length: 4 }, () => ({ text: '', scene: 'empty' }));
|
|
const source = String(text || '').trim();
|
|
if (!source) return empty();
|
|
|
|
const sentences = source.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [source];
|
|
const chunks = sentences.map((s) => s.trim()).filter(Boolean);
|
|
if (!chunks.length) return empty();
|
|
|
|
const panels = [];
|
|
const perPanel = Math.max(1, Math.ceil(chunks.length / 4));
|
|
for (let i = 0; i < 4; i++) {
|
|
const start = i * perPanel;
|
|
const slice = chunks.slice(start, start + perPanel);
|
|
if (slice.length) {
|
|
const panelText = slice.join(' ').trim();
|
|
panels.push({ text: panelText, scene: this.determineScene(panelText, i) });
|
|
} else {
|
|
panels.push({ text: '', scene: 'empty' });
|
|
}
|
|
}
|
|
return panels;
|
|
}
|
|
|
|
determineScene(text, index) {
|
|
const lowerText = String(text || '').toLowerCase();
|
|
if (/\b(forest|tree|trees|wood|woods)\b/.test(lowerText)) return 'forest';
|
|
if (/\b(house|home|room|cottage)\b/.test(lowerText)) return 'house';
|
|
if (/\b(pond|water|lake|river|reflection)\b/.test(lowerText)) return 'pond';
|
|
if (/\b(rabbit|bunny|hare|luna)\b/.test(lowerText)) return 'rabbit';
|
|
if (/\b(turtle|tortoise|wise)\b/.test(lowerText)) return 'turtle';
|
|
if (/\b(sun|sunny|morning|bright|day)\b/.test(lowerText)) return 'sun';
|
|
if (/\b(night|moon|sleep|bed|stars)\b/.test(lowerText)) return 'moon';
|
|
const scenes = ['generic1', 'generic2', 'generic3', 'generic4'];
|
|
return scenes[index % scenes.length];
|
|
}
|
|
|
|
renderComic(panels) {
|
|
const svg = this.buildSvg(panels);
|
|
this.previewArea.innerHTML = '';
|
|
this.previewArea.appendChild(svg);
|
|
}
|
|
|
|
buildSvg(panels) {
|
|
const NS = 'http://www.w3.org/2000/svg';
|
|
const svg = document.createElementNS(NS, 'svg');
|
|
svg.setAttribute('xmlns', NS);
|
|
svg.setAttribute('viewBox', `0 0 ${this.comicWidth} ${this.comicHeight}`);
|
|
svg.setAttribute('width', String(this.comicWidth));
|
|
svg.setAttribute('height', String(this.comicHeight));
|
|
svg.setAttribute('class', 'comic-svg');
|
|
svg.setAttribute('role', 'img');
|
|
svg.setAttribute('aria-label', 'Four panel story comic');
|
|
|
|
const bg = document.createElementNS(NS, 'rect');
|
|
bg.setAttribute('width', String(this.comicWidth));
|
|
bg.setAttribute('height', String(this.comicHeight));
|
|
bg.setAttribute('fill', '#f4f1ea');
|
|
svg.appendChild(bg);
|
|
|
|
const slots = [
|
|
{ x: 16, y: 16 },
|
|
{ x: 408, y: 16 },
|
|
{ x: 16, y: 268 },
|
|
{ x: 408, y: 268 }
|
|
];
|
|
|
|
panels.forEach((panel, i) => {
|
|
const slot = slots[i];
|
|
svg.appendChild(this.panelGroup(panel, slot.x, slot.y, i));
|
|
});
|
|
return svg;
|
|
}
|
|
|
|
panelGroup(panel, x, y, index) {
|
|
const NS = 'http://www.w3.org/2000/svg';
|
|
const g = document.createElementNS(NS, 'g');
|
|
g.setAttribute('class', 'comic-panel');
|
|
|
|
const frame = document.createElementNS(NS, 'rect');
|
|
frame.setAttribute('x', String(x));
|
|
frame.setAttribute('y', String(y));
|
|
frame.setAttribute('width', '376');
|
|
frame.setAttribute('height', '236');
|
|
frame.setAttribute('rx', '12');
|
|
frame.setAttribute('fill', '#fffdf8');
|
|
frame.setAttribute('stroke', '#2b2b2b');
|
|
frame.setAttribute('stroke-width', '3');
|
|
g.appendChild(frame);
|
|
|
|
const scene = document.createElementNS(NS, 'g');
|
|
scene.setAttribute('transform', `translate(${x + 14} ${y + 14})`);
|
|
scene.innerHTML = this.drawScene(panel.scene, 0, 0, 118, 118);
|
|
g.appendChild(scene);
|
|
|
|
const badge = document.createElementNS(NS, 'text');
|
|
badge.setAttribute('x', String(x + 352));
|
|
badge.setAttribute('y', String(y + 28));
|
|
badge.setAttribute('text-anchor', 'end');
|
|
badge.setAttribute('font-family', 'Georgia, "Times New Roman", serif');
|
|
badge.setAttribute('font-size', '13');
|
|
badge.setAttribute('fill', '#6c757d');
|
|
badge.textContent = String(index + 1);
|
|
g.appendChild(badge);
|
|
|
|
const lines = this.wrapText(panel.text || '', 28);
|
|
lines.slice(0, 8).forEach((line, i) => {
|
|
const text = document.createElementNS(NS, 'text');
|
|
text.setAttribute('x', String(x + 146));
|
|
text.setAttribute('y', String(y + 36 + i * 22));
|
|
text.setAttribute('font-family', 'Georgia, "Times New Roman", serif');
|
|
text.setAttribute('font-size', '15');
|
|
text.setAttribute('fill', '#212529');
|
|
text.textContent = line;
|
|
g.appendChild(text);
|
|
});
|
|
|
|
if (!panel.text) {
|
|
const empty = document.createElementNS(NS, 'text');
|
|
empty.setAttribute('x', String(x + 146));
|
|
empty.setAttribute('y', String(y + 48));
|
|
empty.setAttribute('font-family', 'Georgia, "Times New Roman", serif');
|
|
empty.setAttribute('font-size', '15');
|
|
empty.setAttribute('fill', '#adb5bd');
|
|
empty.textContent = 'Empty panel';
|
|
g.appendChild(empty);
|
|
}
|
|
return g;
|
|
}
|
|
|
|
wrapText(text, maxChars) {
|
|
const words = String(text || '').trim().split(/\s+/).filter(Boolean);
|
|
if (!words.length) return [];
|
|
const lines = [];
|
|
let line = '';
|
|
words.forEach((word) => {
|
|
const next = line ? line + ' ' + word : word;
|
|
if (next.length > maxChars && line) {
|
|
lines.push(line);
|
|
line = word;
|
|
} else {
|
|
line = next;
|
|
}
|
|
});
|
|
if (line) lines.push(line);
|
|
return lines;
|
|
}
|
|
|
|
drawScene(sceneType, x, y, width, height) {
|
|
switch (sceneType) {
|
|
case 'forest':
|
|
return `
|
|
<rect x="${x}" y="${y + 70}" width="${width}" height="48" fill="#c6e2b5" />
|
|
<rect x="${x + 22}" y="${y + 42}" width="8" height="40" fill="#8B4513" />
|
|
<circle cx="${x + 26}" cy="${y + 36}" r="18" fill="#2e8b57" />
|
|
<rect x="${x + 58}" y="${y + 34}" width="8" height="48" fill="#8B4513" />
|
|
<circle cx="${x + 62}" cy="${y + 28}" r="22" fill="#228B22" />
|
|
<rect x="${x + 90}" y="${y + 46}" width="7" height="36" fill="#8B4513" />
|
|
<circle cx="${x + 93}" cy="${y + 40}" r="16" fill="#3cb371" />
|
|
`;
|
|
case 'house':
|
|
return `
|
|
<rect x="${x + 18}" y="${y + 48}" width="78" height="58" fill="#e7c39a" />
|
|
<polygon points="${x + 10},${y + 50} ${x + 104},${y + 50} ${x + 57},${y + 16}" fill="#b5651d" />
|
|
<rect x="${x + 48}" y="${y + 74}" width="18" height="32" fill="#6b3e26" />
|
|
<rect x="${x + 26}" y="${y + 62}" width="14" height="14" fill="#87CEEB" stroke="#6b3e26" />
|
|
<rect x="${x + 74}" y="${y + 62}" width="14" height="14" fill="#87CEEB" stroke="#6b3e26" />
|
|
`;
|
|
case 'pond':
|
|
return `
|
|
<ellipse cx="${x + 60}" cy="${y + 78}" rx="48" ry="22" fill="#4f86c6" />
|
|
<ellipse cx="${x + 60}" cy="${y + 74}" rx="36" ry="12" fill="#7eb6e0" />
|
|
<path d="M${x + 18},${y + 40} Q${x + 40},${y + 18} ${x + 28},${y + 58}" fill="#20B2AA" />
|
|
<path d="M${x + 96},${y + 36} Q${x + 78},${y + 16} ${x + 88},${y + 56}" fill="#2e8b57" />
|
|
<circle cx="${x + 48}" cy="${y + 70}" r="3" fill="#fff" />
|
|
`;
|
|
case 'rabbit':
|
|
return `
|
|
<ellipse cx="${x + 58}" cy="${y + 74}" rx="28" ry="18" fill="#f7f4ef" stroke="#d9cfc2" />
|
|
<circle cx="${x + 78}" cy="${y + 58}" r="14" fill="#f7f4ef" stroke="#d9cfc2" />
|
|
<ellipse cx="${x + 70}" cy="${y + 36}" rx="5" ry="16" fill="#f7f4ef" stroke="#d9cfc2" />
|
|
<ellipse cx="${x + 84}" cy="${y + 36}" rx="5" ry="16" fill="#f7f4ef" stroke="#d9cfc2" />
|
|
<circle cx="${x + 74}" cy="${y + 56}" r="1.6" fill="#333" />
|
|
<circle cx="${x + 84}" cy="${y + 56}" r="1.6" fill="#333" />
|
|
<ellipse cx="${x + 34}" cy="${y + 78}" rx="8" ry="5" fill="#f7f4ef" stroke="#d9cfc2" />
|
|
`;
|
|
case 'turtle':
|
|
return `
|
|
<ellipse cx="${x + 62}" cy="${y + 70}" rx="32" ry="18" fill="#2f8f7a" />
|
|
<ellipse cx="${x + 62}" cy="${y + 66}" rx="22" ry="12" fill="#3cb39a" />
|
|
<circle cx="${x + 92}" cy="${y + 62}" r="9" fill="#7dcea0" />
|
|
<circle cx="${x + 95}" cy="${y + 60}" r="1.5" fill="#123" />
|
|
<rect x="${x + 36}" y="${y + 78}" width="10" height="6" rx="2" fill="#1f6f5b" />
|
|
<rect x="${x + 78}" y="${y + 78}" width="10" height="6" rx="2" fill="#1f6f5b" />
|
|
`;
|
|
case 'sun':
|
|
return `
|
|
<circle cx="${x + 60}" cy="${y + 58}" r="22" fill="#FFD24A" />
|
|
<g stroke="#FFD24A" stroke-width="4" stroke-linecap="round">
|
|
<line x1="${x + 60}" y1="${y + 22}" x2="${x + 60}" y2="${y + 8}" />
|
|
<line x1="${x + 60}" y1="${y + 94}" x2="${x + 60}" y2="${y + 108}" />
|
|
<line x1="${x + 24}" y1="${y + 58}" x2="${x + 10}" y2="${y + 58}" />
|
|
<line x1="${x + 96}" y1="${y + 58}" x2="${x + 110}" y2="${y + 58}" />
|
|
<line x1="${x + 34}" y1="${y + 32}" x2="${x + 24}" y2="${y + 22}" />
|
|
<line x1="${x + 86}" y1="${y + 32}" x2="${x + 96}" y2="${y + 22}" />
|
|
<line x1="${x + 34}" y1="${y + 84}" x2="${x + 24}" y2="${y + 94}" />
|
|
<line x1="${x + 86}" y1="${y + 84}" x2="${x + 96}" y2="${y + 94}" />
|
|
</g>
|
|
`;
|
|
case 'moon':
|
|
return `
|
|
<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="#1b2740" />
|
|
<circle cx="${x + 70}" cy="${y + 42}" r="18" fill="#f4f1ea" />
|
|
<circle cx="${x + 80}" cy="${y + 38}" r="14" fill="#1b2740" />
|
|
<circle cx="${x + 28}" cy="${y + 30}" r="1.5" fill="#fff" />
|
|
<circle cx="${x + 40}" cy="${y + 70}" r="1.2" fill="#fff" />
|
|
<circle cx="${x + 96}" cy="${y + 78}" r="1.4" fill="#fff" />
|
|
`;
|
|
case 'generic1':
|
|
return `
|
|
<rect x="${x + 16}" y="${y + 28}" width="84" height="62" rx="8" fill="#7c6cf0" />
|
|
<circle cx="${x + 58}" cy="${y + 58}" r="14" fill="#ffd24a" />
|
|
`;
|
|
case 'generic2':
|
|
return `
|
|
<polygon points="${x + 20},${y + 96} ${x + 100},${y + 96} ${x + 60},${y + 28}" fill="#3cb371" />
|
|
<circle cx="${x + 60}" cy="${y + 70}" r="12" fill="#ff7a59" />
|
|
`;
|
|
case 'generic3':
|
|
return `
|
|
<rect x="${x + 28}" y="${y + 36}" width="62" height="62" rx="6" fill="#ff7a59" />
|
|
<circle cx="${x + 59}" cy="${y + 28}" r="12" fill="#ffd24a" />
|
|
`;
|
|
case 'generic4':
|
|
return `
|
|
<ellipse cx="${x + 60}" cy="${y + 64}" rx="40" ry="26" fill="#7c6cf0" />
|
|
<circle cx="${x + 46}" cy="${y + 58}" r="4" fill="#fff" />
|
|
<circle cx="${x + 74}" cy="${y + 58}" r="4" fill="#fff" />
|
|
`;
|
|
default:
|
|
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6" />`;
|
|
}
|
|
}
|
|
|
|
updatePanelEditor() {
|
|
if (this.panelEditor) this.panelEditor.style.display = 'grid';
|
|
this.syncingEditor = true;
|
|
this.currentPanels.forEach((panel, index) => {
|
|
if (this.panelTextareas[index] && this.panelTextareas[index].value !== panel.text) {
|
|
this.panelTextareas[index].value = panel.text;
|
|
}
|
|
if (this.sceneSelects[index]) {
|
|
this.sceneSelects[index].value = panel.scene;
|
|
}
|
|
});
|
|
this.syncingEditor = false;
|
|
}
|
|
|
|
updatePanelFromEditor(panelIndex) {
|
|
if (this.syncingEditor) return;
|
|
const textarea = this.panelTextareas[panelIndex];
|
|
const select = this.sceneSelects[panelIndex];
|
|
if (!textarea || !select) return;
|
|
this.currentPanels[panelIndex] = {
|
|
text: textarea.value,
|
|
scene: select.value
|
|
};
|
|
this.renderComic(this.currentPanels);
|
|
}
|
|
|
|
svgMarkup() {
|
|
const svg = this.previewArea.querySelector('svg');
|
|
if (!svg) return '';
|
|
const clone = svg.cloneNode(true);
|
|
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
clone.setAttribute('width', String(this.comicWidth));
|
|
clone.setAttribute('height', String(this.comicHeight));
|
|
return new XMLSerializer().serializeToString(clone);
|
|
}
|
|
|
|
rasterize() {
|
|
const markup = this.svgMarkup();
|
|
if (!markup) return Promise.reject(new Error('No comic to copy'));
|
|
const blob = new Blob([markup], { type: 'image/svg+xml;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const img = new Image();
|
|
const width = this.comicWidth * 2;
|
|
const height = this.comicHeight * 2;
|
|
return new Promise((resolve, reject) => {
|
|
img.onload = () => {
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
ctx.fillStyle = '#f4f1ea';
|
|
ctx.fillRect(0, 0, width, height);
|
|
ctx.drawImage(img, 0, 0, width, height);
|
|
URL.revokeObjectURL(url);
|
|
canvas.toBlob((png) => {
|
|
if (!png) {
|
|
reject(new Error('Could not encode PNG'));
|
|
return;
|
|
}
|
|
resolve({ png, canvas });
|
|
}, 'image/png');
|
|
};
|
|
img.onerror = () => {
|
|
URL.revokeObjectURL(url);
|
|
reject(new Error('Could not draw the comic'));
|
|
};
|
|
img.src = url;
|
|
});
|
|
}
|
|
|
|
copyComic() {
|
|
const button = this.copyButton;
|
|
const previous = button ? button.textContent : '';
|
|
this.rasterize().then(({ png }) => {
|
|
const writeImage = navigator.clipboard && window.ClipboardItem && navigator.clipboard.write
|
|
? navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
|
|
: Promise.reject(new Error('clipboard image unsupported'));
|
|
return writeImage.catch(() => this.downloadBlob(png, 'storysketch-comic.png').then(() => 'downloaded'));
|
|
}).then((result) => {
|
|
if (button) {
|
|
button.textContent = result === 'downloaded' ? 'Downloaded PNG' : 'Copied image';
|
|
setTimeout(() => { button.textContent = previous; }, 1600);
|
|
}
|
|
this.showNotification(result === 'downloaded'
|
|
? 'Clipboard images are blocked here, so the PNG was downloaded instead.'
|
|
: 'Comic image copied. Paste it into a message or doc.');
|
|
}).catch((err) => {
|
|
console.error(err);
|
|
this.showNotification('Could not copy the comic image');
|
|
});
|
|
}
|
|
|
|
copyEmbedCode() {
|
|
this.rasterize().then(({ canvas }) => {
|
|
const dataUrl = canvas.toDataURL('image/png');
|
|
const embedCode = `<img src="${dataUrl}" alt="StorySketch comic" width="${this.comicWidth}" />`;
|
|
return this.copyText(embedCode);
|
|
}).then(() => {
|
|
const previous = this.copyEmbedBtn ? this.copyEmbedBtn.textContent : '';
|
|
if (this.copyEmbedBtn) {
|
|
this.copyEmbedBtn.textContent = 'Copied embed';
|
|
setTimeout(() => { this.copyEmbedBtn.textContent = previous; }, 1600);
|
|
}
|
|
this.showNotification('Embed code copied. It is the comic as a PNG image.');
|
|
}).catch((err) => {
|
|
console.error(err);
|
|
this.showNotification('Could not copy embed code');
|
|
});
|
|
}
|
|
|
|
copyText(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
return navigator.clipboard.writeText(text);
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const area = document.createElement('textarea');
|
|
area.value = text;
|
|
area.setAttribute('readonly', '');
|
|
area.style.position = 'fixed';
|
|
area.style.left = '-9999px';
|
|
document.body.appendChild(area);
|
|
area.select();
|
|
try {
|
|
document.execCommand('copy') ? resolve() : reject(new Error('copy failed'));
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
document.body.removeChild(area);
|
|
});
|
|
}
|
|
|
|
exportAsImage() {
|
|
this.rasterize().then(({ png }) => {
|
|
return this.downloadBlob(png, 'storysketch-comic.png');
|
|
}).then(() => {
|
|
this.showNotification('Comic downloaded as PNG');
|
|
}).catch((err) => {
|
|
console.error(err);
|
|
this.showNotification('Could not export the image');
|
|
});
|
|
}
|
|
|
|
downloadBlob(blob, name) {
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = name;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
|
return Promise.resolve();
|
|
}
|
|
|
|
showNotification(message) {
|
|
const notification = document.createElement('div');
|
|
notification.textContent = message;
|
|
notification.style.position = 'fixed';
|
|
notification.style.bottom = '20px';
|
|
notification.style.right = '20px';
|
|
notification.style.backgroundColor = '#333';
|
|
notification.style.color = '#fff';
|
|
notification.style.padding = '10px 20px';
|
|
notification.style.borderRadius = '5px';
|
|
notification.style.zIndex = '1000';
|
|
notification.style.maxWidth = '280px';
|
|
document.body.appendChild(notification);
|
|
setTimeout(() => {
|
|
if (notification.parentNode) notification.parentNode.removeChild(notification);
|
|
}, 2800);
|
|
}
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.storySketch = new StorySketch();
|
|
});
|