refactor(calendar): extract WeekView inline logic into composables (1600→903 LOC)

Replace 697 lines of inline drag/drop/resize/create/keyboard handlers
in WeekView.svelte with existing composables:

- useEventDragDrop: event drag & resize (was ~220 LOC inline)
- useTaskDragDrop: task drag & resize (was ~180 LOC inline)
- useSidebarDrop: sidebar task drop (was ~70 LOC inline)
- useDragToCreate: new composable for click-drag event creation (was ~105 LOC)
- useCalendarKeyboard: Escape key cancel (was ~50 LOC inline)

Also adds getResizePreviewTime() to useEventDragDrop return value
so WeekView doesn't need access to internal resize state.

WeekView.svelte: 1600 → 903 lines (-44%)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-20 19:43:19 +01:00
parent 70671e2b2b
commit aeabdcaf8e
4 changed files with 339 additions and 797 deletions

View file

@ -20,6 +20,9 @@ export { useTaskDragDrop, type TaskDragDropConfig } from './useTaskDragDrop.svel
// Sidebar task drop handling
export { useSidebarDrop, type SidebarDropConfig } from './useSidebarDrop.svelte';
// Drag-to-create
export { useDragToCreate, type DragToCreateConfig } from './useDragToCreate.svelte';
// Keyboard handling
export { useCalendarKeyboard, type CancellableOperation } from './useCalendarKeyboard.svelte';

View file

