0

I'm trying to code in NextJS 15 a very simple text input that lets users format their text and outputs the final text to be saved on a database. The input looks like the image below. (It should work like any text input from any online forum, just like the one i'm writing this post right now).

enter image description here

However, I have been facing two major problems with my input, which I've been unable to solve myself or using AI to try and find a solution:

Problem #1: When clicking on the font size selector menu to change the size in the middle of typing, the next character typed remains in the previous size and the next characters after the second character typed normally remain in the actual selected size. The correct behavior would be for all characters typed after selecting a new size, to be formatted in that size.

Steps to reproduce the error: type "123", then select a new font size, then type "456". You will see "1234" as one font size and "56" as another font size.

Problem #2: The output does not retain any line breaks when changing the font size of a selected portion of text.

Steps to reproduce the error: Type "123" (enter/line break), then "456" (enter/line break), then "789". Select "456" and change the font size. You will see the line breaks are erased now, when instead it should keep them.

Below is the full code for the text input component. Hope someone is able to help! Thanks.

import React, { useRef, useState, useEffect, useCallback, useMemo } from "react";
import { ImBold } from "react-icons/im";
import { FaItalic } from "react-icons/fa";
import { ImUnderline } from "react-icons/im";
import { FaStrikethrough } from "react-icons/fa6";
import { FaScissors } from "react-icons/fa6";
import { FaRegCopy } from "react-icons/fa6";
import { LuClipboardList } from "react-icons/lu";
import { FaMinus, FaPlus, FaAlignLeft, FaAlignCenter, FaAlignRight, FaAlignJustify } from "react-icons/fa";
import { MdOutlineEmojiEmotions } from "react-icons/md";
import dynamic from "next/dynamic";
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";

