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.
@@ -1,5 +1,5 @@
1
1
  import OverType, { type OverType as OverTypeInstance } from "overtype";
2
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
3
3
  import type { ReactNode, ChangeEvent } from "react";
4
4
  import { useComponentsContext } from "@blocknote/react";
5
5
  import { EditLinkMenuItems } from "@blocknote/react";
@@ -63,7 +63,26 @@ const READ_ONLY_ALLOWED_KEYS = new Set([
63
63
 
64
64
  const AUTOCOMPLETE_TRIGGER_KEYS = new Set([" ", "Space"]);
65
65
 
66
- const markdownParser = (OverType as { MarkdownParser?: { parse: (markdown: string) => string } }).MarkdownParser;
66
+ const markdownParser = (
67
+ OverType as {
68
+ MarkdownParser?: {
69
+ parse: (
70
+ markdown: string,
71
+ activeLine?: number,
72
+ showActiveLineRaw?: boolean,
73
+ instanceHighlighter?: unknown,
74
+ isPreviewMode?: boolean,
75
+ ) => string;
76
+ };
77
+ }
78
+ ).MarkdownParser;
79
+
80
+ /**
81
+ * `useLayoutEffect` that degrades to `useEffect` outside the browser so SSR
82
+ * doesn't warn. Static previews and the OverType mount run pre-paint to avoid a
83
+ * blank/jumping frame on the click→edit swap.
84
+ */
85
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
67
86
 
68
87
  function ImageUploadIcon() {
69
88
  return (
@@ -674,6 +693,136 @@ function markdownToPlainText(markdown: string): string {
674
693
  }
675
694
  }
676
695
 
696
+ const IMAGE_SYNTAX = /!\[([^\]]*)\]\(([^)]+)\)/g;
697
+
698
+ /**
699
+ * Render a run of plain text, turning any `![alt](url)` markdown into real
700
+ * `<img>` elements. Returns a string when there are no images (so simple text
701
+ * stays a plain text node), otherwise a keyed array of strings and images.
702
+ */
703
+ function renderTextWithImages(text: string, key: string): ReactNode {
704
+ if (!text.includes("![")) {
705
+ return text;
706
+ }
707
+ const nodes: ReactNode[] = [];
708
+ IMAGE_SYNTAX.lastIndex = 0;
709
+ let last = 0;
710
+ let part = 0;
711
+ let match: RegExpExecArray | null;
712
+ while ((match = IMAGE_SYNTAX.exec(text)) !== null) {
713
+ if (match.index > last) {
714
+ nodes.push(<Fragment key={`${key}-t${part++}`}>{text.slice(last, match.index)}</Fragment>);
715
+ }
716
+ nodes.push(<img key={`${key}-i${part++}`} src={match[2]} alt={match[1] || "Step image"} />);
717
+ last = match.index + match[0].length;
718
+ }
719
+ if (last < text.length) {
720
+ nodes.push(<Fragment key={`${key}-t${part++}`}>{text.slice(last)}</Fragment>);
721
+ }
722
+ return nodes;
723
+ }
724
+
725
+ /**
726
+ * Render a step field's markdown as a faithful, read-only reading view: the same
727
+ * clean text + bold/italic/code/link decorations + inline images the live
728
+ * OverType preview shows, but as plain React children (no editor, no refs, no
729
+ * imperative DOM — BlockNote's node-view renderer doesn't attach refs the way a
730
+ * normal React commit does, so the content must be declarative).
731
+ *
732
+ * Decorations are applied by slicing the plain text at every formatting/link
733
+ * boundary and wrapping each segment in the same `step-preview-*` elements the
734
+ * live editor uses, so all existing CSS applies unchanged.
735
+ */
736
+ function renderStepFieldContent(value: string): ReactNode {
737
+ const { plainText, links, formatting } = stripInlineMarkdown(value);
738
+ if (!plainText) {
739
+ return null;
740
+ }
741
+ if (formatting.length === 0 && links.length === 0) {
742
+ return renderTextWithImages(plainText, "p");
743
+ }
744
+
745
+ const len = plainText.length;
746
+ const points = new Set<number>([0, len]);
747
+ for (const f of formatting) {
748
+ points.add(Math.max(0, f.start));
749
+ points.add(Math.min(len, f.end));
750
+ }
751
+ for (const l of links) {
752
+ points.add(Math.max(0, l.start));
753
+ points.add(Math.min(len, l.end));
754
+ }
755
+ const sorted = [...points].sort((a, b) => a - b);
756
+
757
+ const out: ReactNode[] = [];
758
+ for (let i = 0; i < sorted.length - 1; i++) {
759
+ const a = sorted[i];
760
+ const b = sorted[i + 1];
761
+ if (a >= b) {
762
+ continue;
763
+ }
764
+ const text = plainText.slice(a, b);
765
+ const fmts = new Set(
766
+ formatting.filter((f) => f.start <= a && f.end >= b).map((f) => f.type),
767
+ );
768
+ const link = links.find((l) => l.start <= a && l.end >= b);
769
+
770
+ let node: ReactNode = renderTextWithImages(text, `s${i}`);
771
+ if (fmts.has("code")) {
772
+ node = <code className="step-preview-code">{node}</code>;
773
+ }
774
+ if (fmts.has("italic")) {
775
+ node = <em className="step-preview-italic">{node}</em>;
776
+ }
777
+ if (fmts.has("bold")) {
778
+ node = <strong className="step-preview-bold">{node}</strong>;
779
+ }
780
+ if (link) {
781
+ node = (
782
+ <a className="step-preview-link" href={link.url}>
783
+ {node}
784
+ </a>
785
+ );
786
+ }
787
+ out.push(<Fragment key={i}>{node}</Fragment>);
788
+ }
789
+ return out;
790
+ }
791
+
792
+ /**
793
+ * Lightweight, non-interactive stand-in for {@link StepField}. Mounts no
794
+ * OverType editor, observers, or event handlers — just a styled
795
+ * `.bn-step-editor--preview` box whose markdown is rendered declaratively. Used
796
+ * for every step that isn't currently being edited.
797
+ */
798
+ export function StepFieldPreview({
799
+ value,
800
+ fieldName,
801
+ multiline = true,
802
+ }: {
803
+ value: string;
804
+ fieldName?: string;
805
+ multiline?: boolean;
806
+ }) {
807
+ const content = useMemo(() => renderStepFieldContent(value), [value]);
808
+
809
+ const editorClassName = [
810
+ "bn-step-editor",
811
+ multiline ? "bn-step-editor--multiline" : "",
812
+ "bn-step-editor--preview",
813
+ ]
814
+ .filter(Boolean)
815
+ .join(" ");
816
+
817
+ return (
818
+ <div className="bn-step-field">
819
+ <div className={editorClassName} data-step-field={fieldName}>
820
+ {content}
821
+ </div>
822
+ </div>
823
+ );
824
+ }
825
+
677
826
  export function StepField({
678
827
  label,
679
828
  showLabel = true,
@@ -712,6 +861,10 @@ export function StepField({
712
861
  const autoFocusRef = useRef(false);
713
862
  const pendingFocusRef = useRef(false);
714
863
  const initialValueRef = useRef(value);
864
+ // Read at OverType init so the editor mounts already-collapsed in compact
865
+ // mode (no tall first frame before the compact layout effect runs).
866
+ const compactModeRef = useRef(compactMode);
867
+ compactModeRef.current = compactMode;
715
868
  const onChangeRef = useRef(onChange);
716
869
  const [plainTextValue, setPlainTextValue] = useState(() => markdownToPlainText(value));
717
870
  const [isFocused, setIsFocused] = useState(false);
@@ -799,7 +952,7 @@ export function StepField({
799
952
  onChangeRef.current?.(markdown);
800
953
  }, [pushUndoSnapshot]);
801
954
 
802
- useEffect(() => {
955
+ useIsomorphicLayoutEffect(() => {
803
956
  const container = editorContainerRef.current;
804
957
  if (!container) {
805
958
  return;
@@ -820,11 +973,16 @@ export function StepField({
820
973
  value: plainText,
821
974
  placeholder: resolvedPlaceholder,
822
975
  autoResize: multiline,
823
- minHeight: multiline ? "4rem" : "2.5rem",
976
+ // Seed the compact floor at init so a clicked step paints already
977
+ // collapsed — the compact layout effect below keeps it in sync after.
978
+ minHeight: compactModeRef.current ? "0px" : multiline ? "4rem" : "2.5rem",
824
979
  padding: "0.5rem 0.75rem",
825
980
  fontSize: "0.95rem",
826
981
  onChange: handleEditorChange,
827
982
  });
983
+ if (compactModeRef.current && instance.textarea) {
984
+ instance.textarea.rows = 1;
985
+ }
828
986
 
829
987
  // Monkey-patch updatePreview to add link highlights
830
988
  const originalUpdatePreview = instance.updatePreview.bind(instance);
@@ -976,7 +1134,7 @@ export function StepField({
976
1134
  // so caret and value survive. Driven by the stable compactMode flag (not
977
1135
  // `compact`) so collapsed and expanded share one height — focusing never
978
1136
  // shifts the layout.
979
- useEffect(() => {
1137
+ useIsomorphicLayoutEffect(() => {
980
1138
  const instance = editorInstanceRef.current as
981
1139
  | (OverTypeInstance & {
982
1140
  options?: { minHeight?: string };
@@ -1296,6 +1296,65 @@ describe("markdownToBlocks", () => {
1296
1296
  expect(roundTrip).toContain(" -b 'gclau=1.1.1681594017.1781077572;'");
1297
1297
  });
1298
1298
 
1299
+ it("does not escape dots inside inline code in step data", () => {
1300
+ const markdown = blocksToMarkdown([
1301
+ {
1302
+ id: "step1",
1303
+ type: "testStep",
1304
+ props: {
1305
+ stepTitle: "Run the request.",
1306
+ stepData: "`curl https://example.com/api`",
1307
+ expectedResult: "",
1308
+ listStyle: "bullet",
1309
+ },
1310
+ content: undefined,
1311
+ children: [],
1312
+ },
1313
+ ]);
1314
+
1315
+ expect(markdown).toBe(
1316
+ [
1317
+ // Title outside code is still escaped.
1318
+ "* Run the request\\.",
1319
+ // Dots inside the inline code span stay literal.
1320
+ " `curl https://example.com/api`",
1321
+ ].join("\n"),
1322
+ );
1323
+ });
1324
+
1325
+ it("does not escape dots when a fenced block opens in the title and the body lands in step data", () => {
1326
+ // The step editor splits a multi-line code block: the opening ``` becomes
1327
+ // the title and the body + closing ``` become the step data.
1328
+ const markdown = blocksToMarkdown([
1329
+ {
1330
+ id: "step1",
1331
+ type: "testStep",
1332
+ props: {
1333
+ stepTitle: "```",
1334
+ stepData: [
1335
+ "curl --location 'https://example.com.ua' \\",
1336
+ "--data-raw '{ \"method\": \"url.method\" }'",
1337
+ "```",
1338
+ ].join("\n"),
1339
+ expectedResult: "",
1340
+ listStyle: "bullet",
1341
+ },
1342
+ content: undefined,
1343
+ children: [],
1344
+ },
1345
+ ]);
1346
+
1347
+ expect(markdown).toBe(
1348
+ [
1349
+ "* ```",
1350
+ // Dots inside the fence stay literal even though it opened in the title.
1351
+ " curl --location 'https://example.com.ua' \\",
1352
+ " --data-raw '{ \"method\": \"url.method\" }'",
1353
+ " ```",
1354
+ ].join("\n"),
1355
+ );
1356
+ });
1357
+
1299
1358
  it("does not include content after a blank line in step data", () => {
1300
1359
  const markdown = [
1301
1360
  "### Steps",
@@ -84,8 +84,44 @@ function escapeMarkdown(text: string): string {
84
84
  return result;
85
85
  }
86
86
 
87
- function escapeStepContent(text: string): string {
88
- return text.replace(/\./g, "\\.");
87
+ // Creates a stateful escaper that escapes dots outside Markdown code while
88
+ // leaving code spans (inline `…`, fenced ``` … ```) verbatim — backslash
89
+ // escapes are ignored inside code, so `\.` would render literally.
90
+ //
91
+ // The state (an open, not-yet-closed backtick run) carries across calls so a
92
+ // fence can be opened in one segment and closed in another. This matters
93
+ // because the step editor splits a multi-line code block across props: the
94
+ // opening ``` lands in `stepTitle` while the body and closing ``` land in
95
+ // `stepData`.
96
+ function makeStepEscaper(): (text: string) => string {
97
+ let fence: string | null = null;
98
+ return (text: string): string => {
99
+ let result = "";
100
+ let i = 0;
101
+ while (i < text.length) {
102
+ if (text[i] === "`") {
103
+ let ticks = 0;
104
+ while (text[i + ticks] === "`") ticks++;
105
+ const run = "`".repeat(ticks);
106
+ result += run;
107
+ i += ticks;
108
+ if (fence === null) {
109
+ fence = run; // open a code span/fence
110
+ } else if (fence === run) {
111
+ fence = null; // matching run closes it
112
+ }
113
+ continue;
114
+ }
115
+ if (fence !== null) {
116
+ result += text[i]; // inside code — copy verbatim
117
+ i++;
118
+ continue;
119
+ }
120
+ result += text[i] === "." ? "\\." : text[i];
121
+ i++;
122
+ }
123
+ return result;
124
+ };
89
125
  }
90
126
 
91
127
  function stripHtmlWrappers(text: string): string {
@@ -461,57 +497,38 @@ function serializeBlock(
461
497
  const normalizedExpectedForCheck = stripExpectedPrefix(expectedResult).trim();
462
498
  const hasContent = stepData.length > 0 || normalizedExpectedForCheck.length > 0;
463
499
 
500
+ // One escaper threads code-fence state from the title into the data: the
501
+ // editor splits a multi-line code block so the opening ``` sits in the
502
+ // title and the body + closing ``` sit in the data. Both must be treated
503
+ // as a single code region so their dots stay literal.
504
+ const escapeBody = makeStepEscaper();
505
+
464
506
  if (normalizedTitle.length > 0 || hasContent) {
465
507
  const listStyle = (block.props as any).listStyle ?? "bullet";
466
508
  const prefix = listStyle === "ordered" ? `${(stepIndex ?? 0) + 1}.` : "*";
467
- lines.push(normalizedTitle.length > 0 ? `${prefix} ${escapeStepContent(normalizedTitle)}` : `${prefix} `);
509
+ lines.push(normalizedTitle.length > 0 ? `${prefix} ${escapeBody(normalizedTitle)}` : `${prefix} `);
468
510
  }
469
511
 
470
512
  if (stepData.length > 0) {
471
- const dataLines = stepData.split(/\r?\n/);
472
- let insideCodeFence = false;
473
- dataLines.forEach((dataLine: string) => {
513
+ const escaped = escapeBody(stepData);
514
+ escaped.split(/\r?\n/).forEach((dataLine: string) => {
474
515
  const trimmedLine = dataLine.trim();
475
- if (trimmedLine.length === 0) {
476
- lines.push(" ");
477
- return;
478
- }
479
- // Don't escape dots inside fenced code blocks (or on the fence lines
480
- // themselves) — Markdown ignores backslash escapes there, so `\.`
481
- // would render literally.
482
- const isFence = trimmedLine.startsWith("```");
483
- const content =
484
- insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
485
- lines.push(` ${content}`);
486
- if (isFence) {
487
- insideCodeFence = !insideCodeFence;
488
- }
516
+ lines.push(trimmedLine.length === 0 ? " " : ` ${trimmedLine}`);
489
517
  });
490
518
  }
491
519
 
492
520
  const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
493
521
  if (normalizedExpected.length > 0) {
494
- const expectedLines = normalizedExpected.split(/\r?\n/);
522
+ const escaped = makeStepEscaper()(normalizedExpected);
495
523
  const label = "*Expected result*";
496
- let insideCodeFence = false;
497
- expectedLines.forEach((expectedLine: string, index: number) => {
524
+ let isFirst = true;
525
+ escaped.split(/\r?\n/).forEach((expectedLine: string) => {
498
526
  const trimmedLine = expectedLine.trim();
499
527
  if (trimmedLine.length === 0) {
500
528
  return;
501
529
  }
502
-
503
- // As with step data, leave dots untouched inside fenced code blocks.
504
- const isFence = trimmedLine.startsWith("```");
505
- const content =
506
- insideCodeFence || isFence ? trimmedLine : escapeStepContent(trimmedLine);
507
- if (index === 0) {
508
- lines.push(` ${label}: ${content}`);
509
- } else {
510
- lines.push(` ${content}`);
511
- }
512
- if (isFence) {
513
- insideCodeFence = !insideCodeFence;
514
- }
530
+ lines.push(isFirst ? ` ${label}: ${trimmedLine}` : ` ${trimmedLine}`);
531
+ isFirst = false;
515
532
  });
516
533
  }
517
534
 
@@ -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;
@@ -1,26 +0,0 @@
1
- /**
2
- * Defers mounting of expensive block content until the element is at (or near)
3
- * the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
4
- * editor) render a cheap placeholder first; the real interactive content is
5
- * mounted only once the block scrolls into view. This keeps pasting/loading a
6
- * large document fast — only the visible steps pay the editor-init cost up
7
- * front, the rest are upgraded lazily as the user scrolls.
8
- *
9
- * Returns a ref to attach to the wrapper element and a boolean that flips to
10
- * `true` once (and stays true — we never tear an editor back down).
11
- *
12
- * `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
13
- * `focus: true` (a click/focus on the placeholder) records that the freshly
14
- * mounted content should take focus, so a single click on a preview starts
15
- * editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
16
- * alone via `shouldFocusOnActivate === false`.
17
- */
18
- export declare function useDeferredMount<T extends HTMLElement>(options?: {
19
- rootMargin?: string;
20
- initiallyActive?: boolean;
21
- }): {
22
- ref: React.RefObject<T | null>;
23
- active: boolean;
24
- activate: (focus?: boolean) => void;
25
- shouldFocusOnActivate: boolean;
26
- };
@@ -1,54 +0,0 @@
1
- import { useEffect, useRef, useState } from "react";
2
- /**
3
- * Defers mounting of expensive block content until the element is at (or near)
4
- * the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
5
- * editor) render a cheap placeholder first; the real interactive content is
6
- * mounted only once the block scrolls into view. This keeps pasting/loading a
7
- * large document fast — only the visible steps pay the editor-init cost up
8
- * front, the rest are upgraded lazily as the user scrolls.
9
- *
10
- * Returns a ref to attach to the wrapper element and a boolean that flips to
11
- * `true` once (and stays true — we never tear an editor back down).
12
- *
13
- * `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
14
- * `focus: true` (a click/focus on the placeholder) records that the freshly
15
- * mounted content should take focus, so a single click on a preview starts
16
- * editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
17
- * alone via `shouldFocusOnActivate === false`.
18
- */
19
- export function useDeferredMount(options = {}) {
20
- const { rootMargin = "300px 0px", initiallyActive = false } = options;
21
- const ref = useRef(null);
22
- const [active, setActive] = useState(initiallyActive);
23
- const activeRef = useRef(active);
24
- activeRef.current = active;
25
- const focusOnActivateRef = useRef(false);
26
- const activate = (focus = false) => {
27
- if (activeRef.current)
28
- return;
29
- if (focus)
30
- focusOnActivateRef.current = true;
31
- setActive(true);
32
- };
33
- useEffect(() => {
34
- if (activeRef.current)
35
- return;
36
- const el = ref.current;
37
- if (!el)
38
- return;
39
- // Environments without IntersectionObserver (or SSR) just mount eagerly.
40
- if (typeof IntersectionObserver === "undefined") {
41
- setActive(true);
42
- return;
43
- }
44
- const observer = new IntersectionObserver((entries) => {
45
- if (entries.some((entry) => entry.isIntersecting)) {
46
- setActive(true);
47
- observer.disconnect();
48
- }
49
- }, { rootMargin });
50
- observer.observe(el);
51
- return () => observer.disconnect();
52
- }, [rootMargin]);
53
- return { ref, active, activate, shouldFocusOnActivate: focusOnActivateRef.current };
54
- }
@@ -1,66 +0,0 @@
1
- import { useEffect, useRef, useState } from "react";
2
-
3
- /**
4
- * Defers mounting of expensive block content until the element is at (or near)
5
- * the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
6
- * editor) render a cheap placeholder first; the real interactive content is
7
- * mounted only once the block scrolls into view. This keeps pasting/loading a
8
- * large document fast — only the visible steps pay the editor-init cost up
9
- * front, the rest are upgraded lazily as the user scrolls.
10
- *
11
- * Returns a ref to attach to the wrapper element and a boolean that flips to
12
- * `true` once (and stays true — we never tear an editor back down).
13
- *
14
- * `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
15
- * `focus: true` (a click/focus on the placeholder) records that the freshly
16
- * mounted content should take focus, so a single click on a preview starts
17
- * editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
18
- * alone via `shouldFocusOnActivate === false`.
19
- */
20
- export function useDeferredMount<T extends HTMLElement>(
21
- options: { rootMargin?: string; initiallyActive?: boolean } = {},
22
- ): {
23
- ref: React.RefObject<T | null>;
24
- active: boolean;
25
- activate: (focus?: boolean) => void;
26
- shouldFocusOnActivate: boolean;
27
- } {
28
- const { rootMargin = "300px 0px", initiallyActive = false } = options;
29
- const ref = useRef<T>(null);
30
- const [active, setActive] = useState(initiallyActive);
31
- const activeRef = useRef(active);
32
- activeRef.current = active;
33
- const focusOnActivateRef = useRef(false);
34
-
35
- const activate = (focus = false) => {
36
- if (activeRef.current) return;
37
- if (focus) focusOnActivateRef.current = true;
38
- setActive(true);
39
- };
40
-
41
- useEffect(() => {
42
- if (activeRef.current) return;
43
- const el = ref.current;
44
- if (!el) return;
45
-
46
- // Environments without IntersectionObserver (or SSR) just mount eagerly.
47
- if (typeof IntersectionObserver === "undefined") {
48
- setActive(true);
49
- return;
50
- }
51
-
52
- const observer = new IntersectionObserver(
53
- (entries) => {
54
- if (entries.some((entry) => entry.isIntersecting)) {
55
- setActive(true);
56
- observer.disconnect();
57
- }
58
- },
59
- { rootMargin },
60
- );
61
- observer.observe(el);
62
- return () => observer.disconnect();
63
- }, [rootMargin]);
64
-
65
- return { ref, active, activate, shouldFocusOnActivate: focusOnActivateRef.current };
66
- }