From f41c16f72c4e311682c7186382f08a7e54a2fcaf Mon Sep 17 00:00:00 2001 From: jason5ng32 Date: Wed, 27 May 2026 18:52:54 +0800 Subject: [PATCH] Improvements --- .../components/ip-infos/ASNConnectivity.vue | 186 +++++++++++------- frontend/components/ip-infos/IPCard.vue | 26 ++- .../components/widgets/ScreenshotButton.vue | 64 ++++++ frontend/composables/use-screenshot.js | 91 +++++++++ frontend/data/changelog.json | 18 +- frontend/locales/en.json | 1 + frontend/locales/fr.json | 1 + frontend/locales/tr.json | 1 + frontend/locales/zh.json | 1 + package.json | 1 + 10 files changed, 306 insertions(+), 84 deletions(-) create mode 100644 frontend/components/widgets/ScreenshotButton.vue create mode 100644 frontend/composables/use-screenshot.js diff --git a/frontend/components/ip-infos/ASNConnectivity.vue b/frontend/components/ip-infos/ASNConnectivity.vue index e7b8fcc3..8f906cb5 100644 --- a/frontend/components/ip-infos/ASNConnectivity.vue +++ b/frontend/components/ip-infos/ASNConnectivity.vue @@ -72,84 +72,104 @@ -
- - - {{ t('ipInfos.ASNConnectivity.dialogTitle', { asn: props.asn }) }} - - -
+ +
+
+ + + {{ t('ipInfos.ASNConnectivity.dialogTitle', { asn: props.asn }) }} + + +
- -
+ +
+
+ + + + + +
+ +
+ + + + + + + + + + + + AS{{ n.asn }} · {{ n.label }} + + + + AS{{ n.asn }} + + + {{ shortLabel(n.label) }} + + + +
+
+
- + class="shrink-0 px-4 py-3 flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted-foreground border-t"> + + + {{ item.label }} +
- -
- - - - - - - - - - - - AS{{ n.asn }} · {{ n.label }} - - - - AS{{ n.asn }} - - - {{ shortLabel(n.label) }} - - - -
-
- -
- - - {{ item.label }} -
diff --git a/frontend/composables/use-screenshot.js b/frontend/composables/use-screenshot.js new file mode 100644 index 00000000..77a9e421 --- /dev/null +++ b/frontend/composables/use-screenshot.js @@ -0,0 +1,91 @@ +// Generic DOM screenshot composable. Wraps html-to-image with retina output, +// `data-screenshot-exclude` filtering, and dynamic import so the library +// doesn't enter the initial bundle. + +import { ref } from 'vue'; +import { trackEvent } from '../utils/use-analytics.js'; + +export function slugifyForFilename(text, fallback = 'image') { + return String(text || '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || fallback; +} + +// External CSS rules targeting SVG children (Tailwind `fill-card` etc.) are +// dropped when html-to-image serializes the SVG, so every would fall +// back to black. We force-inline the computed paint here and restore it +// afterwards. +const SVG_INLINED_PROPS = [ + 'fill', 'fill-opacity', + 'stroke', 'stroke-opacity', 'stroke-width', 'stroke-linejoin', 'stroke-linecap', + 'opacity', + 'font-family', 'font-size', 'font-weight', +]; + +function inlineSvgComputedStyles(root) { + if (!root || typeof window === 'undefined') return () => {}; + const targets = root.querySelectorAll('svg, svg *'); + const restorers = []; + targets.forEach((el) => { + const cs = window.getComputedStyle(el); + const orig = el.getAttribute('style'); + let next = orig || ''; + SVG_INLINED_PROPS.forEach((prop) => { + const v = cs.getPropertyValue(prop); + if (v) next += `;${prop}:${v}`; + }); + el.setAttribute('style', next); + restorers.push(() => { + if (orig === null) el.removeAttribute('style'); + else el.setAttribute('style', orig); + }); + }); + return () => restorers.forEach((fn) => fn()); +} + +export function useScreenshot() { + const isCapturing = ref(false); + + // `beforeCapture(element)` lets callers mutate DOM right before capture + // (e.g. expand a clipped container); return a teardown to restore. + const capture = async (element, { + filename, + trackLabel, + pixelRatio = 2, + beforeCapture, + } = {}) => { + if (!element || isCapturing.value) return false; + isCapturing.value = true; + if (trackLabel) trackEvent('Screenshot', 'SaveImageClick', trackLabel); + const cleanups = []; + try { + if (typeof beforeCapture === 'function') { + const teardown = await beforeCapture(element); + if (typeof teardown === 'function') cleanups.push(teardown); + } + cleanups.push(inlineSvgComputedStyles(element)); + const { toPng } = await import('html-to-image'); + const dataUrl = await toPng(element, { + pixelRatio, + cacheBust: true, + filter: (node) => !(node instanceof HTMLElement) + || !node.hasAttribute('data-screenshot-exclude'), + }); + const a = document.createElement('a'); + a.href = dataUrl; + a.download = filename || `myip-${Date.now()}.png`; + a.click(); + return true; + } catch (err) { + console.error('Screenshot failed:', err); + return false; + } finally { + // LIFO: undo SVG inlining first, then caller's teardown. + while (cleanups.length) cleanups.pop()(); + isCapturing.value = false; + } + }; + + return { isCapturing, capture }; +} diff --git a/frontend/data/changelog.json b/frontend/data/changelog.json index e047c1d5..b171f4bb 100644 --- a/frontend/data/changelog.json +++ b/frontend/data/changelog.json @@ -1179,6 +1179,15 @@ "version": "v6.4.0", "date": "Beta", "content": [ + { + "type": "add", + "change": { + "en": "You can now save IP card or ASN connectivity graph as image", + "zh": "现在可以保存 IP 卡片或 ASN 连接图作为图片", + "fr": "Vous pouvez maintenant enregistrer les cartes IP ou le graphique de connectivité ASN en image", + "tr": "IP kartı veya ASN bağlantı grafiği görüntüsü olarak kaydedilebilir" + } + }, { "type": "improve", "change": { @@ -1206,15 +1215,6 @@ "tr": "Navbar mobil tarayıcıda kaydırılırken gizlenir" } }, - { - "type": "improve", - "change": { - "en": "You can now mask IP when taking screenshots", - "zh": "现在可以给 IP 打码,保护隐私", - "fr": "Vous pouvez maintenant masquer les IP lors des captures d'écran", - "tr": "Ekran görüntülerinde IP'leri gizleyebilirsiniz" - } - }, { "type": "fix", "change": { diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 0b92d788..0bd74a7c 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -515,6 +515,7 @@ }, "Tooltips": { "RefreshIPCard": "Refresh IP Card", + "SaveAsImage": "Save as image", "RefreshConnectivityTests": "Refresh Connectivity Tests", "RefreshWebRTC": "Refresh WebRTC Leak Test", "RefreshDNSLeakTest": "Refresh DNS Leak Test", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index f0954916..5147a9ac 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -515,6 +515,7 @@ }, "Tooltips": { "RefreshIPCard": "Actualiser la carte IP", + "SaveAsImage": "Enregistrer en image", "RefreshConnectivityTests": "Actualiser les tests de connectivité", "RefreshWebRTC": "Actualiser le test WebRTC Leak", "RefreshDNSLeakTest": "Actualiser le test de fuite DNS", diff --git a/frontend/locales/tr.json b/frontend/locales/tr.json index f5620a99..7cacc3ed 100644 --- a/frontend/locales/tr.json +++ b/frontend/locales/tr.json @@ -514,6 +514,7 @@ }, "Tooltips": { "RefreshIPCard": "IP Kartını Yenile", + "SaveAsImage": "Resim olarak kaydet", "RefreshConnectivityTests": "Bağlantı Testlerini Yenile", "RefreshWebRTC": "WebRTC Sızıntı Testini Yenile", "RefreshDNSLeakTest": "DNS Sızıntı Testini Yenile", diff --git a/frontend/locales/zh.json b/frontend/locales/zh.json index aee77502..6dc2289b 100644 --- a/frontend/locales/zh.json +++ b/frontend/locales/zh.json @@ -517,6 +517,7 @@ }, "Tooltips": { "RefreshIPCard": "刷新 IP 信息", + "SaveAsImage": "保存为图片", "RefreshConnectivityTests": "刷新连通性测试", "RefreshWebRTC": "刷新 WebRTC 泄露测试", "RefreshDNSLeakTest": "刷新 DNS 泄漏测试", diff --git a/package.json b/package.json index 67b8d9e7..581c5f8e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "express-rate-limit": "^8.5.2", "express-slow-down": "^3.1.0", "firebase": "^12.13.0", + "html-to-image": "^1.11.13", "http-proxy-middleware": "^4.0.0", "maxmind": "^5.0.6", "pinia": "^3.0.4",