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.
Top comments (1)
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 ๐ค๐ผ