DEV Community

Cover image for Part 1: How do custom Caret(cursor)
Vladimir Schneider
Vladimir Schneider

Posted on • Edited on

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
};
Enter fullscreen mode Exit fullscreen mode

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')
  )
}
Enter fullscreen mode Exit fullscreen mode

<Text />

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

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

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>
  )
};
Enter fullscreen mode Exit fullscreen mode

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'
];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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
  };
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

handleBlur

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

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

  setHeight(null);
}, []);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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

return {
    handleClick,
    handleChange,
    handleBlur,
    currentText,
    height,
    coords: {
      x, y
    }
  };
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
vladimirschneider profile image
Vladimir Schneider

Yeah, thank you

It's not well for using on the real world's projects, It is true, but I am looking for a solution to the problem about how visible multiple carets (for multiple people content edit) on the page and I think I tell about this in the next articles.

I think the next solution will be well and you can use this in real world projects ๐ŸคŸ๐Ÿผ