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>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted, computed, onUnmounted } from 'vue'
|
||||||
import { Download } from 'lucide-vue-next'
|
import { Download, Eye, EyeOff } from 'lucide-vue-next'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { useFillHeight } from '../../composables/useFillHeight'
|
import { useFillHeight } from '../../composables/useFillHeight'
|
||||||
import { useLocalStorage } from '../../composables/useLocalStorage'
|
import { useLocalStorage } from '../../composables/useLocalStorage'
|
||||||
@@ -23,11 +23,49 @@ 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 fgGradPos = useLocalStorage('fgGradPos', { x1: 0, y1: 0, x2: 100, y2: 100 }, 'qr-code')
|
||||||
const showHandles = useLocalStorage('showHandles', true, 'qr-code')
|
const showHandles = useLocalStorage('showHandles', true, 'qr-code')
|
||||||
|
const format = useLocalStorage('format', 'png', 'qr-code')
|
||||||
|
|
||||||
const svgContent = ref('')
|
const svgContent = ref('')
|
||||||
const previewRef = ref(null)
|
const previewRef = ref(null)
|
||||||
const qrFrameRef = 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 activeHandle = ref(null)
|
||||||
|
|
||||||
const startDrag = (e, handleStr) => {
|
const startDrag = (e, handleStr) => {
|
||||||
@@ -52,6 +90,23 @@ const onDrag = (e) => {
|
|||||||
x = Math.max(0, Math.min(100, x))
|
x = Math.max(0, Math.min(100, x))
|
||||||
y = Math.max(0, Math.min(100, y))
|
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 [type, point] = activeHandle.value.split(':')
|
||||||
const posRef = type === 'fg' ? fgGradPos : bgGradPos
|
const posRef = type === 'fg' ? fgGradPos : bgGradPos
|
||||||
posRef.value[`x${point}`] = Math.round(x)
|
posRef.value[`x${point}`] = Math.round(x)
|
||||||
@@ -141,8 +196,6 @@ onMounted(() => {
|
|||||||
if (text.value) generateQR()
|
if (text.value) generateQR()
|
||||||
})
|
})
|
||||||
|
|
||||||
import { onUnmounted } from 'vue'
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (worker) {
|
if (worker) {
|
||||||
worker.terminate()
|
worker.terminate()
|
||||||
@@ -285,29 +338,35 @@ const triggerDownload = (blob, filename) => {
|
|||||||
<template v-if="showHandles">
|
<template v-if="showHandles">
|
||||||
<!-- Background Gradient Handles -->
|
<!-- Background Gradient Handles -->
|
||||||
<template v-if="!isBgTransparent && bgType !== 'solid'">
|
<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-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>
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- Foreground Gradient Handles -->
|
<!-- Foreground Gradient Handles -->
|
||||||
<template v-if="fgType !== 'solid'">
|
<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-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>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div class="download-settings">
|
<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">
|
<div class="control-group">
|
||||||
<label>Size (px)</label>
|
<label>Size (px)</label>
|
||||||
<div class="number-control size-control">
|
<div class="number-control size-control">
|
||||||
@@ -442,6 +501,28 @@ const triggerDownload = (blob, filename) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
container-type: size;
|
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 {
|
:root[data-theme="light"] .preview-section {
|
||||||
@@ -486,7 +567,7 @@ const triggerDownload = (blob, filename) => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 5;
|
z-index: 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grad-handle {
|
.grad-handle {
|
||||||
@@ -498,21 +579,18 @@ const triggerDownload = (blob, filename) => {
|
|||||||
cursor: grab;
|
cursor: grab;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgb(255, 255, 255);
|
||||||
box-shadow: 0 0 3px 1px rgba(255, 255, 255, 0.9);
|
box-shadow: 0 0 4px 0px rgb(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.grad-handle:active {
|
.grad-handle:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fg-handle { }
|
|
||||||
.bg-handle { }
|
|
||||||
|
|
||||||
.fg-line, .bg-line {
|
.fg-line, .bg-line {
|
||||||
stroke: rgba(0, 0, 0, 0.5);
|
stroke: rgb(255, 255, 255);
|
||||||
stroke-width: 1;
|
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 {
|
.download-settings {
|
||||||
|
|||||||
Reference in New Issue
Block a user