Leff
Leff

Reputation: 1360

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?

Upvotes: 0

Views: 160

Answers (3)

Raghavendra N
Raghavendra N

Reputation: 5496

Ok, I traced the issue. The main issue is with the Selection component of the Quill. When you open the Quill editor in a new Window, any refernece to the document object must point to the document of the new window. But this is not happening. In this line, when document.getSelection() is called it is referring to the document of the parent window. So you are not getting the correct selection and the toolbar functions depending on this will not work in the new Window.

Solution:

Selection is a core component. So it cannot be easily replaced. One solution is to override the prototype methods on the selection class. You just need to override the 2 methods: getNativeRange and normalizedToRange.

Here is the getNativeRange method:

Object.getPrototypeOf(quill.current.selection).getNativeRange =
  function () {
    // Changed this line to get the correct document object
    const selection = container.ownerDocument.getSelection();
    if (selection == null || selection.rangeCount <= 0) return null;
    const nativeRange = selection.getRangeAt(0);
    if (nativeRange == null) return null;

    const range = this.normalizeNative(nativeRange);
    return range;
  }.bind(quill.current.selection);

Similarly I'd to override the normalizedToRange method, because the underlying Parchment library was throwing an issue.

As you can see Quill editor is not optimized to use with the other windows. But this answers your question.

Full Code: CustomQuillEditor.tsx

import "quill/dist/quill.snow.css";
import "quill/dist/quill.core.css";
import "./CustomQuillEditor.css";
import "quill-paste-smart";

import { LeafBlot } from "parchment";

import Quill from "quill";
import { useEffect, useRef, useState } from "react";

class Range {
  constructor(public index: number, public length = 0) {}
}

type EditorProps = {
  readOnly: boolean;
  defaultValue: string;
  onTextChange: (html: string) => void;
  resize?: boolean;
  ref;
};

export const QuillEditor = ({
  readOnly,
  defaultValue,
  onTextChange,
  ref,
  resize,
}: EditorProps) => {
  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: [
                [{ header: [1, 2, 3, 4, 5, 6, false] }],
                [{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
                ["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>")
        );
      }
    });

    // Override prototype methods
    Object.getPrototypeOf(quill.current.selection).getNativeRange =
      function () {
        const selection = container.ownerDocument.getSelection();
        if (selection == null || selection.rangeCount <= 0) return null;
        const nativeRange = selection.getRangeAt(0);
        if (nativeRange == null) return null;

        const range = this.normalizeNative(nativeRange);
        return range;
      }.bind(quill.current.selection);

    Object.getPrototypeOf(quill.current.selection).normalizedToRange =
      function (range) {
        const positions: [Node, number][] = [
          [range.start.node, range.start.offset],
        ];
        if (!range.native.collapsed) {
          positions.push([range.end.node, range.end.offset]);
        }
        const indexes = positions.map((position) => {
          const [node, offset] = position;
          const blot = this.scroll.find(node, true);
          // @ts-expect-error Fix me later
          const index = blot.offset(this.scroll);
          if (offset === 0) {
            return index;
          }
          if (blot instanceof LeafBlot) {
            return index + blot.index(node, offset);
          }
          // @ts-expect-error Fix me later
          return index + blot.length();
        });
        const end = Math.min(Math.max(...indexes), this.scroll.length() - 1);
        const start = Math.min(end, ...indexes);
        return new Range(start, end - start);
      }.bind(quill.current.selection);

    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>
  );
};

Check the working demo in the CodeSandbox.

Edit young-architecture-jxgln9

See this working gif example:

Working GIF Demo

Upvotes: 0

Lastik
Lastik

Reputation: 981

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.

Upvotes: 1

Vlad Dobrinov
Vlad Dobrinov

Reputation: 256

  1. your codepan is laggy -> anyone who `d want to help ll need to download and change codepan in own env.
  2. don`t think quill was intendet to work in other windows (see actual quill code to see implementation(to overcome it) or open issue for them).
  3. i`d suggest searching editor that state in docs to work in other windows. it the fastest way.
  4. y can try play with proxy.

Maybe this `ll help)

Upvotes: 0

Related Questions