feat(qr): refine gradient drag handles with visual offsets, magnetic snapping to corners, and floating icon toggle

This commit is contained in:
2026-03-04 04:11:05 +00:00
parent bec6a0ec8f
commit b6eb33f205

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, watch, onMounted } from 'vue'
import { Download } from 'lucide-vue-next'
import { ref, watch, onMounted, computed, onUnmounted } from 'vue'
import { Download, Eye, EyeOff } from 'lucide-vue-next'
import QRCode from 'qrcode'
import { useFillHeight } from '../../composables/useFillHeight'
import { useLocalStorage } from '../../composables/useLocalStorage'
@@ -23,11 +23,49 @@ const fgColor1 = useLocalStorage('fgColor1', '#000000', 'qr-code')
const fgColor2 = useLocalStorage('fgColor2', '#10b981', 'qr-code')
const fgGradPos = useLocalStorage('fgGradPos', { x1: 0, y1: 0, x2: 100, y2: 100 }, 'qr-code')
const showHandles = useLocalStorage('showHandles', true, 'qr-code')
const format = useLocalStorage('format', 'png', 'qr-code')
const svgContent = ref('')
const previewRef = ref(null)
const qrFrameRef = ref(null)
const fgLinePts = computed(() => getLineEndPoints(fgGradPos.value))
const bgLinePts = computed(() => getLineEndPoints(bgGradPos.value))
const getLineEndPoints = (pos) => {
if (!qrFrameRef.value) return { x1: pos.x1, y1: pos.y1, x2: pos.x2, y2: pos.y2 }
const rect = qrFrameRef.value.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return { x1: pos.x1, y1: pos.y1, x2: pos.x2, y2: pos.y2 }
// Handle radius in pixels (14/2 = 7px) plus half the line stroke width (1/2 = 0.5px) if needed
const VISUAL_OFFSET_PX = 1;
const rPx = 7 - VISUAL_OFFSET_PX;
const dx = (pos.x2 - pos.x1) * rect.width / 100
const dy = (pos.y2 - pos.y1) * rect.height / 100
const distPx = Math.sqrt(dx * dx + dy * dy)
if (distPx <= rPx * 2) {
// Too close, don't show line
return { x1: pos.x1, y1: pos.y1, x2: pos.x1, y2: pos.y1 }
}
const angle = Math.atan2(dy, dx)
// Calculate offsets in percentages (using radius, not diameter)
// adding extra 1px to accommodate the stroke/shadow
const effectiveRPx = rPx + 1
const xOffsetPct = (Math.cos(angle) * effectiveRPx / rect.width) * 100
const yOffsetPct = (Math.sin(angle) * effectiveRPx / rect.height) * 100
return {
x1: pos.x1 + xOffsetPct,
y1: pos.y1 + yOffsetPct,
x2: pos.x2 - xOffsetPct,
y2: pos.y2 - yOffsetPct
}
}
const activeHandle = ref(null)
const startDrag = (e, handleStr) => {
@@ -52,6 +90,23 @@ const onDrag = (e) => {
x = Math.max(0, Math.min(100, x))
y = Math.max(0, Math.min(100, y))
// Snap to 5 points (Corners + Center)
// Distance threshold in percentages, roughly matching handle diameter mapping
const snapDist = 5
const snapPoints = [
{ x: 0, y: 0 }, { x: 100, y: 0 },
{ x: 0, y: 100 }, { x: 100, y: 100 },
{ x: 50, y: 50 }
]
for (const pt of snapPoints) {
if (Math.abs(x - pt.x) < snapDist && Math.abs(y - pt.y) < snapDist) {
x = pt.x
y = pt.y
break
}
}
const [type, point] = activeHandle.value.split(':')
const posRef = type === 'fg' ? fgGradPos : bgGradPos
posRef.value[`x${point}`] = Math.round(x)
@@ -141,8 +196,6 @@ onMounted(() => {
if (text.value) generateQR()
})
import { onUnmounted } from 'vue'
onUnmounted(() => {
if (worker) {
worker.terminate()
@@ -285,29 +338,35 @@ const triggerDownload = (blob, filename) => {
<template v-if="showHandles">
<!-- 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>
<svg class="grad-line-svg"><line :x1="bgLinePts.x1 + '%'" :y1="bgLinePts.y1 + '%'" :x2="bgLinePts.x2 + '%'" :y2="bgLinePts.y2 + '%'" class="bg-line" /></svg>
</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>
<svg class="grad-line-svg"><line :x1="fgLinePts.x1 + '%'" :y1="fgLinePts.y1 + '%'" :x2="fgLinePts.x2 + '%'" :y2="fgLinePts.y2 + '%'" class="fg-line" /></svg>
</template>
</template>
</div>
</div>
<!-- Overlay Icon Toggle -->
<button
v-if="fgType !== 'solid' || (!isBgTransparent && bgType !== 'solid')"
class="icon-btn edit-toggle-btn"
:class="{ 'active': showHandles }"
@click="showHandles = !showHandles"
title="Toggle edit handles"
>
<Eye v-if="showHandles" size="20" />
<EyeOff v-else size="20" />
</button>
</div>
<div class="download-settings">
<div class="control-group" v-if="fgType !== 'solid' || (!isBgTransparent && bgType !== 'solid')">
<label class="checkbox-label">
<input type="checkbox" v-model="showHandles"> Show edit handles
</label>
</div>
<div class="control-group">
<label>Size (px)</label>
<div class="number-control size-control">
@@ -442,6 +501,28 @@ const triggerDownload = (blob, filename) => {
width: 100%;
min-height: 0;
container-type: size;
position: relative;
}
.edit-toggle-btn {
position: absolute;
top: 0;
right: 0;
z-index: 20;
color: var(--text-secondary);
opacity: 0.6;
background: var(--panel-bg);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.edit-toggle-btn:hover {
opacity: 1;
color: var(--text-strong);
}
.edit-toggle-btn.active {
color: var(--primary-accent);
opacity: 0.9;
}
:root[data-theme="light"] .preview-section {
@@ -486,7 +567,7 @@ const triggerDownload = (blob, filename) => {
width: 100%;
height: 100%;
pointer-events: none;
z-index: 5;
z-index: 15;
}
.grad-handle {
@@ -498,21 +579,18 @@ const triggerDownload = (blob, filename) => {
cursor: grab;
z-index: 10;
touch-action: none;
background: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 3px 1px rgba(255, 255, 255, 0.9);
background: rgb(255, 255, 255);
box-shadow: 0 0 4px 0px rgb(0, 0, 0);
}
.grad-handle:active {
cursor: grabbing;
}
.fg-handle { }
.bg-handle { }
.fg-line, .bg-line {
stroke: rgba(0, 0, 0, 0.5);
stroke: rgb(255, 255, 255);
stroke-width: 1;
filter: drop-shadow(0px 0px 2px rgba(255,255,255,0.9));
filter: drop-shadow(0px 0px 2px rgb(0, 0, 0));
}
.download-settings {