testomatio-editor-blocks 0.4.74 → 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/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/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,8 +1,7 @@
|
|
|
1
1
|
import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type
|
|
3
|
-
import { StepField } from "./stepField";
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
|
|
3
|
+
import { StepField, StepFieldPreview } from "./stepField";
|
|
4
4
|
import { StepHorizontalView } from "./stepHorizontalView";
|
|
5
|
-
import { useDeferredMount } from "./useDeferredMount";
|
|
6
5
|
import { useStepImageUpload } from "../stepImageUpload";
|
|
7
6
|
import type { StepSuggestion } from "../stepAutocomplete";
|
|
8
7
|
|
|
@@ -50,6 +49,36 @@ const writeStepViewMode = (mode: StepViewMode) => {
|
|
|
50
49
|
}
|
|
51
50
|
};
|
|
52
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Subscribes to the globally-shared step view mode. The mode lives in
|
|
54
|
+
* localStorage and changes are broadcast via the `bn-step-view-mode` event
|
|
55
|
+
* (same tab) and the `storage` event (other tabs), so toggling it on any step
|
|
56
|
+
* re-renders every step — including the read-only previews — into the new mode.
|
|
57
|
+
*/
|
|
58
|
+
function useStepViewMode(): StepViewMode {
|
|
59
|
+
const [viewMode, setViewMode] = useState<StepViewMode>(() => readStepViewMode());
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (typeof window === "undefined") {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const handleStorage = (event: StorageEvent) => {
|
|
65
|
+
if (event.key === VIEW_MODE_KEY) {
|
|
66
|
+
setViewMode(readStepViewMode());
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
const handleLocal = () => {
|
|
70
|
+
setViewMode(readStepViewMode());
|
|
71
|
+
};
|
|
72
|
+
window.addEventListener("storage", handleStorage);
|
|
73
|
+
window.addEventListener("bn-step-view-mode", handleLocal as EventListener);
|
|
74
|
+
return () => {
|
|
75
|
+
window.removeEventListener("storage", handleStorage);
|
|
76
|
+
window.removeEventListener("bn-step-view-mode", handleLocal as EventListener);
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
return viewMode;
|
|
80
|
+
}
|
|
81
|
+
|
|
53
82
|
/**
|
|
54
83
|
* Returns true when a normalised (lowercased, trailing-punctuation-stripped)
|
|
55
84
|
* heading text looks like a "Steps" heading.
|
|
@@ -251,76 +280,64 @@ export function computeStepNumber(allBlocks: any[], blockId: string): number {
|
|
|
251
280
|
return count;
|
|
252
281
|
}
|
|
253
282
|
|
|
254
|
-
/** Strip the most common inline markdown markers for a readable static preview. */
|
|
255
|
-
function stripMarkdownForPreview(text: string): string {
|
|
256
|
-
return text
|
|
257
|
-
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
|
|
258
|
-
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
259
|
-
.replace(/(\*\*|__|\*|_|~~|`)/g, "")
|
|
260
|
-
.replace(/<\/?[^>]+>/g, "")
|
|
261
|
-
.trim();
|
|
262
|
-
}
|
|
263
|
-
|
|
264
283
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
284
|
+
* Read-only stand-in rendered for every step that isn't currently being edited.
|
|
285
|
+
* It mirrors the live step's structure for the active view mode and renders each
|
|
286
|
+
* field via {@link StepFieldPreview} — a faithful, formatted reading view with
|
|
287
|
+
* no OverType editor. Only the focused step ever mounts an editor, so scrolling
|
|
288
|
+
* never mounts/tears down editors and the list stays flicker-free.
|
|
289
|
+
*
|
|
290
|
+
* Field visibility is gated on the raw trimmed props (matching the live step's
|
|
291
|
+
* `isDataVisible`/`isExpectedVisible`) so the same fields appear in both states.
|
|
268
292
|
*/
|
|
269
293
|
function TestStepPreview({
|
|
270
294
|
blockId,
|
|
271
295
|
stepNumber,
|
|
296
|
+
viewMode,
|
|
272
297
|
stepTitle,
|
|
273
298
|
stepData,
|
|
274
299
|
expectedResult,
|
|
275
300
|
}: {
|
|
276
301
|
blockId: string;
|
|
277
302
|
stepNumber: number;
|
|
303
|
+
viewMode: StepViewMode;
|
|
278
304
|
stepTitle: string;
|
|
279
305
|
stepData: string;
|
|
280
306
|
expectedResult: string;
|
|
281
307
|
}) {
|
|
282
|
-
const
|
|
283
|
-
const
|
|
284
|
-
const
|
|
308
|
+
const compactMode = viewMode === "compact";
|
|
309
|
+
const hasData = stepData.trim().length > 0;
|
|
310
|
+
const hasExpected = expectedResult.trim().length > 0;
|
|
285
311
|
|
|
286
312
|
return (
|
|
287
|
-
<div
|
|
313
|
+
<div
|
|
314
|
+
className={`bn-teststep${compactMode ? " bn-teststep--compact bn-teststep--collapsed" : ""}`}
|
|
315
|
+
data-block-id={blockId}
|
|
316
|
+
>
|
|
288
317
|
<div className="bn-teststep__timeline">
|
|
289
318
|
<span className="bn-teststep__number">{stepNumber}</span>
|
|
290
319
|
<div className="bn-teststep__line" />
|
|
291
320
|
</div>
|
|
292
321
|
<div className="bn-teststep__content">
|
|
293
|
-
|
|
294
|
-
<
|
|
295
|
-
|
|
296
|
-
<div className="bn-step-field">
|
|
297
|
-
<div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
|
|
298
|
-
{titleText || " "}
|
|
299
|
-
</div>
|
|
300
|
-
</div>
|
|
301
|
-
{dataText ? (
|
|
302
|
-
<div className="bn-step-field">
|
|
303
|
-
<div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
|
|
304
|
-
{dataText}
|
|
305
|
-
</div>
|
|
306
|
-
</div>
|
|
307
|
-
) : null}
|
|
308
|
-
{expectedText ? (
|
|
309
|
-
<div className="bn-step-field">
|
|
310
|
-
<div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
|
|
311
|
-
{expectedText}
|
|
312
|
-
</div>
|
|
322
|
+
{!compactMode && (
|
|
323
|
+
<div className="bn-teststep__header">
|
|
324
|
+
<span className="bn-teststep__title">Step</span>
|
|
313
325
|
</div>
|
|
314
|
-
)
|
|
326
|
+
)}
|
|
327
|
+
<StepFieldPreview value={stepTitle} fieldName="title" />
|
|
328
|
+
{hasData ? <StepFieldPreview value={stepData} fieldName="data" /> : null}
|
|
329
|
+
{hasExpected ? <StepFieldPreview value={expectedResult} fieldName="expected" /> : null}
|
|
315
330
|
</div>
|
|
316
331
|
</div>
|
|
317
332
|
);
|
|
318
333
|
}
|
|
319
334
|
|
|
320
335
|
/**
|
|
321
|
-
* Wrapper that
|
|
322
|
-
*
|
|
323
|
-
*
|
|
336
|
+
* Wrapper that mounts the (expensive) interactive step editor only while the
|
|
337
|
+
* step is being edited. Every other step renders the read-only
|
|
338
|
+
* {@link TestStepPreview}, so a document of any size keeps at most one OverType
|
|
339
|
+
* editor alive — scrolling never mounts or tears down editors, which is what
|
|
340
|
+
* keeps the list flicker-free (and pasting/loading a large document fast).
|
|
324
341
|
*
|
|
325
342
|
* The step number is tracked here and pushed down as a prop. We subscribe to
|
|
326
343
|
* editor changes but bail out of re-rendering when the number is unchanged, so
|
|
@@ -329,14 +346,17 @@ function TestStepPreview({
|
|
|
329
346
|
function TestStepBlock({ block, editor }: { block: any; editor: any }) {
|
|
330
347
|
// An empty step is almost always a freshly-inserted one that needs to focus
|
|
331
348
|
// its title immediately, so mount its real editor eagerly. Steps with content
|
|
332
|
-
//
|
|
349
|
+
// start as a cheap read-only preview and upgrade on click/focus.
|
|
333
350
|
const isEmptyStep =
|
|
334
351
|
!((block.props.stepTitle as string) || "") &&
|
|
335
352
|
!((block.props.stepData as string) || "") &&
|
|
336
353
|
!((block.props.expectedResult as string) || "");
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
354
|
+
const viewMode = useStepViewMode();
|
|
355
|
+
const [editing, setEditing] = useState(isEmptyStep);
|
|
356
|
+
// Set when editing begins from a click/focus on the preview, so the freshly
|
|
357
|
+
// mounted editor takes focus (a single click starts editing). Cleared after
|
|
358
|
+
// the editor consumes it.
|
|
359
|
+
const focusOnMountRef = useRef(false);
|
|
340
360
|
const [stepNumber, setStepNumber] = useState(() =>
|
|
341
361
|
computeStepNumber(editor.document, block.id),
|
|
342
362
|
);
|
|
@@ -350,31 +370,54 @@ function TestStepBlock({ block, editor }: { block: any; editor: any }) {
|
|
|
350
370
|
setStepNumber((prev) => (prev === next ? prev : next));
|
|
351
371
|
}, editor);
|
|
352
372
|
|
|
353
|
-
|
|
373
|
+
const beginEditing = useCallback(() => {
|
|
374
|
+
focusOnMountRef.current = true;
|
|
375
|
+
setEditing(true);
|
|
376
|
+
}, []);
|
|
377
|
+
|
|
378
|
+
// Mousedown rather than click so the editor mounts before focus settles, and
|
|
379
|
+
// preventDefault so the browser doesn't move focus to <body> when the preview
|
|
380
|
+
// (the mousedown target) unmounts mid-click — that stray blur would otherwise
|
|
381
|
+
// immediately tear the new editor back down.
|
|
382
|
+
const beginEditingFromPointer = useCallback(
|
|
383
|
+
(event: ReactMouseEvent) => {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
beginEditing();
|
|
386
|
+
},
|
|
387
|
+
[beginEditing],
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
const endEditing = useCallback(() => setEditing(false), []);
|
|
391
|
+
|
|
392
|
+
if (editing) {
|
|
354
393
|
// Empty steps mounted eagerly (freshly inserted) auto-focus their title.
|
|
355
394
|
// A preview upgraded by a click focuses its field too, so a single click
|
|
356
|
-
// starts editing.
|
|
357
|
-
//
|
|
395
|
+
// starts editing. The editor tears back down to a preview when focus
|
|
396
|
+
// leaves the step (see TestStepContent's blur handling).
|
|
358
397
|
return (
|
|
359
398
|
<TestStepContent
|
|
360
399
|
block={block}
|
|
361
400
|
editor={editor}
|
|
362
401
|
stepNumber={stepNumber}
|
|
402
|
+
viewMode={viewMode}
|
|
363
403
|
autoFocusEnabled={isEmptyStep}
|
|
364
|
-
focusOnMount={
|
|
404
|
+
focusOnMount={focusOnMountRef.current}
|
|
405
|
+
onEditEnd={endEditing}
|
|
365
406
|
/>
|
|
366
407
|
);
|
|
367
408
|
}
|
|
368
409
|
|
|
369
410
|
return (
|
|
370
411
|
<div
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
412
|
+
className="bn-teststep-preview-wrapper"
|
|
413
|
+
tabIndex={0}
|
|
414
|
+
onMouseDownCapture={beginEditingFromPointer}
|
|
415
|
+
onFocusCapture={beginEditing}
|
|
374
416
|
>
|
|
375
417
|
<TestStepPreview
|
|
376
418
|
blockId={block.id}
|
|
377
419
|
stepNumber={stepNumber}
|
|
420
|
+
viewMode={viewMode}
|
|
378
421
|
stepTitle={(block.props.stepTitle as string) || ""}
|
|
379
422
|
stepData={(block.props.stepData as string) || ""}
|
|
380
423
|
expectedResult={(block.props.expectedResult as string) || ""}
|
|
@@ -387,14 +430,18 @@ function TestStepContent({
|
|
|
387
430
|
block,
|
|
388
431
|
editor,
|
|
389
432
|
stepNumber,
|
|
433
|
+
viewMode,
|
|
390
434
|
autoFocusEnabled = false,
|
|
391
435
|
focusOnMount = false,
|
|
436
|
+
onEditEnd,
|
|
392
437
|
}: {
|
|
393
438
|
block: any;
|
|
394
439
|
editor: any;
|
|
395
440
|
stepNumber: number;
|
|
441
|
+
viewMode: StepViewMode;
|
|
396
442
|
autoFocusEnabled?: boolean;
|
|
397
443
|
focusOnMount?: boolean;
|
|
444
|
+
onEditEnd?: () => void;
|
|
398
445
|
}) {
|
|
399
446
|
// When a preview is upgraded by a click, focus its primary field once on
|
|
400
447
|
// mount so a single click starts editing (caret at end).
|
|
@@ -411,12 +458,8 @@ function TestStepContent({
|
|
|
411
458
|
const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
|
|
412
459
|
const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
|
|
413
460
|
const uploadImage = useStepImageUpload();
|
|
414
|
-
const [viewMode, setViewMode] = useState<StepViewMode>(() => readStepViewMode());
|
|
415
461
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
416
462
|
const [forceVertical, setForceVertical] = useState(false);
|
|
417
|
-
// In compact mode each step collapses to a reading-focused row and only
|
|
418
|
-
// expands to the full editing layout while one of its fields has focus.
|
|
419
|
-
const [expanded, setExpanded] = useState(false);
|
|
420
463
|
|
|
421
464
|
useEffect(() => {
|
|
422
465
|
const el = containerRef.current?.parentElement;
|
|
@@ -432,29 +475,41 @@ function TestStepContent({
|
|
|
432
475
|
|
|
433
476
|
const compactMode = viewMode === "compact";
|
|
434
477
|
const effectiveHorizontal = viewMode === "horizontal" && !forceVertical;
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
478
|
+
// A mounted step is, by definition, the one being edited, so it always
|
|
479
|
+
// shows the full editing layout — the collapsed reading row is now the
|
|
480
|
+
// read-only preview's job.
|
|
481
|
+
const compactCollapsed = false;
|
|
482
|
+
|
|
483
|
+
// Tear the editor back down to the read-only preview once focus leaves the
|
|
484
|
+
// whole step. Re-checked on the next frame so transient blurs (clicking a
|
|
485
|
+
// toolbar button, or the link popover that portals to <body>) don't
|
|
486
|
+
// collapse an active edit. Edits persist to block props on change, so
|
|
487
|
+
// unmounting never loses data. Re-bound when the layout (and thus the root
|
|
488
|
+
// element) changes so it always listens on the live root.
|
|
439
489
|
useEffect(() => {
|
|
440
|
-
|
|
490
|
+
const root = containerRef.current;
|
|
491
|
+
if (!root || !onEditEnd) {
|
|
441
492
|
return;
|
|
442
493
|
}
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
494
|
+
const handleFocusOut = () => {
|
|
495
|
+
// Defer to the next frame so focus moving *within* the step (or to a
|
|
496
|
+
// popover that portals to <body>, e.g. the link editor) has settled
|
|
497
|
+
// before we decide whether editing has really ended.
|
|
498
|
+
requestAnimationFrame(() => {
|
|
499
|
+
const active = document.activeElement;
|
|
500
|
+
if (
|
|
501
|
+
active &&
|
|
502
|
+
(root.contains(active) ||
|
|
503
|
+
active.closest(".bn-popover-content, .bn-form-popover, [role='dialog']"))
|
|
504
|
+
) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
onEditEnd();
|
|
508
|
+
});
|
|
456
509
|
};
|
|
457
|
-
|
|
510
|
+
root.addEventListener("focusout", handleFocusOut);
|
|
511
|
+
return () => root.removeEventListener("focusout", handleFocusOut);
|
|
512
|
+
}, [onEditEnd, effectiveHorizontal]);
|
|
458
513
|
|
|
459
514
|
const combinedStepValue = useMemo(() => {
|
|
460
515
|
if (!stepData) {
|
|
@@ -588,34 +643,14 @@ function TestStepContent({
|
|
|
588
643
|
next = "vertical";
|
|
589
644
|
}
|
|
590
645
|
writeStepViewMode(next);
|
|
591
|
-
|
|
646
|
+
// The shared useStepViewMode hook (in every step, including this one)
|
|
647
|
+
// listens for this event and re-reads the mode, so we don't track it
|
|
648
|
+
// locally here.
|
|
592
649
|
if (typeof window !== "undefined") {
|
|
593
650
|
window.dispatchEvent(new Event("bn-step-view-mode"));
|
|
594
651
|
}
|
|
595
652
|
}, [viewMode, forceVertical]);
|
|
596
653
|
|
|
597
|
-
const handleContentFocusCapture = useCallback(() => {
|
|
598
|
-
if (viewMode === "compact") {
|
|
599
|
-
setExpanded(true);
|
|
600
|
-
}
|
|
601
|
-
}, [viewMode]);
|
|
602
|
-
|
|
603
|
-
const handleContentBlurCapture = useCallback(
|
|
604
|
-
(event: FocusEvent<HTMLDivElement>) => {
|
|
605
|
-
if (viewMode !== "compact") {
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
// Keep the step expanded while focus stays inside it (e.g. moving to a
|
|
609
|
-
// toolbar or action button); collapse only when focus leaves entirely.
|
|
610
|
-
const nextTarget = event.relatedTarget as Node | null;
|
|
611
|
-
if (nextTarget && event.currentTarget.contains(nextTarget)) {
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
setExpanded(false);
|
|
615
|
-
},
|
|
616
|
-
[viewMode],
|
|
617
|
-
);
|
|
618
|
-
|
|
619
654
|
const [dataFocusSignal] = useState(0);
|
|
620
655
|
const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);
|
|
621
656
|
|
|
@@ -694,11 +729,7 @@ function TestStepContent({
|
|
|
694
729
|
<span className="bn-teststep__number">{stepNumber}</span>
|
|
695
730
|
<div className="bn-teststep__line" />
|
|
696
731
|
</div>
|
|
697
|
-
<div
|
|
698
|
-
className="bn-teststep__content"
|
|
699
|
-
onFocus={handleContentFocusCapture}
|
|
700
|
-
onBlur={handleContentBlurCapture}
|
|
701
|
-
>
|
|
732
|
+
<div className="bn-teststep__content">
|
|
702
733
|
<div className="bn-teststep__header">
|
|
703
734
|
{!compactMode && <span className="bn-teststep__title">Step</span>}
|
|
704
735
|
{viewToggleButton}
|
|
@@ -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 };
|
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;
|