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..4b21343 --- /dev/null +++ b/src/components/svg/svg_editor.edit_form.tsx @@ -0,0 +1,162 @@ +import { offset, shift, useFloating } from '@floating-ui/react-dom'; +import type { MouseEvent, SubmitEvent } from 'react'; +import { 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', + strategy: 'absolute', + transform: false, + middleware: [ + offset({ crossAxis: 5 }), + shift({ + crossAxis: true, + altBoundary: 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(); + } + + 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(); + } + + function onDialogRef(node: HTMLDialogElement | null) { + node?.showModal(); + floating.refs.setFloating(node); + } + + 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(); -}