diff --git a/app/components/Chart/CopyPngButton.vue b/app/components/Chart/CopyPngButton.vue new file mode 100644 index 0000000000..b527f24f44 --- /dev/null +++ b/app/components/Chart/CopyPngButton.vue @@ -0,0 +1,41 @@ + + + + + + + + + + + diff --git a/app/components/Compare/FacetBarChart.vue b/app/components/Compare/FacetBarChart.vue index 181786f1f0..276770dc46 100644 --- a/app/components/Compare/FacetBarChart.vue +++ b/app/components/Compare/FacetBarChart.vue @@ -11,6 +11,7 @@ import { createPatternDef } from 'vue-data-ui/utils' import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' import { useColors } from '~/composables/useColors' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import { insertLineBreaks, @@ -35,6 +36,8 @@ const resolvedMode = shallowRef<'light' | 'dark'>('light') const rootEl = shallowRef(null) const { width } = useElementSize(rootEl) const { copy, copied } = useClipboard() +const chartRef = useTemplateRef('chartRef') +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const mobileBreakpointWidth = 640 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) @@ -271,7 +274,7 @@ const config = computed(() => { - + {{ $t('compare.packages.bar_chart_nav_hint') }} @@ -293,7 +296,7 @@ const config = computed(() => { CSV + + + + PNG diff --git a/app/components/Compare/FacetScatterChart.vue b/app/components/Compare/FacetScatterChart.vue index 69744bc41b..9c67a5b760 100644 --- a/app/components/Compare/FacetScatterChart.vue +++ b/app/components/Compare/FacetScatterChart.vue @@ -11,6 +11,7 @@ import { buildCompareScatterChartDataset } from '~/utils/compare-scatter-chart' import { copyAltTextForCompareScatterChart } from '~/utils/charts' import { useColors } from '~/composables/useColors' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import('vue-data-ui/style.css') @@ -24,6 +25,8 @@ const resolvedMode = shallowRef<'light' | 'dark'>('light') const rootEl = shallowRef(null) const { width } = useElementSize(rootEl) const { copy, copied } = useClipboard() +const chartRef = useTemplateRef('chartRef') +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const mobileBreakpointWidth = 640 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) @@ -350,7 +353,7 @@ onMounted(async () => { - + { + + + PNG diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 5e8fccd573..a25cc82ee5 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -35,6 +35,7 @@ import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWate import { useColors } from '~/composables/useColors' import { parseStableVersion } from '~/utils/versions' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import { useElementSize, useTimeoutFn } from '@vueuse/core' import TimelineChartDepSizeTooltip from './TimelineChartDepSizeTooltip.vue' import TimelineChartXyTooltip from './TimelineChartXyTooltip.vue' @@ -355,6 +356,7 @@ const datasets = computed<{ }) const { copy, copied } = useClipboard() +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') @@ -986,6 +988,7 @@ const timelineMetricTabs = computed(() => [ :colors :gradientColors="E18E_GRADIENT_COLORS" :pauseAnimations="shouldPauseChartAnimations || loading" + :isCopyingPng /> @@ -996,6 +999,9 @@ const timelineMetricTabs = computed(() => [ CSV + + + PNG @@ -1081,6 +1087,7 @@ const timelineMetricTabs = computed(() => [ :activeVersionPlot="getActiveVersionDatapointBar(svg.data, svg.barWidth)" :colors :pauseAnimations="shouldPauseChartAnimations || loading" + :isCopyingPng /> @@ -1101,6 +1108,9 @@ const timelineMetricTabs = computed(() => [ CSV + + + PNG diff --git a/app/components/Package/TimelineChartDepSizeSvgSlot.vue b/app/components/Package/TimelineChartDepSizeSvgSlot.vue index 5a65f953a1..f1ff962682 100644 --- a/app/components/Package/TimelineChartDepSizeSvgSlot.vue +++ b/app/components/Package/TimelineChartDepSizeSvgSlot.vue @@ -1,18 +1,19 @@ - + diff --git a/app/components/Package/TimelineChartXySvgSlot.vue b/app/components/Package/TimelineChartXySvgSlot.vue index 8db1638a1b..ec38d6badd 100644 --- a/app/components/Package/TimelineChartXySvgSlot.vue +++ b/app/components/Package/TimelineChartXySvgSlot.vue @@ -11,6 +11,7 @@ const props = defineProps<{ colors: Record gradientColors: string[] pauseAnimations: boolean + isCopyingPng?: boolean }>() const svgElementTransitionClass = computed(() => [ @@ -23,7 +24,7 @@ const svgElementTransitionClass = computed(() => [ - + diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index bf5518d9ed..481a784053 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -25,6 +25,7 @@ import { getTrendsDatetimeFormatterOptions, } from '#shared/utils/trends-chart' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import { createLastDatapointLabelsSvg } from '#shared/utils/download-chart-last-label' import('vue-data-ui/style.css') @@ -71,6 +72,7 @@ const rootEl = shallowRef(null) const isZoomed = shallowRef(false) const chartRef = useTemplateRef('chartRef') +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) function setIsZoom({ isZoom }: { isZoom: boolean }) { isZoomed.value = isZoom @@ -1724,7 +1726,7 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value) CSV + + + PNG diff --git a/app/components/Package/VersionDistribution.vue b/app/components/Package/VersionDistribution.vue index badad67b4c..58fe698337 100644 --- a/app/components/Package/VersionDistribution.vue +++ b/app/components/Package/VersionDistribution.vue @@ -10,6 +10,7 @@ import { import TooltipApp from '~/components/Tooltip/App.vue' import { copyAltTextForVersionsBarChart, sanitise, applyEllipsis } from '~/utils/charts' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import('vue-data-ui/style.css') @@ -20,6 +21,8 @@ const props = defineProps<{ const { accentColors, selectedAccentColor } = useAccentColor() const { copy, copied } = useClipboard() +const chartRef = useTemplateRef('chartRef') +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') @@ -449,7 +452,12 @@ const chartConfig = computed(() => { - + @@ -464,7 +472,7 @@ const chartConfig = computed(() => { CSV + + + PNG diff --git a/app/composables/useCopyChartPng.ts b/app/composables/useCopyChartPng.ts new file mode 100644 index 0000000000..f372996724 --- /dev/null +++ b/app/composables/useCopyChartPng.ts @@ -0,0 +1,54 @@ +import { shallowRef, nextTick, toValue, type MaybeRefOrGetter } from 'vue' +import { useClipboardItems } from '@vueuse/core' + +/** The part of a vue-data-ui chart instance we need to export a PNG. */ +type ChartWithImageExport = { + getImage: (options?: { scale?: number }) => Promise<{ imageUri: string }> +} + +/** + * Copy a chart's PNG export to the clipboard, next to the built-in download. + * + * `isCopyingPng` has to be added to the `#svg` slot guard next to + * `svg.isPrintingImg`: the chart only raises its own printing flag for the + * built-in export buttons, so without it the copied image would be missing the + * watermark that the downloaded one has. + */ +export function useCopyChartPng( + chartRef: MaybeRefOrGetter, +) { + const { copy, copied: copiedPng, isSupported } = useClipboardItems() + const { announce } = useCommandPalette() + const { t } = useI18n() + const isCopyingPng = shallowRef(false) + + async function copyChartPng() { + const chart = toValue(chartRef) + if (!chart || !isSupported.value || isCopyingPng.value) return + + isCopyingPng.value = true + + // Everything up to `copy()` stays synchronous, and the item is handed a + // pending blob: awaiting the export first would spend the user activation + // that Safari requires for navigator.clipboard.write. + try { + const png = nextTick() + .then(() => chart.getImage()) + .then(({ imageUri }) => { + // Decode the data URI manually: fetch() on data: URIs is blocked by the CSP + const binary = atob(imageUri.slice(imageUri.indexOf(',') + 1)) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: 'image/png' }) + }) + + await copy([new ClipboardItem({ 'image/png': png })]) + // The button only swaps its icon, which says nothing to a screen reader + announce(t('command_palette.announcements.copied_to_clipboard')) + } finally { + isCopyingPng.value = false + } + } + + return { copiedPng, isCopyingPng, copyChartPng } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index a09e22b59f..c6881a08ae 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -723,6 +723,7 @@ "date_range": "{start} to {end}", "date_range_multiline": "{start}\nto {end}", "download_file": "Download {fileType}", + "copy_file": "Copy {fileType}", "toggle_annotator": "Toggle annotator", "toggle_stack_mode": "Toggle stack mode", "open_options": "Open options", diff --git a/i18n/schema.json b/i18n/schema.json index b7793e498c..a955b9da4e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -2173,6 +2173,9 @@ "download_file": { "type": "string" }, + "copy_file": { + "type": "string" + }, "toggle_annotator": { "type": "string" }, diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 0dc102ac11..2834583d4c 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -275,6 +275,7 @@ import { PackageExternalLinks, LicenseChangeWarning, ChartSplitSparkline, + ChartCopyPngButton, TabRoot, TabList, TabItem, @@ -1425,6 +1426,53 @@ describe('component accessibility audits', () => { }) }) + describe('ChartCopyPngButton', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: false }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations in the copied state', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: true, copying: false }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations while copying', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: true }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should expose an accessible name, since the button is icon-only', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: false }, + }) + const button = component.get('button') + expect(button.attributes('aria-label')).toBeTruthy() + expect(button.attributes('type')).toBe('button') + // The icon carries no text, so it must not be announced + expect(component.get('span').attributes('aria-hidden')).toBe('true') + }) + + it('should mark the button busy while the export runs', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: true }, + }) + const button = component.get('button') + // The spinner is decorative, so aria-busy is what conveys the pending state + expect(button.attributes('aria-busy')).toBe('true') + expect(button.attributes('aria-label')).toBeTruthy() + }) + }) + describe('TabRoot + TabList + TabItem + TabPanel', () => { // oxlint-disable-next-line unicorn/consistent-function-scoping function createTabsFixture(modelValue: string, idPrefix: string) {
{{ $t('compare.packages.bar_chart_nav_hint') }} @@ -293,7 +296,7 @@ const config = computed(() => { CSV + + + + PNG diff --git a/app/components/Compare/FacetScatterChart.vue b/app/components/Compare/FacetScatterChart.vue index 69744bc41b..9c67a5b760 100644 --- a/app/components/Compare/FacetScatterChart.vue +++ b/app/components/Compare/FacetScatterChart.vue @@ -11,6 +11,7 @@ import { buildCompareScatterChartDataset } from '~/utils/compare-scatter-chart' import { copyAltTextForCompareScatterChart } from '~/utils/charts' import { useColors } from '~/composables/useColors' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import('vue-data-ui/style.css') @@ -24,6 +25,8 @@ const resolvedMode = shallowRef<'light' | 'dark'>('light') const rootEl = shallowRef(null) const { width } = useElementSize(rootEl) const { copy, copied } = useClipboard() +const chartRef = useTemplateRef('chartRef') +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const mobileBreakpointWidth = 640 const isMobile = computed(() => width.value > 0 && width.value < mobileBreakpointWidth) @@ -350,7 +353,7 @@ onMounted(async () => { - + { + + + PNG diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 5e8fccd573..a25cc82ee5 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -35,6 +35,7 @@ import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWate import { useColors } from '~/composables/useColors' import { parseStableVersion } from '~/utils/versions' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import { useElementSize, useTimeoutFn } from '@vueuse/core' import TimelineChartDepSizeTooltip from './TimelineChartDepSizeTooltip.vue' import TimelineChartXyTooltip from './TimelineChartXyTooltip.vue' @@ -355,6 +356,7 @@ const datasets = computed<{ }) const { copy, copied } = useClipboard() +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') @@ -986,6 +988,7 @@ const timelineMetricTabs = computed(() => [ :colors :gradientColors="E18E_GRADIENT_COLORS" :pauseAnimations="shouldPauseChartAnimations || loading" + :isCopyingPng /> @@ -996,6 +999,9 @@ const timelineMetricTabs = computed(() => [ CSV + + + PNG @@ -1081,6 +1087,7 @@ const timelineMetricTabs = computed(() => [ :activeVersionPlot="getActiveVersionDatapointBar(svg.data, svg.barWidth)" :colors :pauseAnimations="shouldPauseChartAnimations || loading" + :isCopyingPng /> @@ -1101,6 +1108,9 @@ const timelineMetricTabs = computed(() => [ CSV + + + PNG diff --git a/app/components/Package/TimelineChartDepSizeSvgSlot.vue b/app/components/Package/TimelineChartDepSizeSvgSlot.vue index 5a65f953a1..f1ff962682 100644 --- a/app/components/Package/TimelineChartDepSizeSvgSlot.vue +++ b/app/components/Package/TimelineChartDepSizeSvgSlot.vue @@ -1,18 +1,19 @@ - + diff --git a/app/components/Package/TimelineChartXySvgSlot.vue b/app/components/Package/TimelineChartXySvgSlot.vue index 8db1638a1b..ec38d6badd 100644 --- a/app/components/Package/TimelineChartXySvgSlot.vue +++ b/app/components/Package/TimelineChartXySvgSlot.vue @@ -11,6 +11,7 @@ const props = defineProps<{ colors: Record gradientColors: string[] pauseAnimations: boolean + isCopyingPng?: boolean }>() const svgElementTransitionClass = computed(() => [ @@ -23,7 +24,7 @@ const svgElementTransitionClass = computed(() => [ - + diff --git a/app/components/Package/TrendsChart.vue b/app/components/Package/TrendsChart.vue index bf5518d9ed..481a784053 100644 --- a/app/components/Package/TrendsChart.vue +++ b/app/components/Package/TrendsChart.vue @@ -25,6 +25,7 @@ import { getTrendsDatetimeFormatterOptions, } from '#shared/utils/trends-chart' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import { createLastDatapointLabelsSvg } from '#shared/utils/download-chart-last-label' import('vue-data-ui/style.css') @@ -71,6 +72,7 @@ const rootEl = shallowRef(null) const isZoomed = shallowRef(false) const chartRef = useTemplateRef('chartRef') +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) function setIsZoom({ isZoom }: { isZoom: boolean }) { isZoomed.value = isZoom @@ -1724,7 +1726,7 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value) CSV + + + PNG diff --git a/app/components/Package/VersionDistribution.vue b/app/components/Package/VersionDistribution.vue index badad67b4c..58fe698337 100644 --- a/app/components/Package/VersionDistribution.vue +++ b/app/components/Package/VersionDistribution.vue @@ -10,6 +10,7 @@ import { import TooltipApp from '~/components/Tooltip/App.vue' import { copyAltTextForVersionsBarChart, sanitise, applyEllipsis } from '~/utils/charts' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import('vue-data-ui/style.css') @@ -20,6 +21,8 @@ const props = defineProps<{ const { accentColors, selectedAccentColor } = useAccentColor() const { copy, copied } = useClipboard() +const chartRef = useTemplateRef('chartRef') +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') @@ -449,7 +452,12 @@ const chartConfig = computed(() => { - + @@ -464,7 +472,7 @@ const chartConfig = computed(() => { CSV + + + PNG diff --git a/app/composables/useCopyChartPng.ts b/app/composables/useCopyChartPng.ts new file mode 100644 index 0000000000..f372996724 --- /dev/null +++ b/app/composables/useCopyChartPng.ts @@ -0,0 +1,54 @@ +import { shallowRef, nextTick, toValue, type MaybeRefOrGetter } from 'vue' +import { useClipboardItems } from '@vueuse/core' + +/** The part of a vue-data-ui chart instance we need to export a PNG. */ +type ChartWithImageExport = { + getImage: (options?: { scale?: number }) => Promise<{ imageUri: string }> +} + +/** + * Copy a chart's PNG export to the clipboard, next to the built-in download. + * + * `isCopyingPng` has to be added to the `#svg` slot guard next to + * `svg.isPrintingImg`: the chart only raises its own printing flag for the + * built-in export buttons, so without it the copied image would be missing the + * watermark that the downloaded one has. + */ +export function useCopyChartPng( + chartRef: MaybeRefOrGetter, +) { + const { copy, copied: copiedPng, isSupported } = useClipboardItems() + const { announce } = useCommandPalette() + const { t } = useI18n() + const isCopyingPng = shallowRef(false) + + async function copyChartPng() { + const chart = toValue(chartRef) + if (!chart || !isSupported.value || isCopyingPng.value) return + + isCopyingPng.value = true + + // Everything up to `copy()` stays synchronous, and the item is handed a + // pending blob: awaiting the export first would spend the user activation + // that Safari requires for navigator.clipboard.write. + try { + const png = nextTick() + .then(() => chart.getImage()) + .then(({ imageUri }) => { + // Decode the data URI manually: fetch() on data: URIs is blocked by the CSP + const binary = atob(imageUri.slice(imageUri.indexOf(',') + 1)) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type: 'image/png' }) + }) + + await copy([new ClipboardItem({ 'image/png': png })]) + // The button only swaps its icon, which says nothing to a screen reader + announce(t('command_palette.announcements.copied_to_clipboard')) + } finally { + isCopyingPng.value = false + } + } + + return { copiedPng, isCopyingPng, copyChartPng } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index a09e22b59f..c6881a08ae 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -723,6 +723,7 @@ "date_range": "{start} to {end}", "date_range_multiline": "{start}\nto {end}", "download_file": "Download {fileType}", + "copy_file": "Copy {fileType}", "toggle_annotator": "Toggle annotator", "toggle_stack_mode": "Toggle stack mode", "open_options": "Open options", diff --git a/i18n/schema.json b/i18n/schema.json index b7793e498c..a955b9da4e 100644 --- a/i18n/schema.json +++ b/i18n/schema.json @@ -2173,6 +2173,9 @@ "download_file": { "type": "string" }, + "copy_file": { + "type": "string" + }, "toggle_annotator": { "type": "string" }, diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 0dc102ac11..2834583d4c 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -275,6 +275,7 @@ import { PackageExternalLinks, LicenseChangeWarning, ChartSplitSparkline, + ChartCopyPngButton, TabRoot, TabList, TabItem, @@ -1425,6 +1426,53 @@ describe('component accessibility audits', () => { }) }) + describe('ChartCopyPngButton', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: false }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations in the copied state', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: true, copying: false }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations while copying', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: true }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should expose an accessible name, since the button is icon-only', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: false }, + }) + const button = component.get('button') + expect(button.attributes('aria-label')).toBeTruthy() + expect(button.attributes('type')).toBe('button') + // The icon carries no text, so it must not be announced + expect(component.get('span').attributes('aria-hidden')).toBe('true') + }) + + it('should mark the button busy while the export runs', async () => { + const component = await mountSuspended(ChartCopyPngButton, { + props: { copied: false, copying: true }, + }) + const button = component.get('button') + // The spinner is decorative, so aria-busy is what conveys the pending state + expect(button.attributes('aria-busy')).toBe('true') + expect(button.attributes('aria-label')).toBeTruthy() + }) + }) + describe('TabRoot + TabList + TabItem + TabPanel', () => { // oxlint-disable-next-line unicorn/consistent-function-scoping function createTabsFixture(modelValue: string, idPrefix: string) {
{ + + + PNG diff --git a/app/components/Package/TimelineChart.vue b/app/components/Package/TimelineChart.vue index 5e8fccd573..a25cc82ee5 100644 --- a/app/components/Package/TimelineChart.vue +++ b/app/components/Package/TimelineChart.vue @@ -35,6 +35,7 @@ import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWate import { useColors } from '~/composables/useColors' import { parseStableVersion } from '~/utils/versions' import { downloadFileLink } from '~/utils/download' +import { useCopyChartPng } from '~/composables/useCopyChartPng' import { useElementSize, useTimeoutFn } from '@vueuse/core' import TimelineChartDepSizeTooltip from './TimelineChartDepSizeTooltip.vue' import TimelineChartXyTooltip from './TimelineChartXyTooltip.vue' @@ -355,6 +356,7 @@ const datasets = computed<{ }) const { copy, copied } = useClipboard() +const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef) const colorMode = useColorMode() const resolvedMode = shallowRef<'light' | 'dark'>('light') @@ -986,6 +988,7 @@ const timelineMetricTabs = computed(() => [ :colors :gradientColors="E18E_GRADIENT_COLORS" :pauseAnimations="shouldPauseChartAnimations || loading" + :isCopyingPng />
@@ -464,7 +472,7 @@ const chartConfig = computed(() => { CSV