card #29: update ventures/storysketch/app.js
This commit is contained in:
1 parent
f4a9ad4795
commit
f2b5e93e8e
1 file changed
+361
-268
+361
-268
@@ -1,54 +1,60 @@
|
||||
// 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.";
|
||||
|
||||
// Panel editor elements
|
||||
|
||||
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() {
|
||||
// Load any shared story
|
||||
Time.load().then(data => {
|
||||
if (data && data.story) {
|
||||
this.storyInput.value = data.story;
|
||||
this.generateComic(data.story);
|
||||
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 {
|
||||
// Load sample story
|
||||
this.storyInput.value = this.sampleStory;
|
||||
this.generateComic(this.sampleStory);
|
||||
boot(this.sampleStory);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Fallback to sample if loading fails
|
||||
this.storyInput.value = this.sampleStory;
|
||||
this.generateComic(this.sampleStory);
|
||||
boot(this.sampleStory);
|
||||
});
|
||||
|
||||
// Event listeners
|
||||
this.storyInput.addEventListener('input', () => {
|
||||
this.updateWordCount(this.storyInput.value);
|
||||
this.debounceGenerate();
|
||||
});
|
||||
|
||||
@@ -63,21 +69,22 @@ class StorySketch {
|
||||
this.exportAsImage();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (this.copyEmbedBtn) {
|
||||
this.copyEmbedBtn.addEventListener('click', () => {
|
||||
this.copyEmbedCode();
|
||||
});
|
||||
}
|
||||
|
||||
// Panel editor event listeners
|
||||
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);
|
||||
});
|
||||
@@ -89,341 +96,430 @@ class StorySketch {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.generateComic(this.storyInput.value);
|
||||
}, 500);
|
||||
}, 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) {
|
||||
// Limit to 150 words
|
||||
const words = storyText.trim().split(/\s+/);
|
||||
const words = String(storyText || '').trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length > 150) {
|
||||
storyText = words.slice(0, 150).join(' ');
|
||||
this.storyInput.value = storyText;
|
||||
}
|
||||
|
||||
// Split into 4 panels
|
||||
this.updateWordCount(storyText);
|
||||
this.currentPanels = this.splitIntoPanels(storyText);
|
||||
|
||||
// Update panel editor
|
||||
this.updatePanelEditor();
|
||||
|
||||
// Generate SVG for each panel
|
||||
this.renderComic(this.currentPanels);
|
||||
}
|
||||
|
||||
splitIntoPanels(text) {
|
||||
if (!text.trim()) {
|
||||
return Array(4).fill("").map(() => ({text: "", scene: "empty"}));
|
||||
}
|
||||
const empty = () => Array.from({ length: 4 }, () => ({ text: '', scene: 'empty' }));
|
||||
const source = String(text || '').trim();
|
||||
if (!source) return empty();
|
||||
|
||||
const sentences = text.match(/[^\.!?]+[\.!?]+/g) || [text];
|
||||
if (sentences.length === 0) return Array(4).fill("").map(() => ({text: "", scene: "empty"}));
|
||||
const sentences = source.match(/[^.!?]+[.!?]+|[^.!?]+$/g) || [source];
|
||||
const chunks = sentences.map((s) => s.trim()).filter(Boolean);
|
||||
if (!chunks.length) return empty();
|
||||
|
||||
// Distribute sentences across 4 panels
|
||||
const panels = [];
|
||||
const sentencesPerPanel = Math.max(1, Math.ceil(sentences.length / 4));
|
||||
|
||||
const perPanel = Math.max(1, Math.ceil(chunks.length / 4));
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const start = i * sentencesPerPanel;
|
||||
const end = Math.min(start + sentencesPerPanel, sentences.length);
|
||||
const panelSentences = sentences.slice(start, end);
|
||||
|
||||
if (panelSentences.length > 0) {
|
||||
panels.push({
|
||||
text: panelSentences.join(' ').trim(),
|
||||
scene: this.determineScene(panelSentences.join(' '))
|
||||
});
|
||||
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"});
|
||||
panels.push({ text: '', scene: 'empty' });
|
||||
}
|
||||
}
|
||||
|
||||
// Fill empty panels if less than 4
|
||||
while (panels.length < 4) {
|
||||
panels.push({text: "", scene: "empty"});
|
||||
}
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
determineScene(text) {
|
||||
const lowerText = text.toLowerCase();
|
||||
|
||||
if (lowerText.includes('forest') || lowerText.includes('tree') || lowerText.includes('wood')) {
|
||||
return 'forest';
|
||||
} else if (lowerText.includes('house') || lowerText.includes('home') || lowerText.includes('room')) {
|
||||
return 'house';
|
||||
} else if (lowerText.includes('pond') || lowerText.includes('water') || lowerText.includes('lake')) {
|
||||
return 'pond';
|
||||
} else if (lowerText.includes('rabbit') || lowerText.includes('bunny') || lowerText.includes('animal')) {
|
||||
return 'rabbit';
|
||||
} else if (lowerText.includes('turtle') || lowerText.includes('wise') || lowerText.includes('old')) {
|
||||
return 'turtle';
|
||||
} else if (lowerText.includes('sun') || lowerText.includes('morning') || lowerText.includes('bright')) {
|
||||
return 'sun';
|
||||
} else if (lowerText.includes('night') || lowerText.includes('sleep') || lowerText.includes('bed')) {
|
||||
return 'moon';
|
||||
} else {
|
||||
// Default scenes
|
||||
const scenes = ['generic1', 'generic2', 'generic3', 'generic4'];
|
||||
return scenes[Math.floor(Math.random() * scenes.length)];
|
||||
}
|
||||
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) {
|
||||
let svgContent = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400" class="comic-svg">
|
||||
<style>
|
||||
.panel { fill: #f0f8ff; stroke: #333; stroke-width: 2; }
|
||||
.panel-text { font-family: Arial, sans-serif; font-size: 14px; fill: #333; }
|
||||
.scene-element { fill: #4682b4; }
|
||||
</style>
|
||||
<!-- Panel 1 -->
|
||||
<rect x="10" y="10" width="380" height="180" class="panel" rx="10"/>
|
||||
${this.drawScene(panels[0].scene, 20, 20, 100, 100)}
|
||||
<foreignObject x="130" y="20" width="250" height="160">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="panel-text">${this.escapeHtml(panels[0].text)}</div>
|
||||
</foreignObject>
|
||||
|
||||
<!-- Panel 2 -->
|
||||
<rect x="410" y="10" width="380" height="180" class="panel" rx="10"/>
|
||||
${this.drawScene(panels[1].scene, 420, 20, 100, 100)}
|
||||
<foreignObject x="530" y="20" width="250" height="160">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="panel-text">${this.escapeHtml(panels[1].text)}</div>
|
||||
</foreignObject>
|
||||
|
||||
<!-- Panel 3 -->
|
||||
<rect x="10" y="210" width="380" height="180" class="panel" rx="10"/>
|
||||
${this.drawScene(panels[2].scene, 20, 220, 100, 100)}
|
||||
<foreignObject x="130" y="220" width="250" height="160">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="panel-text">${this.escapeHtml(panels[2].text)}</div>
|
||||
</foreignObject>
|
||||
|
||||
<!-- Panel 4 -->
|
||||
<rect x="410" y="210" width="380" height="180" class="panel" rx="10"/>
|
||||
${this.drawScene(panels[3].scene, 420, 220, 100, 100)}
|
||||
<foreignObject x="530" y="220" width="250" height="160">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml" class="panel-text">${this.escapeHtml(panels[3].text)}</div>
|
||||
</foreignObject>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
this.previewArea.innerHTML = svgContent;
|
||||
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) {
|
||||
switch (sceneType) {
|
||||
case 'forest':
|
||||
return `
|
||||
<rect x="${x}" y="${y}" width="${width}" height="${height/2}" fill="#8FBC8F" />
|
||||
<circle cx="${x + 20}" cy="${y + 30}" r="15" fill="#228B22" />
|
||||
<circle cx="${x + 50}" cy="${y + 20}" r="20" fill="#228B22" />
|
||||
<circle cx="${x + 80}" cy="${y + 35}" r="18" fill="#228B22" />
|
||||
<rect x="${x + 45}" y="${y + 30}" width="10" height="40" fill="#8B4513" />
|
||||
<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 + 30}" y="${y + 20}" width="60" height="50" fill="#CD853F" />
|
||||
<polygon points="${x + 20},${y + 20} ${x + 90},${y + 20} ${x + 55},${y}" fill="#8B4513" />
|
||||
<rect x="${x + 45}" y="${y + 40}" width="15" height="30" fill="#654321" />
|
||||
<rect x="${x + 70}" y="${y + 35}" width="10" height="10" fill="#87CEEB" />
|
||||
<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 + 50}" cy="${y + 50}" rx="40" ry="25" fill="#4169E1" />
|
||||
<circle cx="${x + 30}" cy="${y + 40}" r="3" fill="#fff" />
|
||||
<circle cx="${x + 70}" cy="${y + 60}" r="5" fill="#fff" />
|
||||
<path d="M${x + 20},${y + 30} Q${x + 50},${y + 10} ${x + 80},${y + 30}" fill="#20B2AA" />
|
||||
<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 + 50}" cy="${y + 50}" rx="20" ry="15" fill="#fff" />
|
||||
<ellipse cx="${x + 35}" cy="${y + 40}" rx="8" ry="15" fill="#fff" />
|
||||
<ellipse cx="${x + 65}" cy="${y + 40}" rx="8" ry="15" fill="#fff" />
|
||||
<circle cx="${x + 45}" cy="${y + 45}" r="2" fill="#000" />
|
||||
<ellipse cx="${x + 30}" cy="${y + 30}" rx="5" ry="10" fill="#fff" transform="rotate(-30 ${x + 30} ${y + 30})" />
|
||||
<ellipse cx="${x + 70}" cy="${y + 30}" rx="5" ry="10" fill="#fff" transform="rotate(30 ${x + 70} ${y + 30})" />
|
||||
<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 + 50}" cy="${y + 50}" rx="25" ry="15" fill="#20B2AA" />
|
||||
<circle cx="${x + 50}" cy="${y + 40}" r="8" fill="#20B2AA" />
|
||||
<circle cx="${x + 47}" cy="${y + 38}" r="2" fill="#000" />
|
||||
<rect x="${x + 30}" y="${y + 50}" width="40" height="5" fill="#20B2AA" />
|
||||
<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 + 50}" cy="${y + 50}" r="20" fill="#FFD700" />
|
||||
<line x1="${x + 50}" y1="${y + 20}" x2="${x + 50}" y2="${y + 10}" stroke="#FFD700" stroke-width="2" />
|
||||
<line x1="${x + 70}" y1="${y + 50}" x2="${x + 80}" y2="${y + 50}" stroke="#FFD700" stroke-width="2" />
|
||||
<line x1="${x + 50}" y1="${y + 80}" x2="${x + 50}" y2="${y + 90}" stroke="#FFD700" stroke-width="2" />
|
||||
<line x1="${x + 30}" y1="${y + 50}" x2="${x + 20}" y2="${y + 50}" stroke="#FFD700" stroke-width="2" />
|
||||
<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 `
|
||||
<circle cx="${x + 50}" cy="${y + 50}" r="20" fill="#F5F5F5" />
|
||||
<circle cx="${x + 60}" cy="${y + 45}" r="15" fill="#fff" />
|
||||
<circle cx="${x + 45}" cy="${y + 55}" r="8" fill="#F5F5F5" />
|
||||
<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 + 20}" y="${y + 20}" width="60" height="40" fill="#9370DB" />
|
||||
<circle cx="${x + 50}" cy="${y + 50}" r="10" fill="#BA55D3" />
|
||||
<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 + 30},${y + 70} ${x + 70},${y + 70} ${x + 50},${y + 30}" fill="#32CD32" />
|
||||
<circle cx="${x + 50}" cy="${y + 60}" r="15" fill="#FF6347" />
|
||||
<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 + 30}" y="${y + 30}" width="40" height="40" fill="#FF6347" />
|
||||
<circle cx="${x + 50}" cy="${y + 30}" r="10" fill="#FFA500" />
|
||||
<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 + 50}" cy="${y + 50}" rx="30" ry="20" fill="#9370DB" />
|
||||
<circle cx="${x + 40}" cy="${y + 45}" r="5" fill="#fff" />
|
||||
<circle cx="${x + 60}" cy="${y + 45}" r="5" fill="#fff" />
|
||||
<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: // empty
|
||||
return `<text x="${x + 50}" y="${y + 50}" text-anchor="middle" fill="#ccc">Empty</text>`;
|
||||
default:
|
||||
return `<rect x="${x}" y="${y}" width="${width}" height="${height}" fill="#f8f9fa" stroke="#dee2e6" />`;
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(unsafe) {
|
||||
if (!unsafe) return '';
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
updatePanelEditor() {
|
||||
// Show the panel editor
|
||||
this.panelEditor.style.display = 'grid';
|
||||
|
||||
// Update panel editor fields with current panel data
|
||||
if (this.panelEditor) this.panelEditor.style.display = 'grid';
|
||||
this.syncingEditor = true;
|
||||
this.currentPanels.forEach((panel, index) => {
|
||||
if (this.panelTextareas[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) {
|
||||
// Update the panel data from editor inputs
|
||||
const newText = this.panelTextareas[panelIndex].value;
|
||||
const newScene = this.sceneSelects[panelIndex].value;
|
||||
|
||||
if (this.syncingEditor) return;
|
||||
const textarea = this.panelTextareas[panelIndex];
|
||||
const select = this.sceneSelects[panelIndex];
|
||||
if (!textarea || !select) return;
|
||||
this.currentPanels[panelIndex] = {
|
||||
text: newText,
|
||||
scene: newScene
|
||||
text: textarea.value,
|
||||
scene: select.value
|
||||
};
|
||||
|
||||
// Re-render the comic with updated panels
|
||||
this.renderComic(this.currentPanels);
|
||||
}
|
||||
|
||||
copyComic() {
|
||||
const svgElement = this.previewArea.querySelector('svg');
|
||||
if (!svgElement) return;
|
||||
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);
|
||||
}
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||
const blob = new Blob([svgData], {type: "image/svg+xml"});
|
||||
|
||||
if (navigator.clipboard && window.ClipboardItem) {
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
"image/svg+xml": blob
|
||||
})
|
||||
]).then(() => {
|
||||
this.showNotification("Comic copied to clipboard!");
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
this.showNotification("Failed to copy comic");
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = svgData;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
this.showNotification("Comic copied to clipboard!");
|
||||
}
|
||||
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() {
|
||||
const svgElement = this.previewArea.querySelector('svg');
|
||||
if (!svgElement) return;
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||
const encodedSvg = encodeURIComponent(svgData);
|
||||
const embedCode = `<img src="data:image/svg+xml,${encodedSvg}" alt="StorySketch Comic" />`;
|
||||
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(embedCode).then(() => {
|
||||
this.showNotification("Embed code copied to clipboard!");
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
this.showNotification("Failed to copy embed code");
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = embedCode;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textArea);
|
||||
this.showNotification("Embed code copied to clipboard!");
|
||||
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() {
|
||||
const svgElement = this.previewArea.querySelector('svg');
|
||||
if (!svgElement) return;
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
const svgData = new XMLSerializer().serializeToString(svgElement);
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
|
||||
const svgBlob = new Blob([svgData], {type: "image/svg+xml;charset=utf-8"});
|
||||
const url = URL.createObjectURL(svgBlob);
|
||||
|
||||
img.onload = () => {
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const pngUrl = canvas.toDataURL("image/png");
|
||||
const link = document.createElement("a");
|
||||
link.download = "storysketch-comic.png";
|
||||
link.href = pngUrl;
|
||||
link.click();
|
||||
|
||||
this.showNotification("Comic downloaded as PNG!");
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
this.showNotification("Failed to export image");
|
||||
};
|
||||
|
||||
img.src = url;
|
||||
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) {
|
||||
// Simple notification implementation
|
||||
const notification = document.createElement('div');
|
||||
notification.textContent = message;
|
||||
notification.style.position = 'fixed';
|
||||
@@ -434,17 +530,14 @@ class StorySketch {
|
||||
notification.style.padding = '10px 20px';
|
||||
notification.style.borderRadius = '5px';
|
||||
notification.style.zIndex = '1000';
|
||||
notification.style.opacity = '0.9';
|
||||
|
||||
notification.style.maxWidth = '280px';
|
||||
document.body.appendChild(notification);
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(notification);
|
||||
}, 3000);
|
||||
if (notification.parentNode) notification.parentNode.removeChild(notification);
|
||||
}, 2800);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize the app when the page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.storySketch = new StorySketch();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user