const TextEditorInput = ({ inputText }) => {
    const editorRef = useRef(null);
    const activeFontSpanRef = useRef(null);
    const [fontSize, setFontSize] = useState("médio");
    const [isFocused, setIsFocused] = useState(false);
    const [showEmojiPicker, setShowEmojiPicker] = useState(false);
    const [cursorPosition, setCursorPosition] = useState(null); // Track cursor position for emoji insertion
    const [activeStyles, setActiveStyles] = useState({
        bold: false,
        italic: false,
        underline: false,
        strikethrough: false,
    });
    const FONT_SIZES = useMemo(
        () => ({
            pequeno: "12px",
            médio: "16px",
            grande: "20px",
        }),
        []
    );

    const getActiveStyles = (node) => {
        let active = {
            bold: false,
            italic: false,
            underline: false,
            strikethrough: false,
        };

        while (node && node !== editorRef.current) {
            const computedStyle = window.getComputedStyle(node);

            if (!active.bold && (computedStyle.fontWeight === "700" || computedStyle.fontWeight === "bold")) {
                active.bold = true;
            }
            if (!active.italic && computedStyle.fontStyle === "italic") {
                active.italic = true;
            }
            if (!active.underline && computedStyle.textDecorationLine.includes("underline")) {
                active.underline = true;
            }
            if (!active.strikethrough && computedStyle.textDecorationLine.includes("line-through")) {
                active.strikethrough = true;
            }
            node = node.parentNode;
        }

        return active;
    };

    const focusEditor = () => {
        if (editorRef.current) {
            editorRef.current.focus();
        }
    };

    const updateActiveStyles = useCallback(() => {
        const selection = window.getSelection();
        if (!selection.rangeCount) return;

        const range = selection.getRangeAt(0);
        let container = range.commonAncestorContainer;
        if (container.nodeType === Node.TEXT_NODE) {
            container = container.parentElement;
        }

        const active = getActiveStyles(container);
        setActiveStyles(active);
    }, []);

    const toggleStyle = (style) => {
        document.execCommand(style, false, null);
        updateActiveStyles();
        focusEditor();
        saveContent();
    };

    const getSelectionOffsets = (container) => {
        const selection = window.getSelection();
        if (selection.rangeCount === 0) return { start: 0, end: 0 };
        const range = selection.getRangeAt(0);
        const preRange = range.cloneRange();
        preRange.selectNodeContents(container);
        preRange.setEnd(range.startContainer, range.startOffset);
        const start = preRange.toString().length;
        const end = start + range.toString().length;
        return { start, end };
    };

    const getCurrentFontSize = useCallback(() => {
        const selection = window.getSelection();
        if (selection.rangeCount > 0) {
            const range = selection.getRangeAt(0);
            let node = range.commonAncestorContainer;
            if (node.nodeType === Node.TEXT_NODE) {
                node = node.parentNode;
            }
            const computedStyle = window.getComputedStyle(node);
            for (let key in FONT_SIZES) {
                if (FONT_SIZES[key] === computedStyle.fontSize) {
                    return key;
                }
            }
        }
        return "médio";
    }, [FONT_SIZES]);

    const extractSegments = (container) => {
        let segments = [];
        const traverse = (node, inheritedStyle = "normal") => {
            if (node.nodeType === Node.TEXT_NODE) {
                if (node.textContent.length > 0) {
                    segments.push({ text: node.textContent, style: inheritedStyle });
                }
            } else if (node.nodeType === Node.ELEMENT_NODE) {
                let currentStyle = inheritedStyle;
                if (node.style && node.style.fontSize) {
                    for (let key in FONT_SIZES) {
                        if (FONT_SIZES[key] === node.style.fontSize) {
                            currentStyle = key;
                            break;
                        }
                    }
                }
                node.childNodes.forEach((child) => {
                    traverse(child, currentStyle);
                });
            }
        };
        traverse(container);
        return segments;
    };

    const rebuildContent = (container, segments) => {
        let newHTML = "";
        segments.forEach((seg) => {
            if (seg.style === "normal") {
                newHTML += seg.text;
            } else {
                newHTML += `<span style="font-size: ${FONT_SIZES[seg.style]}; line-height: normal;">${seg.text}</span>`;
            }
        });
        container.innerHTML = newHTML;
    };

    const changeFontSize = (newSize) => {
        setFontSize(newSize);
        focusEditor();
        const container = editorRef.current;
        if (!container) return;
        const segments = extractSegments(container);
        const fullText = segments.map((s) => s.text).join("");
        const { start: selStart, end: selEnd } = getSelectionOffsets(container);
        if (selStart === selEnd) return;
        let newSegments = [];
        let currentOffset = 0;
        segments.forEach((seg) => {
            const segStart = currentOffset;
            const segEnd = currentOffset + seg.text.length;
            if (segEnd <= selStart || segStart >= selEnd) {
                newSegments.push(seg);
            } else {
                if (segStart < selStart) {
                    const count = selStart - segStart;
                    newSegments.push({
                        text: seg.text.slice(0, count),
                        style: seg.style,
                    });
                }
                const overlapStart = Math.max(0, selStart - segStart);
                const overlapEnd = Math.min(seg.text.length, selEnd - segStart);
                newSegments.push({
                    text: seg.text.slice(overlapStart, overlapEnd),
                    style: newSize,
                });
                if (segEnd > selEnd) {
                    newSegments.push({
                        text: seg.text.slice(overlapEnd),
                        style: seg.style,
                    });
                }
            }
            currentOffset = segEnd;
        });
        rebuildContent(container, newSegments);
        window.getSelection().removeAllRanges();
        focusEditor();
        saveContent();
    };

    const adjustFontSize = (increase = true) => {
        const sizes = Object.keys(FONT_SIZES);
        const currentIndex = sizes.indexOf(fontSize);
        let newIndex;
        if (increase) {
            newIndex = currentIndex < sizes.length - 1 ? currentIndex + 1 : currentIndex;
        } else {
            newIndex = currentIndex > 0 ? currentIndex - 1 : currentIndex;
        }
        const newSize = sizes[newIndex];
        changeFontSize(newSize);
    };

    const setAlignment = (alignment) => {
        document.execCommand("justify" + alignment, false, null);
        focusEditor();
        saveContent();
    };

    const handleCut = () => {
        document.execCommand("cut");
        focusEditor();
        saveContent();
    };

    const handleCopy = () => {
        document.execCommand("copy");
        focusEditor();
        saveContent();
    };

    const handlePaste = async () => {
        try {
            const text = await navigator.clipboard.readText();
            document.execCommand("insertText", false, text);
        } catch (error) {
            console.error("Failed to paste:", error);
        }
        focusEditor();
        saveContent();
    };

    const handleEmojiClick = (emoji) => {
        const editor = editorRef.current;
        if (cursorPosition) {
            const selection = window.getSelection();
            selection.removeAllRanges();
            selection.addRange(cursorPosition);
        }
        document.execCommand("insertText", false, emoji.native);
        setShowEmojiPicker(false);
    };

    const saveCursorPosition = () => {
        const selection = window.getSelection();
        if (selection.rangeCount > 0) {
            const range = selection.getRangeAt(0);
            setCursorPosition(range);
        }
        setShowEmojiPicker(!showEmojiPicker);
        focusEditor();
    };

    const handleInput = (e) => {
        const container = editorRef.current;
        if (!container) return;
        const selection = window.getSelection();
        if (!selection.rangeCount) return;
        const range = selection.getRangeAt(0);
        let activeSpan = activeFontSpanRef.current;
        const fontSizeValue = FONT_SIZES[fontSize];

        if (!activeSpan || !activeSpan.contains(range.startContainer) || activeSpan.style.fontSize !== fontSizeValue) {
                const span = document.createElement("span");
                span.style.fontSize = fontSizeValue;
                const zwsp = document.createTextNode("\u200B");
                span.appendChild(zwsp);
                range.deleteContents();
                range.insertNode(span);
                const newRange = document.createRange();
                newRange.setStart(zwsp, 1);
                newRange.collapse(true);
                selection.removeAllRanges();
                selection.addRange(newRange);
                activeFontSpanRef.current = span;
        }
        focusEditor();
        saveContent();
    };

    const saveContent = () => {
        const content = editorRef.current.innerHTML;
        if (inputText) inputText(content);
    };

    return (
        <>
            {showEmojiPicker && (
                <div className="fixed z-50" style={{ top: "50%", left: "50%", transform: "translate(-50%, -50%)" }}>
                    <Picker onEmojiSelect={handleEmojiClick} onClickOutside={() => setShowEmojiPicker(false)} title="Pick your emoji" emoji="point_up" style={{ width: "100%" }} />
                </div>
            )}

            <div className={`border-[1.3px] ${isFocused ? "ring-[--color-tangelo] ring-[2px]" : "ring-[--color-white-smoke-dark1] ring-[1px]"} ring-solid rounded-md overflow-hidden resize-y min-h-[150px] transition-all w-full flex flex-col h-full`}>
                <div className="bg-[--color-white-smoke] p-2 flex border-b-[1.3px] border-[--color-white-smoke-dark1] flex-wrap">
                    <button onClick={handleCut} title="Recortar (Ctrl+X)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaScissors size={16} className="-rotate-90" />
                    </button>
                    <button onClick={handleCopy} title="Copiar (Ctrl+C)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaRegCopy size={16} />
                    </button>
                    <button onClick={handlePaste} title="Colar (Ctrl+V)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <LuClipboardList size={16} />
                    </button>
                    <span className="h-auto w-[1px] bg-[--color-white-smoke-dark1] mx-2"></span>

                    <button onClick={saveCursorPosition} title="Emoji (Ctrl+E)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <MdOutlineEmojiEmotions size={18} />
                    </button>
                    <span className="h-auto w-[1px] bg-[--color-white-smoke-dark1] mx-2"></span>

                    <button onClick={() => toggleStyle("bold")} title="Negrito (Ctrl+B)" className={`hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition ${activeStyles.bold ? "bg-[--color-white-smoke-highlight]" : ""}`}>
                        <ImBold size={16} />
                    </button>
                    <button onClick={() => toggleStyle("italic")} title="Itálico (Ctrl+I)" className={`hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition ${activeStyles.italic ? "bg-[--color-white-smoke-highlight]" : ""}`}>
                        <FaItalic size={16} />
                    </button>
                    <button onClick={() => toggleStyle("underline")} title="Sublinhar (Ctrl+U)" className={`hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition ${activeStyles.underline ? "bg-[--color-white-smoke-highlight]" : ""}`}>
                        <ImUnderline size={16} />
                    </button>
                    <button onClick={() => toggleStyle("strikeThrough")} title="Tachado (Ctrl+K)" className={`hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition ${activeStyles.strikethrough ? "bg-[--color-white-smoke-highlight]" : ""}`}>
                        <FaStrikethrough size={16} />
                    </button>
                    <span className="h-auto w-[1px] bg-[--color-white-smoke-dark1] mx-2"></span>

                    <button onClick={() => adjustFontSize(false)} title="Reduzir tamanho (Ctrl -)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaMinus size={16} />
                    </button>
                    <select
                        value={fontSize}
                        onChange={(e) => {
                            changeFontSize(e.target.value);
                            activeFontSpanRef.current = null;
                        }}
                        className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition"
                    >
                        {Object.keys(FONT_SIZES).map((size) => (
                            <option key={size} value={size}>
                                {size.charAt(0).toUpperCase() + size.slice(1)}
                            </option>
                        ))}
                    </select>

                    <button onClick={() => adjustFontSize(true)} title="Aumentar tamanho (Ctrl +)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaPlus size={16} />
                    </button>
                    <span className="h-auto w-[1px] bg-[--color-white-smoke-dark1] mx-2"></span>

                    <button onClick={() => setAlignment("Left")} title="Alinhar à esquerda (Ctrl+Shift+L)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaAlignLeft size={16} />
                    </button>
                    <button onClick={() => setAlignment("Center")} title="Alinhar ao centro (Ctrl+Shift+C)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaAlignCenter size={16} />
                    </button>
                    <button onClick={() => setAlignment("Right")} title="Alinhar à direita (Ctrl+Shift+R)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaAlignRight size={16} />
                    </button>
                    <button onClick={() => setAlignment("Full")} title="Justificar (Ctrl+Shift+J)" className="hover:bg-white hover:border-[--color-white-smoke-highlight] border-[1px] border-[--color-white-smoke] rounded-md p-1.5 transition">
                        <FaAlignJustify size={16} />
                    </button>
                </div>
                <div className="overflow-y-scroll w-full h-full">
                    <div ref={editorRef} onInput={handleInput} contentEditable={true} className="p-3 outline-none resize-y w-full h-full min-h-[150px]" onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} />
                </div>
            </div>
        </>
    );
};

export default TextEditorInput;

0

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.