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:
@@ -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);
|
||||
|
||||
26
styles.css
26
styles.css
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user