Skip to content

Commit

Permalink
Merge pull request #112 from melfore/58-task-implement-resize
Browse files Browse the repository at this point in the history
[Task] Implement resize
  • Loading branch information
luciob committed Oct 16, 2023
2 parents cc9c2fe + 79f163c commit 69054fa
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 36 deletions.
3 changes: 1 addition & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
# [1.9.0](https://github.com/melfore/konva-timeline/compare/v1.8.0...v1.9.0) (2023-09-29)


### Features

* 🎸 [Timeline] Allowing both number and string format ([fba691c](https://github.com/melfore/konva-timeline/commit/fba691c61824087616db029f769de022418550e2)), closes [#105](https://github.com/melfore/konva-timeline/issues/105)
- 🎸 [Timeline] Allowing both number and string format ([fba691c](https://github.com/melfore/konva-timeline/commit/fba691c61824087616db029f769de022418550e2)), closes [#105](https://github.com/melfore/konva-timeline/issues/105)

# [1.8.0](https://github.com/melfore/konva-timeline/compare/v1.7.7...v1.8.0) (2023-09-28)

Expand Down
3 changes: 2 additions & 1 deletion src/tasks/components/Layer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DateTime } from "luxon";
import { useTimelineContext } from "../../../timeline/TimelineContext";
import { KonvaPoint } from "../../../utils/konva";
import { InternalTimeRange } from "../../../utils/time";
import { getTaskYCoordinate } from "../../utils/tasks";
import Task from "../Task";
import TaskTooltip, { TaskTooltipProps } from "../Tooltip";

Expand Down Expand Up @@ -97,7 +98,7 @@ const TasksLayer: FC<TasksLayerProps> = ({ setTaskTooltip, taskTooltip }) => {

const { color: resourceColor } = resources[resourceIndex];
const xCoordinate = getTaskXCoordinate(time.start);
const yCoordinate = rowHeight * resourceIndex + rowHeight * 0.1;
const yCoordinate = getTaskYCoordinate(resourceIndex, rowHeight);
const width = getTaskWidth(time);
if (xCoordinate > drawRange.end || xCoordinate + width < drawRange.start) {
return null;
Expand Down
171 changes: 139 additions & 32 deletions src/tasks/components/Task/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { memo, useCallback, useMemo, useState } from "react";
import { Group, Rect } from "react-konva";
import { Group, Rect, useStrictMode as enableStrictMode } from "react-konva";
import { KonvaEventObject } from "konva/lib/Node";
import { DateTime, Duration } from "luxon";

Expand All @@ -9,7 +9,8 @@ import { useTimelineContext } from "../../../timeline/TimelineContext";
import { KonvaDrawable, KonvaPoint } from "../../../utils/konva";
import { logDebug } from "../../../utils/logger";
import { getContrastColor } from "../../../utils/theme";
import { TaskData } from "../../utils/tasks";
import { getTaskYCoordinate, TaskData } from "../../utils/tasks";
import TaskResizeHandler from "../TaskResizeHandler";

type TaskMouseEventHandler = (taskId: string, point: KonvaPoint) => void;

Expand All @@ -33,11 +34,21 @@ type TaskProps = KonvaDrawable &
width: number;
};

type TaskDimensions = {
row: number;
width: number;
x: number;
y: number;
};

const TASK_DEFAULT_FILL = "#FFFFFF";
const TASK_DEFAULT_STROKE = "#000000";
const TASK_DEFAULT_STROKE_WIDTH = 1;

const TASK_BORDER_RADIUS = 4;

enableStrictMode(true);

/**
* This component renders a simple task as a rectangle inside a canvas.
* Each task is rendered by `TasksLayer` component, with a loop on each task provided to `KonvaTimeline`.
Expand Down Expand Up @@ -65,6 +76,14 @@ const Task = ({ data, fill = TASK_DEFAULT_FILL, onLeave, onOver, x, y, width }:
const { id: taskId } = data;

const [dragging, setDragging] = useState(false);
const [resizing, setResizing] = useState(false);

const initialTaskDimensions = useMemo((): TaskDimensions => {
const row = findResourceIndexByCoordinate(y, rowHeight, resources);
return { row, width, x, y };
}, [resources, rowHeight, width, x, y]);

const [taskDimensions, setTaskDimensions] = useState(initialTaskDimensions);

const dragSnapInPX = useMemo(() => {
const resolutionInSnapUnit = Duration.fromObject({ [unit]: sizeInUnits }).as(dragUnit);
Expand All @@ -77,16 +96,6 @@ const Task = ({ data, fill = TASK_DEFAULT_FILL, onLeave, onOver, x, y, width }:
return dragSnapInPx;
}, [columnWidth, dragUnit, dragSizeInUnits, sizeInUnits, unit]);

const getBoundedCoordinates = useCallback(
(xCoordinate: number, resourceIndex: number): KonvaPoint => {
const boundedX = xCoordinate < 0 ? 0 : xCoordinate;
const boundedY = resourceIndex * rowHeight + rowHeight * 0.1;

return { x: boundedX, y: boundedY };
},
[rowHeight]
);

const getDragPoint = useCallback((e: KonvaEventObject<DragEvent>): KonvaPoint => {
const { target } = e;
const dragX = target.x();
Expand Down Expand Up @@ -118,34 +127,67 @@ const Task = ({ data, fill = TASK_DEFAULT_FILL, onLeave, onOver, x, y, width }:
);

const onTaskLeave = useCallback(
(e: KonvaEventObject<MouseEvent>) => onTaskMouseEvent(e, onLeave),
[onLeave, onTaskMouseEvent]
(e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
if (resizing) {
return;
}

const stage = e.target.getStage();
if (!stage) {
return;
}

stage.container().style.cursor = "default";
onTaskMouseEvent(e, onLeave);
},
[onLeave, onTaskMouseEvent, resizing]
);

const onTaskOver = useCallback(
(e: KonvaEventObject<MouseEvent>) => onTaskMouseEvent(e, onOver),
[onOver, onTaskMouseEvent]
(e: KonvaEventObject<MouseEvent>) => {
e.cancelBubble = true;
if (resizing) {
return;
}

const stage = e.target.getStage();
if (!stage) {
return;
}

stage.container().style.cursor = "move";
onTaskMouseEvent(e, onOver);
},
[onOver, onTaskMouseEvent, resizing]
);

const onDragStart = useCallback((e: KonvaEventObject<DragEvent>) => setDragging(true), []);
const onDragStart = useCallback((e: KonvaEventObject<DragEvent>) => {
console.log("=> onDragStart", e.target);
setDragging(true);
}, []);

const onDragMove = useCallback(
(e: KonvaEventObject<DragEvent>) => {
// e.cancelBubble = true;
console.log("=> onDragMove", e.target);
const { x, y } = getDragPoint(e);
// console.log("=> onDragMove.dragY", y);
const resourceIndex = findResourceIndexByCoordinate(y, rowHeight, resources);
// console.log("=> onDragMove.resourceIndex", resourceIndex);
const dragFinalX = Math.ceil(x / dragSnapInPX) * dragSnapInPX;
const point = getBoundedCoordinates(dragFinalX, resourceIndex);
// console.log("=> onDragMove.point.y", point.y);
e.target.setPosition(point);
const xCoordinate = dragFinalX < 0 ? 0 : dragFinalX;
const resourceIndex = findResourceIndexByCoordinate(y, rowHeight, resources);
const yCoordinate = getTaskYCoordinate(resourceIndex, rowHeight);
const point = { x: xCoordinate, y: yCoordinate };

setTaskDimensions((dimensions) => ({ ...dimensions, ...point }));
onOver(taskId, point);
},
[dragSnapInPX, getBoundedCoordinates, getDragPoint, onOver, resources, rowHeight, taskId]
[dragSnapInPX, getDragPoint, onOver, resources, rowHeight, taskId]
);

const onDragEnd = useCallback(
(e: KonvaEventObject<DragEvent>) => {
// e.cancelBubble = true;
console.log("=> onDragEnd", e.target);
const { x, y } = getDragPoint(e);
const timeOffset = (x * sizeInUnits) / columnWidth;
const newStartInMillis = interval.start!.plus({ [unit]: timeOffset }).toMillis();
Expand All @@ -169,7 +211,7 @@ const Task = ({ data, fill = TASK_DEFAULT_FILL, onLeave, onOver, x, y, width }:
[columnWidth, data, interval.start, onTaskDrag, getDragPoint, resources, rowHeight, sizeInUnits, unit, width]
);

const opacity = useMemo(() => (dragging ? 0.5 : 1), [dragging]);
const opacity = useMemo(() => (dragging || resizing ? 0.5 : 1), [dragging, resizing]);

const taskHeight = useMemo(() => rowHeight * 0.8, [rowHeight]);

Expand All @@ -179,29 +221,94 @@ const Task = ({ data, fill = TASK_DEFAULT_FILL, onLeave, onOver, x, y, width }:

const textStroke = useMemo(() => getContrastColor(fill), [fill]);

const textWidth = useMemo(() => width - textOffsets * 2, [textOffsets, width]);
const textWidth = useMemo(() => taskDimensions.width - textOffsets * 2, [taskDimensions, textOffsets]);

const onResizeStart = useCallback((e: KonvaEventObject<DragEvent>) => {
e.cancelBubble = true;
console.log("=> onResizeStart", e.target);
setResizing(true);
}, []);

const onResizeMove = useCallback(
(e: KonvaEventObject<DragEvent>, handler: "lx" | "rx") => {
e.cancelBubble = true;

const { x: dragX } = getDragPoint(e);

setTaskDimensions((taskDimensions) => {
const { x: taskX, width: taskWidth } = taskDimensions;
const handlerX = taskX + dragX;
const taskEndX = taskX + taskWidth;

switch (handler) {
case "rx":
if (handlerX <= taskX + TASK_BORDER_RADIUS) {
console.log("=> onResizeMove: abort x lower than task start");
return { ...taskDimensions };
}

return { ...taskDimensions, width: handlerX - taskX };
case "lx":
if (handlerX >= taskEndX - TASK_BORDER_RADIUS) {
console.log("=> onResizeMove: abort x higher than task end");
return { ...taskDimensions };
}

return { ...taskDimensions, x: handlerX, width: taskEndX - handlerX };
}
});
},
[getDragPoint]
);

const onResizeEnd = useCallback((e: KonvaEventObject<DragEvent>) => {
e.cancelBubble = true;
console.log("=> onResizeEnd", e.target);
setResizing(false);
}, []);

return (
<Group
x={x}
y={y}
x={taskDimensions.x}
y={taskDimensions.y}
draggable={!!onTaskDrag}
onClick={onClick}
onDragEnd={onDragEnd}
onDragMove={onDragMove}
onDragStart={onDragStart}
onMouseLeave={onTaskLeave}
onMouseMove={onTaskOver}
onMouseOver={onTaskOver}
>
<Rect
id={taskId}
cornerRadius={TASK_BORDER_RADIUS}
fill={fill}
height={taskHeight}
opacity={opacity}
onMouseLeave={onTaskLeave}
onMouseMove={onTaskOver}
onMouseOver={onTaskOver}
stroke={TASK_DEFAULT_STROKE}
width={width}
strokeWidth={TASK_DEFAULT_STROKE_WIDTH}
width={taskDimensions.width}
/>
<TaskResizeHandler
height={taskHeight}
onResizeStart={onResizeStart}
onResizeMove={onResizeMove}
onResizeEnd={onResizeEnd}
opacity={opacity}
position="lx"
taskId={taskId}
xCoordinate={-1}
/>
<TaskResizeHandler
height={taskHeight}
onResizeStart={onResizeStart}
onResizeMove={onResizeMove}
onResizeEnd={onResizeEnd}
opacity={opacity}
position="rx"
taskId={taskId}
xCoordinate={taskDimensions.width}
/>
{displayTasksLabel && (
<KonvaText
Expand Down
65 changes: 65 additions & 0 deletions src/tasks/components/TaskResizeHandler/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React, { useMemo } from "react";
import { Rect } from "react-konva";
import { KonvaEventObject } from "konva/lib/Node";

interface TaskResizeHandlerProps {
height: number;
onResizeStart: (e: KonvaEventObject<DragEvent>) => void;
onResizeMove: (e: KonvaEventObject<DragEvent>, position: "lx" | "rx") => void;
onResizeEnd: (e: KonvaEventObject<DragEvent>) => void;
opacity: number;
position: "lx" | "rx";
taskId: string;
xCoordinate: number;
}

const TASK_BORDER_RADIUS = 4;

const TaskResizeHandler = ({
height,
onResizeEnd,
onResizeMove,
onResizeStart,
opacity,
position,
taskId,
xCoordinate,
}: TaskResizeHandlerProps) => {
const overCursor = useMemo(() => `${position === "lx" ? "w" : "e"}-resize`, [position]);

return (
<Rect
id={`${taskId}-resize-${position}`}
draggable
fill="transparent"
height={height}
onDragStart={onResizeStart}
onDragMove={(e) => onResizeMove(e, position)}
onDragEnd={onResizeEnd}
onMouseOver={(e) => {
e.cancelBubble = true;
const stage = e.target.getStage();
if (!stage) {
return;
}

stage.container().style.cursor = overCursor;
}}
onMouseLeave={(e) => {
e.cancelBubble = true;
const stage = e.target.getStage();
if (!stage) {
return;
}

stage.container().style.cursor = "default";
}}
opacity={opacity}
width={TASK_BORDER_RADIUS}
x={xCoordinate}
y={0}
/>
);
};

export default TaskResizeHandler;
11 changes: 10 additions & 1 deletion src/tasks/utils/tasks.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { generateStoryData } from "../../KonvaTimeline/stories-data";
import { InternalTimeRange } from "../../utils/time";

import { validateTasks } from "./tasks";
import { getTaskYCoordinate, validateTasks } from "./tasks";

// From: Sunday, 1 January 2023 00:00:00 GMT+01:00
// To: Monday, 2 January 2023 00:00:00 GMT+01:00
const range: InternalTimeRange = { start: 1672527600000, end: 1672614000000 };

describe("getTaskYCoordinate", () => {
it("valid", () => {
const ROW_HEIGHT = 50;
const resourceIndex = Math.ceil(Math.random() * 10);
const yCoordinate = getTaskYCoordinate(resourceIndex, ROW_HEIGHT);
expect(yCoordinate % ROW_HEIGHT).toEqual(ROW_HEIGHT * 0.1);
});
});

describe("validateTasks", () => {
it("empty", () => {
const tasks = validateTasks([], range);
Expand Down
10 changes: 10 additions & 0 deletions src/tasks/utils/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,16 @@ export interface TaskData<T extends TimeRange = TimeRange> {

type FilteredTasks = Operation<TaskData<InternalTimeRange>>;

const TASK_OFFSET_Y = 0.1;

/**
* Gets task Y coordinate
* @param rowIndex the row index
* @param rowHeight the row height
*/
export const getTaskYCoordinate = (rowIndex: number, rowHeight: number) =>
rowHeight * rowIndex + rowHeight * TASK_OFFSET_Y;

/**
* Filters valid tasks to be shown in the chart
* @param tasks list of tasks as passed to the component
Expand Down

0 comments on commit 69054fa

Please sign in to comment.