Adding comments on a page

Not unlike Figma

I’ve been thinking about spatial software and planning to prototype a few ideas. One of the fundamental aspects of interaction in such tools is the ability to leave notes or comments in the space. I prototyped a simple version of this feature.

To interact, click anywhere on the embedded frame (to enable events), choose a spot with your cursor and press the c key to open up a comment. After you’ve written your comment, either press the enter key or click on the submit button to place your comment in the space. You can also press the esc key to cancel your submission. To redo the action delete the comment and start over.

Process

For the sake of simplicity, I assumed that we’re only allowed to leave one comment. Beyond this prototype, we could lift the state up and have a comment manager to keep track of multiple comments, instead of storing the state in the comment component.

Code

The implementation is straightforward. There are two hooks for detecting key presses and tracking the cursor. Here shouldRun is an optional flag that’s used for disabling keypress detection at certain times (ex. when the user is typing their comment).

interface Props {
  targetKey: string;
  callback: () => void;
  shouldRun?: boolean;
}

function useKeyPress(props: Props) {
  const { callback, targetKey, shouldRun = true } = props;
  const downHandler = useCallback(
    (e: KeyboardEvent) => {
      if (!shouldRun) return;
      if (e.key === targetKey) {
        e.preventDefault();
        callback();
      }
    },
    [callback, shouldRun, targetKey]
  );

  useEffect(() => {
    window.addEventListener("keydown", downHandler);

    return () => window.removeEventListener("keydown", downHandler);
  }, [downHandler]);
}

We should only track mouse movement when we need it (ex. not when the user is leaving a comment). In a more complex example, tracking would start after pressing the c key, where the comment indicator would become visible (instead of being present by default). Throttling is also beneficial, though anything beyond 12ms would lessen the experience.

interface Props {
  shouldTrack: boolean;
}

function useMousePosition(props: Props) {
  const { shouldTrack } = props;
  const [mousePosition, setMousePosition] = useState<Point>({ x: 0, y: 0 });

  useEffect(() => {
    if (!shouldTrack) return;

    const updateMousePosition = throttle((e: MouseEvent) => {
      setMousePosition({ x: e.clientX, y: e.clientY });
    }, 12);

    window.addEventListener("mousemove", updateMousePosition);

    return () => {
      window.removeEventListener("mousemove", updateMousePosition);
    };
  }, [shouldTrack]);

  return mousePosition;
}

Then we use the hooks in the comment component. Here mousePosition is passed as a prop to our components, which are created using styled-components.

function Comment() {
  const [isOpen, setIsOpen] = useState(false);
  const [isSubmitted, setIsSubmitted] = useState(false);
  const [comment, setComment] = useState("");

  const mousePosition = useMousePosition({
    shouldTrack: !isSubmitted && !isOpen,
  });

  const handleOpen = () => {
    if (isSubmitted) return;
    setIsOpen(true);
  };

  const handleCancel = () => {
    setIsOpen(false);
  };

  const handleSubmit = () => {
    if (comment.trim().length === 0) return;
    setIsSubmitted(true);
  };

  const handleRemove = () => {
    setIsSubmitted(false);
    setIsOpen(false);
    setComment("");
  };

  useKeyPress({
    targetKey: "Escape",
    callback: handleCancel,
  });

  useKeyPress({
    targetKey: "Enter",
    callback: handleSubmit,
  });

  useKeyPress({
    targetKey: "c",
    callback: handleOpen,
    shouldRun: !isSubmitted && !isOpen,
  });

  return (
    <>
      {!isOpen && (
        <StyledCommentIndicator point={mousePosition}>
          <CommentIcon />
        </StyledCommentIndicator>
      )}
      {isSubmitted && (
        <StyledSubmittedComment point={mousePosition} submitted>
          <Button onClick={handleRemove}>
            <CloseIcon />
          </Button>
          <StyledParagraph>{comment}</StyledParagraph>
        </StyledSubmittedComment>
      )}
      {isOpen && !isSubmitted && (
        <StyledComment point={mousePosition}>
          <Input placeholder="Add your note here" onChange={setComment} />
          <Button onClick={handleSubmit}>
            <ArrowIcon />
          </Button>
        </StyledComment>
      )}
    </>
  );
}