Part 1: How do custom Caret(cursor)

Hi there 👋🏼

If you wanna see this right now: DEMO and GitHub.

I work on a startup about managing To-Do lists and now my task is to create a custom caret for editing some text content of To-Do items.

This is my first try (spoiler: not successful).

I did not find articles about how to create custom caret and I hope that this article and my thinkings will be helpful for you.

I wanna say now that this is not yet a solved problem. This is for fun only.

So. Let's write a silly component before starting to write logic.

<Caret />

This is a very simple component.

I use createPortal for position caret on a page.

The component has coords props and height of caret.

export type Coordinate = number | null;

export type CaretProps = {
  coords: {
    x: Coordinate
    y: Coordinate
  }
  height: number | null
};

So If coords or height props equal null I return null and caret is not visible. In the end, the component look like that

export const Caret = ({
  coords: {
    x, y
  },
  height
}: CaretProps) => {
  if (x === null || y === null || height === null) {
    return null
  }

  return createPortal(
    <div
      className={cx('caret')}
      style={{
        transform: `translate3d(${x}px, ${y}px, 0px)`,
        height: height,
        backgroundColor: 'var(--color-system-blue-light)'
      }}
    />,
    // @ts-ignore
    document.getElementById('caret')
  )
}

<Text />

This component calls our hook when I going to write later.

const {
    handleClick,
    handleChange,
    handleBlur,
    currentText,
    coords: {
      x, y
    }
    height,
  } = useCaret(refNode, text);

The props of hook I pass to <div /> when containing currentText and the <Caret /> component.

To do <div /> editable I use contentEditable attribute.

But by default, I have a placeholder and I should not have the ability to edit a placeholder, so contentEditable is true if currentText is not null. But I should catch a focus in the field, so I set another attribute tabIndex={0}.

So the component look like that

const Placeholder = () => (
  <span className={cx('placeholder')}>
    Enter your To-Do
  </span>
);

export const TextListsWidget = ({ text }: TextListsWidgetProps) => {
  const refNode = useRef<HTMLDivElement>(null);

  const {
    handleClick,
    handleChange,
    handleBlur,
    currentText,
    height,
    coords: {
      x, y
    }
  } = useCaret(refNode, text);

  return (
    <div className={cx('wrapper')}>
      <div
        ref={refNode}
        className={cx('text')}
        onClick={handleClick}
        onBlur={handleBlur}
        onKeyDown={handleChange}
        tabIndex={0}
        contentEditable={currentText !== null}
        suppressContentEditableWarning
      >
        {currentText || <Placeholder />}
        <Caret
          coords={{
            x, y
          }}
          height={height}
        />
      </div>
    </div>
  )
};

useCaret hook

So, first I write constants with keys and for keys as ignore, backspace, and arrows keys

export const IGNORE_KEYS = [
  'Shift',

  'Control',
  'Alt',
  'Meta',
  'Escape',
  'Tab',
  'CapsLock',

  // Arrows
  'ArrowUp',
  'ArrowDown',
  'Enter',
];

export const BACKSPACE_KEY = [
  'Backspace'
];

export const ARROW_LEFT_KEY = [
  'ArrowLeft'
];

export const ARROW_RIGHT_KEY = [
  'ArrowRight'
];

The hook has two props: text node and text.

I going to follow some values: caretPosition, currentText, x, y and caret height.

I did useState hooks for this.

const [caretPosition, setCaretPosition] = useState<CaretPosition>(null);

const [currentText, setCurrentText] = useState(text);

const [x, setX] = useState<Coordinate>(null);
const [y, setY] = useState<Coordinate>(null);

const [height, setHeight] = useState<number | null>(null);

Next, I going to write handlers and start with handleClick.

First I need the function to get coords and height of caret when the user does click.

For this I use window.getSelection(). Next I get first node with getRangeAt(0) and next I get x, y and height with getBoundingClientRect to selected node.

I should remember about the user scroll. Content could be very long and users can have the scroll. I get only y scroll because I can not have y scroll.

So If the text does not exist I should have x equal offsetLift of the node.

So, getCoords function

