feat: add copy PNG to clipboard button

New button next to Download PNG writes the rendered QR as a PNG Blob
to the system clipboard via the async Clipboard API, with a brief
in-button confirmation (Copied!) or error state.
This commit is contained in:
Julien Calixte
2026-05-28 12:02:34 +02:00
parent 6bb4b1b2fe
commit 77f54d0574
3 changed files with 65 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useQrCode, type QrOptions } from './composables/useQrCode'
const preview = ref<HTMLDivElement | null>(null)
@@ -20,7 +20,25 @@ const options = reactive<QrOptions>({
hideBackgroundDots: true,
})
const { download } = useQrCode(preview, options)
const { download, copyPng } = useQrCode(preview, options)
const copyState = ref<'idle' | 'copied' | 'error'>('idle')
let copyTimer: number | undefined
async function onCopyPng() {
try {
await copyPng()
copyState.value = 'copied'
} catch {
copyState.value = 'error'
}
window.clearTimeout(copyTimer)
copyTimer = window.setTimeout(() => (copyState.value = 'idle'), 1800)
}
const copyLabel = computed(() =>
copyState.value === 'copied' ? 'Copied!' : copyState.value === 'error' ? 'Copy failed' : 'Copy PNG',
)
const dotTypes = ['square', 'dots', 'rounded', 'classy', 'classy-rounded', 'extra-rounded'] as const
const cornerSquareTypes = ['square', 'dot', 'extra-rounded'] as const
@@ -146,6 +164,14 @@ function clearLogo() {
</div>
<div class="download">
<button type="button" @click="download('png')">Download PNG</button>
<button
type="button"
class="secondary"
:class="{ copied: copyState === 'copied', errored: copyState === 'error' }"
@click="onCopyPng"
>
{{ copyLabel }}
</button>
<button type="button" @click="download('svg')">Download SVG</button>
</div>
</section>

View File

@@ -40,7 +40,14 @@ export function useQrCode(container: Ref<HTMLElement | null>, options: QrOptions
qr.value?.download({ name: 'qrcode', extension })
}
return { download }
const copyPng = async () => {
if (!qr.value) throw new Error('QR not ready')
const data = await qr.value.getRawData('png')
if (!(data instanceof Blob)) throw new Error('Unexpected PNG output')
await navigator.clipboard.write([new ClipboardItem({ 'image/png': data })])
}
return { download, copyPng }
}
function toLibOptions(o: QrOptions) {

View File

@@ -203,10 +203,16 @@ input[type="file"] {
.download {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
@media (max-width: 480px) {
.download {
grid-template-columns: 1fr;
}
}
button {
font: inherit;
font-weight: 600;
@@ -240,3 +246,25 @@ button.ghost:hover {
background: #f1f5f9;
color: #1e293b;
}
button.secondary {
background: #fff;
color: #4f46e5;
border-color: #c7d2fe;
}
button.secondary:hover {
background: #eef2ff;
}
button.secondary.copied {
background: #ecfdf5;
color: #047857;
border-color: #6ee7b7;
}
button.secondary.errored {
background: #fef2f2;
color: #b91c1c;
border-color: #fecaca;
}