fix: 🐛 update focus on @tiptap editor

This commit is contained in:
Alexis Gousseau 2026-02-06 13:07:57 +01:00
parent a146824ff3
commit 3f06aa64c6
3 changed files with 123 additions and 50 deletions

View file

@ -9,6 +9,7 @@
.tiptap {
@apply px-3 py-2;
display: flow-root;
min-height: 180px;
.link {

View file

@ -7,6 +7,7 @@ $tertiary-color: #222222;
font-size: 14px;
color: $secondary-color;
line-height: 1.75;
display: flow-root;
}
.description.small {

View file

@ -87,7 +87,7 @@ const PLACEMENT_STYLES: Record<PlacementValue, string> = {
};
export default function ImageNodeView(props: NodeViewProps) {
const { node, updateAttributes, selected } = props;
const { node, updateAttributes, editor, selected } = props;
const attrs = node.attrs as ImageAttrs;
const imgRef = useRef<HTMLImageElement>(null);
@ -96,7 +96,10 @@ export default function ImageNodeView(props: NodeViewProps) {
startWidth: number;
ratio: number;
} | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const isInteractingWithChildren = useRef<boolean>(false);
const [editorFocused, setEditorFocused] = useState<boolean>(false);
const [isResizing, setIsResizing] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
// Value of errors.
@ -131,9 +134,21 @@ export default function ImageNodeView(props: NodeViewProps) {
const transValidationAlt = transValidation(transAlt);
const transValidationCaption = transValidation(transCaption);
/**
* Handle click on specific children.
*
* @return void
*/
const handleClickOnChildren = (): void => {
isInteractingWithChildren.current = true;
requestAnimationFrame(() => {
isInteractingWithChildren.current = false;
});
};
/**
* Get the current placement of the image (align or wrap or null).
*
*
* @return PlacementValue | null
*/
const getCurrentPlacement = (): PlacementValue | null => {
@ -148,8 +163,8 @@ export default function ImageNodeView(props: NodeViewProps) {
/**
* Update attribute when changing placement.
*
* @param PlacementValue value
*
* @param PlacementValue value
*/
const handlePlacementChange = (value: PlacementValue) => {
updateAttributes({
@ -158,6 +173,7 @@ export default function ImageNodeView(props: NodeViewProps) {
? (value.split("-")[1] as FloatOption)
: null,
});
setEditorFocused(false);
};
/**
@ -241,11 +257,10 @@ export default function ImageNodeView(props: NodeViewProps) {
try {
// Validate data.
const parsedZod = inputTextSchema
.parse({
alt: inputText.alt,
caption: inputText.caption,
});
const parsedZod = inputTextSchema.parse({
alt: inputText.alt,
caption: inputText.caption,
});
// Update data of the image in @tiptap editor.
updateAttributes({
@ -257,31 +272,31 @@ export default function ImageNodeView(props: NodeViewProps) {
setNewErrors(error);
}
};
/**
* Set new errors from object errors
*
* @param err unknown
* @return void
*/
const setNewErrors = (err: unknown): void => {
const fieldErrors: typeof errors = {};
const fieldMapping: Record<string, string> = {
alt: "alt",
caption: "caption",
};
if (err instanceof z.ZodError) {
err.issues.forEach((error) => {
const key = error.path[0] as string | undefined;
const mappedKey =
(key && fieldMapping[key]) ??
(error.path.length === 0 ? "form" : undefined);
if (mappedKey) fieldErrors[mappedKey] = error.message;
});
setErrors(fieldErrors);
return;
}
/**
* Set new errors from object errors
*
* @param err unknown
* @return void
*/
const setNewErrors = (err: unknown): void => {
const fieldErrors: typeof errors = {};
const fieldMapping: Record<string, string> = {
alt: "alt",
caption: "caption",
};
if (err instanceof z.ZodError) {
err.issues.forEach((error) => {
const key = error.path[0] as string | undefined;
const mappedKey =
(key && fieldMapping[key]) ??
(error.path.length === 0 ? "form" : undefined);
if (mappedKey) fieldErrors[mappedKey] = error.message;
});
setErrors(fieldErrors);
return;
}
};
/**
* Close modal and reset component.
@ -291,8 +306,49 @@ export default function ImageNodeView(props: NodeViewProps) {
*/
const closeModal = (status: boolean = false): void => {
setOpen(status);
setEditorFocused(false);
};
/**
* Handle focus on editor.
*
* @return void
*/
const handleFocus = (): void => {
setEditorFocused(true);
};
/**
* Handle blur on editor.
*
* @return void
*/
const handleBlur = ({ event }: { event?: FocusEvent }): void => {
if (isInteractingWithChildren.current) {
return;
}
const relatedTarget = event?.relatedTarget as HTMLElement | null;
if (relatedTarget && wrapperRef.current?.contains(relatedTarget)) {
return;
}
setEditorFocused(false);
};
// Update the focus on @tiptap editor.
useEffect(() => {
if (!editor) return;
editor.on("focus", handleFocus);
editor.on("blur", handleBlur);
return () => {
editor.off("focus", handleFocus);
editor.off("blur", handleBlur);
};
}, [editor]);
// Reset events when resizing.
useEffect(() => {
if (!isResizing) return;
@ -318,31 +374,38 @@ export default function ImageNodeView(props: NodeViewProps) {
// Set height of header + @tiptap toolbar editor with resize event.
useEffect(() => {
const handleResize = () => {
const currentHeaderHeight = document.querySelector("header")?.clientHeight ?? 0;
const currentToolbarHeight = document.querySelectorAll(".tiptap-toolbar")[0].clientHeight ?? 0;
const currentHeaderHeight =
document.querySelector("header")?.clientHeight ?? 0;
const currentToolbarHeight =
document.querySelectorAll(".tiptap-toolbar")[0].clientHeight ??
0;
setTopSticky(currentHeaderHeight + currentToolbarHeight);
};
window.addEventListener('resize', handleResize);
window.addEventListener("resize", handleResize);
handleResize();
return () => window.removeEventListener('resize', handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return (
<NodeViewWrapper
ref={wrapperRef}
as="figure"
data-type="image"
style={parseStyleString(attrs.style)}
className={cn("relative w-fit", {
"outline outline-2 outline-dashed": selected,
"outline outline-2 outline-dashed": editorFocused && selected,
})}
>
{selected && (
<div className="sticky" style={{
top: topSticky,
}}>
<span className="absolute top-2 left-2 bg-background/90 backdrop-blur-sm rounded-md border shadow-md m-0 p-1">
{editorFocused && selected && (
<div
className="sticky"
style={{
top: topSticky,
}}
>
<span className="absolute top-2 left-2 bg-background backdrop-blur-sm rounded-md border shadow-md m-0 p-1">
{`${attrs.width || imageDetails.width}x${attrs.height || imageDetails.height}`}
</span>
</div>
@ -355,18 +418,23 @@ export default function ImageNodeView(props: NodeViewProps) {
height={attrs.height}
draggable={false}
/>
{selected && (
<div className="absolute bottom-2 right-2 flex flex-col gap-1 bg-background/90 backdrop-blur-sm rounded-md border shadow-md p-1">
{editorFocused && selected && (
<div className="absolute bottom-2 right-2 flex flex-col gap-1 bg-background backdrop-blur-sm rounded-md border shadow-md p-1">
{/* Placement dropdown */}
<Select
value={getCurrentPlacement() || "default"}
onValueChange={handlePlacementChange}
>
<SelectTrigger className="w-10 h-10 hover:bg-accent hover:text-accent-foreground p-0">
<SelectTrigger
className="w-10 h-10 hover:bg-accent hover:text-accent-foreground p-0"
onPointerDown={handleClickOnChildren}
>
<LayoutIcon className="w-4 h-4 mx-auto" />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="default" disabled>{useTrans("back.wysiwyg.image.position")}</SelectItem>
<SelectItem value="default" disabled>
{useTrans("back.wysiwyg.image.position")}
</SelectItem>
{PLACEMENT_OPTIONS.map((value) => {
const [type, dir] = value.split("-") as [
"align" | "float",
@ -394,7 +462,9 @@ export default function ImageNodeView(props: NodeViewProps) {
{value === "float-right" && (
<ArrowRightLeftIcon className="w-4 h-4" />
)}
{useTrans(`back.wysiwyg.image.${str(label).slug().value()}`)}
{useTrans(
`back.wysiwyg.image.${str(label).slug().value()}`,
)}
</div>
</SelectItem>
);
@ -411,6 +481,7 @@ export default function ImageNodeView(props: NodeViewProps) {
e.stopPropagation();
setOpen(true);
}}
onPointerDown={handleClickOnChildren}
>
<ChevronsLeftRightEllipsisIcon className="w-4 h-4" />
</Button>