4 Commits

Author SHA1 Message Date
Leo Vasanko
d461a42ae5 Hide shortcut key on Android. 2025-09-24 17:03:19 -06:00
Leo Vasanko
ade17b80b1 Shrink header by removing gaps on small screens. 2025-09-24 16:57:08 -06:00
Leo Vasanko
a0b140d54b Responsive date strings in calendar days for small screen support and consistent wrapping. 2025-09-24 16:53:17 -06:00
Leo Vasanko
365d9e1be2 Add touch support to months scroll. 2025-09-24 16:25:40 -06:00
5 changed files with 63 additions and 22 deletions

View File

@@ -1,15 +1,61 @@
<script setup>
import { computed } from 'vue'
import { formatDateCompact, fromLocalString } from '@/utils/date'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { fromLocalString } from '@/utils/date'
const props = defineProps({
day: Object,
dragging: { type: Boolean, default: false },
})
// Reactive viewport width detection
const isNarrowView = ref(false)
const isVeryNarrowView = ref(false)
const isSmallView = ref(false)
function checkViewportWidth() {
const width = window.innerWidth
isSmallView.value = width < 800
isNarrowView.value = width < 600
isVeryNarrowView.value = width < 400
}
onMounted(() => {
checkViewportWidth()
window.addEventListener('resize', checkViewportWidth)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkViewportWidth)
})
const formattedDate = computed(() => {
const date = fromLocalString(props.day.date)
return formatDateCompact(date)
let options = { day: 'numeric', month: 'short' }
if (isVeryNarrowView.value) {
// Very narrow: show only day number
options = { day: 'numeric' }
} else if (isNarrowView.value) {
// Narrow: show day and month, no weekday
options = { day: 'numeric', month: 'short' }
} else {
// Wide: show weekday, day, and month
options = { weekday: 'short', day: 'numeric', month: 'short' }
}
let formatted = date.toLocaleDateString(undefined, options)
// Below 700px, replace first space with newline to force weekday on separate line
if (isSmallView.value && !isNarrowView.value && !isVeryNarrowView.value) {
formatted = formatted.replace(/\s/, '\n')
}
// Replace the last space (between month and day) with nbsp to prevent breaking there
// but keep the space after weekday (if present) as regular space to allow wrapping
formatted = formatted.replace(/\s+(?=\S+$)/, '\u00A0')
return formatted
})
</script>
@@ -136,6 +182,7 @@ const formattedDate = computed(() => {
color: var(--ink);
line-height: 1;
pointer-events: none;
white-space: pre-wrap;
}
.cell.weekend .compact-date {

View File

@@ -211,6 +211,9 @@ onBeforeUnmount(() => {
gap: 1rem;
}
@media (max-width: 600px) {
.header-controls { gap: 0.1rem; }
}
/* Group search + spacer so outer gap doesn't create unwanted space */
.search-with-spacer {
display: flex;

View File

@@ -52,17 +52,17 @@ function clampScroll(x) {
function animateTo(target){target=clampScroll(target);const now=performance.now();if(animActive){const p=Math.min(1,(now-animStart)/ANIM_DURATION);animFrom=animFrom+(animTo-animFrom)*easeOutCubic(p);animTo=target;animStart=now;}else{animFrom=props.scrollTop;animTo=target;animStart=now;animActive=true;animFrame=requestAnimationFrame(stepAnim);return}if(!animFrame)animFrame=requestAnimationFrame(stepAnim)}
function stepAnim(){if(!animActive)return;const t=Math.min(1,(performance.now()-animStart)/ANIM_DURATION);const val=animFrom+(animTo-animFrom)*easeOutCubic(t);emit('scroll-to',clampScroll(val));if(t>=1){animActive=false;animFrame=null;return}animFrame=requestAnimationFrame(stepAnim)}
function onDragMouseDown(e){if(e.button!==0)return;if(animActive){const now=performance.now();const p=Math.min(1,(now-animStart)/ANIM_DURATION);const cur=animFrom+(animTo-animFrom)*easeOutCubic(p);animActive=false;animFrame&&cancelAnimationFrame(animFrame);animFrame=null;emit('scroll-to',clampScroll(cur));}cancelDragMomentum();isDragging.value=true;mainStartScroll=props.scrollTop;accumDelta=0;lastClientY=e.clientY;dragSamples=[{t:performance.now(),s:mainStartScroll}];if(jogwheelViewport.value&&jogwheelViewport.value.requestPointerLock)jogwheelViewport.value.requestPointerLock();window.addEventListener('mousemove',onDragMouseMove,{passive:false});window.addEventListener('mouseup',onDragMouseUp,{passive:false});e.preventDefault()}
function onDragPointerDown(e){if(e.button!==0)return;if(animActive){const now=performance.now();const p=Math.min(1,(now-animStart)/ANIM_DURATION);const cur=animFrom+(animTo-animFrom)*easeOutCubic(p);animActive=false;animFrame&&cancelAnimationFrame(animFrame);animFrame=null;emit('scroll-to',clampScroll(cur));}cancelDragMomentum();isDragging.value=true;mainStartScroll=props.scrollTop;accumDelta=0;lastClientY=e.clientY;dragSamples=[{t:performance.now(),s:mainStartScroll}];if(jogwheelViewport.value&&jogwheelViewport.value.requestPointerLock)jogwheelViewport.value.requestPointerLock();window.addEventListener('pointermove',onDragPointerMove,{passive:false});window.addEventListener('pointerup',onDragPointerUp,{passive:false});window.addEventListener('pointercancel',onDragPointerUp,{passive:false});e.preventDefault()}
function onDragMouseMove(e){if(!isDragging.value) return;let dy=typeof e.movementY==='number'?e.movementY:0;if(!pointerLocked){if(lastClientY!=null)dy=e.clientY-lastClientY;lastClientY=e.clientY;}accumDelta+=dy;let desired=mainStartScroll-accumDelta*SPEED_DRAG;if(desired<0)desired=0;const maxScroll=Math.max(0,props.totalVirtualWeeks*props.rowHeight-props.viewportHeight);if(desired>maxScroll)desired=maxScroll;emit('scroll-to',desired);dragSamples.push({t:performance.now(),s:desired});e.preventDefault()}
function onDragPointerMove(e){if(!isDragging.value) return;let dy=typeof e.movementY==='number'?e.movementY:0;if(!pointerLocked){if(lastClientY!=null)dy=e.clientY-lastClientY;lastClientY=e.clientY;}accumDelta+=dy;let desired=mainStartScroll-accumDelta*SPEED_DRAG;if(desired<0)desired=0;const maxScroll=Math.max(0,props.totalVirtualWeeks*props.rowHeight-props.viewportHeight);if(desired>maxScroll)desired=maxScroll;emit('scroll-to',desired);dragSamples.push({t:performance.now(),s:desired});e.preventDefault()}
function onDragMouseUp(e){if(!isDragging.value)return;isDragging.value=false;lastClientY=null;window.removeEventListener('mousemove',onDragMouseMove);window.removeEventListener('mouseup',onDragMouseUp);if(pointerLocked&&document.exitPointerLock)document.exitPointerLock();const v=computeDragVelocity();dragSamples=[];if(Math.abs(v)>=DRAG_MIN_V)startDragMomentum(v);e.preventDefault()}
function onDragPointerUp(e){if(!isDragging.value)return;isDragging.value=false;lastClientY=null;window.removeEventListener('pointermove',onDragPointerMove);window.removeEventListener('pointerup',onDragPointerUp);window.removeEventListener('pointercancel',onDragPointerUp);if(pointerLocked&&document.exitPointerLock)document.exitPointerLock();const v=computeDragVelocity();dragSamples=[];if(Math.abs(v)>=DRAG_MIN_V)startDragMomentum(v);e.preventDefault()}
function handlePointerLockChange(){pointerLocked=document.pointerLockElement===jogwheelViewport.value;if(!pointerLocked&&isDragging.value)onDragMouseUp(new MouseEvent('mouseup'))}
function handlePointerLockChange(){pointerLocked=document.pointerLockElement===jogwheelViewport.value;if(!pointerLocked&&isDragging.value)onDragPointerUp(new PointerEvent('pointerup'))}
onMounted(()=>{if(jogwheelViewport.value){jogwheelViewport.value.addEventListener('mousedown',onDragMouseDown);jogwheelViewport.value.addEventListener('wheel',onWheel,{passive:false,capture:true});}document.addEventListener('pointerlockchange',handlePointerLockChange)})
onMounted(()=>{if(jogwheelViewport.value){jogwheelViewport.value.addEventListener('pointerdown',onDragPointerDown);jogwheelViewport.value.addEventListener('wheel',onWheel,{passive:false,capture:true});}document.addEventListener('pointerlockchange',handlePointerLockChange)})
onBeforeUnmount(()=>{if(jogwheelViewport.value){jogwheelViewport.value.removeEventListener('mousedown',onDragMouseDown);jogwheelViewport.value.removeEventListener('wheel',onWheel);}window.removeEventListener('mousemove',onDragMouseMove);window.removeEventListener('mouseup',onDragMouseUp);document.removeEventListener('pointerlockchange',handlePointerLockChange)})
onBeforeUnmount(()=>{if(jogwheelViewport.value){jogwheelViewport.value.removeEventListener('pointerdown',onDragPointerDown);jogwheelViewport.value.removeEventListener('wheel',onWheel);}window.removeEventListener('pointermove',onDragPointerMove);window.removeEventListener('pointerup',onDragPointerUp);window.removeEventListener('pointercancel',onDragPointerUp);document.removeEventListener('pointerlockchange',handlePointerLockChange)})
function onWheel(e){if(e.ctrlKey)return;e.preventDefault();e.stopPropagation();cancelDragMomentum();const dy=e.deltaY;if(Math.abs(dy)<MIN_WHEEL_ABS)return;const dir=dy>0?1:-1;const base=animActive?animTo:props.scrollTop;animateTo(base+dir*MONTH_SCROLL())}
@@ -87,6 +87,7 @@ function startDragMomentum(v){cancelDragMomentum();dragMomentumVelocity=v;dragMo
z-index: 20;
cursor: ns-resize;
overscroll-behavior: contain;
touch-action: none;
}
.jogwheel-viewport::-webkit-scrollbar { display: none; }

View File

@@ -63,7 +63,9 @@ const searchIndex = ref(0)
const searchInputRef = ref(null)
let previewTimer = null
const shortcut = /Mac/.test(navigator.userAgent) ? '⌘F'
// Note: Android is also Linux. HarmonyOS 5 doesn't include "Linux".
const shortcut = /Android/.test(navigator.userAgent) ? ''
: /Mac/.test(navigator.userAgent) ? '⌘F'
: /Windows|Linux/.test(navigator.userAgent) ? 'Ctrl+F'
: ''

View File

@@ -192,17 +192,6 @@ function formatTodayString(date, weekday = "long", month = "long") {
return formatted.charAt(0).toUpperCase() + formatted.slice(1)
}
/**
* Format date as compact string for day cell corner (e.g., "Mon 15 Jan")
*/
function formatDateCompact(date) {
return date.toLocaleDateString(undefined, {
weekday: 'short',
day: 'numeric',
month: 'short'
})
}
export {
// constants
monthAbbr,
@@ -229,7 +218,6 @@ export {
formatDateRange,
formatDateShort,
formatDateLong,
formatDateCompact,
formatTodayString,
lunarPhaseSymbol,
// iso helpers re-export