testomatio-editor-blocks 0.4.65 → 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.
Files changed (33) hide show
  1. package/package/editor/blocks/step.d.ts +5 -0
  2. package/package/editor/blocks/step.js +273 -213
  3. package/package/editor/blocks/stepHorizontalView.d.ts +1 -0
  4. package/package/editor/blocks/stepHorizontalView.js +2 -2
  5. package/package/editor/blocks/testMeta.d.ts +37 -0
  6. package/package/editor/blocks/testMeta.js +111 -0
  7. package/package/editor/blocks/useDeferredMount.d.ts +26 -0
  8. package/package/editor/blocks/useDeferredMount.js +54 -0
  9. package/package/editor/createMarkdownPasteHandler.js +56 -9
  10. package/package/editor/customMarkdownConverter.js +127 -10
  11. package/package/editor/customSchema.d.ts +32 -0
  12. package/package/editor/customSchema.js +2 -0
  13. package/package/editor/testMetaFields.d.ts +17 -0
  14. package/package/editor/testMetaFields.js +33 -0
  15. package/package/index.d.ts +2 -0
  16. package/package/index.js +2 -0
  17. package/package/styles.css +215 -0
  18. package/package.json +1 -1
  19. package/src/App.tsx +54 -15
  20. package/src/editor/blocks/step.tsx +198 -47
  21. package/src/editor/blocks/stepHorizontalView.tsx +3 -0
  22. package/src/editor/blocks/stepNumber.test.ts +39 -0
  23. package/src/editor/blocks/testMeta.tsx +242 -0
  24. package/src/editor/blocks/useDeferredMount.ts +66 -0
  25. package/src/editor/createMarkdownPasteHandler.test.ts +126 -0
  26. package/src/editor/createMarkdownPasteHandler.ts +60 -8
  27. package/src/editor/customMarkdownConverter.test.ts +135 -0
  28. package/src/editor/customMarkdownConverter.ts +125 -0
  29. package/src/editor/customSchema.tsx +2 -0
  30. package/src/editor/renderingPerf.test.ts +59 -0
  31. package/src/editor/styles.css +215 -0
  32. package/src/editor/testMetaFields.ts +53 -0
  33. package/src/index.ts +7 -0
@@ -595,6 +595,207 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin
595
595
  flex-shrink: 0;
596
596
  }
597
597
 
