import React, { useState, useEffect, useMemo } from 'react'; import { Printer, Grid, Edit3, Copy, Clipboard, Trash2, CheckSquare, Square, Sliders, HelpCircle, Plus, RotateCcw, Layers, AlignLeft, AlignCenter, AlignRight, Bold, Italic, Underline, Eye, Save, Sparkles, CheckCircle2, Ban, ArrowUpToLine, FoldVertical, ArrowDownToLine, Hash, Bookmark, X, Smartphone, Monitor, Move } from 'lucide-react'; // ========================================== // CONSTANTS & PRESETS // ========================================== const A4_WIDTH_MM = 210; const A4_HEIGHT_MM = 297; const DEFAULT_GRID_PRESETS = [ { id: '8x3-zero', name: '8 × 3 Zero Margin (70 × 37.1mm)', rows: 8, cols: 3, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0, gapX: 0, gapY: 0, padding: 3, offsetX: 0, offsetY: 0 }, { id: '8x3-margin', name: '8 × 3 Standard Margin (66 × 33.8mm)', rows: 8, cols: 3, marginTop: 13, marginBottom: 13, marginLeft: 6, marginRight: 6, gapX: 0, gapY: 0, padding: 3, offsetX: 0, offsetY: 0 }, { id: '7x3-standard', name: '7 × 3 Address Labels (63.5 × 38.1mm)', rows: 7, cols: 3, marginTop: 15.1, marginBottom: 15.1, marginLeft: 7.2, marginRight: 7.2, gapX: 2.5, gapY: 0, padding: 3.5, offsetX: 0, offsetY: 0 }, { id: '10x4-small', name: '10 × 4 Small Return (52.5 × 29.7mm)', rows: 10, cols: 4, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0, gapX: 0, gapY: 0, padding: 2, offsetX: 0, offsetY: 0 }, { id: '4x2-shipping', name: '4 × 2 Large Shipping (105 × 74.25mm)', rows: 4, cols: 2, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0, gapX: 0, gapY: 0, padding: 5, offsetX: 0, offsetY: 0 } ]; const FONT_OPTIONS = [ { id: 'sans-tc', label: '黑體 / Sans-Serif (繁體中文 + EN)', family: '"PingFang HK", "PingFang TC", "Noto Sans TC", "Microsoft JhengHei", "Heiti TC", "Inter", system-ui, sans-serif' }, { id: 'serif-tc', label: '宋體・明體 / Serif (繁體中文 + EN)', family: '"Noto Serif TC", "Songti TC", "PMingLiU", "Times New Roman", Georgia, serif' }, { id: 'rounded-tc', label: '圓體・現代 / Clean UI (繁體 + EN)', family: '"PingFang HK", "Microsoft JhengHei UI", "Helvetica Neue", Arial, sans-serif' }, { id: 'mono', label: '等寬字體 / Monospace (Code / SKU)', family: '"Roboto Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Noto Sans TC", monospace' } ]; const DEFAULT_LABEL_DESIGN = { blocks: [ { id: 'b1', text: '收件人:陳大文 先生\nFlat A, 12/F, Tower 1\nKwai Chung, N.T., Hong Kong', fontId: 'sans-tc', fontSize: 12, color: '#0f172a', bold: true, italic: false, underline: false, alignX: 'center' } ], alignY: 'middle', bgColor: '#ffffff', showBorder: false, borderColor: '#0f172a', borderWidth: 1 }; const QUICK_TEMPLATES = [ { name: '香港地址標籤 (Address)', design: { blocks: [ { id: 't1-1', text: '陳大文 先生 收', fontId: 'sans-tc', fontSize: 13, color: '#0f172a', bold: true, italic: false, underline: false, alignX: 'left' }, { id: 't1-2', text: '香港九龍彌敦道 100 號\n商業大廈 15 樓 A 室\nTel: 9123 4567', fontId: 'sans-tc', fontSize: 10, color: '#334155', bold: false, italic: false, underline: false, alignX: 'left' } ], alignY: 'middle', bgColor: '#ffffff', showBorder: false, borderColor: '#0f172a', borderWidth: 1 } }, { name: '產品標價 / SKU (Product)', design: { blocks: [ { id: 't2-1', text: '精選台灣高山烏龍茶 (150g)', fontId: 'serif-tc', fontSize: 11, color: '#1e293b', bold: true, italic: false, underline: false, alignX: 'center' }, { id: 't2-2', text: 'SKU: TW-TEA-089 HK$ 168.00\nBest Before: 2027.12.31', fontId: 'mono', fontSize: 9, color: '#475569', bold: false, italic: false, underline: false, alignX: 'center' } ], alignY: 'middle', bgColor: '#ffffff', showBorder: true, borderColor: '#94a3b8', borderWidth: 1 } }, { name: '檔案分類 (Folder Tag)', design: { blocks: [ { id: 't3-1', text: '2026 年度財務報表 & 發票\nFY2026 Financial & Invoices', fontId: 'sans-tc', fontSize: 12, color: '#1e3a8a', bold: true, italic: false, underline: true, alignX: 'center' } ], alignY: 'middle', bgColor: '#f8fafc', showBorder: true, borderColor: '#1e3a8a', borderWidth: 1.5 } } ]; const cloneDesign = (design) => JSON.parse(JSON.stringify(design)); export default function App() { // ========================================== // STATE MANAGEMENT WITH LOCALSTORAGE // ========================================== // Grid Config State const [gridConfig, setGridConfig] = useState(() => { try { const saved = localStorage.getItem('a4_label_grid_config'); return saved ? JSON.parse(saved) : DEFAULT_GRID_PRESETS[0]; } catch { return DEFAULT_GRID_PRESETS[0]; } }); // Custom Saved Presets const [customPresets, setCustomPresets] = useState(() => { try { const saved = localStorage.getItem('a4_label_custom_presets'); return saved ? JSON.parse(saved) : []; } catch { return []; } }); // Slots Data: dictionary keyed by slot index (0 to rows*cols - 1) // { used: boolean, hasContent: boolean, design: LabelDesign } const [slotsData, setSlotsData] = useState(() => { try { const saved = localStorage.getItem('a4_label_slots_data'); if (saved) return JSON.parse(saved); } catch { // ignore error } return { 0: { used: false, hasContent: true, design: cloneDesign(DEFAULT_LABEL_DESIGN) }, 1: { used: false, hasContent: true, design: cloneDesign(DEFAULT_LABEL_DESIGN) }, 2: { used: true, hasContent: false, design: cloneDesign(DEFAULT_LABEL_DESIGN) }, 3: { used: true, hasContent: false, design: cloneDesign(DEFAULT_LABEL_DESIGN) } }; }); // Currently selected slot indices for editing or batch operation const [selectedSlots, setSelectedSlots] = useState([0]); // Active Editor Design const [activeDesign, setActiveDesign] = useState(() => { return cloneDesign(DEFAULT_LABEL_DESIGN); }); // Interaction mode on the A4 sheet: 'select' (pick slots to edit/print) | 'used' (mark already peeled stickers) const [sheetClickMode, setSheetClickMode] = useState('select'); const [multiSelectToggle, setMultiSelectToggle] = useState(false); // Clipboard for copying & pasting between slots const [clipboardDesign, setClipboardDesign] = useState(null); // Print Test Cutline Mode const [printTestGrid, setPrintTestGrid] = useState(false); // Mobile Tab Navigation ('sheet' | 'editor' | 'grid') const [mobileTab, setMobileTab] = useState('sheet'); // Modals & Notifications const [showHelpModal, setShowHelpModal] = useState(false); const [showSavePresetModal, setShowSavePresetModal] = useState(false); const [showSequenceModal, setShowSequenceModal] = useState(false); const [newPresetName, setNewPresetName] = useState(''); const [toastMsg, setToastMsg] = useState(null); // Numbering sequence generator state const [seqPrefix, setSeqPrefix] = useState('#'); const [seqStart, setSeqStart] = useState(1); const [seqDigits, setSeqDigits] = useState(3); // ========================================== // EFFECTS & PERSISTENCE // ========================================== // Inject Google Fonts for Traditional Chinese & English useEffect(() => { const linkId = 'a4-label-google-fonts'; if (!document.getElementById(linkId)) { const link = document.createElement('link'); link.id = linkId; link.rel = 'stylesheet'; link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Noto+Sans+TC:wght@400;500;700&family=Noto+Serif+TC:wght@400;600;700&family=Roboto+Mono:wght@400;600;700&display=swap'; document.head.appendChild(link); } }, []); useEffect(() => { try { localStorage.setItem('a4_label_grid_config', JSON.stringify(gridConfig)); } catch {} }, [gridConfig]); useEffect(() => { try { localStorage.setItem('a4_label_slots_data', JSON.stringify(slotsData)); } catch {} }, [slotsData]); useEffect(() => { try { localStorage.setItem('a4_label_custom_presets', JSON.stringify(customPresets)); } catch {} }, [customPresets]); const showToast = (msg) => { setToastMsg(msg); setTimeout(() => { setToastMsg((prev) => (prev === msg ? null : prev)); }, 2600); }; // ========================================== // COMPUTED METRICS // ========================================== const totalSlots = gridConfig.rows * gridConfig.cols; const labelDimensions = useMemo(() => { const usableWidth = A4_WIDTH_MM - gridConfig.marginLeft - gridConfig.marginRight - (gridConfig.cols - 1) * gridConfig.gapX; const usableHeight = A4_HEIGHT_MM - gridConfig.marginTop - gridConfig.marginBottom - (gridConfig.rows - 1) * gridConfig.gapY; const width = Math.max(10, usableWidth / gridConfig.cols); const height = Math.max(8, usableHeight / gridConfig.rows); return { width: Number(width.toFixed(2)), height: Number(height.toFixed(2)) }; }, [gridConfig]); const sheetStats = useMemo(() => { let usedCount = 0; let printCount = 0; let availableEmptyCount = 0; for (let i = 0; i < totalSlots; i++) { const slot = slotsData[i]; if (slot?.used) { usedCount++; } else if (slot?.hasContent) { printCount++; } else { availableEmptyCount++; } } return { usedCount, printCount, availableEmptyCount }; }, [slotsData, totalSlots]); // ========================================== // SLOT & DESIGN HANDLERS // ========================================== // Synchronize editor changes to currently selected active slots automatically const updateActiveDesignAndSync = (updater) => { setActiveDesign((prevDesign) => { const nextDesign = typeof updater === 'function' ? updater(prevDesign) : updater; if (selectedSlots.length > 0) { setSlotsData((prevSlots) => { const updated = { ...prevSlots }; selectedSlots.forEach((slotIdx) => { if (slotIdx < totalSlots) { const existing = updated[slotIdx] || { used: false }; if (!existing.used) { updated[slotIdx] = { ...existing, hasContent: true, design: cloneDesign(nextDesign) }; } } }); return updated; }); } return nextDesign; }); }; // Handle clicking a slot on the A4 visualizer const handleSlotClick = (index, event) => { const isShiftOrCmd = event.shiftKey || event.metaKey || event.ctrlKey || multiSelectToggle; if (sheetClickMode === 'used') { // Toggle "Used / Peeled Sticker" status directly setSlotsData((prev) => { const current = prev[index] || { used: false, hasContent: false, design: cloneDesign(activeDesign) }; const nextUsed = !current.used; return { ...prev, [index]: { ...current, used: nextUsed, hasContent: nextUsed ? false : current.hasContent } }; }); return; } // Normal Select / Edit Mode if (isShiftOrCmd) { setSelectedSlots((prev) => { if (prev.includes(index)) { return prev.length > 1 ? prev.filter((i) => i !== index) : prev; } return [...prev, index]; }); } else { setSelectedSlots([index]); const targetSlot = slotsData[index]; if (targetSlot && targetSlot.hasContent && targetSlot.design) { setActiveDesign(cloneDesign(targetSlot.design)); } else if (targetSlot && !targetSlot.used && !targetSlot.hasContent) { // When clicking an empty available slot, populate it with current active design or select it setSlotsData((prev) => ({ ...prev, [index]: { used: false, hasContent: true, design: cloneDesign(activeDesign) } })); } } }; // Apply current editor design to all selected slots const applyToSelectedSlots = () => { if (selectedSlots.length === 0) { showToast('請先選擇至少一個標籤格 (Select a slot first)'); return; } setSlotsData((prev) => { const next = { ...prev }; let count = 0; selectedSlots.forEach((idx) => { if (!next[idx]?.used) { next[idx] = { used: false, hasContent: true, design: cloneDesign(activeDesign) }; count++; } }); showToast(`已套用設計至 ${count} 個標籤格 (Applied to ${count} slots)`); return next; }); }; // Fill all available (non-used) slots with current design const fillAllAvailableSlots = () => { setSlotsData((prev) => { const next = { ...prev }; let count = 0; for (let i = 0; i < totalSlots; i++) { if (!next[i]?.used) { next[i] = { used: false, hasContent: true, design: cloneDesign(activeDesign) }; count++; } } showToast(`已填滿全部 ${count} 個可用標籤格 (Filled ${count} available slots)`); return next; }); }; // Toggle Used state for currently selected slots const toggleUsedForSelected = () => { if (selectedSlots.length === 0) return; setSlotsData((prev) => { const next = { ...prev }; const firstSlotUsed = Boolean(next[selectedSlots[0]]?.used); const targetUsedState = !firstSlotUsed; selectedSlots.forEach((idx) => { const existing = next[idx] || { design: cloneDesign(activeDesign) }; next[idx] = { ...existing, used: targetUsedState, hasContent: targetUsedState ? false : existing.hasContent }; }); showToast( targetUsedState ? `已將 ${selectedSlots.length} 格標記為「已撕除/已用」(Marked as Used)` : `已將 ${selectedSlots.length} 格恢復為「可用」(Marked Available)` ); return next; }); }; // Clear content from selected slots (keep used status intact) const clearSelectedSlotsContent = () => { setSlotsData((prev) => { const next = { ...prev }; selectedSlots.forEach((idx) => { if (next[idx]) { next[idx] = { ...next[idx], hasContent: false }; } }); showToast(`已清除所選標籤內容 (Cleared ${selectedSlots.length} slots)`); return next; }); }; // Reset entire sheet const resetWholeSheet = () => { setSlotsData({}); setSelectedSlots([0]); showToast('已清空整張標籤紙狀態 (Sheet Reset)'); }; // Copy & Paste between slots const handleCopyDesign = () => { setClipboardDesign(cloneDesign(activeDesign)); showToast('已複製目前標籤設計 (Copied label design)'); }; const handlePasteDesign = () => { if (!clipboardDesign) { showToast('剪貼簿尚無複製的設計 (Clipboard is empty)'); return; } updateActiveDesignAndSync(cloneDesign(clipboardDesign)); showToast(`已貼上至 ${selectedSlots.length} 個所選格 (Pasted to selected slots)`); }; // Select All Available Slots const selectAllAvailable = () => { const avail = []; for (let i = 0; i < totalSlots; i++) { if (!slotsData[i]?.used) avail.push(i); } setSelectedSlots(avail); showToast(`已全選 ${avail.length} 個可用格 (Selected ${avail.length} available slots)`); }; // Generate Sequential Labels across selected slots const handleApplySequence = () => { const sortedTargets = [...selectedSlots] .filter((idx) => !slotsData[idx]?.used) .sort((a, b) => a - b); if (sortedTargets.length === 0) { showToast('請先選擇可用的標籤格 (No available slots selected)'); setShowSequenceModal(false); return; } setSlotsData((prev) => { const next = { ...prev }; sortedTargets.forEach((slotIdx, orderIdx) => { const numStr = String(Number(seqStart) + orderIdx).padStart(Number(seqDigits), '0'); const seqLabel = `${seqPrefix}${numStr}`; const cloned = cloneDesign(activeDesign); if (cloned.blocks.length > 0) { cloned.blocks[0].text = `${cloned.blocks[0].text}\n${seqLabel}`; } next[slotIdx] = { used: false, hasContent: true, design: cloned }; }); return next; }); setShowSequenceModal(false); showToast(`已產生 ${sortedTargets.length} 個序號標籤 (Generated ${sortedTargets.length} serial labels)`); }; // ========================================== // TEXT BLOCK EDITOR HANDLERS // ========================================== const handleBlockChange = (blockId, field, value) => { updateActiveDesignAndSync((prev) => ({ ...prev, blocks: prev.blocks.map((b) => (b.id === blockId ? { ...b, [field]: value } : b)) })); }; const addTextBlock = () => { updateActiveDesignAndSync((prev) => ({ ...prev, blocks: [ ...prev.blocks, { id: `b-${Date.now()}`, text: '新增文字行 / New Text Line', fontId: 'sans-tc', fontSize: 10, color: '#0f172a', bold: false, italic: false, underline: false, alignX: 'center' } ] })); }; const removeTextBlock = (blockId) => { updateActiveDesignAndSync((prev) => { if (prev.blocks.length <= 1) return prev; return { ...prev, blocks: prev.blocks.filter((b) => b.id !== blockId) }; }); }; // Save Custom Grid Preset const handleSaveCustomPreset = () => { if (!newPresetName.trim()) return; const preset = { ...gridConfig, id: `custom-${Date.now()}`, name: `${newPresetName.trim()} (${gridConfig.rows}×${gridConfig.cols})` }; setCustomPresets((prev) => [...prev, preset]); setGridConfig(preset); setNewPresetName(''); setShowSavePresetModal(false); showToast('已儲存自訂版面規格 (Saved custom grid preset)'); }; // Trigger Native Print const handleTriggerPrint = (testGridOnly = false) => { setPrintTestGrid(testGridOnly); setTimeout(() => { window.print(); }, 120); }; // Helper to resolve font family CSS string const getFontFamilyCSS = (fontId) => { const found = FONT_OPTIONS.find((f) => f.id === fontId); return found ? found.family : FONT_OPTIONS[0].family; }; // Render content inside a label cell (shared between screen mini-preview, live editor, and print output) const renderLabelContent = (design, scaleRatio = 1, isPrint = false) => { if (!design) return null; const justifyMap = { top: 'flex-start', middle: 'center', bottom: 'flex-end' }; const paddingMm = gridConfig.padding * scaleRatio; return (
{design.blocks.map((block) => { // Convert pt to scaled pixels on screen, or true pt in print const fontSizeStyle = isPrint ? `${block.fontSize}pt` : `${Math.max(5, block.fontSize * 1.333 * (scaleRatio / 3.78))}px`; return (
{block.text}
); })}
); }; return (
{/* ========================================== EXACT-SCALE NATIVE PRINT STYLESHEET ========================================== */} {/* ========================================== TOP NAVIGATION BAR (HIDDEN IN PRINT) ========================================== */}
{/* App Brand */}

A4 Label Studio

{gridConfig.rows} × {gridConfig.cols} ({labelDimensions.width}×{labelDimensions.height}mm)

A4 貼紙精準排版列印工具 • 支援局部已用貼紙跳格 & 繁體中文

{/* Right Action Controls */}
{/* Mobile Switcher Tabs (Only visible on < lg screens) */}
{/* Toast Notification Banner */} {toastMsg && (
{toastMsg}
)} {/* ========================================== MAIN WORKSPACE (DESKTOP SPLIT / MOBILE TABS) ========================================== */}
{/* LEFT COLUMN: LABEL DESIGN EDITOR & GRID SETTINGS (5 cols on Desktop) */}
{/* PANEL A: RICH LABEL EDITOR */}
{/* Editor Header */}

標籤內容與字型設計 (Label Editor)

正編輯 {selectedSlots.length} 格
{/* REAL-TIME SINGLE LABEL WYSIWYG PREVIEW */}
單張標籤即時預覽 ({labelDimensions.width}mm × {labelDimensions.height}mm) 比例實況模擬
{/* Scaled Single Label Preview Box */}
{renderLabelContent(activeDesign, 3.8, false)}
{/* Quick Templates Bar */}
{QUICK_TEMPLATES.map((tpl, idx) => ( ))}
{/* TEXT BLOCKS LIST */}
{activeDesign.blocks.map((block, bIdx) => (
段落 #{bIdx + 1} {activeDesign.blocks.length > 1 && ( )}
{/* Multi-line Textarea */}