testomatio-editor-blocks 0.4.82 → 0.4.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -313,23 +313,62 @@ function TestStepBlock({ block, editor }) {
313
313
  focusOnMountRef.current = true;
314
314
  setEditing(true);
315
315
  }, []);
316
- // Mousedown rather than click so the editor mounts before focus settles, and
317
- // preventDefault so the browser doesn't move focus to <body> when the preview
318
- // (the mousedown target) unmounts mid-click — that stray blur would otherwise
319
- // immediately tear the new editor back down.
320
- const beginEditingFromPointer = useCallback((event) => {
321
- event.preventDefault();
322
- beginEditing();
323
- }, [beginEditing]);
324
316
  const endEditing = useCallback(() => setEditing(false), []);
325
- if (editing) {
326
- // Empty steps mounted eagerly (freshly inserted) auto-focus their title.
327
- // A preview upgraded by a click focuses its field too, so a single click
328
- // starts editing. The editor tears back down to a preview when focus
329
- // leaves the step (see TestStepContent's blur handling).
330
- return (_jsx(TestStepContent, { block: block, editor: editor, stepNumber: stepNumber, viewMode: viewMode, autoFocusEnabled: isEmptyStep, focusOnMount: focusOnMountRef.current, onEditEnd: endEditing }));
331
- }
332
- return (_jsx("div", { className: "bn-teststep-preview-wrapper", tabIndex: 0, onMouseDownCapture: beginEditingFromPointer, onFocusCapture: beginEditing, children: _jsx(TestStepPreview, { blockId: block.id, stepNumber: stepNumber, viewMode: viewMode, stepTitle: block.props.stepTitle || "", stepData: block.props.stepData || "", expectedResult: block.props.expectedResult || "" }) }));
317
+ // `editing` read from a ref so the single mousedown guard below (attached once)
318
+ // always sees the current state without re-binding.
319
+ const editingRef = useRef(editing);
320
+ editingRef.current = editing;
321
+ // Zero-box anchor (display: contents) used only to locate this block's node-view
322
+ // element; it adds no layout box, so the step renders exactly as before.
323
+ const anchorRef = useRef(null);
324
+ // The step is a ProseMirror atom node (content: "none") whose node-view
325
+ // (`.bn-block-content`) is wider than the visible step, so the empty area around
326
+ // it — and, in the collapsed preview, the whole strip to the right of the text —
327
+ // is inert atom surface. A plain mousedown there makes ProseMirror node-select the
328
+ // block (the stray blue outline) instead of editing it. One native listener on the
329
+ // node-view guards every mousedown in both the preview and editing states before
330
+ // ProseMirror sees it. Native (not onMouseDown) is required because React 18
331
+ // delegates at the app root, so its handlers run after ProseMirror's own.
332
+ useEffect(() => {
333
+ var _a;
334
+ const blockEl = (_a = anchorRef.current) === null || _a === void 0 ? void 0 : _a.closest(".bn-block-content");
335
+ if (!blockEl) {
336
+ return;
337
+ }
338
+ const handleMouseDown = (event) => {
339
+ const target = event.target;
340
+ // The real editor surface, form controls, and popovers/dialogs need the
341
+ // native mousedown (caret placement, typing, focus) to behave normally.
342
+ if (target === null || target === void 0 ? void 0 : target.closest('.overtype-container, textarea, input, [role="dialog"], .bn-popover-content, .bn-form-popover')) {
343
+ return;
344
+ }
345
+ // Buttons and links (toolbar, view toggle, action bar, autocomplete
346
+ // suggestions): preventDefault keeps the focused field from blurring — a blur
347
+ // node-selects the atom and can trip the focusout teardown on re-render — but
348
+ // we don't stopPropagation, so their own click/mousedown handlers still run.
349
+ if (target === null || target === void 0 ? void 0 : target.closest("button, a[href]")) {
350
+ event.preventDefault();
351
+ return;
352
+ }
353
+ // Everything else — field labels, the timeline, the header, surrounding
354
+ // padding, and the empty node-view strip — is inert atom chrome. Swallow the
355
+ // mousedown so ProseMirror can't node-select the block; in the collapsed
356
+ // preview a click there means "edit this step", so begin editing.
357
+ event.preventDefault();
358
+ event.stopPropagation();
359
+ if (!editingRef.current) {
360
+ beginEditing();
361
+ }
362
+ };
363
+ blockEl.addEventListener("mousedown", handleMouseDown);
364
+ return () => blockEl.removeEventListener("mousedown", handleMouseDown);
365
+ }, [beginEditing]);
366
+ return (_jsx("div", { ref: anchorRef, style: { display: "contents" }, children: editing ? (
367
+ // Empty steps mounted eagerly (freshly inserted) auto-focus their title. A
368
+ // preview upgraded by a click focuses its field too, so a single click starts
369
+ // editing. The editor tears back down to a preview when focus leaves the step
370
+ // (see TestStepContent's blur handling).
371
+ _jsx(TestStepContent, { block: block, editor: editor, stepNumber: stepNumber, viewMode: viewMode, autoFocusEnabled: isEmptyStep, focusOnMount: focusOnMountRef.current, onEditEnd: endEditing })) : (_jsx("div", { className: "bn-teststep-preview-wrapper", tabIndex: 0, onFocusCapture: beginEditing, children: _jsx(TestStepPreview, { blockId: block.id, stepNumber: stepNumber, viewMode: viewMode, stepTitle: block.props.stepTitle || "", stepData: block.props.stepData || "", expectedResult: block.props.expectedResult || "" }) })) }));
333
372
  }
