|
8 | 8 | import VCardForm from '$lib/qr_code_templates/VCardForm.svelte'; |
9 | 9 | import CalendarEventForm from '$lib/qr_code_templates/CalendarEventForm.svelte'; |
10 | 10 |
|
11 | | - let selectedModeValue = $state('wifi'); |
| 11 | + // Constants for canvas rendering |
| 12 | + const CANVAS_QR_PADDING = 10; // Padding around the QR code graphic on the canvas |
| 13 | + const CANVAS_TITLE_FONT_SIZE = 20; // Font size for the title |
| 14 | + const CANVAS_TITLE_AREA_VERTICAL_PADDING = 5; // Vertical padding above and below the title text (each side) |
12 | 15 |
|
13 | | - // Calendar Event Details state variables are now moved to CalendarEventForm.svelte |
| 16 | + let selectedModeValue = $state('wifi'); |
| 17 | + let qrTitle = $state(''); |
14 | 18 | let activeFormOutput = $state(''); |
15 | 19 | let activeFilenameHint = $state(''); |
16 | 20 |
|
17 | 21 | let qrCodeDataURL = $state(''); |
18 | | - let size = $state(256); |
| 22 | + let size = $state(256); // This is the size of the QR code graphic itself |
19 | 23 | let errorCorrectionLevel = $state('M'); |
20 | 24 | let isGenerating = $state(false); |
21 | 25 |
|
22 | | - let darkColor = $state('#000000'); // default black |
23 | | - let lightColor = $state('#ffffff'); // default white |
| 26 | + let darkColor = $state('#000000'); |
| 27 | + let lightColor = $state('#ffffff'); |
24 | 28 |
|
25 | 29 | const modeOptions = [ |
26 | 30 | { value: 'text', label: 'Text' }, |
|
33 | 37 | modeOptions.find((opt) => opt.value === selectedModeValue)?.label |
34 | 38 | ); |
35 | 39 |
|
36 | | - // All generate...String and getTextToEncode functions are removed |
37 | | - // as their logic is now within their respective child components. |
38 | | - // Child components will update `activeFormOutput`. |
| 40 | + // Derived values for UI display dimensions, reacting to `size` and `qrTitle` |
| 41 | + let titleUiExtraHeight = $derived( |
| 42 | + qrTitle.trim() ? CANVAS_TITLE_FONT_SIZE + CANVAS_TITLE_AREA_VERTICAL_PADDING * 2 : 0 |
| 43 | + ); |
| 44 | + // Width of the generated image (canvas) |
| 45 | + let qrImageActualWidth = $derived(size + CANVAS_QR_PADDING * 2); |
| 46 | + // Height of the generated image (canvas) |
| 47 | + let qrImageActualHeight = $derived(size + CANVAS_QR_PADDING * 2 + titleUiExtraHeight); |
39 | 48 |
|
40 | | - // Download QR code as image |
41 | 49 | function downloadQRCode() { |
42 | 50 | if (!qrCodeDataURL) return; |
43 | 51 |
|
44 | 52 | const link = document.createElement('a'); |
45 | | - // Make filename more unique to try and bust browser cache for downloads |
46 | 53 | const timestamp = Date.now(); |
47 | 54 | let baseFilename = 'qrcode'; |
48 | | - if (activeFilenameHint) { |
49 | | - baseFilename = activeFilenameHint.replace(/[^\\w-]/g, '_'); |
| 55 | +
|
| 56 | + const titleTrimmed = qrTitle.trim(); |
| 57 | + if (titleTrimmed) { |
| 58 | + baseFilename = titleTrimmed.replace(/[^-\w\s]/g, '').replace(/\s+/g, '_'); |
| 59 | + } else if (activeFilenameHint) { |
| 60 | + baseFilename = activeFilenameHint.replace(/[^-\w\s]/g, '').replace(/\s+/g, '_'); |
50 | 61 | } |
51 | | - // All mode-specific filename logic removed, activeFilenameHint handles this. |
52 | | - // Append current size and timestamp to the filename |
| 62 | +
|
53 | 63 | link.download = `${baseFilename}-${size}-${timestamp}.png`; |
54 | 64 | link.href = qrCodeDataURL; |
55 | 65 | link.click(); |
56 | 66 | } |
57 | 67 |
|
58 | | - // Auto-generate QR code when inputs change |
59 | 68 | $effect(() => { |
60 | | - // Capture reactive values from component state for this specific effect run |
61 | | - const capturedModeValue = selectedModeValue; |
62 | | - // WiFi state captures removed as WifiForm manages its own state |
63 | | - // const capturedCustomText = customText; // Removed, TextForm output used directly |
64 | 69 | const capturedSize = size; |
65 | 70 | const capturedErrorCorrectionLevel = errorCorrectionLevel; |
66 | | - // const capturedSimpleTestInput = simpleTestInput; |
67 | | -
|
68 | | - // console.log('Page - capturedSimpleTestInput:', capturedSimpleTestInput); |
69 | | - // console.log('Page - capturedSize for slider:', capturedSize); |
70 | | - // console.log('Page - activeFormOutput from child:', activeFormOutput); |
71 | | -
|
72 | | - // vCard details capture removed, VCardForm manages its own state |
73 | | - // Calendar event details capture removed, CalendarEventForm manages its own state |
74 | | -
|
75 | | - // All form-specific data is now in activeFormOutput, provided by the active child component. |
76 | | - // The getTextToEncode function is removed. |
77 | 71 | const textToEncode = activeFormOutput; |
| 72 | + const capturedQrTitle = qrTitle; |
| 73 | + const capturedDarkColor = darkColor; |
| 74 | + const capturedLightColor = lightColor; |
78 | 75 |
|
79 | | - // Use an Immediately Invoked Async Function Expression (IIAFE) |
80 | | - // to handle the asynchronous QR code generation. |
81 | 76 | (async () => { |
82 | 77 | if (!textToEncode.trim()) { |
83 | 78 | qrCodeDataURL = ''; |
84 | | - // isGenerating should be false if we return early and no generation happens. |
85 | | - // However, isGenerating is set to true only if we proceed. |
86 | 79 | return; |
87 | 80 | } |
88 | 81 |
|
89 | 82 | isGenerating = true; |
90 | 83 | try { |
91 | | - // QRCode is now imported statically at the top |
92 | | - const options = { |
93 | | - width: capturedSize, // Use captured size from the effect's scope |
94 | | - margin: 2, |
| 84 | + const qrOptions = { |
| 85 | + width: capturedSize, |
| 86 | + margin: 0, // We handle padding on the canvas |
95 | 87 | color: { |
96 | | - dark: darkColor, |
97 | | - light: lightColor |
| 88 | + dark: capturedDarkColor, |
| 89 | + light: '#00000000' // Transparent light color for QR, canvas bg will be lightColor |
98 | 90 | }, |
99 | | - errorCorrectionLevel: capturedErrorCorrectionLevel // Use captured ECL |
| 91 | + errorCorrectionLevel: capturedErrorCorrectionLevel |
100 | 92 | }; |
101 | | - // Assign to a temporary variable first to ensure the await completes |
102 | | - // before updating the reactive state. |
103 | | - const dataUrl = await QRCode.toDataURL(textToEncode, options); |
104 | | - qrCodeDataURL = dataUrl; |
| 93 | + // Generate QR code as a data URL (this is the QR pattern only) |
| 94 | + const rawQrDataUrl = await QRCode.toDataURL(textToEncode, qrOptions); |
| 95 | +
|
| 96 | + // Draw QR and title onto a new canvas |
| 97 | + await new Promise((resolve, reject) => { |
| 98 | + const img = new Image(); |
| 99 | + img.onload = () => { |
| 100 | + const canvas = document.createElement('canvas'); |
| 101 | + const ctx = canvas.getContext('2d'); |
| 102 | + const titleText = capturedQrTitle.trim(); |
| 103 | +
|
| 104 | + const titleAreaHeightOnCanvas = titleText |
| 105 | + ? CANVAS_TITLE_FONT_SIZE + CANVAS_TITLE_AREA_VERTICAL_PADDING * 2 |
| 106 | + : 0; |
| 107 | +
|
| 108 | + canvas.width = capturedSize + CANVAS_QR_PADDING * 2; |
| 109 | + canvas.height = capturedSize + CANVAS_QR_PADDING * 2 + titleAreaHeightOnCanvas; |
| 110 | +
|
| 111 | + // Fill canvas background with the chosen light color |
| 112 | + ctx.fillStyle = capturedLightColor; |
| 113 | + ctx.fillRect(0, 0, canvas.width, canvas.height); |
| 114 | +
|
| 115 | + // Draw QR code image onto the canvas |
| 116 | + ctx.drawImage(img, CANVAS_QR_PADDING, CANVAS_QR_PADDING, capturedSize, capturedSize); |
| 117 | +
|
| 118 | + // If title is provided, draw it below the QR code |
| 119 | + if (titleText) { |
| 120 | + ctx.fillStyle = capturedDarkColor; // Title text color |
| 121 | + ctx.font = `${CANVAS_TITLE_FONT_SIZE}px Arial`; // Consider making font family configurable |
| 122 | + ctx.textAlign = 'center'; |
| 123 | + ctx.textBaseline = 'top'; // Align the top of the text to the Y coordinate |
| 124 | +
|
| 125 | + const titleTextY = |
| 126 | + CANVAS_QR_PADDING + // Top padding for QR on canvas |
| 127 | + capturedSize + // QR graphic height |
| 128 | + CANVAS_TITLE_AREA_VERTICAL_PADDING; // Padding above title text |
| 129 | +
|
| 130 | + ctx.fillText(titleText, canvas.width / 2, titleTextY); |
| 131 | + } |
| 132 | +
|
| 133 | + // Update reactive state with the new Data URL from canvas |
| 134 | + qrCodeDataURL = canvas.toDataURL('image/png'); |
| 135 | + resolve(); |
| 136 | + }; |
| 137 | + img.onerror = (errEvent) => { |
| 138 | + console.error('Error loading QR code image for canvas drawing:', errEvent); |
| 139 | + qrCodeDataURL = ''; // Clear QR code on error |
| 140 | + reject(new Error('Failed to load QR image onto canvas.')); |
| 141 | + }; |
| 142 | + img.src = rawQrDataUrl; |
| 143 | + }); |
105 | 144 | } catch (error) { |
106 | | - console.error('Error generating QR code:', error); |
107 | | - qrCodeDataURL = ''; // Clear QR code on error |
| 145 | + console.error('Error in QR generation pipeline:', error); |
| 146 | + qrCodeDataURL = ''; |
108 | 147 | } finally { |
109 | 148 | isGenerating = false; |
110 | 149 | } |
|
120 | 159 | <div class="w-full max-w-md space-y-6 lg:w-[350px] lg:flex-none"> |
121 | 160 | <!-- Mode Selector --> |
122 | 161 | <div> |
| 162 | + <label for="template" class="mb-2 block text-sm font-medium text-blue-500">Template</label |
| 163 | + > |
123 | 164 | <Select.Root type="single" bind:value={selectedModeValue}> |
124 | | - <label for="template" class="mb-2 block text-sm font-medium text-blue-500" |
125 | | - >Template</label |
126 | | - > |
127 | 165 | <Select.Trigger |
128 | 166 | id="template" |
129 | 167 | class="flex h-10 w-full items-center justify-between rounded-md bg-[#d9ff7a] px-4 text-sm font-medium shadow-sm transition-colors hover:bg-[#bede68]" |
|
161 | 199 | </Select.Root> |
162 | 200 | </div> |
163 | 201 |
|
164 | | - <!-- Input Fields --> |
| 202 | + <!-- QR Code Title Input --> |
| 203 | + <div> |
| 204 | + <label for="qrTitle" class="mb-2 block text-sm font-medium text-blue-500" |
| 205 | + >QR Code Title (Optional)</label |
| 206 | + > |
| 207 | + <input |
| 208 | + type="text" |
| 209 | + id="qrTitle" |
| 210 | + bind:value={qrTitle} |
| 211 | + class="block w-full rounded-md border-gray-600 bg-gray-700 p-2.5 text-sm text-white placeholder-gray-400 shadow-sm focus:border-blue-500 focus:ring-blue-500" |
| 212 | + placeholder="Enter title (displays on image)" |
| 213 | + /> |
| 214 | + </div> |
| 215 | +
|
| 216 | + <!-- Input Fields based on Mode --> |
165 | 217 | {#if selectedModeValue === 'wifi'} |
166 | 218 | <WifiForm |
167 | 219 | bind:generatedString={activeFormOutput} |
|
187 | 239 | <!-- Size Slider --> |
188 | 240 | <div class="space-y-3"> |
189 | 241 | <div class="flex items-center justify-between"> |
190 | | - <label class="text-sm font-medium text-blue-600">Size</label> |
| 242 | + <label class="text-sm font-medium text-blue-600">QR Size</label> |
191 | 243 | <span class="text-sm font-medium text-blue-400">{size}px</span> |
192 | 244 | </div> |
193 | 245 | <Slider.Root |
|
211 | 263 | </div> |
212 | 264 | </div> |
213 | 265 |
|
214 | | - <!-- Right Section --> |
| 266 | + <!-- Right Section: QR Code Display and Actions --> |
215 | 267 | <div |
216 | 268 | class="mx-auto flex w-full max-w-[584px] flex-col items-center justify-center space-y-6 lg:w-auto lg:flex-none" |
217 | 269 | > |
| 270 | + <!-- QR Code Display Area: Loading, Image, or Placeholder --> |
| 271 | + <!-- The outer div's size is determined by qrImageActualWidth/Height + its own padding (p-4 = 1rem = 16px, so 32px total) --> |
218 | 272 | {#if isGenerating} |
219 | 273 | <div |
220 | | - class="mx-auto flex w-full max-w-full items-center justify-center rounded-lg border border-black p-4" |
221 | | - style="width: {size + 32}px; height: {size + 32}px;" |
| 274 | + class="mx-auto flex items-center justify-center rounded-lg border border-gray-500 p-4" |
| 275 | + style="width: {qrImageActualWidth + 32}px; height: {qrImageActualHeight + 32}px;" |
222 | 276 | > |
223 | 277 | <div |
224 | | - class="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-black" |
| 278 | + class="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-500" |
225 | 279 | ></div> |
226 | 280 | </div> |
227 | 281 | {:else if qrCodeDataURL} |
228 | 282 | <div |
229 | | - class="mx-auto w-full max-w-full rounded-lg border border-black p-4" |
230 | | - style="width: {size + 32}px; height: {size + 32}px;" |
| 283 | + class="mx-auto rounded-lg border border-gray-600 bg-gray-800 p-4 shadow-lg" |
| 284 | + style="width: {qrImageActualWidth + 32}px; height: {qrImageActualHeight + 32}px;" |
231 | 285 | > |
232 | 286 | <img |
233 | 287 | src={qrCodeDataURL} |
234 | | - alt="QR Code" |
235 | | - class="block h-auto max-w-full object-contain" |
236 | | - style="width: {size}px; height: {size}px;" |
| 288 | + alt="Generated QR Code{qrTitle.trim() ? ' with title: ' + qrTitle.trim() : ''}" |
| 289 | + class="block" |
| 290 | + style="width: {qrImageActualWidth}px; height: {qrImageActualHeight}px;" |
237 | 291 | /> |
238 | 292 | </div> |
239 | 293 | {:else} |
240 | 294 | <div |
241 | | - class="mx-auto flex w-full max-w-full items-center justify-center rounded-lg border border-dashed border-gray-300" |
242 | | - style="width: {size + 32}px; height: {size + 32}px;" |
| 295 | + class="mx-auto flex flex-col items-center justify-center rounded-lg border border-dashed border-gray-700 p-4 text-center" |
| 296 | + style="width: {qrImageActualWidth + 32}px; height: {qrImageActualHeight + 32}px;" |
243 | 297 | > |
| 298 | + <svg |
| 299 | + class="mb-2 h-12 w-12 text-gray-600" |
| 300 | + fill="none" |
| 301 | + viewBox="0 0 24 24" |
| 302 | + stroke="currentColor" |
| 303 | + stroke-width="1" |
| 304 | + > |
| 305 | + <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" /> |
| 306 | + <path stroke-linecap="round" stroke-linejoin="round" d="M7 7h10v10H7z" /> |
| 307 | + </svg> |
244 | 308 | <p class="text-sm text-gray-500">QR code will appear here</p> |
| 309 | + <p class="text-xs text-gray-600">Configure options to generate</p> |
245 | 310 | </div> |
246 | 311 | {/if} |
247 | 312 |
|
248 | | - {#if qrCodeDataURL} |
| 313 | + <!-- Color Pickers and Download Button (only if QR code is visible) --> |
| 314 | + {#if qrCodeDataURL && !isGenerating} |
249 | 315 | <div |
250 | | - class="qr-color-inputs" |
251 | | - style="display: flex; gap: 1rem; margin-bottom: 1rem; align-items: center;" |
| 316 | + class="qr-color-inputs flex flex-wrap items-center justify-center gap-4" |
| 317 | + style="max-width: {qrImageActualWidth + 32}px;" |
252 | 318 | > |
253 | | - <label style="display: flex; align-items: center;" class="text-blue-500"> |
254 | | - <span style="margin-right: 0.5rem;">Dark color</span> |
| 319 | + <label class="flex items-center text-sm text-blue-500"> |
| 320 | + <span class="mr-2">Dark Color:</span> |
255 | 321 | <input |
256 | 322 | type="color" |
257 | 323 | bind:value={darkColor} |
258 | | - class="border-magenta-500 rounded-sm border" |
| 324 | + class="h-8 w-8 cursor-pointer rounded border border-blue-600 bg-gray-700 p-0" |
259 | 325 | /> |
260 | 326 | </label> |
261 | | - <label style="display: flex; align-items: center;" class="text-blue-500"> |
262 | | - <span style="margin-right: 0.5rem;">Light color</span> |
| 327 | + <label class="flex items-center text-sm text-blue-500"> |
| 328 | + <span class="mr-2">Light Color:</span> |
263 | 329 | <input |
264 | 330 | type="color" |
265 | | - class="border-magenta-500 m-0 rounded-sm border p-0" |
266 | 331 | bind:value={lightColor} |
| 332 | + class="h-8 w-8 cursor-pointer rounded border border-blue-600 bg-gray-700 p-0" |
267 | 333 | /> |
268 | 334 | </label> |
269 | 335 | </div> |
270 | 336 |
|
271 | 337 | <Button.Root |
272 | 338 | onclick={downloadQRCode} |
273 | | - disabled={isGenerating} |
274 | | - class="h-10 cursor-pointer rounded-lg bg-[#d9ff7a] px-6 text-sm font-medium text-gray-800 transition-colors hover:bg-[#bede68] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50" |
| 339 | + class="h-10 cursor-pointer rounded-lg bg-[#d9ff7a] px-6 text-sm font-medium text-gray-800 transition-colors hover:bg-[#bede68] data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50" |
275 | 340 | > |
276 | 341 | Download Image |
277 | 342 | </Button.Root> |
|
282 | 347 | </div> |
283 | 348 |
|
284 | 349 | <style> |
285 | | - /* Empty style block to ensure proper CSS processing */ |
| 350 | + /* Make color input clickable area cover the preview box better */ |
| 351 | + input[type='color']::-webkit-color-swatch-wrapper { |
| 352 | + padding: 0; |
| 353 | + } |
| 354 | + input[type='color']::-webkit-color-swatch { |
| 355 | + border: none; |
| 356 | + border-radius: 0.25rem; /* Match rounded */ |
| 357 | + } |
| 358 | + input[type='color']::-moz-color-swatch { |
| 359 | + border: none; |
| 360 | + border-radius: 0.25rem; /* Match rounded */ |
| 361 | + } |
286 | 362 | </style> |
0 commit comments