diff --git a/frontend/package.json b/frontend/package.json index 12dba8f..18c2853 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@react-three/fiber": "^9.4.0", "antd": "^6.0.0", "axios": "^1.13.2", + "html2canvas": "^1.4.1", "lucide-react": "^0.555.0", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fbb3ab0..8196ce5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ */ import { useState, useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; +import { message } from 'antd'; import { useSpaceData } from './hooks/useSpaceData'; import { useHistoricalData } from './hooks/useHistoricalData'; import { useTrajectory } from './hooks/useTrajectory'; @@ -103,6 +104,18 @@ function App() { setSelectedBody(body); }; + // Screenshot handler with auth check + const handleScreenshot = useCallback(() => { + if (!user) { + message.warning('请先登录以拍摄宇宙快照'); + setShowAuthModal(true); + return; + } + // Use username or full_name or fallback + const nickname = user.full_name || user.username || 'Explorer'; + takeScreenshot(nickname); + }, [user, takeScreenshot]); + // Auth handlers const handleLoginSuccess = (userData: any) => { setUser(userData); @@ -156,7 +169,7 @@ function App() { onToggleSound={() => setIsSoundOn(!isSoundOn)} showDanmaku={showDanmaku} onToggleDanmaku={() => setShowDanmaku(!showDanmaku)} - onScreenshot={takeScreenshot} + onScreenshot={handleScreenshot} /> {/* Auth Modal */} diff --git a/frontend/src/components/Scene.tsx b/frontend/src/components/Scene.tsx index 3dcff0c..dbdf797 100644 --- a/frontend/src/components/Scene.tsx +++ b/frontend/src/components/Scene.tsx @@ -69,7 +69,7 @@ export function Scene({ bodies, selectedBody, trajectoryPositions = [], showOrbi }, [selectedBody, bodies]); return ( -
+
{ - // 1. Find the Three.js Canvas - const canvas = document.querySelector('canvas'); - if (!canvas) { - console.error('Canvas not found'); + const takeScreenshot = useCallback(async (username: string = 'Explorer') => { + // 1. Find the container that includes both the Canvas and the HTML overlays (labels) + const element = document.getElementById('cosmo-scene-container'); + if (!element) { + console.error('Scene container not found'); return; } try { - // 2. Create a temporary 2D canvas for compositing - const tempCanvas = document.createElement('canvas'); - const width = canvas.width; - const height = canvas.height; - tempCanvas.width = width; - tempCanvas.height = height; - const ctx = tempCanvas.getContext('2d'); + // 2. Use html2canvas to capture the visual composite + const canvas = await html2canvas(element, { + backgroundColor: '#000000', + useCORS: true, // Allow loading cross-origin images (textures) + logging: false, + scale: window.devicePixelRatio, // Capture at high resolution + allowTaint: true, // Allow tainted canvas (might limit editing but okay for saving) + ignoreElements: (_el) => { + // Ignore elements that we don't want in the screenshot (if any end up inside) + return false; + } + }); + const ctx = canvas.getContext('2d'); if (!ctx) return; - // 3. Draw the 3D scene onto the temp canvas - ctx.drawImage(canvas, 0, 0, width, height); + const width = canvas.width; + const height = canvas.height; - // 4. Get current camera info (approximate from scene if possible, or just current date) + // 3. Add Overlay / Watermark const now = new Date(); const dateStr = now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); const timeStr = now.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); - - // We can try to get camera position if we had access to the store, - // but for now let's stick to generic data or we can grab it from global window if exposed - // Better: just show the Date and App Name. - // 5. Add Overlay / Watermark - // Background gradient for text - const gradient = ctx.createLinearGradient(0, height - 100, 0, height); + // Background gradient for text legibility + const gradient = ctx.createLinearGradient(0, height - 150, 0, height); gradient.addColorStop(0, 'transparent'); - gradient.addColorStop(1, 'rgba(0,0,0,0.8)'); + gradient.addColorStop(1, 'rgba(0,0,0,0.9)'); ctx.fillStyle = gradient; - ctx.fillRect(0, height - 150, width, 150); + ctx.fillRect(0, height - 200, width, 200); - // App Name + // Text Settings + ctx.shadowColor = 'rgba(0,0,0,0.5)'; + ctx.shadowBlur = 4; + + // User Name & App Name (Bottom Right) + ctx.textAlign = 'right'; + + // Nickname@Cosmo ctx.font = 'bold 32px sans-serif'; ctx.fillStyle = '#ffffff'; - ctx.textAlign = 'right'; - ctx.fillText('Cosmo', width - 40, height - 60); + ctx.fillText(`${username}@Cosmo`, width - 40, height - 60); + // Subtitle ctx.font = '16px sans-serif'; ctx.fillStyle = '#aaaaaa'; ctx.fillText('DEEP SPACE EXPLORER', width - 40, height - 35); - // Date/Time + // Date/Time (Bottom Left) ctx.textAlign = 'left'; ctx.font = 'bold 24px monospace'; ctx.fillStyle = '#44aaff'; @@ -61,8 +69,8 @@ export function useScreenshot() { ctx.fillStyle = '#cccccc'; ctx.fillText(timeStr, 40, height - 35); - // 6. Trigger Download - const dataUrl = tempCanvas.toDataURL('image/png'); + // 4. Trigger Download + const dataUrl = canvas.toDataURL('image/png'); const link = document.createElement('a'); link.download = `Cosmo_Snapshot_${now.toISOString().slice(0,19).replace(/[:T]/g, '-')}.png`; link.href = dataUrl; @@ -74,4 +82,4 @@ export function useScreenshot() { }, []); return { takeScreenshot }; -} +} \ No newline at end of file diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 155762d..4e48b6f 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1434,6 +1434,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base64-arraybuffer@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" + integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== + base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -1618,6 +1623,13 @@ cross-spawn@^7.0.1, cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" +css-line-break@^2.1.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz#bfef660dfa6f5397ea54116bb3cb4873edbc4fa0" + integrity sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w== + dependencies: + utrie "^1.0.2" + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -2085,6 +2097,14 @@ hls.js@^1.5.17: resolved "https://registry.npmmirror.com/hls.js/-/hls.js-1.6.15.tgz#9ce13080d143a9bc9b903fb43f081e335b8321e5" integrity sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA== +html2canvas@^1.4.1: + version "1.4.1" + resolved "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" + integrity sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA== + dependencies: + css-line-break "^2.1.0" + text-segmentation "^1.0.3" + ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -2845,6 +2865,13 @@ tailwindcss@^3.4.0: resolve "^1.22.8" sucrase "^3.35.0" +text-segmentation@^1.0.3: + version "1.0.3" + resolved "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz#52a388159efffe746b24a63ba311b6ac9f2d7943" + integrity sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw== + dependencies: + utrie "^1.0.2" + thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726" @@ -3000,6 +3027,13 @@ utility-types@^3.11.0: resolved "https://registry.npmmirror.com/utility-types/-/utility-types-3.11.0.tgz#607c40edb4f258915e901ea7995607fdf319424c" integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw== +utrie@^1.0.2: + version "1.0.2" + resolved "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz#d42fe44de9bc0119c25de7f564a6ed1b2c87a645" + integrity sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw== + dependencies: + base64-arraybuffer "^1.0.2" + vite@^7.2.4: version "7.2.4" resolved "https://registry.npmmirror.com/vite/-/vite-7.2.4.tgz#a3a09c7e25487612ecc1119c7d412c73da35bd4e"