Calendar Editor with React | Drag & Resize Elements

November 11, 2022

6 min read

Calendar Editor with React | Drag & Resize Elements
Watch on YouTube

There is a time-tracker app, Increaser, with an interface for adding work sessions the same way you would add an event in Google Calendar. Let me show you how I implemented it with React.

editor

To make it comfortable for the user, we want to distinguish an editable session from the existing ones and make it easy to change session boundaries and position.

import {
  MouseEventHandler,
  ReactNode,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react"
import { useEvent } from "react-use"
import { Interval } from "shared/entities/Interval"
import { enforceRange } from "shared/utils/enforceRange"
import { formatDuration } from "shared/utils/formatDuration"
import styled, { css } from "styled-components"
import { HSLA } from "ui/colors/HSLA"
import { MoveIcon } from "ui/icons/MoveIcon"
import { Text } from "ui/Text"
import { HourSpace } from "ui/timeline/HourSpace"
import { centerContentCSS } from "ui/utils/centerContentCSS"
import { getVerticalMarginCSS } from "ui/utils/getVerticalMarginCSS"
import { MS_IN_HOUR, MS_IN_MIN } from "utils/time"

import { InteractiveBoundaryArea } from "./InteractiveBoundaryArea"
import { IntervalRect } from "./IntervalRect"
import { MaxIntervalEndBoundary } from "./MaxIntervalEndBoundary"

interface RenderContentParams {
  pxInMs: number
}

export interface IntervalInputProps {
  color: HSLA
  value: Interval
  onChange: (value: Interval) => void
  startOfDay: number
  startHour: number
  endHour: number
  maxIntervalEnd?: number
  minDuration?: number
  renderContent?: (params: RenderContentParams) => ReactNode
}

const pxInHour = 60
const defaultMinDurationInMin = 10

const Container = styled.div`
  ${getVerticalMarginCSS(8)}
`

type IntervalEditorControl = "start" | "end" | "position"

const MoveIconWr = styled.div`
  font-size: 16px;
`

const CurrentIntervalRect = styled(IntervalRect)`
  ${centerContentCSS}

  ${({ $color }) => css`
    background: ${$color.getVariant({ a: () => 0.12 }).toCssValue()};
    border: 2px solid ${$color.toCssValue()};
    color: ${$color.toCssValue()};
  `}
`

const InteractiveDragArea = styled.div`
  position: absolute;
  width: 100%;
  cursor: grab;
`

const DurationText = styled(Text)`
  position: absolute;
  width: 100%;
  text-align: center;
  transition: none;
`

export const IntervalInput = ({
  color,
  value,
  onChange,
  startOfDay,
  endHour,
  startHour,
  renderContent,
  minDuration = defaultMinDurationInMin * MS_IN_MIN,
  maxIntervalEnd: optionalMaxIntervalEnd,
}: IntervalInputProps) => {
  const hoursCount = endHour - startHour

  const maxIntervalEnd =
    optionalMaxIntervalEnd ?? startOfDay + MS_IN_HOUR * endHour

  const minIntervalStart = startOfDay + MS_IN_HOUR * startHour

  const height = hoursCount * pxInHour
  const pxInMs = height / (hoursCount * MS_IN_HOUR)

  const [activeControl, setActiveControl] =
    useState<IntervalEditorControl | null>(null)

  useEvent("mouseup", () => setActiveControl(null))

  const containerElement = useRef<HTMLDivElement | null>(null)
  const intervalElement = useRef<HTMLDivElement | null>(null)
  useEffect(() => {
    intervalElement.current?.scrollIntoView()
  }, [intervalElement])

  const valueDuration = value.end - value.start

  const handleMouseMove: MouseEventHandler<HTMLDivElement> = ({ clientY }) => {
    if (!activeControl) return

    const containerRect = containerElement?.current?.getBoundingClientRect()
    if (!containerRect) return

    const y =
      enforceRange(clientY, containerRect.top, containerRect.bottom) -
      containerRect.top

    const getNewInterval = () => {
      if (activeControl === "position") {
        const oldCenter =
          (value.start + valueDuration / 2 - startOfDay) * pxInMs

        const offset = y - oldCenter
        const msOffset = enforceRange(
          offset / pxInMs,
          minIntervalStart - value.start,
          maxIntervalEnd - value.end
        )

        return {
          start: value.start + msOffset,
          end: value.end + msOffset,
        }
      } else {
        const timestamp = startOfDay + y / pxInMs

        return {
          start:
            activeControl === "start"
              ? Math.max(
                  Math.min(timestamp, value.end - minDuration),
                  minIntervalStart
                )
              : value.start,
          end:
            activeControl === "end"
              ? Math.min(
                  Math.max(timestamp, value.start + minDuration),
                  maxIntervalEnd
                )
              : value.end,
        }
      }
    }

    onChange(getNewInterval())
  }

  const cursor = useMemo(() => {
    if (!activeControl) return undefined

    if (activeControl === "position") return "grabbing"

    return "row-resize"
  }, [activeControl])

  const intervalStartInPx = pxInMs * (value.start - startOfDay)
  const intervalEndInPx = pxInMs * (value.end - startOfDay)
  const intervalDurationInPx = pxInMs * valueDuration

  return (
    <Container
      ref={containerElement}
      style={{ height: height, cursor }}
      onMouseMove={handleMouseMove}
    >
      <HourSpace
        formatHour={(hour) => {
          const date = new Date(startOfDay + hour * MS_IN_HOUR)
          return date.toLocaleString(undefined, { hour: "numeric" })
        }}
        start={0}
        end={hoursCount}
        hourLabelWidthInPx={20}
      >
        {renderContent && renderContent({ pxInMs })}
        <CurrentIntervalRect
          $color={color}
          ref={intervalElement}
          style={{
            top: intervalStartInPx,
            height: intervalDurationInPx,
          }}
        >
          <MoveIconWr style={{ opacity: activeControl ? 0 : 1 }}>
            <MoveIcon />
          </MoveIconWr>
        </CurrentIntervalRect>

        <DurationText
          style={{
            top: intervalEndInPx + 2,
          }}
          weight="bold"
        >
          {formatDuration(valueDuration, "ms")}
        </DurationText>

        {optionalMaxIntervalEnd && (
          <MaxIntervalEndBoundary
            timestamp={optionalMaxIntervalEnd}
            y={pxInMs * (optionalMaxIntervalEnd - startOfDay)}
            isActive={!!activeControl}
          />
        )}

        {!activeControl && (
          <>
            <InteractiveDragArea
              style={{
                top: intervalStartInPx,
                height: intervalDurationInPx,
              }}
              onMouseDown={() => setActiveControl("position")}
            />

            <InteractiveBoundaryArea
              y={intervalStartInPx}
              onMouseDown={() => setActiveControl("start")}
            />

            <InteractiveBoundaryArea
              y={intervalEndInPx}
              onMouseDown={() => setActiveControl("end")}
            />
          </>
        )}
      </HourSpace>
    </Container>
  )
}

The component receives the session's color and interval, onChange handler, the start of day timestamp, and start and end hours to show only part of the day. To disable making a session later than a given part of the day and disable sessions smaller than a given duration, we pass optional maxIntervalEnd and minDuration properties. We can show an existing session by passing a function to the renderContent property.

First, we want to make a fixed height container using hours count multiplied by the pixelsInHour constant. Then we use the HourSpace component to spread lines with hours numbers along the container.

import styled from "styled-components"
import { Text } from "ui/Text"
import { getRange } from "utils/generic"

import { VStack } from "../Stack"

interface Props {
  start: number
  end: number
  className?: string
  hourLabelWidthInPx?: number
  children?: React.ReactNode
  underLinesContent?: React.ReactNode
  formatHour?: (hour: number) => string | number
}

const Container = styled(VStack)`
  position: relative;
`

const HourWr = styled.div`
  position: relative;
  display: flex;
  align-items: center;
`

const HourContainer = styled.div`
  position: absolute;
  width: 100%;
`

const HourContent = styled.div<{ labelWidth: number }>`
  width: 100%;
  display: grid;
  gap: 8px;
  grid-template-columns: ${({ labelWidth }) => labelWidth}px 1fr;
  align-items: center;
`

const HourLine = styled.div`
  background: ${({ theme }) => theme.colors.backgroundGlass.toCssValue()};
  height: 1px;
  width: 100%;
`

const Content = styled.div<{ leftOffset: number }>`
  position: absolute;
  width: calc(100% - ${(p) => p.leftOffset}px);
  height: 100%;
  left: ${(p) => p.leftOffset}px;
  display: flex;
`

export const HourSpace = ({
  start,
  end,
  className,
  hourLabelWidthInPx = 20,
  children,
  underLinesContent,
  formatHour = (v) => v,
}: Props) => {
  const hours = getRange(end + 1 - start).map((index) => start + index)

  return (
    <Container
      className={className}
      justifyContent="space-between"
      fullHeight
      fullWidth
    >
      {underLinesContent && (
        <Content leftOffset={hourLabelWidthInPx}>{underLinesContent}</Content>
      )}
      {hours.map((hour) => {
        return (
          <HourWr key={hour}>
            <HourContainer>
              <HourContent labelWidth={hourLabelWidthInPx}>
                <Text
                  style={{ textAlign: "start" }}
                  size={14}
                  color="supporting"
                >
                  {formatHour(hour)}
                </Text>
                <HourLine />
              </HourContent>
            </HourContainer>
          </HourWr>
        )
      })}
      {children && (
        <Content leftOffset={hourLabelWidthInPx}>{children}</Content>
      )}
    </Container>
  )
}

Inside it, we first render the content from parents, if there are any, and proceed with the current editable interval. To distinguish it from the other session, we make the content almost transparent, add a border color, and place the move icon inside to indicate to the user that they can interact with the element. Then we render the session duration and max interval boundary.

To start the editing process user will interact with one of three elements: two boundaries for editing the start and end of the interval and an interactive area for dragging the session. Each has a custom cursor pointer, absolute position, and onMouseDown handler to set active control. Once we are in the editing mode, we hide them. Once we have active control, we should listen for mouse movement and update the interval. Once the user lifts the mouse, we set the activeControl value to null and reappear invisible interactive areas. To update interval boundaries, we use the clientY variable from the mousemove event against the container's bounding box to calculate the new start and end for the session.