9 Commits

8 changed files with 173 additions and 82 deletions

View File

@@ -37,8 +37,6 @@ onMounted(() => {
document.addEventListener('keydown', handleGlobalKey, { passive: false })
// Set document language via shared util
if (lang) document.documentElement.setAttribute('lang', lang)
// Initialize title
document.title = formatTodayString(new Date(calendarStore.now))
})
onBeforeUnmount(() => {
@@ -49,7 +47,7 @@ onBeforeUnmount(() => {
watch(
() => calendarStore.now,
(val) => {
document.title = formatTodayString(new Date(val))
document.title = formatTodayString(new Date(val), "short", "short")
},
{ immediate: false },
)

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>
@@ -17,16 +63,7 @@ const formattedDate = computed(() => {
<div
class="cell"
:style="props.dragging ? 'touch-action:none;' : 'touch-action:pan-y;'"
:class="[
props.day.monthClass,
{
today: props.day.isToday,
weekend: props.day.isWeekend,
firstday: props.day.isFirstDay,
selected: props.day.isSelected,
holiday: props.day.isHoliday,
},
]"
:class="[props.day.monthClass, { today: props.day.isToday, weekend: props.day.isWeekend, firstday: props.day.isFirstDay, selected: props.day.isSelected, holiday: props.day.isHoliday }]"
:data-date="props.day.date"
>
<span class="compact-date">{{ formattedDate }}</span>
@@ -43,10 +80,8 @@ const formattedDate = computed(() => {
position: relative;
user-select: none;
display: grid;
/* Updated grid for centered day number */
grid-template-columns: 1fr;
grid-template-rows: 1fr auto;
/* Named grid areas */
grid-template-areas:
'day-number'
'holiday-info';
@@ -89,6 +124,26 @@ const formattedDate = computed(() => {
z-index: 15;
pointer-events: none;
}
/* Search highlight animation */
.cell.search-highlight-flash::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(100% + .2rem);
height: calc(100% + .2rem);
border-radius: 1rem;
background: transparent;
border: 0.3em solid var(--strong);
z-index: 16;
pointer-events: none;
}
.cell.search-highlight-flash::after { animation: search-highlight-flash 1.5s ease-out forwards; }
@keyframes search-highlight-flash {0%{opacity:0;transform:translate(-50%,-50%) scale(.8);border-width:.1em}15%{opacity:1;transform:translate(-50%,-50%) scale(1.05);border-width:.4em}30%{opacity:1;transform:translate(-50%,-50%) scale(1);border-width:.3em}100%{opacity:0;transform:translate(-50%,-50%) scale(1);border-width:.3em}}
.cell.selected h1.day-number {
opacity: 0.3;
filter: brightness(1.2);
@@ -127,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

@@ -197,11 +197,24 @@ function measureFromProbe() {
const {
getWeekIndex,
getFirstDayForVirtualWeek,
goToToday,
handleHeaderYearChange,
scrollToWeekCentered,
} = vwm
function showDay(input) {
const dateStr = input instanceof Date ? toLocalString(input, DEFAULT_TZ) : String(input)
const weekIndex = getWeekIndex(fromLocalString(dateStr, DEFAULT_TZ))
scrollToWeekCentered(weekIndex, 'nav', true)
const diff = Math.abs(weekIndex - centerVisibleWeek.value)
const delay = Math.min(800, diff * 40)
setTimeout(() => {
const el = document.querySelector(`[data-date="${dateStr}"]`)
if (!el) return
el.classList.add('search-highlight-flash')
setTimeout(() => el.classList.remove('search-highlight-flash'), 1500)
}, delay)
}
// Reference date for search: center of the current viewport (virtual week at vertical midpoint)
const centerVisibleWeek = computed(() => {
const midRow = (scrollTop.value + viewportHeight.value / 2) / rowHeight.value
@@ -245,7 +258,6 @@ watch(
function startDrag(dateStr) {
dateStr = normalizeDate(dateStr)
if (calendarStore.config.select_days === 0) return
isDragging.value = true
dragAnchor.value = dateStr
selection.value = { startDate: dateStr, dayCount: 1 }
@@ -358,23 +370,13 @@ function getDateFromCoordinates(clientX, clientY) {
}
function calculateSelection(anchorStr, otherStr) {
const limit = calendarStore.config.select_days
const anchorDate = fromLocalString(anchorStr, DEFAULT_TZ)
const otherDate = fromLocalString(otherStr, DEFAULT_TZ)
const forward = otherDate >= anchorDate
const span = daysInclusive(anchorStr, otherStr)
if (span <= limit) {
const startDate = forward ? anchorStr : otherStr
return { startDate, dayCount: span }
}
if (forward) {
return { startDate: anchorStr, dayCount: limit }
} else {
const startDate = addDaysStr(anchorStr, -(limit - 1), DEFAULT_TZ)
return { startDate, dayCount: limit }
}
const startDate = forward ? anchorStr : otherStr
return { startDate, dayCount: span }
}
onMounted(() => {
@@ -457,26 +459,15 @@ const handleEventClick = (payload) => {
openEditEventDialog(payload)
}
function scrollToEventStart(startDate, smooth = true) {
try {
const dateObj = fromLocalString(startDate, DEFAULT_TZ)
const weekIndex = getWeekIndex(dateObj)
scrollToWeekCentered(weekIndex, 'search-jump', smooth)
} catch {
/* noop */
function handleHeaderSearchPreview(r) { if (r) showDay(r.startDate) }
function handleHeaderSearchActivate(r) {
if (!r) return
showDay(r.startDate)
if (!r._goto && !r._holiday) {
const ev = calendarStore.getEventById(r.id)
if (ev) openEditEventDialog({ id: ev.id, event: ev })
}
}
function handleHeaderSearchPreview(result) {
if (!result) return
scrollToEventStart(result.startDate, true)
}
function handleHeaderSearchActivate(result) {
if (!result) return
scrollToEventStart(result.startDate, true)
// Open edit dialog for the event
const ev = calendarStore.getEventById(result.id)
if (ev) openEditEventDialog({ id: ev.id, event: ev })
}
// Heuristic: rotate month label (180deg) only for predominantly Latin text.
// We explicitly avoid locale detection; rely solely on characters present.
@@ -543,7 +534,7 @@ window.addEventListener('resize', () => {
<div class="wrap">
<HeaderControls
:reference-date="centerVisibleDateStr"
@go-to-today="goToToday"
@go-to-today="() => showDay(calendarStore.today)"
@search-preview="handleHeaderSearchPreview"
@search-activate="handleHeaderSearchActivate"
/>

View File

@@ -2,12 +2,16 @@
<div class="header-controls-wrapper">
<Transition name="header-controls" appear>
<div v-if="isVisible" class="header-controls">
<EventSearch
ref="eventSearchRef"
:reference-date="referenceDate"
@activate="handleSearchActivate"
@preview="(r) => emit('search-preview', r)"
/>
<div class="search-with-spacer">
<!-- Shrinkable spacer to align search with week label column; smoothly shrinks as needed -->
<div class="pre-search-spacer" aria-hidden="true"></div>
<EventSearch
ref="eventSearchRef"
:reference-date="referenceDate"
@activate="handleSearchActivate"
@preview="(r) => emit('search-preview', r)"
/>
</div>
<div
class="current-time"
aria-label="Current time (click to go to today)"
@@ -204,7 +208,32 @@ onBeforeUnmount(() => {
align-items: center;
width: 100%;
padding-inline-end: 2rem;
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;
flex: 1;
min-width: 0;
align-items: stretch;
}
.search-with-spacer > .search-bar {
flex: 1 1 auto;
min-width: 6rem; /* allow spacer to give up space first */
}
.pre-search-spacer {
flex: 0 1000 var(--week-w);
width: var(--week-w);
min-width: .5rem;
pointer-events: none;
transition: flex-basis 0.35s ease, width 0.35s ease;
}
.toggle-btn {
position: fixed;
top: 0;
@@ -307,7 +336,6 @@ onBeforeUnmount(() => {
font-size: 1.5em;
white-space: pre-line;
text-align: center;
margin-inline-end: 2rem;
}
.current-time {
@@ -323,7 +351,7 @@ onBeforeUnmount(() => {
color: var(--strong);
}
@media (max-width: 700px) {
@media (max-width: 770px) {
.current-time {
display: none;
}

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

@@ -8,6 +8,9 @@
aria-label="Search dates, holidays and events"
@keydown="handleSearchKeydown"
/>
<div v-if="shortcut" class="shortcut-hint">
{{ shortcut }}
</div>
<ul
v-if="searchQuery.trim() && searchResults.length"
class="search-dropdown"
@@ -60,6 +63,12 @@ const searchIndex = ref(0)
const searchInputRef = ref(null)
let previewTimer = null
// 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'
: ''
// Accent-insensitive lowercasing
const norm = (s) =>
s
@@ -469,8 +478,8 @@ function parseGoToDateCandidate(input, refStr) {
<style scoped>
.search-bar {
flex: 0 1 20rem;
margin-inline: auto; /* center with equal free-space on both sides */
flex: 1;
min-width: 0;
position: relative;
}
.search-bar input {
@@ -509,6 +518,27 @@ function parseGoToDateCandidate(input, refStr) {
.search-bar input::-webkit-search-cancel-button {
cursor: pointer;
}
.shortcut-hint {
position: absolute;
top: 50%;
inset-inline-end: 0.5rem;
transform: translateY(-50%);
pointer-events: none;
font-size: 0.75rem;
opacity: 0.6;
color: var(--muted);
font-family: ui-monospace, SF Mono, Consolas, Monaco, "Cascadia Mono", "Segoe UI Mono", "Roboto Mono", "Ubuntu Mono", "Source Code Pro", "Fira Mono", "Droid Sans Mono", "Courier New", monospace;
background: color-mix(in srgb, var(--panel) 85%, transparent);
padding: 0.15rem 0.3rem;
border-radius: 0.25rem;
border: 1px solid color-mix(in srgb, var(--muted) 25%, transparent);
}
.search-bar input:focus + .shortcut-hint,
.search-bar input:not(:placeholder-shown) + .shortcut-hint {
display: none;
}
.search-dropdown {
position: absolute;
top: calc(100% + 0.25rem);

View File

@@ -23,7 +23,6 @@ export const useCalendarStore = defineStore('calendar', {
_holidayConfigSignature: null,
_holidaysInitialized: false,
config: {
select_days: 14,
first_day: 1,
holidays: {
enabled: true,

View File

@@ -185,24 +185,13 @@ function formatDateLong(date, includeYear = false) {
/**
* Format date as today string (e.g., "Monday\nJanuary 15")
*/
function formatTodayString(date) {
function formatTodayString(date, weekday = "long", month = "long") {
const formatted = date
.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
.toLocaleDateString(undefined, { weekday, month, day: 'numeric' })
.replace(/,? /, '\n')
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