@@ -137,7 +137,7 @@ const coreStats = stats.slice(0, POSTER_STATS_COUNT)
137137 Scan to view activity
138138 </p >
139139 </div >
140- <div class =" shrink-0 rounded-sm bg-white p-1 shadow-sm " >
140+ <div class =" shrink-0 overflow-hidden bg-white p-1" >
141141 <canvas id ={ qrCodeId } width =" 60" height =" 60" class =" block" style =" image-rendering: pixelated;" >
142142 </canvas >
143143 </div >
@@ -161,6 +161,7 @@ const coreStats = stats.slice(0, POSTER_STATS_COUNT)
161161
162162 <button
163163 data-copy-id ={ posterWrapperId }
164+ data-activity-name ={ activityName }
164165 class =" copy-poster-btn inline-flex items-center justify-center gap-2 px-4 py-3 text-sm font-mono text-gray-500 hover:text-gray-900 border border-gray-200 hover:border-gray-400 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2"
165166 aria-label =" Copy poster to clipboard"
166167 >
@@ -178,7 +179,6 @@ import QRCode from 'qrcode'
178179import {
179180 addLightBasemapTiles,
180181 LEAFLET_MAP_OPTIONS_FRACTIONAL_ZOOM,
181- LEAFLET_MAP_OPTIONS_ZOOM_ANIMATION,
182182 nudgeZoomOutAfterFit,
183183 resolveBasemapProvider,
184184 toMapCoords,
@@ -189,6 +189,11 @@ const htmlToImagePromise = import('html-to-image')
189189
190190const dialogFocusStack: HTMLElement[] = []
191191const leafletMaps = new Map<string, L.Map>()
192+ /** Resolves when QR + map tiles are ready for a reliable capture. */
193+ const posterReady = new Map<string, Promise<void>>()
194+
195+ const POSTER_TILE_WAIT_MS = 4000
196+ const POSTER_CAPTURE_SETTLE_MS = 80
192197
193198// ── Dialog open / close / destroy ─────────────────────────────────────────
194199
@@ -217,7 +222,7 @@ function openDialog(dialogId: string, templateId?: string) {
217222
218223 const wrapper = dialog.querySelector('.poster-wrapper') as HTMLElement | null
219224 if (wrapper)
220- initPoster(wrapper)
225+ posterReady.set(wrapper.id, initPoster(wrapper) )
221226
222227 requestAnimationFrame(() => {
223228 dialog.querySelector<HTMLButtonElement>(
@@ -234,6 +239,8 @@ function destroyDialog(dialogId: string) {
234239 // Tear down Leaflet map to free tiles / event listeners
235240 const wrapper = dialog.querySelector('.poster-wrapper') as HTMLElement | null
236241 const mapId = wrapper?.dataset.mapId
242+ if (wrapper)
243+ posterReady.delete(wrapper.id)
237244 if (mapId) {
238245 leafletMaps.get(mapId)?.remove()
239246 leafletMaps.delete(mapId)
@@ -284,48 +291,83 @@ window.addEventListener('resize', () => {
284291
285292// ── Poster initializer ─────────────────────────────────────────────────────
286293
287- async function initPoster(wrapper: HTMLElement) {
294+ function waitForTileLayer(layer: L.TileLayer, timeoutMs: number): Promise<void> {
295+ return new Promise((resolve) => {
296+ let settled = false
297+ const done = () => {
298+ if (settled)
299+ return
300+ settled = true
301+ clearTimeout(timer)
302+ layer.off('load', done)
303+ resolve()
304+ }
305+ const timer = window.setTimeout(done, timeoutMs)
306+ layer.once('load', done)
307+ // If fitBounds didn't start a new fetch (tiles already cached), resolve now.
308+ const loading = (layer as L.TileLayer & { _loading?: boolean })._loading
309+ if (!loading)
310+ done()
311+ })
312+ }
313+
314+ function settleFrames(ms = POSTER_CAPTURE_SETTLE_MS): Promise<void> {
315+ return new Promise((resolve) => {
316+ requestAnimationFrame(() => {
317+ requestAnimationFrame(() => {
318+ window.setTimeout(resolve, ms)
319+ })
320+ })
321+ })
322+ }
323+
324+ async function initPoster(wrapper: HTMLElement): Promise<void> {
288325 await resolveBasemapProvider()
289326
290327 const mapId = wrapper.dataset.mapId!
291328 const qrId = wrapper.dataset.qrId!
292329 const pageUrl = wrapper.dataset.pageUrl!
293330 const pointsRaw = wrapper.dataset.points!
294331
295- // 1. QR Code
332+ // 1. QR Code — opaque light modules so capture never shows map bleed-through
296333 const qrCanvas = document.getElementById(qrId) as HTMLCanvasElement | null
297- if (qrCanvas) {
298- QRCode.toCanvas(qrCanvas, pageUrl, {
299- width: 60,
300- margin: 1,
301- color: { light: '#00000000 ', dark: '#111827ff' },
302- errorCorrectionLevel: 'M',
303- }).catch((err) => {
304- console.error('Failed to generate QR code:', err)
305- const ctx = qrCanvas.getContext('2d')
306- if (ctx) {
307- ctx.fillStyle = '#ef4444'
308- ctx.font = '8px monospace'
309- ctx.fillText('QR error', 2, 32)
310- }
311- })
312- }
313-
314- // 2. Route map (no controls, static display)
334+ const qrReady = qrCanvas
335+ ? QRCode.toCanvas(qrCanvas, pageUrl, {
336+ width: 60,
337+ margin: 1,
338+ color: { light: '#ffffffff ', dark: '#111827ff' },
339+ errorCorrectionLevel: 'M',
340+ }).catch((err) => {
341+ console.error('Failed to generate QR code:', err)
342+ const ctx = qrCanvas.getContext('2d')
343+ if (ctx) {
344+ ctx.fillStyle = '#ef4444'
345+ ctx.font = '8px monospace'
346+ ctx.fillText('QR error', 2, 32)
347+ }
348+ })
349+ : Promise.resolve()
350+
351+ // 2. Route map (no controls, static display; canvas paths capture more reliably )
315352 const mapEl = document.getElementById(mapId)
316- if (!mapEl)
353+ if (!mapEl) {
354+ await qrReady
317355 return
356+ }
318357
319358 let points: number[][] = []
320359 try {
321360 points = JSON.parse(pointsRaw)
322361 }
323362 catch (e) {
324363 console.error('Failed to parse points data for poster map:', e)
364+ await qrReady
325365 return
326366 }
327- if (points.length === 0)
367+ if (points.length === 0) {
368+ await qrReady
328369 return
370+ }
329371
330372 const map = L.map(mapId, {
331373 zoomControl: false,
@@ -335,11 +377,17 @@ async function initPoster(wrapper: HTMLElement) {
335377 scrollWheelZoom: false,
336378 doubleClickZoom: false,
337379 keyboard: false,
380+ preferCanvas: true,
381+ fadeAnimation: false,
338382 ...LEAFLET_MAP_OPTIONS_FRACTIONAL_ZOOM,
339- ...LEAFLET_MAP_OPTIONS_ZOOM_ANIMATION,
383+ zoomAnimation: false,
384+ markerZoomAnimation: false,
340385 })
341386
342- addLightBasemapTiles(map, { labels: false })
387+ // Layout after clone into dialog — without this, tiles/vectors can be blank on first paint.
388+ map.invalidateSize({ animate: false })
389+
390+ const tileLayer = addLightBasemapTiles(map, { labels: false })
343391
344392 const latlngs = points.map((p: number[]) => {
345393 const [lat, lng] = toMapCoords(p[0], p[1])
@@ -385,23 +433,105 @@ async function initPoster(wrapper: HTMLElement) {
385433 animate: false,
386434 })
387435 nudgeZoomOutAfterFit(map, 0.4)
436+ map.invalidateSize({ animate: false })
388437
389438 leafletMaps.set(mapId, map)
439+
440+ // Wait for the post-fitBounds tile set (not the initial default-view load).
441+ await Promise.all([qrReady, waitForTileLayer(tileLayer, POSTER_TILE_WAIT_MS)])
442+ await settleFrames()
390443}
391444
392445// ── Shared: capture poster as Blob ────────────────────────────────────────
393446
447+ async function waitUntilPosterReady(wrapperId: string): Promise<void> {
448+ const ready = posterReady.get(wrapperId)
449+ if (ready)
450+ await ready
451+ // Extra paint settle in case the user tapped immediately after open.
452+ await settleFrames()
453+ }
454+
394455async function capturePosterBlob(wrapperId: string): Promise<Blob> {
395456 const wrapper = document.getElementById(wrapperId)
396457 if (!wrapper)
397458 throw new Error('Poster wrapper not found')
459+
460+ await waitUntilPosterReady(wrapperId)
461+
462+ const mapId = wrapper.dataset.mapId
463+ if (mapId)
464+ leafletMaps.get(mapId)?.invalidateSize({ animate: false })
465+
398466 const { toBlob } = await htmlToImagePromise
399- const blob = await toBlob(wrapper, { cacheBust: true, pixelRatio: 2, skipFonts: false })
467+ // cacheBust:false — re-fetching Leaflet tiles with a bust query often blanks them on mobile.
468+ const blob = await toBlob(wrapper, {
469+ cacheBust: false,
470+ pixelRatio: 2,
471+ skipFonts: false,
472+ })
400473 if (!blob)
401474 throw new Error('Failed to generate image blob')
402475 return blob
403476}
404477
478+ /**
479+ * Safari drops user-activation if we `await` blob generation before `clipboard.write`.
480+ * Pass a Promise into ClipboardItem so write() is invoked synchronously in the click path.
481+ * Falls back to Web Share / download when image clipboard write is blocked.
482+ */
483+ async function copyPosterBlob(wrapperId: string, activityName: string): Promise<'copied' | 'shared' | 'downloaded'> {
484+ const filename = `workout-${activityName.replace(/\s+/g, '-').toLowerCase()}.png`
485+ const blobPromise = capturePosterBlob(wrapperId)
486+
487+ try {
488+ await navigator.clipboard.write([
489+ new ClipboardItem({ 'image/png': blobPromise }),
490+ ])
491+ return 'copied'
492+ }
493+ catch (clipboardErr) {
494+ console.warn('Clipboard image write failed, trying share/download fallback:', clipboardErr)
495+ }
496+
497+ const blob = await blobPromise
498+ const file = new File([blob], filename, { type: 'image/png' })
499+
500+ if (typeof navigator.canShare === 'function' && navigator.canShare({ files: [file] })) {
501+ try {
502+ await navigator.share({ files: [file], title: activityName })
503+ return 'shared'
504+ }
505+ catch (shareErr) {
506+ // User dismissed the share sheet — treat as cancel, not a hard failure.
507+ if (shareErr instanceof Error && shareErr.name === 'AbortError')
508+ throw shareErr
509+ console.warn('Web Share failed, falling back to download:', shareErr)
510+ }
511+ }
512+
513+ triggerBlobDownload(blob, filename)
514+ return 'downloaded'
515+ }
516+
517+ function triggerBlobDownload(blob: Blob, filename: string): void {
518+ const url = URL.createObjectURL(blob)
519+ const link = document.createElement('a')
520+ link.download = filename
521+ link.href = url
522+ link.rel = 'noopener'
523+ document.body.appendChild(link)
524+ link.click()
525+ link.remove()
526+ URL.revokeObjectURL(url)
527+ }
528+
529+ async function downloadPosterBlob(wrapperId: string, activityName: string): Promise<void> {
530+ const blob = await capturePosterBlob(wrapperId)
531+ const filename = `workout-${activityName.replace(/\s+/g, '-').toLowerCase()}.png`
532+ triggerBlobDownload(blob, filename)
533+ }
534+
405535// ── Poster actions (download / copy) — single delegated listener ─────────────
406536
407537document.addEventListener('click', async (e) => {
@@ -415,6 +545,7 @@ document.addEventListener('click', async (e) => {
415545 const btn = (downloadBtn || copyBtn)!
416546 const isCopy = !!copyBtn
417547 const wrapperId = isCopy ? btn.dataset.copyId! : btn.dataset.wrapperId!
548+ const activityName = btn.dataset.activityName || 'workout'
418549 const label = btn.querySelector(isCopy ? '.copy-label' : '.btn-label')
419550 const originalText = label?.textContent ?? (isCopy ? 'Copy' : 'Download')
420551
@@ -423,20 +554,18 @@ document.addEventListener('click', async (e) => {
423554 label.textContent = isCopy ? 'Copying…' : 'Generating…'
424555
425556 try {
426- const blob = await capturePosterBlob(wrapperId)
427557 if (isCopy) {
428- await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
429- if (label)
430- label.textContent = 'Copied!'
558+ const result = await copyPosterBlob(wrapperId, activityName)
559+ if (label) {
560+ label.textContent = result === 'copied'
561+ ? 'Copied!'
562+ : result === 'shared'
563+ ? 'Shared!'
564+ : 'Saved!'
565+ }
431566 }
432567 else {
433- const activityName = btn.dataset.activityName || 'workout'
434- const url = URL.createObjectURL(blob)
435- const link = document.createElement('a')
436- link.download = `workout-${activityName.replace(/\s+/g, '-').toLowerCase()}.png`
437- link.href = url
438- link.click()
439- URL.revokeObjectURL(url)
568+ await downloadPosterBlob(wrapperId, activityName)
440569 }
441570
442571 setTimeout(() => {
@@ -445,6 +574,12 @@ document.addEventListener('click', async (e) => {
445574 }, 2000)
446575 }
447576 catch (err) {
577+ // Share sheet dismissed — restore label quietly.
578+ if (err instanceof Error && err.name === 'AbortError') {
579+ if (label)
580+ label.textContent = originalText
581+ return
582+ }
448583 console.error(`Failed to ${isCopy ? 'copy' : 'download'} poster:`, err)
449584 if (label) {
450585 label.textContent = 'Failed'
0 commit comments