598
+ /* ============================================
599
+ HEADING SIZES INSIDE THE EDITOR
600
+ Override BlockNote's default heading scale (3em / 2em / 1.3em) with a
601
+ compact 22px → 14px range. BlockNote derives heading font-size from the
602
+ `--level` custom property, so we just redefine it per level.
603
+ ============================================ */
604
+ .testomatio-editor [data-content-type="heading"] {
605
+ --level: 22px; /* h1 (level 1 has no [data-level] rule of its own) */
606
+ }
607
+ .testomatio-editor [data-content-type="heading"][data-level="2"] {
608
+ --level: 20px;
609
+ }
610
+ .testomatio-editor [data-content-type="heading"][data-level="3"] {
611
+ --level: 18px;
612
+ }
613
+ .testomatio-editor [data-content-type="heading"][data-level="4"] {
614
+ --level: 16px;
615
+ }
616
+ .testomatio-editor [data-content-type="heading"][data-level="5"] {
617
+ --level: 14px;
618
+ }
619
+ .testomatio-editor [data-content-type="heading"][data-level="6"] {
620
+ --level: 14px;
621
+ }
622
+ /* Keep size stable during BlockNote's heading-transition animation. */
623
+ .testomatio-editor [data-prev-level="1"] {
624
+ --prev-level: 22px;
625
+ }
626
+ .testomatio-editor [data-prev-level="2"] {
627
+ --prev-level: 20px;
628
+ }
629
+ .testomatio-editor [data-prev-level="3"] {
630
+ --prev-level: 18px;
631
+ }
632
+
633
+ /* ============================================
634
+ TEST / SUITE METADATA BLOCK
635
+ Dimmed card for `<!-- test ... -->` / `<!-- suite ... -->` comments.
636
+ ============================================ */
637
+ .bn-testmeta {
638
+ display: flex;
639
+ flex-direction: column;
640
+ gap: 4px;
641
+ width: 100%;
642
+ box-sizing: border-box;
643
+ padding: 6px 10px;
644
+ background: var(--bg-muted);
645
+ /*border: 1px solid var(--border-default);*/
646
+ /* Stronger top edge signals that the test case begins below this line. */
647
+ border-top: 3px solid var(--color-slate-400);
648
+ /*border-radius: 8px;*/
649
+ margin-top: 2rem;
650
+ opacity: 0.5;
651
+ }
652
+
653
+ /* Header line: `TEST @T1233456 ............ [+]` — label, id, and add button
654
+ always share one row. */
655
+ .bn-testmeta__header {
656
+ display: flex;
657
+ align-items: center;
658
+ gap: 8px;
659
+ margin-left: 8px;
660
+ }
661
+
662
+ .bn-testmeta__header .bn-testmeta__add-wrap {
663
+ margin-left: auto;
664
+ }
665
+
666
+ .bn-testmeta__label {
667
+ font-size: 11px;
668
+ font-weight: 600;
669
+ letter-spacing: 0.04em;
670
+ text-transform: uppercase;
671
+ color: var(--text-muted);
672
+ flex-shrink: 0;
673
+ }
674
+
675
+ .bn-testmeta__id {
676
+ font-size: 13px;
677
+ font-weight: 600;
678
+ color: var(--text-primary);
679
+ }
680
+
681
+ .bn-testmeta__rows {
682
+ display: flex;
683
+ flex-direction: column;
684
+ gap: 2px;
685
+ }
686
+
687
+ .bn-testmeta__row {
688
+ display: grid;
689
+ grid-template-columns: 140px minmax(0, 1fr) 24px;
690
+ align-items: center;
691
+ gap: 8px;
692
+ }
693
+
694
+ .bn-testmeta__key {
695
+ min-width: 0;
696
+ font-size: 13px;
697
+ color: var(--text-muted);
698
+ }
699
+
700
+ /* Defined values blend into the block like normal text, and only reveal the
701
+ input affordance on hover/focus ("activate on click"). */
702
+ .bn-testmeta__key--input,
703
+ .bn-testmeta__value {
704
+ width: 100%;
705
+ height: 26px;
706
+ padding: 0 8px;
707
+ box-sizing: border-box;
708
+ font-family: inherit;
709
+ font-size: 13px;
710
+ color: var(--text-primary);
711
+ background: transparent;
712
+ border: 1px solid transparent;
713
+ border-radius: 6px;
714
+ cursor: text;
715
+ }
716
+
717
+ .bn-testmeta__key--input:hover,
718
+ .bn-testmeta__value:hover {
719
+ border-color: var(--border-light);
720
+ }
721
+
722
+ .bn-testmeta__key--input:focus,
723
+ .bn-testmeta__value:focus {
724
+ outline: none;
725
+ background: var(--bg-white);
726
+ border-color: var(--step-input-border-focus);
727
+ box-shadow: 0 0 0 2px var(--step-input-shadow);
728
+ }
729
+
730
+ .bn-testmeta__remove,
731
+ .bn-testmeta__add {
732
+ width: 24px;
733
+ height: 24px;
734
+ display: inline-flex;
735
+ align-items: center;
736
+ justify-content: center;
737
+ font-size: 18px;
738
+ line-height: 1;
739
+ color: var(--text-muted);
740
+ background: transparent;
741
+ border: none;
742
+ border-radius: 6px;
743
+ cursor: pointer;
744
+ padding: 0;
745
+ }
746
+
747
+ .bn-testmeta__remove:hover,
748
+ .bn-testmeta__add:hover {
749
+ background: var(--step-bg-button-hover);
750
+ color: var(--text-primary);
751
+ }
752
+
753
+ .bn-testmeta__add-wrap {
754
+ position: relative;
755
+ }
756
+
757
+ .bn-testmeta__menu {
758
+ position: absolute;
759
+ top: calc(100% + 4px);
760
+ right: 0;
761
+ z-index: 100;
762
+ min-width: 160px;
763
+ max-height: 240px;
764
+ overflow-y: auto;
765
+ display: flex;
766
+ flex-direction: column;
767
+ padding: 4px;
768
+ background: var(--bg-white-opaque);
769
+ border: 1px solid var(--border-default);
770
+ border-radius: 8px;
771
+ box-shadow: 0 8px 24px var(--shadow-medium);
772
+ }
773
+
774
+ .bn-testmeta__menu-item {
775
+ display: block;
776
+ width: 100%;
777
+ padding: 6px 8px;
778
+ text-align: left;
779
+ font-family: inherit;
780
+ font-size: 13px;
781
+ color: var(--text-primary);
782
+ background: transparent;
783
+ border: none;
784
+ border-radius: 6px;
785
+ cursor: pointer;
786
+ }
787
+
788
+ .bn-testmeta__menu-item:hover {
789
+ background: var(--bg-muted);
790
+ }
791
+
792
+ .bn-testmeta__menu-item--custom {
793
+ margin-top: 2px;
794
+ border-top: 1px solid var(--border-light);
795
+ border-radius: 0 0 6px 6px;
796
+ color: var(--text-muted);
797
+ }
798
+
598
799
  .bn-snippet-dropdown {
599
800
  position: relative;
600
801
  }
