Spaces:
Sleeping
Sleeping
| import { Share } from '@capacitor/share' | |
| import { Filesystem, Directory } from '@capacitor/filesystem' | |
| import { SERVER_URL } from '../config' | |
| import { Editor } from 'tldraw' | |
| import { customAlert, customPrompt, showToast } from './uiUtils' | |
| import { jsPDF } from 'jspdf' | |
| import 'svg2pdf.js' | |
| // Helper: Convert Blob to Base64 | |
| const blobToBase64 = (blob: Blob): Promise<string> => { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader() | |
| reader.onerror = reject | |
| reader.onload = () => resolve(reader.result as string) | |
| reader.readAsDataURL(blob) | |
| }) | |
| } | |
| export const shareLink = async (roomId: string) => { | |
| const url = `${SERVER_URL}/#/${roomId}` | |
| try { | |
| // Try native share or Web Share API first | |
| await Share.share({ | |
| title: 'Join my Whiteboard', | |
| text: 'Collaborate with me on this board:', | |
| url: url, | |
| dialogTitle: 'Share Board Link', | |
| }) | |
| } catch (error) { | |
| // Fallback to clipboard if Share API fails (common on desktop) | |
| console.log('Share API not available, falling back to clipboard', error) | |
| try { | |
| await navigator.clipboard.writeText(url) | |
| await customAlert('Link copied to clipboard!') | |
| } catch (clipboardError) { | |
| console.error('Failed to copy to clipboard:', clipboardError) | |
| await customPrompt('Copy this link:', url) | |
| } | |
| } | |
| } | |
| export const exportToImage = async (editor: Editor, roomId: string, format: 'png' | 'svg' | 'pdf' = 'png') => { | |
| try { | |
| // 1. Get all shape IDs from the current page | |
| const shapeIds = Array.from(editor.getCurrentPageShapeIds()) | |
| if (shapeIds.length === 0) { | |
| await customAlert('Board is empty') | |
| return | |
| } | |
| // Show quiet indicator | |
| showToast(`Preparing ${format.toUpperCase()} export...`, { duration: 3000 }) | |
| // 2. Calculate Bounds to prevent OOM | |
| let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity | |
| shapeIds.forEach(id => { | |
| const bounds = editor.getShapePageBounds(id) | |
| if (bounds) { | |
| minX = Math.min(minX, bounds.x) | |
| minY = Math.min(minY, bounds.y) | |
| maxX = Math.max(maxX, bounds.x + bounds.w) | |
| maxY = Math.max(maxY, bounds.y + bounds.h) | |
| } | |
| }) | |
| const width = maxX - minX | |
| const height = maxY - minY | |
| // --- SVG EXPORT --- | |
| if (format === 'svg') { | |
| try { | |
| // Use toImage with format: 'svg' which returns a Blob | |
| const result = await editor.toImage(shapeIds, { | |
| format: 'svg', | |
| background: true, | |
| scale: 1, | |
| padding: 32, | |
| }) | |
| if (!result || !result.blob) throw new Error('Failed to generate SVG blob') | |
| // Convert Blob to Base64 | |
| const base64Data = await blobToBase64(result.blob) | |
| // base64Data is like "data:image/svg+xml;base64,PHN2Zy..." | |
| const base64Svg = base64Data.split(',')[1] | |
| const fileName = `board-${roomId}-${Date.now()}.svg` | |
| const savedFile = await Filesystem.writeFile({ | |
| path: fileName, | |
| data: base64Svg, | |
| directory: Directory.Cache | |
| }) | |
| try { | |
| await Share.share({ | |
| title: 'Export Board as SVG', | |
| files: [savedFile.uri], | |
| }) | |
| } catch (shareError) { | |
| console.log('Share API not available for SVG, downloading directly') | |
| // For SVG blob, we can use the base64 data URI we already have | |
| const downloadUrl = base64Data | |
| const link = document.createElement('a') | |
| link.href = downloadUrl | |
| link.download = fileName | |
| document.body.appendChild(link) | |
| link.click() | |
| document.body.removeChild(link) | |
| } | |
| return | |
| } catch (e: any) { | |
| console.error('SVG Export failed', e) | |
| throw new Error('SVG Export failed: ' + e.message) | |
| } | |
| } | |
| // --- IMAGE GENERATION (PNG/PDF) --- | |
| // Limit max dimension to ~4000px (increased from 3000) for better quality, | |
| // but still safety capped for mobile memory | |
| const MAX_DIMENSION = 4000 | |
| let scale = 3 // Default high quality scale (increased from 2) | |
| if (width * scale > MAX_DIMENSION || height * scale > MAX_DIMENSION) { | |
| scale = Math.min(MAX_DIMENSION / width, MAX_DIMENSION / height) | |
| console.log(`Large board detected. Reducing scale to ${scale.toFixed(2)} to prevent crash.`) | |
| } | |
| // 3. Generate Image with improved scale | |
| let result | |
| try { | |
| // Force PNG for quality first | |
| result = await editor.toImage(shapeIds, { | |
| format: 'png', | |
| background: true, | |
| scale: scale, | |
| padding: 32, | |
| }) | |
| } catch (e) { | |
| throw new Error('Image generation failed: ' + (e as any).message) | |
| } | |
| if (!result || !result.blob) { | |
| throw new Error('Failed to generate image blob (empty result)') | |
| } | |
| // --- PDF EXPORT (Vector Optimized) --- | |
| if (format === 'pdf') { | |
| try { | |
| // Ask user for background preference | |
| const isDarkMode = editor.user.getIsDarkMode() | |
| let keepBackground = true | |
| if (isDarkMode) { | |
| keepBackground = window.confirm( | |
| "Export with Dark Background?\n\n" + | |
| "OK: Keep dark background (like screen)\n" + | |
| "Cancel: Use light background (better for printing)" | |
| ) | |
| } | |
| // 1. Generate SVG first (best quality, vector) | |
| const result = await editor.toImage(shapeIds, { | |
| format: 'svg', | |
| scale: 1, | |
| background: keepBackground, // Tldraw handles theme based on current user preference usually | |
| padding: 32, | |
| darkMode: keepBackground ? isDarkMode : false // Force light mode if user wants printing version | |
| }) | |
| if (!result || !result.blob) throw new Error('Failed to generate SVG for PDF') | |
| // 2. Read SVG string from Blob | |
| const svgText = await result.blob.text() | |
| // 3. Parse to DOM Element | |
| const parser = new DOMParser() | |
| const svgElement = parser.parseFromString(svgText, "image/svg+xml").documentElement as unknown as SVGElement | |
| // 4. Create PDF with correct dimensions | |
| // Dimensions are in pixels in the SVG, we can keep using pixels in PDF for simplicity | |
| const orientation = width > height ? 'l' : 'p' | |
| const pdf = new jsPDF({ | |
| orientation, | |
| unit: 'px', | |
| format: [width + 64, height + 64] // Add padding | |
| }) | |
| // Draw background directly on PDF canvas | |
| if (keepBackground) { | |
| const bgColor = isDarkMode ? "#212529" : "#ffffff" | |
| pdf.setFillColor(bgColor) | |
| pdf.rect(0, 0, width + 64, height + 64, 'F') | |
| } | |
| // 5. Render SVG to PDF using svg2pdf (injected into jsPDF) | |
| // Note: using 'await' as svg() returns a Promise | |
| await pdf.svg(svgElement, { | |
| x: 0, | |
| y: 0, | |
| width: width + 64, | |
| height: height + 64, | |
| }) | |
| // 6. Output as base64 string | |
| const pdfBase64 = pdf.output('datauristring').split(',')[1] | |
| const fileName = `board-${roomId}-${Date.now()}.pdf` | |
| const savedFile = await Filesystem.writeFile({ | |
| path: fileName, | |
| data: pdfBase64, | |
| directory: Directory.Cache | |
| }) | |
| try { | |
| await Share.share({ | |
| title: 'Export Board as PDF', | |
| files: [savedFile.uri], | |
| }) | |
| } catch (shareError) { | |
| console.log('Share API not available for PDF, downloading directly') | |
| const downloadUrl = `data:application/pdf;base64,${pdfBase64}` | |
| const link = document.createElement('a') | |
| link.href = downloadUrl | |
| link.download = fileName | |
| document.body.appendChild(link) | |
| link.click() | |
| document.body.removeChild(link) | |
| } | |
| return | |
| } catch (e: any) { | |
| console.error('PDF Export failed', e) | |
| throw new Error('PDF Export failed: ' + e.message) | |
| } | |
| } | |
| // --- PNG EXPORT (FALLBACK & DEFAULT) --- | |
| // 4. Check file size. If huge (>4MB), convert to JPEG to ensure shareability | |
| // Increased limit from 2MB to 4MB as devices handle larger files now | |
| if (result.blob.size > 4 * 1024 * 1024) { | |
| console.log('Image too large (>4MB), switching to JPEG for compression') | |
| try { | |
| result = await editor.toImage(shapeIds, { | |
| format: 'jpeg', | |
| quality: 0.9, // High quality JPEG | |
| background: true, | |
| scale: scale, | |
| padding: 32, | |
| }) | |
| } catch (e) { | |
| console.warn('JPEG fallback failed, using original PNG') | |
| } | |
| } | |
| // 5. Convert Blob to Base64 for Capacitor | |
| let pureBase64 | |
| try { | |
| const base64Data = await blobToBase64(result.blob!) | |
| pureBase64 = base64Data.split(',')[1] | |
| } catch (e) { | |
| throw new Error('Base64 conversion failed') | |
| } | |
| const isJpeg = result.blob!.type === 'image/jpeg' | |
| const ext = isJpeg ? 'jpg' : 'png' | |
| const mimeType = isJpeg ? 'image/jpeg' : 'image/png' | |
| const fileName = `board-${roomId}-${Date.now()}.${ext}` | |
| // 6. Save to Capacitor Filesystem | |
| let savedFile | |
| try { | |
| savedFile = await Filesystem.writeFile({ | |
| path: fileName, | |
| data: pureBase64, | |
| directory: Directory.Cache | |
| }) | |
| } catch (e) { | |
| throw new Error('File save failed: ' + (e as any).message) | |
| } | |
| // 7. Share | |
| try { | |
| await Share.share({ | |
| title: 'Export Board', | |
| files: [savedFile.uri], | |
| }) | |
| } catch (e) { | |
| console.log('Share API failed or not available, falling back to download', e) | |
| // Web/Desktop Fallback: Download via anchor tag | |
| const downloadUrl = `data:${mimeType};base64,${pureBase64}` | |
| const link = document.createElement('a') | |
| link.href = downloadUrl | |
| link.download = fileName | |
| document.body.appendChild(link) | |
| link.click() | |
| document.body.removeChild(link) | |
| } | |
| } catch (error) { | |
| console.error('Export failed:', error) | |
| await customAlert((error as any).message) | |
| } | |
| } | |