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.
- package/package/editor/blocks/step.d.ts +5 -0
- package/package/editor/blocks/step.js +273 -213
- package/package/editor/blocks/stepHorizontalView.d.ts +1 -0
- package/package/editor/blocks/stepHorizontalView.js +2 -2
- package/package/editor/blocks/testMeta.d.ts +37 -0
- package/package/editor/blocks/testMeta.js +111 -0
- package/package/editor/blocks/useDeferredMount.d.ts +26 -0
- package/package/editor/blocks/useDeferredMount.js +54 -0
- package/package/editor/createMarkdownPasteHandler.js +56 -9
- package/package/editor/customMarkdownConverter.js +127 -10
- package/package/editor/customSchema.d.ts +32 -0
- package/package/editor/customSchema.js +2 -0
- package/package/editor/testMetaFields.d.ts +17 -0
- package/package/editor/testMetaFields.js +33 -0
- package/package/index.d.ts +2 -0
- package/package/index.js +2 -0
- package/package/styles.css +215 -0
- package/package.json +1 -1
- package/src/App.tsx +54 -15
- package/src/editor/blocks/step.tsx +198 -47
- package/src/editor/blocks/stepHorizontalView.tsx +3 -0
- package/src/editor/blocks/stepNumber.test.ts +39 -0
- package/src/editor/blocks/testMeta.tsx +242 -0
- package/src/editor/blocks/useDeferredMount.ts +66 -0
- package/src/editor/createMarkdownPasteHandler.test.ts +126 -0
- package/src/editor/createMarkdownPasteHandler.ts +60 -8
- package/src/editor/customMarkdownConverter.test.ts +135 -0
- package/src/editor/customMarkdownConverter.ts +125 -0
- package/src/editor/customSchema.tsx +2 -0
- package/src/editor/renderingPerf.test.ts +59 -0
- package/src/editor/styles.css +215 -0
- package/src/editor/testMetaFields.ts +53 -0
- package/src/index.ts +7 -0
package/package/styles.css
CHANGED
|
@@ -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
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
|
-
|
|
416
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
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
|
+
});
|