Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/components/Chart/CopyPngButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<script setup lang="ts">
// Custom entry for a vue-data-ui user options menu (via the #custom-menu-before
// slot): copies the chart's PNG export to the clipboard. See useCopyChartPng.
defineProps<{
copied: boolean
copying: boolean
}>()
</script>

<template>
<TooltipApp
:text="$t('package.trends.copy_file', { fileType: 'PNG' })"
position="left"
strategy="fixed"
:offset="0"
:tooltip-attr="{
class:
'px-3! py-1! text-base! text-fg-subtle! bg-bg! border-0! rounded-s! rounded-e-none! shadow-none! whitespace-nowrap!',
}"
>
<button
type="button"
:aria-busy="copying"
:aria-label="$t('package.trends.copy_file', { fileType: 'PNG' })"
class="vue-ui-user-options-button !cursor-pointer"
>
<!-- Rasterising a large chart takes a moment, so the click needs feedback -->
<span
v-if="copying"
class="i-svg-spinners:ring-resize w-6 h-6 text-fg-subtle"
aria-hidden="true"
/>
<span
v-else
class="w-6 h-6"
:class="copied ? 'i-lucide:check text-accent' : 'i-lucide:copy text-fg-subtle'"
aria-hidden="true"
/>
</button>
</TooltipApp>
</template>
11 changes: 9 additions & 2 deletions app/components/Compare/FacetBarChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -35,6 +36,8 @@ const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef<HTMLElement | null>(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)
Expand Down Expand Up @@ -271,7 +274,7 @@ const config = computed<VueUiHorizontalBarConfig>(() => {
<template>
<div class="font-mono facet-bar">
<ClientOnly v-if="packages.length">
<VueUiHorizontalBar :key="chartKey" :dataset :config class="[direction:ltr]">
<VueUiHorizontalBar ref="chartRef" :key="chartKey" :dataset :config class="[direction:ltr]">
<template #hint="{ isVisible }">
<p v-if="isVisible" class="text-accent text-xs pt-2" aria-hidden="true">
{{ $t('compare.packages.bar_chart_nav_hint') }}
Expand All @@ -293,7 +296,7 @@ const config = computed<VueUiHorizontalBarConfig>(() => {

<template #svg="{ svg }">
<g
v-if="svg.isPrintingSvg || svg.isPrintingImg"
v-if="svg.isPrintingSvg || svg.isPrintingImg || isCopyingPng"
v-html="
drawSmallNpmxLogoAndTaglineWatermark({
svg,
Expand All @@ -313,6 +316,10 @@ const config = computed<VueUiHorizontalBarConfig>(() => {
<span class="text-fg-subtle font-mono pointer-events-none">CSV</span>
</template>

<template #custom-menu-before>
<ChartCopyPngButton :copied="copiedPng" :copying="isCopyingPng" @click="copyChartPng" />
</template>

<template #optionImg>
<span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
</template>
Expand Down
14 changes: 12 additions & 2 deletions app/components/Compare/FacetScatterChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -24,6 +25,8 @@ const resolvedMode = shallowRef<'light' | 'dark'>('light')
const rootEl = shallowRef<HTMLElement | null>(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)
Expand Down Expand Up @@ -350,7 +353,7 @@ onMounted(async () => {

<ClientOnly>
<div class="w-full sm:max-w-[450px] order-2 sm:order-1">
<VueUiScatter :dataset :config :key="step">
<VueUiScatter ref="chartRef" :dataset :config :key="step">
<!-- Keyboard navigation hint -->
<template #hint="{ isVisible }">
<p
Expand Down Expand Up @@ -411,7 +414,7 @@ onMounted(async () => {
<template #svg="{ svg }">
<!-- Watermark -->
<g
v-if="svg.isPrintingSvg || svg.isPrintingImg"
v-if="svg.isPrintingSvg || svg.isPrintingImg || isCopyingPng"
v-html="
drawSmallNpmxLogoAndTaglineWatermark({
svg,
Expand Down Expand Up @@ -455,6 +458,13 @@ onMounted(async () => {
<span v-if="isOpen" class="i-lucide:x w-6 h-6" aria-hidden="true" />
<span v-else class="i-lucide:ellipsis-vertical w-6 h-6" aria-hidden="true" />
</template>
<template #custom-menu-before>
<ChartCopyPngButton
:copied="copiedPng"
:copying="isCopyingPng"
@click="copyChartPng"
/>
</template>
<template #optionImg>
<span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
</template>
Expand Down
10 changes: 10 additions & 0 deletions app/components/Package/TimelineChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -986,6 +988,7 @@ const timelineMetricTabs = computed(() => [
:colors
:gradientColors="E18E_GRADIENT_COLORS"
:pauseAnimations="shouldPauseChartAnimations || loading"
:isCopyingPng
/>
</template>

Expand All @@ -996,6 +999,9 @@ const timelineMetricTabs = computed(() => [
<template #optionCsv>
<span class="text-fg-subtle font-mono pointer-events-none">CSV</span>
</template>
<template #custom-menu-before>
<ChartCopyPngButton :copied="copiedPng" :copying="isCopyingPng" @click="copyChartPng" />
</template>
<template #optionImg>
<span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
</template>
Expand Down Expand Up @@ -1081,6 +1087,7 @@ const timelineMetricTabs = computed(() => [
:activeVersionPlot="getActiveVersionDatapointBar(svg.data, svg.barWidth)"
:colors
:pauseAnimations="shouldPauseChartAnimations || loading"
:isCopyingPng
/>
</template>

Expand All @@ -1101,6 +1108,9 @@ const timelineMetricTabs = computed(() => [
<template #optionCsv>
<span class="text-fg-subtle font-mono pointer-events-none">CSV</span>
</template>
<template #custom-menu-before>
<ChartCopyPngButton :copied="copiedPng" :copying="isCopyingPng" @click="copyChartPng" />
</template>
<template #optionImg>
<span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
</template>
Expand Down
5 changes: 3 additions & 2 deletions app/components/Package/TimelineChartDepSizeSvgSlot.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
<script setup lang="ts">
import type { VueUiStackbarSvgSlotProps } from 'vue-data-ui/vue-ui-stackbar'

const props = defineProps<{
defineProps<{
svg: VueUiStackbarSvgSlotProps['svg']
watermark?: string
activeVersionPlot: Partial<TimelinePlotItem> | null
colors: Record<string, string>
pauseAnimations: boolean
isCopyingPng?: boolean
}>()
</script>

<template>
<!-- Print watermark-->
<g v-if="svg.isPrintingSvg || svg.isPrintingImg" v-html="watermark" />
<g v-if="svg.isPrintingSvg || svg.isPrintingImg || isCopyingPng" v-html="watermark" />

<g class="pointer-events-none">
<!-- Marker for selected version -->
Expand Down
3 changes: 2 additions & 1 deletion app/components/Package/TimelineChartXySvgSlot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const props = defineProps<{
colors: Record<string, string>
gradientColors: string[]
pauseAnimations: boolean
isCopyingPng?: boolean
}>()

const svgElementTransitionClass = computed(() => [
Expand All @@ -23,7 +24,7 @@ const svgElementTransitionClass = computed(() => [

<template>
<!-- Print watermark-->
<g v-if="svg.isPrintingSvg || svg.isPrintingImg" v-html="watermark" />
<g v-if="svg.isPrintingSvg || svg.isPrintingImg || isCopyingPng" v-html="watermark" />

<g class="pointer-events-none">
<!-- Marker for selected version -->
Expand Down
11 changes: 10 additions & 1 deletion app/components/Package/TrendsChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -71,6 +72,7 @@ const rootEl = shallowRef<HTMLElement | null>(null)
const isZoomed = shallowRef(false)

const chartRef = useTemplateRef('chartRef')
const { copiedPng, isCopyingPng, copyChartPng } = useCopyChartPng(chartRef)

function setIsZoom({ isZoom }: { isZoom: boolean }) {
isZoomed.value = isZoom
Expand Down Expand Up @@ -1724,7 +1726,7 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value)

<!-- Inject npmx logo & tagline during SVG and PNG print -->
<g
v-if="svg.isPrintingSvg || svg.isPrintingImg"
v-if="svg.isPrintingSvg || svg.isPrintingImg || isCopyingPng"
v-html="
drawNpmxLogoAndTaglineWatermark({
svg,
Expand Down Expand Up @@ -1837,6 +1839,13 @@ const copyEmbedUrl = () => copyEmbed(embedUrl.value)
<template #optionCsv>
<span class="text-fg-subtle font-mono pointer-events-none">CSV</span>
</template>
<template #custom-menu-before>
<ChartCopyPngButton
:copied="copiedPng"
:copying="isCopyingPng"
@click="copyChartPng"
/>
</template>
<template #optionImg>
<span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
</template>
Expand Down
19 changes: 17 additions & 2 deletions app/components/Package/VersionDistribution.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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')
Expand Down Expand Up @@ -449,7 +452,12 @@ const chartConfig = computed<VueUiXyConfig>(() => {
<!-- Chart content -->
<ClientOnly v-if="xyDataset.length > 0 && !error">
<div class="chart-container w-full" :key="groupingMode">
<VueUiXy :dataset="xyDataset" :config="chartConfig" class="[direction:ltr]">
<VueUiXy
ref="chartRef"
:dataset="xyDataset"
:config="chartConfig"
class="[direction:ltr]"
>
<!-- Keyboard navigation hint -->
<template #hint="{ isVisible }">
<p v-if="isVisible" class="text-accent text-xs -mt-6 text-center" aria-hidden="true">
Expand All @@ -464,7 +472,7 @@ const chartConfig = computed<VueUiXyConfig>(() => {

<!-- Inject npmx logo & tagline during SVG and PNG print -->
<g
v-if="svg.isPrintingSvg || svg.isPrintingImg"
v-if="svg.isPrintingSvg || svg.isPrintingImg || isCopyingPng"
v-html="
drawNpmxLogoAndTaglineWatermark({
svg,
Expand Down Expand Up @@ -536,6 +544,13 @@ const chartConfig = computed<VueUiXyConfig>(() => {
<template #optionCsv>
<span class="text-fg-subtle font-mono pointer-events-none">CSV</span>
</template>
<template #custom-menu-before>
<ChartCopyPngButton
:copied="copiedPng"
:copying="isCopyingPng"
@click="copyChartPng"
/>
</template>
<template #optionImg>
<span class="text-fg-subtle font-mono pointer-events-none">PNG</span>
</template>
Expand Down
54 changes: 54 additions & 0 deletions app/composables/useCopyChartPng.ts
Original file line number Diff line number Diff line change
@@ -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<ChartWithImageExport | null | undefined>,
) {
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 }
}
1 change: 1 addition & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions i18n/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2173,6 +2173,9 @@
"download_file": {
"type": "string"
},
"copy_file": {
"type": "string"
},
"toggle_annotator": {
"type": "string"
},
Expand Down
Loading
Loading