Implement drag-to-create event functionality in week view

Add interactive drag-to-create event functionality that allows users to click and drag in empty spaces of the week view to create new events. Features include:

- Mouse event handlers for drag interaction (mousedown, mousemove, mouseup)
- Real-time temporary event box display with visual feedback during drag
- Proper coordinate calculation using layer_y() for accurate time positioning
- Minimum 15-minute event duration enforcement
- Integration with event creation modal via callback with pre-filled start/end times
- CSS pointer-events optimizations to prevent child element interference
- Time-to-pixel and pixel-to-time conversion functions for accurate positioning

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Connor Johnstone
2025-08-29 11:00:32 -04:00
parent a8bb2c8164
commit df714a43a2
2 changed files with 158 additions and 3 deletions

View File

@@ -1,7 +1,8 @@
use yew::prelude::*;
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike};
use chrono::{Datelike, NaiveDate, Duration, Weekday, Local, Timelike, NaiveDateTime, NaiveTime};
use std::collections::HashMap;
use web_sys::MouseEvent;
use wasm_bindgen::JsCast;
use crate::services::calendar_service::{CalendarEvent, UserInfo};
#[derive(Properties, PartialEq)]
@@ -18,6 +19,16 @@ pub struct WeekViewProps {
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
#[prop_or_default]
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
#[prop_or_default]
pub on_create_event: Option<Callback<(NaiveDate, NaiveDateTime, NaiveDateTime)>>,
}
#[derive(Clone, PartialEq)]
struct DragState {
is_dragging: bool,
start_date: NaiveDate,
start_y: f64,
current_y: f64,
}
#[function_component(WeekView)]
@@ -27,6 +38,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
.map(|i| start_of_week + Duration::days(i))
.collect();
// Drag state for event creation
let drag_state = use_state(|| None::<DragState>);
// Helper function to get calendar color for an event
let get_event_color = |event: &CalendarEvent| -> String {
if let Some(user_info) = &props.user_info {
@@ -96,10 +110,85 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Day columns
<div class="week-days-grid">
{
week_days.iter().map(|date| {
week_days.iter().enumerate().map(|(_column_index, date)| {
let is_today = *date == props.today;
let day_events = props.events.get(date).cloned().unwrap_or_default();
// Drag event handlers
let drag_state_clone = drag_state.clone();
let date_for_drag = *date;
let onmousedown = {
let drag_state = drag_state_clone.clone();
Callback::from(move |e: MouseEvent| {
// Calculate Y position relative to day column container
// Use layer_y which gives coordinates relative to positioned ancestor
let relative_y = e.layer_y() as f64;
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
drag_state.set(Some(DragState {
is_dragging: true,
start_date: date_for_drag,
start_y: relative_y,
current_y: relative_y,
}));
e.prevent_default();
})
};
let onmousemove = {
let drag_state = drag_state_clone.clone();
Callback::from(move |e: MouseEvent| {
if let Some(mut current_drag) = (*drag_state).clone() {
if current_drag.is_dragging {
// Use layer_y for consistent coordinate calculation
let relative_y = e.layer_y() as f64;
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
current_drag.current_y = relative_y;
drag_state.set(Some(current_drag));
}
}
})
};
let onmouseup = {
let drag_state = drag_state_clone.clone();
let on_create_event = props.on_create_event.clone();
Callback::from(move |_e: MouseEvent| {
if let Some(current_drag) = (*drag_state).clone() {
if current_drag.is_dragging {
// Calculate start and end times
let start_time = pixels_to_time(current_drag.start_y);
let end_time = pixels_to_time(current_drag.current_y);
// Ensure start is before end
let (actual_start, actual_end) = if start_time <= end_time {
(start_time, end_time)
} else {
(end_time, start_time)
};
// Ensure minimum duration (15 minutes)
let actual_end = if actual_end.signed_duration_since(actual_start).num_minutes() < 15 {
actual_start + chrono::Duration::minutes(15)
} else {
actual_end
};
let start_datetime = NaiveDateTime::new(current_drag.start_date, actual_start);
let end_datetime = NaiveDateTime::new(current_drag.start_date, actual_end);
if let Some(callback) = &on_create_event {
callback.emit((current_drag.start_date, start_datetime, end_datetime));
}
drag_state.set(None);
}
}
})
};
html! {
<div
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
@@ -115,6 +204,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
None
}
}
{onmousedown}
{onmousemove}
{onmouseup}
>
// Time slot backgrounds - 24 full hour slots + 1 boundary slot
{
@@ -225,6 +317,31 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}).collect::<Html>()
}
</div>
// Temporary event box during drag
{
if let Some(drag) = (*drag_state).clone() {
if drag.is_dragging && drag.start_date == *date {
let start_y = drag.start_y.min(drag.current_y);
let height = (drag.current_y - drag.start_y).abs().max(20.0);
// Debug logging
html! {
<div
class="temp-event-box"
style={format!("top: {}px; height: {}px;", start_y, height)}
>
{format!("{}px - {}px", start_y as u32, (start_y + height) as u32)}
</div>
}
} else {
html! {}
}
} else {
html! {}
}
}
</div>
}
}).collect::<Html>()
@@ -264,6 +381,20 @@ fn get_weekday_name(weekday: Weekday) -> &'static str {
// Calculate the pixel position of an event based on its time
// Each hour is 60px, so we convert time to pixels
// Convert pixel position to time (inverse of time to pixels)
fn pixels_to_time(pixels: f64) -> NaiveTime {
let total_minutes = (pixels / 60.0) * 60.0; // 60px per hour, 60 minutes per hour
let hours = (total_minutes / 60.0) as u32;
let minutes = (total_minutes % 60.0) as u32;
// Clamp to valid time range
let hours = hours.min(23);
let minutes = minutes.min(59);
NaiveTime::from_hms_opt(hours, minutes, 0).unwrap_or(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
}
fn calculate_event_position(event: &CalendarEvent, date: NaiveDate) -> (f32, f32, bool) {
// Convert UTC times to local time for display
let local_start = event.start.with_timezone(&Local);

View File

@@ -582,11 +582,13 @@ body {
height: 60px;
border-bottom: 1px solid #f0f0f0;
position: relative;
pointer-events: none; /* Don't capture mouse events */
}
.time-slot-half {
height: 30px;
border-bottom: 1px dotted #f5f5f5;
pointer-events: none; /* Don't capture mouse events */
}
.time-slot-half:last-child {
@@ -597,6 +599,7 @@ body {
height: 60px; /* Match the final time label height */
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */
background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */
pointer-events: none; /* Don't capture mouse events */
}
/* Events Container */
@@ -606,7 +609,7 @@ body {
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
pointer-events: none; /* Container doesn't capture, but children (events) do */
}
/* Week Events */
@@ -644,6 +647,27 @@ body {
border-color: #ff9800;
}
/* Temporary event box during drag creation */
.temp-event-box {
position: absolute;
left: 4px;
right: 4px;
background: rgba(59, 130, 246, 0.3);
border: 2px dashed rgba(59, 130, 246, 0.8);
border-radius: 4px;
color: rgba(59, 130, 246, 0.9);
font-size: 0.75rem;
font-weight: 600;
padding: 4px 6px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 6; /* Higher than events */
text-align: center;
user-select: none;
}
.week-event .event-title {
font-weight: 600;
margin-bottom: 2px;