All checks were successful
Deploy to Production / deploy (push) Successful in 8s
366 lines
10 KiB
Vue
366 lines
10 KiB
Vue
<script setup>
|
|
import { onMounted, onUnmounted, computed, ref, watch, nextTick } from 'vue';
|
|
import { usePuzzleStore } from '@/stores/puzzle';
|
|
import { useHints } from '@/composables/useHints';
|
|
import { useNonogram } from '@/composables/useNonogram';
|
|
import Cell from './Cell.vue';
|
|
import Hints from './Hints.vue';
|
|
|
|
const store = usePuzzleStore();
|
|
const { rowHints, colHints } = useHints(computed(() => store.solution));
|
|
const { startDrag, onMouseEnter, stopDrag } = useNonogram();
|
|
|
|
// Compute grid dimensions from hints
|
|
const gridRows = computed(() => rowHints.value.length);
|
|
const gridCols = computed(() => colHints.value.length);
|
|
|
|
const cellSize = ref(30);
|
|
const rowHintsRef = ref(null);
|
|
const activeRow = ref(null);
|
|
const activeCol = ref(null);
|
|
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 content = el.firstElementChild;
|
|
const contentWidth = content ? content.offsetWidth : el.scrollWidth;
|
|
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;
|
|
// Use contentWidth to check for overflow, as scrollWidth might be misleading
|
|
showScrollbar.value = isMobile && (contentWidth > cw + 1);
|
|
|
|
if (showScrollbar.value) {
|
|
// Thumb width percentage = (viewport / total) * 100
|
|
// Use contentWidth for more accurate ratio
|
|
const ratio = cw / contentWidth;
|
|
thumbWidth.value = Math.max(10, ratio * 100);
|
|
|
|
// Hide if content fits or almost fits (prevent useless scrollbar)
|
|
// Increased tolerance to 95%
|
|
if (ratio >= 0.95) {
|
|
showScrollbar.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
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 el = rowHintsRef.value?.$el;
|
|
if (!el) return 0;
|
|
return el.offsetWidth || 0;
|
|
};
|
|
|
|
const computeCellSize = () => {
|
|
const rootStyles = getComputedStyle(document.documentElement);
|
|
const hintWidth = getRowHintsWidth();
|
|
const gapRaw = rootStyles.getPropertyValue('--gap-size') || '2px';
|
|
const gridPadRaw = rootStyles.getPropertyValue('--grid-padding') || '5px';
|
|
const gap = parseFloat(gapRaw);
|
|
const gridPad = parseFloat(gridPadRaw);
|
|
|
|
const isDesktop = window.matchMedia('(min-width: 769px)').matches;
|
|
|
|
let containerWidth;
|
|
if (scrollWrapper.value) {
|
|
containerWidth = scrollWrapper.value.clientWidth;
|
|
} else {
|
|
// Fallback if wrapper not ready: window width minus estimated padding
|
|
// Body padding (40) + Layout padding (40) = 80
|
|
containerWidth = window.innerWidth - 80;
|
|
}
|
|
|
|
// Ensure we don't have negative space
|
|
const availableForGrid = Math.max(0, containerWidth - hintWidth);
|
|
|
|
// Calculate cell size based on width availability (columns)
|
|
// Vertical scrolling is acceptable, so we don't constrain by height (rows)
|
|
const cols = Math.max(1, gridCols.value);
|
|
const size = Math.floor((availableForGrid - gridPad * 2 - (cols - 1) * gap) / cols);
|
|
|
|
if (isDesktop) {
|
|
// Desktop: Allow overflow, use comfortable size
|
|
cellSize.value = 30;
|
|
} else {
|
|
// Mobile: Fit to screen width
|
|
// Keep min 18, max 36
|
|
cellSize.value = Math.max(18, Math.min(36, size));
|
|
}
|
|
};
|
|
|
|
const handleGlobalMouseUp = () => {
|
|
stopDrag();
|
|
};
|
|
|
|
const handleGlobalPointerUp = () => {
|
|
stopDrag();
|
|
};
|
|
|
|
const handlePointerMove = (e) => {
|
|
const el = document.elementFromPoint(e.clientX, e.clientY);
|
|
if (!el) return;
|
|
const r = el.getAttribute('data-r');
|
|
const c = el.getAttribute('data-c');
|
|
if (r != null && c != null) {
|
|
onMouseEnter(Number(r), Number(c));
|
|
}
|
|
};
|
|
|
|
const handleCellEnter = (r, c) => {
|
|
onMouseEnter(r, c);
|
|
if (!isFinePointer.value) return;
|
|
activeRow.value = r;
|
|
activeCol.value = c;
|
|
};
|
|
|
|
const handleGridLeave = () => {
|
|
stopDrag();
|
|
activeRow.value = null;
|
|
activeCol.value = null;
|
|
};
|
|
|
|
const handleResize = () => {
|
|
computeCellSize();
|
|
checkScroll();
|
|
// Re-check after potential layout animation/transition
|
|
setTimeout(() => {
|
|
computeCellSize();
|
|
checkScroll();
|
|
}, 300);
|
|
};
|
|
|
|
onMounted(() => {
|
|
nextTick(() => {
|
|
computeCellSize();
|
|
checkScroll();
|
|
// Extra check for slow layout/font loading or orientation changes
|
|
setTimeout(() => {
|
|
computeCellSize();
|
|
checkScroll();
|
|
}, 300);
|
|
});
|
|
isFinePointer.value = window.matchMedia('(pointer: fine)').matches;
|
|
|
|
window.addEventListener('resize', handleResize);
|
|
window.addEventListener('orientationchange', handleResize);
|
|
window.addEventListener('mouseup', handleGlobalMouseUp);
|
|
window.addEventListener('pointerup', handleGlobalPointerUp);
|
|
window.addEventListener('touchend', handleGlobalPointerUp, { passive: true });
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('resize', handleResize);
|
|
window.removeEventListener('orientationchange', handleResize);
|
|
window.removeEventListener('mouseup', handleGlobalMouseUp);
|
|
window.removeEventListener('pointerup', handleGlobalPointerUp);
|
|
window.removeEventListener('touchend', handleGlobalPointerUp);
|
|
});
|
|
|
|
watch(() => store.size, async () => {
|
|
await nextTick();
|
|
computeCellSize();
|
|
checkScroll();
|
|
setTimeout(() => {
|
|
computeCellSize();
|
|
checkScroll();
|
|
}, 300);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="game-board-wrapper" ref="scrollWrapper" @scroll="handleScroll">
|
|
<div class="game-container" :style="{ '--cell-size': `${cellSize}px` }">
|
|
<div class="corner-spacer"></div>
|
|
|
|
<!-- Column Hints -->
|
|
<Hints :hints="colHints" orientation="col" :size="gridCols" :activeIndex="activeCol" />
|
|
|
|
<!-- Row Hints -->
|
|
<Hints ref="rowHintsRef" :hints="rowHints" orientation="row" :size="gridRows" :activeIndex="activeRow" />
|
|
|
|
<!-- Grid -->
|
|
<div
|
|
class="grid"
|
|
:style="{
|
|
gridTemplateColumns: `repeat(${gridCols}, var(--cell-size))`,
|
|
gridTemplateRows: `repeat(${gridRows}, var(--cell-size))`
|
|
}"
|
|
@pointermove.prevent="handlePointerMove"
|
|
@mouseleave="handleGridLeave"
|
|
>
|
|
<template v-for="(row, r) in store.playerGrid" :key="r">
|
|
<Cell
|
|
v-for="(state, c) in row"
|
|
:key="`${r}-${c}`"
|
|
:state="state"
|
|
:r="r"
|
|
:c="c"
|
|
:class="{
|
|
'guide-right': (c + 1) % 5 === 0 && c !== gridCols - 1,
|
|
'guide-bottom': (r + 1) % 5 === 0 && r !== gridRows - 1
|
|
}"
|
|
@start-drag="startDrag"
|
|
@enter-cell="handleCellEnter"
|
|
/>
|
|
</template>
|
|
</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>
|
|
|
|
<style scoped>
|
|
.game-board-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
overflow-x: auto;
|
|
width: 100%;
|
|
scrollbar-width: none; /* Hide default scrollbar */
|
|
}
|
|
|
|
/* Desktop: Remove scroll behavior to ensure full grid visibility */
|
|
@media (min-width: 769px) {
|
|
.game-board-wrapper {
|
|
overflow: visible;
|
|
width: max-content;
|
|
min-width: 100%;
|
|
margin: 0 auto; /* Center the wrapper safely */
|
|
align-items: flex-start; /* Prevent cropping when centered */
|
|
padding-right: 40px;
|
|
}
|
|
|
|
.game-container {
|
|
/* margin: 0 auto; - wrapper handles centering now */
|
|
}
|
|
}
|
|
|
|
.game-board-wrapper::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.game-container {
|
|
display: grid;
|
|
grid-template-columns: auto auto;
|
|
grid-template-rows: auto auto;
|
|
gap: 0;
|
|
padding: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
margin-top: 10px;
|
|
margin-left: auto;
|
|
margin-right: auto;
|
|
position: relative;
|
|
}
|
|
|
|
.corner-spacer {
|
|
height: auto; /* Adapts to Col Hints height */
|
|
}
|
|
|
|
/* Row Hints */
|
|
.game-container > :nth-child(3) {
|
|
/* No special styles */
|
|
}
|
|
|
|
.grid {
|
|
display: grid;
|
|
gap: var(--gap-size);
|
|
padding: var(--grid-padding);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
/* Guide Lines */
|
|
:deep(.cell.guide-right) {
|
|
border-right: 2px solid rgba(0, 242, 255, 0.5) !important;
|
|
}
|
|
|
|
:deep(.cell.guide-bottom) {
|
|
border-bottom: 2px solid rgba(0, 242, 255, 0.5) !important;
|
|
}
|
|
</style>
|