Search code examples
reactjsreact-hook-formquill

Quill editor toolbar not initialized in new window


I have made a form with react-hook-form where I would like to sync text value in editors that are open in 2 windows with react portal.

I have made a full working example here. If you click on the button next to Description label of the input, a new window will open up with an editor. I would like to sync text value between these two editors. Syncing text content is working, but I have a problem that the toolbar functions are not working in the new window. Toolbar buttons are rendered, but they don't trigger anything, and content is not changed in the new window. They only work in the "original" window.

const containerRef = useRef(null);
const quill = useRef(null);

useEffect(() => {
    const container = containerRef.current;
    const editorContainer = container.appendChild(container.ownerDocument.createElement("div"));

    quill.current = new Quill(editorContainer, {
        theme: "snow",
        readOnly,
        modules: {
            history: {},
            toolbar: readOnly
                ? false
                : {
                      container: [
                          ["bold", "italic", "underline", { header: 3 }],
                          // [{ 'color': "red" }, { 'background': "yellow" }]
                      ],
                  },
            clipboard: {
                allowed: {
                    tags: ["strong", "h3", "h4", "em", "p", "br", "span", "u"],
                    // attributes: ['href', 'rel', 'target', 'class', "style"]
                    attributes: [],
                },
                customButtons: [],
                keepSelection: true,
                substituteBlockElements: true,
                magicPasteLinks: false,
                removeConsecutiveSubstitutionTags: false,
            },
        },
    });

    ref.current = quill.current;

    quill.current.on(Quill.events.TEXT_CHANGE, () => {
        console.log("on change");
        if (quill.current.getLength() <= 1) {
            onTextChange("");
        } else {
            onTextChange(quill.current.getSemanticHTML().replaceAll("<p></p>", "<p><br/></p>"));
        }
    });

    return () => {
        ref.current = null;
        quill.current = null;
        container.innerHTML = "";
    };
}, [ref]);

useEffect(() => {
    if (quill.current) {
        const currentHTML = quill.current.getSemanticHTML().replaceAll("<p></p>", "<p><br/></p>");
        const isSame = defaultValue === currentHTML;

        if (!isSame) {
            const updatedDelta = quill.current.clipboard.convert({ html: defaultValue });
            quill.current.setContents(updatedDelta, "silent");
        }
    }
}, [defaultValue]);

return (
    <div
        spellCheck={false}
        className={`ql-top-container ${readOnly ? "readonly" : ""} ${resize ? "resizable" : ""}`}
        ref={containerRef}
    ></div>
);

UPDATE

I have tried based on suggestion from answers to pass a new document to an editor, so that event listeners could be added to the right document, but that didn't help either:

export const NewWindowPortal = ({ onClose, children }: { onClose: () => void; children: (props) => ReactNode }) => {
    const ref = useRef(null);
    const [container, setContainer] = useState(null);
    const [newWindow, setNewWindow] = useState<Window>(null);
    const parentHead = window.document.querySelector("head").childNodes;

    useEffect(() => {
        setNewWindow(window.open("", "", "width=800,height=700,left=200,top=200"));

        if (newWindow) {
            parentHead.forEach((item) => {
                const appendItem = item;
                if (item.nodeName === "TITLE") {
                    newWindow.document.title = `${(item as HTMLElement).innerHTML} - begrunnelse`;
                } else {
                    newWindow.document.head.appendChild(appendItem.cloneNode(true));
                }
            });

            newWindow.window.addEventListener("beforeunload", onClose);

            setContainer(newWindow.document.createElement("div"));
            newWindow.document.body.appendChild(container);
        }

        return () => {
            newWindow.window.removeEventListener("beforeunload", onClose);
            newWindow.close();
        };
    }, []);

    return container && createPortal(children({ ref, newDocument: newWindow.document }), container);
};

So, I am passing this newDocument as a prop to an editor:

{openInNewWindow && (
    <NewWindowPortal onClose={() => setOpenInNewWindow(false)}>
        {(props) => (
            <div className="p-4">
                {label && (
                    <Label className="flex items-center gap-2" spacing size="small" htmlFor={name}>
                        {label}
                    </Label>
                )}
                {description && (
                    <BodyShort
                        spacing
                        textColor="subtle"
                        size="small"
                        className="max-w-[500px] mt-[-0.375rem]"
                    >
                        {description}
                    </BodyShort>
                )}
                <CustomQuillEditor
                    ref={props.ref}
                    resize={resize}
                    readOnly={lesemodus || readOnly}
                    defaultValue={reformatText(value)}
                    onTextChange={onTextChange}
                    newDocument={props.newDocument}
                />
            </div>
        )}
    </NewWindowPortal>
)}

