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