duqing2026 commited on
Commit
76257a0
·
0 Parent(s):

Initial commit: Carousel Maker Pro

Browse files
Files changed (5) hide show
  1. Dockerfile +18 -0
  2. README.md +58 -0
  3. app.py +15 -0
  4. requirements.txt +3 -0
  5. templates/index.html +448 -0
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ RUN useradd -m -u 1000 user
11
+ USER user
12
+
13
+ ENV HOME=/home/user \
14
+ PATH=/home/user/.local/bin:$PATH
15
+
16
+ EXPOSE 7860
17
+
18
+ CMD ["gunicorn", "-b", "0.0.0.0:7860", "app:app"]
README.md ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Carousel Maker Pro
3
+ emoji: 🎞️
4
+ colorFrom: red
5
+ colorTo: pink
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ short_description: 小红书/IG 轮播图制作神器
10
+ ---
11
+
12
+ # 自媒体轮播图制作神器 (Carousel Maker Pro)
13
+
14
+ 这是一个专为小红书、Instagram 创作者设计的 **无缝轮播图制作工具**。你可以轻松制作跨页拼接的长图,添加跨页文字,并一键切片导出。
15
+
16
+ ## 核心功能
17
+
18
+ * **无缝拼接背景**:上传一张长图或背景图,自动填充到所有页面,实现完美的滑动视觉效果。
19
+ * **跨页文字排版**:支持在页面之间自由拖拽文字,文字可以横跨两张图片(小红书流行的“破格”设计)。
20
+ * **多尺寸支持**:
21
+ * **3:4 (1080x1350)**:小红书/Instagram 标准尺寸。
22
+ * **1:1 (1080x1080)**:朋友圈/INS 正方形。
23
+ * **9:16 (1080x1920)**:Story/抖音图文。
24
+ * **一键切片导出**:前端自动高清渲染(基于 HTML2Canvas),一键生成分片后的 ZIP 包。
25
+ * **自动页码**:一键添加 "1/4", "2/4" 等样式统一的页码。
26
+ * **隐私安全**:纯前端渲染,图片不上传服务器,保护你的素材安全。
27
+
28
+ ## 为什么做这个?
29
+
30
+ 市面上的切图工具(Grid Splitter)大多只能切图,无法在切图前进行整体排版(加字、加遮罩)。而专业设计软件(PS/Figma)对普通创作者门槛较高。本项目填补了这一空白,让普通人也能做出“设计感”极强的连页轮播图。
31
+
32
+ ## 技术栈
33
+
34
+ * **Backend**: Flask (Python) - 提供静态资源服务。
35
+ * **Frontend**: Vue 3 (Composition API) + Tailwind CSS。
36
+ * **Core**:
37
+ * `html2canvas`: 负责将 DOM 渲染为图片。
38
+ * `JSZip`: 负责前端打包下载。
39
+ * **Deployment**: Docker (User 1000) for Hugging Face Spaces.
40
+
41
+ ## 本地运行
42
+
43
+ 1. 安装依赖:
44
+ ```bash
45
+ pip install -r requirements.txt
46
+ ```
47
+ 2. 运行应用:
48
+ ```bash
49
+ python app.py
50
+ ```
51
+ 3. 访问: `http://localhost:7860`
52
+
53
+ ## 部署到 Hugging Face Spaces
54
+
55
+ 本项目包含 Dockerfile,可直接部署。
56
+ 1. 新建 Space,SDK 选择 Docker。
57
+ 2. 上传代码。
58
+ 3. 等待构建完成即可使用。
app.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from flask import Flask, render_template, send_from_directory
3
+
4
+ app = Flask(__name__, static_folder='static', template_folder='templates')
5
+
6
+ @app.route('/')
7
+ def index():
8
+ return render_template('index.html')
9
+
10
+ @app.route('/static/<path:path>')
11
+ def serve_static(path):
12
+ return send_from_directory('static', path)
13
+
14
+ if __name__ == '__main__':
15
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ Flask==3.0.0
2
+ Pillow==10.1.0
3
+ gunicorn==21.2.0
templates/index.html ADDED
@@ -0,0 +1,448 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>小红书轮播图制作神器 | Carousel Maker Pro</title>
7
+ <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
10
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
11
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js"></script>
12
+ <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap" rel="stylesheet">
13
+ <style>
14
+ body { font-family: 'Noto Sans SC', sans-serif; }
15
+ .slide-grid {
16
+ background-image:
17
+ linear-gradient(to right, rgba(255,255,255,0.3) 1px, transparent 1px);
18
+ background-size: var(--slide-width) 100%;
19
+ }
20
+ .text-element {
21
+ cursor: move;
22
+ user-select: none;
23
+ }
24
+ .text-element:hover {
25
+ outline: 2px dashed #3b82f6;
26
+ }
27
+ .text-element.selected {
28
+ outline: 2px solid #2563eb;
29
+ }
30
+ [contenteditable]:empty:before {
31
+ content: attr(placeholder);
32
+ color: #aaa;
33
+ }
34
+ /* Hide scrollbar for workspace */
35
+ .workspace-scroll::-webkit-scrollbar {
36
+ height: 8px;
37
+ }
38
+ .workspace-scroll::-webkit-scrollbar-thumb {
39
+ background: #cbd5e1;
40
+ border-radius: 4px;
41
+ }
42
+ </style>
43
+ </head>
44
+ <body class="bg-gray-100 h-screen flex flex-col overflow-hidden">
45
+ <div id="app" class="flex flex-col h-full">
46
+ <!-- Header -->
47
+ <header class="bg-white shadow-sm z-10 p-4 flex justify-between items-center">
48
+ <div class="flex items-center gap-3">
49
+ <div class="bg-red-500 text-white p-2 rounded-lg font-bold text-xl">
50
+ CM
51
+ </div>
52
+ <h1 class="text-xl font-bold text-gray-800">Carousel Maker Pro <span class="text-xs bg-red-100 text-red-600 px-2 py-1 rounded ml-2">小红书神器</span></h1>
53
+ </div>
54
+ <div class="flex gap-4">
55
+ <button @click="exportImages" class="bg-red-500 hover:bg-red-600 text-white px-6 py-2 rounded-full font-medium flex items-center gap-2 transition-colors">
56
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
57
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
58
+ </svg>
59
+ 导出 ZIP
60
+ </button>
61
+ </div>
62
+ </header>
63
+
64
+ <div class="flex flex-1 overflow-hidden">
65
+ <!-- Sidebar Controls -->
66
+ <aside class="w-80 bg-white border-r p-6 flex flex-col gap-6 overflow-y-auto">
67
+
68
+ <!-- Layout Settings -->
69
+ <div class="space-y-3">
70
+ <h3 class="font-bold text-gray-700 flex items-center gap-2">
71
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"></path></svg>
72
+ 布局设置
73
+ </h3>
74
+ <div class="grid grid-cols-2 gap-2">
75
+ <div>
76
+ <label class="text-xs text-gray-500 mb-1 block">页数</label>
77
+ <input type="number" v-model="slideCount" min="2" max="10" class="w-full border rounded px-3 py-2 text-sm">
78
+ </div>
79
+ <div>
80
+ <label class="text-xs text-gray-500 mb-1 block">比例</label>
81
+ <select v-model="aspectRatio" class="w-full border rounded px-3 py-2 text-sm">
82
+ <option value="3:4">3:4 (小红书)</option>
83
+ <option value="1:1">1:1 (INS/朋友圈)</option>
84
+ <option value="9:16">9:16 (Story)</option>
85
+ </select>
86
+ </div>
87
+ </div>
88
+ </div>
89
+
90
+ <!-- Background -->
91
+ <div class="space-y-3">
92
+ <h3 class="font-bold text-gray-700 flex items-center gap-2">
93
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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"></path></svg>
94
+ 背景图片
95
+ </h3>
96
+ <div class="border-2 border-dashed border-gray-300 rounded-lg p-4 text-center hover:bg-gray-50 transition-colors cursor-pointer relative">
97
+ <input type="file" @change="handleImageUpload" class="absolute inset-0 opacity-0 cursor-pointer" accept="image/*">
98
+ <p class="text-sm text-gray-500" v-if="!bgImage">点击上传背景图</p>
99
+ <div v-else class="relative h-20 w-full">
100
+ <img :src="bgImage" class="h-full w-full object-cover rounded">
101
+ <button @click.stop="bgImage = null" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full p-1 text-xs shadow">✕</button>
102
+ </div>
103
+ </div>
104
+ <div class="flex gap-2 text-xs">
105
+ <button @click="fitMode = 'cover'" :class="{'bg-blue-100 text-blue-600': fitMode==='cover'}" class="flex-1 border rounded py-1 hover:bg-gray-50">填满 Cover</button>
106
+ <button @click="fitMode = 'contain'" :class="{'bg-blue-100 text-blue-600': fitMode==='contain'}" class="flex-1 border rounded py-1 hover:bg-gray-50">适应 Contain</button>
107
+ </div>
108
+ </div>
109
+
110
+ <!-- Text Tools -->
111
+ <div class="space-y-3">
112
+ <h3 class="font-bold text-gray-700 flex items-center gap-2">
113
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
114
+ 文字组件
115
+ </h3>
116
+ <div class="grid grid-cols-2 gap-2">
117
+ <button @click="addText('标题', 48, 700)" class="border rounded p-2 text-center hover:bg-gray-50 text-sm font-bold">
118
+ + 大标题
119
+ </button>
120
+ <button @click="addText('正文内容', 24, 400)" class="border rounded p-2 text-center hover:bg-gray-50 text-sm">
121
+ + 正文
122
+ </button>
123
+ <button @click="addNumbering" class="border rounded p-2 text-center hover:bg-gray-50 text-sm col-span-2">
124
+ + 自动页码 (1/{{slideCount}})
125
+ </button>
126
+ </div>
127
+ </div>
128
+
129
+ <!-- Selection Settings -->
130
+ <div v-if="selectedElement" class="border-t pt-4 space-y-3 animate-fade-in">
131
+ <h3 class="font-bold text-gray-700 text-sm">选中样式</h3>
132
+ <div class="grid grid-cols-2 gap-2 text-sm">
133
+ <div>
134
+ <label class="block text-gray-500 text-xs mb-1">颜色</label>
135
+ <input type="color" v-model="selectedElement.color" class="w-full h-8 border rounded">
136
+ </div>
137
+ <div>
138
+ <label class="block text-gray-500 text-xs mb-1">大小</label>
139
+ <input type="number" v-model="selectedElement.fontSize" class="w-full border rounded px-2 py-1">
140
+ </div>
141
+ <div class="col-span-2">
142
+ <label class="block text-gray-500 text-xs mb-1">背景色</label>
143
+ <div class="flex gap-2">
144
+ <input type="checkbox" v-model="selectedElement.hasBg">
145
+ <input type="color" v-model="selectedElement.bgColor" :disabled="!selectedElement.hasBg" class="flex-1 h-6 border rounded">
146
+ </div>
147
+ </div>
148
+ </div>
149
+ <button @click="deleteSelected" class="w-full bg-red-100 text-red-600 py-2 rounded text-sm hover:bg-red-200">删除选中元素</button>
150
+ </div>
151
+
152
+ <div class="mt-auto text-xs text-gray-400">
153
+ <p>拖拽文字可移动位置</p>
154
+ <p>点击文字进行编辑</p>
155
+ </div>
156
+ </aside>
157
+
158
+ <!-- Main Workspace -->
159
+ <main class="flex-1 bg-gray-200 overflow-auto workspace-scroll relative flex items-center justify-center p-10">
160
+
161
+ <!-- The Canvas Area -->
162
+ <div id="canvas-container"
163
+ class="bg-white shadow-2xl relative transition-all duration-300"
164
+ :style="containerStyle"
165
+ @click.self="selectedId = null">
166
+
167
+ <!-- Background Layer -->
168
+ <div class="absolute inset-0 overflow-hidden pointer-events-none">
169
+ <img v-if="bgImage" :src="bgImage"
170
+ class="w-full h-full"
171
+ :class="fitMode === 'cover' ? 'object-cover' : 'object-contain'">
172
+ <div v-else class="w-full h-full bg-gradient-to-br from-indigo-50 to-pink-50 flex items-center justify-center text-gray-300 font-bold text-4xl">
173
+ DROP IMAGE HERE
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Grid Lines Layer -->
178
+ <div class="absolute inset-0 slide-grid pointer-events-none border border-gray-300 z-10"
179
+ :style="{'--slide-width': singleSlideWidth + 'px'}">
180
+ <!-- Slide Numbers -->
181
+ <div class="absolute top-0 left-0 w-full h-full flex">
182
+ <div v-for="n in slideCount" :key="n" class="flex-1 border-r border-dashed border-gray-400/30 flex justify-center pt-2 relative">
183
+ <span class="bg-black/20 text-white text-[10px] px-2 rounded-full backdrop-blur-sm h-fit">
184
+ P{{ n }}
185
+ </span>
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <!-- Content Layer -->
191
+ <div class="absolute inset-0 z-20 overflow-hidden">
192
+ <div v-for="el in elements"
193
+ :key="el.id"
194
+ class="text-element absolute whitespace-nowrap p-2 rounded"
195
+ :class="{ 'selected': selectedId === el.id }"
196
+ :style="{
197
+ left: el.x + 'px',
198
+ top: el.y + 'px',
199
+ color: el.color,
200
+ fontSize: el.fontSize + 'px',
201
+ fontWeight: el.fontWeight,
202
+ backgroundColor: el.hasBg ? el.bgColor : 'transparent',
203
+ transform: 'translate(-50%, -50%)'
204
+ }"
205
+ @mousedown="startDrag($event, el)"
206
+ @click.stop="selectElement(el)">
207
+
208
+ <div contenteditable="true"
209
+ @input="updateText(el, $event)"
210
+ @blur="cleanupText(el)"
211
+ class="outline-none min-w-[20px]"
212
+ v-html="el.text">
213
+ </div>
214
+ </div>
215
+ </div>
216
+
217
+ </div>
218
+ </main>
219
+ </div>
220
+
221
+ <!-- Processing Modal -->
222
+ <div v-if="isProcessing" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center backdrop-blur-sm">
223
+ <div class="bg-white rounded-xl p-8 flex flex-col items-center gap-4">
224
+ <div class="animate-spin rounded-full h-10 w-10 border-b-2 border-red-600"></div>
225
+ <p class="text-gray-600 font-medium">正在生成高清切片...</p>
226
+ </div>
227
+ </div>
228
+ </div>
229
+
230
+ <script>
231
+ const { createApp, ref, computed, onMounted } = Vue;
232
+
233
+ createApp({
234
+ setup() {
235
+ const slideCount = ref(4);
236
+ const aspectRatio = ref('3:4');
237
+ const bgImage = ref(null);
238
+ const fitMode = ref('cover');
239
+ const elements = ref([]);
240
+ const selectedId = ref(null);
241
+ const isProcessing = ref(false);
242
+
243
+ // Constants for rendering resolution
244
+ // Base height 1350 (standard 4:5), width 1080
245
+ const BASE_HEIGHT = 600; // Display height (scaled down for viewing)
246
+
247
+ const ratioMap = {
248
+ '3:4': 3/4,
249
+ '1:1': 1,
250
+ '9:16': 9/16
251
+ };
252
+
253
+ const singleSlideWidth = computed(() => {
254
+ return BASE_HEIGHT * ratioMap[aspectRatio.value];
255
+ });
256
+
257
+ const totalWidth = computed(() => {
258
+ return singleSlideWidth.value * slideCount.value;
259
+ });
260
+
261
+ const containerStyle = computed(() => {
262
+ return {
263
+ width: totalWidth.value + 'px',
264
+ height: BASE_HEIGHT + 'px'
265
+ };
266
+ });
267
+
268
+ const selectedElement = computed(() => {
269
+ return elements.value.find(e => e.id === selectedId.value);
270
+ });
271
+
272
+ const handleImageUpload = (e) => {
273
+ const file = e.target.files[0];
274
+ if (!file) return;
275
+ const reader = new FileReader();
276
+ reader.onload = (e) => bgImage.value = e.target.result;
277
+ reader.readAsDataURL(file);
278
+ };
279
+
280
+ const addText = (text, size, weight) => {
281
+ const id = Date.now();
282
+ elements.value.push({
283
+ id,
284
+ text,
285
+ x: totalWidth.value / 2,
286
+ y: BASE_HEIGHT / 2,
287
+ fontSize: size,
288
+ fontWeight: weight,
289
+ color: '#000000',
290
+ hasBg: false,
291
+ bgColor: '#ffffff'
292
+ });
293
+ selectedId.value = id;
294
+ };
295
+
296
+ const addNumbering = () => {
297
+ // Remove existing numbering
298
+ elements.value = elements.value.filter(e => !e.isPageNumber);
299
+
300
+ for(let i=0; i < slideCount.value; i++) {
301
+ elements.value.push({
302
+ id: Date.now() + i,
303
+ text: `${i+1}/${slideCount.value}`,
304
+ x: (i * singleSlideWidth.value) + (singleSlideWidth.value / 2),
305
+ y: BASE_HEIGHT - 30,
306
+ fontSize: 16,
307
+ fontWeight: 400,
308
+ color: '#ffffff',
309
+ hasBg: true,
310
+ bgColor: '#000000',
311
+ isPageNumber: true
312
+ });
313
+ }
314
+ };
315
+
316
+ const selectElement = (el) => {
317
+ selectedId.value = el.id;
318
+ };
319
+
320
+ const updateText = (el, event) => {
321
+ el.text = event.target.innerHTML;
322
+ };
323
+
324
+ const deleteSelected = () => {
325
+ if (selectedId.value) {
326
+ elements.value = elements.value.filter(e => e.id !== selectedId.value);
327
+ selectedId.value = null;
328
+ }
329
+ };
330
+
331
+ // Dragging Logic
332
+ let dragOffset = { x: 0, y: 0 };
333
+ let isDragging = false;
334
+ let activeEl = null;
335
+
336
+ const startDrag = (e, el) => {
337
+ if (e.target.isContentEditable) return; // Don't drag if editing text
338
+ isDragging = true;
339
+ activeEl = el;
340
+ dragOffset.x = e.clientX - el.x; // Simplified, assuming transform translate(-50%, -50%) logic matches visual
341
+ // Actually, with translate(-50%, -50%), el.x is center.
342
+ // Mouse is at clientX.
343
+ // Let's just track delta.
344
+ dragOffset.x = e.clientX;
345
+ dragOffset.y = e.clientY;
346
+
347
+ document.addEventListener('mousemove', onDrag);
348
+ document.addEventListener('mouseup', stopDrag);
349
+ };
350
+
351
+ const onDrag = (e) => {
352
+ if (!isDragging || !activeEl) return;
353
+ const dx = e.clientX - dragOffset.x;
354
+ const dy = e.clientY - dragOffset.y;
355
+
356
+ activeEl.x += dx;
357
+ activeEl.y += dy;
358
+
359
+ dragOffset.x = e.clientX;
360
+ dragOffset.y = e.clientY;
361
+ };
362
+
363
+ const stopDrag = () => {
364
+ isDragging = false;
365
+ activeEl = null;
366
+ document.removeEventListener('mousemove', onDrag);
367
+ document.removeEventListener('mouseup', stopDrag);
368
+ };
369
+
370
+ // Export Logic
371
+ const exportImages = async () => {
372
+ selectedId.value = null; // Deselect to remove borders
373
+ isProcessing.value = true;
374
+
375
+ await new Promise(r => setTimeout(r, 100)); // UI update
376
+
377
+ try {
378
+ const container = document.getElementById('canvas-container');
379
+
380
+ // High quality capture
381
+ // We scale up the canvas to match 1080px width per slide
382
+ const scaleFactor = 1080 / singleSlideWidth.value;
383
+
384
+ const canvas = await html2canvas(container, {
385
+ scale: scaleFactor,
386
+ useCORS: true,
387
+ backgroundColor: null
388
+ });
389
+
390
+ const zip = new JSZip();
391
+ const slideW = 1080; // Target width
392
+ const slideH = slideW / ratioMap[aspectRatio.value];
393
+
394
+ // Slice
395
+ for (let i = 0; i < slideCount.value; i++) {
396
+ const sliceCanvas = document.createElement('canvas');
397
+ sliceCanvas.width = slideW;
398
+ sliceCanvas.height = slideH;
399
+ const ctx = sliceCanvas.getContext('2d');
400
+
401
+ // Draw portion
402
+ // Source X = i * slideW
403
+ ctx.drawImage(canvas,
404
+ i * slideW, 0, slideW, slideH,
405
+ 0, 0, slideW, slideH
406
+ );
407
+
408
+ const blob = await new Promise(resolve => sliceCanvas.toBlob(resolve, 'image/png'));
409
+ zip.file(`slide_${i+1}.png`, blob);
410
+ }
411
+
412
+ const content = await zip.generateAsync({type:"blob"});
413
+ saveAs(content, "carousel_slides.zip");
414
+
415
+ } catch (e) {
416
+ alert('导出失败: ' + e.message);
417
+ console.error(e);
418
+ } finally {
419
+ isProcessing.value = false;
420
+ }
421
+ };
422
+
423
+ return {
424
+ slideCount,
425
+ aspectRatio,
426
+ bgImage,
427
+ fitMode,
428
+ elements,
429
+ selectedElement,
430
+ selectedId,
431
+ isProcessing,
432
+ singleSlideWidth,
433
+ totalWidth,
434
+ containerStyle,
435
+ handleImageUpload,
436
+ addText,
437
+ addNumbering,
438
+ selectElement,
439
+ updateText,
440
+ deleteSelected,
441
+ startDrag,
442
+ exportImages
443
+ };
444
+ }
445
+ }).mount('#app');
446
+ </script>
447
+ </body>
448
+ </html>