@ -0,0 +1,188 @@
/**
* Drag-to-Create Composable
* Handles click-and-drag on the calendar grid to create new events
*/
import { SNAP_INTERVAL_MINUTES } from '$lib/utils/calendarConstants';
export interface DragToCreateConfig {
containerEl: HTMLElement | null;
days: Date[];
firstVisibleHour: number;
lastVisibleHour: number;
totalVisibleHours: number;
hourHeight: number;
minutesToPercent: (minutes: number) => number;
snapMinutes?: number;
isOtherOperationActive: () => boolean;
onCreateEnd?: (startTime: Date, endTime: Date, position: { x: number; y: number }) => void;
}
export function useDragToCreate(getConfig: () => DragToCreateConfig) {
let isCreating = $state(false);
let createTargetDay = $state<Date | null>(null);
let createStartMinutes = $state(0);
let createEndMinutes = $state(0);
let createPreviewTop = $state(0);
let createPreviewHeight = $state(0);
let hasMoved = $state(false);
function getSnapMinutes(): number {
return getConfig().snapMinutes ?? SNAP_INTERVAL_MINUTES;
}
function getDayFromX(clientX: number): Date | null {
const config = getConfig();
if (!config.containerEl) return null;
const rect = config.containerEl.getBoundingClientRect();
const relativeX = clientX - rect.left;
const dayWidth = rect.width / config.days.length;
const dayIndex = Math.floor(relativeX / dayWidth);
if (dayIndex >= 0 && dayIndex < config.days.length) {
return config.days[dayIndex];
}
return null;
}
function getMinutesFromY(clientY: number): number {
const config = getConfig();
if (!config.containerEl) return 0;
const rect = config.containerEl.getBoundingClientRect();
const scrollTop = config.containerEl.parentElement?.scrollTop || 0;
const relativeY = clientY - rect.top + scrollTop;
const visibleMinutes =
(relativeY / (config.totalVisibleHours * config.hourHeight)) * config.totalVisibleHours * 60;
const totalMinutes = visibleMinutes + config.firstVisibleHour * 60;
const snap = getSnapMinutes();
return Math.round(totalMinutes / snap) * snap;
}
function updatePreview() {
const config = getConfig();
createPreviewTop = config.minutesToPercent(createStartMinutes);
const duration = createEndMinutes - createStartMinutes;
createPreviewHeight = (duration / (config.totalVisibleHours * 60)) * 100;
}
function startCreate(e: PointerEvent) {
const config = getConfig();
if (config.isOtherOperationActive()) return;
// Don't start creating if clicking on interactive elements
const target = e.target as HTMLElement;
if (
target.closest(
'.event-card, .task-block, .all-day-event, .all-day-block-event, .overflow-indicator, .resize-handle'
)
) {
return;
}
e.preventDefault();
const day = getDayFromX(e.clientX);
if (!day) return;
const minutes = getMinutesFromY(e.clientY);
const snap = getSnapMinutes();
const snappedMinutes = Math.round(minutes / snap) * snap;
isCreating = true;
hasMoved = false;
createTargetDay = day;
createStartMinutes = snappedMinutes;
createEndMinutes = snappedMinutes + snap;
updatePreview();
document.addEventListener('pointermove', handleCreateMove);
document.addEventListener('pointerup', handleCreateEnd);
}
function handleCreateMove(e: PointerEvent) {
if (!isCreating) return;
hasMoved = true;
const config = getConfig();
const snap = getSnapMinutes();
const day = getDayFromX(e.clientX);
if (day) createTargetDay = day;
const minutes = getMinutesFromY(e.clientY);
const snappedMinutes = Math.round(minutes / snap) * snap;
if (snappedMinutes >= createStartMinutes) {
createEndMinutes = Math.max(snappedMinutes, createStartMinutes + snap);
} else {
createEndMinutes = createStartMinutes + snap;
createStartMinutes = snappedMinutes;
}
createStartMinutes = Math.max(config.firstVisibleHour * 60, createStartMinutes);
createEndMinutes = Math.min(config.lastVisibleHour * 60, createEndMinutes);
updatePreview();
}
function handleCreateEnd(e: PointerEvent) {
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
if (!isCreating || !createTargetDay) {
isCreating = false;
return;
}
const startTime = new Date(createTargetDay);
startTime.setHours(Math.floor(createStartMinutes / 60), createStartMinutes % 60, 0, 0);
const endTime = new Date(createTargetDay);
endTime.setHours(Math.floor(createEndMinutes / 60), createEndMinutes % 60, 0, 0);
isCreating = false;
createTargetDay = null;
hasMoved = false;
const config = getConfig();
config.onCreateEnd?.(startTime, endTime, { x: e.clientX, y: e.clientY });
}
function getCreatePreviewTime(): string {
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(Math.floor(createStartMinutes / 60))}:${pad(createStartMinutes % 60)} - ${pad(Math.floor(createEndMinutes / 60))}:${pad(createEndMinutes % 60)}`;
}
function cancel() {
if (isCreating) {
document.removeEventListener('pointermove', handleCreateMove);
document.removeEventListener('pointerup', handleCreateEnd);
isCreating = false;
createTargetDay = null;
hasMoved = false;
}
}
return {
get isCreating() {
return isCreating;
},
get createTargetDay() {
return createTargetDay;
},
get createPreviewTop() {
return createPreviewTop;
},
get createPreviewHeight() {
return createPreviewHeight;
},
startCreate,
cancel,
getCreatePreviewTime,
};
}

View file

@ -373,6 +373,36 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
}
}
/**
* Get formatted time range during resize preview
*/
function getResizePreviewTime(): string {
if (!resizeEvent || !resizeOriginalStart || !resizeOriginalEnd) return '';
const config = getConfig();
const origStartMinutes = resizeOriginalStart.getHours() * 60 + resizeOriginalStart.getMinutes();
const origEndMinutes = resizeOriginalEnd.getHours() * 60 + resizeOriginalEnd.getMinutes();
const previewStartMinutes =
(resizePreviewTop / 100) * config.totalVisibleHours * 60 + config.firstVisibleHour * 60;
const previewEndMinutes =
previewStartMinutes + (resizePreviewHeight / 100) * config.totalVisibleHours * 60;
let startMin: number;
let endMin: number;
if (resizeEdge === 'top') {
startMin = Math.round(previewStartMinutes);
endMin = origEndMinutes;
} else {
startMin = origStartMinutes;
endMin = Math.round(previewEndMinutes);
}
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(Math.floor(startMin / 60))}:${pad(startMin % 60)} - ${pad(Math.floor(endMin / 60))}:${pad(endMin % 60)}`;
}
return {
// Drag state (reactive getters)
get isDragging() {
@ -423,5 +453,6 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
startResize,
cancel,
cleanup,
getResizePreviewTime,
};
}