Compare commits

...

2 Commits

Author SHA1 Message Date
d82f5ec7c5 0.6.23
All checks were successful
Deploy to Production / deploy (push) Successful in 24s
2026-03-04 05:22:07 +00:00
4711102407 feat(qr): improve mobile UX for scanner and generator 2026-03-04 05:21:53 +00:00
5 changed files with 117 additions and 49 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "tools-app", "name": "tools-app",
"version": "0.6.22", "version": "0.6.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "tools-app", "name": "tools-app",
"version": "0.6.22", "version": "0.6.23",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@gkucmierz/utils": "^1.28.7", "@gkucmierz/utils": "^1.28.7",

View File

@@ -1,7 +1,7 @@
{ {
"name": "tools-app", "name": "tools-app",
"private": true, "private": true,
"version": "0.6.22", "version": "0.6.23",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -113,7 +113,13 @@ const onDrag = (e) => {
posRef.value[`y${point}`] = Math.round(y) posRef.value[`y${point}`] = Math.round(y)
} }
let justDragged = false
const stopDrag = () => { const stopDrag = () => {
if (activeHandle.value) {
justDragged = true
setTimeout(() => { justDragged = false }, 50)
}
activeHandle.value = null activeHandle.value = null
window.removeEventListener('mousemove', onDrag) window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag) window.removeEventListener('mouseup', stopDrag)
@@ -121,6 +127,14 @@ const stopDrag = () => {
window.removeEventListener('touchend', stopDrag) window.removeEventListener('touchend', stopDrag)
} }
const handleFrameClick = (event) => {
if (justDragged) return
if (!activeHandle.value) {
showHandles.value = !showHandles.value
}
}
const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin const { height: previewHeight } = useFillHeight(previewRef, 40) // 40px extra margin
let worker = null let worker = null
@@ -331,39 +345,36 @@ 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})`) }"> <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})`),
cursor: (fgType !== 'solid' || (!isBgTransparent && bgType !== 'solid')) ? 'pointer' : 'default'
}"
@click="handleFrameClick"
v-tooltip="(fgType !== 'solid' || (!isBgTransparent && bgType !== 'solid')) ? (showHandles ? 'Hide edit handles' : 'Show edit handles') : ''"
>
<div class="svg-wrapper" ref="qrFrameRef"> <div class="svg-wrapper" ref="qrFrameRef">
<div v-html="svgContent" class="svg-content-box"></div> <div v-html="svgContent" class="svg-content-box"></div>
<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'">
<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')" @click.stop></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')" @click.stop></div>
<svg class="grad-line-svg"><line :x1="bgLinePts.x1 + '%'" :y1="bgLinePts.y1 + '%'" :x2="bgLinePts.x2 + '%'" :y2="bgLinePts.y2 + '%'" class="bg-line" /></svg> <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'">
<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')" @click.stop></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')" @click.stop></div>
<svg class="grad-line-svg"><line :x1="fgLinePts.x1 + '%'" :y1="fgLinePts.y1 + '%'" :x2="fgLinePts.x2 + '%'" :y2="fgLinePts.y2 + '%'" class="fg-line" /></svg> <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"
v-tooltip="'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">
@@ -504,26 +515,7 @@ const triggerDownload = (blob, filename) => {
position: relative; 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 {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
@@ -619,5 +611,23 @@ const triggerDownload = (blob, filename) => {
min-width: 100px !important; min-width: 100px !important;
} }
@media (max-width: 600px) {
.preview-section {
padding: 1rem 0.5rem;
gap: 1rem;
border-radius: 8px;
}
.qr-container {
width: 100%;
aspect-ratio: 1;
min-height: unset;
}
.qr-frame {
padding: 0.5rem;
}
}
</style> </style>

View File

@@ -492,9 +492,11 @@ const isUrl = (string) => {
} }
.switch-camera-btn { .switch-camera-btn {
position: absolute; position: absolute !important;
top: 1rem; top: auto !important;
right: 1rem; left: auto !important;
bottom: 0.75rem !important;
right: 0.75rem !important;
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff; color: #fff;

View File

@@ -3,11 +3,60 @@ import { showTooltip, hideTooltip, tooltipState } from '../composables/useToolti
export const tooltipDirective = { export const tooltipDirective = {
mounted(el, binding) { mounted(el, binding) {
el._tooltipText = binding.value; el._tooltipText = binding.value;
let touchTimeout = null;
let isTouch = false;
el.addEventListener('mouseenter', () => showTooltip(el, el._tooltipText)); el._handleMouseEnter = () => {
el.addEventListener('mouseleave', hideTooltip); if (!isTouch) showTooltip(el, el._tooltipText);
el.addEventListener('focus', () => showTooltip(el, el._tooltipText)); };
el.addEventListener('blur', hideTooltip); el._handleMouseLeave = () => {
if (!isTouch) hideTooltip();
};
el._handleFocus = () => {
if (!isTouch) showTooltip(el, el._tooltipText);
};
el._handleBlur = () => {
if (!isTouch) hideTooltip();
};
el._handleTouchStart = () => {
isTouch = true;
if (touchTimeout) clearTimeout(touchTimeout);
touchTimeout = setTimeout(() => {
showTooltip(el, el._tooltipText);
}, 400); // 400ms long press threshold
};
el._handleTouchEnd = () => {
if (touchTimeout) clearTimeout(touchTimeout);
hideTooltip();
// Block ensuing simulated mouseenter events
setTimeout(() => { isTouch = false; }, 500);
};
el._handleTouchCancel = () => {
if (touchTimeout) clearTimeout(touchTimeout);
hideTooltip();
setTimeout(() => { isTouch = false; }, 500);
};
el._handleContextMenu = (e) => {
// Prevent the OS context menu if we're showing a tooltip via long press
if (isTouch && tooltipState.isVisible && tooltipState.text === el._tooltipText) {
e.preventDefault();
}
};
el.addEventListener('mouseenter', el._handleMouseEnter);
el.addEventListener('mouseleave', el._handleMouseLeave);
el.addEventListener('focus', el._handleFocus);
el.addEventListener('blur', el._handleBlur);
el.addEventListener('touchstart', el._handleTouchStart, { passive: true });
el.addEventListener('touchend', el._handleTouchEnd);
el.addEventListener('touchmove', el._handleTouchCancel, { passive: true });
el.addEventListener('touchcancel', el._handleTouchCancel);
el.addEventListener('contextmenu', el._handleContextMenu);
}, },
updated(el, binding) { updated(el, binding) {
el._tooltipText = binding.value; el._tooltipText = binding.value;
@@ -19,10 +68,17 @@ export const tooltipDirective = {
} }
}, },
unmounted(el) { unmounted(el) {
el.removeEventListener('mouseenter', () => showTooltip(el, el._tooltipText)); if (el._handleMouseEnter) {
el.removeEventListener('mouseleave', hideTooltip); el.removeEventListener('mouseenter', el._handleMouseEnter);
el.removeEventListener('focus', () => showTooltip(el, el._tooltipText)); el.removeEventListener('mouseleave', el._handleMouseLeave);
el.removeEventListener('blur', hideTooltip); el.removeEventListener('focus', el._handleFocus);
el.removeEventListener('blur', el._handleBlur);
el.removeEventListener('touchstart', el._handleTouchStart);
el.removeEventListener('touchend', el._handleTouchEnd);
el.removeEventListener('touchmove', el._handleTouchCancel);
el.removeEventListener('touchcancel', el._handleTouchCancel);
el.removeEventListener('contextmenu', el._handleContextMenu);
}
hideTooltip(); hideTooltip();
} }
}; };