reactjsquillreact-quill

Clicking the bold button deselects the selected text before emboldening


I have a problem that only exists when using a Shadow DOM. It’s difficult to explain, but when the Shadow DOM is present (i.e. using the ShadowWrapper component), it behaves so that, when typing content in the Quill editor, selecting it and clicking a button like bold, it doesn’t apply the bold to the selected text - instead, it seems to deselect the selected text and then turns the button on, so if you were to add new text, that text would then be emboldened. The link button also does not work whatsoever for example.

I think it might not be a CSS/styling issue since I tried including the same CSS as with the working version (without the Shadow DOM) and it still doesn’t work.

Here is the complete code:

import "./styles.css";
import React, { useRef, useEffect, useState } from "react";
import ReactDOM from "react-dom";
import ReactQuill from "react-quill";
import "react-quill/dist/quill.snow.css";

const modules = {
  toolbar: [
    [{ font: [] }],
    [{ header: [1, 2, 3, 4, 5, 6, false] }],
    ["bold", "italic", "underline", "strike"],
    [{ color: [] }, { background: [] }],
    [{ script: "sub" }, { script: "super" }],
    ["blockquote", "code-block"],
    [{ list: "ordered" }, { list: "bullet" }],
    [{ indent: "-1" }, { indent: "+1" }, { align: [] }],
    ["link", "image", "video"],
    ["clean"],
  ],
};

const selectionChange = (e: any) => {
  if (e) console.log(e.index, e.length);
};

interface ShadowWrapperProps {
  children: React.ReactNode;
}

const ShadowWrapper: React.FC<ShadowWrapperProps> = ({ children }) => {
  const shadowRootRef = useRef<HTMLDivElement | null>(null); // Reference to the div that will contain the shadow DOM
  const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null); // State to store the shadow root

  useEffect(() => {
    // Check if the shadowRootRef is set and shadowRoot is not already created
    if (shadowRootRef.current && !shadowRoot) {
      // Check if the shadow DOM is not already attached to the div
      if (!shadowRootRef.current.shadowRoot) {
        // Attach shadow DOM to the div
        const shadow = shadowRootRef.current.attachShadow({ mode: "open" });
        setShadowRoot(shadow);

        // Inject Quill styles into the shadow DOM
        const style = document.createElement("style");
        style.textContent = `
                  @import url('https://cdn.quilljs.com/1.3.6/quill.snow.css');
              `;
        shadow.appendChild(style);
      } else {
        // If shadow root already exists, set it to state
        setShadowRoot(shadowRootRef.current.shadowRoot);
      }
    }
  }, [shadowRoot]);

  return (
    // Render children inside the shadow DOM
    <div ref={shadowRootRef}>
      {shadowRoot && ReactDOM.createPortal(children, shadowRoot)}
    </div>
  );
};

const App = () => {
  const initialValue = `highlight this text`;
  const [value, setValue] = useState(initialValue);

  return (
    <>
      <ShadowWrapper>
        <ReactQuill
          modules={modules}
          value={value}
          theme="snow"
          onChange={setValue}
          onChangeSelection={selectionChange}
          placeholder="Content goes here..."
        />
      </ShadowWrapper>
      <div style={{ width: "100%" }}>
        <pre>{value}</pre>
      </div>
    </>
  );
};

export default App;

For convenience, I have also been able to make a clone of the problem here (you may need to use Google Chrome for it to work):

https://codesandbox.io/p/sandbox/react-quill-editor-playground-forked-mcz33c?file=%2Fsrc%2FApp.tsx%3A6%2C1&workspaceId=ws_XF7JbL8XCDBxF1yKmujfTX

Load the URL and then click inside the box, select the 'highlight this text' value in the editor and click bold - you'll notice that the text isn't emboldened, but if you type again it will be emboldened.

To fix the problem, comment out the ShadowWrapper component.

So the main question is how can it be made so that it works with the ShadowWrapper component? (Because in my real app I need a Shadow DOM to prevent external styles affecting it). Thanks for any help


Solution

  • Here it seems like the ReactQuill editor is being rendered inside a ShadowWrapper, but it doesn't correctly handle Quill's initialization inside the shadow DOM. The problem is that Quill expects the root element to be attached directly to the DOM. Since the ReactQuill component is wrapped inside the ShadowWrapper, the editor's internal root is not getting properly set or initialized within the shadow DOM.

    It seems to get fixed by manually attaching the Quill editor's root to the element inside the shadow DOM (quillRef.current.getEditor().root = el;), this ensures that the Quill editor operates correctly within the shadow DOM.

    import "./styles.css";
    import React, { useRef, useEffect, useState } from "react";
    import ReactDOM from "react-dom";
    import ReactQuill from "react-quill";
    import "react-quill/dist/quill.snow.css";
    
    const modules = {
      toolbar: [
        [{ font: [] }],
        [{ header: [1, 2, 3, 4, 5, 6, false] }],
        ["bold", "italic", "underline", "strike"],
        [{ color: [] }, { background: [] }],
        [{ script: "sub" }, { script: "super" }],
        ["blockquote", "code-block"],
        [{ list: "ordered" }, { list: "bullet" }],
        [{ indent: "-1" }, { indent: "+1" }, { align: [] }],
        ["link", "image", "video"],
        ["clean"],
      ],
    };
    
    const selectionChange = (e: any) => {
      if (e) console.log(e.index, e.length);
    };
    
    interface ShadowWrapperProps {
      children: React.ReactNode;
    }
    
    const ShadowWrapper: React.FC<ShadowWrapperProps> = ({ children }) => {
      const shadowRootRef = useRef<HTMLDivElement | null>(null); // Reference to the div that will contain the shadow DOM
      const [shadowRoot, setShadowRoot] = useState<ShadowRoot | null>(null); // State to store the shadow root
    
      useEffect(() => {
        // Check if the shadowRootRef is set and shadowRoot is not already created
        if (shadowRootRef.current && !shadowRoot) {
          // Attach shadow DOM to the div
          const shadow = shadowRootRef.current.attachShadow({ mode: "open" });
          setShadowRoot(shadow);
    
          // Inject Quill styles into the shadow DOM
          const style = document.createElement("style");
          style.textContent = `
            @import url('https://cdn.quilljs.com/1.3.6/quill.snow.css');
          `;
          shadow.appendChild(style);
        }
      }, [shadowRoot]);
    
      return (
        // Render children inside the shadow DOM
        <div ref={shadowRootRef}>
          {shadowRoot && ReactDOM.createPortal(children, shadowRoot)}
        </div>
      );
    };
    
    const App = () => {
      const initialValue = `<p>highlight this text</p>`;
      const [value, setValue] = useState(initialValue);
    
      const quillRef = useRef<ReactQuill | null>(null);
    
      return (
        <>
          {/* Toolbar outside the Shadow DOM */}
          <div>
            <ReactQuill
              ref={quillRef}
              modules={modules}
              value={value}
              theme="snow"
              onChange={setValue}
              onChangeSelection={selectionChange}
              placeholder="Content goes here..."
            />
          </div>
    
          {/* Shadow wrapper for the Quill editor content */}
          <ShadowWrapper>
            <div>
              <div
                ref={(el) => {
                  // Attach the Quill editor instance to the shadow DOM
                  if (el && quillRef.current) {
                    quillRef.current.getEditor().root = el;
                  }
                }}
                className="editor-container"
              />
            </div>
          </ShadowWrapper>
    
          {/* Preview of the editor content */}
          <div style={{ width: "100%" }}>
            <pre>{value}</pre>
          </div>
        </>
      );
    };
    
    export default App;