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