Spaces:
Sleeping
Sleeping
| /** | |
| * Interactive Comic Editor | |
| * Allows dragging speech bubbles and editing text | |
| */ | |
| class ComicEditor { | |
| constructor(containerId) { | |
| this.container = document.getElementById(containerId); | |
| this.bubbles = []; | |
| this.selectedBubble = null; | |
| this.isDragging = false; | |
| this.dragOffset = { x: 0, y: 0 }; | |
| this.isEditing = false; | |
| this.init(); | |
| } | |
| init() { | |
| // Add editor styles | |
| this.addStyles(); | |
| // Load comic data | |
| this.loadComicData(); | |
| // Setup event listeners | |
| this.setupEventListeners(); | |
| // Add toolbar | |
| this.createToolbar(); | |
| } | |
| addStyles() { | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| .comic-editor-container { | |
| position: relative; | |
| user-select: none; | |
| background: #f0f0f0; | |
| padding: 20px; | |
| border-radius: 10px; | |
| } | |
| .comic-page { | |
| position: relative; | |
| background: white; | |
| margin: 20px auto; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.1); | |
| width: 800px; /* exact width */ | |
| height: 1080px; /* exact height */ | |
| } | |
| .comic-panel { | |
| position: absolute; | |
| border: 2px solid #333; | |
| overflow: hidden; | |
| background: white; | |
| } | |
| .comic-panel img { | |
| width: 100%; | |
| height: 100%; | |
| object-fit: contain; | |
| background: #000; | |
| } | |
| .speech-bubble { | |
| position: absolute; | |
| background: white; | |
| border: 3px solid #333; | |
| border-radius: 20px; | |
| padding: 15px; | |
| cursor: move; | |
| min-width: 100px; | |
| min-height: 50px; | |
| box-shadow: 2px 2px 5px rgba(0,0,0,0.1); | |
| transition: transform 0.1s; | |
| z-index: 10; | |
| } | |
| .speech-bubble:hover { | |
| transform: scale(1.02); | |
| box-shadow: 4px 4px 10px rgba(0,0,0,0.2); | |
| } | |
| .speech-bubble.selected { | |
| border-color: #007bff; | |
| box-shadow: 0 0 0 3px rgba(0,123,255,0.3); | |
| z-index: 100; | |
| } | |
| .speech-bubble.dragging { | |
| opacity: 0.8; | |
| z-index: 1000; | |
| } | |
| .bubble-text { | |
| font-family: 'Comic Sans MS', cursive; | |
| font-size: 14px; | |
| font-weight: bold; | |
| text-align: center; | |
| line-height: 1.4; | |
| color: #000; | |
| word-wrap: break-word; | |
| cursor: text; | |
| } | |
| .bubble-text.editing { | |
| background: rgba(255,255,255,0.9); | |
| border: 1px dashed #007bff; | |
| padding: 5px; | |
| outline: none; | |
| } | |
| .bubble-tail { | |
| position: absolute; | |
| bottom: -15px; | |
| left: 20px; | |
| width: 0; | |
| height: 0; | |
| border-left: 15px solid transparent; | |
| border-right: 5px solid transparent; | |
| border-top: 20px solid #333; | |
| transform: rotate(-20deg); | |
| } | |
| .bubble-tail::after { | |
| content: ''; | |
| position: absolute; | |
| bottom: 3px; | |
| left: -12px; | |
| width: 0; | |
| height: 0; | |
| border-left: 12px solid transparent; | |
| border-right: 4px solid transparent; | |
| border-top: 16px solid white; | |
| } | |
| .editor-toolbar { | |
| position: fixed; | |
| top: 20px; | |
| right: 20px; | |
| background: white; | |
| border: 2px solid #333; | |
| border-radius: 10px; | |
| padding: 15px; | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.1); | |
| z-index: 1000; | |
| } | |
| .toolbar-btn { | |
| display: block; | |
| width: 100%; | |
| padding: 10px 15px; | |
| margin: 5px 0; | |
| background: #007bff; | |
| color: white; | |
| border: none; | |
| border-radius: 5px; | |
| cursor: pointer; | |
| font-weight: bold; | |
| transition: background 0.2s; | |
| } | |
| .toolbar-btn:hover { | |
| background: #0056b3; | |
| } | |
| .toolbar-btn.danger { | |
| background: #dc3545; | |
| } | |
| .toolbar-btn.danger:hover { | |
| background: #c82333; | |
| } | |
| .toolbar-btn.success { | |
| background: #28a745; | |
| } | |
| .toolbar-btn.success:hover { | |
| background: #218838; | |
| } | |
| .toolbar-btn.download { | |
| background: #ff66b3; /* pink */ | |
| color: white; | |
| } | |
| .toolbar-btn.download:hover { | |
| background: #ff4da6; | |
| } | |
| .resize-handle { | |
| position: absolute; | |
| width: 10px; | |
| height: 10px; | |
| background: #007bff; | |
| border: 1px solid white; | |
| border-radius: 50%; | |
| cursor: nwse-resize; | |
| } | |
| .resize-handle.se { | |
| bottom: -5px; | |
| right: -5px; | |
| } | |
| .coordinates { | |
| position: absolute; | |
| bottom: -25px; | |
| left: 0; | |
| font-size: 10px; | |
| color: #666; | |
| background: white; | |
| padding: 2px 5px; | |
| border-radius: 3px; | |
| display: none; | |
| } | |
| .selected .coordinates { | |
| display: block; | |
| } | |
| .edit-hint { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: #333; | |
| color: white; | |
| padding: 10px 20px; | |
| border-radius: 20px; | |
| font-size: 14px; | |
| z-index: 1000; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| } | |
| .edit-hint.show { | |
| opacity: 1; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| loadComicData() { | |
| // Load existing comic data or create new | |
| const savedData = localStorage.getItem('comicEditorData'); | |
| if (savedData) { | |
| const data = JSON.parse(savedData); | |
| this.renderComic(data); | |
| } else { | |
| // Load from server or create default | |
| this.loadFromServer(); | |
| } | |
| } | |
| loadFromServer() { | |
| // Load from server | |
| fetch('/load_comic') | |
| .then(response => response.json()) | |
| .then(data => { | |
| if (data.error) { | |
| console.error('Error loading comic:', data.error); | |
| this.createDefaultComic(); | |
| } else { | |
| this.renderComic(data); | |
| } | |
| }) | |
| .catch(error => { | |
| console.error('Failed to load comic:', error); | |
| this.createDefaultComic(); | |
| }); | |
| } | |
| createDefaultComic() { | |
| // Create a default comic if loading fails | |
| const sampleData = { | |
| pages: [{ | |
| width: 800, | |
| height: 600, | |
| panels: [ | |
| { | |
| x: 10, y: 10, width: 380, height: 280, | |
| image: '/frames/frame000.png' | |
| }, | |
| { | |
| x: 410, y: 10, width: 380, height: 280, | |
| image: '/frames/frame001.png' | |
| } | |
| ], | |
| bubbles: [ | |
| { | |
| id: 'bubble1', | |
| x: 50, y: 50, width: 150, height: 60, | |
| text: 'Add your text here!', | |
| panelIndex: 0 | |
| } | |
| ] | |
| }] | |
| }; | |
| this.renderComic(sampleData); | |
| } | |
| renderComic(data) { | |
| this.container.innerHTML = ''; | |
| this.container.className = 'comic-editor-container'; | |
| data.pages.forEach((page, pageIndex) => { | |
| const pageDiv = document.createElement('div'); | |
| pageDiv.className = 'comic-page'; | |
| pageDiv.style.width = page.width + 'px'; | |
| pageDiv.style.height = page.height + 'px'; | |
| pageDiv.dataset.pageIndex = pageIndex; | |
| // Render panels | |
| page.panels.forEach((panel, panelIndex) => { | |
| const panelDiv = document.createElement('div'); | |
| panelDiv.className = 'comic-panel'; | |
| panelDiv.style.left = panel.x + 'px'; | |
| panelDiv.style.top = panel.y + 'px'; | |
| panelDiv.style.width = panel.width + 'px'; | |
| panelDiv.style.height = panel.height + 'px'; | |
| panelDiv.dataset.panelIndex = panelIndex; | |
| const img = document.createElement('img'); | |
| img.src = panel.image; | |
| panelDiv.appendChild(img); | |
| pageDiv.appendChild(panelDiv); | |
| }); | |
| // Render bubbles | |
| page.bubbles.forEach(bubble => { | |
| this.createBubble(bubble, pageDiv); | |
| }); | |
| this.container.appendChild(pageDiv); | |
| }); | |
| } | |
| createBubble(bubbleData, pageDiv) { | |
| const bubble = document.createElement('div'); | |
| bubble.className = 'speech-bubble'; | |
| bubble.id = bubbleData.id || 'bubble_' + Date.now(); | |
| bubble.style.left = bubbleData.x + 'px'; | |
| bubble.style.top = bubbleData.y + 'px'; | |
| bubble.style.width = bubbleData.width + 'px'; | |
| bubble.style.height = bubbleData.height + 'px'; | |
| // Add text | |
| const text = document.createElement('div'); | |
| text.className = 'bubble-text'; | |
| text.textContent = bubbleData.text || 'Click to edit'; | |
| text.contentEditable = false; | |
| bubble.appendChild(text); | |
| // Add tail | |
| const tail = document.createElement('div'); | |
| tail.className = 'bubble-tail'; | |
| bubble.appendChild(tail); | |
| // Add resize handle | |
| const resizeHandle = document.createElement('div'); | |
| resizeHandle.className = 'resize-handle se'; | |
| bubble.appendChild(resizeHandle); | |
| // Add coordinates display | |
| const coords = document.createElement('div'); | |
| coords.className = 'coordinates'; | |
| bubble.appendChild(coords); | |
| // Store data | |
| bubble.dataset.bubbleData = JSON.stringify(bubbleData); | |
| pageDiv.appendChild(bubble); | |
| this.bubbles.push(bubble); | |
| // Setup bubble events | |
| this.setupBubbleEvents(bubble); | |
| } | |
| setupEventListeners() { | |
| // Document-wide mouse events | |
| document.addEventListener('mousemove', (e) => this.handleMouseMove(e)); | |
| document.addEventListener('mouseup', (e) => this.handleMouseUp(e)); | |
| // Keyboard shortcuts | |
| document.addEventListener('keydown', (e) => { | |
| if (e.key === 'Delete' && this.selectedBubble && !this.isEditing) { | |
| this.deleteBubble(this.selectedBubble); | |
| } | |
| if (e.key === 'Escape') { | |
| this.deselectBubble(); | |
| } | |
| }); | |
| // Click outside to deselect | |
| this.container.addEventListener('click', (e) => { | |
| if (e.target === this.container || e.target.classList.contains('comic-page')) { | |
| this.deselectBubble(); | |
| } | |
| }); | |
| } | |
| setupBubbleEvents(bubble) { | |
| const text = bubble.querySelector('.bubble-text'); | |
| const resizeHandle = bubble.querySelector('.resize-handle'); | |
| // Drag start | |
| bubble.addEventListener('mousedown', (e) => { | |
| if (e.target === text && this.isEditing) return; | |
| if (e.target === resizeHandle) return; | |
| this.startDragging(bubble, e); | |
| }); | |
| // Click to select | |
| bubble.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| this.selectBubble(bubble); | |
| }); | |
| // Double-click to edit text | |
| text.addEventListener('dblclick', (e) => { | |
| e.stopPropagation(); | |
| this.startEditingText(bubble, text); | |
| }); | |
| // Handle text editing | |
| text.addEventListener('blur', () => { | |
| if (this.isEditing) { | |
| this.stopEditingText(bubble, text); | |
| } | |
| }); | |
| text.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| text.blur(); | |
| } | |
| }); | |
| // Resize handle | |
| resizeHandle.addEventListener('mousedown', (e) => { | |
| e.stopPropagation(); | |
| this.startResizing(bubble, e); | |
| }); | |
| } | |
| startDragging(bubble, e) { | |
| this.isDragging = true; | |
| this.selectedBubble = bubble; | |
| bubble.classList.add('dragging'); | |
| const rect = bubble.getBoundingClientRect(); | |
| const containerRect = this.container.getBoundingClientRect(); | |
| this.dragOffset = { | |
| x: e.clientX - rect.left, | |
| y: e.clientY - rect.top | |
| }; | |
| this.selectBubble(bubble); | |
| } | |
| handleMouseMove(e) { | |
| if (!this.isDragging || !this.selectedBubble) return; | |
| const containerRect = this.container.getBoundingClientRect(); | |
| const pageRect = this.selectedBubble.parentElement.getBoundingClientRect(); | |
| let newX = e.clientX - pageRect.left - this.dragOffset.x; | |
| let newY = e.clientY - pageRect.top - this.dragOffset.y; | |
| // Constrain to page bounds | |
| const maxX = pageRect.width - this.selectedBubble.offsetWidth; | |
| const maxY = pageRect.height - this.selectedBubble.offsetHeight; | |
| newX = Math.max(0, Math.min(newX, maxX)); | |
| newY = Math.max(0, Math.min(newY, maxY)); | |
| this.selectedBubble.style.left = newX + 'px'; | |
| this.selectedBubble.style.top = newY + 'px'; | |
| this.updateCoordinates(this.selectedBubble); | |
| } | |
| handleMouseUp(e) { | |
| if (this.isDragging && this.selectedBubble) { | |
| this.selectedBubble.classList.remove('dragging'); | |
| this.isDragging = false; | |
| this.saveBubblePosition(this.selectedBubble); | |
| } | |
| } | |
| selectBubble(bubble) { | |
| // Deselect previous | |
| this.deselectBubble(); | |
| // Select new | |
| this.selectedBubble = bubble; | |
| bubble.classList.add('selected'); | |
| this.updateCoordinates(bubble); | |
| this.showHint('Double-click to edit text • Drag to move • Delete key to remove'); | |
| } | |
| deselectBubble() { | |
| if (this.selectedBubble) { | |
| this.selectedBubble.classList.remove('selected'); | |
| this.selectedBubble = null; | |
| } | |
| this.hideHint(); | |
| } | |
| startEditingText(bubble, textElement) { | |
| this.isEditing = true; | |
| textElement.contentEditable = true; | |
| textElement.classList.add('editing'); | |
| textElement.focus(); | |
| // Select all text | |
| const range = document.createRange(); | |
| range.selectNodeContents(textElement); | |
| const selection = window.getSelection(); | |
| selection.removeAllRanges(); | |
| selection.addRange(range); | |
| this.showHint('Press Enter to save • Shift+Enter for new line'); | |
| } | |
| stopEditingText(bubble, textElement) { | |
| this.isEditing = false; | |
| textElement.contentEditable = false; | |
| textElement.classList.remove('editing'); | |
| // Save the text | |
| this.saveBubbleText(bubble, textElement.textContent); | |
| this.hideHint(); | |
| } | |
| deleteBubble(bubble) { | |
| if (confirm('Delete this speech bubble?')) { | |
| bubble.remove(); | |
| const index = this.bubbles.indexOf(bubble); | |
| if (index > -1) { | |
| this.bubbles.splice(index, 1); | |
| } | |
| this.selectedBubble = null; | |
| this.saveComicData(); | |
| } | |
| } | |
| updateCoordinates(bubble) { | |
| const coords = bubble.querySelector('.coordinates'); | |
| coords.textContent = `x: ${parseInt(bubble.style.left)}, y: ${parseInt(bubble.style.top)}`; | |
| } | |
| createToolbar() { | |
| const toolbar = document.createElement('div'); | |
| toolbar.className = 'editor-toolbar'; | |
| // Add bubble button | |
| const addBtn = document.createElement('button'); | |
| addBtn.className = 'toolbar-btn'; | |
| addBtn.textContent = '➕ Add Bubble'; | |
| addBtn.onclick = () => this.addNewBubble(); | |
| toolbar.appendChild(addBtn); | |
| // Save button | |
| const saveBtn = document.createElement('button'); | |
| saveBtn.className = 'toolbar-btn success'; | |
| saveBtn.textContent = '💾 Save Comic'; | |
| saveBtn.onclick = () => this.saveComic(); | |
| toolbar.appendChild(saveBtn); | |
| // Export button | |
| const exportBtn = document.createElement('button'); | |
| exportBtn.className = 'toolbar-btn download'; | |
| exportBtn.textContent = '⬇️ Download'; | |
| exportBtn.onclick = () => this.downloadPages(); | |
| toolbar.appendChild(exportBtn); | |
| // Reset button | |
| const resetBtn = document.createElement('button'); | |
| resetBtn.className = 'toolbar-btn danger'; | |
| resetBtn.textContent = '🔄 Reset'; | |
| resetBtn.onclick = () => this.resetComic(); | |
| toolbar.appendChild(resetBtn); | |
| document.body.appendChild(toolbar); | |
| } | |
| addNewBubble() { | |
| const page = this.container.querySelector('.comic-page'); | |
| if (!page) return; | |
| const newBubble = { | |
| id: 'bubble_' + Date.now(), | |
| x: 100, | |
| y: 100, | |
| width: 150, | |
| height: 60, | |
| text: 'New bubble!' | |
| }; | |
| this.createBubble(newBubble, page); | |
| this.saveComicData(); | |
| } | |
| saveBubblePosition(bubble) { | |
| this.saveComicData(); | |
| } | |
| saveBubbleText(bubble, text) { | |
| const data = JSON.parse(bubble.dataset.bubbleData || '{}'); | |
| data.text = text; | |
| bubble.dataset.bubbleData = JSON.stringify(data); | |
| this.saveComicData(); | |
| } | |
| saveComicData() { | |
| const data = { | |
| pages: [] | |
| }; | |
| this.container.querySelectorAll('.comic-page').forEach(page => { | |
| const pageData = { | |
| width: parseInt(page.style.width), | |
| height: parseInt(page.style.height), | |
| panels: [], | |
| bubbles: [] | |
| }; | |
| // Save panel data | |
| page.querySelectorAll('.comic-panel').forEach(panel => { | |
| pageData.panels.push({ | |
| x: parseInt(panel.style.left), | |
| y: parseInt(panel.style.top), | |
| width: parseInt(panel.style.width), | |
| height: parseInt(panel.style.height), | |
| image: panel.querySelector('img').src | |
| }); | |
| }); | |
| // Save bubble data | |
| page.querySelectorAll('.speech-bubble').forEach(bubble => { | |
| pageData.bubbles.push({ | |
| id: bubble.id, | |
| x: parseInt(bubble.style.left), | |
| y: parseInt(bubble.style.top), | |
| width: parseInt(bubble.style.width), | |
| height: parseInt(bubble.style.height), | |
| text: bubble.querySelector('.bubble-text').textContent | |
| }); | |
| }); | |
| data.pages.push(pageData); | |
| }); | |
| localStorage.setItem('comicEditorData', JSON.stringify(data)); | |
| this.showHint('Comic saved!'); | |
| } | |
| saveComic() { | |
| this.saveComicData(); | |
| // Send to server | |
| fetch('/save_comic', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| }, | |
| body: JSON.stringify(this.getComicData()) | |
| }) | |
| .then(response => response.json()) | |
| .then(data => { | |
| this.showHint('Comic saved to server!'); | |
| }) | |
| .catch(error => { | |
| console.error('Error:', error); | |
| this.showHint('Error saving to server!'); | |
| }); | |
| } | |
| exportComic() { | |
| const data = this.getComicData(); | |
| const json = JSON.stringify(data, null, 2); | |
| // Create download | |
| const blob = new Blob([json], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'comic_data.json'; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| this.showHint('Comic exported!'); | |
| } | |
| resetComic() { | |
| if (confirm('Reset all changes? This cannot be undone!')) { | |
| localStorage.removeItem('comicEditorData'); | |
| this.loadFromServer(); | |
| this.showHint('Comic reset!'); | |
| } | |
| } | |
| getComicData() { | |
| return JSON.parse(localStorage.getItem('comicEditorData') || '{}'); | |
| } | |
| showHint(message) { | |
| let hint = document.querySelector('.edit-hint'); | |
| if (!hint) { | |
| hint = document.createElement('div'); | |
| hint.className = 'edit-hint'; | |
| document.body.appendChild(hint); | |
| } | |
| hint.textContent = message; | |
| hint.classList.add('show'); | |
| clearTimeout(this.hintTimeout); | |
| this.hintTimeout = setTimeout(() => { | |
| hint.classList.remove('show'); | |
| }, 3000); | |
| } | |
| hideHint() { | |
| const hint = document.querySelector('.edit-hint'); | |
| if (hint) { | |
| hint.classList.remove('show'); | |
| } | |
| } | |
| startResizing(bubble, e) { | |
| e.preventDefault(); | |
| const startX = e.clientX; | |
| const startY = e.clientY; | |
| const startWidth = parseInt(bubble.style.width); | |
| const startHeight = parseInt(bubble.style.height); | |
| const handleResize = (e) => { | |
| const newWidth = startWidth + (e.clientX - startX); | |
| const newHeight = startHeight + (e.clientY - startY); | |
| bubble.style.width = Math.max(100, newWidth) + 'px'; | |
| bubble.style.height = Math.max(50, newHeight) + 'px'; | |
| this.updateCoordinates(bubble); | |
| }; | |
| const stopResize = () => { | |
| document.removeEventListener('mousemove', handleResize); | |
| document.removeEventListener('mouseup', stopResize); | |
| this.saveComicData(); | |
| }; | |
| document.addEventListener('mousemove', handleResize); | |
| document.addEventListener('mouseup', stopResize); | |
| } | |
| /** Download each page as PNG using html2canvas */ | |
| downloadPages() { | |
| const pages = this.container.querySelectorAll('.comic-page'); | |
| if (!pages.length) return; | |
| pages.forEach((page, idx) => { | |
| html2canvas(page, {width: 800, height: 1080, scale: 2, useCORS: true, allowTaint: true}).then(canvas => { | |
| canvas.toBlob(blob => { | |
| const a = document.createElement('a'); | |
| a.download = `comic_page_${idx+1}.png`; | |
| a.href = URL.createObjectURL(blob); | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| }, 'image/png'); | |
| }); | |
| }); | |
| } | |
| } | |
| // Initialize when page loads | |
| document.addEventListener('DOMContentLoaded', () => { | |
| if (document.getElementById('comic-editor')) { | |
| window.comicEditor = new ComicEditor('comic-editor'); | |
| } | |
| }); |