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?
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.
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.
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:
Quill
library.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.