Reputation: 1360
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
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.
See this working gif example:
Upvotes: 0
Reputation: 981
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.
Upvotes: 1
Reputation: 256
Maybe this `ll help)
Upvotes: 0