Spaces:
Sleeping
Sleeping
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Carousel Maker Pro - 小红书轮播图神器</title> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script> | |
| <!-- Google Fonts --> | |
| <link href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&family=Noto+Sans+SC:wght@400;700;900&family=ZCOOL+KuaiLe&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Noto Sans SC', sans-serif; } | |
| /* Canvas Grid Pattern */ | |
| .slide-grid { | |
| background-image: linear-gradient(to right, rgba(0,0,0,0.1) 1px, transparent 1px); | |
| background-size: var(--slide-width) 100%; | |
| } | |
| /* Element Interaction */ | |
| .text-element { | |
| cursor: move; | |
| user-select: none; | |
| transition: outline 0.1s; | |
| } | |
| .text-element:hover { | |
| outline: 1px dashed #3b82f6; | |
| } | |
| .text-element.selected { | |
| outline: 2px solid #2563eb; | |
| z-index: 50; /* Bring selected to front visually */ | |
| } | |
| /* Content Editable Placeholder */ | |
| [contenteditable]:empty:before { | |
| content: attr(placeholder); | |
| color: rgba(0,0,0,0.3); | |
| } | |
| /* Scrollbar Styling */ | |
| .custom-scroll::-webkit-scrollbar { | |
| width: 6px; | |
| height: 6px; | |
| } | |
| .custom-scroll::-webkit-scrollbar-track { | |
| background: #f1f5f9; | |
| } | |
| .custom-scroll::-webkit-scrollbar-thumb { | |
| background: #cbd5e1; | |
| border-radius: 3px; | |
| } | |
| .custom-scroll::-webkit-scrollbar-thumb:hover { | |
| background: #94a3b8; | |
| } | |
| /* Loading Spinner */ | |
| .loader { | |
| border: 3px solid #f3f3f3; | |
| border-radius: 50%; | |
| border-top: 3px solid #ef4444; | |
| width: 24px; | |
| height: 24px; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 h-screen flex flex-col overflow-hidden text-slate-800"> | |
| <div id="app" class="flex flex-col h-full"> | |
| <!-- Header --> | |
| <header class="bg-white border-b z-20 px-6 py-3 flex justify-between items-center shadow-sm"> | |
| <div class="flex items-center gap-3"> | |
| <div class="bg-gradient-to-br from-red-500 to-pink-500 text-white p-2 rounded-lg shadow-md"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> | |
| </svg> | |
| </div> | |
| <div> | |
| <h1 class="text-xl font-black tracking-tight text-gray-800">Carousel Maker <span class="text-red-500">Pro</span></h1> | |
| <p class="text-xs text-gray-400">小红书/IG 无缝轮播图设计工具</p> | |
| </div> | |
| </div> | |
| <div class="flex gap-3"> | |
| <button @click="showPreview" class="px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg font-medium text-sm transition flex items-center gap-2"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /> | |
| </svg> | |
| 切片预览 | |
| </button> | |
| <button @click="exportImages" :disabled="isProcessing" class="px-6 py-2 bg-gray-900 hover:bg-black text-white rounded-lg font-medium text-sm transition flex items-center gap-2 shadow-lg hover:shadow-xl disabled:opacity-50"> | |
| <div v-if="isProcessing" class="loader border-t-white w-4 h-4"></div> | |
| <span v-else>导出 ZIP</span> | |
| </button> | |
| </div> | |
| </header> | |
| <div class="flex flex-1 overflow-hidden"> | |
| <!-- Left Sidebar: Settings --> | |
| <aside class="w-80 bg-white border-r flex flex-col overflow-hidden z-10"> | |
| <div class="p-5 overflow-y-auto custom-scroll space-y-6"> | |
| <!-- 0. Quick Actions & Themes --> | |
| <section> | |
| <div class="flex justify-between items-center mb-3"> | |
| <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider">快捷主题</h3> | |
| <button @click="resetCanvas" class="text-xs text-red-400 hover:text-red-600 underline">清空画布</button> | |
| </div> | |
| <div class="grid grid-cols-3 gap-2"> | |
| <button v-for="theme in themes" :key="theme.name" @click="applyTheme(theme)" class="border rounded-md p-2 text-center hover:border-red-400 hover:shadow-sm transition bg-white group"> | |
| <div class="w-full h-8 rounded mb-1 border" :style="{ background: theme.bg ? `url(${theme.bg}) center/cover` : theme.color }"></div> | |
| <span class="text-[10px] text-gray-600 group-hover:text-red-500">[[ theme.name ]]</span> | |
| </button> | |
| </div> | |
| </section> | |
| <!-- 1. Layout --> | |
| <section> | |
| <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">画布设置</h3> | |
| <div class="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label class="text-xs text-gray-500 mb-1 block">页数 (Slides)</label> | |
| <div class="flex items-center border rounded-md bg-gray-50"> | |
| <button @click="slideCount = Math.max(2, slideCount-1)" class="px-3 py-1 hover:bg-gray-200 text-gray-600">-</button> | |
| <input type="number" v-model="slideCount" readonly class="w-full bg-transparent text-center text-sm font-medium focus:outline-none"> | |
| <button @click="slideCount = Math.min(10, slideCount+1)" class="px-3 py-1 hover:bg-gray-200 text-gray-600">+</button> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="text-xs text-gray-500 mb-1 block">比例 (Ratio)</label> | |
| <select v-model="aspectRatio" class="w-full border rounded-md px-2 py-1.5 text-sm bg-white focus:ring-2 focus:ring-red-500 outline-none"> | |
| <option value="3:4">3:4 (小红书)</option> | |
| <option value="1:1">1:1 (INS)</option> | |
| <option value="9:16">9:16 (Story)</option> | |
| </select> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- 2. Background --> | |
| <section> | |
| <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">背景图片</h3> | |
| <div class="border-2 border-dashed border-gray-200 rounded-xl p-4 text-center hover:border-red-400 hover:bg-red-50 transition cursor-pointer relative group"> | |
| <input type="file" @change="handleImageUpload" class="absolute inset-0 opacity-0 cursor-pointer z-10" accept="image/*"> | |
| <div v-if="!bgImage" class="py-2"> | |
| <div class="mx-auto w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center text-gray-400 mb-2 group-hover:bg-white group-hover:text-red-500"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> | |
| </svg> | |
| </div> | |
| <p class="text-xs text-gray-500 font-medium">点击或拖拽上传背景</p> | |
| </div> | |
| <div v-else class="relative h-24 w-full rounded-lg overflow-hidden border"> | |
| <img :src="bgImage" class="w-full h-full object-cover"> | |
| <div class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition"> | |
| <span class="text-white text-xs font-bold">更换图片</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div v-if="bgImage" class="mt-3 grid grid-cols-2 gap-2"> | |
| <button @click="fitMode = 'cover'" :class="fitMode==='cover' ? 'bg-red-100 text-red-700 border-red-200' : 'bg-white text-gray-600 border-gray-200'" class="border rounded px-3 py-1.5 text-xs font-medium transition">填满 (Cover)</button> | |
| <button @click="fitMode = 'contain'" :class="fitMode==='contain' ? 'bg-red-100 text-red-700 border-red-200' : 'bg-white text-gray-600 border-gray-200'" class="border rounded px-3 py-1.5 text-xs font-medium transition">适应 (Contain)</button> | |
| </div> | |
| </section> | |
| <!-- 3. Add Elements --> | |
| <section> | |
| <h3 class="text-xs font-bold text-gray-400 uppercase tracking-wider mb-3">添加组件</h3> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <button @click="addText('主标题', 56, 900)" class="flex items-center justify-center gap-2 p-3 bg-white border border-gray-200 rounded-lg hover:shadow-md hover:border-red-300 transition group"> | |
| <span class="text-xl font-black text-gray-800 group-hover:text-red-600">T</span> | |
| <span class="text-xs font-medium text-gray-600">大标题</span> | |
| </button> | |
| <button @click="addText('正文内容', 28, 400)" class="flex items-center justify-center gap-2 p-3 bg-white border border-gray-200 rounded-lg hover:shadow-md hover:border-red-300 transition group"> | |
| <span class="text-sm font-normal text-gray-800 group-hover:text-red-600">t</span> | |
| <span class="text-xs font-medium text-gray-600">正文</span> | |
| </button> | |
| <button @click="addNumbering" class="col-span-2 flex items-center justify-center gap-2 p-2 bg-indigo-50 border border-indigo-100 rounded-lg hover:bg-indigo-100 text-indigo-700 transition"> | |
| <span class="text-xs font-bold">#</span> | |
| <span class="text-xs font-medium">自动页码 (1/[[slideCount]])</span> | |
| </button> | |
| </div> | |
| </section> | |
| <!-- 4. Element Style (Conditional) --> | |
| <section v-if="selectedElement" class="bg-gray-50 -mx-5 px-5 py-4 border-t border-b animate-fade-in"> | |
| <div class="flex justify-between items-center mb-3"> | |
| <h3 class="text-xs font-bold text-gray-800">样式编辑</h3> | |
| <button @click="deleteSelected" class="text-red-500 hover:text-red-700 text-xs font-medium flex items-center gap-1"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /></svg> | |
| 删除 | |
| </button> | |
| </div> | |
| <div class="space-y-3"> | |
| <!-- Font & Size --> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <div> | |
| <label class="text-[10px] text-gray-400 mb-1 block">字体</label> | |
| <select v-model="selectedElement.fontFamily" class="w-full text-xs border rounded p-1.5"> | |
| <option value="'Noto Sans SC', sans-serif">标准黑体</option> | |
| <option value="'Ma Shan Zheng', cursive">毛笔书法</option> | |
| <option value="'ZCOOL KuaiLe', cursive">快乐体</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="text-[10px] text-gray-400 mb-1 block">字号 (px)</label> | |
| <input type="number" v-model="selectedElement.fontSize" class="w-full text-xs border rounded p-1.5"> | |
| </div> | |
| </div> | |
| <!-- Color & Bg --> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <div> | |
| <label class="text-[10px] text-gray-400 mb-1 block">文字颜色</label> | |
| <div class="flex items-center gap-2 border rounded p-1 bg-white"> | |
| <input type="color" v-model="selectedElement.color" class="w-6 h-6 border-none rounded cursor-pointer"> | |
| <span class="text-xs text-gray-500">[[selectedElement.color]]</span> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="text-[10px] text-gray-400 mb-1 block">背景颜色</label> | |
| <div class="flex items-center gap-2 border rounded p-1 bg-white"> | |
| <input type="checkbox" v-model="selectedElement.hasBg" class="rounded text-red-500 focus:ring-red-500"> | |
| <input type="color" v-model="selectedElement.bgColor" :disabled="!selectedElement.hasBg" class="w-6 h-6 border-none rounded cursor-pointer disabled:opacity-30"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Alignment --> | |
| <div> | |
| <label class="text-[10px] text-gray-400 mb-1 block">对齐方式</label> | |
| <div class="flex border rounded overflow-hidden bg-white"> | |
| <button @click="selectedElement.textAlign = 'left'" :class="{'bg-gray-200': selectedElement.textAlign === 'left'}" class="flex-1 py-1 hover:bg-gray-100 flex justify-center"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h10M4 18h16"></path></svg></button> | |
| <button @click="selectedElement.textAlign = 'center'" :class="{'bg-gray-200': selectedElement.textAlign === 'center'}" class="flex-1 py-1 hover:bg-gray-100 flex justify-center"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path></svg></button> | |
| <button @click="selectedElement.textAlign = 'right'" :class="{'bg-gray-200': selectedElement.textAlign === 'right'}" class="flex-1 py-1 hover:bg-gray-100 flex justify-center"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M10 12h10M4 18h16"></path></svg></button> | |
| </div> | |
| </div> | |
| <!-- Effects --> | |
| <div class="flex items-center gap-2"> | |
| <label class="flex items-center gap-2 text-xs text-gray-600 cursor-pointer"> | |
| <input type="checkbox" v-model="selectedElement.hasShadow" class="rounded text-red-500"> | |
| 文字阴影 | |
| </label> | |
| <label class="flex items-center gap-2 text-xs text-gray-600 cursor-pointer ml-4"> | |
| <input type="checkbox" v-model="selectedElement.isBold" class="rounded text-red-500"> | |
| 加粗 | |
| </label> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| <!-- Footer Info --> | |
| <div class="p-4 border-t bg-gray-50 text-[10px] text-gray-400 text-center"> | |
| Carousel Maker Pro v1.1 | |
| </div> | |
| </aside> | |
| <!-- Main Workspace --> | |
| <main class="flex-1 bg-gray-200 overflow-auto custom-scroll relative flex items-center justify-center p-12" @mousedown.self="selectedId = null"> | |
| <!-- The Canvas Area --> | |
| <div id="canvas-container" | |
| class="bg-white shadow-2xl relative transition-all duration-300 select-none" | |
| :style="containerStyle" | |
| @click.self="selectedId = null"> | |
| <!-- Background Layer --> | |
| <div class="absolute inset-0 overflow-hidden pointer-events-none" :style="{ background: canvasBgColor }"> | |
| <img v-if="bgImage" :src="bgImage" | |
| class="w-full h-full" | |
| :class="fitMode === 'cover' ? 'object-cover' : 'object-contain'"> | |
| <!-- Placeholder Text (Only if no bgImage AND white/transparent bg) --> | |
| <div v-if="!bgImage && canvasBgColor === '#ffffff'" class="w-full h-full flex items-center justify-center opacity-30"> | |
| <div class="text-center"> | |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 mx-auto mb-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /> | |
| </svg> | |
| <p class="font-bold text-xl text-gray-300">Drop Background Here</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Grid & Guidelines Layer --> | |
| <div class="absolute inset-0 slide-grid pointer-events-none z-10" | |
| :style="{'--slide-width': singleSlideWidth + 'px'}"> | |
| <!-- Page Labels --> | |
| <div class="absolute -top-6 left-0 w-full flex text-xs text-gray-500 font-mono"> | |
| <div v-for="n in slideCount" :key="n" class="flex-1 text-center relative"> | |
| <span class="bg-gray-200 px-2 py-0.5 rounded-full">Page [[ n ]]</span> | |
| <!-- Vertical Dashed Line Marker --> | |
| <div class="absolute top-6 bottom-[-600px] right-0 border-r border-dashed border-gray-400 opacity-30 h-[1000px] pointer-events-none" v-if="n < slideCount"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Elements Layer --> | |
| <div class="absolute inset-0 z-20 overflow-hidden"> | |
| <div v-for="el in elements" | |
| :key="el.id" | |
| class="text-element absolute whitespace-nowrap p-2 rounded" | |
| :class="{ 'selected': selectedId === el.id }" | |
| :style="getElementStyle(el)" | |
| @mousedown="startDrag($event, el)" | |
| @click.stop="selectElement(el)"> | |
| <div contenteditable="true" | |
| @input="updateText(el, $event)" | |
| @blur="cleanupText(el)" | |
| class="outline-none min-w-[20px]" | |
| :style="{ textAlign: el.textAlign }" | |
| v-html="el.text"> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <!-- Preview Modal --> | |
| <div v-if="showPreviewModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-10" @click.self="showPreviewModal = false"> | |
| <div class="bg-white rounded-xl w-full max-w-6xl max-h-full flex flex-col overflow-hidden"> | |
| <div class="p-4 border-b flex justify-between items-center bg-gray-50"> | |
| <h3 class="font-bold text-gray-800">切片预览 (Gap Simulation)</h3> | |
| <button @click="showPreviewModal = false" class="text-gray-500 hover:text-gray-800 text-2xl leading-none">×</button> | |
| </div> | |
| <div class="p-8 bg-gray-200 overflow-x-auto flex-1 custom-scroll flex items-center justify-center"> | |
| <div class="flex gap-0.5 mx-auto w-fit border-4 border-white shadow-2xl bg-white"> | |
| <div v-for="(img, idx) in previewImages" :key="idx" class="relative group"> | |
| <img :src="img" class="h-[50vh] min-h-[300px] object-contain block"> | |
| <span class="absolute bottom-2 right-2 bg-black/50 text-white text-[10px] px-1.5 py-0.5 rounded opacity-0 group-hover:opacity-100 transition">[[idx+1]]</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="p-4 border-t flex justify-end gap-3 bg-white"> | |
| <button @click="showPreviewModal = false" class="px-4 py-2 text-gray-600 font-medium">关闭</button> | |
| <button @click="downloadFromPreview" class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg font-medium shadow-md">下载所有切片</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Processing Overlay --> | |
| <div v-if="isProcessing" class="fixed inset-0 z-[60] flex flex-col items-center justify-center bg-white/80 backdrop-blur-md"> | |
| <div class="loader mb-4 border-t-red-600 w-12 h-12 border-4"></div> | |
| <p class="text-gray-800 font-bold text-lg animate-pulse">正在高清渲染切片...</p> | |
| <p class="text-gray-500 text-sm mt-2">Generating [[ slideCount ]] slides at [[ aspectRatio ]]</p> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, computed, onMounted } = Vue; | |
| createApp({ | |
| delimiters: ['[[', ']]'], | |
| setup() { | |
| // State | |
| const slideCount = ref(4); | |
| const aspectRatio = ref('3:4'); | |
| const bgImage = ref('/static/default_bg.png'); // Default to the generated background | |
| const canvasBgColor = ref('#ffffff'); // New: Solid background color | |
| const fitMode = ref('cover'); | |
| const elements = ref([]); | |
| const selectedId = ref(null); | |
| const isProcessing = ref(false); | |
| const showPreviewModal = ref(false); | |
| const previewImages = ref([]); | |
| // Constants | |
| const BASE_HEIGHT = 600; | |
| const ratioMap = { | |
| '3:4': 3/4, | |
| '1:1': 1, | |
| '9:16': 9/16 | |
| }; | |
| const themes = [ | |
| { name: '默认渐变', bg: '/static/default_bg.png', color: null, textColor: '#ffffff' }, | |
| { name: '极简白', bg: null, color: '#ffffff', textColor: '#333333' }, | |
| { name: '暗黑风', bg: null, color: '#1a1a1a', textColor: '#f0f0f0' }, | |
| { name: '复古暖', bg: null, color: '#fdf6e3', textColor: '#5d513c' }, | |
| { name: '活力橙', bg: null, color: '#fff7ed', textColor: '#c2410c' } | |
| ]; | |
| // Computed | |
| const singleSlideWidth = computed(() => BASE_HEIGHT * ratioMap[aspectRatio.value]); | |
| const totalWidth = computed(() => singleSlideWidth.value * slideCount.value); | |
| const containerStyle = computed(() => ({ | |
| width: totalWidth.value + 'px', | |
| height: BASE_HEIGHT + 'px' | |
| })); | |
| const selectedElement = computed(() => elements.value.find(e => e.id === selectedId.value)); | |
| // Methods | |
| const applyTheme = (theme) => { | |
| bgImage.value = theme.bg; | |
| if (theme.color) canvasBgColor.value = theme.color; | |
| // Update all text elements color | |
| elements.value.forEach(el => { | |
| el.color = theme.textColor; | |
| }); | |
| }; | |
| const resetCanvas = () => { | |
| if(confirm('确定要清空画布吗?')) { | |
| elements.value = []; | |
| bgImage.value = null; | |
| canvasBgColor.value = '#ffffff'; | |
| } | |
| }; | |
| const initDefaultData = () => { | |
| // Title spanning slide 1 and 2 | |
| elements.value.push({ | |
| id: 'title-1', | |
| text: '如何制作<br>无缝轮播图', | |
| x: singleSlideWidth.value, // Exactly on the first divider | |
| y: BASE_HEIGHT * 0.4, | |
| fontSize: 64, | |
| fontWeight: 900, | |
| color: '#ffffff', | |
| fontFamily: "'Noto Sans SC', sans-serif", | |
| textAlign: 'center', | |
| hasBg: false, | |
| bgColor: '#ffffff', | |
| hasShadow: true, | |
| isBold: true | |
| }); | |
| // Subtitle | |
| elements.value.push({ | |
| id: 'sub-1', | |
| text: 'Carousel Maker Pro', | |
| x: singleSlideWidth.value, | |
| y: BASE_HEIGHT * 0.55, | |
| fontSize: 24, | |
| fontWeight: 400, | |
| color: '#ffffff', | |
| fontFamily: "'ZCOOL KuaiLe', cursive", | |
| textAlign: 'center', | |
| hasBg: true, | |
| bgColor: 'rgba(255,255,255,0.2)', | |
| hasShadow: true, | |
| isBold: false | |
| }); | |
| addNumbering(); | |
| }; | |
| const handleImageUpload = (e) => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => bgImage.value = e.target.result; | |
| reader.readAsDataURL(file); | |
| }; | |
| const addText = (text, size, weight) => { | |
| const id = Date.now(); | |
| // Place in center of current view or center of slide 1 | |
| elements.value.push({ | |
| id, | |
| text, | |
| x: singleSlideWidth.value / 2, | |
| y: BASE_HEIGHT / 2, | |
| fontSize: size, | |
| fontWeight: weight, | |
| color: '#000000', | |
| fontFamily: "'Noto Sans SC', sans-serif", | |
| textAlign: 'center', | |
| hasBg: false, | |
| bgColor: '#ffffff', | |
| hasShadow: false, | |
| isBold: weight > 600 | |
| }); | |
| selectedId.value = id; | |
| }; | |
| const addNumbering = () => { | |
| elements.value = elements.value.filter(e => !e.isPageNumber); | |
| for(let i=0; i < slideCount.value; i++) { | |
| elements.value.push({ | |
| id: 'pg-' + i, | |
| text: `${i+1} / ${slideCount.value}`, | |
| x: (i * singleSlideWidth.value) + (singleSlideWidth.value / 2), | |
| y: BASE_HEIGHT - 40, | |
| fontSize: 14, | |
| fontWeight: 400, | |
| color: '#9ca3af', | |
| fontFamily: "'Noto Sans SC', sans-serif", | |
| textAlign: 'center', | |
| hasBg: false, | |
| bgColor: '#000000', | |
| hasShadow: false, | |
| isBold: false, | |
| isPageNumber: true | |
| }); | |
| } | |
| }; | |
| const selectElement = (el) => selectedId.value = el.id; | |
| const deleteSelected = () => { | |
| if (selectedId.value) { | |
| elements.value = elements.value.filter(e => e.id !== selectedId.value); | |
| selectedId.value = null; | |
| } | |
| }; | |
| const getElementStyle = (el) => ({ | |
| left: el.x + 'px', | |
| top: el.y + 'px', | |
| color: el.color, | |
| fontSize: el.fontSize + 'px', | |
| fontWeight: el.isBold ? 'bold' : 'normal', | |
| fontFamily: el.fontFamily, | |
| backgroundColor: el.hasBg ? el.bgColor : 'transparent', | |
| textShadow: el.hasShadow ? '2px 2px 4px rgba(0,0,0,0.3)' : 'none', | |
| transform: 'translate(-50%, -50%)', | |
| zIndex: selectedId.value === el.id ? 50 : 20 | |
| }); | |
| const updateText = (el, event) => el.text = event.target.innerHTML; | |
| const cleanupText = (el) => { if(!el.text) el.text = "Double click"; }; | |
| // Drag Logic | |
| let dragOffset = { x: 0, y: 0 }; | |
| let isDragging = false; | |
| let activeEl = null; | |
| const startDrag = (e, el) => { | |
| if (e.target.isContentEditable) return; | |
| isDragging = true; | |
| activeEl = el; | |
| dragOffset.x = e.clientX - el.x; | |
| dragOffset.y = e.clientY - el.y; | |
| document.addEventListener('mousemove', onDrag); | |
| document.addEventListener('mouseup', stopDrag); | |
| }; | |
| const onDrag = (e) => { | |
| if (!isDragging || !activeEl) return; | |
| activeEl.x = e.clientX - dragOffset.x; | |
| activeEl.y = e.clientY - dragOffset.y; | |
| }; | |
| const stopDrag = () => { | |
| isDragging = false; | |
| activeEl = null; | |
| document.removeEventListener('mousemove', onDrag); | |
| document.removeEventListener('mouseup', stopDrag); | |
| }; | |
| // Generate Images Logic | |
| const generateSlices = async () => { | |
| selectedId.value = null; | |
| isProcessing.value = true; | |
| await new Promise(r => setTimeout(r, 100)); // Render cycle | |
| const originalContainer = document.getElementById('canvas-container'); | |
| // CLONE STRATEGY: Clone the container and append to body to avoid scroll clipping | |
| const clone = originalContainer.cloneNode(true); | |
| // Set styles to ensure full visibility and correct layout | |
| clone.style.position = 'fixed'; | |
| clone.style.top = '0'; | |
| clone.style.left = '0'; | |
| clone.style.width = totalWidth.value + 'px'; | |
| clone.style.height = BASE_HEIGHT + 'px'; | |
| clone.style.zIndex = '-9999'; | |
| clone.style.overflow = 'visible'; | |
| clone.style.transform = 'none'; | |
| document.body.appendChild(clone); | |
| const scaleFactor = 1080 / singleSlideWidth.value; | |
| try { | |
| const canvas = await html2canvas(clone, { | |
| scale: scaleFactor, | |
| useCORS: true, | |
| backgroundColor: null, | |
| logging: false, | |
| width: totalWidth.value, | |
| height: BASE_HEIGHT, | |
| windowWidth: document.documentElement.scrollWidth, | |
| windowHeight: document.documentElement.scrollHeight | |
| }); | |
| const slices = []; | |
| const slideW = 1080; | |
| const slideH = slideW / ratioMap[aspectRatio.value]; | |
| for (let i = 0; i < slideCount.value; i++) { | |
| const sliceCanvas = document.createElement('canvas'); | |
| sliceCanvas.width = slideW; | |
| sliceCanvas.height = slideH; | |
| const ctx = sliceCanvas.getContext('2d'); | |
| ctx.drawImage(canvas, i * slideW, 0, slideW, slideH, 0, 0, slideW, slideH); | |
| const dataUrl = sliceCanvas.toDataURL('image/png'); | |
| slices.push({ blob: null, dataUrl }); | |
| } | |
| return slices; | |
| } catch (e) { | |
| console.error(e); | |
| alert("生成失败,请重试"); | |
| return []; | |
| } finally { | |
| if (document.body.contains(clone)) { | |
| document.body.removeChild(clone); | |
| } | |
| isProcessing.value = false; | |
| } | |
| }; | |
| const showPreview = async () => { | |
| const slices = await generateSlices(); | |
| if (slices.length) { | |
| previewImages.value = slices.map(s => s.dataUrl); | |
| showPreviewModal.value = true; | |
| } | |
| }; | |
| const downloadFromPreview = async () => { | |
| const zip = new JSZip(); | |
| // Convert DataURLs to Blobs for zip | |
| previewImages.value.forEach((dataUrl, i) => { | |
| const base64 = dataUrl.split(',')[1]; | |
| zip.file(`slide_${i+1}.png`, base64, {base64: true}); | |
| }); | |
| const content = await zip.generateAsync({type:"blob"}); | |
| saveAs(content, "carousel_slides.zip"); | |
| }; | |
| const exportImages = async () => { | |
| const slices = await generateSlices(); | |
| if (slices.length) { | |
| previewImages.value = slices.map(s => s.dataUrl); | |
| downloadFromPreview(); // Direct download | |
| } | |
| }; | |
| onMounted(() => { | |
| initDefaultData(); | |
| }); | |
| return { | |
| slideCount, aspectRatio, bgImage, canvasBgColor, fitMode, elements, selectedElement, selectedId, | |
| isProcessing, showPreviewModal, previewImages, themes, | |
| singleSlideWidth, totalWidth, containerStyle, | |
| handleImageUpload, addText, addNumbering, selectElement, updateText, cleanupText, deleteSelected, | |
| getElementStyle, startDrag, showPreview, exportImages, downloadFromPreview, applyTheme, resetCanvas | |
| }; | |
| } | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |