mirror of
https://bitbucket.org/studiotem/jandl-taskmanager-app.git
synced 2026-10-21 14:29:34 +00:00
feat(timeline): improved ux of time block dragging / creating on timeline
This commit is contained in:
1 parent
cdd7d14d4c
commit
46f578feb9
3 files changed
+168
-52
No files matched your search
+4
@@ -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>
|
||||
|
||||
+163
-52
@@ -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,17 +609,27 @@ 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) {
|
||||
// Emit the new timeblock
|
||||
emit('timeblockUpdated', {
|
||||
starts_at_time: formatTime(previewBlock.value.start),
|
||||
ends_at_time: formatTime(previewBlock.value.end),
|
||||
});
|
||||
// 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;
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user