testomatio-editor-blocks 0.4.66 → 0.4.67

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,4 +1,18 @@
1
1
  const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
2
+ // For large pastes, only the first chunk is inserted synchronously (enough to
3
+ // fill the viewport); the remaining blocks are streamed in during idle time so
4
+ // the editor stays responsive and the user sees content immediately instead of
5
+ // the main thread freezing while a thousand-block document is built at once.
6
+ const CHUNK_THRESHOLD = 150;
7
+ const FIRST_CHUNK = 50;
8
+ const REST_CHUNK = 40;
9
+ const scheduleIdle = typeof window !== "undefined" && typeof window.requestIdleCallback === "function"
10
+ ? (cb) => window.requestIdleCallback(() => cb(), { timeout: 200 })
11
+ : (cb) => setTimeout(cb, 0);
12
+ function lastBlockId(blocks) {
13
+ var _a;
14
+ return blocks.length ? (_a = blocks[blocks.length - 1]) === null || _a === void 0 ? void 0 : _a.id : undefined;
15
+ }
2
16
  function isInlineOnlyPaste(plainText, parsedBlocks) {
3
17
  if (parsedBlocks.length !== 1)
4
18
  return false;
@@ -41,21 +55,54 @@ export function createMarkdownPasteHandler(converter) {
41
55
  }
42
56
  const selection = editor.getSelection();
43
57
  const selectedIds = (_h = (_g = selection === null || selection === void 0 ? void 0 : selection.blocks) === null || _g === void 0 ? void 0 : _g.map((block) => block.id).filter((id) => Boolean(id))) !== null && _h !== void 0 ? _h : [];
44
- if (selectedIds.length > 0) {
45
- editor.replaceBlocks(selectedIds, parsedBlocks);
46
- }
47
- else {
58
+ // Insert the initial set of blocks at the paste location, returning the
59
+ // inserted blocks so subsequent chunks can be appended after the last one.
60
+ const insertInitial = (blocksToInsert) => {
61
+ if (selectedIds.length > 0) {
62
+ return editor.replaceBlocks(selectedIds, blocksToInsert).insertedBlocks;
63
+ }
48
64
  const cursorBlock = editor.getTextCursorPosition().block;
49
65
  if (cursorBlock) {
50
- editor.replaceBlocks([cursorBlock.id], parsedBlocks);
66
+ return editor.replaceBlocks([cursorBlock.id], blocksToInsert).insertedBlocks;
51
67
  }
52
- else if (editor.document.length > 0) {
68
+ if (editor.document.length > 0) {
53
69
  const reference = editor.document[editor.document.length - 1];
54
- editor.insertBlocks(parsedBlocks, reference.id, "after");
70
+ return editor.insertBlocks(blocksToInsert, reference.id, "after");
55
71
  }
56
- else {
72
+ return null;
73
+ };
74
+ if (parsedBlocks.length <= CHUNK_THRESHOLD) {
75
+ // Small paste: insert everything in one transaction (original behaviour).
76
+ if (insertInitial(parsedBlocks) === null)
57
77
  return defaultPasteHandler();
58
- }
78
+ }
79
+ else {
80
+ // Large paste: render the first screenful now, stream the rest in idle
81
+ // time so the main thread is never blocked building the whole document.
82
+ const firstChunk = parsedBlocks.slice(0, FIRST_CHUNK);
83
+ const rest = parsedBlocks.slice(FIRST_CHUNK);
84
+ const inserted = insertInitial(firstChunk);
85
+ if (inserted === null)
86
+ return defaultPasteHandler();
87
+ let anchorId = lastBlockId(inserted);
88
+ let cursor = 0;
89
+ const pump = () => {
90
+ var _a;
91
+ if (!anchorId || cursor >= rest.length)
92
+ return;
93
+ const batch = rest.slice(cursor, cursor + REST_CHUNK);
94
+ cursor += REST_CHUNK;
95
+ try {
96
+ const insertedBatch = editor.insertBlocks(batch, anchorId, "after");
97
+ anchorId = (_a = lastBlockId(insertedBatch)) !== null && _a !== void 0 ? _a : anchorId;
98
+ }
99
+ catch {
100
+ return; // stop streaming on any structural error
101
+ }
102
+ if (cursor < rest.length)
103
+ scheduleIdle(pump);
104
+ };
105
+ scheduleIdle(pump);
59
106
  }
60
107
  editor.focus();
61
108
  return true;
@@ -1240,6 +1240,20 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin
1240
1240
  min-height: 4rem;
1241
1241
  }
1242
1242
 
1243
+ /* Static stand-in shown before a step's interactive editor is lazily mounted.
1244
+ Mirrors the OverType inner padding/typography so document height stays stable. */
1245
+ .bn-step-editor--preview {
1246
+ padding: 10px 12px;
1247
+ white-space: pre-wrap;
1248
+ word-break: break-word;
1249
+ color: #262626;
1250
+ cursor: text;
1251
+ }
1252
+
1253
+ html.dark .bn-step-editor--preview {
1254
+ color: #e5e5e5;
1255
+ }
1256
+
1243
1257
  .bn-step-editor.bn-step-editor--focused {
1244
1258
  outline: none;
1245
1259
  box-shadow: none;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.66",
3
+ "version": "0.4.67",
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",
package/src/App.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { BlockNoteView } from "@blocknote/mantine";
3
3
  import {
4
4
  useCreateBlockNote,
@@ -430,22 +430,42 @@ function App() {
430
430
  document.documentElement.classList.toggle("dark", darkMode);
431
431
  }, [darkMode]);
432
432
 
433
+ // Re-serializing the whole document (Markdown + pretty JSON) on every change
434
+ // is wasteful during bursts like a large paste, where the document mutates
435
+ // many times in quick succession (chunked streaming). Debounce so the preview
436
+ // panels update once the document settles instead of on every intermediate
437
+ // edit, keeping the editor responsive.
438
+ const serializeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
433
439
  useEditorChange((editorInstance) => {
434
- try {
435
- const documentBlocks = editorInstance.document as CustomEditorBlock[];
436
- const md = blocksToMarkdown(documentBlocks);
437
- setMarkdown(md);
438
- setBlocksJson(JSON.stringify(documentBlocks, null, 2));
439
- setConversionError(null);
440
- setCopyStatus("idle");
441
- setCopyBlocksStatus("idle");
442
- } catch (error) {
443
- setConversionError(error instanceof Error ? error.message : String(error));
444
- setCopyStatus("idle");
445
- setCopyBlocksStatus("idle");
440
+ if (serializeTimerRef.current !== null) {
441
+ clearTimeout(serializeTimerRef.current);
446
442
  }
443
+ serializeTimerRef.current = setTimeout(() => {
444
+ serializeTimerRef.current = null;
445
+ try {
446
+ const documentBlocks = editorInstance.document as CustomEditorBlock[];
447
+ const md = blocksToMarkdown(documentBlocks);
448
+ setMarkdown(md);
449
+ setBlocksJson(JSON.stringify(documentBlocks, null, 2));
450
+ setConversionError(null);
451
+ setCopyStatus("idle");
452
+ setCopyBlocksStatus("idle");
453
+ } catch (error) {
454
+ setConversionError(error instanceof Error ? error.message : String(error));
455
+ setCopyStatus("idle");
456
+ setCopyBlocksStatus("idle");
457
+ }
458
+ }, 120);
447
459
  }, editor);
448
460
 
461
+ useEffect(() => {
462
+ return () => {
463
+ if (serializeTimerRef.current !== null) {
464
+ clearTimeout(serializeTimerRef.current);
465
+ }
466
+ };
467
+ }, []);
468
+
449
469
  useEffect(() => {
450
470
  if (!editor) {
451
471
  return;
@@ -2,6 +2,7 @@ import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { StepField } from "./stepField";
4
4
  import { StepHorizontalView } from "./stepHorizontalView";
5
+ import { useDeferredMount } from "./useDeferredMount";
5
6
  import { useStepImageUpload } from "../stepImageUpload";
6
7
  import type { StepSuggestion } from "../stepAutocomplete";
7
8
 
@@ -227,27 +228,176 @@ export function addSnippetBlock(editor: {
227
228
  return inserted?.[1]?.id ?? null;
228
229
  }
229
230
 
230
- export const stepBlock = createReactBlockSpec(
231
- {
232
- type: "testStep",
233
- content: "none",
234
- propSchema: {
235
- stepTitle: {
236
- default: "",
237
- },
238
- stepData: {
239
- default: "",
240
- },
241
- expectedResult: {
242
- default: "",
243
- },
244
- listStyle: {
245
- default: "bullet",
246
- },
247
- },
248
- },
249
- {
250
- render: ({ block, editor }) => {
231
+ /**
232
+ * A test step's 1-based position within its group: count back over preceding
233
+ * steps (blank lines don't break the run) until a non-step block.
234
+ */
235
+ export function computeStepNumber(allBlocks: any[], blockId: string): number {
236
+ const blockIndex = allBlocks.findIndex((b) => b.id === blockId);
237
+ if (blockIndex < 0) return 1;
238
+
239
+ let count = 1;
240
+ for (let i = blockIndex - 1; i >= 0; i--) {
241
+ const b = allBlocks[i];
242
+ if (b.type === "testStep") {
243
+ count++;
244
+ } else if (isEmptyParagraph(b)) {
245
+ continue;
246
+ } else {
247
+ break;
248
+ }
249
+ }
250
+ return count;
251
+ }
252
+
253
+ /** Strip the most common inline markdown markers for a readable static preview. */
254
+ function stripMarkdownForPreview(text: string): string {
255
+ return text
256
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
257
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
258
+ .replace(/(\*\*|__|\*|_|~~|`)/g, "")
259
+ .replace(/<\/?[^>]+>/g, "")
260
+ .trim();
261
+ }
262
+
263
+ /**
264
+ * Cheap static stand-in shown before a step's interactive editor is mounted.
265
+ * Mirrors the real step's structure/typography so the document height stays
266
+ * stable and so it reads correctly during the brief window before upgrade.
267
+ */
268
+ function TestStepPreview({
269
+ blockId,
270
+ stepNumber,
271
+ stepTitle,
272
+ stepData,
273
+ expectedResult,
274
+ }: {
275
+ blockId: string;
276
+ stepNumber: number;
277
+ stepTitle: string;
278
+ stepData: string;
279
+ expectedResult: string;
280
+ }) {
281
+ const titleText = stripMarkdownForPreview(stepTitle);
282
+ const dataText = stripMarkdownForPreview(stepData);
283
+ const expectedText = stripMarkdownForPreview(expectedResult);
284
+
285
+ return (
286
+ <div className="bn-teststep" data-block-id={blockId}>
287
+ <div className="bn-teststep__timeline">
288
+ <span className="bn-teststep__number">{stepNumber}</span>
289
+ <div className="bn-teststep__line" />
290
+ </div>
291
+ <div className="bn-teststep__content">
292
+ <div className="bn-teststep__header">
293
+ <span className="bn-teststep__title">Step</span>
294
+ </div>
295
+ <div className="bn-step-field">
296
+ <div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
297
+ {titleText || " "}
298
+ </div>
299
+ </div>
300
+ {dataText ? (
301
+ <div className="bn-step-field">
302
+ <div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
303
+ {dataText}
304
+ </div>
305
+ </div>
306
+ ) : null}
307
+ {expectedText ? (
308
+ <div className="bn-step-field">
309
+ <div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
310
+ {expectedText}
311
+ </div>
312
+ </div>
313
+ ) : null}
314
+ </div>
315
+ </div>
316
+ );
317
+ }
318
+
319
+ /**
320
+ * Wrapper that defers mounting the (expensive) interactive step editor until
321
+ * the block scrolls into view. Off-screen steps render {@link TestStepPreview}
322
+ * instead, which is what keeps pasting/loading a large test document fast.
323
+ *
324
+ * The step number is tracked here and pushed down as a prop. We subscribe to
325
+ * editor changes but bail out of re-rendering when the number is unchanged, so
326
+ * ordinary text edits don't re-render every step in the document.
327
+ */
328
+ function TestStepBlock({ block, editor }: { block: any; editor: any }) {
329
+ // An empty step is almost always a freshly-inserted one that needs to focus
330
+ // its title immediately, so mount its real editor eagerly. Steps with content
331
+ // (e.g. from a large paste) can safely start as a cheap preview.
332
+ const isEmptyStep =
333
+ !((block.props.stepTitle as string) || "") &&
334
+ !((block.props.stepData as string) || "") &&
335
+ !((block.props.expectedResult as string) || "");
336
+ const { ref, active, activate, shouldFocusOnActivate } = useDeferredMount<HTMLDivElement>({
337
+ initiallyActive: isEmptyStep,
338
+ });
339
+ const [stepNumber, setStepNumber] = useState(() =>
340
+ computeStepNumber(editor.document, block.id),
341
+ );
342
+
343
+ useEditorChange(() => {
344
+ // Recompute on change, but bail out of the state update (and therefore the
345
+ // re-render) when the number is unchanged. This is the key win: ordinary
346
+ // text edits leave every step's number untouched, so they don't re-render
347
+ // the whole step list.
348
+ const next = computeStepNumber(editor.document, block.id);
349
+ setStepNumber((prev) => (prev === next ? prev : next));
350
+ }, editor);
351
+
352
+ if (active) {
353
+ // Empty steps mounted eagerly (freshly inserted) auto-focus their title.
354
+ // A preview upgraded by a click focuses its field too, so a single click
355
+ // starts editing. Steps upgraded passively (scroll-into-view, hover
356
+ // pre-warm) must never steal focus.
357
+ return (
358
+ <TestStepContent
359
+ block={block}
360
+ editor={editor}
361
+ stepNumber={stepNumber}
362
+ autoFocusEnabled={isEmptyStep}
363
+ focusOnMount={shouldFocusOnActivate}
364
+ />
365
+ );
366
+ }
367
+
368
+ return (
369
+ <div
370
+ ref={ref}
371
+ onMouseDownCapture={() => activate(true)}
372
+ onFocusCapture={() => activate(true)}
373
+ >
374
+ <TestStepPreview
375
+ blockId={block.id}
376
+ stepNumber={stepNumber}
377
+ stepTitle={(block.props.stepTitle as string) || ""}
378
+ stepData={(block.props.stepData as string) || ""}
379
+ expectedResult={(block.props.expectedResult as string) || ""}
380
+ />
381
+ </div>
382
+ );
383
+ }
384
+
385
+ function TestStepContent({
386
+ block,
387
+ editor,
388
+ stepNumber,
389
+ autoFocusEnabled = false,
390
+ focusOnMount = false,
391
+ }: {
392
+ block: any;
393
+ editor: any;
394
+ stepNumber: number;
395
+ autoFocusEnabled?: boolean;
396
+ focusOnMount?: boolean;
397
+ }) {
398
+ // When a preview is upgraded by a click, focus its primary field once on
399
+ // mount so a single click starts editing (caret at end).
400
+ const mountFocusSignal = focusOnMount ? 1 : 0;
251
401
  const stepTitle = (block.props.stepTitle as string) || "";
252
402
  const stepData = (block.props.stepData as string) || "";
253
403
  const expectedResult = (block.props.expectedResult as string) || "";
@@ -259,7 +409,6 @@ export const stepBlock = createReactBlockSpec(
259
409
  );
260
410
  const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
261
411
  const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
262
- const [documentVersion, setDocumentVersion] = useState(0);
263
412
  const uploadImage = useStepImageUpload();
264
413
  const [viewMode, setViewMode] = useState<StepViewMode>(() => readStepViewMode());
265
414
  const containerRef = useRef<HTMLDivElement>(null);
@@ -279,30 +428,6 @@ export const stepBlock = createReactBlockSpec(
279
428
 
280
429
  const effectiveVertical = forceVertical || viewMode === "vertical";
281
430
 
282
- // Calculate step number based on position in document
283
- const stepNumber = useMemo(() => {
284
- const allBlocks = editor.document;
285
- const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
286
- if (blockIndex < 0) return 1;
287
-
288
- let count = 1;
289
- for (let i = blockIndex - 1; i >= 0; i--) {
290
- const b = allBlocks[i];
291
- if (b.type === "testStep") {
292
- count++;
293
- } else if (isEmptyParagraph(b)) {
294
- continue;
295
- } else {
296
- break;
297
- }
298
- }
299
- return count;
300
- }, [block.id, documentVersion, editor.document]);
301
-
302
- useEditorChange(() => {
303
- setDocumentVersion((version) => version + 1);
304
- }, editor);
305
-
306
431
  useEffect(() => {
307
432
  if (typeof window === "undefined") {
308
433
  return;
@@ -501,6 +626,7 @@ export const stepBlock = createReactBlockSpec(
501
626
  onInsertNextStep={handleInsertNextStep}
502
627
  onFieldFocus={handleFieldFocus}
503
628
  viewToggle={viewToggleButton}
629
+ focusSignal={mountFocusSignal}
504
630
  />
505
631
  );
506
632
  }
@@ -522,7 +648,8 @@ export const stepBlock = createReactBlockSpec(
522
648
  value={stepTitle}
523
649
  placeholder={STEP_TITLE_PLACEHOLDER}
524
650
  onChange={handleStepTitleChange}
525
- autoFocus={stepTitle.length === 0}
651
+ autoFocus={autoFocusEnabled && stepTitle.length === 0}
652
+ focusSignal={mountFocusSignal}
526
653
  multiline
527
654
  disableNewlines
528
655
  enableAutocomplete
@@ -634,6 +761,30 @@ export const stepBlock = createReactBlockSpec(
634
761
  </div>
635
762
  </div>
636
763
  );
764
+ }
765
+
766
+ export const stepBlock = createReactBlockSpec(
767
+ {
768
+ type: "testStep",
769
+ content: "none",
770
+ propSchema: {
771
+ stepTitle: {
772
+ default: "",
773
+ },
774
+ stepData: {
775
+ default: "",
776
+ },
777
+ expectedResult: {
778
+ default: "",
779
+ },
780
+ listStyle: {
781
+ default: "bullet",
782
+ },
637
783
  },
638
784
  },
785
+ {
786
+ render: ({ block, editor }) => (
787
+ <TestStepBlock block={block} editor={editor} />
788
+ ),
789
+ },
639
790
  );
@@ -15,6 +15,7 @@ type StepHorizontalViewProps = {
15
15
  onInsertNextStep: () => void;
16
16
  onFieldFocus: () => void;
17
17
  viewToggle?: ReactNode;
18
+ focusSignal?: number;
18
19
  };
19
20
 
20
21
  export const StepHorizontalView = forwardRef<HTMLDivElement, StepHorizontalViewProps>(function StepHorizontalView({
@@ -27,6 +28,7 @@ export const StepHorizontalView = forwardRef<HTMLDivElement, StepHorizontalViewP
27
28
  onInsertNextStep,
28
29
  onFieldFocus,
29
30
  viewToggle,
31
+ focusSignal,
30
32
  }, ref) {
31
33
  return (
32
34
  <div className="bn-teststep bn-teststep--horizontal" data-block-id={blockId} ref={ref}>
@@ -42,6 +44,7 @@ export const StepHorizontalView = forwardRef<HTMLDivElement, StepHorizontalViewP
42
44
  value={stepValue}
43
45
  onChange={onStepChange}
44
46
  placeholder={STEP_PLACEHOLDER}
47
+ focusSignal={focusSignal}
45
48
  enableAutocomplete
46
49
  fieldName="title"
47
50
  suggestionFilter={(suggestion) => (suggestion as StepSuggestion).isSnippet !== true}
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeStepNumber } from "./step";
3
+
4
+ // Numbering correctness: a step's number is its position within its group.
5
+ // Blank lines between steps don't break the run; any other block resets it.
6
+
7
+ const heading = (id: string) => ({ id, type: "heading", content: [{ type: "text", text: "Steps" }] });
8
+ const step = (id: string) => ({ id, type: "testStep", props: {} });
9
+ const emptyPara = (id: string) => ({ id, type: "paragraph", content: [] });
10
+ const para = (id: string, text: string) => ({ id, type: "paragraph", content: [{ type: "text", text }] });
11
+
12
+ describe("computeStepNumber", () => {
13
+ it("numbers consecutive steps within a group", () => {
14
+ const doc = [heading("h"), step("a"), step("b"), step("c")];
15
+ expect(computeStepNumber(doc, "a")).toBe(1);
16
+ expect(computeStepNumber(doc, "b")).toBe(2);
17
+ expect(computeStepNumber(doc, "c")).toBe(3);
18
+ });
19
+
20
+ it("keeps counting across blank lines but resets after other content", () => {
21
+ const doc = [
22
+ heading("h"),
23
+ step("a"), // 1
24
+ emptyPara("e1"), // blank line — does not break the run
25
+ step("b"), // 2
26
+ para("note", "some note"), // non-step content resets the run
27
+ step("c"), // 1
28
+ step("d"), // 2
29
+ ];
30
+ expect(computeStepNumber(doc, "a")).toBe(1);
31
+ expect(computeStepNumber(doc, "b")).toBe(2);
32
+ expect(computeStepNumber(doc, "c")).toBe(1);
33
+ expect(computeStepNumber(doc, "d")).toBe(2);
34
+ });
35
+
36
+ it("falls back to 1 for an unknown block", () => {
37
+ expect(computeStepNumber([step("a")], "missing")).toBe(1);
38
+ });
39
+ });
@@ -0,0 +1,66 @@
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
+ }