Skip to content

Commit 7f5d173

Browse files
yulercursoragent
andcommitted
🐛 Fix mobile workout share copy and QR capture artifacts
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3815ef8 commit 7f5d173

1 file changed

Lines changed: 174 additions & 39 deletions

File tree

src/components/ShareWorkout.astro

Lines changed: 174 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -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'
178179
import {
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

190190
const dialogFocusStack: HTMLElement[] = []
191191
const 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+
394455
async 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

407537
document.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

Comments
 (0)