@@ -1039,6 +1240,20 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin
1039
1240
  min-height: 4rem;
1040
1241
  }
1041
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
+
1042
1257
  .bn-step-editor.bn-step-editor--focused {
1043
1258
  outline: none;
1044
1259
  box-shadow: none;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.65",
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,
@@ -347,11 +347,30 @@ function CustomSlashMenu() {
347
347
  },
348
348
  };
349
349
 
350
+ const addTestItem = {
351
+ key: "add_test" as any,
352
+ title: "Add Test",
353
+ subtext: "Insert test metadata (id, priority, tags…)",
354
+ group: "Test documentation",
355
+ icon: <span className="bn-suggestion-icon">@T</span>,
356
+ aliases: ["test", "metadata", "meta", "test id"],
357
+ onItemClick: () => {
358
+ insertOrUpdateBlock(editor, {
359
+ type: "testMeta",
360
+ props: {
361
+ metaKind: "test",
362
+ metaFields: "[]",
363
+ metaInline: false,
364
+ },
365
+ });
366
+ },
367
+ };
368
+
350
369
  const currentBlock = editor.getTextCursorPosition().block;
351
370
  const canInsert = canInsertStepOrSnippet(editor, currentBlock.id);
352
371
  const items = canInsert
353
- ? [...defaultItems, stepItem, snippetItem]
354
- : defaultItems;
372
+ ? [...defaultItems, stepItem, snippetItem, addTestItem]
373
+ : [...defaultItems, addTestItem];
355
374
  return filterSuggestionItems(items, query);
356
375
  };
357
376
 
@@ -411,22 +430,42 @@ function App() {
411
430
  document.documentElement.classList.toggle("dark", darkMode);
412
431
  }, [darkMode]);
413
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);
414
439
  useEditorChange((editorInstance) => {
415
- try {
416
- const documentBlocks = editorInstance.document as CustomEditorBlock[];
417
- const md = blocksToMarkdown(documentBlocks);
418
- setMarkdown(md);
419
- setBlocksJson(JSON.stringify(documentBlocks, null, 2));
420
- setConversionError(null);
421
- setCopyStatus("idle");
422
- setCopyBlocksStatus("idle");
423
- } catch (error) {
424
- setConversionError(error instanceof Error ? error.message : String(error));
425
- setCopyStatus("idle");
426
- setCopyBlocksStatus("idle");
440
+ if (serializeTimerRef.current !== null) {
441
+ clearTimeout(serializeTimerRef.current);
427
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);
428
459
  }, editor);
429
460
 
461
+ useEffect(() => {
462
+ return () => {
463
+ if (serializeTimerRef.current !== null) {
464
+ clearTimeout(serializeTimerRef.current);
465
+ }
466
+ };
467
+ }, []);
468
+
430
469
  useEffect(() => {
431
470
  if (!editor) {
432
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
+ });