feat(timeline): improved ux of time block dragging / creating on timeline

This commit is contained in:
Matej Glasnák committed 2026-01-14 03:07:33 +01:00
1 parent cdd7d14d4c
commit 46f578feb9
3 files changed
+168 -52

No files matched your search

@@ -42,6 +42,8 @@ const getHourPosition = (hour: number): number => {
position: relative position: relative
height: 30px height: 30px
width: calc(24 * 60px) width: calc(24 * 60px)
user-select: none
cursor: inherit
@media (max-width: 768px) @media (max-width: 768px)
width: calc(24 * 50px) width: calc(24 * 50px)
@@ -51,6 +53,7 @@ const getHourPosition = (hour: number): number => {
top: 0 top: 0
height: 30px height: 30px
border-left: 1px solid #e0e0e0 border-left: 1px solid #e0e0e0
cursor: inherit
.hour-label .hour-label
position: absolute position: absolute
@@ -59,5 +62,6 @@ const getHourPosition = (hour: number): number => {
font-size: 11px font-size: 11px
color: #666 color: #666
white-space: nowrap white-space: nowrap
cursor: inherit
</style> </style>
@@ -4,7 +4,13 @@
<span>Načítavam...</span> <span>Načítavam...</span>
</div> </div>
<div v-else ref="timelineContainer" class="timeline__content"> <div v-else ref="timelineContainer" class="timeline__content">
<div ref="timelineScroll" class="timeline-scroll"> <div
ref="timelineScroll"
class="timeline-scroll"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
@mousedown="handleTimelineMouseDown"
>
<a-employee-timeblock-timeline-header <a-employee-timeblock-timeline-header
:start-hour="startHour" :start-hour="startHour"
:end-hour="endHour" :end-hour="endHour"
@@ -14,9 +20,6 @@
<div <div
ref="timelineBar" ref="timelineBar"
class="timeline-bar" class="timeline-bar"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
@mousedown="handleMouseDown"
> >
<!-- Non-working hours - before work --> <!-- Non-working hours - before work -->
<div <div
@@ -93,7 +96,6 @@
left: `${getTimePosition(previewBlock.start)}px`, left: `${getTimePosition(previewBlock.start)}px`,
width: `${getTimeWidth(previewBlock.start, previewBlock.end)}px`, width: `${getTimeWidth(previewBlock.start, previewBlock.end)}px`,
}" }"
@mousedown.stop="handlePreviewMouseDown"
> >
<div class="preview-time-label"> <div class="preview-time-label">
<div <div
@@ -183,6 +185,15 @@
width: `${getTimeWidth(segment.start, segment.end)}px`, width: `${getTimeWidth(segment.start, segment.end)}px`,
}" }"
></div> ></div>
<!-- Cursor line (snapped to 15-min intervals) -->
<div
v-if="showCursor && cursorPosition"
class="timeline-cursor"
:style="{
left: `${getTimePosition(cursorPosition)}px`,
}"
></div>
</div> </div>
</div> </div>
</div> </div>
@@ -211,6 +222,7 @@ interface CollisionSegment {
interface Props { interface Props {
isLoading: boolean; isLoading: boolean;
employee: EmployeeFullData; employee: EmployeeFullData;
date: Date | string | null;
scheduledBlocks: TimelineTask[]; scheduledBlocks: TimelineTask[];
otherTimeblocks?: TimeblockData[]; otherTimeblocks?: TimeblockData[];
currentTimeblockRange: { currentTimeblockRange: {
@@ -260,9 +272,14 @@ const dragState = ref({
}); });
const resizedCurrentBlock = ref<{ start: Date; end: Date } | null>(null); const resizedCurrentBlock = ref<{ start: Date; end: Date } | null>(null);
// Cursor line state
const showCursor = ref(false);
const cursorPosition = ref<Date | null>(null);
// Check if the current date is a weekend (Saturday or Sunday) // Check if the current date is a weekend (Saturday or Sunday)
const isWeekend = computed(() => { const isWeekend = computed(() => {
const date = props.currentTimeblockRange.start; // Use the explicit date prop first, fallback to currentTimeblockRange.start
const date = props.date || props.currentTimeblockRange.start;
if (!date) return false; if (!date) return false;
const dayOfWeek = moment(date).day(); // 0 = Sunday, 6 = Saturday const dayOfWeek = moment(date).day(); // 0 = Sunday, 6 = Saturday
@@ -343,8 +360,10 @@ const formatTime = (date: Date): string => {
}; };
// Check if mouse is over an existing block (x is already relative position with scroll) // Check if mouse is over an existing block (x is already relative position with scroll)
// Note: This does NOT check the current timeblock because we want to allow
// clicking elsewhere to change the current timeblock to a new time
const isOverExistingBlock = (relativeX: number): boolean => { const isOverExistingBlock = (relativeX: number): boolean => {
// Check if over any scheduled block // Check if over any scheduled block (saved tasks from database)
for (const task of props.scheduledBlocks) { for (const task of props.scheduledBlocks) {
const blockLeft = getTimePosition(task._real_start); const blockLeft = getTimePosition(task._real_start);
const blockWidth = getTimeWidth(task._real_start, task._real_end); const blockWidth = getTimeWidth(task._real_start, task._real_end);
@@ -353,7 +372,7 @@ const isOverExistingBlock = (relativeX: number): boolean => {
} }
} }
// Check if over any other timeblock // Check if over any other timeblock (from other rows in the modal)
for (const tb of otherTimeblocksRendered.value) { for (const tb of otherTimeblocksRendered.value) {
const blockLeft = getTimePosition(tb._start); const blockLeft = getTimePosition(tb._start);
const blockWidth = getTimeWidth(tb._start, tb._end); const blockWidth = getTimeWidth(tb._start, tb._end);
@@ -362,18 +381,7 @@ const isOverExistingBlock = (relativeX: number): boolean => {
} }
} }
// Check if over current timeblock // Don't check current timeblock - allow changing it by clicking elsewhere
if (props.currentTimeblockRange.start && props.currentTimeblockRange.end) {
const blockLeft = getTimePosition(props.currentTimeblockRange.start);
const blockWidth = getTimeWidth(
props.currentTimeblockRange.start,
props.currentTimeblockRange.end,
);
if (relativeX >= blockLeft && relativeX <= blockLeft + blockWidth) {
return true;
}
}
return false; return false;
}; };
@@ -394,13 +402,38 @@ const handleMouseMove = (event: MouseEvent) => {
return; return;
} }
if (!timelineScroll.value) return; if (!timelineScroll.value || !timelineBar.value) return;
// Get position relative to scroll container, accounting for scroll // Get position relative to scroll container
const rect = timelineScroll.value.getBoundingClientRect(); const rect = timelineScroll.value.getBoundingClientRect();
const scrollLeft = timelineScroll.value.scrollLeft; const scrollLeft = timelineScroll.value.scrollLeft;
// Calculate X position relative to timeline
const x = event.clientX - rect.left + scrollLeft; const x = event.clientX - rect.left + scrollLeft;
// Always calculate cursor position (with 15-min snapping)
const rawTime = positionToTime(x);
const snappedTime = snapToInterval(rawTime);
const cursorHour = snappedTime.getHours() + snappedTime.getMinutes() / 60;
// Check if cursor is in working hours
const isInWorkHours =
cursorHour >= props.employee.work_start &&
cursorHour < props.employee.work_end;
// Only show cursor line if in working hours
if (isInWorkHours) {
cursorPosition.value = snappedTime;
showCursor.value = true;
} else {
showCursor.value = false;
}
// Update cursor style based on work hours
if (timelineScroll.value) {
timelineScroll.value.style.cursor = isInWorkHours ? 'crosshair' : 'not-allowed';
}
// Don't show preview if over existing block (using relative position) // Don't show preview if over existing block (using relative position)
if (isOverExistingBlock(x)) { if (isOverExistingBlock(x)) {
showPreview.value = false; showPreview.value = false;
@@ -408,19 +441,13 @@ const handleMouseMove = (event: MouseEvent) => {
} }
// Convert to time and snap // Convert to time and snap
const rawTime = positionToTime(x); const snappedStart = snappedTime;
const snappedStart = snapToInterval(rawTime);
// Calculate default end time (1 hour later) // Calculate default end time (1 hour later)
let snappedEnd = moment(snappedStart).add(1, 'hour').toDate(); let snappedEnd = moment(snappedStart).add(1, 'hour').toDate();
// Only show preview if start is within work hours // Only show preview if start is within work hours
const startHour = snappedStart.getHours() + snappedStart.getMinutes() / 60; if (isInWorkHours) {
if (
startHour >= props.employee.work_start &&
startHour < props.employee.work_end
) {
// If the end would exceed work hours, cap it at work_end // If the end would exceed work hours, cap it at work_end
const endHour = snappedEnd.getHours() + snappedEnd.getMinutes() / 60; const endHour = snappedEnd.getHours() + snappedEnd.getMinutes() / 60;
@@ -450,23 +477,72 @@ const handleMouseMove = (event: MouseEvent) => {
const handleMouseLeave = () => { const handleMouseLeave = () => {
if (!dragState.value.isStretching && !dragState.value.isResizingCurrent) { if (!dragState.value.isStretching && !dragState.value.isResizingCurrent) {
showPreview.value = false; showPreview.value = false;
showCursor.value = false;
// Reset cursor style to default crosshair
if (timelineScroll.value) {
timelineScroll.value.style.cursor = 'crosshair';
}
} }
}; };
// Mouse down on timeline bar (not on preview or current block) // Handle mousedown on timeline (including header area)
const handleMouseDown = (_event: MouseEvent) => { const handleTimelineMouseDown = (event: MouseEvent) => {
// This will only trigger if not clicking on preview or current block if (isMobile.value) return;
// (those have @mousedown.stop) if (!timelineScroll.value || !cursorPosition.value) return;
};
// Preview block mouse down - start creating/stretching // Get the target element
const handlePreviewMouseDown = (event: MouseEvent) => { const target = event.target as HTMLElement;
if (!previewBlock.value) return;
// Don't interfere with resize handles on current timeblock
if (target.classList.contains('resize-handle')) {
return;
}
// Check if clicking on current timeblock (allow resize handles to work)
if (target.closest('.current-timeblock')) {
return;
}
const rect = timelineScroll.value.getBoundingClientRect();
const scrollLeft = timelineScroll.value.scrollLeft;
const x = event.clientX - rect.left + scrollLeft;
// Don't start creating if over existing block
if (isOverExistingBlock(x)) {
return;
}
// Check if cursor is in work hours (cursor line must be visible)
if (!showCursor.value || !showPreview.value || !previewBlock.value) {
return;
}
// Start creating/stretching block at the snapped cursor position
dragState.value.isStretching = true; dragState.value.isStretching = true;
dragState.value.startX = event.clientX; dragState.value.startX = event.clientX;
dragState.value.initialStart = previewBlock.value.start; dragState.value.initialStart = cursorPosition.value;
dragState.value.initialEnd = previewBlock.value.end;
// Calculate initial end time (1 hour later, capped at work_end)
let initialEnd = moment(cursorPosition.value).add(1, 'hour').toDate();
const endHour = initialEnd.getHours() + initialEnd.getMinutes() / 60;
if (endHour > props.employee.work_end) {
initialEnd = moment(cursorPosition.value)
.set({
hour: Math.floor(props.employee.work_end),
minute: (props.employee.work_end % 1) * 60,
second: 0,
millisecond: 0,
})
.toDate();
}
dragState.value.initialEnd = initialEnd;
// Set initial preview block (already has 1 hour duration from preview)
previewBlock.value = {
start: cursorPosition.value,
end: initialEnd,
};
event.preventDefault(); event.preventDefault();
}; };
@@ -484,13 +560,18 @@ const handleStretchMove = (event: MouseEvent) => {
const initialStartTime = dragState.value.initialStart.getTime(); const initialStartTime = dragState.value.initialStart.getTime();
const snappedTimeValue = snappedTime.getTime(); const snappedTimeValue = snappedTime.getTime();
if (snappedTimeValue > initialStartTime) { if (snappedTimeValue >= initialStartTime) {
// Stretching right (extending end) // Stretching right (extending end)
// Ensure minimum 15-minute block
let endTime = snappedTime;
if (snappedTimeValue === initialStartTime) {
endTime = moment(snappedTime).add(15, 'minutes').toDate();
}
// Cap at work_end // Cap at work_end
const snappedEndHour = const endHour = endTime.getHours() + endTime.getMinutes() / 60;
snappedTime.getHours() + snappedTime.getMinutes() / 60; if (endHour > props.employee.work_end) {
if (snappedEndHour > props.employee.work_end) { endTime = moment(endTime)
snappedTime = moment(snappedTime)
.set({ .set({
hour: Math.floor(props.employee.work_end), hour: Math.floor(props.employee.work_end),
minute: (props.employee.work_end % 1) * 60, minute: (props.employee.work_end % 1) * 60,
@@ -502,8 +583,12 @@ const handleStretchMove = (event: MouseEvent) => {
previewBlock.value = { previewBlock.value = {
start: dragState.value.initialStart, start: dragState.value.initialStart,
end: snappedTime, end: endTime,
}; };
// Update cursor to show at the actual block edge (end)
cursorPosition.value = endTime;
showCursor.value = true;
} else { } else {
// Stretching left (extending start) // Stretching left (extending start)
// Cap at work_start // Cap at work_start
@@ -524,17 +609,27 @@ const handleStretchMove = (event: MouseEvent) => {
start: snappedTime, start: snappedTime,
end: dragState.value.initialStart, end: dragState.value.initialStart,
}; };
// Update cursor to show at the actual block edge (start)
cursorPosition.value = snappedTime;
showCursor.value = true;
} }
}; };
// Handle stretch end // Handle stretch end
const handleStretchEnd = () => { const handleStretchEnd = () => {
if (dragState.value.isStretching && previewBlock.value) { if (dragState.value.isStretching && previewBlock.value) {
// Emit the new timeblock // Only emit if we have a valid block (start != end)
emit('timeblockUpdated', { const startTime = previewBlock.value.start.getTime();
starts_at_time: formatTime(previewBlock.value.start), const endTime = previewBlock.value.end.getTime();
ends_at_time: formatTime(previewBlock.value.end),
}); if (endTime > startTime) {
// Emit the new timeblock
emit('timeblockUpdated', {
starts_at_time: formatTime(previewBlock.value.start),
ends_at_time: formatTime(previewBlock.value.end),
});
}
} }
dragState.value.isStretching = false; dragState.value.isStretching = false;
@@ -599,6 +694,9 @@ const handleResizeCurrentMove = (event: MouseEvent) => {
start: snappedTime, start: snappedTime,
end: dragState.value.initialEnd, end: dragState.value.initialEnd,
}; };
// Update cursor to show at the actual block edge
cursorPosition.value = snappedTime;
showCursor.value = true;
} }
} else if (dragState.value.resizeEdge === 'right') { } else if (dragState.value.resizeEdge === 'right') {
// Resizing right edge (changing end time) // Resizing right edge (changing end time)
@@ -622,6 +720,9 @@ const handleResizeCurrentMove = (event: MouseEvent) => {
start: dragState.value.initialStart, start: dragState.value.initialStart,
end: snappedTime, end: snappedTime,
}; };
// Update cursor to show at the actual block edge
cursorPosition.value = snappedTime;
showCursor.value = true;
} }
} }
}; };
@@ -709,6 +810,7 @@ defineExpose({
position: relative position: relative
width: 100% width: 100%
scrollbar-width: none // Firefox scrollbar-width: none // Firefox
cursor: crosshair
&::-webkit-scrollbar &::-webkit-scrollbar
display: none // Chrome, Safari, Opera display: none // Chrome, Safari, Opera
@@ -719,7 +821,6 @@ defineExpose({
width: calc(24 * 60px) width: calc(24 * 60px)
background: #fafafa background: #fafafa
border-radius: 4px border-radius: 4px
cursor: crosshair
@media (max-width: 768px) @media (max-width: 768px)
width: calc(24 * 50px) width: calc(24 * 50px)
@@ -792,6 +893,7 @@ defineExpose({
display: flex display: flex
align-items: center align-items: center
justify-content: center justify-content: center
pointer-events: none
&:hover &:hover
opacity: 0.8 opacity: 0.8
@@ -861,6 +963,15 @@ defineExpose({
z-index: 4 z-index: 4
pointer-events: none pointer-events: none
.timeline-cursor
position: absolute
top: 0
bottom: 0
width: 2px
background: rgba(0, 0, 0, 0.3)
z-index: 10
pointer-events: none
.timeblock-duration .timeblock-duration
position: absolute position: absolute
top: 50% top: 50%
@@ -33,6 +33,7 @@
ref="timelineRef" ref="timelineRef"
:is-loading="isLoading" :is-loading="isLoading"
:employee="employee" :employee="employee"
:date="modelValue.date"
:scheduled-blocks="scheduledBlocks" :scheduled-blocks="scheduledBlocks"
:other-timeblocks="otherTimeblocksForSameDate" :other-timeblocks="otherTimeblocksForSameDate"
:current-timeblock-range="currentTimeblockRange" :current-timeblock-range="currentTimeblockRange"