const getCoords = (node: RefObject<HTMLDivElement>, text: string | null) => {
  const scrollTopSize = document.documentElement.scrollTop;

  const selection = window.getSelection();

  if (!selection) {
    return {
      x: null,
      y: null,
      height: null
    };
  }

  const {
    x, y, height,
  } = selection.getRangeAt(0).getBoundingClientRect();

  if (text === null || text === '') {
    return {
      x: node.current?.offsetLeft || 0,
      y: y + scrollTopSize,
      height
    };
  }

  return {
    x, y: y + scrollTopSize, height
  };
};

Let's write a first handler 🙌🏼

handleClick

By click, I should get coords and set our states x, y, height and set caretPosition for component. If the text does not exist I set caretPosition to zero.

const handleClick = useCallback(() => {
  const selection = window.getSelection();

  if (!selection) {
    return;
  }

  const coords = getCoords(node, currentText);

  setX(coords.x);
  setY(coords.y);

  setHeight(coords.height);

  if (currentText !== null && currentText !== '') {
    setCaretPosition(selection.getRangeAt(0).startOffset);
  } else {
    setCaretPosition(0);
  }
}, [node, currentText]);

handleBlur

This is the very simple handler. I should reset our states

const handleBlur = useCallback(() => {
  setX(null);
  setY(null);

  setHeight(null);
}, []);

handleChange

This is the very important handler and I think It may be not simple for you.

First I check If the pressed key is IGNORE KEY and if it is I do return.

If the pressed key arrow left or right I set caretPosition to caretPosition - 1 or caretPosition + 1.

Next If pressed key is backspace I get left by caretPosition substring - 1 and right substring and do setCurrentText(left + right).

If I do not find pressed key in my keys constant I calc left and right substrings and do left + e.key + right.

Full handler look like that

const handleChange = useCallback((e: any) => {
  e.preventDefault();

  const coords = getCoords(node, currentText);

  setX(coords.x);
  setY(coords.y);

  setHeight(coords.height);

  if (IGNORE_KEYS.includes(e.key)) {
    return;
  }

  if (ARROW_LEFT_KEY.includes(e.key)) {
    if (caretPosition !== null && caretPosition !== 0) {
      setCaretPosition(caretPosition - 1);
    }
    return;
  }

  if (ARROW_RIGHT_KEY.includes(e.key)) {
    if (caretPosition !== null && currentText !== null && currentText !== '' && caretPosition < currentText.length) {
      setCaretPosition(caretPosition + 1);
    }
    return;
  }

  if (BACKSPACE_KEY.includes(e.key)) {
    if (currentText === null || currentText === '') {
      return;
    }

    if (caretPosition === null || caretPosition === 0) {
      return;
    }

    const left = currentText.substring(0, caretPosition - 1);
    const right = currentText.substring(caretPosition);

    setCurrentText(left + right);

    if (caretPosition !== 0 && caretPosition !== null) {
      setCaretPosition(caretPosition - 1);
    } else {
      setCaretPosition(0);
    }

    return;
  }

  if (caretPosition === null) {
    return;
  }

  if (currentText === null || currentText === '') {
    setCurrentText(e.key);
    setCaretPosition(e.key.length);
    return;
  }

  const left = currentText.substring(0, caretPosition);
  const right = currentText.substring(caretPosition);

  setCurrentText(left + e.key + right);

  setCaretPosition(caretPosition + e.key.length);
}, [node, currentText, caretPosition]);

So each time when I change the caret position I should update x, y, and height on correct values. So I use the useEffect hook for this and a native Range class.

useEffect(() => {
  const range = new Range();
  const selection = document.getSelection();

  if (selection && selection.focusNode && caretPosition !== null) {
    try {
      range.setStart(selection.focusNode, caretPosition);
    } catch (e) {}

    range.collapse(true);
    selection.removeAllRanges();
    selection.addRange(range);

    const {
      x, y, height
    } = getCoords(node, currentText);

    setX(x);
    setY(y);

    setHeight(height);
  }
}, [caretPosition, currentText, node]);

In the end, I just return handlers and values to the user in the out.

return {
    handleClick,
    handleChange,
    handleBlur,
    currentText,
    height,
    coords: {
      x, y
    }
  };

I wrote a simple example for you. Welcome to the GitHub page and thank you.

In the next week, I going to write the second part about how you can do this very simple and more boilerplate.

24