From cc2b2508df6c8dcea25c5f230cad765f76acf43c Mon Sep 17 00:00:00 2001 From: tpoisseau <22891227+tpoisseau@users.noreply.github.com> Date: Mon, 23 Feb 2026 10:41:02 +0100 Subject: [PATCH 1/2] feat: use dialog with backdrop to avoid weird interactions behind the form * refactor: move `AtomLabelEditForm` in his own file Closes: https://github.com/zakodium-oss/react-ocl/issues/80 --- src/components/svg/svg_editor.edit_form.tsx | 158 ++++++++++++++++++ src/components/svg/svg_editor.styled.ts | 15 +- src/components/svg/svg_editor.tsx | 176 +------------------- 3 files changed, 174 insertions(+), 175 deletions(-) create mode 100644 src/components/svg/svg_editor.edit_form.tsx diff --git a/src/components/svg/svg_editor.edit_form.tsx b/src/components/svg/svg_editor.edit_form.tsx new file mode 100644 index 0000000..1c132fd --- /dev/null +++ b/src/components/svg/svg_editor.edit_form.tsx @@ -0,0 +1,158 @@ +import { offset, shift, useFloating } from '@floating-ui/react-dom'; +import type { MouseEvent, SubmitEvent } from 'react'; +import { useEffect, useRef } from 'react'; + +import { + AtomLabelEditButtonStyled, + AtomLabelEditDialogStyled, + AtomLabelEditFormStyled, + AtomLabelEditInputStyled, + greekLetters, + primes, +} from './svg_editor.styled.ts'; + +interface AtomLabelEditFormProps { + defaultValue: string; + atomCoords: { x: number; y: number }; + onSubmit: (value: string) => void; + onCancel: () => void; +} + +export function AtomLabelEditForm(props: AtomLabelEditFormProps) { + const { defaultValue, atomCoords, onSubmit, onCancel } = props; + const floating = useFloating({ + placement: 'bottom-start', + middleware: [offset({ crossAxis: 5 }), shift({ crossAxis: true })], + }); + const formRef = useRef(null); + + function onFormSubmit(event: SubmitEvent) { + event.preventDefault(); + event.stopPropagation(); + const formData = new FormData(event.currentTarget); + const value = formData.get('label') as string; + onSubmit(value); + } + + function onCancelClick(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + } + + useEffect(() => { + const dialog = floating.refs.floating.current as HTMLDialogElement | null; + if (!dialog) return; + if (dialog.open) return; + + dialog.showModal(); + }, [floating.refs.floating]); + + function onShortcut(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (!formRef.current) return; + const form = formRef.current; + const input = form.querySelector('input[type="text"]') as HTMLInputElement; + if (!input) return; + + const value = event.currentTarget.textContent.trim(); + input.setRangeText( + value, + input.selectionStart ?? 0, + input.selectionEnd ?? input.value.length, + 'end', + ); + input.focus(); + } + + function handleDialogLightDismiss(event: MouseEvent) { + if (event.target !== floating.refs.floating.current) return; + + // The dialog has no padding, if it is the target of the click, + // it means we click on the backdrop. + onCancel(); + } + + return ( + <> + {/* dom node for floating ui to hook on */} + + + {/* The floating dialog (open at mount with `.showModal()`) */} + + + + + ✔️ + + + ❌ + + + {Object.entries(greekLetters).map(([charName, greekChar]) => ( + + {greekChar} + + ))} + + {Object.entries(primes).map(([primeName, primeChar]) => ( + + {primeChar} + + ))} + + + + ); +} + +function autoSelectText(node: HTMLInputElement | null) { + node?.select(); +} diff --git a/src/components/svg/svg_editor.styled.ts b/src/components/svg/svg_editor.styled.ts index 765d570..78a30fc 100644 --- a/src/components/svg/svg_editor.styled.ts +++ b/src/components/svg/svg_editor.styled.ts @@ -21,11 +21,22 @@ export const primes = { }; const primeNames = Object.keys(primes); +export const AtomLabelEditDialogStyled = styled.dialog` + position: absolute; + padding: 0; + margin: 0; + border: none; + + ::backdrop { + position: fixed; + inset: 0; + background: transparent; + } +`; + export const AtomLabelEditFormStyled = styled.form` --box-size: 24px; - position: absolute; - z-index: 1; line-height: 1; font-size: 16px; display: grid; diff --git a/src/components/svg/svg_editor.tsx b/src/components/svg/svg_editor.tsx index 7369996..e2c3174 100644 --- a/src/components/svg/svg_editor.tsx +++ b/src/components/svg/svg_editor.tsx @@ -1,11 +1,6 @@ -import { offset, shift, useFloating } from '@floating-ui/react-dom'; import type { Molecule } from 'openchemlib'; -import type { - FormEvent, - KeyboardEvent as ReactKeyboardEvent, - MouseEvent, -} from 'react'; -import { useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import type { MouseEvent } from 'react'; +import { useEffect, useMemo, useReducer, useState } from 'react'; import { useRefUpToDate } from '../../hooks/use_ref_up_to_date.js'; import type { BaseEditorProps } from '../types.js'; @@ -18,13 +13,7 @@ import { getPreviousCustomLabel } from './editor/quick_numbering.js'; import type { State } from './editor/reducer.js'; import { stateReducer } from './editor/reducer.js'; import { useHighlight } from './editor/use_highlight.js'; -import { - AtomLabelEditButtonStyled, - AtomLabelEditFormStyled, - AtomLabelEditInputStyled, - greekLetters, - primes, -} from './svg_editor.styled.ts'; +import { AtomLabelEditForm } from './svg_editor.edit_form.tsx'; import type { SvgRendererProps } from './svg_renderer.js'; import { SvgRenderer } from './svg_renderer.js'; @@ -175,162 +164,3 @@ export function SvgEditor(props: SvgEditorProps) { ); } - -interface AtomLabelEditFormProps { - defaultValue: string; - atomCoords: { x: number; y: number }; - onSubmit: (value: string) => void; - onCancel: () => void; -} - -function AtomLabelEditForm(props: AtomLabelEditFormProps) { - const { defaultValue, atomCoords, onSubmit, onCancel } = props; - const floating = useFloating({ - placement: 'bottom-start', - middleware: [offset({ crossAxis: 5 }), shift({ crossAxis: true })], - }); - - function onFormSubmit(event: FormEvent) { - event.preventDefault(); - event.stopPropagation(); - const formData = new FormData(event.currentTarget); - const value = formData.get('label') as string; - onSubmit(value); - } - - function onKeyDown(event: ReactKeyboardEvent) { - if (event.key !== 'Escape') return; - event.preventDefault(); - event.stopPropagation(); - onCancel(); - } - - function onCancelClick(event: MouseEvent) { - event.preventDefault(); - event.stopPropagation(); - onCancel(); - } - - const onCancelRef = useRef(onCancel); - useEffect(() => { - onCancelRef.current = onCancel; - }); - - useEffect(() => { - function onClickOutside(event: PointerEvent) { - const form = floating.refs.floating.current; - if (!form) return; - - if (form === event.currentTarget) return; - if (form.contains(event.target as HTMLElement)) return; - - onCancelRef.current(); - } - - // It seems mounting the form is fast enough to catch the click event that - // triggered the edit mode. - // To avoid this we delay the binding of the event - // handler to the next event loop iteration. - const timeoutId = setTimeout( - () => document.addEventListener('click', onClickOutside), - 0, - ); - return () => { - clearTimeout(timeoutId); - document.removeEventListener('click', onClickOutside); - }; - }, [floating.refs.floating]); - - function onShortcut(event: MouseEvent) { - event.preventDefault(); - event.stopPropagation(); - - if (!floating.refs.floating.current) return; - const form = floating.refs.floating.current; - const input = form.querySelector('input[type="text"]') as HTMLInputElement; - if (!input) return; - - const value = event.currentTarget.textContent.trim(); - input.setRangeText( - value, - input.selectionStart ?? 0, - input.selectionEnd ?? input.value.length, - 'end', - ); - input.focus(); - } - - return ( - <> - {/* dom node for floating ui to hook on */} - - - {/* The floating form */} - - - - ✔️ - - - ❌ - - - {Object.entries(greekLetters).map(([charName, greekChar]) => ( - - {greekChar} - - ))} - - {Object.entries(primes).map(([primeName, primeChar]) => ( - - {primeChar} - - ))} - - - ); -} - -function autoSelectText(node: HTMLInputElement | null) { - node?.select(); -} From c65f989c45b1921a0fcd1fd29fb16528425dc2e9 Mon Sep 17 00:00:00 2001 From: tpoisseau <22891227+tpoisseau@users.noreply.github.com> Date: Thu, 26 Feb 2026 16:41:32 +0100 Subject: [PATCH 2/2] fix: keep floating dialog in parent container Refs: https://github.com/zakodium-oss/react-ocl/pull/85#issuecomment-3960214202 --- src/components/svg/svg_editor.edit_form.tsx | 28 ++++++++++++--------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/components/svg/svg_editor.edit_form.tsx b/src/components/svg/svg_editor.edit_form.tsx index 1c132fd..4b21343 100644 --- a/src/components/svg/svg_editor.edit_form.tsx +++ b/src/components/svg/svg_editor.edit_form.tsx @@ -1,6 +1,6 @@ import { offset, shift, useFloating } from '@floating-ui/react-dom'; import type { MouseEvent, SubmitEvent } from 'react'; -import { useEffect, useRef } from 'react'; +import { useRef } from 'react'; import { AtomLabelEditButtonStyled, @@ -22,7 +22,15 @@ export function AtomLabelEditForm(props: AtomLabelEditFormProps) { const { defaultValue, atomCoords, onSubmit, onCancel } = props; const floating = useFloating({ placement: 'bottom-start', - middleware: [offset({ crossAxis: 5 }), shift({ crossAxis: true })], + strategy: 'absolute', + transform: false, + middleware: [ + offset({ crossAxis: 5 }), + shift({ + crossAxis: true, + altBoundary: true, + }), + ], }); const formRef = useRef(null); @@ -40,14 +48,6 @@ export function AtomLabelEditForm(props: AtomLabelEditFormProps) { onCancel(); } - useEffect(() => { - const dialog = floating.refs.floating.current as HTMLDialogElement | null; - if (!dialog) return; - if (dialog.open) return; - - dialog.showModal(); - }, [floating.refs.floating]); - function onShortcut(event: MouseEvent) { event.preventDefault(); event.stopPropagation(); @@ -75,6 +75,11 @@ export function AtomLabelEditForm(props: AtomLabelEditFormProps) { onCancel(); } + function onDialogRef(node: HTMLDialogElement | null) { + node?.showModal(); + floating.refs.setFloating(node); + } + return ( <> {/* dom node for floating ui to hook on */} @@ -89,8 +94,7 @@ export function AtomLabelEditForm(props: AtomLabelEditFormProps) { {/* The floating dialog (open at mount with `.showModal()`) */}