Fix Undo icon and improve mobile UX
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
54
src/styles/scrollbar.css
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user