Add event resize functionality with drag handles in week view

- Extend DragType enum to support ResizeEventStart and ResizeEventEnd operations
- Add visual resize handles at top/bottom edges of events for left-click resizing
- Implement start time resize by dragging top handle (preserves end time)
- Implement end time resize by dragging bottom handle (preserves start time)
- Add visual feedback with resizing event preview during drag operations
- Integrate resize operations with existing CalDAV update system
- Add CSS styling for resize handles with hover effects and resize cursors
- Maintain minimum 15-minute event duration during resize operations
- Preserve context menu functionality while preventing conflicts during drag
- Clean up code by removing experimental right-click drag functionality

🤖 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 12:54:45 -04:00
parent d36609d8c2
commit 697eb64dd4
2 changed files with 289 additions and 16 deletions

View File

@@ -32,6 +32,8 @@ pub struct WeekViewProps {
enum DragType {
CreateEvent,
MoveEvent(CalendarEvent),
ResizeEventStart(CalendarEvent), // Resizing from the top edge (start time)
ResizeEventEnd(CalendarEvent), // Resizing from the bottom edge (end time)
}
#[derive(Clone, PartialEq)]
@@ -234,6 +236,51 @@ pub fn week_view(props: &WeekViewProps) -> Html {
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
let new_end_datetime = new_start_datetime + original_duration;
if let Some(callback) = &on_event_update {
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
}
},
DragType::ResizeEventStart(event) => {
// Calculate new start time based on drag position
let new_start_time = pixels_to_time(current_drag.current_y);
// Keep the original end time
let original_end = if let Some(end) = event.end {
end.with_timezone(&chrono::Local).naive_local()
} else {
// If no end time, use start time + 1 hour as default
event.start.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
};
let new_start_datetime = NaiveDateTime::new(current_drag.start_date, new_start_time);
// Ensure start is before end (minimum 15 minutes)
let new_end_datetime = if new_start_datetime >= original_end {
new_start_datetime + chrono::Duration::minutes(15)
} else {
original_end
};
if let Some(callback) = &on_event_update {
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
}
},
DragType::ResizeEventEnd(event) => {
// Calculate new end time based on drag position
let new_end_time = pixels_to_time(current_drag.current_y);
// Keep the original start time
let original_start = event.start.with_timezone(&chrono::Local).naive_local();
let new_end_datetime = NaiveDateTime::new(current_drag.start_date, new_end_time);
// Ensure end is after start (minimum 15 minutes)
let new_start_datetime = if new_end_datetime <= original_start {
new_end_datetime - chrono::Duration::minutes(15)
} else {
original_start
};
if let Some(callback) = &on_event_update {
callback.emit((event.clone(), new_start_datetime, new_end_datetime));
}
@@ -301,24 +348,22 @@ pub fn week_view(props: &WeekViewProps) -> Html {
Callback::from(move |e: MouseEvent| {
e.stop_propagation(); // Prevent drag-to-create from starting on event clicks
// Only handle left-click (button 0)
// Only handle left-click (button 0) for moving
if e.button() != 0 {
return;
}
// Calculate Y position relative to the day column
let relative_y = e.layer_y() as f64;
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
// Calculate absolute Y position for drag calculations
let absolute_y = e.layer_y() as f64;
let absolute_y = if absolute_y > 0.0 { absolute_y } else { e.offset_y() as f64 };
// Get event's current position
// Get event's current position for offset calculation
let (event_start_pixels, _, _) = calculate_event_position(&event_for_drag, date_for_drag);
let event_start_pixels = event_start_pixels as f64;
// Calculate offset from the top of the event
let offset_y = relative_y - event_start_pixels;
let offset_y = absolute_y - event_start_pixels;
// Snap to increment
let snapped_y = snap_to_increment(relative_y, time_increment);
let snapped_y = snap_to_increment(absolute_y, time_increment);
drag_state.set(Some(DragState {
is_dragging: true,
@@ -336,7 +381,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
if let Some(callback) = &props.on_event_context_menu {
let callback = callback.clone();
let event = event.clone();
let drag_state_for_context = drag_state.clone();
Some(Callback::from(move |e: web_sys::MouseEvent| {
// Check if we're currently dragging - if so, prevent context menu
if let Some(drag) = (*drag_state_for_context).clone() {
if drag.is_dragging {
e.prevent_default();
return;
}
}
e.prevent_default();
e.stop_propagation(); // Prevent calendar context menu from also triggering
callback.emit((e, event.clone()));
@@ -377,12 +431,16 @@ pub fn week_view(props: &WeekViewProps) -> Html {
}
};
// Check if this event is currently being dragged
// Check if this event is currently being dragged or resized
let is_being_dragged = if let Some(drag) = (*drag_state).clone() {
if let DragType::MoveEvent(dragged_event) = &drag.drag_type {
dragged_event.uid == event.uid && drag.is_dragging
} else {
false
match &drag.drag_type {
DragType::MoveEvent(dragged_event) =>
dragged_event.uid == event.uid && drag.is_dragging,
DragType::ResizeEventStart(dragged_event) =>
dragged_event.uid == event.uid && drag.is_dragging,
DragType::ResizeEventEnd(dragged_event) =>
dragged_event.uid == event.uid && drag.is_dragging,
_ => false,
}
} else {
false
@@ -392,6 +450,55 @@ pub fn week_view(props: &WeekViewProps) -> Html {
// Hide the original event while being dragged
Some(html! {})
} else {
// Create resize handles for left-click resize
let resize_start_handler = {
let drag_state = drag_state.clone();
let event_for_resize = event.clone();
let date_for_drag = *date;
let time_increment = props.time_increment;
Callback::from(move |e: web_sys::MouseEvent| {
e.stop_propagation();
let relative_y = e.layer_y() as f64;
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
let snapped_y = snap_to_increment(relative_y, time_increment);
drag_state.set(Some(DragState {
is_dragging: true,
drag_type: DragType::ResizeEventStart(event_for_resize.clone()),
start_date: date_for_drag,
start_y: snapped_y,
current_y: snapped_y,
offset_y: 0.0,
}));
e.prevent_default();
})
};
let resize_end_handler = {
let drag_state = drag_state.clone();
let event_for_resize = event.clone();
let date_for_drag = *date;
let time_increment = props.time_increment;
Callback::from(move |e: web_sys::MouseEvent| {
e.stop_propagation();
let relative_y = e.layer_y() as f64;
let relative_y = if relative_y > 0.0 { relative_y } else { e.offset_y() as f64 };
let snapped_y = snap_to_increment(relative_y, time_increment);
drag_state.set(Some(DragState {
is_dragging: true,
drag_type: DragType::ResizeEventEnd(event_for_resize.clone()),
start_date: date_for_drag,
start_y: snapped_y,
current_y: snapped_y,
offset_y: 0.0,
}));
e.prevent_default();
})
};
Some(html! {
<div
class={classes!(
@@ -409,9 +516,36 @@ pub fn week_view(props: &WeekViewProps) -> Html {
{oncontextmenu}
onmousedown={onmousedown_event}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
// Top resize handle
{if !is_all_day {
html! { <div class="event-time">{time_display}</div> }
html! {
<div
class="resize-handle resize-handle-top"
onmousedown={resize_start_handler}
/>
}
} else {
html! {}
}}
// Event content
<div class="event-content">
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
{if !is_all_day {
html! { <div class="event-time">{time_display}</div> }
} else {
html! {}
}}
</div>
// Bottom resize handle
{if !is_all_day {
html! {
<div
class="resize-handle resize-handle-bottom"
onmousedown={resize_end_handler}
/>
}
} else {
html! {}
}}
@@ -467,6 +601,58 @@ pub fn week_view(props: &WeekViewProps) -> Html {
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
</div>
}
},
DragType::ResizeEventStart(event) => {
// Show the event being resized from the start
let new_start_time = pixels_to_time(drag.current_y);
let original_end = if let Some(end) = event.end {
end.with_timezone(&chrono::Local).naive_local()
} else {
event.start.with_timezone(&chrono::Local).naive_local() + chrono::Duration::hours(1)
};
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
let original_duration = original_end.signed_duration_since(event.start.with_timezone(&chrono::Local).naive_local());
let original_end_pixels = original_start_pixels + (original_duration.num_minutes() as f32);
let new_start_pixels = drag.current_y;
let new_height = (original_end_pixels as f64 - new_start_pixels).max(20.0);
let event_color = get_event_color(event);
html! {
<div
class="temp-event-box resizing-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", new_start_pixels, new_height, event_color)}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", new_start_time.format("%I:%M %p"), original_end.time().format("%I:%M %p"))}</div>
</div>
}
},
DragType::ResizeEventEnd(event) => {
// Show the event being resized from the end
let new_end_time = pixels_to_time(drag.current_y);
let original_start = event.start.with_timezone(&chrono::Local).naive_local();
// Calculate positions for the preview
let (original_start_pixels, _, _) = calculate_event_position(event, drag.start_date);
let new_end_pixels = drag.current_y;
let new_height = (new_end_pixels - original_start_pixels as f64).max(20.0);
let event_color = get_event_color(event);
html! {
<div
class="temp-event-box resizing-event"
style={format!("top: {}px; height: {}px; background-color: {}; opacity: 0.7;", original_start_pixels, new_height, event_color)}
>
<div class="event-title">{event.summary.as_ref().unwrap_or(&"Untitled".to_string())}</div>
<div class="event-time">{format!("{} - {}", original_start.time().format("%I:%M %p"), new_end_time.format("%I:%M %p"))}</div>
</div>
}
}
}
} else {

View File

@@ -727,6 +727,93 @@ body {
width: 100%;
}
/* Resizing event during drag */
.temp-event-box.resizing-event {
background: inherit;
border: 2px solid rgba(255, 255, 255, 0.9);
color: white;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
text-align: left;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
transform: scale(1.01);
}
.temp-event-box.resizing-event .event-title {
font-weight: 600;
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.temp-event-box.resizing-event .event-time {
font-size: 0.65rem;
opacity: 0.9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
/* Event resize zones and handles */
.week-event {
position: relative;
}
.week-event .event-content {
padding: 2px;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
pointer-events: auto;
z-index: 5;
position: relative;
}
/* Left-click drag handles */
.resize-handle {
position: absolute;
left: 0;
right: 0;
height: 4px;
background: transparent;
cursor: ns-resize;
z-index: 10;
transition: background-color 0.2s;
}
.resize-handle-top {
top: 0;
border-top: 2px solid transparent;
}
.resize-handle-bottom {
bottom: 0;
border-bottom: 2px solid transparent;
}
.week-event:hover .resize-handle {
background: rgba(255, 255, 255, 0.3);
}
.week-event:hover .resize-handle-top {
border-top-color: rgba(255, 255, 255, 0.8);
}
.week-event:hover .resize-handle-bottom {
border-bottom-color: rgba(255, 255, 255, 0.8);
}
.resize-handle:hover {
background: rgba(255, 255, 255, 0.5) !important;
border-color: rgba(255, 255, 255, 1) !important;
}
.week-event .event-title {
font-weight: 600;
margin-bottom: 2px;