diff --git a/ventures/storysketch/app.js b/ventures/storysketch/app.js index 3af87c5..eb39467 100644 --- a/ventures/storysketch/app.js +++ b/ventures/storysketch/app.js @@ -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 = ` - - - - - ${this.drawScene(panels[0].scene, 20, 20, 100, 100)} - -
${this.escapeHtml(panels[0].text)}
-
- - - - ${this.drawScene(panels[1].scene, 420, 20, 100, 100)} - -
${this.escapeHtml(panels[1].text)}
-
- - - - ${this.drawScene(panels[2].scene, 20, 220, 100, 100)} - -
${this.escapeHtml(panels[2].text)}
-
- - - - ${this.drawScene(panels[3].scene, 420, 220, 100, 100)} - -
${this.escapeHtml(panels[3].text)}
-
-
- `; - - 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 ` - - - - - + + + + + + + `; case 'house': return ` - - - - + + + + + `; case 'pond': return ` - - - - + + + + + `; case 'rabbit': return ` - - - - - - + + + + + + + `; case 'turtle': return ` - - - - + + + + + + `; case 'sun': return ` - - - - - + + + + + + + + + + + `; case 'moon': return ` - - - + + + + + + `; case 'generic1': return ` - - + + `; case 'generic2': return ` - - + + `; case 'generic3': return ` - - + + `; case 'generic4': return ` - - - + + + `; - default: // empty - return `Empty`; + default: + return ``; } } - escapeHtml(unsafe) { - if (!unsafe) return ''; - return unsafe - .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 = `StorySketch comic`; + 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 = `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(); -}); \ No newline at end of file +});