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 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 std::collections::HashMap;
|
||||||
use web_sys::MouseEvent;
|
use web_sys::MouseEvent;
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
use crate::services::calendar_service::{CalendarEvent, UserInfo};
|
||||||
|
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
@@ -18,6 +19,16 @@ pub struct WeekViewProps {
|
|||||||
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
pub on_event_context_menu: Option<Callback<(web_sys::MouseEvent, CalendarEvent)>>,
|
||||||
#[prop_or_default]
|
#[prop_or_default]
|
||||||
pub on_calendar_context_menu: Option<Callback<(web_sys::MouseEvent, NaiveDate)>>,
|
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)]
|
#[function_component(WeekView)]
|
||||||
@@ -27,6 +38,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
.map(|i| start_of_week + Duration::days(i))
|
.map(|i| start_of_week + Duration::days(i))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Drag state for event creation
|
||||||
|
let drag_state = use_state(|| None::<DragState>);
|
||||||
|
|
||||||
// Helper function to get calendar color for an event
|
// Helper function to get calendar color for an event
|
||||||
let get_event_color = |event: &CalendarEvent| -> String {
|
let get_event_color = |event: &CalendarEvent| -> String {
|
||||||
if let Some(user_info) = &props.user_info {
|
if let Some(user_info) = &props.user_info {
|
||||||
@@ -96,10 +110,85 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
// Day columns
|
// Day columns
|
||||||
<div class="week-days-grid">
|
<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 is_today = *date == props.today;
|
||||||
let day_events = props.events.get(date).cloned().unwrap_or_default();
|
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! {
|
html! {
|
||||||
<div
|
<div
|
||||||
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
class={classes!("week-day-column", if is_today { Some("today") } else { None })}
|
||||||
@@ -115,6 +204,9 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
{onmousedown}
|
||||||
|
{onmousemove}
|
||||||
|
{onmouseup}
|
||||||
>
|
>
|
||||||
// Time slot backgrounds - 24 full hour slots + 1 boundary slot
|
// Time slot backgrounds - 24 full hour slots + 1 boundary slot
|
||||||
{
|
{
|
||||||
@@ -225,6 +317,31 @@ pub fn week_view(props: &WeekViewProps) -> Html {
|
|||||||
}).collect::<Html>()
|
}).collect::<Html>()
|
||||||
}
|
}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
}
|
}
|
||||||
}).collect::<Html>()
|
}).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
|
// Calculate the pixel position of an event based on its time
|
||||||
// Each hour is 60px, so we convert time to pixels
|
// 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) {
|
fn calculate_event_position(event: &CalendarEvent, date: NaiveDate) -> (f32, f32, bool) {
|
||||||
// Convert UTC times to local time for display
|
// Convert UTC times to local time for display
|
||||||
let local_start = event.start.with_timezone(&Local);
|
let local_start = event.start.with_timezone(&Local);
|
||||||
|
|||||||
26
styles.css
26
styles.css
@@ -582,11 +582,13 @@ body {
|
|||||||
height: 60px;
|
height: 60px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
pointer-events: none; /* Don't capture mouse events */
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot-half {
|
.time-slot-half {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-bottom: 1px dotted #f5f5f5;
|
border-bottom: 1px dotted #f5f5f5;
|
||||||
|
pointer-events: none; /* Don't capture mouse events */
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-slot-half:last-child {
|
.time-slot-half:last-child {
|
||||||
@@ -597,6 +599,7 @@ body {
|
|||||||
height: 60px; /* Match the final time label height */
|
height: 60px; /* Match the final time label height */
|
||||||
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */
|
border-bottom: 2px solid #e9ecef; /* Strong border to match final boundary */
|
||||||
background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */
|
background: rgba(0,0,0,0.02); /* Slightly different background to indicate boundary */
|
||||||
|
pointer-events: none; /* Don't capture mouse events */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Events Container */
|
/* Events Container */
|
||||||
@@ -606,7 +609,7 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
pointer-events: none;
|
pointer-events: none; /* Container doesn't capture, but children (events) do */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Week Events */
|
/* Week Events */
|
||||||
@@ -644,6 +647,27 @@ body {
|
|||||||
border-color: #ff9800;
|
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 {
|
.week-event .event-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
|||||||
Reference in New Issue
Block a user