And then in editor I check for the right document:

export const CustomQuillEditor = ({ readOnly, defaultValue, onTextChange, ref, resize, newDocument }: EditorProps) => {
    const containerRef = useRef(null);
    const quill = useRef(null);

    useEffect(() => {
        if (!containerRef.current) return;

        const doc = newDocument || window.document;
        const editorContainer = doc.createElement("div");
        const container = containerRef.current;
        container.appendChild(editorContainer);

        quill.current = new Quill(editorContainer, {
            theme: "snow",
            readOnly,
            modules: {
                history: {},
                toolbar: readOnly
                    ? false
                    : {
                          container: [["bold", "italic", "underline", { header: 3 }]],
                      },
            },
        });

        ref.current = quill.current;

        quill.current.on(Quill.events.TEXT_CHANGE, () => {
            if (quill.current.getLength() <= 1) {
                onTextChange("");
            } else {
                onTextChange(quill.current.getSemanticHTML().replaceAll("<p></p>", "<p><br/></p>"));
            }
        });

        return () => {
            ref.current = null;
            quill.current = null;
            container.innerHTML = "";
        };
    }, [ref]);

    useEffect(() => {
        if (quill.current) {
            const currentHTML = quill.current.getSemanticHTML().replaceAll("<p></p>", "<p><br/></p>");

            if (defaultValue !== currentHTML) {
                const updatedDelta = quill.current.clipboard.convert({ html: defaultValue });
                quill.current.setContents(updatedDelta, "silent");
            }
        }
    }, [defaultValue]);

    return (
        <div
            spellCheck={false}
            className={`ql-top-container ${readOnly ? "readonly" : ""} ${resize ? "resizable" : ""}`}
            ref={containerRef}
        ></div>
    );
};

But, that didn't help either, not sure why it is not able to add event listeners here to the right document?


Solution

  • Why provided code doesn't work

    The reason why your code doesn't work is in Quill library's architecture. And I have several possible proposals for you how to overcome it.

    Please, take a look at the Emitter package. It contains listeners to the document events:

    EVENTS.forEach((eventName) => {
      document.addEventListener(eventName, (...args) => {
        Array.from(document.querySelectorAll('.ql-container')).forEach((node) => {
          const quill = instances.get(node);
          if (quill && quill.emitter) {
            quill.emitter.handleDOM(...args);
          }
        });
      });
    });
    

    There are even more listeners to the DOM's root if you use the search for the project

    When you are initializing an instance of Quill library via React function createPortal, you are passing an element in the other window, created by the window.open function. The other window has the separate document tree attached. So when events trigger in the child window's DOM model, they bubble up to the child window's document, not original window's document.

    React portal doesn't help here. It knows nothing about these handlers and doesn't bubble them up to the original window.

    Switching to other library instead of Quill probably won't help you there and here's why. If you would choose the other library instead of Quill (e.g. facebook's Lexical), you would face similar set of issues (see Lexical source code, it has similar listeners attached). So the issue is not with Quill library itself but with common architectural patterns which are often used in such rich editors.

    There are two options how to overcome this difficulty.

    1. Use functions to communicate with child window

    Instead of initializing Quill for the child in main window's code, you should have separate page (window.open('/separate-page')) in the same domain for it. Initialize it there with separate React code (it might be shared with main code via common packages). You don't need react createPortal in this implementation.

    These two pages can communicate with each other by using functions, declared in child window's and main window's code and window.opener property.

    
    // call the function ProcessChildMessage on the parent page
    window.opener.ProcessChildMessage('Message to the parent');
    
    ...
    
    const childWindow = window.open('/separate-page', '_blank');
    // call the function ProcessParentMessage on the child page
    childWindow.ProcessParentMessage('Message to the child');
    
    

    Please take a look at this article for more details:

    https://usefulangle.com/post/4/javascript-communication-parent-child-window

    I prefer this option because it has much cleaner architecture than the next one.

    2. Manually pass through events triggered in the child window to the main window

    You can manually add event listeners to all the events that Quill library listens in the document and manually trigger them in child's document via dispatchEvent method.

    This is hard one and has a lot of disadvantages:

    • It might need to reverse engineer the source code of the Quill library.
    • It might need modifications when Quill library change. Because it's highly bound to it's current architecture and code.

    I would strongly advice you to step away off this route and use the first approach instead.