From 981e2d247ce5a07448ef9da359c456e010bfa8d2 Mon Sep 17 00:00:00 2001 From: littlefrontender Date: Fri, 10 Jul 2026 17:24:42 +0400 Subject: [PATCH] refactor: streamline mouse event handling and editing logic in TestStepBlock - Removed the use of a ref for the editing state, simplifying the editing logic. - Clarified comments regarding the handling of mousedown events to prevent unintended node selection. - Updated the logic for managing the block's node-view element and its interaction with the editing interface. - Enhanced focus management during editing transitions to ensure a smoother user experience. --- src/editor/blocks/step.tsx | 100 ++++++++++++------------------------- 1 file changed, 31 insertions(+), 69 deletions(-) diff --git a/src/editor/blocks/step.tsx b/src/editor/blocks/step.tsx index 3168719..90f8db1 100644 --- a/src/editor/blocks/step.tsx +++ b/src/editor/blocks/step.tsx @@ -377,23 +377,14 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) { const endEditing = useCallback(() => setEditing(false), []); - // `editing` read from a ref so the single mousedown guard below (attached once) - // always sees the current state without re-binding. - const editingRef = useRef(editing); - editingRef.current = editing; - - // Zero-box anchor (display: contents) used only to locate this block's node-view - // element; it adds no layout box, so the step renders exactly as before. + // Zero-box anchor (display: contents) used only to locate this block's node-view. const anchorRef = useRef(null); - // The step is a ProseMirror atom node (content: "none") whose node-view - // (`.bn-block-content`) is wider than the visible step, so the empty area around - // it — and, in the collapsed preview, the whole strip to the right of the text — - // is inert atom surface. A plain mousedown there makes ProseMirror node-select the - // block (the stray blue outline) instead of editing it. One native listener on the - // node-view guards every mousedown in both the preview and editing states before - // ProseMirror sees it. Native (not onMouseDown) is required because React 18 - // delegates at the app root, so its handlers run after ProseMirror's own. + // The step is a ProseMirror atom whose node-view (`.bn-block-content`) is wider + // than the visible step, so the empty area around it — and the strip to the right + // of the collapsed preview — is inert atom surface where a plain mousedown + // node-selects the block. One native listener guards the whole node-view (native, + // because React 18 delegates at the app root, after ProseMirror's own handler). useEffect(() => { const blockEl = anchorRef.current?.closest(".bn-block-content") as HTMLElement | null; if (!blockEl) { @@ -401,8 +392,7 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) { } const handleMouseDown = (event: MouseEvent) => { const target = event.target as HTMLElement | null; - // The real editor surface, form controls, and popovers/dialogs need the - // native mousedown (caret placement, typing, focus) to behave normally. + // Editor surface, form controls and popovers need the native mousedown. if ( target?.closest( '.overtype-container, textarea, input, [role="dialog"], .bn-popover-content, .bn-form-popover', @@ -410,35 +400,27 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) { ) { return; } - // Buttons and links (toolbar, view toggle, action bar, autocomplete - // suggestions): preventDefault keeps the focused field from blurring — a blur - // node-selects the atom and can trip the focusout teardown on re-render — but - // we don't stopPropagation, so their own click/mousedown handlers still run. + // Buttons/links: preventDefault keeps the field focused (no blur → no + // node-select / teardown) but lets their own click handlers run. if (target?.closest("button, a[href]")) { event.preventDefault(); return; } - // Everything else — field labels, the timeline, the header, surrounding - // padding, and the empty node-view strip — is inert atom chrome. Swallow the - // mousedown so ProseMirror can't node-select the block; in the collapsed - // preview a click there means "edit this step", so begin editing. + // Inert chrome (labels, timeline, padding, empty strip): swallow so ProseMirror + // can't node-select; in the collapsed preview a click here means "edit". event.preventDefault(); event.stopPropagation(); - if (!editingRef.current) { + if (!editing) { beginEditing(); } }; blockEl.addEventListener("mousedown", handleMouseDown); return () => blockEl.removeEventListener("mousedown", handleMouseDown); - }, [beginEditing]); + }, [beginEditing, editing]); return (
{editing ? ( - // Empty steps mounted eagerly (freshly inserted) auto-focus their title. A - // preview upgraded by a click focuses its field too, so a single click starts - // editing. The editor tears back down to a preview when focus leaves the step - // (see TestStepContent's blur handling). (null); const [forceVertical, setForceVertical] = useState(false); - // Set for a moment while a view-mode toggle is in flight. Switching into or - // out of the horizontal layout swaps the whole subtree, so the OverType editor - // unmounts and its blur would otherwise trip the focusout teardown below and - // collapse the step to a preview. The flag tells the teardown to re-focus the - // freshly mounted editor instead of ending the edit. - const viewTransitionRef = useRef(false); useEffect(() => { - const el = containerRef.current?.parentElement; + // Measure the block's node-view box (the step's real available width). The + // immediate parent is a `display: contents` anchor with no box, so observe + // the node-view element directly. + const el = containerRef.current?.closest(".bn-block-content"); if (!el) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { @@ -530,16 +509,17 @@ function TestStepContent({ // Tear the editor back down to the read-only preview once focus leaves the // whole step. Re-checked on the next frame so transient blurs (clicking a - // toolbar button, or the link popover that portals to ) don't - // collapse an active edit. Edits persist to block props on change, so - // unmounting never loses data. Re-bound when the layout (and thus the root - // element) changes so it always listens on the live root. + // toolbar button, or the link popover that portals to ) don't collapse + // an active edit. Bound to the block's node-view (stable across layout swaps) + // rather than the step root, so it still fires when a view toggle remounts the + // editor. Edits persist to block props on change, so unmounting never loses data. useEffect(() => { - const root = containerRef.current; + const root = containerRef.current?.closest(".bn-block-content") as HTMLElement | null; if (!root || !onEditEnd) { return; } - const handleFocusOut = () => { + const handleFocusOut = (event: FocusEvent) => { + const blurred = event.target as HTMLElement | null; // Defer to the next frame so focus moving *within* the step (or to a // popover that portals to , e.g. the link editor) has settled // before we decide whether editing has really ended. @@ -552,23 +532,19 @@ function TestStepContent({ ) { return; } - // A view-mode toggle remounted the editor; the blur isn't the user - // leaving the step. Keep editing and move focus into the freshly - // mounted layout instead of collapsing to a preview. - if (viewTransitionRef.current) { - viewTransitionRef.current = false; - const live = containerRef.current?.querySelector("textarea"); - if (live) { - (live as HTMLTextAreaElement).focus(); - return; - } + // A view-mode toggle remounts the editor: the blurred field was removed + // from the DOM (unlike a real click-away, which lands on a live element). + // Keep editing by moving focus into the freshly mounted field. + if (blurred && !blurred.isConnected) { + containerRef.current?.querySelector("textarea")?.focus(); + return; } onEditEnd(); }); }; root.addEventListener("focusout", handleFocusOut); return () => root.removeEventListener("focusout", handleFocusOut); - }, [onEditEnd, effectiveHorizontal]); + }, [onEditEnd]); const combinedStepValue = useMemo(() => { if (!stepData) { @@ -701,20 +677,6 @@ function TestStepContent({ } else { next = "vertical"; } - // Only switching into or out of the horizontal layout swaps the subtree and - // remounts the editor; vertical↔compact reuse the same editor instance and - // never blur. Flag just the remounting transitions so the focusout teardown - // re-focuses the new editor instead of collapsing — and so a genuine - // click-away after a non-remounting toggle still tears down normally. Cleared - // on a timer as a safety net in case the expected blur never arrives. - if (viewMode === "horizontal" || next === "horizontal") { - viewTransitionRef.current = true; - if (typeof window !== "undefined") { - window.setTimeout(() => { - viewTransitionRef.current = false; - }, 300); - } - } writeStepViewMode(next); // The shared useStepViewMode hook (in every step, including this one) // listens for this event and re-reads the mode, so we don't track it