MagicA4 / index.html
Lashtw's picture
Update index.html
a3f2c98 verified
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Magic Origami Designer | 魔法摺紙設計師 (雙頁版)</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- PDF Generation Libraries -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<!-- PPTX Generation Library -->
<script src="https://cdn.jsdelivr.net/npm/pptxgenjs@3.12.0/dist/pptxgen.bundle.js"></script>
<!-- Cropper.js for Image Cropping -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
<!-- FontAwesome 6 (For Consistent Icons) -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Noto Sans TC', sans-serif;
background-color: #f1f5f9;
}
.grid-cell {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.rotation-wrapper {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 1 / 1;
transition: transform 0.3s ease-in-out;
}
.cell-text {
line-height: 1;
display: block;
padding-bottom: 0.1em;
}
.preview-page {
transform-origin: center center;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.preview-page:hover {
transform: scale(1.05);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
ring: 4px solid #6366f1; /* indigo-500 */
z-index: 10;
}
#pdf-generator-container {
position: absolute;
top: -9999px;
left: -9999px;
}
/* Custom Scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f5f9;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
/* Cropper CSS Override */
.cropper-container {
width: 100%;
height: 100%;
}
</style>
</head>
<body class="h-screen overflow-hidden text-slate-800">
<div id="app" class="h-full flex flex-col md:flex-row">
<!-- =======================
LEFT PANEL: Controls
======================= -->
<div class="w-full md:w-1/3 lg:w-1/4 bg-white border-r border-slate-200 flex flex-col h-full shadow-lg z-20 relative">
<!-- Header -->
<div class="p-6 border-b border-slate-100 bg-slate-50">
<div class="flex justify-between items-center mb-2">
<h1 class="text-2xl font-bold text-indigo-600 flex items-center gap-2">
<span class="text-3xl">🎩</span>
摺紙魔術設計師
</h1>
</div>
<!-- Page Navigator -->
<div class="mt-4 flex bg-slate-200 p-1 rounded-lg">
<button
@click="viewMode = 'overview'"
class="flex-1 py-1 text-sm font-bold rounded-md transition-all"
:class="viewMode === 'overview' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
>
雙頁全覽
</button>
<button
v-for="pageId in [1, 2]"
:key="pageId"
@click="switchToPage(pageId)"
class="flex-1 py-1 text-sm font-bold rounded-md transition-all ml-1"
:class="(viewMode === 'edit' && activePageId === pageId) ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
>
第 {{ pageId }} 頁
</button>
</div>
</div>
<!-- Scrollable Content -->
<div class="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
<!-- Grid Settings -->
<div>
<label class="block text-sm font-bold text-slate-700 mb-2">📐 網格設定</label>
<div class="grid grid-cols-2 gap-2 mb-2">
<button
v-for="conf in gridOptions"
:key="conf.label"
@click="changeGridSize(conf)"
class="py-2 text-sm border rounded-lg transition-all active:scale-95"
:class="currentGrid.rows === conf.rows && currentGrid.cols === conf.cols ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-600 border-slate-300 hover:bg-slate-50'"
>
{{ conf.label }}
</button>
<!-- Custom Grid Button -->
<button
@click="openCustomGridModal"
class="py-2 text-sm border border-dashed border-indigo-300 text-indigo-600 rounded-lg hover:bg-indigo-50 active:scale-95 transition-all font-bold"
:class="{'bg-indigo-600 text-white border-indigo-600 border-solid': isCustomGridActive}"
>
<i class="fa-solid fa-gear mr-1"></i> 自訂網格
</button>
</div>
<!-- Clear All Button -->
<button
@click="clearAllContent"
class="w-full py-2 text-sm text-red-600 border border-red-200 bg-red-50 hover:bg-red-100 rounded-lg transition-all flex items-center justify-center gap-1"
>
<i class="fa-solid fa-trash-can mr-1"></i>
一鍵清空所有內容
</button>
</div>
<!-- Templates -->
<div>
<label class="block text-sm font-bold text-slate-700 mb-2">🎁 快速模板</label>
<div class="space-y-2">
<!-- Lucky Clover -->
<button
@click="applyTemplate('lucky')"
class="w-full py-2.5 bg-gradient-to-r from-emerald-500 to-teal-600 hover:from-emerald-600 hover:to-teal-700 text-white font-bold rounded-lg shadow-sm active:scale-95 transition-all flex items-center justify-center gap-2"
>
<span class="text-xl leading-none">🍀</span>
<span class="text-sm">幸運草與一句話 <span class="text-xs opacity-80 font-normal">(如皓老師分享)</span></span>
</button>
<div class="grid grid-cols-2 gap-2">
<button @click="applyTemplate('4x4')" class="py-2 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-bold rounded-lg text-sm active:scale-95">
4x4 範例
</button>
<button @click="applyTemplate('4x6')" class="py-2 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-bold rounded-lg text-sm active:scale-95">
4x6 範例 (單面)
</button>
<button @click="applyTemplate('6x8')" class="py-2 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-bold rounded-lg text-sm active:scale-95">
6x8 範例
</button>
<button @click="applyTemplate('8x8')" class="py-2 bg-white border border-slate-300 hover:bg-slate-50 text-slate-700 font-bold rounded-lg text-sm active:scale-95">
8x8 範例
</button>
</div>
</div>
<p class="text-xs text-slate-400 mt-2">提示:套用模板會自動切換網格並覆蓋內容。</p>
</div>
<!-- Project Management -->
<div class="border-t border-slate-200 pt-4">
<label class="block text-sm font-bold text-slate-700 mb-2">💾 專案管理</label>
<div class="grid grid-cols-2 gap-2">
<button
@click="exportProject"
class="py-2 bg-slate-600 hover:bg-slate-700 text-white font-bold rounded-lg shadow-sm active:scale-95 transition-all flex items-center justify-center gap-1 text-sm"
>
<i class="fa-solid fa-download mr-1"></i>
匯出存檔
</button>
<button
@click="triggerImport"
class="py-2 bg-slate-600 hover:bg-slate-700 text-white font-bold rounded-lg shadow-sm active:scale-95 transition-all flex items-center justify-center gap-1 text-sm"
>
<i class="fa-solid fa-upload mr-1"></i>
匯入舊檔
</button>
<input
type="file"
ref="fileInputRef"
class="hidden"
accept=".json"
@change="importProject"
>
</div>
</div>
<!-- Instruction -->
<div class="bg-indigo-50 p-4 rounded-lg border border-indigo-100 text-sm text-indigo-800">
<h3 class="font-bold mb-2">💡 操作說明:</h3>
<ul class="list-disc list-inside space-y-1">
<li v-if="viewMode === 'overview'"><strong>點擊頁面:</strong>進入該頁編輯模式</li>
<li v-else><strong>目前編輯:</strong>第 {{ activePageId }} 頁</li>
<li><strong>點擊格子:</strong>選取編輯</li>
<li><strong>複選模式:</strong>開啟下方開關可連續點擊選取</li>
<li><strong>再次點擊:</strong>單選時旋轉 90°</li>
</ul>
</div>
<!-- Editing Controls -->
<div v-if="viewMode === 'edit'" class="space-y-8 animate-fade-in pb-10">
<!-- Multi-Select Mode Toggle (NEW) -->
<div class="flex items-center justify-between bg-white p-3 border border-slate-200 rounded-lg shadow-sm">
<span class="text-sm font-bold text-slate-700 flex items-center gap-2">
<i class="fa-solid fa-check-double text-indigo-500"></i>
複選模式
</span>
<button
@click="toggleMultiSelectMode"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
:class="isMultiSelectMode ? 'bg-indigo-600' : 'bg-slate-200'"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="isMultiSelectMode ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<!-- Selection Summary -->
<div v-if="selectedCellIndices.size > 0" class="bg-indigo-50 px-4 py-3 rounded-lg border border-indigo-100">
<div class="flex justify-between items-center mb-2">
<div class="text-sm font-bold text-indigo-800">
已選取 {{ selectedCellIndices.size }} 個格子
</div>
<button @click="clearSelection" class="text-xs text-slate-500 hover:text-red-500 underline">
取消選取
</button>
</div>
<div class="flex gap-2">
<button @click="rotateCurrentCell" class="flex-1 px-3 py-1.5 bg-white border border-indigo-200 text-indigo-700 font-bold rounded shadow-sm text-xs hover:bg-indigo-50 transition-all">
<i class="fa-solid fa-rotate-right mr-1"></i> 旋轉 90°
</button>
</div>
</div>
<!-- Colors -->
<div class="space-y-4">
<!-- Text Color -->
<div>
<label class="block text-sm font-bold text-slate-700 mb-2">1. 文字/圖示顏色</label>
<div class="flex flex-wrap gap-2">
<button
v-for="color in colors"
:key="color"
@click="applyColor(color)"
:disabled="selectedCellIndices.size === 0"
class="w-8 h-8 rounded-full border-2 transition-all shadow-sm active:scale-95 hover:scale-110"
:class="{'ring-2 ring-indigo-400 ring-offset-2': selectedColor === color && selectedCellIndices.size > 0, 'opacity-50 cursor-not-allowed': selectedCellIndices.size === 0}"
:style="{ backgroundColor: color, borderColor: color === '#ffffff' ? '#e2e8f0' : 'transparent' }"
></button>
</div>
</div>
<!-- Background Color (Macaron) -->
<div>
<label class="block text-sm font-bold text-slate-700 mb-2">2. 背景網底 (淺色系)</label>
<div class="flex flex-wrap gap-2">
<button
v-for="bgColor in bgColors"
:key="bgColor"
@click="applyBgColor(bgColor)"
:disabled="selectedCellIndices.size === 0"
class="w-8 h-8 rounded border transition-all shadow-sm active:scale-95 hover:scale-110 relative"
:class="{'ring-2 ring-indigo-400 ring-offset-2': selectedBgColor === bgColor && selectedCellIndices.size > 0, 'opacity-50 cursor-not-allowed': selectedCellIndices.size === 0}"
:style="{ backgroundColor: bgColor, borderColor: '#e2e8f0' }"
>
<!-- Slash for transparent/white -->
<div v-if="bgColor === '#ffffff'" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-full h-px bg-slate-300 transform rotate-45"></div>
</div>
</button>
</div>
</div>
</div>
<!-- Text Input -->
<div>
<label class="block text-sm font-bold text-slate-700 mb-2">3. 文字輸入 (單格最多3字)</label>
<div class="flex gap-2 mb-2" v-if="selectedCellIndices.size > 1">
<input
ref="batchInputRef"
type="text"
v-model="batchInputBuffer"
placeholder="輸入文字將循環填入選取格子..."
class="flex-1 min-w-0 border-2 border-indigo-200 rounded-lg p-2 text-sm focus:border-indigo-500 focus:outline-none"
>
<button
@click="applyBatchText"
class="px-3 py-2 bg-indigo-600 text-white font-bold rounded-lg text-sm hover:bg-indigo-700 transition-all whitespace-nowrap"
>
填入
</button>
</div>
<input
v-else
ref="textInputRef"
type="text"
v-model="inputBuffer"
@input="updateSelectedCellText"
placeholder="先點選右側格子..."
maxlength="3"
:disabled="selectedCellIndices.size === 0"
class="w-full text-center text-2xl p-3 border-2 rounded-lg focus:outline-none focus:ring-2 transition-all"
:class="selectedCellIndices.size === 0 ? 'bg-slate-100 border-slate-200 cursor-not-allowed' : 'bg-white border-indigo-300 focus:border-indigo-500 focus:ring-indigo-200'"
:style="{ color: selectedCellIndices.size > 0 ? selectedColor : '' }"
>
<p class="text-xs text-slate-400 mt-1" v-if="selectedCellIndices.size <= 1">提示:輸入 2~3 個字或數字時,字體會自動縮小。</p>
<p class="text-xs text-indigo-500 mt-1 font-bold" v-else>批次模式:文字將依序循環填入選取格子。</p>
</div>
<!-- Icon Picker -->
<div>
<label class="block text-sm font-bold text-slate-700 mb-3">4. 選擇圖示</label>
<div class="grid grid-cols-4 gap-2 max-h-60 overflow-y-auto p-1 custom-scrollbar">
<button
v-for="(val, name) in icons"
:key="name"
@click="applyIconToCell(name)"
:disabled="selectedCellIndices.size === 0"
class="aspect-square flex items-center justify-center rounded-lg border hover:bg-indigo-50 active:scale-95 transition-all"
:class="selectedCellIndices.size === 0 ? 'border-slate-200 text-slate-300 cursor-not-allowed' : 'border-slate-300 text-slate-600 hover:border-indigo-400 hover:text-indigo-600 cursor-pointer'"
:title="name"
>
<span class="text-3xl leading-none select-none">{{ val }}</span>
</button>
</div>
</div>
<!-- Image Upload -->
<div>
<div class="flex justify-between items-center mb-1">
<label class="block text-sm font-bold text-slate-700">5. 自定義圖片</label>
<!-- Toggle for Image Fit/Fill -->
<div v-if="selectedCellIndices.size > 0" class="flex items-center gap-1">
<button
@click="toggleImageScale"
class="text-xs px-2 py-1 rounded border transition-all"
:class="currentImageScale === 'fill' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-slate-500 border-slate-300 hover:bg-slate-50'"
title="切換圖片填充模式:適中 / 滿版"
>
<i class="fa-solid" :class="currentImageScale === 'fill' ? 'fa-expand' : 'fa-compress'"></i>
{{ currentImageScale === 'fill' ? '滿版' : '適中' }}
</button>
</div>
</div>
<div class="flex gap-2 mb-2">
<button
@click="triggerImageUpload('single')"
class="flex-1 text-xs bg-indigo-100 text-indigo-700 px-2 py-2 rounded hover:bg-indigo-200 font-bold flex items-center justify-center gap-1"
>
<svg viewBox="0 0 512 512" class="w-3 h-3 fill-current"><path d="M0 96C0 60.7 28.7 32 64 32H448c35.3 0 64 28.7 64 64V416c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6h96 32H424c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192c26.5 0 48-21.5 48-48s-21.5-48-48-48s-48 21.5-48 48s21.5 48 48 48z"/></svg>
單圖上傳
</button>
<!-- Puzzle Upload Button (Visible only when multiple cells selected) -->
<button
v-if="selectedCellIndices.size > 1"
@click="triggerImageUpload('puzzle')"
class="flex-1 text-xs bg-purple-100 text-purple-700 px-2 py-2 rounded hover:bg-purple-200 font-bold flex items-center justify-center gap-1"
>
<i class="fa-solid fa-puzzle-piece"></i>
拼圖切割 (存入列表)
</button>
<input type="file" ref="imageUploadInput" class="hidden" accept="image/*" @change="handleImageUpload">
</div>
<div v-if="customImages.length === 0" class="text-xs text-slate-400 text-center py-2 border border-dashed border-slate-300 rounded-lg">
尚未上傳圖片
</div>
<div v-else class="grid grid-cols-4 gap-2 p-1">
<button
v-for="(imgSrc, idx) in customImages"
:key="idx"
@click="applyCustomImageToCell(imgSrc)"
:disabled="selectedCellIndices.size === 0"
class="aspect-square flex items-center justify-center rounded-lg border overflow-hidden relative hover:opacity-90 active:scale-95 transition-all bg-white"
:class="selectedCellIndices.size === 0 ? 'border-slate-200 cursor-not-allowed opacity-50' : 'border-slate-300 cursor-pointer hover:border-indigo-400 ring-offset-1'"
>
<img :src="imgSrc" class="w-full h-full object-cover">
<!-- Delete Button -->
<div @click.stop="removeCustomImage(idx)" class="absolute top-0 right-0 bg-black/50 text-white w-4 h-4 flex items-center justify-center text-[10px] hover:bg-red-500 rounded-bl">×</div>
</button>
</div>
<p v-if="customImages.length > 0 && selectedCellIndices.size > 0" class="text-[10px] text-indigo-500 mt-1 font-bold">
<i class="fa-solid fa-arrow-pointer"></i> 點選圖片即可填入目前選取的格子
</p>
</div>
<div>
<button
@click="clearCurrentCell"
:disabled="selectedCellIndices.size === 0"
class="w-full py-2 text-sm text-red-500 border border-red-200 rounded hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
清除內容
</button>
</div>
</div>
<div v-else class="text-center text-slate-400 py-10">
<p>請從右側點擊任一頁面<br>開始設計</p>
</div>
</div>
<!-- Footer Action -->
<div class="p-6 border-t border-slate-200 bg-slate-50">
<button
@click="openExportModal"
:disabled="isGenerating"
class="w-full py-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-xl shadow-lg hover:shadow-xl active:scale-95 transition-all flex items-center justify-center gap-2 disabled:opacity-70 disabled:cursor-wait"
>
<span v-if="!isGenerating">
<i class="fa-solid fa-file-export"></i>
匯出設計 (PDF / PPTX / JPG)
</span>
<span v-else>
<!-- Spinner -->
<svg class="animate-spin h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
處理中...
</span>
</button>
</div>
<!-- Image Cropper Modal -->
<div v-if="showCropper" class="absolute inset-0 z-50 bg-slate-900/90 flex flex-col p-4 animate-fade-in">
<div class="flex-1 relative bg-black rounded-lg overflow-hidden mb-4">
<img ref="cropperImgRef" :src="tempImageSrc" class="max-w-full max-h-full block">
</div>
<div class="flex justify-between">
<!-- Rotate Controls -->
<div class="flex gap-2">
<button @click="rotateCropper(-90)" class="px-3 py-2 text-white bg-slate-700 rounded hover:bg-slate-600" title="向左旋轉">
<i class="fa-solid fa-rotate-left"></i>
</button>
<button @click="rotateCropper(90)" class="px-3 py-2 text-white bg-slate-700 rounded hover:bg-slate-600" title="向右旋轉">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
<!-- Action Buttons -->
<div class="flex gap-2">
<button @click="cancelCrop" class="px-4 py-2 text-white text-sm font-bold bg-slate-600 rounded hover:bg-slate-500">取消</button>
<button @click="confirmCrop" class="px-4 py-2 text-white text-sm font-bold bg-indigo-600 rounded hover:bg-indigo-500">確認裁切</button>
</div>
</div>
</div>
<!-- Custom Grid Modal -->
<div v-if="showGridModal" class="absolute inset-0 z-50 bg-slate-900/50 flex items-center justify-center p-4 animate-fade-in">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-sm overflow-hidden">
<div class="bg-indigo-600 px-6 py-4">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<i class="fa-solid fa-gear"></i> 自訂網格設定
</h3>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-bold text-slate-700 mb-1">列數 (Rows)</label>
<input type="number" v-model.number="customGridConfig.rows" min="1" max="20" class="w-full border-2 border-slate-200 rounded-lg p-2 focus:border-indigo-500 focus:outline-none">
</div>
<div>
<label class="block text-sm font-bold text-slate-700 mb-1">欄數 (Columns)</label>
<input type="number" v-model.number="customGridConfig.cols" min="1" max="20" class="w-full border-2 border-slate-200 rounded-lg p-2 focus:border-indigo-500 focus:outline-none">
</div>
<p class="text-xs text-slate-500 bg-slate-50 p-2 rounded">
<i class="fa-solid fa-circle-info mr-1"></i>
建議範圍:1 ~ 12,過大可能會影響列印清晰度。
</p>
</div>
<div class="bg-slate-50 px-6 py-4 flex justify-end gap-2 border-t border-slate-100">
<button @click="showGridModal = false" class="px-4 py-2 text-slate-600 font-bold hover:bg-slate-200 rounded-lg transition-all">取消</button>
<button @click="confirmCustomGrid" class="px-4 py-2 bg-indigo-600 text-white font-bold rounded-lg hover:bg-indigo-700 transition-all shadow-md">確認套用</button>
</div>
</div>
</div>
<!-- Export PDF/PPTX/JPG Modal -->
<div v-if="showExportModal" class="absolute inset-0 z-50 bg-slate-900/50 flex items-center justify-center p-4 animate-fade-in">
<div class="bg-white rounded-xl shadow-2xl w-full max-w-sm overflow-hidden">
<div class="bg-indigo-600 px-6 py-4">
<h3 class="text-lg font-bold text-white flex items-center gap-2">
<i class="fa-solid fa-file-export"></i> 匯出設定
</h3>
</div>
<div class="p-6 space-y-4">
<p class="text-sm text-slate-600 mb-2">產生專屬浮水印(可略過):</p>
<div>
<label class="block text-sm font-bold text-slate-700 mb-1">姓名 (選填)</label>
<input type="text" v-model="exportName" placeholder="例如:王小明" class="w-full border-2 border-slate-200 rounded-lg p-2 focus:border-indigo-500 focus:outline-none">
</div>
<div>
<label class="block text-sm font-bold text-slate-700 mb-1">作品名稱 (選填)</label>
<input type="text" v-model="exportProjectName" placeholder="例如:我的幸運草" class="w-full border-2 border-slate-200 rounded-lg p-2 focus:border-indigo-500 focus:outline-none">
</div>
<!-- Checkbox for removing grid on page 2 -->
<div class="flex items-center gap-2 border-t border-slate-100 pt-3">
<input type="checkbox" id="removeGridPage2" v-model="removeGridOnPage2" class="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500">
<label for="removeGridPage2" class="text-sm font-bold text-slate-700 cursor-pointer">
消除第二頁格線 <span class="text-xs font-normal text-slate-500">(解決雙面列印偏移問題)</span>
</label>
</div>
</div>
<div class="bg-slate-50 px-6 py-4 flex flex-col gap-2 border-t border-slate-100">
<button @click="processExport('pdf')" class="w-full py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-lg transition-all shadow-md flex items-center justify-center gap-2">
<i class="fa-solid fa-file-pdf"></i> 下載雙頁 PDF
</button>
<button @click="processExport('pptx')" class="w-full py-3 bg-orange-500 hover:bg-orange-600 text-white font-bold rounded-lg transition-all shadow-md flex items-center justify-center gap-2">
<i class="fa-solid fa-file-powerpoint"></i> 下載可編輯 PPTX
</button>
<!-- JPG Button -->
<button @click="processExport('jpg')" class="w-full py-3 bg-emerald-600 hover:bg-emerald-700 text-white font-bold rounded-lg transition-all shadow-md flex items-center justify-center gap-2">
<i class="fa-solid fa-image"></i> 下載 JPG 圖片 (雙頁)
</button>
<button @click="showExportModal = false" class="w-full py-2 text-slate-500 hover:text-slate-700 font-bold text-sm mt-1">取消</button>
</div>
</div>
</div>
</div>
<!-- =======================
RIGHT PANEL: Workspace
======================= -->
<div class="flex-1 bg-slate-200 overflow-auto relative no-scrollbar">
<div class="min-h-full flex flex-col items-center justify-center p-4 md:p-8 relative z-10">
<!-- OVERVIEW MODE -->
<div v-if="viewMode === 'overview'" class="flex flex-row flex-wrap gap-8 justify-center items-center">
<div v-for="pageId in [1, 2]" :key="pageId" class="flex flex-col items-center">
<h2 class="text-lg font-bold text-slate-600 mb-2">第 {{ pageId }} 頁</h2>
<div
class="bg-white shadow-xl preview-page relative"
style="width: 120mm; height: 170mm;"
@click="switchToPage(pageId)"
>
<div
class="grid gap-0 border border-slate-200 w-full h-full pointer-events-none"
:key="currentGrid.label"
:style="{
gridTemplateColumns: `repeat(${currentGrid.cols}, 1fr)`,
gridTemplateRows: `repeat(${currentGrid.rows}, 1fr)`
}"
>
<div
v-for="(cell, index) in pages[pageId-1].cells"
:key="cell.id"
class="grid-cell relative border border-slate-300"
>
<!-- Inner Background (Highlight) -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div v-if="cell.bgColor !== '#ffffff'" class="w-[85%] h-[85%] rounded-lg transition-colors" :style="{ backgroundColor: cell.bgColor }"></div>
</div>
<div class="rotation-wrapper relative z-10" :style="{ transform: `rotate(${cell.rotation}deg)` }">
<span v-if="cell.type === 'text'" class="font-bold cell-text" :class="getOverviewFontSizeClass(cell.content)" :style="{ color: cell.color }">{{ cell.content }}</span>
<!-- Render Icon: Emoji -->
<span v-if="cell.type === 'icon'" class="text-2xl leading-none select-none">{{ icons[cell.content] }}</span>
<!-- Image Renderer -->
<img v-if="cell.type === 'image'" :src="cell.content" class="w-full h-full" :style="{ objectFit: cell.imageScale === 'fill' ? 'cover' : 'contain', width: cell.imageScale === 'fill' ? '100%' : '80%', height: cell.imageScale === 'fill' ? '100%' : '80%' }">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- EDIT MODE -->
<div v-else class="flex flex-col gap-2 animate-fade-in">
<button @click="viewMode = 'overview'" class="self-start text-slate-500 hover:text-indigo-600 font-bold text-sm flex items-center gap-1">
<!-- Arrow Left -->
<svg viewBox="0 0 448 512" class="w-4 h-4 fill-current mr-1 inline-block"><path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.2 288 416 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-306.7 0L214.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z"/></svg>
回到全覽
</button>
<div
:id="`edit-canvas-${activePageId}`"
class="bg-white shadow-2xl relative"
style="width: 210mm; height: 297mm; padding: 0mm;"
>
<div class="absolute bottom-1 right-2 text-slate-200 text-[10px] font-sans select-none z-0">
Page {{ activePageId }} - {{ currentGrid.rows }}x{{ currentGrid.cols }}
</div>
<div
class="grid gap-0 border border-slate-200 w-full h-full"
:key="currentGrid.label + activePageId"
:style="{
gridTemplateColumns: `repeat(${currentGrid.cols}, 1fr)`,
gridTemplateRows: `repeat(${currentGrid.rows}, 1fr)`
}"
>
<div
v-for="(cell, index) in activePageCells"
:key="cell.id"
@click="handleCellClick(index, $event)"
class="grid-cell relative border border-slate-300 cursor-pointer hover:bg-slate-50 select-none"
:class="{ 'ring-4 ring-indigo-500 ring-inset z-20': selectedCellIndices.has(index) }"
>
<!-- Inner Background (Highlight) -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div v-if="cell.bgColor !== '#ffffff'" class="w-[85%] h-[85%] rounded-lg transition-colors" :style="{ backgroundColor: cell.bgColor }"></div>
</div>
<!-- Selection Order Badge (Only visible in Multi-Select Mode when selected) -->
<div v-if="isMultiSelectMode && selectedCellIndices.has(index)"
class="absolute top-1 right-1 w-6 h-6 bg-indigo-600 text-white rounded-full flex items-center justify-center text-xs font-bold z-30 shadow-sm border-2 border-white">
{{ getSelectionOrder(index) }}
</div>
<div class="rotation-wrapper pointer-events-none relative z-10" :style="{ transform: `rotate(${cell.rotation}deg)` }">
<span v-if="cell.type === 'text'" class="font-bold cell-text block" :class="getFontSizeClass(cell.content)" :style="{ color: cell.color }">
{{ cell.content }}
</span>
<!-- Render Icon: Emoji -->
<span v-if="cell.type === 'icon'" class="text-5xl md:text-7xl leading-none select-none">{{ icons[cell.content] }}</span>
<!-- Image Renderer -->
<!-- Dynamic Style for Fit/Fill -->
<img v-if="cell.type === 'image'" :src="cell.content" class="w-full h-full" :style="{ objectFit: cell.imageScale === 'fill' ? 'cover' : 'contain', width: cell.imageScale === 'fill' ? '100%' : '80%', height: cell.imageScale === 'fill' ? '100%' : '80%' }">
</div>
</div>
</div>
</div>
</div>
<!-- Credits -->
<div class="mt-8 text-center w-full max-w-2xl">
<div class="text-xs text-slate-400 font-sans space-y-1 bg-slate-100/50 p-4 rounded-lg inline-block text-left border border-slate-200">
<p>靈感來源:台北市興雅國中 吳如皓老師 《摺紙中的數學魔術》</p>
<p>程式設計:新竹縣精華國中 藍星宇老師</p>
<p>教育社群:
<a href="https://www.facebook.com/groups/1554372228718393" target="_blank" class="text-indigo-500 hover:text-indigo-700 font-bold hover:underline">萬物皆數</a>
<a href="https://www.facebook.com/groups/108923286120994" target="_blank" class="text-indigo-500 hover:text-indigo-700 font-bold hover:underline">藝數摺學</a>
</p>
<!-- Feedback Link -->
<p class="mt-2 pt-2 border-t border-slate-200">
如果您有任何建議或回饋,歡迎至
<a href="https://padlet.com/hccedu/padlet-5rkzrebd75t7al13" target="_blank" class="text-indigo-500 hover:text-indigo-700 font-bold hover:underline flex items-center gap-1 inline-flex">
<i class="fa-regular fa-comments"></i> 意見回饋留言板
</a> 留言。
<br>若您留下姓名,我們將會把您加入致謝名單中!
</p>
</div>
</div>
</div>
</div>
<div id="pdf-generator-container"></div>
</div>
<script>
const { createApp, ref, computed, nextTick, onMounted, watch } = Vue;
const { jsPDF } = window.jspdf;
createApp({
setup() {
// --- Configuration ---
const gridOptions = [
{ label: '4x4', rows: 4, cols: 4 },
{ label: '4x6', rows: 6, cols: 4 }, // Added 4x6 support
{ label: '6x8', rows: 8, cols: 6 },
{ label: '8x8', rows: 8, cols: 8 }
];
const currentGrid = ref(gridOptions[2]); // Default 6x8
// --- Colors ---
const colors = ['#1e293b', '#ef4444', '#f97316', '#f59e0b', '#22c55e', '#14b8a6', '#3b82f6', '#6366f1', '#a855f7', '#ec4899'];
// --- Background Colors (Macaron) ---
const bgColors = ['#ffffff', '#fecaca', '#fed7aa', '#fef08a', '#bbf7d0', '#a5f3fc', '#bfdbfe', '#ddd6fe', '#fbcfe8', '#e2e8f0'];
// --- Icons (Unified to Unicode Emoji) ---
const icons = {
'幸運草': '🍀',
'愛心': '❤️',
'星星': '⭐',
'勝利': '✌️',
'獎盃': '🏆',
'笑臉': '😊',
'皇冠': '👑',
'鑽石': '💎',
'燈泡': '💡',
'太陽': '☀️',
'月亮': '🌙',
'雲朵': '☁️',
'音符': '🎵',
'飛機': '✈️',
'花朵': '🌸',
'樹木': '🌳',
'禮物': '🎁',
'猴子臉': '🐵',
'猴子': '🐒',
'馬臉': '🐴',
'馬': '🐎',
'白圓': '⚪',
'白方': '⬜'
};
// --- State Initialization ---
const createPageCells = (pageOffset, rows, cols) => Array.from({ length: rows * cols }, (_, i) => ({
id: pageOffset + i,
type: 'text',
content: '',
rotation: 0,
color: '#1e293b', // Default text color
bgColor: '#ffffff', // Default bg color
imageScale: 'fit' // 'fit' (contain, with padding) or 'fill' (cover, no padding)
}));
const pages = ref([
{ id: 1, cells: createPageCells(0, 8, 6) },
{ id: 2, cells: createPageCells(48, 8, 6) }
]);
const viewMode = ref('overview');
const activePageId = ref(1);
// Replaced single index with Set for multi-select
// For ordered batch fill, we need to track ORDER
const selectedCellIndices = ref(new Set());
const isMultiSelectMode = ref(false); // Toggle for tablet friendly mode
const inputBuffer = ref('');
const batchInputBuffer = ref(''); // For batch string input
const selectedColor = ref('#1e293b');
const selectedBgColor = ref('#ffffff');
const isGenerating = ref(false);
// Refs
const textInputRef = ref(null);
const batchInputRef = ref(null);
const fileInputRef = ref(null);
const imageUploadInput = ref(null);
const cropperImgRef = ref(null);
// Custom Grid State
const showGridModal = ref(false);
const customGridConfig = ref({ rows: 10, cols: 10 });
// Export Modal State
const showExportModal = ref(false);
const exportName = ref('');
const exportProjectName = ref('');
// NEW: Grid removal for PDF
const removeGridOnPage2 = ref(false);
// Image Upload & Crop State
const showCropper = ref(false);
const tempImageSrc = ref('');
const customImages = ref([]); // Stores base64 strings
let cropperInstance = null;
// Puzzle Upload State
const isPuzzleUpload = ref(false);
const puzzleConfig = ref({ rows: 1, cols: 1, targetAspectRatio: 1 });
// Image Scale State (for UI toggle)
const currentImageScale = computed(() => {
if (selectedCellIndices.value.size === 0) return 'fit';
// Check first selected cell
const idx = [...selectedCellIndices.value][0];
return activePageCells.value[idx].imageScale || 'fit';
});
// --- Auto-Save Key ---
const AUTOSAVE_KEY = 'magic_origami_autosave_v1';
const activePageCells = computed(() => pages.value[activePageId.value - 1].cells);
const isCustomGridActive = computed(() => {
return !gridOptions.some(opt => opt.label === currentGrid.value.label);
});
// NEW: Computed array to track selection order
const selectedIndicesArray = computed(() => Array.from(selectedCellIndices.value));
// --- Helper: Dynamic Font Size ---
const getFontSizeClass = (content) => {
const len = content ? content.length : 0;
if (len >= 3) return 'text-3xl md:text-5xl';
if (len === 2) return 'text-4xl md:text-6xl';
return 'text-5xl md:text-7xl';
};
const getOverviewFontSizeClass = (content) => {
const len = content ? content.length : 0;
if (len >= 3) return 'text-xl';
if (len === 2) return 'text-2xl';
return 'text-3xl';
};
const getFontSizeForPdf = (content) => {
const len = content ? content.length : 0;
if (len >= 3) return "40";
if (len === 2) return "50";
return "60";
};
// NEW: Helper to get order for UI Badge
const getSelectionOrder = (index) => {
const order = selectedIndicesArray.value.indexOf(index);
return order !== -1 ? order + 1 : '';
};
// --- Actions ---
const changeGridSize = (conf) => {
if (currentGrid.value.label === conf.label) return;
const hasContent = pages.value.some(p => p.cells.some(c => c.content !== ''));
if (hasContent) {
if(!confirm("切換網格設定將會清空您目前的設計,確定要繼續嗎?")) return;
}
currentGrid.value = conf;
const totalCells = conf.rows * conf.cols;
pages.value = [
{ id: 1, cells: createPageCells(0, conf.rows, conf.cols) },
{ id: 2, cells: createPageCells(totalCells, conf.rows, conf.cols) }
];
clearSelection();
activePageId.value = 1;
viewMode.value = 'overview';
};
// Custom Grid Logic
const openCustomGridModal = () => {
const hasContent = pages.value.some(p => p.cells.some(c => c.content !== ''));
if (hasContent) {
if(!confirm("設定自訂網格將會清空您目前的設計,確定要繼續嗎?")) return;
}
customGridConfig.value.rows = currentGrid.value.rows;
customGridConfig.value.cols = currentGrid.value.cols;
showGridModal.value = true;
};
const confirmCustomGrid = () => {
const r = Math.max(1, Math.min(20, parseInt(customGridConfig.value.rows) || 4));
const c = Math.max(1, Math.min(20, parseInt(customGridConfig.value.cols) || 4));
const customConf = {
label: `${r}x${c}`,
rows: r,
cols: c
};
currentGrid.value = customConf;
const totalCells = r * c;
pages.value = [
{ id: 1, cells: createPageCells(0, r, c) },
{ id: 2, cells: createPageCells(totalCells, r, c) }
];
clearSelection();
activePageId.value = 1;
viewMode.value = 'overview';
showGridModal.value = false;
};
const clearAllContent = () => {
const hasContent = pages.value.some(p => p.cells.some(c => c.content !== ''));
if (!hasContent) {
alert("目前畫布是空的,無需清除。");
return;
}
if (confirm("確定要進行一鍵清空嗎?所有編輯的內容將會消失且無法復原。")) {
const rows = currentGrid.value.rows;
const cols = currentGrid.value.cols;
const totalCells = rows * cols;
pages.value = [
{ id: 1, cells: createPageCells(0, rows, cols) },
{ id: 2, cells: createPageCells(totalCells, rows, cols) }
];
clearSelection();
alert("內容已清空!");
}
};
const setCell = (pageIndex, row, col, type, content, rotation = 0, color = '#1e293b', bgColor='#ffffff', imageScale='fit') => {
if (row > currentGrid.value.rows || col > currentGrid.value.cols) return;
const cells = pages.value[pageIndex].cells;
const index = (row - 1) * currentGrid.value.cols + (col - 1);
if (cells[index]) {
cells[index].type = type;
cells[index].content = content;
cells[index].rotation = rotation;
cells[index].color = color;
cells[index].bgColor = bgColor;
cells[index].imageScale = imageScale;
}
};
const applyColor = (color) => {
selectedColor.value = color;
selectedCellIndices.value.forEach(index => {
activePageCells.value[index].color = color;
});
};
const applyBgColor = (bgColor) => {
selectedBgColor.value = bgColor;
selectedCellIndices.value.forEach(index => {
activePageCells.value[index].bgColor = bgColor;
});
};
const toggleImageScale = () => {
const newScale = currentImageScale.value === 'fit' ? 'fill' : 'fit';
selectedCellIndices.value.forEach(index => {
activePageCells.value[index].imageScale = newScale;
});
};
// --- Image Upload Logic ---
const triggerImageUpload = (mode = 'single') => {
isPuzzleUpload.value = (mode === 'puzzle');
imageUploadInput.value.click();
};
const handleImageUpload = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
tempImageSrc.value = e.target.result;
showCropper.value = true;
event.target.value = '';
// Logic for Puzzle Mode aspect ratio
let aspectRatio = 1; // default square
if (isPuzzleUpload.value && selectedCellIndices.value.size > 1) {
// Calculate bounds
const indices = [...selectedCellIndices.value];
const cols = currentGrid.value.cols;
const rows = currentGrid.value.rows;
// Map to Row/Col coords
const coords = indices.map(idx => ({ r: Math.floor(idx / cols), c: idx % cols }));
const minR = Math.min(...coords.map(p => p.r));
const maxR = Math.max(...coords.map(p => p.r));
const minC = Math.min(...coords.map(p => p.c));
const maxC = Math.max(...coords.map(p => p.c));
const selRows = maxR - minR + 1;
const selCols = maxC - minC + 1;
// Calculate A4-based aspect ratio
// A4 W=210, H=297.
// CellW = 210/cols, CellH = 297/rows
// BlockW = selCols * (210/cols)
// BlockH = selRows * (297/rows)
const blockW = selCols * (210 / cols);
const blockH = selRows * (297 / rows);
aspectRatio = blockW / blockH;
puzzleConfig.value = { rows: selRows, cols: selCols, minR, minC, targetAspectRatio: aspectRatio };
}
nextTick(() => {
if (cropperInstance) cropperInstance.destroy();
const options = {
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.9,
background: false
};
if (isPuzzleUpload.value) {
options.aspectRatio = puzzleConfig.value.targetAspectRatio;
} else {
options.aspectRatio = 1; // Default square for single cell
}
cropperInstance = new Cropper(cropperImgRef.value, options);
});
};
reader.readAsDataURL(file);
};
const rotateCropper = (deg) => {
if(cropperInstance) cropperInstance.rotate(deg);
};
const cancelCrop = () => {
showCropper.value = false;
if (cropperInstance) {
cropperInstance.destroy();
cropperInstance = null;
}
tempImageSrc.value = '';
};
const confirmCrop = () => {
if (!cropperInstance) return;
if (isPuzzleUpload.value) {
// --- Puzzle Mode Logic (Manual Placement) ---
const canvas = cropperInstance.getCroppedCanvas();
const imgW = canvas.width;
const imgH = canvas.height;
const pRows = puzzleConfig.value.rows;
const pCols = puzzleConfig.value.cols;
const pieceW = imgW / pCols;
const pieceH = imgH / pRows;
const newImages = [];
// Loop through rows and cols to cut pieces
for(let r = 0; r < pRows; r++) {
for(let c = 0; c < pCols; c++) {
// Create a temporary canvas for this piece
const pCanvas = document.createElement('canvas');
pCanvas.width = pieceW;
pCanvas.height = pieceH;
const pCtx = pCanvas.getContext('2d');
pCtx.drawImage(canvas,
c * pieceW, r * pieceH, pieceW, pieceH, // Source
0, 0, pieceW, pieceH // Dest
);
const pieceData = pCanvas.toDataURL('image/jpeg', 0.9);
newImages.push(pieceData);
}
}
// Add all pieces to customImages list
customImages.value.push(...newImages);
alert(`圖片已成功分割為 ${newImages.length} 張碎片,並加入左側「自定義圖片」列表。\n請點選目標格子,再從列表中選擇對應的碎片填入。`);
} else {
// --- Single Mode Logic ---
const canvas = cropperInstance.getCroppedCanvas({
width: 300,
height: 300
});
const base64 = canvas.toDataURL('image/jpeg', 0.85);
customImages.value.push(base64);
applyCustomImageToCell(base64);
}
cancelCrop();
};
const applyCustomImageToCell = (imgSrc) => {
selectedCellIndices.value.forEach(index => {
const cell = activePageCells.value[index];
cell.type = 'image';
cell.content = imgSrc;
// Default to fill for puzzle pieces or fit for single?
// Let's default to fill if it came from puzzle upload, but here we don't track source.
// For manual apply, 'fill' is usually better for puzzles to avoid white gaps.
cell.imageScale = 'fill';
});
inputBuffer.value = '';
batchInputBuffer.value = '';
};
const removeCustomImage = (idx) => {
if(confirm('確定要移除這張自定義圖片嗎?')) {
customImages.value.splice(idx, 1);
}
};
// --- Export / Import ---
const exportProject = () => {
const projectData = {
version: '1.6',
timestamp: new Date().toISOString(),
grid: currentGrid.value,
pages: pages.value,
customImages: customImages.value
};
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(projectData));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "magic_origami_project.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
};
const triggerImport = () => {
if (fileInputRef.value) fileInputRef.value.click();
};
const importProject = (event) => {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedData = JSON.parse(e.target.result);
if (!importedData.grid || !importedData.pages) {
throw new Error("Invalid project file format");
}
currentGrid.value = importedData.grid;
pages.value = importedData.pages;
if (importedData.customImages && Array.isArray(importedData.customImages)) {
customImages.value = importedData.customImages;
}
clearSelection();
activePageId.value = 1;
viewMode.value = 'overview';
alert("專案匯入成功!");
} catch (err) {
console.error(err);
alert("讀取檔案失敗,請確認檔案格式正確。");
} finally {
event.target.value = '';
}
};
reader.readAsText(file);
};
const applyTemplate = (templateId) => {
// Add Confirmation
if (!confirm("套用模板將會清空您目前的設計內容,確定要套用嗎?")) {
return;
}
try {
if (templateId === 'lucky') {
// Switch to 6x8 grid first
currentGrid.value = gridOptions[2]; // 6x8 is index 2
pages.value = [
{ id: 1, cells: createPageCells(0, 8, 6) },
{ id: 2, cells: createPageCells(48, 8, 6) }
];
// Page 1
setCell(0, 1, 3, 'text', '最', 180);
setCell(0, 1, 4, 'text', '是', 180);
setCell(0, 2, 3, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 2, 4, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 3, 3, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 3, 4, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 3, 5, 'text', '遇', 0);
setCell(0, 3, 6, 'text', '幸', 0);
setCell(0, 6, 3, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 6, 4, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 6, 5, 'text', '見', 0);
setCell(0, 6, 6, 'text', '運', 0);
setCell(0, 7, 3, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 7, 4, 'icon', '幸運草', 0, '#22c55e');
setCell(0, 8, 3, 'text', '就', 180);
setCell(0, 8, 4, 'text', '你', 180);
// Page 2
setCell(1, 1, 6, 'text', '茫', 180);
setCell(1, 2, 6, 'icon', '幸運草', 0, '#22c55e');
setCell(1, 5, 5, 'text', '茫', 0);
setCell(1, 5, 6, 'icon', '幸運草', 0, '#22c55e');
setCell(1, 6, 5, 'text', '人', 0);
setCell(1, 6, 6, 'icon', '幸運草', 0, '#22c55e');
setCell(1, 7, 6, 'icon', '幸運草', 0, '#22c55e');
setCell(1, 8, 6, 'text', '海', 180);
}
else if (templateId === '4x4') {
currentGrid.value = gridOptions[0]; // 4x4
pages.value = [
{ id: 1, cells: createPageCells(0, 4, 4) },
{ id: 2, cells: createPageCells(16, 4, 4) }
];
// Page 1
setCell(0, 1, 2, 'icon', '白圓', 0); setCell(0, 2, 2, 'icon', '白方', 0);
setCell(0, 3, 1, 'icon', '白圓', 0); setCell(0, 3, 2, 'icon', '白方', 0);
setCell(0, 4, 3, 'icon', '白圓', 0); setCell(0, 4, 4, 'icon', '白方', 0);
// Page 2
setCell(1, 1, 1, 'icon', '白圓', 0); setCell(1, 2, 1, 'icon', '白方', 0);
setCell(1, 1, 3, 'text', 'B', 180); setCell(1, 1, 4, 'text', 'A', 180);
setCell(1, 2, 3, 'text', 'B', 180); setCell(1, 2, 4, 'text', 'A', 180);
setCell(1, 3, 1, 'text', 'B', 0); setCell(1, 3, 2, 'text', 'A', 0);
setCell(1, 4, 1, 'text', 'B', 0); setCell(1, 4, 2, 'text', 'A', 0);
}
else if (templateId === '4x6') {
currentGrid.value = gridOptions[1]; // 4x6
pages.value = [
{ id: 1, cells: createPageCells(0, 6, 4) },
{ id: 2, cells: createPageCells(24, 6, 4) }
];
// Page 1
setCell(0, 1, 1, 'icon', '白方', 0); setCell(0, 1, 2, 'icon', '白方', 0);
setCell(0, 2, 1, 'icon', '白圓', 0); setCell(0, 2, 4, 'icon', '白圓', 0);
setCell(0, 3, 1, 'icon', '白圓', 0); setCell(0, 3, 4, 'icon', '白圓', 0);
setCell(0, 4, 1, 'icon', '白圓', 0); setCell(0, 4, 4, 'icon', '白圓', 0);
setCell(0, 5, 1, 'icon', '白方', 0); setCell(0, 5, 4, 'icon', '白方', 0);
setCell(0, 6, 1, 'icon', '白方', 0); setCell(0, 6, 4, 'icon', '白方', 0);
}
else if (templateId === '6x8') {
currentGrid.value = gridOptions[2]; // 6x8
pages.value = [
{ id: 1, cells: createPageCells(0, 8, 6) },
{ id: 2, cells: createPageCells(48, 8, 6) }
];
// Page 1
setCell(0, 1, 1, 'icon', '猴子臉', 0); setCell(0, 1, 2, 'icon', '猴子臉', 0); setCell(0, 1, 5, 'icon', '猴子臉', 0); setCell(0, 1, 6, 'icon', '猴子臉', 0);
setCell(0, 2, 1, 'icon', '猴子臉', 0); setCell(0, 2, 2, 'icon', '猴子臉', 0); setCell(0, 2, 5, 'icon', '猴子臉', 0); setCell(0, 2, 6, 'icon', '猴子臉', 0);
setCell(0, 3, 1, 'icon', '猴子臉', 0); setCell(0, 3, 2, 'icon', '猴子臉', 0); setCell(0, 3, 5, 'icon', '猴子臉', 0); setCell(0, 3, 6, 'icon', '猴子臉', 0);
setCell(0, 6, 1, 'icon', '馬臉', 0); setCell(0, 6, 2, 'icon', '馬臉', 0); setCell(0, 6, 5, 'icon', '馬臉', 0); setCell(0, 6, 6, 'icon', '馬臉', 0);
setCell(0, 7, 1, 'icon', '馬臉', 0); setCell(0, 7, 2, 'icon', '馬臉', 0); setCell(0, 7, 5, 'icon', '馬臉', 0); setCell(0, 7, 6, 'icon', '馬臉', 0);
setCell(0, 8, 1, 'icon', '馬臉', 0); setCell(0, 8, 2, 'icon', '馬臉', 0); setCell(0, 8, 5, 'icon', '馬臉', 0); setCell(0, 8, 6, 'icon', '馬臉', 0);
// Page 2
setCell(1, 1, 1, 'icon', '白圓', 0); setCell(1, 2, 1, 'icon', '白圓', 0); setCell(1, 3, 3, 'icon', '白圓', 0); setCell(1, 3, 6, 'icon', '白圓', 0);
setCell(1, 4, 3, 'icon', '白圓', 0); setCell(1, 4, 6, 'icon', '白圓', 0); setCell(1, 5, 3, 'icon', '白圓', 0); setCell(1, 5, 6, 'icon', '白圓', 0);
setCell(1, 6, 3, 'icon', '白圓', 0); setCell(1, 6, 6, 'icon', '白圓', 0); setCell(1, 7, 1, 'icon', '白圓', 0); setCell(1, 8, 1, 'icon', '白圓', 0);
setCell(1, 1, 2, 'icon', '白方', 0); setCell(1, 1, 3, 'icon', '白方', 0); setCell(1, 1, 6, 'icon', '白方', 0);
setCell(1, 2, 2, 'icon', '白方', 0); setCell(1, 2, 3, 'icon', '白方', 0); setCell(1, 2, 6, 'icon', '白方', 0);
setCell(1, 7, 2, 'icon', '白方', 0); setCell(1, 7, 3, 'icon', '白方', 0); setCell(1, 7, 6, 'icon', '白方', 0);
setCell(1, 8, 2, 'icon', '白方', 0); setCell(1, 8, 3, 'icon', '白方', 0); setCell(1, 8, 6, 'icon', '白方', 0);
}
else if (templateId === '8x8') {
currentGrid.value = gridOptions[3]; // 8x8
pages.value = [
{ id: 1, cells: createPageCells(0, 8, 8) },
{ id: 2, cells: createPageCells(64, 8, 8) }
];
// Page 1
setCell(0, 1, 7, 'icon', '太陽', 0); setCell(0, 2, 7, 'icon', '太陽', 0); setCell(0, 3, 7, 'icon', '太陽', 0); setCell(0, 4, 7, 'icon', '太陽', 0);
setCell(0, 1, 8, 'icon', '星星', 270); setCell(0, 2, 8, 'icon', '星星', 270);
setCell(0, 5, 7, 'icon', '星星', 90); setCell(0, 6, 7, 'icon', '星星', 90);
setCell(0, 3, 1, 'text', '135', 90); setCell(0, 3, 2, 'text', '142', 90); setCell(0, 3, 3, 'text', '148', 90);
setCell(0, 4, 1, 'text', '430', 90, '#1e293b', '#e2e8f0'); setCell(0, 4, 2, 'text', '256', 90, '#1e293b', '#e2e8f0'); setCell(0, 4, 3, 'text', '169', 90, '#1e293b', '#e2e8f0');
setCell(0, 5, 1, 'text', '111', 90, '#1e293b', '#e2e8f0'); setCell(0, 5, 2, 'text', '517', 90, '#1e293b', '#e2e8f0'); setCell(0, 5, 3, 'text', '372', 90, '#1e293b', '#e2e8f0');
setCell(0, 5, 4, 'text', '140', 270); setCell(0, 5, 5, 'text', '134', 270); setCell(0, 5, 6, 'text', '127', 270);
setCell(0, 6, 1, 'text', '488', 90, '#1e293b', '#e2e8f0'); setCell(0, 6, 2, 'text', '198', 90, '#1e293b', '#e2e8f0'); setCell(0, 6, 3, 'text', '227', 90, '#1e293b', '#e2e8f0');
setCell(0, 6, 4, 'text', '114', 270); setCell(0, 6, 5, 'text', '108', 270); setCell(0, 6, 6, 'text', '101', 270);
// Page 2
setCell(1, 5, 1, 'icon', '太陽', 0); setCell(1, 5, 2, 'icon', '太陽', 0); setCell(1, 5, 3, 'icon', '太陽', 0);
setCell(1, 6, 1, 'icon', '太陽', 0); setCell(1, 6, 2, 'icon', '太陽', 0); setCell(1, 6, 3, 'icon', '太陽', 0);
setCell(1, 7, 1, 'icon', '太陽', 0); setCell(1, 7, 2, 'icon', '太陽', 0); setCell(1, 7, 3, 'icon', '太陽', 0);
setCell(1, 8, 1, 'icon', '太陽', 0); setCell(1, 8, 2, 'icon', '太陽', 0); setCell(1, 8, 3, 'icon', '太陽', 0);
setCell(1, 3, 1, 'icon', '星星', 270); setCell(1, 3, 2, 'icon', '星星', 270); setCell(1, 3, 3, 'icon', '星星', 270);
setCell(1, 4, 1, 'icon', '星星', 270); setCell(1, 4, 2, 'icon', '星星', 270); setCell(1, 4, 3, 'icon', '星星', 270);
setCell(1, 7, 4, 'icon', '星星', 90); setCell(1, 7, 5, 'icon', '星星', 90); setCell(1, 7, 6, 'icon', '星星', 90);
setCell(1, 8, 4, 'icon', '星星', 90); setCell(1, 8, 5, 'icon', '星星', 90); setCell(1, 8, 6, 'icon', '星星', 90);
setCell(1, 1, 1, 'text', '285', 90, '#1e293b', '#e2e8f0'); setCell(1, 1, 2, 'text', '343', 90, '#1e293b', '#e2e8f0'); setCell(1, 1, 3, 'text', '546', 90, '#1e293b', '#e2e8f0');
setCell(1, 1, 4, 'text', '126', 270); setCell(1, 1, 5, 'text', '120', 270); setCell(1, 1, 6, 'text', '113', 270);
setCell(1, 2, 7, 'text', '140', 270, '#1e293b', '#e2e8f0'); setCell(1, 2, 8, 'text', '137', 90);
setCell(1, 3, 7, 'text', '401', 270, '#1e293b', '#e2e8f0'); setCell(1, 3, 8, 'text', '125', 90);
setCell(1, 4, 7, 'text', '314', 270, '#1e293b', '#e2e8f0'); setCell(1, 4, 8, 'text', '151', 90);
setCell(1, 7, 8, 'text', '159', 90); setCell(1, 8, 8, 'text', '459', 90);
}
clearSelection();
activePageId.value = 1;
viewMode.value = 'overview';
alert("模板套用成功!");
} catch (e) {
console.error(e);
alert("套用模板時發生錯誤,請重試。");
}
};
const switchToPage = (pageId) => {
activePageId.value = pageId;
viewMode.value = 'edit';
clearSelection();
inputBuffer.value = '';
};
// Enhanced Click Handler for Multi-select with Toggle Switch
const toggleMultiSelectMode = () => {
isMultiSelectMode.value = !isMultiSelectMode.value;
if (!isMultiSelectMode.value) {
// When turning off, if multiple selected, keep them.
// Or we could clear. Keeping seems safer.
}
};
const handleCellClick = (index, event) => {
// Check if Ctrl key is pressed (override toggle) OR Toggle is ON
const isMultiSelect = (event && (event.ctrlKey || event.metaKey)) || isMultiSelectMode.value;
if (isMultiSelect) {
if (selectedCellIndices.value.has(index)) {
selectedCellIndices.value.delete(index);
} else {
selectedCellIndices.value.add(index);
}
// Setup buffer logic similar to before
if (selectedCellIndices.value.size === 1) {
const idx = [...selectedCellIndices.value][0];
const cell = activePageCells.value[idx];
inputBuffer.value = (cell.type === 'text') ? cell.content : '';
selectedColor.value = cell.color;
selectedBgColor.value = cell.bgColor;
nextTick(() => { if (textInputRef.value) textInputRef.value.focus(); });
} else {
inputBuffer.value = '';
batchInputBuffer.value = '';
}
}
else {
// Normal Single Click Mode
// If clicking an already selected cell (single), Rotate it
if (selectedCellIndices.value.has(index) && selectedCellIndices.value.size === 1) {
rotateCurrentCell();
}
else {
// Reset to single selection
selectedCellIndices.value.clear();
selectedCellIndices.value.add(index);
const cell = activePageCells.value[index];
inputBuffer.value = (cell.type === 'text') ? cell.content : '';
selectedColor.value = cell.color;
selectedBgColor.value = cell.bgColor;
nextTick(() => { if (textInputRef.value) textInputRef.value.focus(); });
}
}
};
const clearSelection = () => {
selectedCellIndices.value.clear();
inputBuffer.value = '';
batchInputBuffer.value = '';
selectedColor.value = '#1e293b';
selectedBgColor.value = '#ffffff';
};
const rotateCurrentCell = () => {
if (selectedCellIndices.value.size === 0) return;
selectedCellIndices.value.forEach(index => {
const cell = activePageCells.value[index];
cell.rotation = (cell.rotation + 90) % 360;
});
};
// Single Cell Text Update
const updateSelectedCellText = () => {
if (selectedCellIndices.value.size === 0) return;
selectedCellIndices.value.forEach(index => {
const cell = activePageCells.value[index];
cell.type = 'text';
cell.content = inputBuffer.value;
cell.color = selectedColor.value;
});
};
// Batch Text Update (Sequential with Modulo)
const applyBatchText = () => {
const text = batchInputBuffer.value;
if (!text || selectedCellIndices.value.size === 0) return;
// We need to respect the ORDER of selection.
// However, Set iteration order is insertion order in JS.
// So [...Set] preserves click order.
const indices = [...selectedCellIndices.value];
const charArray = [...text]; // Use spread for unicode safety
indices.forEach((cellIndex, i) => {
// Use modulo to cycle through input text
const char = charArray[i % charArray.length];
const cell = activePageCells.value[cellIndex];
cell.type = 'text';
cell.content = char;
cell.color = selectedColor.value;
});
// Clear buffers after apply
batchInputBuffer.value = '';
inputBuffer.value = '';
};
const applyIconToCell = (iconName) => {
if (selectedCellIndices.value.size === 0) return;
selectedCellIndices.value.forEach(index => {
const cell = activePageCells.value[index];
cell.type = 'icon';
cell.content = iconName;
cell.color = selectedColor.value;
});
inputBuffer.value = '';
batchInputBuffer.value = '';
};
const clearCurrentCell = () => {
if (selectedCellIndices.value.size === 0) return;
selectedCellIndices.value.forEach(index => {
const cell = activePageCells.value[index];
cell.content = '';
cell.type = 'text';
cell.rotation = 0;
cell.color = '#1e293b';
cell.bgColor = '#ffffff'; // Reset BG
});
inputBuffer.value = '';
batchInputBuffer.value = '';
selectedColor.value = '#1e293b';
selectedBgColor.value = '#ffffff';
};
// --- Auto Save & Init ---
onMounted(() => {
// 1. Auto-save restoration check
const savedData = localStorage.getItem(AUTOSAVE_KEY);
if (savedData) {
try {
const parsed = JSON.parse(savedData);
const hasContent = parsed.pages.some(p => p.cells.some(c => c.content !== ''));
if (hasContent) {
const restore = confirm("發現上次未完成的自動存檔,是否要還原進度?");
if (restore) {
currentGrid.value = parsed.grid;
pages.value = parsed.pages;
customImages.value = parsed.customImages || [];
}
}
} catch (e) {
console.error("Auto-save parse error", e);
}
}
// 2. Auto-save Watcher
watch([currentGrid, pages, customImages], () => {
const dataToSave = {
grid: currentGrid.value,
pages: pages.value,
customImages: customImages.value
};
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(dataToSave));
}, { deep: true });
});
// Helper to replace text element with SVG for perfect centering
const replaceWithSvgText = (el, content, color, fontFamily, fontWeight, fontSize) => {
const ns = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(ns, "svg");
svg.setAttribute("width", "100%");
svg.setAttribute("height", "100%");
svg.setAttribute("viewBox", "0 0 100 100");
svg.style.position = "absolute";
svg.style.top = "0";
svg.style.left = "0";
const textNode = document.createElementNS(ns, "text");
textNode.setAttribute("x", "50%");
textNode.setAttribute("y", "50%");
textNode.setAttribute("dominant-baseline", "central");
textNode.setAttribute("text-anchor", "middle");
textNode.setAttribute("fill", color);
textNode.setAttribute("font-family", fontFamily);
textNode.setAttribute("font-weight", fontWeight);
textNode.setAttribute("font-size", fontSize);
textNode.textContent = content;
svg.appendChild(textNode);
const parent = el.parentNode;
parent.style.position = "relative";
parent.innerHTML = '';
parent.appendChild(svg);
};
// ... PDF Export Logic ...
const openExportModal = () => {
showExportModal.value = true;
};
const processExport = (type) => {
showExportModal.value = false;
if (type === 'pdf') {
exportPDF();
} else if (type === 'pptx') {
exportPPTX();
} else if (type === 'jpg') {
exportJPG();
}
};
const renderPageToCanvas = async (pageId, hideGrid = false) => {
const pageData = pages.value[pageId - 1];
const rows = currentGrid.value.rows;
const cols = currentGrid.value.cols;
const container = document.getElementById('pdf-generator-container');
container.innerHTML = '';
const wrapper = document.createElement('div');
wrapper.style.width = '210mm';
wrapper.style.height = '297mm';
wrapper.style.backgroundColor = 'white';
wrapper.style.position = 'relative';
// Logic for grid borders
const borderStyle = hideGrid ? 'border: none;' : 'border: 1px solid #cbd5e1;';
let gridHtml = `<div style="display: grid; grid-template-columns: repeat(${cols}, 1fr); grid-template-rows: repeat(${rows}, 1fr); width: 100%; height: 100%; border: 1px solid #e2e8f0;">`;
pageData.cells.forEach((cell, idx) => {
let contentHtml = '';
// Dynamic Font Size for Export
const fontSize = getFontSizeForPdf(cell.content);
if (cell.type === 'text') {
// Text uses the SVG text replacement trick for perfect centering
contentHtml = `<span class="export-text" data-color="${cell.color}" data-size="${fontSize}" style="font-size: ${fontSize}px; font-weight: bold; color: ${cell.color}; font-family: 'Noto Sans TC', sans-serif;">${cell.content}</span>`;
} else if (cell.type === 'icon') {
// Icon (Emoji) -> Treat as Text for Export to fix offset
const iconChar = icons[cell.content];
contentHtml = `<span class="export-text" data-color="${cell.color}" data-size="50" style="font-size: 50px; line-height: 1; user-select: none;">${iconChar}</span>`;
} else if (cell.type === 'image') {
// Check if image scale is 'fill' (Full Bleed)
const imgStyle = cell.imageScale === 'fill'
? 'width: 100%; height: 100%; object-fit: cover;'
: 'width: 80%; height: 80%; object-fit: contain;';
contentHtml = `<img src="${cell.content}" style="${imgStyle}">`;
}
// New: Inner Highlight for PDF Export
let innerBg = '';
if (cell.bgColor && cell.bgColor !== '#ffffff') {
innerBg = `<div style="position: absolute; top: 7.5%; left: 7.5%; width: 85%; height: 85%; background-color: ${cell.bgColor}; border-radius: 8px; z-index: 0;"></div>`;
}
gridHtml += `
<div class="grid-cell" style="position: relative; ${borderStyle} background-color: transparent; display: flex; align-items: center; justify-content: center; overflow: hidden;">
${innerBg}
<div style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; transform: rotate(${cell.rotation}deg); z-index: 1; position: relative;">
${contentHtml}
</div>
</div>
`;
});
gridHtml += `</div>`;
// --- Watermark Logic ---
if (exportName.value || exportProjectName.value) {
const watermarkText = `${exportName.value ? exportName.value + '的' : ''}${exportProjectName.value || ''}`;
if (watermarkText) {
gridHtml += `<div style="position: absolute; bottom: 15mm; right: 15mm; color: #94a3b8; font-size: 12px; font-family: 'Noto Sans TC', sans-serif;">${watermarkText}</div>`;
}
}
wrapper.innerHTML = gridHtml;
container.appendChild(wrapper);
const canvas = await html2canvas(wrapper, {
scale: 3,
useCORS: true,
backgroundColor: '#ffffff',
onclone: (clonedDoc) => {
// Handle Text Centering (Only for Text/Emoji now)
const textElements = clonedDoc.querySelectorAll('.export-text');
textElements.forEach(el => {
const textContent = el.innerText;
const color = el.getAttribute('data-color');
const size = el.getAttribute('data-size') || "60";
if (!textContent) return;
replaceWithSvgText(el, textContent, color, "'Noto Sans TC', sans-serif", "bold", size);
});
}
});
return canvas;
};
const exportPDF = async () => {
if (selectedCellIndices.value.size > 0) clearSelection();
isGenerating.value = true;
// Determine filename
let fileName = 'magic-origami-booklet.pdf';
const namePart = exportName.value ? exportName.value : '';
const projPart = exportProjectName.value ? exportProjectName.value : '';
if (namePart || projPart) fileName = `${namePart}${namePart && projPart ? '-' : ''}${projPart || '作品'}.pdf`;
try {
const pdf = new jsPDF('p', 'mm', 'a4');
const pdfWidth = 210;
const pdfHeight = 297;
// Page 1 (Never hide grid)
const canvas1 = await renderPageToCanvas(1, false);
const imgData1 = canvas1.toDataURL('image/jpeg', 0.95);
pdf.addImage(imgData1, 'JPEG', 0, 0, pdfWidth, pdfHeight);
pdf.addPage();
// Page 2 (Hide grid if requested)
const canvas2 = await renderPageToCanvas(2, removeGridOnPage2.value);
const imgData2 = canvas2.toDataURL('image/jpeg', 0.95);
pdf.addImage(imgData2, 'JPEG', 0, 0, pdfWidth, pdfHeight);
pdf.save(fileName);
} catch (err) {
console.error(err);
alert("PDF 生成發生錯誤");
} finally {
isGenerating.value = false;
document.getElementById('pdf-generator-container').innerHTML = '';
}
};
const exportPPTX = async () => {
if (selectedCellIndices.value.size > 0) clearSelection();
isGenerating.value = true;
try {
const pres = new PptxGenJS();
// Fix for Error: UNKNOWN-LAYOUT
// Explicitly define the layout size (A4 in inches)
pres.defineLayout({ name: 'A4', width: 8.27, height: 11.69 });
pres.layout = 'A4';
const pageWidth = 8.27;
const pageHeight = 11.69;
const rows = currentGrid.value.rows;
const cols = currentGrid.value.cols;
const cellW = pageWidth / cols;
const cellH = pageHeight / rows;
// Determine filename
let fileName = 'magic-origami-project';
const namePart = exportName.value ? exportName.value : '';
const projPart = exportProjectName.value ? exportProjectName.value : '';
if (namePart || projPart) fileName = `${namePart}${namePart && projPart ? '-' : ''}${projPart || '作品'}`;
pages.value.forEach((pageData, pageIndex) => {
const slide = pres.addSlide();
// Add cells
pageData.cells.forEach((cell, idx) => {
const r = Math.floor(idx / cols);
const c = idx % cols;
const x = c * cellW;
const y = r * cellH;
// 1. Highlight (Inner Background)
if (cell.bgColor !== '#ffffff') {
const padW = cellW * 0.075; // 7.5% padding
const padH = cellH * 0.075;
slide.addShape(pres.ShapeType.roundRect, {
x: x + padW, y: y + padH,
w: cellW * 0.85, h: cellH * 0.85,
fill: { color: cell.bgColor.replace('#', '') },
line: { type: 'none' }, // No border on highlight
rectRadius: 0.2
});
}
// 2. Grid Border (Always draw transparent rect with border)
// This ensures grid lines are visible and clean
slide.addShape(pres.ShapeType.rect, {
x: x, y: y, w: cellW, h: cellH,
fill: { type: 'none' },
line: { color: 'CCCCCC', width: 0.5 }
});
// 3. Content
if (cell.content) {
if (cell.type === 'text' || cell.type === 'icon') {
let content = cell.content;
if (cell.type === 'icon') content = icons[cell.content] || cell.content;
// Determine font size roughly
let fontSize = 24;
const len = content.length;
if (len >= 3) fontSize = 20;
if (len === 2) fontSize = 28;
if (len === 1) fontSize = 36;
if (cell.type === 'icon') fontSize = 32;
slide.addText(content, {
x: x, y: y, w: cellW, h: cellH,
align: 'center',
valign: 'middle',
fontSize: fontSize,
fontFace: 'Arial', // Fallback font
color: cell.color.replace('#', ''),
rotate: cell.rotation
});
} else if (cell.type === 'image') {
// Image
let imgX = x, imgY = y, imgW = cellW, imgH = cellH;
// Check fill vs fit
if (cell.imageScale === 'fill') {
// Full Bleed: x, y, w, h are full cell
} else {
// Fit: 80% with padding
const padW = cellW * 0.1;
const padH = cellH * 0.1;
imgX += padW;
imgY += padH;
imgW *= 0.8;
imgH *= 0.8;
}
slide.addImage({
data: cell.content,
x: imgX,
y: imgY,
w: imgW,
h: imgH,
rotate: cell.rotation
});
}
}
});
// Watermark
if (exportName.value || exportProjectName.value) {
const watermarkText = `${exportName.value ? exportName.value + '的' : ''}${exportProjectName.value || ''}`;
slide.addText(watermarkText, {
x: pageWidth - 3, y: pageHeight - 0.8, w: 2.5, h: 0.5,
align: 'right', fontSize: 10, color: '999999'
});
}
});
await pres.writeFile({ fileName: `${fileName}.pptx` });
} catch (err) {
console.error("PPTX Error", err);
alert("PPTX 生成發生錯誤");
} finally {
isGenerating.value = false;
}
};
const exportJPG = async () => {
if (selectedCellIndices.value.size > 0) clearSelection();
isGenerating.value = true;
// Filename base
let fileNameBase = 'magic-origami';
const namePart = exportName.value ? exportName.value : '';
const projPart = exportProjectName.value ? exportProjectName.value : '';
if (namePart || projPart) fileNameBase = `${namePart}${namePart && projPart ? '-' : ''}${projPart || '作品'}`;
try {
// Page 1
const canvas1 = await renderPageToCanvas(1, false);
const link1 = document.createElement('a');
link1.href = canvas1.toDataURL('image/jpeg', 1.0);
link1.download = `${fileNameBase}-第1頁.jpg`;
link1.click();
// Small delay to prevent browser blocking multiple downloads
await new Promise(r => setTimeout(r, 500));
// Page 2
const canvas2 = await renderPageToCanvas(2, removeGridOnPage2.value);
const link2 = document.createElement('a');
link2.href = canvas2.toDataURL('image/jpeg', 1.0);
link2.download = `${fileNameBase}-第2頁.jpg`;
link2.click();
} catch (err) {
console.error(err);
alert("JPG 生成發生錯誤");
} finally {
isGenerating.value = false;
document.getElementById('pdf-generator-container').innerHTML = '';
}
};
return {
gridOptions,
currentGrid,
changeGridSize,
applyTemplate,
pages,
viewMode,
activePageId,
activePageCells,
selectedCellIndices,
inputBuffer,
batchInputBuffer,
icons,
colors,
bgColors,
selectedColor,
selectedBgColor,
applyColor,
applyBgColor,
textInputRef,
batchInputRef,
isGenerating,
switchToPage,
handleCellClick,
rotateCurrentCell,
updateSelectedCellText,
applyBatchText,
applyIconToCell,
clearCurrentCell,
clearAllContent,
exportProject,
triggerImport,
importProject,
fileInputRef,
// Image Upload Refs & Methods
imageUploadInput,
cropperImgRef,
showCropper,
tempImageSrc,
customImages,
triggerImageUpload,
handleImageUpload,
cancelCrop,
confirmCrop,
rotateCropper,
applyCustomImageToCell,
removeCustomImage,
// Custom Grid
showGridModal,
customGridConfig,
openCustomGridModal,
confirmCustomGrid,
isCustomGridActive,
getFontSizeClass,
getOverviewFontSizeClass,
// Export Modal
showExportModal,
openExportModal,
processExport,
exportName,
exportProjectName,
removeGridOnPage2, // Export ref
// Multi-select
isMultiSelectMode,
toggleMultiSelectMode,
clearSelection,
getSelectionOrder,
selectedIndicesArray,
// Puzzle
toggleImageScale,
currentImageScale
};
}
}).mount('#app');
</script>
</body>
</html>