|
|
<!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> |
|
|
|
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
|
|
|
|
|
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></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/jspdf/2.5.1/jspdf.umd.min.js"></script> |
|
|
|
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/pptxgenjs@3.12.0/dist/pptxgen.bundle.js"></script> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
|
|
|
|
|
|
|
|
<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; |
|
|
z-index: 10; |
|
|
} |
|
|
|
|
|
#pdf-generator-container { |
|
|
position: absolute; |
|
|
top: -9999px; |
|
|
left: -9999px; |
|
|
} |
|
|
|
|
|
|
|
|
.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-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"> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<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"> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div class="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar"> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div> |
|
|
<label class="block text-sm font-bold text-slate-700 mb-2">🎁 快速模板</label> |
|
|
<div class="space-y-2"> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div v-if="viewMode === 'edit'" class="space-y-8 animate-fade-in pb-10"> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div class="space-y-4"> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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' }" |
|
|
> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<div> |
|
|
<div class="flex justify-between items-center mb-1"> |
|
|
<label class="block text-sm font-bold text-slate-700">5. 自定義圖片</label> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
<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"> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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"> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<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"> |
|
|
|
|
|
|
|
|
<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" |
|
|
> |
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<span v-if="cell.type === 'icon'" class="text-2xl leading-none select-none">{{ icons[cell.content] }}</span> |
|
|
|
|
|
|
|
|
<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> |
|
|
|
|
|
|
|
|
<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"> |
|
|
|
|
|
<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) }" |
|
|
> |
|
|
|
|
|
<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 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> |
|
|
|
|
|
|
|
|
<span v-if="cell.type === 'icon'" class="text-5xl md:text-7xl leading-none select-none">{{ icons[cell.content] }}</span> |
|
|
|
|
|
|
|
|
|
|
|
<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 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> |
|
|
|
|
|
<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() { |
|
|
|
|
|
const gridOptions = [ |
|
|
{ label: '4x4', rows: 4, cols: 4 }, |
|
|
{ label: '4x6', rows: 6, cols: 4 }, |
|
|
{ label: '6x8', rows: 8, cols: 6 }, |
|
|
{ label: '8x8', rows: 8, cols: 8 } |
|
|
]; |
|
|
const currentGrid = ref(gridOptions[2]); |
|
|
|
|
|
|
|
|
const colors = ['#1e293b', '#ef4444', '#f97316', '#f59e0b', '#22c55e', '#14b8a6', '#3b82f6', '#6366f1', '#a855f7', '#ec4899']; |
|
|
|
|
|
|
|
|
const bgColors = ['#ffffff', '#fecaca', '#fed7aa', '#fef08a', '#bbf7d0', '#a5f3fc', '#bfdbfe', '#ddd6fe', '#fbcfe8', '#e2e8f0']; |
|
|
|
|
|
|
|
|
const icons = { |
|
|
'幸運草': '🍀', |
|
|
'愛心': '❤️', |
|
|
'星星': '⭐', |
|
|
'勝利': '✌️', |
|
|
'獎盃': '🏆', |
|
|
'笑臉': '😊', |
|
|
'皇冠': '👑', |
|
|
'鑽石': '💎', |
|
|
'燈泡': '💡', |
|
|
'太陽': '☀️', |
|
|
'月亮': '🌙', |
|
|
'雲朵': '☁️', |
|
|
'音符': '🎵', |
|
|
'飛機': '✈️', |
|
|
'花朵': '🌸', |
|
|
'樹木': '🌳', |
|
|
'禮物': '🎁', |
|
|
'猴子臉': '🐵', |
|
|
'猴子': '🐒', |
|
|
'馬臉': '🐴', |
|
|
'馬': '🐎', |
|
|
'白圓': '⚪', |
|
|
'白方': '⬜' |
|
|
}; |
|
|
|
|
|
|
|
|
const createPageCells = (pageOffset, rows, cols) => Array.from({ length: rows * cols }, (_, i) => ({ |
|
|
id: pageOffset + i, |
|
|
type: 'text', |
|
|
content: '', |
|
|
rotation: 0, |
|
|
color: '#1e293b', |
|
|
bgColor: '#ffffff', |
|
|
imageScale: 'fit' |
|
|
})); |
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
|
|
|
const selectedCellIndices = ref(new Set()); |
|
|
const isMultiSelectMode = ref(false); |
|
|
|
|
|
const inputBuffer = ref(''); |
|
|
const batchInputBuffer = ref(''); |
|
|
const selectedColor = ref('#1e293b'); |
|
|
const selectedBgColor = ref('#ffffff'); |
|
|
const isGenerating = ref(false); |
|
|
|
|
|
|
|
|
const textInputRef = ref(null); |
|
|
const batchInputRef = ref(null); |
|
|
const fileInputRef = ref(null); |
|
|
const imageUploadInput = ref(null); |
|
|
const cropperImgRef = ref(null); |
|
|
|
|
|
|
|
|
const showGridModal = ref(false); |
|
|
const customGridConfig = ref({ rows: 10, cols: 10 }); |
|
|
|
|
|
|
|
|
const showExportModal = ref(false); |
|
|
const exportName = ref(''); |
|
|
const exportProjectName = ref(''); |
|
|
|
|
|
const removeGridOnPage2 = ref(false); |
|
|
|
|
|
|
|
|
const showCropper = ref(false); |
|
|
const tempImageSrc = ref(''); |
|
|
const customImages = ref([]); |
|
|
let cropperInstance = null; |
|
|
|
|
|
|
|
|
const isPuzzleUpload = ref(false); |
|
|
const puzzleConfig = ref({ rows: 1, cols: 1, targetAspectRatio: 1 }); |
|
|
|
|
|
|
|
|
const currentImageScale = computed(() => { |
|
|
if (selectedCellIndices.value.size === 0) return 'fit'; |
|
|
|
|
|
const idx = [...selectedCellIndices.value][0]; |
|
|
return activePageCells.value[idx].imageScale || 'fit'; |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
}); |
|
|
|
|
|
|
|
|
const selectedIndicesArray = computed(() => Array.from(selectedCellIndices.value)); |
|
|
|
|
|
|
|
|
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"; |
|
|
}; |
|
|
|
|
|
|
|
|
const getSelectionOrder = (index) => { |
|
|
const order = selectedIndicesArray.value.indexOf(index); |
|
|
return order !== -1 ? order + 1 : ''; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
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'; |
|
|
}; |
|
|
|
|
|
|
|
|
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; |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
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 = ''; |
|
|
|
|
|
|
|
|
let aspectRatio = 1; |
|
|
if (isPuzzleUpload.value && selectedCellIndices.value.size > 1) { |
|
|
|
|
|
const indices = [...selectedCellIndices.value]; |
|
|
const cols = currentGrid.value.cols; |
|
|
const rows = currentGrid.value.rows; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
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) { |
|
|
|
|
|
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 = []; |
|
|
|
|
|
|
|
|
for(let r = 0; r < pRows; r++) { |
|
|
for(let c = 0; c < pCols; c++) { |
|
|
|
|
|
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, |
|
|
0, 0, pieceW, pieceH |
|
|
); |
|
|
|
|
|
const pieceData = pCanvas.toDataURL('image/jpeg', 0.9); |
|
|
newImages.push(pieceData); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
customImages.value.push(...newImages); |
|
|
|
|
|
alert(`圖片已成功分割為 ${newImages.length} 張碎片,並加入左側「自定義圖片」列表。\n請點選目標格子,再從列表中選擇對應的碎片填入。`); |
|
|
|
|
|
} else { |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
|
|
|
cell.imageScale = 'fill'; |
|
|
}); |
|
|
inputBuffer.value = ''; |
|
|
batchInputBuffer.value = ''; |
|
|
}; |
|
|
|
|
|
const removeCustomImage = (idx) => { |
|
|
if(confirm('確定要移除這張自定義圖片嗎?')) { |
|
|
customImages.value.splice(idx, 1); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
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) => { |
|
|
|
|
|
if (!confirm("套用模板將會清空您目前的設計內容,確定要套用嗎?")) { |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
if (templateId === 'lucky') { |
|
|
|
|
|
currentGrid.value = gridOptions[2]; |
|
|
pages.value = [ |
|
|
{ id: 1, cells: createPageCells(0, 8, 6) }, |
|
|
{ id: 2, cells: createPageCells(48, 8, 6) } |
|
|
]; |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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]; |
|
|
pages.value = [ |
|
|
{ id: 1, cells: createPageCells(0, 4, 4) }, |
|
|
{ id: 2, cells: createPageCells(16, 4, 4) } |
|
|
]; |
|
|
|
|
|
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); |
|
|
|
|
|
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]; |
|
|
pages.value = [ |
|
|
{ id: 1, cells: createPageCells(0, 6, 4) }, |
|
|
{ id: 2, cells: createPageCells(24, 6, 4) } |
|
|
]; |
|
|
|
|
|
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]; |
|
|
pages.value = [ |
|
|
{ id: 1, cells: createPageCells(0, 8, 6) }, |
|
|
{ id: 2, cells: createPageCells(48, 8, 6) } |
|
|
]; |
|
|
|
|
|
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); |
|
|
|
|
|
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]; |
|
|
pages.value = [ |
|
|
{ id: 1, cells: createPageCells(0, 8, 8) }, |
|
|
{ id: 2, cells: createPageCells(64, 8, 8) } |
|
|
]; |
|
|
|
|
|
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); |
|
|
|
|
|
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 = ''; |
|
|
}; |
|
|
|
|
|
|
|
|
const toggleMultiSelectMode = () => { |
|
|
isMultiSelectMode.value = !isMultiSelectMode.value; |
|
|
if (!isMultiSelectMode.value) { |
|
|
|
|
|
|
|
|
} |
|
|
}; |
|
|
|
|
|
const handleCellClick = (index, event) => { |
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
|
|
|
|
|
|
if (selectedCellIndices.value.has(index) && selectedCellIndices.value.size === 1) { |
|
|
rotateCurrentCell(); |
|
|
} |
|
|
else { |
|
|
|
|
|
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; |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
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; |
|
|
}); |
|
|
}; |
|
|
|
|
|
|
|
|
const applyBatchText = () => { |
|
|
const text = batchInputBuffer.value; |
|
|
if (!text || selectedCellIndices.value.size === 0) return; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const indices = [...selectedCellIndices.value]; |
|
|
const charArray = [...text]; |
|
|
|
|
|
indices.forEach((cellIndex, i) => { |
|
|
|
|
|
const char = charArray[i % charArray.length]; |
|
|
const cell = activePageCells.value[cellIndex]; |
|
|
cell.type = 'text'; |
|
|
cell.content = char; |
|
|
cell.color = selectedColor.value; |
|
|
}); |
|
|
|
|
|
|
|
|
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'; |
|
|
}); |
|
|
inputBuffer.value = ''; |
|
|
batchInputBuffer.value = ''; |
|
|
selectedColor.value = '#1e293b'; |
|
|
selectedBgColor.value = '#ffffff'; |
|
|
}; |
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => { |
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
watch([currentGrid, pages, customImages], () => { |
|
|
const dataToSave = { |
|
|
grid: currentGrid.value, |
|
|
pages: pages.value, |
|
|
customImages: customImages.value |
|
|
}; |
|
|
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(dataToSave)); |
|
|
}, { deep: true }); |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
}; |
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
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 = ''; |
|
|
|
|
|
const fontSize = getFontSizeForPdf(cell.content); |
|
|
|
|
|
if (cell.type === 'text') { |
|
|
|
|
|
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') { |
|
|
|
|
|
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') { |
|
|
|
|
|
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}">`; |
|
|
} |
|
|
|
|
|
|
|
|
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>`; |
|
|
|
|
|
|
|
|
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) => { |
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
const canvas1 = await renderPageToCanvas(1, false); |
|
|
const imgData1 = canvas1.toDataURL('image/jpeg', 0.95); |
|
|
pdf.addImage(imgData1, 'JPEG', 0, 0, pdfWidth, pdfHeight); |
|
|
|
|
|
pdf.addPage(); |
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
pageData.cells.forEach((cell, idx) => { |
|
|
const r = Math.floor(idx / cols); |
|
|
const c = idx % cols; |
|
|
const x = c * cellW; |
|
|
const y = r * cellH; |
|
|
|
|
|
|
|
|
if (cell.bgColor !== '#ffffff') { |
|
|
const padW = cellW * 0.075; |
|
|
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' }, |
|
|
rectRadius: 0.2 |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
slide.addShape(pres.ShapeType.rect, { |
|
|
x: x, y: y, w: cellW, h: cellH, |
|
|
fill: { type: 'none' }, |
|
|
line: { color: 'CCCCCC', width: 0.5 } |
|
|
}); |
|
|
|
|
|
|
|
|
if (cell.content) { |
|
|
if (cell.type === 'text' || cell.type === 'icon') { |
|
|
let content = cell.content; |
|
|
if (cell.type === 'icon') content = icons[cell.content] || cell.content; |
|
|
|
|
|
|
|
|
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', |
|
|
color: cell.color.replace('#', ''), |
|
|
rotate: cell.rotation |
|
|
}); |
|
|
} else if (cell.type === 'image') { |
|
|
|
|
|
let imgX = x, imgY = y, imgW = cellW, imgH = cellH; |
|
|
|
|
|
|
|
|
if (cell.imageScale === 'fill') { |
|
|
|
|
|
} else { |
|
|
|
|
|
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 |
|
|
}); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
await new Promise(r => setTimeout(r, 500)); |
|
|
|
|
|
|
|
|
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, |
|
|
|
|
|
imageUploadInput, |
|
|
cropperImgRef, |
|
|
showCropper, |
|
|
tempImageSrc, |
|
|
customImages, |
|
|
triggerImageUpload, |
|
|
handleImageUpload, |
|
|
cancelCrop, |
|
|
confirmCrop, |
|
|
rotateCropper, |
|
|
applyCustomImageToCell, |
|
|
removeCustomImage, |
|
|
|
|
|
showGridModal, |
|
|
customGridConfig, |
|
|
openCustomGridModal, |
|
|
confirmCustomGrid, |
|
|
isCustomGridActive, |
|
|
getFontSizeClass, |
|
|
getOverviewFontSizeClass, |
|
|
|
|
|
showExportModal, |
|
|
openExportModal, |
|
|
processExport, |
|
|
exportName, |
|
|
exportProjectName, |
|
|
removeGridOnPage2, |
|
|
|
|
|
isMultiSelectMode, |
|
|
toggleMultiSelectMode, |
|
|
clearSelection, |
|
|
getSelectionOrder, |
|
|
selectedIndicesArray, |
|
|
|
|
|
toggleImageScale, |
|
|
currentImageScale |
|
|
}; |
|
|
} |
|
|
}).mount('#app'); |
|
|
</script> |
|
|
</body> |
|
|
</html> |