334
373
  function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled = false, focusOnMount = false, onEditEnd, }) {
335
374
  // When a preview is upgraded by a click, focus its primary field once on
@@ -347,9 +386,19 @@ function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled
347
386
  const uploadImage = useStepImageUpload();
348
387
  const containerRef = useRef(null);
349
388
  const [forceVertical, setForceVertical] = useState(false);
389
+ // Set for a moment while a view-mode toggle is in flight. Switching into or
390
+ // out of the horizontal layout swaps the whole subtree, so the OverType editor
391
+ // unmounts and its blur would otherwise trip the focusout teardown below and
392
+ // collapse the step to a preview. The flag tells the teardown to re-focus the
393
+ // freshly mounted editor instead of ending the edit.
394
+ const viewTransitionRef = useRef(false);
350
395
  useEffect(() => {
351
396
  var _a;
352
- const el = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.parentElement;
397
+ // Measure the block's node-view box (the step's real available width). The
398
+ // immediate parent is a `display: contents` anchor with no box, so its width
399
+ // reads as 0 and would wrongly force the vertical layout — in horizontal view
400
+ // that spurious flip remounts the editor and collapses the step on open.
401
+ const el = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.closest(".bn-block-content");
353
402
  if (!el)
354
403
  return;
355
404
  const observer = new ResizeObserver((entries) => {
@@ -382,12 +431,24 @@ function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled
382
431
  // popover that portals to <body>, e.g. the link editor) has settled
383
432
  // before we decide whether editing has really ended.
384
433
  requestAnimationFrame(() => {
434
+ var _a;
385
435
  const active = document.activeElement;
386
436
  if (active &&
387
437
  (root.contains(active) ||
388
438
  active.closest(".bn-popover-content, .bn-form-popover, [role='dialog']"))) {
389
439
  return;
390
440
  }
441
+ // A view-mode toggle remounted the editor; the blur isn't the user
442
+ // leaving the step. Keep editing and move focus into the freshly
443
+ // mounted layout instead of collapsing to a preview.
444
+ if (viewTransitionRef.current) {
445
+ viewTransitionRef.current = false;
446
+ const live = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.querySelector("textarea");
447
+ if (live) {
448
+ live.focus();
449
+ return;
450
+ }
451
+ }
391
452
  onEditEnd();
392
453
  });
393
454
  };
@@ -502,6 +563,20 @@ function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled
502
563
  else {
503
564
  next = "vertical";
504
565
  }
566
+ // Only switching into or out of the horizontal layout swaps the subtree and
567
+ // remounts the editor; vertical↔compact reuse the same editor instance and
568
+ // never blur. Flag just the remounting transitions so the focusout teardown
569
+ // re-focuses the new editor instead of collapsing — and so a genuine
570
+ // click-away after a non-remounting toggle still tears down normally. Cleared
571
+ // on a timer as a safety net in case the expected blur never arrives.
572
+ if (viewMode === "horizontal" || next === "horizontal") {
573
+ viewTransitionRef.current = true;
574
+ if (typeof window !== "undefined") {
575
+ window.setTimeout(() => {
576
+ viewTransitionRef.current = false;
577
+ }, 300);
578
+ }
579
+ }
505
580
  writeStepViewMode(next);
506
581
  // The shared useStepViewMode hook (in every step, including this one)
507
582
  // listens for this event and re-reads the mode, so we don't track it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.82",
3
+ "version": "0.4.84",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
@@ -1,5 +1,5 @@
1
1
  import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
2
- import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { StepField, StepFieldPreview } from "./stepField";
4
4
  import { StepHorizontalView } from "./stepHorizontalView";
5
5
  import { useStepImageUpload } from "../stepImageUpload";
@@ -375,53 +375,95 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) {
375
375
  setEditing(true);
376
376
  }, []);
377
377
 
378
- // Mousedown rather than click so the editor mounts before focus settles, and
379
- // preventDefault so the browser doesn't move focus to <body> when the preview
380
- // (the mousedown target) unmounts mid-click — that stray blur would otherwise
381
- // immediately tear the new editor back down.
382
- const beginEditingFromPointer = useCallback(
383
- (event: ReactMouseEvent) => {
384
- event.preventDefault();
385
- beginEditing();
386
- },
387
- [beginEditing],
388
- );
389
-
390
378
  const endEditing = useCallback(() => setEditing(false), []);
391
379
 
392
- if (editing) {
393
- // Empty steps mounted eagerly (freshly inserted) auto-focus their title.
394
- // A preview upgraded by a click focuses its field too, so a single click
395
- // starts editing. The editor tears back down to a preview when focus
396
- // leaves the step (see TestStepContent's blur handling).
397
- return (
398
- <TestStepContent
399
- block={block}
400
- editor={editor}
401
- stepNumber={stepNumber}
402
- viewMode={viewMode}
403
- autoFocusEnabled={isEmptyStep}
404
- focusOnMount={focusOnMountRef.current}
405
- onEditEnd={endEditing}
406
- />
407
- );
408
- }
380
+ // `editing` read from a ref so the single mousedown guard below (attached once)
381
+ // always sees the current state without re-binding.
382
+ const editingRef = useRef(editing);
383
+ editingRef.current = editing;
384
+
385
+ // Zero-box anchor (display: contents) used only to locate this block's node-view
386
+ // element; it adds no layout box, so the step renders exactly as before.
387
+ const anchorRef = useRef<HTMLDivElement>(null);
388
+
389
+ // The step is a ProseMirror atom node (content: "none") whose node-view
390
+ // (`.bn-block-content`) is wider than the visible step, so the empty area around
391
+ // it — and, in the collapsed preview, the whole strip to the right of the text —
392
+ // is inert atom surface. A plain mousedown there makes ProseMirror node-select the
393
+ // block (the stray blue outline) instead of editing it. One native listener on the
394
+ // node-view guards every mousedown in both the preview and editing states before
395
+ // ProseMirror sees it. Native (not onMouseDown) is required because React 18
396
+ // delegates at the app root, so its handlers run after ProseMirror's own.
397
+ useEffect(() => {
398
+ const blockEl = anchorRef.current?.closest(".bn-block-content") as HTMLElement | null;
399
+ if (!blockEl) {
400
+ return;
401
+ }
402
+ const handleMouseDown = (event: MouseEvent) => {
403
+ const target = event.target as HTMLElement | null;
404
+ // The real editor surface, form controls, and popovers/dialogs need the
405
+ // native mousedown (caret placement, typing, focus) to behave normally.
406
+ if (
407
+ target?.closest(
408
+ '.overtype-container, textarea, input, [role="dialog"], .bn-popover-content, .bn-form-popover',
409
+ )
410
+ ) {
411
+ return;
412
+ }
413
+ // Buttons and links (toolbar, view toggle, action bar, autocomplete
414
+ // suggestions): preventDefault keeps the focused field from blurring — a blur
415
+ // node-selects the atom and can trip the focusout teardown on re-render — but
416
+ // we don't stopPropagation, so their own click/mousedown handlers still run.
417
+ if (target?.closest("button, a[href]")) {
418
+ event.preventDefault();
419
+ return;
420
+ }
421
+ // Everything else — field labels, the timeline, the header, surrounding
422
+ // padding, and the empty node-view strip — is inert atom chrome. Swallow the
423
+ // mousedown so ProseMirror can't node-select the block; in the collapsed
424
+ // preview a click there means "edit this step", so begin editing.
425
+ event.preventDefault();
426
+ event.stopPropagation();
427
+ if (!editingRef.current) {
428
+ beginEditing();
429
+ }
430
+ };
431
+ blockEl.addEventListener("mousedown", handleMouseDown);
432
+ return () => blockEl.removeEventListener("mousedown", handleMouseDown);
433
+ }, [beginEditing]);
409
434
 
410
435
  return (
411
- <div
412
- className="bn-teststep-preview-wrapper"
413
- tabIndex={0}
414
- onMouseDownCapture={beginEditingFromPointer}
415
- onFocusCapture={beginEditing}
416
- >
417
- <TestStepPreview
418
- blockId={block.id}
419
- stepNumber={stepNumber}
420
- viewMode={viewMode}
421
- stepTitle={(block.props.stepTitle as string) || ""}
422
- stepData={(block.props.stepData as string) || ""}
423
- expectedResult={(block.props.expectedResult as string) || ""}
424
- />
436
+ <div ref={anchorRef} style={{ display: "contents" }}>
437
+ {editing ? (
438
+ // Empty steps mounted eagerly (freshly inserted) auto-focus their title. A
439
+ // preview upgraded by a click focuses its field too, so a single click starts
440
+ // editing. The editor tears back down to a preview when focus leaves the step
441
+ // (see TestStepContent's blur handling).
442
+ <TestStepContent
443
+ block={block}
444
+ editor={editor}
445
+ stepNumber={stepNumber}
446
+ viewMode={viewMode}
447
+ autoFocusEnabled={isEmptyStep}
448
+ focusOnMount={focusOnMountRef.current}
449
+ onEditEnd={endEditing}
450
+ />
451
+ ) : (
452
+ <div
453
+ className="bn-teststep-preview-wrapper"
454
+ tabIndex={0}
455
+ onFocusCapture={beginEditing}
456
+ >
457
+ <TestStepPreview
458
+ blockId={block.id}
459
+ stepNumber={stepNumber}
460
+ viewMode={viewMode}
461
+ stepTitle={(block.props.stepTitle as string) || ""}
462
+ stepData={(block.props.stepData as string) || ""}
463
+ expectedResult={(block.props.expectedResult as string) || ""}
464
+ />
465
+ </div>
466
+ )}
425
467
  </div>
426
468
  );
427
469
  }
@@ -460,9 +502,19 @@ function TestStepContent({
460
502
  const uploadImage = useStepImageUpload();
461
503
  const containerRef = useRef<HTMLDivElement>(null);
462
504
  const [forceVertical, setForceVertical] = useState(false);
505
+ // Set for a moment while a view-mode toggle is in flight. Switching into or
506
+ // out of the horizontal layout swaps the whole subtree, so the OverType editor
507
+ // unmounts and its blur would otherwise trip the focusout teardown below and
508
+ // collapse the step to a preview. The flag tells the teardown to re-focus the
509
+ // freshly mounted editor instead of ending the edit.
510
+ const viewTransitionRef = useRef(false);
463
511
 
464
512
  useEffect(() => {
465
- const el = containerRef.current?.parentElement;
513
+ // Measure the block's node-view box (the step's real available width). The
514
+ // immediate parent is a `display: contents` anchor with no box, so its width
515
+ // reads as 0 and would wrongly force the vertical layout — in horizontal view
516
+ // that spurious flip remounts the editor and collapses the step on open.
517
+ const el = containerRef.current?.closest(".bn-block-content");
466
518
  if (!el) return;
467
519
  const observer = new ResizeObserver((entries) => {
468
520
  for (const entry of entries) {
@@ -504,6 +556,17 @@ function TestStepContent({
504
556
  ) {
505
557
  return;
506
558
  }
559
+ // A view-mode toggle remounted the editor; the blur isn't the user
560
+ // leaving the step. Keep editing and move focus into the freshly
561
+ // mounted layout instead of collapsing to a preview.
562
+ if (viewTransitionRef.current) {
563
+ viewTransitionRef.current = false;
564
+ const live = containerRef.current?.querySelector("textarea");
565
+ if (live) {
566
+ (live as HTMLTextAreaElement).focus();
567
+ return;
568
+ }
569
+ }
507
570
  onEditEnd();
508
571
  });
509
572
  };
@@ -642,6 +705,20 @@ function TestStepContent({
642
705
  } else {
643
706
  next = "vertical";
644
707
  }
708
+ // Only switching into or out of the horizontal layout swaps the subtree and
709
+ // remounts the editor; vertical↔compact reuse the same editor instance and
710
+ // never blur. Flag just the remounting transitions so the focusout teardown
711
+ // re-focuses the new editor instead of collapsing — and so a genuine
712
+ // click-away after a non-remounting toggle still tears down normally. Cleared
713
+ // on a timer as a safety net in case the expected blur never arrives.
714
+ if (viewMode === "horizontal" || next === "horizontal") {
715
+ viewTransitionRef.current = true;
716
+ if (typeof window !== "undefined") {
717
+ window.setTimeout(() => {
718
+ viewTransitionRef.current = false;
719
+ }, 300);
720
+ }
721
+ }
645
722
  writeStepViewMode(next);
646
723
  // The shared useStepViewMode hook (in every step, including this one)
647
724
  // listens for this event and re-reads the mode, so we don't track it