testomatio-editor-blocks 0.4.73 → 0.4.75

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.
@@ -631,6 +631,30 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin
631
631
  vertical-align: 1px;
632
632
  }
633
633
 
634
+ /* Read-only preview parity for compact reading rows: the live compact field
635
+ tightens padding on the OverType layers (above), which the static preview
636
+ doesn't have, so apply the same padding to the flow `--preview` box. */
637
+ .bn-teststep--compact .bn-step-editor--preview {
638
+ padding: 4px 12px;
639
+ }
640
+
641
+ /* Same "Expected" reading badge for the static preview. The static preview is a
642
+ single flow box (not OverType's per-line divs), so the badge goes on its own
643
+ ::before. */
644
+ .bn-teststep--collapsed .bn-step-editor--preview[data-step-field="expected"]::before {
645
+ content: "Expected";
646
+ display: inline-block;
647
+ margin-right: 8px;
648
+ padding: 0 6px;
649
+ border-radius: 4px;
650
+ background: var(--step-bg-light);
651
+ color: var(--step-muted);
652
+ font-size: 11px;
653
+ font-weight: 600;
654
+ line-height: 18px;
655
+ vertical-align: 1px;
656
+ }
657
+
634
658
  .bn-teststep__view-toggle--compact svg {
635
659
  color: var(--step-muted);
636
660
  }
@@ -1432,6 +1456,58 @@ html.dark .bn-step-editor--preview {
1432
1456
  color: #e5e5e5;
1433
1457
  }
1434
1458
 
1459
+ /* Wrapper around a non-edited step's read-only preview. Focusable so Tab enters
1460
+ a step (which mounts its editor); no lingering outline since focus moves to
1461
+ the freshly-mounted field immediately. */
1462
+ .bn-teststep-preview-wrapper {
1463
+ outline: none;
1464
+ }
1465
+
1466
+ /* Inline decorations inside the read-only preview mirror the live OverType
1467
+ preview, which uses the same step-preview-* classes. The live rules are
1468
+ scoped to `.overtype-wrapper .overtype-preview` (which the static preview
1469
+ doesn't render), so the same declarations are repeated here for the flow
1470
+ `--preview` container. */
1471
+ .bn-step-editor--preview a.step-preview-link {
1472
+ color: #4f46e5;
1473
+ text-decoration: underline;
1474
+ pointer-events: none;
1475
+ }
1476
+
1477
+ .bn-step-editor--preview strong.step-preview-bold {
1478
+ -webkit-text-stroke: 0.5px currentColor;
1479
+ font-weight: inherit;
1480
+ color: inherit;
1481
+ }
1482
+
1483
+ .bn-step-editor--preview em.step-preview-italic {
1484
+ font-style: italic;
1485
+ color: inherit;
1486
+ }
1487
+
1488
+ .bn-step-editor--preview code.step-preview-code {
1489
+ background-color: transparent;
1490
+ font-family: inherit;
1491
+ font-size: inherit;
1492
+ color: rgb(146, 64, 14);
1493
+ }
1494
+
1495
+ .bn-step-editor--preview img {
1496
+ display: block;
1497
+ max-width: 100%;
1498
+ border-radius: 0.65rem;
1499
+ margin: 0.5rem 0;
1500
+ pointer-events: none;
1501
+ }
1502
+
1503
+ html.dark .bn-step-editor--preview code.step-preview-code {
1504
+ color: rgba(251, 191, 36, 1);
1505
+ }
1506
+
1507
+ html.dark .bn-step-editor--preview a.step-preview-link {
1508
+ color: rgba(129, 140, 248, 1);
1509
+ }
1510
+
1435
1511
  .bn-step-editor.bn-step-editor--focused {
1436
1512
  outline: none;
1437
1513
  box-shadow: none;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.73",
3
+ "version": "0.4.75",
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,8 +1,7 @@
1
1
  import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
2
- import { useCallback, useEffect, useMemo, useRef, useState, type FocusEvent } from "react";
3
- import { StepField } from "./stepField";
2
+ import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
3
+ import { StepField, StepFieldPreview } from "./stepField";
4
4
  import { StepHorizontalView } from "./stepHorizontalView";
5
- import { useDeferredMount } from "./useDeferredMount";
6
5
  import { useStepImageUpload } from "../stepImageUpload";
7
6
  import type { StepSuggestion } from "../stepAutocomplete";
8
7
 
@@ -50,6 +49,36 @@ const writeStepViewMode = (mode: StepViewMode) => {
50
49
  }
51
50
  };
