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.
@@ -34,6 +34,11 @@ export declare function addSnippetBlock(editor: {
34
34
  document: any[];
35
35
  insertBlocks: (blocks: any[], referenceId: string, placement: "before" | "after") => any[];
36
36
  }): string | null;
37
+ /**
38
+ * A test step's 1-based position within its group: count back over preceding
39
+ * steps (blank lines don't break the run) until a non-step block.
40
+ */
41
+ export declare function computeStepNumber(allBlocks: any[], blockId: string): number;
37
42
  export declare const stepBlock: {
38
43
  config: {
39
44
  readonly type: "testStep";
@@ -3,6 +3,7 @@ import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { StepField } from "./stepField";
5
5
  import { StepHorizontalView } from "./stepHorizontalView";
6
+ import { useDeferredMount } from "./useDeferredMount";
6
7
  import { useStepImageUpload } from "../stepImageUpload";
7
8
  const EXPECTED_COLLAPSED_KEY = "bn-expected-collapsed";
8
9
  const VIEW_MODE_KEY = "bn-step-view-mode";
@@ -207,6 +208,277 @@ export function addSnippetBlock(editor) {
207
208
  const inserted = editor.insertBlocks([stepsHeading, emptySnippet], lastBlock.id, "after");
208
209
  return (_d = (_c = inserted === null || inserted === void 0 ? void 0 : inserted[1]) === null || _c === void 0 ? void 0 : _c.id) !== null && _d !== void 0 ? _d : null;
209
210
  }
211
+ /**
212
+ * A test step's 1-based position within its group: count back over preceding
213
+ * steps (blank lines don't break the run) until a non-step block.
214
+ */
215
+ export function computeStepNumber(allBlocks, blockId) {
216
+ const blockIndex = allBlocks.findIndex((b) => b.id === blockId);
217
+ if (blockIndex < 0)
218
+ return 1;
219
+ let count = 1;
220
+ for (let i = blockIndex - 1; i >= 0; i--) {
221
+ const b = allBlocks[i];
222
+ if (b.type === "testStep") {
223
+ count++;
224
+ }
225
+ else if (isEmptyParagraph(b)) {
226
+ continue;
227
+ }
228
+ else {
229
+ break;
230
+ }
231
+ }
232
+ return count;
233
+ }
234
+ /** Strip the most common inline markdown markers for a readable static preview. */
235
+ function stripMarkdownForPreview(text) {
236
+ return text
237
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
238
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
239
+ .replace(/(\*\*|__|\*|_|~~|`)/g, "")
240
+ .replace(/<\/?[^>]+>/g, "")
241
+ .trim();
242
+ }
243
+ /**
244
+ * Cheap static stand-in shown before a step's interactive editor is mounted.
245
+ * Mirrors the real step's structure/typography so the document height stays
246
+ * stable and so it reads correctly during the brief window before upgrade.
247
+ */
248
+ function TestStepPreview({ blockId, stepNumber, stepTitle, stepData, expectedResult, }) {
249
+ const titleText = stripMarkdownForPreview(stepTitle);
250
+ const dataText = stripMarkdownForPreview(stepData);
251
+ const expectedText = stripMarkdownForPreview(expectedResult);
252
+ return (_jsxs("div", { className: "bn-teststep", "data-block-id": blockId, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [_jsx("div", { className: "bn-teststep__header", children: _jsx("span", { className: "bn-teststep__title", children: "Step" }) }), _jsx("div", { className: "bn-step-field", children: _jsx("div", { className: "bn-step-editor bn-step-editor--multiline bn-step-editor--preview", children: titleText || " " }) }), dataText ? (_jsx("div", { className: "bn-step-field", children: _jsx("div", { className: "bn-step-editor bn-step-editor--multiline bn-step-editor--preview", children: dataText }) })) : null, expectedText ? (_jsx("div", { className: "bn-step-field", children: _jsx("div", { className: "bn-step-editor bn-step-editor--multiline bn-step-editor--preview", children: expectedText }) })) : null] })] }));
253
+ }
254
+ /**
255
+ * Wrapper that defers mounting the (expensive) interactive step editor until
256
+ * the block scrolls into view. Off-screen steps render {@link TestStepPreview}
257
+ * instead, which is what keeps pasting/loading a large test document fast.
258
+ *
259
+ * The step number is tracked here and pushed down as a prop. We subscribe to
260
+ * editor changes but bail out of re-rendering when the number is unchanged, so
261
+ * ordinary text edits don't re-render every step in the document.
262
+ */
263
+ function TestStepBlock({ block, editor }) {
264
+ // An empty step is almost always a freshly-inserted one that needs to focus
265
+ // its title immediately, so mount its real editor eagerly. Steps with content
266
+ // (e.g. from a large paste) can safely start as a cheap preview.
267
+ const isEmptyStep = !(block.props.stepTitle || "") &&
268
+ !(block.props.stepData || "") &&
269
+ !(block.props.expectedResult || "");
270
+ const { ref, active, activate, shouldFocusOnActivate } = useDeferredMount({
271
+ initiallyActive: isEmptyStep,
272
+ });
273
+ const [stepNumber, setStepNumber] = useState(() => computeStepNumber(editor.document, block.id));
274
+ useEditorChange(() => {
275
+ // Recompute on change, but bail out of the state update (and therefore the
276
+ // re-render) when the number is unchanged. This is the key win: ordinary
277
+ // text edits leave every step's number untouched, so they don't re-render
278
+ // the whole step list.
279
+ const next = computeStepNumber(editor.document, block.id);
280
+ setStepNumber((prev) => (prev === next ? prev : next));
281
+ }, editor);
282
+ if (active) {
283
+ // Empty steps mounted eagerly (freshly inserted) auto-focus their title.
284
+ // A preview upgraded by a click focuses its field too, so a single click
285
+ // starts editing. Steps upgraded passively (scroll-into-view, hover
286
+ // pre-warm) must never steal focus.
287
+ return (_jsx(TestStepContent, { block: block, editor: editor, stepNumber: stepNumber, autoFocusEnabled: isEmptyStep, focusOnMount: shouldFocusOnActivate }));
288
+ }
289
+ return (_jsx("div", { ref: ref, onMouseDownCapture: () => activate(true), onFocusCapture: () => activate(true), children: _jsx(TestStepPreview, { blockId: block.id, stepNumber: stepNumber, stepTitle: block.props.stepTitle || "", stepData: block.props.stepData || "", expectedResult: block.props.expectedResult || "" }) }));
290
+ }
291
+ function TestStepContent({ block, editor, stepNumber, autoFocusEnabled = false, focusOnMount = false, }) {
292
+ // When a preview is upgraded by a click, focus its primary field once on
293
+ // mount so a single click starts editing (caret at end).
294
+ const mountFocusSignal = focusOnMount ? 1 : 0;
295
+ const stepTitle = block.props.stepTitle || "";
296
+ const stepData = block.props.stepData || "";
297
+ const expectedResult = block.props.expectedResult || "";
298
+ const expectedHasContent = expectedResult.trim().length > 0;
299
+ /* storedExpectedCollapsed removed — currently unused */
300
+ const dataHasContent = stepData.trim().length > 0;
301
+ const [isExpectedVisible, setIsExpectedVisible] = useState(expectedHasContent);
302
+ const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
303
+ const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
304
+ const uploadImage = useStepImageUpload();
305
+ const [viewMode, setViewMode] = useState(() => readStepViewMode());
306
+ const containerRef = useRef(null);
307
+ const [forceVertical, setForceVertical] = useState(false);
308
+ useEffect(() => {
309
+ var _a;
310
+ const el = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.parentElement;
311
+ if (!el)
312
+ return;
313
+ const observer = new ResizeObserver((entries) => {
314
+ for (const entry of entries) {
315
+ setForceVertical(entry.contentRect.width < FORCE_VERTICAL_WIDTH);
316
+ }
317
+ });
318
+ observer.observe(el);
319
+ return () => observer.disconnect();
320
+ }, []);
321
+ const effectiveVertical = forceVertical || viewMode === "vertical";
322
+ useEffect(() => {
323
+ if (typeof window === "undefined") {
324
+ return;
325
+ }
326
+ const handleStorage = (event) => {
327
+ if (event.key === VIEW_MODE_KEY) {
328
+ setViewMode(readStepViewMode());
329
+ }
330
+ };
331
+ const handleLocal = () => {
332
+ setViewMode(readStepViewMode());
333
+ };
334
+ window.addEventListener("storage", handleStorage);
335
+ window.addEventListener("bn-step-view-mode", handleLocal);
336
+ return () => {
337
+ window.removeEventListener("storage", handleStorage);
338
+ window.removeEventListener("bn-step-view-mode", handleLocal);
339
+ };
340
+ }, []);
341
+ const combinedStepValue = useMemo(() => {
342
+ if (!stepData) {
343
+ return stepTitle;
344
+ }
345
+ return stepTitle ? `${stepTitle}\n${stepData}` : stepData;
346
+ }, [stepData, stepTitle]);
347
+ const handleCombinedStepChange = useCallback((next) => {
348
+ if (next === combinedStepValue) {
349
+ return;
350
+ }
351
+ const [nextTitle = "", ...rest] = next.split("\n");
352
+ const nextData = rest.join("\n");
353
+ editor.updateBlock(block.id, {
354
+ props: {
355
+ stepTitle: nextTitle,
356
+ stepData: nextData,
357
+ },
358
+ });
359
+ }, [block.id, combinedStepValue, editor]);
360
+ const handleStepTitleChange = useCallback((next) => {
361
+ if (next === stepTitle) {
362
+ return;
363
+ }
364
+ editor.updateBlock(block.id, {
365
+ props: {
366
+ stepTitle: next,
367
+ },
368
+ });
369
+ }, [editor, block.id, stepTitle]);
370
+ const handleStepDataChange = useCallback((next) => {
371
+ if (next === stepData) {
372
+ return;
373
+ }
374
+ editor.updateBlock(block.id, {
375
+ props: {
376
+ stepData: next,
377
+ },
378
+ });
379
+ }, [editor, block.id, stepData]);
380
+ const handleShowData = useCallback(() => {
381
+ setIsDataVisible(true);
382
+ setShouldFocusDataField(true);
383
+ }, []);
384
+ const handleHideData = useCallback(() => {
385
+ setIsDataVisible(false);
386
+ editor.updateBlock(block.id, { props: { stepData: "" } });
387
+ }, [editor, block.id]);
388
+ const handleExpectedChange = useCallback((next) => {
389
+ if (next === expectedResult) {
390
+ return;
391
+ }
392
+ editor.updateBlock(block.id, {
393
+ props: {
394
+ expectedResult: next,
395
+ },
396
+ });
397
+ }, [editor, block.id, expectedResult]);
398
+ const handleInsertNextStep = useCallback(() => {
399
+ var _a;
400
+ const allBlocks = editor.document;
401
+ const idx = allBlocks.findIndex((b) => b.id === block.id);
402
+ const next = idx >= 0 ? allBlocks[idx + 1] : null;
403
+ if (next && isEmptyParagraph(next)) {
404
+ editor.removeBlocks([next.id]);
405
+ }
406
+ const currentListStyle = (_a = block.props.listStyle) !== null && _a !== void 0 ? _a : "bullet";
407
+ editor.insertBlocks([
408
+ {
409
+ type: "testStep",
410
+ props: {
411
+ stepTitle: "",
412
+ stepData: "",
413
+ expectedResult: "",
414
+ listStyle: currentListStyle,
415
+ },
416
+ children: [],
417
+ },
418
+ ], block.id, "after");
419
+ }, [editor, block.id, block.props]);
420
+ const handleFieldFocus = useCallback(() => {
421
+ var _a, _b, _c;
422
+ const selection = editor.getSelection();
423
+ const blocks = (_a = selection === null || selection === void 0 ? void 0 : selection.blocks) !== null && _a !== void 0 ? _a : [];
424
+ const firstId = (_b = blocks[0]) === null || _b === void 0 ? void 0 : _b.id;
425
+ const lastId = (_c = blocks[blocks.length - 1]) === null || _c === void 0 ? void 0 : _c.id;
426
+ if (firstId === block.id && lastId === block.id) {
427
+ return;
428
+ }
429
+ try {
430
+ editor.setSelection(block.id, block.id);
431
+ }
432
+ catch {
433
+ //
434
+ }
435
+ }, [editor, block.id]);
436
+ const handleToggleView = useCallback(() => {
437
+ const next = viewMode === "horizontal" ? "vertical" : "horizontal";
438
+ writeStepViewMode(next);
439
+ setViewMode(next);
440
+ if (typeof window !== "undefined") {
441
+ window.dispatchEvent(new Event("bn-step-view-mode"));
442
+ }
443
+ }, [viewMode]);
444
+ const [dataFocusSignal] = useState(0);
445
+ const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);
446
+ const handleShowExpected = useCallback(() => {
447
+ setIsExpectedVisible(true);
448
+ setExpectedFocusSignal((value) => value + 1);
449
+ writeExpectedCollapsedPreference(false);
450
+ }, []);
451
+ const handleHideExpected = useCallback(() => {
452
+ setIsExpectedVisible(false);
453
+ writeExpectedCollapsedPreference(true);
454
+ editor.updateBlock(block.id, { props: { expectedResult: "" } });
455
+ }, [editor, block.id]);
456
+ const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, tabIndex: -1, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
457
+ if (!effectiveVertical) {
458
+ return (_jsx(StepHorizontalView, { ref: containerRef, blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: viewToggleButton, focusSignal: mountFocusSignal }));
459
+ }
460
+ return (_jsxs("div", { className: "bn-teststep", "data-block-id": block.id, ref: containerRef, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [_jsxs("div", { className: "bn-teststep__header", children: [_jsx("span", { className: "bn-teststep__title", children: "Step" }), viewToggleButton] }), _jsx(StepField, { label: "Step", showLabel: false, value: stepTitle, placeholder: STEP_TITLE_PLACEHOLDER, onChange: handleStepTitleChange, autoFocus: autoFocusEnabled && stepTitle.length === 0, focusSignal: mountFocusSignal, multiline: true, disableNewlines: true, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: handleFieldFocus, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
461
+ if (!uploadImage) {
462
+ return;
463
+ }
464
+ setIsDataVisible(true);
465
+ setShouldFocusDataField(true);
466
+ try {
467
+ const result = await uploadImage(file);
468
+ if (result === null || result === void 0 ? void 0 : result.url) {
469
+ const nextValue = stepData.trim().length > 0 ? `${stepData}\n![](${result.url})` : `![](${result.url})`;
470
+ editor.updateBlock(block.id, {
471
+ props: {
472
+ stepData: nextValue,
473
+ },
474
+ });
475
+ }
476
+ }
477
+ catch (error) {
478
+ console.error("Failed to upload image to Step Data", error);
479
+ }
480
+ } }), isDataVisible ? (_jsx(StepField, { label: "Step data", placeholder: STEP_DATA_PLACEHOLDER, labelAction: _jsx("button", { type: "button", className: "bn-step-field__dismiss", "data-tooltip": "Hide step data", onClick: handleHideData, "aria-label": "Hide step data", children: "\u00D7" }), value: stepData, onChange: handleStepDataChange, autoFocus: shouldFocusDataField, focusSignal: dataFocusSignal, multiline: true, enableAutocomplete: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })) : null, isExpectedVisible ? (_jsx(StepField, { label: "Expected result", placeholder: EXPECTED_RESULT_PLACEHOLDER, labelAction: _jsx("button", { type: "button", className: "bn-step-field__dismiss", "data-tooltip": "Hide expected result", onClick: handleHideExpected, tabIndex: -1, "aria-label": "Hide expected result", children: "\u00D7" }), value: expectedResult, onChange: handleExpectedChange, multiline: true, focusSignal: expectedFocusSignal, enableAutocomplete: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })) : null, _jsxs("div", { className: "bn-step-actions", children: [_jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: handleInsertNextStep, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 13.334 13.334", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M6.667 0a6.667 6.667 0 1 1 0 13.334A6.667 6.667 0 0 1 6.667 0Zm0 1.334a5.333 5.333 0 1 0 0 10.666 5.333 5.333 0 0 0 0-10.666ZM7.334 3.334V6H10v1.334H7.334V10H6V7.334H3.334V6H6V3.334h1.334Z", fill: "currentColor" }) }), "Add new step"] }), !isDataVisible && (_jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: handleShowData, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { fillRule: "evenodd", clipRule: "evenodd", d: "M8.666 7.333H12.666V8.667H8.666V12.667H7.332V8.667H3.332V7.333H7.332V3.333H8.666V7.333Z", fill: "currentColor" }) }), "Step data"] })), !isExpectedVisible && (_jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: handleShowExpected, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { fillRule: "evenodd", clipRule: "evenodd", d: "M8.666 7.333H12.666V8.667H8.666V12.667H7.332V8.667H3.332V7.333H7.332V3.333H8.666V7.333Z", fill: "currentColor" }) }), "Expected result"] }))] })] })] }));
481
+ }
210
482
  export const stepBlock = createReactBlockSpec({
211
483
  type: "testStep",
212
484
  content: "none",
@@ -225,217 +497,5 @@ export const stepBlock = createReactBlockSpec({
225
497
  },
226
498
  },
227
499
  }, {
228
- render: ({ block, editor }) => {
229
- const stepTitle = block.props.stepTitle || "";
230
- const stepData = block.props.stepData || "";
231
- const expectedResult = block.props.expectedResult || "";
232
- const expectedHasContent = expectedResult.trim().length > 0;
233
- /* storedExpectedCollapsed removed — currently unused */
234
- const dataHasContent = stepData.trim().length > 0;
235
- const [isExpectedVisible, setIsExpectedVisible] = useState(expectedHasContent);
236
- const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
237
- const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
238
- const [documentVersion, setDocumentVersion] = useState(0);
239
- const uploadImage = useStepImageUpload();
240
- const [viewMode, setViewMode] = useState(() => readStepViewMode());
241
- const containerRef = useRef(null);
242
- const [forceVertical, setForceVertical] = useState(false);
243
- useEffect(() => {
244
- var _a;
245
- const el = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.parentElement;
246
- if (!el)
247
- return;
248
- const observer = new ResizeObserver((entries) => {
249
- for (const entry of entries) {
250
- setForceVertical(entry.contentRect.width < FORCE_VERTICAL_WIDTH);
251
- }
252
- });
253
- observer.observe(el);
254
- return () => observer.disconnect();
255
- }, []);
256
- const effectiveVertical = forceVertical || viewMode === "vertical";
257
- // Calculate step number based on position in document
258
- const stepNumber = useMemo(() => {
259
- const allBlocks = editor.document;
260
- const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
261
- if (blockIndex < 0)
262
- return 1;
263
- let count = 1;
264
- for (let i = blockIndex - 1; i >= 0; i--) {
265
- const b = allBlocks[i];
266
- if (b.type === "testStep") {
267
- count++;
268
- }
269
- else if (isEmptyParagraph(b)) {
270
- continue;
271
- }
272
- else {
273
- break;
274
- }
275
- }
276
- return count;
277
- }, [block.id, documentVersion, editor.document]);
278
- useEditorChange(() => {
279
- setDocumentVersion((version) => version + 1);
280
- }, editor);
281
- useEffect(() => {
282
- if (typeof window === "undefined") {
283
- return;
284
- }
285
- const handleStorage = (event) => {
286
- if (event.key === VIEW_MODE_KEY) {
287
- setViewMode(readStepViewMode());
288
- }
289
- };
290
- const handleLocal = () => {
291
- setViewMode(readStepViewMode());
292
- };
293
- window.addEventListener("storage", handleStorage);
294
- window.addEventListener("bn-step-view-mode", handleLocal);
295
- return () => {
296
- window.removeEventListener("storage", handleStorage);
297
- window.removeEventListener("bn-step-view-mode", handleLocal);
298
- };
299
- }, []);
300
- const combinedStepValue = useMemo(() => {
301
- if (!stepData) {
302
- return stepTitle;
303
- }
304
- return stepTitle ? `${stepTitle}\n${stepData}` : stepData;
305
- }, [stepData, stepTitle]);
306
- const handleCombinedStepChange = useCallback((next) => {
307
- if (next === combinedStepValue) {
308
- return;
309
- }
310
- const [nextTitle = "", ...rest] = next.split("\n");
311
- const nextData = rest.join("\n");
312
- editor.updateBlock(block.id, {
313
- props: {
314
- stepTitle: nextTitle,
315
- stepData: nextData,
316
- },
317
- });
318
- }, [block.id, combinedStepValue, editor]);
319
- const handleStepTitleChange = useCallback((next) => {
320
- if (next === stepTitle) {
321
- return;
322
- }
323
- editor.updateBlock(block.id, {
324
- props: {
325
- stepTitle: next,
326
- },
327
- });
328
- }, [editor, block.id, stepTitle]);
329
- const handleStepDataChange = useCallback((next) => {
330
- if (next === stepData) {
331
- return;
332
- }
333
- editor.updateBlock(block.id, {
334
- props: {
335
- stepData: next,
336
- },
337
- });
338
- }, [editor, block.id, stepData]);
339
- const handleShowData = useCallback(() => {
340
- setIsDataVisible(true);
341
- setShouldFocusDataField(true);
342
- }, []);
343
- const handleHideData = useCallback(() => {
344
- setIsDataVisible(false);
345
- editor.updateBlock(block.id, { props: { stepData: "" } });
346
- }, [editor, block.id]);
347
- const handleExpectedChange = useCallback((next) => {
348
- if (next === expectedResult) {
349
- return;
350
- }
351
- editor.updateBlock(block.id, {
352
- props: {
353
- expectedResult: next,
354
- },
355
- });
356
- }, [editor, block.id, expectedResult]);
357
- const handleInsertNextStep = useCallback(() => {
358
- var _a;
359
- const allBlocks = editor.document;
360
- const idx = allBlocks.findIndex((b) => b.id === block.id);
361
- const next = idx >= 0 ? allBlocks[idx + 1] : null;
362
- if (next && isEmptyParagraph(next)) {
363
- editor.removeBlocks([next.id]);
364
- }
365
- const currentListStyle = (_a = block.props.listStyle) !== null && _a !== void 0 ? _a : "bullet";
366
- editor.insertBlocks([
367
- {
368
- type: "testStep",
369
- props: {
370
- stepTitle: "",
371
- stepData: "",
372
- expectedResult: "",
373
- listStyle: currentListStyle,
374
- },
375
- children: [],
376
- },
377
- ], block.id, "after");
378
- }, [editor, block.id, block.props]);
379
- const handleFieldFocus = useCallback(() => {
380
- var _a, _b, _c;
381
- const selection = editor.getSelection();
382
- const blocks = (_a = selection === null || selection === void 0 ? void 0 : selection.blocks) !== null && _a !== void 0 ? _a : [];
383
- const firstId = (_b = blocks[0]) === null || _b === void 0 ? void 0 : _b.id;
384
- const lastId = (_c = blocks[blocks.length - 1]) === null || _c === void 0 ? void 0 : _c.id;
385
- if (firstId === block.id && lastId === block.id) {
386
- return;
387
- }
388
- try {
389
- editor.setSelection(block.id, block.id);
390
- }
391
- catch {
392
- //
393
- }
394
- }, [editor, block.id]);
395
- const handleToggleView = useCallback(() => {
396
- const next = viewMode === "horizontal" ? "vertical" : "horizontal";
397
- writeStepViewMode(next);
398
- setViewMode(next);
399
- if (typeof window !== "undefined") {
400
- window.dispatchEvent(new Event("bn-step-view-mode"));
401
- }
402
- }, [viewMode]);
403
- const [dataFocusSignal] = useState(0);
404
- const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);
405
- const handleShowExpected = useCallback(() => {
406
- setIsExpectedVisible(true);
407
- setExpectedFocusSignal((value) => value + 1);
408
- writeExpectedCollapsedPreference(false);
409
- }, []);
410
- const handleHideExpected = useCallback(() => {
411
- setIsExpectedVisible(false);
412
- writeExpectedCollapsedPreference(true);
413
- editor.updateBlock(block.id, { props: { expectedResult: "" } });
414
- }, [editor, block.id]);
415
- const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, tabIndex: -1, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
416
- if (!effectiveVertical) {
417
- return (_jsx(StepHorizontalView, { ref: containerRef, blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: viewToggleButton }));
418
- }
419
- return (_jsxs("div", { className: "bn-teststep", "data-block-id": block.id, ref: containerRef, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [_jsxs("div", { className: "bn-teststep__header", children: [_jsx("span", { className: "bn-teststep__title", children: "Step" }), viewToggleButton] }), _jsx(StepField, { label: "Step", showLabel: false, value: stepTitle, placeholder: STEP_TITLE_PLACEHOLDER, onChange: handleStepTitleChange, autoFocus: stepTitle.length === 0, multiline: true, disableNewlines: true, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: handleFieldFocus, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
420
- if (!uploadImage) {
421
- return;
422
- }
423
- setIsDataVisible(true);
424
- setShouldFocusDataField(true);
425
- try {
426
- const result = await uploadImage(file);
427
- if (result === null || result === void 0 ? void 0 : result.url) {
428
- const nextValue = stepData.trim().length > 0 ? `${stepData}\n![](${result.url})` : `![](${result.url})`;
429
- editor.updateBlock(block.id, {
430
- props: {
431
- stepData: nextValue,
432
- },
433
- });
434
- }
435
- }
436
- catch (error) {
437
- console.error("Failed to upload image to Step Data", error);
438
- }
439
- } }), isDataVisible ? (_jsx(StepField, { label: "Step data", placeholder: STEP_DATA_PLACEHOLDER, labelAction: _jsx("button", { type: "button", className: "bn-step-field__dismiss", "data-tooltip": "Hide step data", onClick: handleHideData, "aria-label": "Hide step data", children: "\u00D7" }), value: stepData, onChange: handleStepDataChange, autoFocus: shouldFocusDataField, focusSignal: dataFocusSignal, multiline: true, enableAutocomplete: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })) : null, isExpectedVisible ? (_jsx(StepField, { label: "Expected result", placeholder: EXPECTED_RESULT_PLACEHOLDER, labelAction: _jsx("button", { type: "button", className: "bn-step-field__dismiss", "data-tooltip": "Hide expected result", onClick: handleHideExpected, tabIndex: -1, "aria-label": "Hide expected result", children: "\u00D7" }), value: expectedResult, onChange: handleExpectedChange, multiline: true, focusSignal: expectedFocusSignal, enableAutocomplete: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: handleFieldFocus })) : null, _jsxs("div", { className: "bn-step-actions", children: [_jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: handleInsertNextStep, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 13.334 13.334", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M6.667 0a6.667 6.667 0 1 1 0 13.334A6.667 6.667 0 0 1 6.667 0Zm0 1.334a5.333 5.333 0 1 0 0 10.666 5.333 5.333 0 0 0 0-10.666ZM7.334 3.334V6H10v1.334H7.334V10H6V7.334H3.334V6H6V3.334h1.334Z", fill: "currentColor" }) }), "Add new step"] }), !isDataVisible && (_jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: handleShowData, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { fillRule: "evenodd", clipRule: "evenodd", d: "M8.666 7.333H12.666V8.667H8.666V12.667H7.332V8.667H3.332V7.333H7.332V3.333H8.666V7.333Z", fill: "currentColor" }) }), "Step data"] })), !isExpectedVisible && (_jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: handleShowExpected, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { fillRule: "evenodd", clipRule: "evenodd", d: "M8.666 7.333H12.666V8.667H8.666V12.667H7.332V8.667H3.332V7.333H7.332V3.333H8.666V7.333Z", fill: "currentColor" }) }), "Expected result"] }))] })] })] }));
440
- },
500
+ render: ({ block, editor }) => (_jsx(TestStepBlock, { block: block, editor: editor })),
441
501
  });
@@ -9,6 +9,7 @@ type StepHorizontalViewProps = {
9
9
  onInsertNextStep: () => void;
10
10
  onFieldFocus: () => void;
11
11
  viewToggle?: ReactNode;
12
+ focusSignal?: number;
12
13
  };
13
14
  export declare const StepHorizontalView: import("react").ForwardRefExoticComponent<StepHorizontalViewProps & import("react").RefAttributes<HTMLDivElement>>;
14
15
  export {};
@@ -3,6 +3,6 @@ import { forwardRef } from "react";
3
3
  import { StepField } from "./stepField";
4
4
  const STEP_PLACEHOLDER = "Enter step name";
5
5
  const EXPECTED_RESULT_PLACEHOLDER = "Enter expected result";
6
- export const StepHorizontalView = forwardRef(function StepHorizontalView({ blockId, stepNumber, stepValue, expectedResult, onStepChange, onExpectedChange, onInsertNextStep, onFieldFocus, viewToggle, }, ref) {
7
- return (_jsxs("div", { className: "bn-teststep bn-teststep--horizontal", "data-block-id": blockId, ref: ref, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [_jsxs("div", { className: "bn-teststep__horizontal-fields", children: [_jsx("div", { className: "bn-teststep__horizontal-col", children: _jsx(StepField, { label: "Step", value: stepValue, onChange: onStepChange, placeholder: STEP_PLACEHOLDER, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: onFieldFocus, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true }) }), _jsx("div", { className: "bn-teststep__horizontal-col", children: _jsx(StepField, { label: "Expected result", labelAction: viewToggle, value: expectedResult, onChange: onExpectedChange, placeholder: EXPECTED_RESULT_PLACEHOLDER, multiline: true, enableAutocomplete: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: onFieldFocus }) })] }), _jsx("div", { className: "bn-step-actions", children: _jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: onInsertNextStep, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 13.334 13.334", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M6.667 0a6.667 6.667 0 1 1 0 13.334A6.667 6.667 0 0 1 6.667 0Zm0 1.334a5.333 5.333 0 1 0 0 10.666 5.333 5.333 0 0 0 0-10.666ZM7.334 3.334V6H10v1.334H7.334V10H6V7.334H3.334V6H6V3.334h1.334Z", fill: "currentColor" }) }), "Add new step"] }) })] })] }));
6
+ export const StepHorizontalView = forwardRef(function StepHorizontalView({ blockId, stepNumber, stepValue, expectedResult, onStepChange, onExpectedChange, onInsertNextStep, onFieldFocus, viewToggle, focusSignal, }, ref) {
7
+ return (_jsxs("div", { className: "bn-teststep bn-teststep--horizontal", "data-block-id": blockId, ref: ref, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [_jsxs("div", { className: "bn-teststep__horizontal-fields", children: [_jsx("div", { className: "bn-teststep__horizontal-col", children: _jsx(StepField, { label: "Step", value: stepValue, onChange: onStepChange, placeholder: STEP_PLACEHOLDER, focusSignal: focusSignal, enableAutocomplete: true, fieldName: "title", suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: onFieldFocus, multiline: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true }) }), _jsx("div", { className: "bn-teststep__horizontal-col", children: _jsx(StepField, { label: "Expected result", labelAction: viewToggle, value: expectedResult, onChange: onExpectedChange, placeholder: EXPECTED_RESULT_PLACEHOLDER, multiline: true, enableAutocomplete: true, enableImageUpload: true, showFormattingButtons: true, showImageButton: true, onFieldFocus: onFieldFocus }) })] }), _jsx("div", { className: "bn-step-actions", children: _jsxs("button", { type: "button", className: "bn-step-action-btn", onClick: onInsertNextStep, children: [_jsx("svg", { className: "bn-step-action-btn__icon", width: "16", height: "16", viewBox: "0 0 13.334 13.334", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M6.667 0a6.667 6.667 0 1 1 0 13.334A6.667 6.667 0 0 1 6.667 0Zm0 1.334a5.333 5.333 0 1 0 0 10.666 5.333 5.333 0 0 0 0-10.666ZM7.334 3.334V6H10v1.334H7.334V10H6V7.334H3.334V6H6V3.334h1.334Z", fill: "currentColor" }) }), "Add new step"] }) })] })] }));
8
8
  });
@@ -0,0 +1,26 @@
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
+ };
@@ -0,0 +1,54 @@
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
+ }