feat(qr): add draggable gradient handles for background and foreground styling

This commit is contained in:
2026-03-04 03:22:56 +00:00
parent 9f9ea255a8
commit 8fa0c9bd44
2 changed files with 141 additions and 9 deletions

View File

@@ -17,12 +17,53 @@ const isBgTransparent = useLocalStorage('isBgTransparent', true, 'qr-code')
const bgType = useLocalStorage('bgType', 'solid', 'qr-code') const bgType = useLocalStorage('bgType', 'solid', 'qr-code')
const bgColor1 = useLocalStorage('bgColor1', '#ffffff', 'qr-code') const bgColor1 = useLocalStorage('bgColor1', '#ffffff', 'qr-code')
const bgColor2 = useLocalStorage('bgColor2', '#e2e8f0', 'qr-code') const bgColor2 = useLocalStorage('bgColor2', '#e2e8f0', 'qr-code')
const bgGradPos = useLocalStorage('bgGradPos', { x1: 50, y1: 50, x2: 100, y2: 100 }, 'qr-code')
const fgType = useLocalStorage('fgType', 'solid', 'qr-code') const fgType = useLocalStorage('fgType', 'solid', 'qr-code')
const fgColor1 = useLocalStorage('fgColor1', '#000000', 'qr-code') const fgColor1 = useLocalStorage('fgColor1', '#000000', 'qr-code')
const fgColor2 = useLocalStorage('fgColor2', '#10b981', 'qr-code') const fgColor2 = useLocalStorage('fgColor2', '#10b981', 'qr-code')
const fgGradPos = useLocalStorage('fgGradPos', { x1: 0, y1: 0, x2: 100, y2: 100 }, 'qr-code')
const svgContent = ref('') const svgContent = ref('')
const previewRef = ref(null) const previewRef = ref(null)
const qrFrameRef = ref(null)
const activeHandle = ref(null)
const startDrag = (e, handleStr) => {
e.preventDefault()
activeHandle.value = handleStr
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
window.addEventListener('touchmove', onDrag, { passive: false })
window.addEventListener('touchend', stopDrag)
}
const onDrag = (e) => {
if (!activeHandle.value || !qrFrameRef.value) return
if (e.type === 'touchmove') e.preventDefault()
const rect = qrFrameRef.value.getBoundingClientRect()
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const clientY = e.touches ? e.touches[0].clientY : e.clientY
let x = ((clientX - rect.left) / rect.width) * 100
let y = ((clientY - rect.top) / rect.height) * 100
x = Math.max(0, Math.min(100, x))
y = Math.max(0, Math.min(100, y))
const [type, point] = activeHandle.value.split(':')
const posRef = type === 'fg' ? fgGradPos : bgGradPos
posRef.value[`x${point}`] = Math.round(x)
posRef.value[`y${point}`] = Math.round(y)
}
const stopDrag = () => {
activeHandle.value = null
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
window.removeEventListener('touchmove', onDrag)
window.removeEventListener('touchend', stopDrag)
}
const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin
@@ -64,15 +105,17 @@ const generateQR = () => {
bgType: bgType.value, bgType: bgType.value,
bgColor1: bgColor1.value, bgColor1: bgColor1.value,
bgColor2: bgColor2.value, bgColor2: bgColor2.value,
bgGradPos: { ...bgGradPos.value },
fgType: fgType.value, fgType: fgType.value,
fgColor1: fgColor1.value, fgColor1: fgColor1.value,
fgColor2: fgColor2.value fgColor2: fgColor2.value,
fgGradPos: { ...fgGradPos.value }
}) })
} }
watch([text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, fgType, fgColor1, fgColor2], () => { watch([text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, bgGradPos, fgType, fgColor1, fgColor2, fgGradPos], () => {
generateQR() generateQR()
}) }, { deep: true })
watch(text, (newText) => { watch(text, (newText) => {
if (newText) { if (newText) {
@@ -234,7 +277,25 @@ const triggerDownload = (blob, filename) => {
<div class="preview-section" v-if="text" ref="previewRef" :style="{ height: previewHeight }"> <div class="preview-section" v-if="text" ref="previewRef" :style="{ height: previewHeight }">
<div class="qr-container"> <div class="qr-container">
<div class="qr-frame" :style="{ background: isBgTransparent ? 'white' : bgType === 'solid' ? bgColor1 : (bgType === 'linear' ? `linear-gradient(to bottom right, ${bgColor1}, ${bgColor2})` : `radial-gradient(circle, ${bgColor1}, ${bgColor2})`) }" v-html="svgContent"></div> <div class="qr-frame" :style="{ background: isBgTransparent ? 'white' : bgType === 'solid' ? bgColor1 : (bgType === 'linear' ? `linear-gradient(to bottom right, ${bgColor1}, ${bgColor2})` : `radial-gradient(circle, ${bgColor1}, ${bgColor2})`) }">
<div class="svg-wrapper" ref="qrFrameRef">
<div v-html="svgContent" class="svg-content-box"></div>
<!-- Background Gradient Handles -->
<template v-if="!isBgTransparent && bgType !== 'solid'">
<svg class="grad-line-svg"><line :x1="bgGradPos.x1 + '%'" :y1="bgGradPos.y1 + '%'" :x2="bgGradPos.x2 + '%'" :y2="bgGradPos.y2 + '%'" class="bg-line" /></svg>
<div class="grad-handle bg-handle handle-1" :style="{ left: bgGradPos.x1 + '%', top: bgGradPos.y1 + '%' }" @mousedown="startDrag($event, 'bg:1')" @touchstart.prevent="startDrag($event, 'bg:1')"></div>
<div class="grad-handle bg-handle handle-2" :style="{ left: bgGradPos.x2 + '%', top: bgGradPos.y2 + '%' }" @mousedown="startDrag($event, 'bg:2')" @touchstart.prevent="startDrag($event, 'bg:2')"></div>
</template>
<!-- Foreground Gradient Handles -->
<template v-if="fgType !== 'solid'">
<svg class="grad-line-svg"><line :x1="fgGradPos.x1 + '%'" :y1="fgGradPos.y1 + '%'" :x2="fgGradPos.x2 + '%'" :y2="fgGradPos.y2 + '%'" class="fg-line" /></svg>
<div class="grad-handle fg-handle handle-1" :style="{ left: fgGradPos.x1 + '%', top: fgGradPos.y1 + '%' }" @mousedown="startDrag($event, 'fg:1')" @touchstart.prevent="startDrag($event, 'fg:1')"></div>
<div class="grad-handle fg-handle handle-2" :style="{ left: fgGradPos.x2 + '%', top: fgGradPos.y2 + '%' }" @mousedown="startDrag($event, 'fg:2')" @touchstart.prevent="startDrag($event, 'fg:2')"></div>
</template>
</div>
</div>
</div> </div>
<div class="download-settings"> <div class="download-settings">
@@ -391,6 +452,73 @@ const triggerDownload = (blob, filename) => {
overflow: hidden; overflow: hidden;
} }
.svg-wrapper {
position: relative;
width: 100%;
height: 100%;
}
.svg-content-box {
width: 100%;
height: 100%;
display: block;
}
.svg-content-box :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
.grad-line-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
}
.grad-handle {
position: absolute;
width: 20px;
height: 20px;
transform: translate(-50%, -50%);
border-radius: 50%;
cursor: grab;
z-index: 10;
touch-action: none;
}
.grad-handle:active {
cursor: grabbing;
}
.fg-handle {
border: 2px solid var(--primary-accent);
background: white;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
.bg-handle {
border: 2px dotted #888;
background: rgba(255,255,255,0.9);
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
}
.fg-line {
stroke: var(--primary-accent);
stroke-width: 2;
stroke-dasharray: 4;
}
.bg-line {
stroke: #888;
stroke-width: 2;
stroke-dasharray: 2;
}
.download-settings { .download-settings {
display: flex; display: flex;
gap: 1.5rem; gap: 1.5rem;

View File

@@ -1,7 +1,7 @@
import QRCode from 'qrcode' import QRCode from 'qrcode'
self.onmessage = async (e) => { self.onmessage = async (e) => {
const { id, text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, fgType, fgColor1, fgColor2 } = e.data const { id, text, ecc, isBgTransparent, bgType, bgColor1, bgColor2, bgGradPos, fgType, fgColor1, fgColor2, fgGradPos } = e.data
if (!text) { if (!text) {
self.postMessage({ id, svgContent: '' }) self.postMessage({ id, svgContent: '' })
@@ -23,16 +23,20 @@ self.onmessage = async (e) => {
if (fgType !== 'solid') { if (fgType !== 'solid') {
const isLinear = fgType === 'linear' const isLinear = fgType === 'linear'
const pos = fgGradPos || { x1: 0, y1: 0, x2: 100, y2: 100 }
const r = Math.sqrt(Math.pow(pos.x2 - pos.x1, 2) + Math.pow(pos.y2 - pos.y1, 2))
defsHtml += isLinear defsHtml += isLinear
? `<linearGradient id="qr-fg-grad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></linearGradient>` ? `<linearGradient id="qr-fg-grad" x1="${pos.x1}%" y1="${pos.y1}%" x2="${pos.x2}%" y2="${pos.y2}%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></linearGradient>`
: `<radialGradient id="qr-fg-grad" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></radialGradient>` : `<radialGradient id="qr-fg-grad" cx="${pos.x1}%" cy="${pos.y1}%" r="${r}%"><stop offset="0%" stop-color="${fgColor1}" /><stop offset="100%" stop-color="${fgColor2}" /></radialGradient>`
} }
if (!isBgTransparent && bgType !== 'solid') { if (!isBgTransparent && bgType !== 'solid') {
const isLinear = bgType === 'linear' const isLinear = bgType === 'linear'
const pos = bgGradPos || { x1: 0, y1: 0, x2: 100, y2: 100 }
const r = Math.sqrt(Math.pow(pos.x2 - pos.x1, 2) + Math.pow(pos.y2 - pos.y1, 2))
defsHtml += isLinear defsHtml += isLinear
? `<linearGradient id="qr-bg-grad" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></linearGradient>` ? `<linearGradient id="qr-bg-grad" x1="${pos.x1}%" y1="${pos.y1}%" x2="${pos.x2}%" y2="${pos.y2}%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></linearGradient>`
: `<radialGradient id="qr-bg-grad" cx="50%" cy="50%" r="50%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></radialGradient>` : `<radialGradient id="qr-bg-grad" cx="${pos.x1}%" cy="${pos.y1}%" r="${r}%"><stop offset="0%" stop-color="${bgColor1}" /><stop offset="100%" stop-color="${bgColor2}" /></radialGradient>`
} }
if (defsHtml) { if (defsHtml) {