52
51
 
52
+ /**
53
+ * Subscribes to the globally-shared step view mode. The mode lives in
54
+ * localStorage and changes are broadcast via the `bn-step-view-mode` event
55
+ * (same tab) and the `storage` event (other tabs), so toggling it on any step
56
+ * re-renders every step — including the read-only previews — into the new mode.
57
+ */
58
+ function useStepViewMode(): StepViewMode {
59
+ const [viewMode, setViewMode] = useState<StepViewMode>(() => readStepViewMode());
60
+ useEffect(() => {
61
+ if (typeof window === "undefined") {
62
+ return;
63
+ }
64
+ const handleStorage = (event: StorageEvent) => {
65
+ if (event.key === VIEW_MODE_KEY) {
66
+ setViewMode(readStepViewMode());
67
+ }
68
+ };
69
+ const handleLocal = () => {
70
+ setViewMode(readStepViewMode());
71
+ };
72
+ window.addEventListener("storage", handleStorage);
73
+ window.addEventListener("bn-step-view-mode", handleLocal as EventListener);
74
+ return () => {
75
+ window.removeEventListener("storage", handleStorage);
76
+ window.removeEventListener("bn-step-view-mode", handleLocal as EventListener);
77
+ };
78
+ }, []);
79
+ return viewMode;
80
+ }
81
+
53
82
  /**
54
83
  * Returns true when a normalised (lowercased, trailing-punctuation-stripped)
55
84
  * heading text looks like a "Steps" heading.
@@ -251,76 +280,64 @@ export function computeStepNumber(allBlocks: any[], blockId: string): number {
251
280
  return count;
252
281
  }
253
282
 
254
- /** Strip the most common inline markdown markers for a readable static preview. */
255
- function stripMarkdownForPreview(text: string): string {
256
- return text
257
- .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
258
- .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
259
- .replace(/(\*\*|__|\*|_|~~|`)/g, "")
260
- .replace(/<\/?[^>]+>/g, "")
261
- .trim();
262
- }
263
-
264
283
  /**
265
- * Cheap static stand-in shown before a step's interactive editor is mounted.
266
- * Mirrors the real step's structure/typography so the document height stays
267
- * stable and so it reads correctly during the brief window before upgrade.
284
+ * Read-only stand-in rendered for every step that isn't currently being edited.
285
+ * It mirrors the live step's structure for the active view mode and renders each
286
+ * field via {@link StepFieldPreview} a faithful, formatted reading view with
287
+ * no OverType editor. Only the focused step ever mounts an editor, so scrolling
288
+ * never mounts/tears down editors and the list stays flicker-free.
289
+ *
290
+ * Field visibility is gated on the raw trimmed props (matching the live step's
291
+ * `isDataVisible`/`isExpectedVisible`) so the same fields appear in both states.
268
292
  */
269
293
  function TestStepPreview({
270
294
  blockId,
271
295
  stepNumber,
296
+ viewMode,
272
297
  stepTitle,
273
298
  stepData,
274
299
  expectedResult,
275
300
  }: {
276
301
  blockId: string;
277
302
  stepNumber: number;
303
+ viewMode: StepViewMode;
278
304
  stepTitle: string;
279
305
  stepData: string;
280
306
  expectedResult: string;
281
307
  }) {
282
- const titleText = stripMarkdownForPreview(stepTitle);
283
- const dataText = stripMarkdownForPreview(stepData);
284
- const expectedText = stripMarkdownForPreview(expectedResult);
308
+ const compactMode = viewMode === "compact";
309
+ const hasData = stepData.trim().length > 0;
310
+ const hasExpected = expectedResult.trim().length > 0;
285
311
 
286
312
  return (
287
- <div className="bn-teststep" data-block-id={blockId}>
313
+ <div
314
+ className={`bn-teststep${compactMode ? " bn-teststep--compact bn-teststep--collapsed" : ""}`}
315
+ data-block-id={blockId}
316
+ >
288
317
  <div className="bn-teststep__timeline">
289
318
  <span className="bn-teststep__number">{stepNumber}</span>
290
319
  <div className="bn-teststep__line" />
291
320
  </div>
292
321
  <div className="bn-teststep__content">
293
- <div className="bn-teststep__header">
294
- <span className="bn-teststep__title">Step</span>
295
- </div>
296
- <div className="bn-step-field">
297
- <div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
298
- {titleText || " "}
299
- </div>
300
- </div>
301
- {dataText ? (
302
- <div className="bn-step-field">
303
- <div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
304
- {dataText}
305
- </div>
306
- </div>
307
- ) : null}
308
- {expectedText ? (
309
- <div className="bn-step-field">
310
- <div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
311
- {expectedText}
312
- </div>
322
+ {!compactMode && (
323
+ <div className="bn-teststep__header">
324
+ <span className="bn-teststep__title">Step</span>
313
325
  </div>
314
- ) : null}
326
+ )}
327
+ <StepFieldPreview value={stepTitle} fieldName="title" />
328
+ {hasData ? <StepFieldPreview value={stepData} fieldName="data" /> : null}
329
+ {hasExpected ? <StepFieldPreview value={expectedResult} fieldName="expected" /> : null}
315
330
  </div>
316
331
  </div>
317
332
  );
318
333
  }
319
334
 
320
335
  /**
321
- * Wrapper that defers mounting the (expensive) interactive step editor until
322
- * the block scrolls into view. Off-screen steps render {@link TestStepPreview}
323
- * instead, which is what keeps pasting/loading a large test document fast.
336
+ * Wrapper that mounts the (expensive) interactive step editor only while the
337
+ * step is being edited. Every other step renders the read-only
338
+ * {@link TestStepPreview}, so a document of any size keeps at most one OverType
339
+ * editor alive — scrolling never mounts or tears down editors, which is what
340
+ * keeps the list flicker-free (and pasting/loading a large document fast).
324
341
  *
325
342
  * The step number is tracked here and pushed down as a prop. We subscribe to
326
343
  * editor changes but bail out of re-rendering when the number is unchanged, so
@@ -329,14 +346,17 @@ function TestStepPreview({
329
346
  function TestStepBlock({ block, editor }: { block: any; editor: any }) {
330
347
  // An empty step is almost always a freshly-inserted one that needs to focus
331
348
  // its title immediately, so mount its real editor eagerly. Steps with content
332
- // (e.g. from a large paste) can safely start as a cheap preview.
349
+ // start as a cheap read-only preview and upgrade on click/focus.
333
350
  const isEmptyStep =
334
351
  !((block.props.stepTitle as string) || "") &&
335
352
  !((block.props.stepData as string) || "") &&
336
353
  !((block.props.expectedResult as string) || "");
337
- const { ref, active, activate, shouldFocusOnActivate } = useDeferredMount<HTMLDivElement>({
338
- initiallyActive: isEmptyStep,
339
- });
354
+ const viewMode = useStepViewMode();
355
+ const [editing, setEditing] = useState(isEmptyStep);
356
+ // Set when editing begins from a click/focus on the preview, so the freshly
357
+ // mounted editor takes focus (a single click starts editing). Cleared after
358
+ // the editor consumes it.
359
+ const focusOnMountRef = useRef(false);
340
360
  const [stepNumber, setStepNumber] = useState(() =>
341
361
  computeStepNumber(editor.document, block.id),
342
362
  );
@@ -350,31 +370,54 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) {
350
370
  setStepNumber((prev) => (prev === next ? prev : next));
351
371
  }, editor);
352
372
 
353
- if (active) {
373
+ const beginEditing = useCallback(() => {
374
+ focusOnMountRef.current = true;
375
+ setEditing(true);
376
+ }, []);
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
+ const endEditing = useCallback(() => setEditing(false), []);
391
+
392
+ if (editing) {
354
393
  // Empty steps mounted eagerly (freshly inserted) auto-focus their title.
355
394
  // A preview upgraded by a click focuses its field too, so a single click
356
- // starts editing. Steps upgraded passively (scroll-into-view, hover
357
- // pre-warm) must never steal focus.
395
+ // starts editing. The editor tears back down to a preview when focus
396
+ // leaves the step (see TestStepContent's blur handling).
358
397
  return (
359
398
  <TestStepContent
360
399
  block={block}
361
400
  editor={editor}
362
401
  stepNumber={stepNumber}
402
+ viewMode={viewMode}
363
403
  autoFocusEnabled={isEmptyStep}
364
- focusOnMount={shouldFocusOnActivate}
404
+ focusOnMount={focusOnMountRef.current}
405
+ onEditEnd={endEditing}
365
406
  />
366
407
  );
367
408
  }
368
409
 
369
410
  return (
370
411
  <div
371
- ref={ref}
372
- onMouseDownCapture={() => activate(true)}
373
- onFocusCapture={() => activate(true)}
412
+ className="bn-teststep-preview-wrapper"
413
+ tabIndex={0}
414
+ onMouseDownCapture={beginEditingFromPointer}
415
+ onFocusCapture={beginEditing}
374
416
  >
375
417
  <TestStepPreview
376
418
  blockId={block.id}
377
419
  stepNumber={stepNumber}
420
+ viewMode={viewMode}
378
421
  stepTitle={(block.props.stepTitle as string) || ""}
379
422
  stepData={(block.props.stepData as string) || ""}
380
423
  expectedResult={(block.props.expectedResult as string) || ""}
@@ -387,14 +430,18 @@ function TestStepContent({
387
430
  block,
388
431
  editor,
389
432
  stepNumber,
433
+ viewMode,
390
434
  autoFocusEnabled = false,
391
435
  focusOnMount = false,
436
+ onEditEnd,
392
437
  }: {
393
438
  block: any;
394
439
  editor: any;
395
440
  stepNumber: number;
441
+ viewMode: StepViewMode;
396
442
  autoFocusEnabled?: boolean;
397
443
  focusOnMount?: boolean;
444
+ onEditEnd?: () => void;
398
445
  }) {
399
446
  // When a preview is upgraded by a click, focus its primary field once on
400
447
  // mount so a single click starts editing (caret at end).
@@ -411,12 +458,8 @@ function TestStepContent({
411
458
  const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
412
459
  const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
413
460
  const uploadImage = useStepImageUpload();
414
- const [viewMode, setViewMode] = useState<StepViewMode>(() => readStepViewMode());
415
461
  const containerRef = useRef<HTMLDivElement>(null);
416
462
  const [forceVertical, setForceVertical] = useState(false);
417
- // In compact mode each step collapses to a reading-focused row and only
418
- // expands to the full editing layout while one of its fields has focus.
419
- const [expanded, setExpanded] = useState(false);
420
463
 
421
464
  useEffect(() => {
422
465
  const el = containerRef.current?.parentElement;
@@ -432,29 +475,41 @@ function TestStepContent({
432
475
 
433
476
  const compactMode = viewMode === "compact";
434
477
  const effectiveHorizontal = viewMode === "horizontal" && !forceVertical;
435
- // Compact steps render the vertical layout but collapse their chrome until
436
- // a field gains focus, at which point the step expands to "normal" editing.
437
- const compactCollapsed = compactMode && !expanded;
438
-
478
+ // A mounted step is, by definition, the one being edited, so it always
479
+ // shows the full editing layout the collapsed reading row is now the
480
+ // read-only preview's job.
481
+ const compactCollapsed = false;
482
+
483
+ // Tear the editor back down to the read-only preview once focus leaves the
484
+ // whole step. Re-checked on the next frame so transient blurs (clicking a
485
+ // toolbar button, or the link popover that portals to <body>) don't
486
+ // collapse an active edit. Edits persist to block props on change, so
487
+ // unmounting never loses data. Re-bound when the layout (and thus the root
488
+ // element) changes so it always listens on the live root.
439
489
  useEffect(() => {
440
- if (typeof window === "undefined") {
490
+ const root = containerRef.current;
491
+ if (!root || !onEditEnd) {
441
492
  return;
442
493
  }
443
- const handleStorage = (event: StorageEvent) => {
444
- if (event.key === VIEW_MODE_KEY) {
445
- setViewMode(readStepViewMode());
446
- }
447
- };
448
- const handleLocal = () => {
449
- setViewMode(readStepViewMode());
450
- };
451
- window.addEventListener("storage", handleStorage);
452
- window.addEventListener("bn-step-view-mode", handleLocal as EventListener);
453
- return () => {
454
- window.removeEventListener("storage", handleStorage);
455
- window.removeEventListener("bn-step-view-mode", handleLocal as EventListener);
494
+ const handleFocusOut = () => {
495
+ // Defer to the next frame so focus moving *within* the step (or to a
496
+ // popover that portals to <body>, e.g. the link editor) has settled
497
+ // before we decide whether editing has really ended.
498
+ requestAnimationFrame(() => {
499
+ const active = document.activeElement;
500
+ if (
501
+ active &&
502
+ (root.contains(active) ||
503
+ active.closest(".bn-popover-content, .bn-form-popover, [role='dialog']"))
504
+ ) {
505
+ return;
506
+ }
507
+ onEditEnd();
508
+ });
456
509
  };
457
- }, []);
510
+ root.addEventListener("focusout", handleFocusOut);
511
+ return () => root.removeEventListener("focusout", handleFocusOut);
512
+ }, [onEditEnd, effectiveHorizontal]);
458
513
 
459
514
  const combinedStepValue = useMemo(() => {
460
515
  if (!stepData) {
@@ -588,34 +643,14 @@ function TestStepContent({
588
643
  next = "vertical";
589
644
  }
590
645
  writeStepViewMode(next);
591
- setViewMode(next);
646
+ // The shared useStepViewMode hook (in every step, including this one)
647
+ // listens for this event and re-reads the mode, so we don't track it
648
+ // locally here.
592
649
  if (typeof window !== "undefined") {
593
650
  window.dispatchEvent(new Event("bn-step-view-mode"));
594
651
  }
595
652
  }, [viewMode, forceVertical]);
596
653
 
597
- const handleContentFocusCapture = useCallback(() => {
598
- if (viewMode === "compact") {
599
- setExpanded(true);
600
- }
601
- }, [viewMode]);
602
-
603
- const handleContentBlurCapture = useCallback(
604
- (event: FocusEvent<HTMLDivElement>) => {
605
- if (viewMode !== "compact") {
606
- return;
607
- }
608
- // Keep the step expanded while focus stays inside it (e.g. moving to a
609
- // toolbar or action button); collapse only when focus leaves entirely.
610
- const nextTarget = event.relatedTarget as Node | null;
611
- if (nextTarget && event.currentTarget.contains(nextTarget)) {
612
- return;
613
- }
614
- setExpanded(false);
615
- },
616
- [viewMode],
617
- );
618
-
619
654
  const [dataFocusSignal] = useState(0);
620
655
  const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);
621
656
 
@@ -694,11 +729,7 @@ function TestStepContent({
694
729
  <span className="bn-teststep__number">{stepNumber}</span>
695
730
  <div className="bn-teststep__line" />
696
731
  </div>
697
- <div
698
- className="bn-teststep__content"
699
- onFocus={handleContentFocusCapture}
700
- onBlur={handleContentBlurCapture}
701
- >
732
+ <div className="bn-teststep__content">
702
733
  <div className="bn-teststep__header">
703
734
  {!compactMode && <span className="bn-teststep__title">Step</span>}
704
735
  {viewToggleButton}