24
Part 1: How do custom Caret(cursor)
Hi there 👋🏼
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.
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')
)
}
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>
)
};
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 🙌🏼
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]);
This is the very simple handler. I should reset our states
const handleBlur = useCallback(() => {
setX(null);
setY(null);
setHeight(null);
}, []);
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