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.
- 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/useDeferredMount.d.ts +26 -0
- package/package/editor/blocks/useDeferredMount.js +54 -0
- package/package/editor/createMarkdownPasteHandler.js +56 -9
- package/package/styles.css +14 -0
- package/package.json +1 -1
- package/src/App.tsx +33 -13
- 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/useDeferredMount.ts +66 -0
- package/src/editor/createMarkdownPasteHandler.test.ts +126 -0
- package/src/editor/createMarkdownPasteHandler.ts +60 -8
- package/src/editor/renderingPerf.test.ts +59 -0
- package/src/editor/styles.css +14 -0
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
const BLOCK_MARKDOWN_PREFIX = /^(\s*)(#{1,6}\s|[-*+]\s|\d+[.)]\s|>\s|```|~~~|\||!\[)/;
|
|
2
|
+
// For large pastes, only the first chunk is inserted synchronously (enough to
|
|
3
|
+
// fill the viewport); the remaining blocks are streamed in during idle time so
|
|
4
|
+
// the editor stays responsive and the user sees content immediately instead of
|
|
5
|
+
// the main thread freezing while a thousand-block document is built at once.
|
|
6
|
+
const CHUNK_THRESHOLD = 150;
|
|
7
|
+
const FIRST_CHUNK = 50;
|
|
8
|
+
const REST_CHUNK = 40;
|
|
9
|
+
const scheduleIdle = typeof window !== "undefined" && typeof window.requestIdleCallback === "function"
|
|
10
|
+
? (cb) => window.requestIdleCallback(() => cb(), { timeout: 200 })
|
|
11
|
+
: (cb) => setTimeout(cb, 0);
|
|
12
|
+
function lastBlockId(blocks) {
|
|
13
|
+
var _a;
|
|
14
|
+
return blocks.length ? (_a = blocks[blocks.length - 1]) === null || _a === void 0 ? void 0 : _a.id : undefined;
|
|
15
|
+
}
|
|
2
16
|
function isInlineOnlyPaste(plainText, parsedBlocks) {
|
|
3
17
|
if (parsedBlocks.length !== 1)
|
|
4
18
|
return false;
|
|
@@ -41,21 +55,54 @@ export function createMarkdownPasteHandler(converter) {
|
|
|
41
55
|
}
|
|
42
56
|
const selection = editor.getSelection();
|
|
43
57
|
const selectedIds = (_h = (_g = selection === null || selection === void 0 ? void 0 : selection.blocks) === null || _g === void 0 ? void 0 : _g.map((block) => block.id).filter((id) => Boolean(id))) !== null && _h !== void 0 ? _h : [];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
// Insert the initial set of blocks at the paste location, returning the
|
|
59
|
+
// inserted blocks so subsequent chunks can be appended after the last one.
|
|
60
|
+
const insertInitial = (blocksToInsert) => {
|
|
61
|
+
if (selectedIds.length > 0) {
|
|
62
|
+
return editor.replaceBlocks(selectedIds, blocksToInsert).insertedBlocks;
|
|
63
|
+
}
|
|
48
64
|
const cursorBlock = editor.getTextCursorPosition().block;
|
|
49
65
|
if (cursorBlock) {
|
|
50
|
-
editor.replaceBlocks([cursorBlock.id],
|
|
66
|
+
return editor.replaceBlocks([cursorBlock.id], blocksToInsert).insertedBlocks;
|
|
51
67
|
}
|
|
52
|
-
|
|
68
|
+
if (editor.document.length > 0) {
|
|
53
69
|
const reference = editor.document[editor.document.length - 1];
|
|
54
|
-
editor.insertBlocks(
|
|
70
|
+
return editor.insertBlocks(blocksToInsert, reference.id, "after");
|
|
55
71
|
}
|
|
56
|
-
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
if (parsedBlocks.length <= CHUNK_THRESHOLD) {
|
|
75
|
+
// Small paste: insert everything in one transaction (original behaviour).
|
|
76
|
+
if (insertInitial(parsedBlocks) === null)
|
|
57
77
|
return defaultPasteHandler();
|
|
58
|
-
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// Large paste: render the first screenful now, stream the rest in idle
|
|
81
|
+
// time so the main thread is never blocked building the whole document.
|
|
82
|
+
const firstChunk = parsedBlocks.slice(0, FIRST_CHUNK);
|
|
83
|
+
const rest = parsedBlocks.slice(FIRST_CHUNK);
|
|
84
|
+
const inserted = insertInitial(firstChunk);
|
|
85
|
+
if (inserted === null)
|
|
86
|
+
return defaultPasteHandler();
|
|
87
|
+
let anchorId = lastBlockId(inserted);
|
|
88
|
+
let cursor = 0;
|
|
89
|
+
const pump = () => {
|
|
90
|
+
var _a;
|
|
91
|
+
if (!anchorId || cursor >= rest.length)
|
|
92
|
+
return;
|
|
93
|
+
const batch = rest.slice(cursor, cursor + REST_CHUNK);
|
|
94
|
+
cursor += REST_CHUNK;
|
|
95
|
+
try {
|
|
96
|
+
const insertedBatch = editor.insertBlocks(batch, anchorId, "after");
|
|
97
|
+
anchorId = (_a = lastBlockId(insertedBatch)) !== null && _a !== void 0 ? _a : anchorId;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return; // stop streaming on any structural error
|
|
101
|
+
}
|
|
102
|
+
if (cursor < rest.length)
|
|
103
|
+
scheduleIdle(pump);
|
|
104
|
+
};
|
|
105
|
+
scheduleIdle(pump);
|
|
59
106
|
}
|
|
60
107
|
editor.focus();
|
|
61
108
|
return true;
|
package/package/styles.css
CHANGED
|
@@ -1240,6 +1240,20 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin
|
|
|
1240
1240
|
min-height: 4rem;
|
|
1241
1241
|
}
|
|
1242
1242
|
|
|
1243
|
+
/* Static stand-in shown before a step's interactive editor is lazily mounted.
|
|
1244
|
+
Mirrors the OverType inner padding/typography so document height stays stable. */
|
|
1245
|
+
.bn-step-editor--preview {
|
|
1246
|
+
padding: 10px 12px;
|
|
1247
|
+
white-space: pre-wrap;
|
|
1248
|
+
word-break: break-word;
|
|
1249
|
+
color: #262626;
|
|
1250
|
+
cursor: text;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
html.dark .bn-step-editor--preview {
|
|
1254
|
+
color: #e5e5e5;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1243
1257
|
.bn-step-editor.bn-step-editor--focused {
|
|
1244
1258
|
outline: none;
|
|
1245
1259
|
box-shadow: none;
|
package/package.json
CHANGED
package/src/App.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useMemo, useState } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { BlockNoteView } from "@blocknote/mantine";
|
|
3
3
|
import {
|
|
4
4
|
useCreateBlockNote,
|
|
@@ -430,22 +430,42 @@ function App() {
|
|
|
430
430
|
document.documentElement.classList.toggle("dark", darkMode);
|
|
431
431
|
}, [darkMode]);
|
|
432
432
|
|
|
433
|
+
// Re-serializing the whole document (Markdown + pretty JSON) on every change
|
|
434
|
+
// is wasteful during bursts like a large paste, where the document mutates
|
|
435
|
+
// many times in quick succession (chunked streaming). Debounce so the preview
|
|
436
|
+
// panels update once the document settles instead of on every intermediate
|
|
437
|
+
// edit, keeping the editor responsive.
|
|
438
|
+
const serializeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
433
439
|
useEditorChange((editorInstance) => {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const md = blocksToMarkdown(documentBlocks);
|
|
437
|
-
setMarkdown(md);
|
|
438
|
-
setBlocksJson(JSON.stringify(documentBlocks, null, 2));
|
|
439
|
-
setConversionError(null);
|
|
440
|
-
setCopyStatus("idle");
|
|
441
|
-
setCopyBlocksStatus("idle");
|
|
442
|
-
} catch (error) {
|
|
443
|
-
setConversionError(error instanceof Error ? error.message : String(error));
|
|
444
|
-
setCopyStatus("idle");
|
|
445
|
-
setCopyBlocksStatus("idle");
|
|
440
|
+
if (serializeTimerRef.current !== null) {
|
|
441
|
+
clearTimeout(serializeTimerRef.current);
|
|
446
442
|
}
|
|
443
|
+
serializeTimerRef.current = setTimeout(() => {
|
|
444
|
+
serializeTimerRef.current = null;
|
|
445
|
+
try {
|
|
446
|
+
const documentBlocks = editorInstance.document as CustomEditorBlock[];
|
|
447
|
+
const md = blocksToMarkdown(documentBlocks);
|
|
448
|
+
setMarkdown(md);
|
|
449
|
+
setBlocksJson(JSON.stringify(documentBlocks, null, 2));
|
|
450
|
+
setConversionError(null);
|
|
451
|
+
setCopyStatus("idle");
|
|
452
|
+
setCopyBlocksStatus("idle");
|
|
453
|
+
} catch (error) {
|
|
454
|
+
setConversionError(error instanceof Error ? error.message : String(error));
|
|
455
|
+
setCopyStatus("idle");
|
|
456
|
+
setCopyBlocksStatus("idle");
|
|
457
|
+
}
|
|
458
|
+
}, 120);
|
|
447
459
|
}, editor);
|
|
448
460
|
|
|
461
|
+
useEffect(() => {
|
|
462
|
+
return () => {
|
|
463
|
+
if (serializeTimerRef.current !== null) {
|
|
464
|
+
clearTimeout(serializeTimerRef.current);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
}, []);
|
|
468
|
+
|
|
449
469
|
useEffect(() => {
|
|
450
470
|
if (!editor) {
|
|
451
471
|
return;
|
|
@@ -2,6 +2,7 @@ import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
|
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { StepField } from "./stepField";
|
|
4
4
|
import { StepHorizontalView } from "./stepHorizontalView";
|
|
5
|
+
import { useDeferredMount } from "./useDeferredMount";
|
|
5
6
|
import { useStepImageUpload } from "../stepImageUpload";
|
|
6
7
|
import type { StepSuggestion } from "../stepAutocomplete";
|
|
7
8
|
|
|
@@ -227,27 +228,176 @@ export function addSnippetBlock(editor: {
|
|
|
227
228
|
return inserted?.[1]?.id ?? null;
|
|
228
229
|
}
|
|
229
230
|
|
|
230
|
-
|
|
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
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Defers mounting of expensive block content until the element is at (or near)
|
|
5
|
+
* the viewport. Heavy blocks (e.g. test steps that each spin up an OverType
|
|
6
|
+
* editor) render a cheap placeholder first; the real interactive content is
|
|
7
|
+
* mounted only once the block scrolls into view. This keeps pasting/loading a
|
|
8
|
+
* large document fast — only the visible steps pay the editor-init cost up
|
|
9
|
+
* front, the rest are upgraded lazily as the user scrolls.
|
|
10
|
+
*
|
|
11
|
+
* Returns a ref to attach to the wrapper element and a boolean that flips to
|
|
12
|
+
* `true` once (and stays true — we never tear an editor back down).
|
|
13
|
+
*
|
|
14
|
+
* `activate(focus)` lets the caller upgrade eagerly on interaction. Passing
|
|
15
|
+
* `focus: true` (a click/focus on the placeholder) records that the freshly
|
|
16
|
+
* mounted content should take focus, so a single click on a preview starts
|
|
17
|
+
* editing. Passive activation (hover pre-warm, scroll-into-view) leaves focus
|
|
18
|
+
* alone via `shouldFocusOnActivate === false`.
|
|
19
|
+
*/
|
|
20
|
+
export function useDeferredMount<T extends HTMLElement>(
|
|
21
|
+
options: { rootMargin?: string; initiallyActive?: boolean } = {},
|
|
22
|
+
): {
|
|
23
|
+
ref: React.RefObject<T | null>;
|
|
24
|
+
active: boolean;
|
|
25
|
+
activate: (focus?: boolean) => void;
|
|
26
|
+
shouldFocusOnActivate: boolean;
|
|
27
|
+
} {
|
|
28
|
+
const { rootMargin = "300px 0px", initiallyActive = false } = options;
|
|
29
|
+
const ref = useRef<T>(null);
|
|
30
|
+
const [active, setActive] = useState(initiallyActive);
|
|
31
|
+
const activeRef = useRef(active);
|
|
32
|
+
activeRef.current = active;
|
|
33
|
+
const focusOnActivateRef = useRef(false);
|
|
34
|
+
|
|
35
|
+
const activate = (focus = false) => {
|
|
36
|
+
if (activeRef.current) return;
|
|
37
|
+
if (focus) focusOnActivateRef.current = true;
|
|
38
|
+
setActive(true);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (activeRef.current) return;
|
|
43
|
+
const el = ref.current;
|
|
44
|
+
if (!el) return;
|
|
45
|
+
|
|
46
|
+
// Environments without IntersectionObserver (or SSR) just mount eagerly.
|
|
47
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
48
|
+
setActive(true);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const observer = new IntersectionObserver(
|
|
53
|
+
(entries) => {
|
|
54
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
55
|
+
setActive(true);
|
|
56
|
+
observer.disconnect();
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
{ rootMargin },
|
|
60
|
+
);
|
|
61
|
+
observer.observe(el);
|
|
62
|
+
return () => observer.disconnect();
|
|
63
|
+
}, [rootMargin]);
|
|
64
|
+
|
|
65
|
+
return { ref, active, activate, shouldFocusOnActivate: focusOnActivateRef.current };
|
|
66
|
+
}
|