feat(qr): refine gradient drag handles with visual offsets, magnetic snapping to corners, and floating icon toggle
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user