duqing2026's picture
Feat: Add Themes support and Reset Canvas functionality
8b88085
<!DOCTYPE html>
<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">&times;</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>