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
+163 -47

No files matched your search

@@ -42,6 +42,8 @@ const getHourPosition = (hour: number): number => {
position: relative
height: 30px
width: calc(24 * 60px)
user-select: none
cursor: inherit
@media (max-width: 768px)
width: calc(24 * 50px)
@@ -51,6 +53,7 @@ const getHourPosition = (hour: number): number => {
top: 0
height: 30px
border-left: 1px solid #e0e0e0
cursor: inherit
.hour-label
position: absolute
@@ -59,5 +62,6 @@ const getHourPosition = (hour: number): number => {
font-size: 11px
color: #666
white-space: nowrap
cursor: inherit
</style>
@@ -4,7 +4,13 @@
<span>Načítavam...</span>
</div>
<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
:start-hour="startHour"
:end-hour="endHour"
@@ -14,9 +20,6 @@
<div
ref="timelineBar"
class="timeline-bar"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
@mousedown="handleMouseDown"
>
<!-- Non-working hours - before work -->
<div
@@ -93,7 +96,6 @@
left: `${getTimePosition(previewBlock.start)}px`,
width: `${getTimeWidth(previewBlock.start, previewBlock.end)}px`,
}"
@mousedown.stop="handlePreviewMouseDown"
>
<div class="preview-time-label">
<div
@@ -183,6 +185,15 @@
width: `${getTimeWidth(segment.start, segment.end)}px`,
}"
></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>
@@ -211,6 +222,7 @@ interface CollisionSegment {
interface Props {
isLoading: boolean;
employee: EmployeeFullData;
date: Date | string | null;
scheduledBlocks: TimelineTask[];
otherTimeblocks?: TimeblockData[];
currentTimeblockRange: {
@@ -260,9 +272,14 @@ const dragState = ref({
});
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)
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;
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)
// 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 => {
// Check if over any scheduled block
// Check if over any scheduled block (saved tasks from database)
for (const task of props.scheduledBlocks) {
const blockLeft = getTimePosition(task._real_start);
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) {
const blockLeft = getTimePosition(tb._start);
const blockWidth = getTimeWidth(tb._start, tb._end);
@@ -362,18 +381,7 @@ const isOverExistingBlock = (relativeX: number): boolean => {
}
}
// Check if over current timeblock
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;
}
}
// Don't check current timeblock - allow changing it by clicking elsewhere
return false;
};
@@ -394,13 +402,38 @@ const handleMouseMove = (event: MouseEvent) => {
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 scrollLeft = timelineScroll.value.scrollLeft;
// Calculate X position relative to timeline
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)
if (isOverExistingBlock(x)) {
showPreview.value = false;
@@ -408,19 +441,13 @@ const handleMouseMove = (event: MouseEvent) => {
}
// Convert to time and snap
const rawTime = positionToTime(x);
const snappedStart = snapToInterval(rawTime);
const snappedStart = snappedTime;
// Calculate default end time (1 hour later)
let snappedEnd = moment(snappedStart).add(1, 'hour').toDate();
// Only show preview if start is within work hours
const startHour = snappedStart.getHours() + snappedStart.getMinutes() / 60;
if (
startHour >= props.employee.work_start &&
startHour < props.employee.work_end
) {
if (isInWorkHours) {
// If the end would exceed work hours, cap it at work_end
const endHour = snappedEnd.getHours() + snappedEnd.getMinutes() / 60;
@@ -450,23 +477,72 @@ const handleMouseMove = (event: MouseEvent) => {
const handleMouseLeave = () => {
if (!dragState.value.isStretching && !dragState.value.isResizingCurrent) {
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)
const handleMouseDown = (_event: MouseEvent) => {
// This will only trigger if not clicking on preview or current block
// (those have @mousedown.stop)
};
// Handle mousedown on timeline (including header area)
const handleTimelineMouseDown = (event: MouseEvent) => {
if (isMobile.value) return;
if (!timelineScroll.value || !cursorPosition.value) return;
// Preview block mouse down - start creating/stretching
const handlePreviewMouseDown = (event: MouseEvent) => {
if (!previewBlock.value) return;
// Get the target element
const target = event.target as HTMLElement;
// 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.startX = event.clientX;
dragState.value.initialStart = previewBlock.value.start;
dragState.value.initialEnd = previewBlock.value.end;
dragState.value.initialStart = cursorPosition.value;
// 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();
};
@@ -484,13 +560,18 @@ const handleStretchMove = (event: MouseEvent) => {
const initialStartTime = dragState.value.initialStart.getTime();
const snappedTimeValue = snappedTime.getTime();
if (snappedTimeValue > initialStartTime) {
if (snappedTimeValue >= initialStartTime) {
// 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
const snappedEndHour =
snappedTime.getHours() + snappedTime.getMinutes() / 60;
if (snappedEndHour > props.employee.work_end) {
snappedTime = moment(snappedTime)
const endHour = endTime.getHours() + endTime.getMinutes() / 60;
if (endHour > props.employee.work_end) {
endTime = moment(endTime)
.set({
hour: Math.floor(props.employee.work_end),
minute: (props.employee.work_end % 1) * 60,
@@ -502,8 +583,12 @@ const handleStretchMove = (event: MouseEvent) => {
previewBlock.value = {
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 {
// Stretching left (extending start)
// Cap at work_start
@@ -524,18 +609,28 @@ const handleStretchMove = (event: MouseEvent) => {
start: snappedTime,
end: dragState.value.initialStart,
};
// Update cursor to show at the actual block edge (start)
cursorPosition.value = snappedTime;
showCursor.value = true;
}
};
// Handle stretch end
const handleStretchEnd = () => {
if (dragState.value.isStretching && previewBlock.value) {
// Only emit if we have a valid block (start != end)
const startTime = previewBlock.value.start.getTime();
const endTime = previewBlock.value.end.getTime();
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.initialStart = null;
@@ -599,6 +694,9 @@ const handleResizeCurrentMove = (event: MouseEvent) => {
start: snappedTime,
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') {
// Resizing right edge (changing end time)
@@ -622,6 +720,9 @@ const handleResizeCurrentMove = (event: MouseEvent) => {
start: dragState.value.initialStart,
end: snappedTime,
};
// Update cursor to show at the actual block edge
cursorPosition.value = snappedTime;
showCursor.value = true;
}
}
};
@@ -709,6 +810,7 @@ defineExpose({
position: relative
width: 100%
scrollbar-width: none // Firefox
cursor: crosshair
&::-webkit-scrollbar
display: none // Chrome, Safari, Opera
@@ -719,7 +821,6 @@ defineExpose({
width: calc(24 * 60px)
background: #fafafa
border-radius: 4px
cursor: crosshair
@media (max-width: 768px)
width: calc(24 * 50px)
@@ -792,6 +893,7 @@ defineExpose({
display: flex
align-items: center
justify-content: center
pointer-events: none
&:hover
opacity: 0.8
@@ -861,6 +963,15 @@ defineExpose({
z-index: 4
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
position: absolute
top: 50%
@@ -33,6 +33,7 @@
ref="timelineRef"
:is-loading="isLoading"
:employee="employee"
:date="modelValue.date"
:scheduled-blocks="scheduledBlocks"
:other-timeblocks="otherTimeblocksForSameDate"
:current-timeblock-range="currentTimeblockRange"