| |
| |
| |
| |
|
|
| 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() { |
| |
| this.addStyles(); |
| |
| |
| this.loadComicData(); |
| |
| |
| this.setupEventListeners(); |
| |
| |
| 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() { |
| |
| const savedData = localStorage.getItem('comicEditorData'); |
| if (savedData) { |
| const data = JSON.parse(savedData); |
| this.renderComic(data); |
| } else { |
| |
| this.loadFromServer(); |
| } |
| } |
| |
| loadFromServer() { |
| |
| 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() { |
| |
| 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; |
| |
| |
| 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); |
| }); |
| |
| |
| 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'; |
| |
| |
| const text = document.createElement('div'); |
| text.className = 'bubble-text'; |
| text.textContent = bubbleData.text || 'Click to edit'; |
| text.contentEditable = false; |
| bubble.appendChild(text); |
| |
| |
| const tail = document.createElement('div'); |
| tail.className = 'bubble-tail'; |
| bubble.appendChild(tail); |
| |
| |
| const resizeHandle = document.createElement('div'); |
| resizeHandle.className = 'resize-handle se'; |
| bubble.appendChild(resizeHandle); |
| |
| |
| const coords = document.createElement('div'); |
| coords.className = 'coordinates'; |
| bubble.appendChild(coords); |
| |
| |
| bubble.dataset.bubbleData = JSON.stringify(bubbleData); |
| |
| pageDiv.appendChild(bubble); |
| this.bubbles.push(bubble); |
| |
| |
| this.setupBubbleEvents(bubble); |
| } |
| |
| setupEventListeners() { |
| |
| document.addEventListener('mousemove', (e) => this.handleMouseMove(e)); |
| document.addEventListener('mouseup', (e) => this.handleMouseUp(e)); |
| |
| |
| document.addEventListener('keydown', (e) => { |
| if (e.key === 'Delete' && this.selectedBubble && !this.isEditing) { |
| this.deleteBubble(this.selectedBubble); |
| } |
| if (e.key === 'Escape') { |
| this.deselectBubble(); |
| } |
| }); |
| |
| |
| 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'); |
| |
| |
| bubble.addEventListener('mousedown', (e) => { |
| if (e.target === text && this.isEditing) return; |
| if (e.target === resizeHandle) return; |
| |
| this.startDragging(bubble, e); |
| }); |
| |
| |
| bubble.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| this.selectBubble(bubble); |
| }); |
| |
| |
| text.addEventListener('dblclick', (e) => { |
| e.stopPropagation(); |
| this.startEditingText(bubble, text); |
| }); |
| |
| |
| text.addEventListener('blur', () => { |
| if (this.isEditing) { |
| this.stopEditingText(bubble, text); |
| } |
| }); |
| |
| text.addEventListener('keydown', (e) => { |
| if (e.key === 'Enter' && !e.shiftKey) { |
| e.preventDefault(); |
| text.blur(); |
| } |
| }); |
| |
| |
| 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; |
| |
| |
| 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) { |
| |
| this.deselectBubble(); |
| |
| |
| 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(); |
| |
| |
| 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'); |
| |
| |
| 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'; |
| |
| |
| const addBtn = document.createElement('button'); |
| addBtn.className = 'toolbar-btn'; |
| addBtn.textContent = '➕ Add Bubble'; |
| addBtn.onclick = () => this.addNewBubble(); |
| toolbar.appendChild(addBtn); |
| |
| |
| const saveBtn = document.createElement('button'); |
| saveBtn.className = 'toolbar-btn success'; |
| saveBtn.textContent = '💾 Save Comic'; |
| saveBtn.onclick = () => this.saveComic(); |
| toolbar.appendChild(saveBtn); |
| |
| |
| const exportBtn = document.createElement('button'); |
| exportBtn.className = 'toolbar-btn download'; |
| exportBtn.textContent = '⬇️ Download'; |
| exportBtn.onclick = () => this.downloadPages(); |
| toolbar.appendChild(exportBtn); |
| |
| |
| 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: [] |
| }; |
| |
| |
| 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 |
| }); |
| }); |
| |
| |
| 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(); |
| |
| |
| 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); |
| |
| |
| 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); |
| } |
|
|
| |
| 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'); |
| }); |
| }); |
| } |
| } |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| if (document.getElementById('comic-editor')) { |
| window.comicEditor = new ComicEditor('comic-editor'); |
| } |
| }); |