Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 31 additions & 69 deletions src/editor/blocks/step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,68 +377,50 @@ 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<HTMLDivElement>(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) {
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.
// 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',
)
) {
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 (
<div ref={anchorRef} style={{ display: "contents" }}>
{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).
<TestStepContent
block={block}
editor={editor}
Expand Down Expand Up @@ -502,15 +484,12 @@ function TestStepContent({
const uploadImage = useStepImageUpload();
const containerRef = useRef<HTMLDivElement>(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) {
Expand All @@ -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 <body>) 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 <body>) 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 <body>, e.g. the link editor) has settled
// before we decide whether editing has really ended.
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Loading