From f7fdfbd05a31987dc0b88bbf9433dda7da7f6d3e Mon Sep 17 00:00:00 2001 From: littlefrontender Date: Fri, 10 Jul 2026 15:42:55 +0400 Subject: [PATCH] feat: improve editing experience in TestStepBlock component - Refactored the editing logic to use a ref for the editing state, allowing for more efficient handling of mouse events. - Introduced an anchor ref to manage the block's node-view element without affecting layout. - Enhanced mousedown event handling to prevent unintended node selection and improve user interaction. - Updated rendering logic to conditionally display the editing interface or preview based on the editing state. - Added a view transition ref to maintain focus during layout changes, preventing premature editing termination. --- src/editor/blocks/step.tsx | 161 +++++++++++++++++++++++++++---------- 1 file changed, 117 insertions(+), 44 deletions(-) diff --git a/src/editor/blocks/step.tsx b/src/editor/blocks/step.tsx index 6a7c26b..3168719 100644 --- a/src/editor/blocks/step.tsx +++ b/src/editor/blocks/step.tsx @@ -1,5 +1,5 @@ import { createReactBlockSpec, useEditorChange } from "@blocknote/react"; -import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { StepField, StepFieldPreview } from "./stepField"; import { StepHorizontalView } from "./stepHorizontalView"; import { useStepImageUpload } from "../stepImageUpload"; @@ -375,53 +375,95 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) { setEditing(true); }, []); - // Mousedown rather than click so the editor mounts before focus settles, and - // preventDefault so the browser doesn't move focus to when the preview - // (the mousedown target) unmounts mid-click — that stray blur would otherwise - // immediately tear the new editor back down. - const beginEditingFromPointer = useCallback( - (event: ReactMouseEvent) => { - event.preventDefault(); - beginEditing(); - }, - [beginEditing], - ); - const endEditing = useCallback(() => setEditing(false), []); - if (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). - return ( - - ); - } + // `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. + 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. + useEffect(() => { + const blockEl = anchorRef.current?.closest(".bn-block-content") as HTMLElement | null; + if (!blockEl) { + return; + } + 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. + if ( + target?.closest( + '.overtype-container, textarea, input, [role="dialog"], .bn-popover-content, .bn-form-popover', + ) + ) { + 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. + 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. + event.preventDefault(); + event.stopPropagation(); + if (!editingRef.current) { + beginEditing(); + } + }; + blockEl.addEventListener("mousedown", handleMouseDown); + return () => blockEl.removeEventListener("mousedown", handleMouseDown); + }, [beginEditing]); 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). + + ) : ( +
+ +
+ )}
); } @@ -460,6 +502,12 @@ function TestStepContent({ const uploadImage = useStepImageUpload(); const containerRef = useRef(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; @@ -504,6 +552,17 @@ 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; + } + } onEditEnd(); }); }; @@ -642,6 +701,20 @@ 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