| | <!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> |
| | |
| | |
| | <script src="https://cdn.jsdelivr.net/npm/idb-keyval@6/dist/iife/index.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; |
| | |
| | transition: transform 0.3s ease-in-out; |
| | transform-origin: center center; |
| | } |
| | |
| | .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 relative"> |
| | <div class="flex justify-between items-center mb-4"> |
| | <h1 class="text-2xl font-bold text-indigo-600 flex items-center gap-2"> |
| | <span class="text-3xl">🎩</span> |
| | 摺紙魔術設計師 |
| | </h1> |
| | </div> |
| |
|
| | |
| | <div class="flex gap-2 mb-4"> |
| | <button |
| | @click="undo" |
| | :disabled="historyIndex <= 0" |
| | class="flex-1 py-2 bg-white border border-slate-300 rounded-lg text-slate-700 font-bold text-sm hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2 shadow-sm" |
| | title="復原 (Ctrl+Z)" |
| | > |
| | <i class="fa-solid fa-rotate-left"></i> 復原 |
| | </button> |
| | <button |
| | @click="redo" |
| | :disabled="historyIndex >= history.length - 1" |
| | class="flex-1 py-2 bg-white border border-slate-300 rounded-lg text-slate-700 font-bold text-sm hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2 shadow-sm" |
| | title="重做 (Ctrl+Y)" |
| | > |
| | <i class="fa-solid fa-rotate-right"></i> 重做 |
| | </button> |
| | </div> |
| | |
| | |
| | <div class="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" |
| | @blur="saveHistory" |
| | 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, onUnmounted, watch } = Vue; |
| | const { jsPDF } = window.jspdf; |
| | |
| | |
| | const db = window.idbKeyval || { |
| | get: async () => null, |
| | set: async () => {}, |
| | del: async () => {} |
| | }; |
| | |
| | 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 history = ref([]); |
| | const historyIndex = ref(-1); |
| | const MAX_HISTORY = 30; |
| | const isUndoing = ref(false); |
| | |
| | const saveHistory = () => { |
| | if (isUndoing.value) return; |
| | |
| | |
| | if (historyIndex.value < history.value.length - 1) { |
| | history.value = history.value.slice(0, historyIndex.value + 1); |
| | } |
| | |
| | |
| | const snapshot = JSON.parse(JSON.stringify({ |
| | grid: currentGrid.value, |
| | pages: pages.value, |
| | customImages: customImages.value |
| | })); |
| | |
| | history.value.push(snapshot); |
| | historyIndex.value++; |
| | |
| | |
| | if (history.value.length > MAX_HISTORY) { |
| | history.value.shift(); |
| | historyIndex.value--; |
| | } |
| | }; |
| | |
| | const undo = () => { |
| | if (historyIndex.value > 0) { |
| | isUndoing.value = true; |
| | historyIndex.value--; |
| | loadSnapshot(history.value[historyIndex.value]); |
| | |
| | nextTick(() => { isUndoing.value = false; }); |
| | } |
| | }; |
| | |
| | const redo = () => { |
| | if (historyIndex.value < history.value.length - 1) { |
| | isUndoing.value = true; |
| | historyIndex.value++; |
| | loadSnapshot(history.value[historyIndex.value]); |
| | nextTick(() => { isUndoing.value = false; }); |
| | } |
| | }; |
| | |
| | const loadSnapshot = (snapshot) => { |
| | currentGrid.value = snapshot.grid; |
| | pages.value = snapshot.pages; |
| | customImages.value = snapshot.customImages; |
| | |
| | |
| | |
| | clearSelection(); |
| | }; |
| | |
| | const handleKeyboardShortcuts = (e) => { |
| | if ((e.ctrlKey || e.metaKey) && e.key === 'z') { |
| | e.preventDefault(); |
| | if (e.shiftKey) { |
| | redo(); |
| | } else { |
| | undo(); |
| | } |
| | } else if ((e.ctrlKey || e.metaKey) && e.key === 'y') { |
| | e.preventDefault(); |
| | redo(); |
| | } |
| | }; |
| | |
| | |
| | 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'; |
| | |
| | saveHistory(); |
| | }; |
| | |
| | |
| | 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; |
| | |
| | saveHistory(); |
| | }; |
| | |
| | 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(); |
| | saveHistory(); |
| | 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; |
| | }); |
| | saveHistory(); |
| | }; |
| | |
| | const applyBgColor = (bgColor) => { |
| | selectedBgColor.value = bgColor; |
| | selectedCellIndices.value.forEach(index => { |
| | activePageCells.value[index].bgColor = bgColor; |
| | }); |
| | saveHistory(); |
| | }; |
| | |
| | const toggleImageScale = () => { |
| | const newScale = currentImageScale.value === 'fit' ? 'fill' : 'fit'; |
| | selectedCellIndices.value.forEach(index => { |
| | activePageCells.value[index].imageScale = newScale; |
| | }); |
| | saveHistory(); |
| | }; |
| | |
| | |
| | |
| | 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); |
| | } |
| | |
| | saveHistory(); |
| | 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 = ''; |
| | saveHistory(); |
| | }; |
| | |
| | const removeCustomImage = (idx) => { |
| | if(confirm('確定要移除這張自定義圖片嗎?')) { |
| | customImages.value.splice(idx, 1); |
| | saveHistory(); |
| | } |
| | }; |
| | |
| | |
| | |
| | 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'; |
| | |
| | saveHistory(); |
| | 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, '#1e293b', '#e2e8f0'); |
| | } |
| | |
| | clearSelection(); |
| | activePageId.value = 1; |
| | viewMode.value = 'overview'; |
| | |
| | saveHistory(); |
| | 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; |
| | }); |
| | saveHistory(); |
| | }; |
| | |
| | |
| | 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 = ''; |
| | |
| | saveHistory(); |
| | }; |
| | |
| | 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 = ''; |
| | |
| | saveHistory(); |
| | }; |
| | |
| | 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'; |
| | |
| | saveHistory(); |
| | }; |
| | |
| | |
| | |
| | onMounted(async () => { |
| | |
| | window.addEventListener('keydown', handleKeyboardShortcuts); |
| | |
| | |
| | try { |
| | const savedData = await db.get(AUTOSAVE_KEY); |
| | |
| | |
| | let parsed = savedData; |
| | if (!parsed) { |
| | const lsData = localStorage.getItem(AUTOSAVE_KEY); |
| | if (lsData) { |
| | try { |
| | parsed = JSON.parse(lsData); |
| | console.log("Migrating from LocalStorage to IndexedDB..."); |
| | } catch(e) {} |
| | } |
| | } |
| | |
| | if (parsed) { |
| | 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 || []; |
| | } |
| | } |
| | } |
| | |
| | |
| | saveHistory(); |
| | |
| | } catch (e) { |
| | console.error("Auto-save load error", e); |
| | } |
| | |
| | |
| | watch([currentGrid, pages, customImages], async () => { |
| | |
| | if (isUndoing.value) return; |
| | |
| | const dataToSave = JSON.parse(JSON.stringify({ |
| | grid: currentGrid.value, |
| | pages: pages.value, |
| | customImages: customImages.value |
| | })); |
| | |
| | try { |
| | await db.set(AUTOSAVE_KEY, dataToSave); |
| | } catch (e) { |
| | console.error("IndexedDB Save Error", e); |
| | } |
| | }, { deep: true }); |
| | }); |
| | |
| | onUnmounted(() => { |
| | window.removeEventListener('keydown', handleKeyboardShortcuts); |
| | }); |
| | |
| | |
| | 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); transform-origin: center center; 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, |
| | |
| | undo, |
| | redo, |
| | saveHistory, |
| | historyIndex, |
| | history |
| | }; |
| | } |
| | }).mount('#app'); |
| | </script> |
| | </body> |
| | </html> |