Fix Undo icon and improve mobile UX

This commit is contained in:
2026-02-10 00:08:23 +01:00
parent a1df95d3d4
commit 4d50eb97eb
7 changed files with 211 additions and 39 deletions

View File

@@ -34,35 +34,30 @@ const clearLongPress = () => {
const handlePointerDown = (e) => { const handlePointerDown = (e) => {
if (e.pointerType === 'mouse') { if (e.pointerType === 'mouse') {
if (e.button === 0) emit('start-drag', props.r, props.c, false); if (e.button === 0) emit('start-drag', props.r, props.c, false, false);
if (e.button === 2) emit('start-drag', props.r, props.c, true); if (e.button === 2) emit('start-drag', props.r, props.c, true, false);
return; return;
} }
longPressTriggered = false;
clearLongPress(); // Touch logic
longPressTimer = setTimeout(() => { const now = Date.now();
longPressTriggered = true; if (now - lastTap < 300) {
emit('start-drag', props.r, props.c, true); // Double tap -> X (Force)
}, 450); emit('start-drag', props.r, props.c, true, true);
lastTap = 0;
} else {
// Single tap / Start drag -> Fill
emit('start-drag', props.r, props.c, false, false);
lastTap = now;
}
}; };
const handlePointerUp = (e) => { const handlePointerUp = (e) => {
if (e.pointerType === 'mouse') return; // Handled in pointerdown
clearLongPress();
if (longPressTriggered) return;
const now = Date.now();
const isDoubleTap = now - lastTap < 300;
lastTap = now;
if (isDoubleTap) {
emit('start-drag', props.r, props.c, true);
} else {
emit('start-drag', props.r, props.c, false);
}
}; };
const handlePointerCancel = (e) => { const handlePointerCancel = (e) => {
if (e.pointerType === 'mouse') return; // Handled in pointerdown
clearLongPress();
}; };
</script> </script>

View File

@@ -15,6 +15,94 @@ const rowHintsRef = ref(null);
const activeRow = ref(null); const activeRow = ref(null);
const activeCol = ref(null); const activeCol = ref(null);
const isFinePointer = ref(false); const isFinePointer = ref(false);
const scrollWrapper = ref(null);
const scrollTrack = ref(null);
const showScrollbar = ref(false);
const thumbWidth = ref(20);
const thumbLeft = ref(0);
let isDraggingScroll = false;
let dragStartX = 0;
let dragStartLeft = 0;
const checkScroll = () => {
const el = scrollWrapper.value;
if (!el) return;
const sw = el.scrollWidth;
const cw = el.clientWidth;
// Only show custom scrollbar on mobile/tablet (width < 768px) and if content overflows
const isMobile = window.innerWidth <= 768;
showScrollbar.value = isMobile && (sw > cw + 1);
if (showScrollbar.value) {
// Thumb width percentage = (viewport / total) * 100
const ratio = cw / sw;
thumbWidth.value = Math.max(10, ratio * 100);
}
};
const handleScroll = () => {
if (isDraggingScroll) return;
const el = scrollWrapper.value;
if (!el) return;
const sw = el.scrollWidth;
const cw = el.clientWidth;
const sl = el.scrollLeft;
const maxScroll = sw - cw;
if (maxScroll <= 0) return;
// Map scroll position to thumb position (0 to 100 - thumbWidth)
const maxThumb = 100 - thumbWidth.value;
thumbLeft.value = (sl / maxScroll) * maxThumb;
};
const startScrollDrag = (e) => {
isDraggingScroll = true;
dragStartX = e.clientX || e.touches[0].clientX;
dragStartLeft = thumbLeft.value;
document.addEventListener('mousemove', onScrollDrag);
document.addEventListener('mouseup', stopScrollDrag);
document.addEventListener('touchmove', onScrollDrag, { passive: false });
document.addEventListener('touchend', stopScrollDrag);
};
const onScrollDrag = (e) => {
if (!isDraggingScroll || !scrollTrack.value) return;
const clientX = e.clientX || (e.touches ? e.touches[0].clientX : 0);
const deltaX = clientX - dragStartX;
const trackWidth = scrollTrack.value.offsetWidth;
if (trackWidth === 0) return;
// Calculate delta as percentage of track
const deltaPercent = (deltaX / trackWidth) * 100;
// New thumb position
let newLeft = dragStartLeft + deltaPercent;
const maxThumb = 100 - thumbWidth.value;
newLeft = Math.max(0, Math.min(maxThumb, newLeft));
thumbLeft.value = newLeft;
// Sync scroll
const el = scrollWrapper.value;
if (el) {
const sw = el.scrollWidth;
const cw = el.clientWidth;
const maxScroll = sw - cw;
el.scrollLeft = (newLeft / maxThumb) * maxScroll;
}
};
const stopScrollDrag = () => {
isDraggingScroll = false;
document.removeEventListener('mousemove', onScrollDrag);
document.removeEventListener('mouseup', stopScrollDrag);
document.removeEventListener('touchmove', onScrollDrag);
document.removeEventListener('touchend', stopScrollDrag);
};
const getRowHintsWidth = () => { const getRowHintsWidth = () => {
const el = rowHintsRef.value?.$el; const el = rowHintsRef.value?.$el;
@@ -74,6 +162,7 @@ onMounted(() => {
}); });
isFinePointer.value = window.matchMedia('(pointer: fine)').matches; isFinePointer.value = window.matchMedia('(pointer: fine)').matches;
window.addEventListener('resize', computeCellSize); window.addEventListener('resize', computeCellSize);
window.addEventListener('resize', checkScroll);
window.addEventListener('mouseup', handleGlobalMouseUp); window.addEventListener('mouseup', handleGlobalMouseUp);
window.addEventListener('pointerup', handleGlobalPointerUp); window.addEventListener('pointerup', handleGlobalPointerUp);
window.addEventListener('touchend', handleGlobalPointerUp, { passive: true }); window.addEventListener('touchend', handleGlobalPointerUp, { passive: true });
@@ -81,6 +170,7 @@ onMounted(() => {
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', computeCellSize); window.removeEventListener('resize', computeCellSize);
window.removeEventListener('resize', checkScroll);
window.removeEventListener('mouseup', handleGlobalMouseUp); window.removeEventListener('mouseup', handleGlobalMouseUp);
window.removeEventListener('pointerup', handleGlobalPointerUp); window.removeEventListener('pointerup', handleGlobalPointerUp);
window.removeEventListener('touchend', handleGlobalPointerUp); window.removeEventListener('touchend', handleGlobalPointerUp);
@@ -89,11 +179,12 @@ onUnmounted(() => {
watch(() => store.size, async () => { watch(() => store.size, async () => {
await nextTick(); await nextTick();
computeCellSize(); computeCellSize();
checkScroll();
}); });
</script> </script>
<template> <template>
<div class="game-board-wrapper"> <div class="game-board-wrapper" ref="scrollWrapper" @scroll="handleScroll">
<div class="game-container" :style="{ '--cell-size': `${cellSize}px` }"> <div class="game-container" :style="{ '--cell-size': `${cellSize}px` }">
<div class="corner-spacer"></div> <div class="corner-spacer"></div>
@@ -131,13 +222,30 @@ watch(() => store.size, async () => {
</div> </div>
</div> </div>
</div> </div>
<Teleport to="body">
<div v-if="showScrollbar" class="fixed-scroll-bar">
<div class="fixed-scroll-track" ref="scrollTrack" @pointerdown="startScrollDrag">
<div
class="fixed-scroll-thumb"
:style="{ width: `${thumbWidth}%`, left: `${thumbLeft}%` }"
></div>
</div>
</div>
</Teleport>
</template> </template>
<style scoped> <style scoped>
.game-board-wrapper { .game-board-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: flex-start;
overflow-x: auto;
width: 100%;
scrollbar-width: none; /* Hide default scrollbar */
}
.game-board-wrapper::-webkit-scrollbar {
display: none;
} }
.game-container { .game-container {
@@ -145,17 +253,24 @@ watch(() => store.size, async () => {
grid-template-columns: auto auto; grid-template-columns: auto auto;
grid-template-rows: auto auto; grid-template-rows: auto auto;
gap: 0; gap: 0;
padding: 20px; padding: 0;
background: rgba(0, 0, 0, 0.2); background: transparent;
border-radius: 16px; box-shadow: none;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.3);
margin-top: 10px; margin-top: 10px;
margin-left: auto;
margin-right: auto;
position: relative;
} }
.corner-spacer { .corner-spacer {
height: auto; /* Adapts to Col Hints height */ height: auto; /* Adapts to Col Hints height */
} }
/* Row Hints */
.game-container > :nth-child(3) {
/* No special styles */
}
.grid { .grid {
display: grid; display: grid;
gap: var(--gap-size); gap: var(--gap-size);

View File

@@ -607,14 +607,14 @@ watch(isMobileMenuOpen, (val) => {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
width: 100vw; width: 100%;
height: 100vh; height: 100vh;
background: rgba(10, 10, 25, 0.98); background: rgba(10, 10, 25, 0.98);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
z-index: 10000; z-index: 10000;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 20px; padding: 0;
overflow-y: auto; overflow-y: auto;
} }
@@ -622,22 +622,24 @@ watch(isMobileMenuOpen, (val) => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 40px; padding: 10px 20px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(0, 242, 254, 0.2); border-bottom: 1px solid rgba(0, 242, 254, 0.2);
flex-shrink: 0;
} }
.mobile-title { .mobile-title {
font-size: 1.5rem; font-size: 1.8rem;
color: var(--primary-neon); color: var(--primary-neon);
margin: 0; margin: 0;
letter-spacing: 2px; letter-spacing: 3px;
font-weight: 300;
} }
.mobile-menu-items { .mobile-menu-items {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 15px; gap: 15px;
padding: 20px;
} }
.mobile-group { .mobile-group {

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { usePuzzleStore } from '@/stores/puzzle'; import { usePuzzleStore } from '@/stores/puzzle';
import { useTimer } from '@/composables/useTimer'; import { useTimer } from '@/composables/useTimer';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { RotateCcw, RefreshCw, Eye } from 'lucide-vue-next'; import { RotateCcw, RefreshCw, Eye, Undo } from 'lucide-vue-next';
const store = usePuzzleStore(); const store = usePuzzleStore();
const { formatTime } = useTimer(); const { formatTime } = useTimer();
@@ -45,7 +45,7 @@ const progressText = computed(() => `${store.progressPercentage.toFixed(3)}%`);
</button> </button>
<div class="action-separator"></div> <div class="action-separator"></div>
<button class="action-btn" @click="store.undo" :title="t('actions.undo')"> <button class="action-btn" @click="store.undo" :title="t('actions.undo')">
<RotateCcw :size="20" /> <Undo :size="20" />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -8,7 +8,7 @@ export function useNonogram() {
const dragMode = ref(null); // 'fill', 'empty', 'cross' const dragMode = ref(null); // 'fill', 'empty', 'cross'
const startCellState = ref(null); const startCellState = ref(null);
const startDrag = (r, c, isRightClick = false) => { const startDrag = (r, c, isRightClick = false, force = false) => {
if (store.isGameWon) return; if (store.isGameWon) return;
isDragging.value = true; isDragging.value = true;
@@ -16,9 +16,7 @@ export function useNonogram() {
if (isRightClick) { if (isRightClick) {
// Right click logic // Right click logic
// If current is 1 (filled), do nothing usually? Or ignore? if (!force && current === 1) {
// Standard: Toggle 0 <-> 2
if (current === 1) {
dragMode.value = null; // invalid drag start dragMode.value = null; // invalid drag start
return; return;
} }

View File

@@ -1,3 +1,5 @@
@import './scrollbar.css';
:root { :root {
/* --- Glassmorphism Design System --- */ /* --- Glassmorphism Design System --- */
--bg-gradient: linear-gradient(135deg, #43C6AC 0%, #191654 100%); --bg-gradient: linear-gradient(135deg, #43C6AC 0%, #191654 100%);
@@ -52,6 +54,9 @@
--scroll-track: rgba(0, 0, 0, 0.1); --scroll-track: rgba(0, 0, 0, 0.1);
--scroll-thumb: rgba(255, 255, 255, 0.2); --scroll-thumb: rgba(255, 255, 255, 0.2);
--scroll-thumb-hover: var(--accent-cyan); --scroll-thumb-hover: var(--accent-cyan);
--fixed-bar-bg: rgba(0, 0, 0, 0.85);
--fixed-bar-thumb: rgba(0, 242, 255, 0.5);
--fixed-bar-thumb-hover: rgba(0, 242, 255, 0.8);
/* Rozmiary */ /* Rozmiary */
--cell-size: 30px; --cell-size: 30px;
@@ -113,6 +118,9 @@
--scroll-track: rgba(15, 23, 42, 0.08); --scroll-track: rgba(15, 23, 42, 0.08);
--scroll-thumb: rgba(15, 23, 42, 0.2); --scroll-thumb: rgba(15, 23, 42, 0.2);
--scroll-thumb-hover: #0ea5e9; --scroll-thumb-hover: #0ea5e9;
--fixed-bar-bg: rgba(248, 250, 255, 0.95);
--fixed-bar-thumb: rgba(14, 165, 233, 0.5);
--fixed-bar-thumb-hover: rgba(14, 165, 233, 0.8);
} }
* { * {

54
src/styles/scrollbar.css Normal file
View File

@@ -0,0 +1,54 @@
/* Fixed Scroll Bar */
.fixed-scroll-bar {
position: fixed;
bottom: 15px;
left: 50%;
transform: translateX(-50%);
width: 85%;
max-width: 400px;
height: 44px; /* Increased hit area */
background: transparent;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
animation: fadeIn 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
pointer-events: none;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
.fixed-scroll-track {
width: 100%;
height: 14px; /* Increased visual thickness */
background: rgba(15, 23, 42, 0.4);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
border-radius: 7px;
position: relative;
pointer-events: auto;
border: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.fixed-scroll-thumb {
position: absolute;
top: 0;
height: 100%;
background: var(--primary-accent);
border-radius: 7px;
cursor: grab;
box-shadow: 0 0 12px rgba(0, 242, 255, 0.5);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.fixed-scroll-thumb:active {
cursor: grabbing;
background: #fff;
box-shadow: 0 0 15px rgba(255, 255, 255, 0.6);
}