diff --git a/src/App.vue b/src/App.vue index e7c1eeb..f451e1f 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,7 +13,7 @@ import { retainDays, captureInterval, TIME_OFFSET_MINUTES, TOTAL_MINUTES, getTagColor, getTagName, mainTags, getSubTags, logicalMinutesToTime, logicalMinutesFromTime, formatDuration, - loadTags, loadEvents + loadTags, loadEvents, toISODate } from "./store"; import { DBEvent, TimelineItem } from "./types"; @@ -31,6 +31,7 @@ const isTagManagerOpen = ref(false); const isExportModalOpen = ref(false); const isEventModalOpen = ref(false); const isCalendarOpen = ref(false); +const isMouseOverTimeline = ref(false); // Track mouse state to prevent race conditions const calendarRef = ref(null); const calendarMonth = ref(new Date()); @@ -51,6 +52,26 @@ const endTimePickerRef = ref(null); const isStartTimeOpen = ref(false); const isEndTimeOpen = ref(false); +const openStartTimePicker = async () => { + isStartTimeOpen.value = !isStartTimeOpen.value; + isEndTimeOpen.value = false; + if (isStartTimeOpen.value) { + await nextTick(); + const activeItems = document.querySelectorAll('.z-120 .bg-\\[\\#007AFF\\].text-white'); + activeItems.forEach(el => el.scrollIntoView({ block: 'center' })); + } +}; + +const openEndTimePicker = async () => { + isEndTimeOpen.value = !isEndTimeOpen.value; + isStartTimeOpen.value = false; + if (isEndTimeOpen.value) { + await nextTick(); + const activeItems = document.querySelectorAll('.z-120 .bg-\\[\\#007AFF\\].text-white'); + activeItems.forEach(el => el.scrollIntoView({ block: 'center' })); + } +}; + // --- Logic --- const updateCurrentMinute = () => { const now = new Date(); @@ -125,7 +146,13 @@ const applyTheme = (val: string) => { const loadTimeline = async (autoScroll = false) => { if (savePath.value) { - timelineImages.value = await invoke("get_timeline", { date: currentDate.value, baseDir: savePath.value }); + const rawImages = await invoke("get_timeline", { date: currentDate.value, baseDir: savePath.value }); + // Pre-calculate logical minutes to prevent high-cost recalculation on every mouse move + timelineImages.value = rawImages.map(img => ({ + ...img, + logical_minute: timeToLogicalMinutes(img.time, img.isNextDay) + })); + if (autoScroll && timelineRef.value) { await nextTick(); const now = new Date(); @@ -155,20 +182,56 @@ const timeToLogicalMinutes = (timeStr: string, isNextDay = false) => { let t = h * 60 + m + (s / 60); if (isNextDay) t += 1440; return t - TIME_OFFSET_MINUTES; }; +// Request Animation Frame lock for mouse move +let isMouseMovePending = false; const handleTimelineMouseMove = (e: MouseEvent) => { if (!timelineRef.value) return; - const rect = timelineRef.value.getBoundingClientRect(); - const min = Math.floor((e.clientY - rect.top + timelineRef.value.scrollTop) / timelineZoom.value); - if (min >= 0 && min < 1440) { - hoveredTime.value = logicalMinutesToTime(min); - if (isDragging.value) dragEndMin.value = min; - if (viewMode.value === 'dashboard') return; - const closest = timelineImages.value.reduce((p, c) => { - const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - min); - const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - min); - return cd < pd ? c : p; - }, timelineImages.value[0]); - if (closest) updatePreview(closest); + isMouseOverTimeline.value = true; + + if (isMouseMovePending) return; + isMouseMovePending = true; + + const clientY = e.clientY; // Capture coordinate outside RAF + + requestAnimationFrame(() => { + isMouseMovePending = false; + + // Safety check: If mouse already left before this frame executed, DO NOT update preview + if (!isMouseOverTimeline.value || !timelineRef.value) return; + + const rect = timelineRef.value.getBoundingClientRect(); + const min = Math.floor((clientY - rect.top + timelineRef.value.scrollTop) / timelineZoom.value); + + if (min >= 0 && min < 1440) { + hoveredTime.value = logicalMinutesToTime(min); + if (isDragging.value) dragEndMin.value = min; + if (viewMode.value === 'dashboard') return; + + if (timelineImages.value.length === 0) return; + + let closest = timelineImages.value[0]; + let minDiff = Math.abs(closest.logical_minute! - min); + + for (let i = 1; i < timelineImages.value.length; i++) { + const img = timelineImages.value[i]; + const diff = Math.abs(img.logical_minute! - min); + if (diff < minDiff) { + minDiff = diff; + closest = img; + } + } + + if (closest) updatePreview(closest); + } + }); +}; + +const handleTimelineMouseLeave = () => { + isMouseOverTimeline.value = false; // Immediately flag as left + hoveredTime.value = null; + if (viewMode.value === 'dashboard') return; + if (!isDragging.value && lockedImage.value) { + updatePreview(lockedImage.value); } }; @@ -181,11 +244,17 @@ const handleTimelineMouseUp = () => { editingEvent.value = { id: 0, date: currentDate.value, start_minute: start, end_minute: end, main_tag_id: mainTags.value[0]?.id || 0, sub_tag_id: null, content: "" }; isEventModalOpen.value = true; } else if (timelineImages.value.length) { - const closest = timelineImages.value.reduce((p, c) => { - const pd = Math.abs(timeToLogicalMinutes(p.time, p.isNextDay) - start); - const cd = Math.abs(timeToLogicalMinutes(c.time, c.isNextDay) - start); - return cd < pd ? c : p; - }, timelineImages.value[0]); + // Optimized linear search on click + let closest = timelineImages.value[0]; + let minDiff = Math.abs(closest.logical_minute! - start); + for (let i = 1; i < timelineImages.value.length; i++) { + const img = timelineImages.value[i]; + const diff = Math.abs(img.logical_minute! - start); + if (diff < minDiff) { + minDiff = diff; + closest = img; + } + } if (closest) { lockedImage.value = closest; viewMode.value = 'preview'; updatePreview(closest); } } } @@ -203,10 +272,18 @@ const handleTimelineWheel = (e: WheelEvent) => { }; const selectCalendarDate = (date: Date) => { - currentDate.value = date.toLocaleDateString('sv'); + currentDate.value = toISODate(date); isCalendarOpen.value = false; - selectedImage.value = null; lockedImage.value = null; previewSrc.value = ""; - loadTimeline(true); loadEvents(); + selectedImage.value = null; + lockedImage.value = null; + previewSrc.value = ""; + hoveredTime.value = null; + hoveredEventDetails.value = null; + dayEvents.value = [] + timelineImages.value = []; + updateCurrentMinute(); + loadTimeline(true); + loadEvents(); }; const calendarDays = computed(() => { @@ -273,12 +350,12 @@ const togglePause = async () => { -
+
{{ String((h - 1 + 3) % 24).padStart(2, '0') }}:00
-
+
{{ hoveredTime }}
@@ -331,20 +408,20 @@ const togglePause = async () => {
- +
- +
- +
{{ formatDuration(editingEvent.start_minute, editingEvent.end_minute) }}
- +
@@ -362,7 +439,7 @@ const togglePause = async () => {
- +
@@ -374,8 +451,12 @@ const togglePause = async () => {
-
-
{{ getTagName(hoveredEventDetails.event.main_tag_id) }}
+
+
+
+ {{ getTagName(hoveredEventDetails.event.main_tag_id) }} + {{ getTagName(hoveredEventDetails.event.sub_tag_id) }} +
{{ logicalMinutesToTime(hoveredEventDetails.event.start_minute) }} - {{ logicalMinutesToTime(hoveredEventDetails.event.end_minute) }}{{ formatDuration(hoveredEventDetails.event.start_minute, hoveredEventDetails.event.end_minute) }}
{{ hoveredEventDetails.event.content }}
@@ -392,4 +473,4 @@ const togglePause = async () => { + \ No newline at end of file diff --git a/src/components/modals/SettingsModal.vue b/src/components/modals/SettingsModal.vue index 6a51e9a..7a84924 100644 --- a/src/components/modals/SettingsModal.vue +++ b/src/components/modals/SettingsModal.vue @@ -44,7 +44,7 @@ const createDBFile = async () => {