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.
@@ -1,9 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { StepField } from "./stepField";
4
+ import { StepField, StepFieldPreview } from "./stepField";
5
5
  import { StepHorizontalView } from "./stepHorizontalView";
6
- import { useDeferredMount } from "./useDeferredMount";
7
6
  import { useStepImageUpload } from "../stepImageUpload";
8
7
  const EXPECTED_COLLAPSED_KEY = "bn-expected-collapsed";
9
8
  const VIEW_MODE_KEY = "bn-step-view-mode";
@@ -46,6 +45,35 @@ const writeStepViewMode = (mode) => {
46
45
  //
47
46
  }
48
47
  };
48
+ /**
49
+ * Subscribes to the globally-shared step view mode. The mode lives in
50
+ * localStorage and changes are broadcast via the `bn-step-view-mode` event
51
+ * (same tab) and the `storage` event (other tabs), so toggling it on any step
52
+ * re-renders every step — including the read-only previews — into the new mode.
53
+ */
54
+ function useStepViewMode() {
55
+ const [viewMode, setViewMode] = useState(() => readStepViewMode());
56
+ useEffect(() => {
57
+ if (typeof window === "undefined") {
58
+ return;
59
+ }
60
+ const handleStorage = (event) => {
61
+ if (event.key === VIEW_MODE_KEY) {
62
+ setViewMode(readStepViewMode());
63
+ }
64
+ };
65
+ const handleLocal = () => {
66
+ setViewMode(readStepViewMode());
67
+ };
68
+ window.addEventListener("storage", handleStorage);
69
+ window.addEventListener("bn-step-view-mode", handleLocal);
70
+ return () => {
71
+ window.removeEventListener("storage", handleStorage);
72
+ window.removeEventListener("bn-step-view-mode", handleLocal);
73
+ };
74
+ }, []);
75
+ return viewMode;
76
+ }
49
77
  /**
50
78
  * Returns true when a normalised (lowercased, trailing-punctuation-stripped)
51
79
  * heading text looks like a "Steps" heading.
@@ -232,30 +260,28 @@ export function computeStepNumber(allBlocks, blockId) {
232
260
  }
233
261
  return count;
234
262
  }
235
- /** Strip the most common inline markdown markers for a readable static preview. */
236
- function stripMarkdownForPreview(text) {
237
- return text
238
- .replace(/!\[[^\]]*\]\([^)]*\)/g, "")
239
- .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
240
- .replace(/(\*\*|__|\*|_|~~|`)/g, "")
241
- .replace(/<\/?[^>]+>/g, "")
242
- .trim();
243
- }
244
263
  /**
245
- * Cheap static stand-in shown before a step's interactive editor is mounted.
246
- * Mirrors the real step's structure/typography so the document height stays
247
- * stable and so it reads correctly during the brief window before upgrade.
264
+ * Read-only stand-in rendered for every step that isn't currently being edited.
265
+ * It mirrors the live step's structure for the active view mode and renders each
266
+ * field via {@link StepFieldPreview} a faithful, formatted reading view with
267
+ * no OverType editor. Only the focused step ever mounts an editor, so scrolling
268
+ * never mounts/tears down editors and the list stays flicker-free.
269
+ *
270
+ * Field visibility is gated on the raw trimmed props (matching the live step's
271
+ * `isDataVisible`/`isExpectedVisible`) so the same fields appear in both states.
248
272
  */
249
- function TestStepPreview({ blockId, stepNumber, stepTitle, stepData, expectedResult, }) {
250
- const titleText = stripMarkdownForPreview(stepTitle);
251
- const dataText = stripMarkdownForPreview(stepData);
252
- const expectedText = stripMarkdownForPreview(expectedResult);
253
- return (_jsxs("div", { className: "bn-teststep", "data-block-id": blockId, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [_jsx("div", { className: "bn-teststep__header", children: _jsx("span", { className: "bn-teststep__title", children: "Step" }) }), _jsx("div", { className: "bn-step-field", children: _jsx("div", { className: "bn-step-editor bn-step-editor--multiline bn-step-editor--preview", children: titleText || " " }) }), dataText ? (_jsx("div", { className: "bn-step-field", children: _jsx("div", { className: "bn-step-editor bn-step-editor--multiline bn-step-editor--preview", children: dataText }) })) : null, expectedText ? (_jsx("div", { className: "bn-step-field", children: _jsx("div", { className: "bn-step-editor bn-step-editor--multiline bn-step-editor--preview", children: expectedText }) })) : null] })] }));
273
+ function TestStepPreview({ blockId, stepNumber, viewMode, stepTitle, stepData, expectedResult, }) {
274
+ const compactMode = viewMode === "compact";
275
+ const hasData = stepData.trim().length > 0;
276
+ const hasExpected = expectedResult.trim().length > 0;
277
+ return (_jsxs("div", { className: `bn-teststep${compactMode ? " bn-teststep--compact bn-teststep--collapsed" : ""}`, "data-block-id": blockId, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [!compactMode && (_jsx("div", { className: "bn-teststep__header", children: _jsx("span", { className: "bn-teststep__title", children: "Step" }) })), _jsx(StepFieldPreview, { value: stepTitle, fieldName: "title" }), hasData ? _jsx(StepFieldPreview, { value: stepData, fieldName: "data" }) : null, hasExpected ? _jsx(StepFieldPreview, { value: expectedResult, fieldName: "expected" }) : null] })] }));
254
278
  }
255
279
  /**
256
- * Wrapper that defers mounting the (expensive) interactive step editor until
257
- * the block scrolls into view. Off-screen steps render {@link TestStepPreview}
258
- * instead, which is what keeps pasting/loading a large test document fast.
280
+ * Wrapper that mounts the (expensive) interactive step editor only while the
281
+ * step is being edited. Every other step renders the read-only
282
+ * {@link TestStepPreview}, so a document of any size keeps at most one OverType
283
+ * editor alive — scrolling never mounts or tears down editors, which is what
284
+ * keeps the list flicker-free (and pasting/loading a large document fast).
259
285
  *
260
286
  * The step number is tracked here and pushed down as a prop. We subscribe to
261
287
  * editor changes but bail out of re-rendering when the number is unchanged, so
@@ -264,13 +290,16 @@ function TestStepPreview({ blockId, stepNumber, stepTitle, stepData, expectedRes
264
290
  function TestStepBlock({ block, editor }) {
265
291
  // An empty step is almost always a freshly-inserted one that needs to focus
266
292
  // its title immediately, so mount its real editor eagerly. Steps with content
267
- // (e.g. from a large paste) can safely start as a cheap preview.
293
+ // start as a cheap read-only preview and upgrade on click/focus.
268
294
  const isEmptyStep = !(block.props.stepTitle || "") &&
269
295
  !(block.props.stepData || "") &&
270
296
  !(block.props.expectedResult || "");
271
- const { ref, active, activate, shouldFocusOnActivate } = useDeferredMount({
272
- initiallyActive: isEmptyStep,
273
- });
297
+ const viewMode = useStepViewMode();
298
+ const [editing, setEditing] = useState(isEmptyStep);
299
+ // Set when editing begins from a click/focus on the preview, so the freshly
300
+ // mounted editor takes focus (a single click starts editing). Cleared after
301
+ // the editor consumes it.
302
+ const focusOnMountRef = useRef(false);
274
303
  const [stepNumber, setStepNumber] = useState(() => computeStepNumber(editor.document, block.id));
275
304
  useEditorChange(() => {
276
305
  // Recompute on change, but bail out of the state update (and therefore the
@@ -280,16 +309,29 @@ function TestStepBlock({ block, editor }) {
280
309
  const next = computeStepNumber(editor.document, block.id);
281
310
  setStepNumber((prev) => (prev === next ? prev : next));
282
311
  }, editor);
283
- if (active) {
312
+ const beginEditing = useCallback(() => {
313
+ focusOnMountRef.current = true;
314
+ setEditing(true);
315
+ }, []);
316
+ // Mousedown rather than click so the editor mounts before focus settles, and
317
+ // preventDefault so the browser doesn't move focus to <body> when the preview
318
+ // (the mousedown target) unmounts mid-click — that stray blur would otherwise
319
+ // immediately tear the new editor back down.
320
+ const beginEditingFromPointer = useCallback((event) => {
321
+ event.preventDefault();
322
+ beginEditing();
323
+ }, [beginEditing]);
324
+ const endEditing = useCallback(() => setEditing(false), []);
325
+ if (editing) {
284
326
  // Empty steps mounted eagerly (freshly inserted) auto-focus their title.
285
327
  // A preview upgraded by a click focuses its field too, so a single click
286
- // starts editing. Steps upgraded passively (scroll-into-view, hover
287
- // pre-warm) must never steal focus.
288
- return (_jsx(TestStepContent, { block: block, editor: editor, stepNumber: stepNumber, autoFocusEnabled: isEmptyStep, focusOnMount: shouldFocusOnActivate }));
328
+ // starts editing. The editor tears back down to a preview when focus
329
+ // leaves the step (see TestStepContent's blur handling).
330
+ return (_jsx(TestStepContent, { block: block, editor: editor, stepNumber: stepNumber, viewMode: viewMode, autoFocusEnabled: isEmptyStep, focusOnMount: focusOnMountRef.current, onEditEnd: endEditing }));
289
331
  }
290
- return (_jsx("div", { ref: ref, onMouseDownCapture: () => activate(true), onFocusCapture: () => activate(true), children: _jsx(TestStepPreview, { blockId: block.id, stepNumber: stepNumber, stepTitle: block.props.stepTitle || "", stepData: block.props.stepData || "", expectedResult: block.props.expectedResult || "" }) }));
332
+ return (_jsx("div", { className: "bn-teststep-preview-wrapper", tabIndex: 0, onMouseDownCapture: beginEditingFromPointer, onFocusCapture: beginEditing, children: _jsx(TestStepPreview, { blockId: block.id, stepNumber: stepNumber, viewMode: viewMode, stepTitle: block.props.stepTitle || "", stepData: block.props.stepData || "", expectedResult: block.props.expectedResult || "" }) }));
291
333
  }
292
- function TestStepContent({ block, editor, stepNumber, autoFocusEnabled = false, focusOnMount = false, }) {
334
+ function TestStepContent({ block, editor, stepNumber, viewMode, autoFocusEnabled = false, focusOnMount = false, onEditEnd, }) {
293
335
  // When a preview is upgraded by a click, focus its primary field once on
294
336
  // mount so a single click starts editing (caret at end).
295
337
  const mountFocusSignal = focusOnMount ? 1 : 0;
@@ -303,12 +345,8 @@ function TestStepContent({ block, editor, stepNumber, autoFocusEnabled = false,
303
345
  const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
304
346
  const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
305
347
  const uploadImage = useStepImageUpload();
306
- const [viewMode, setViewMode] = useState(() => readStepViewMode());
307
348
  const containerRef = useRef(null);
308
349
  const [forceVertical, setForceVertical] = useState(false);
309
- // In compact mode each step collapses to a reading-focused row and only
310
- // expands to the full editing layout while one of its fields has focus.
311
- const [expanded, setExpanded] = useState(false);
312
350
  useEffect(() => {
313
351
  var _a;
314
352
  const el = (_a = containerRef.current) === null || _a === void 0 ? void 0 : _a.parentElement;
@@ -324,28 +362,38 @@ function TestStepContent({ block, editor, stepNumber, autoFocusEnabled = false,
324
362
  }, []);
325
363
  const compactMode = viewMode === "compact";
326
364
  const effectiveHorizontal = viewMode === "horizontal" && !forceVertical;
327
- // Compact steps render the vertical layout but collapse their chrome until
328
- // a field gains focus, at which point the step expands to "normal" editing.
329
- const compactCollapsed = compactMode && !expanded;
365
+ // A mounted step is, by definition, the one being edited, so it always
366
+ // shows the full editing layout the collapsed reading row is now the
367
+ // read-only preview's job.
368
+ const compactCollapsed = false;
369
+ // Tear the editor back down to the read-only preview once focus leaves the
370
+ // whole step. Re-checked on the next frame so transient blurs (clicking a
371
+ // toolbar button, or the link popover that portals to <body>) don't
372
+ // collapse an active edit. Edits persist to block props on change, so
373
+ // unmounting never loses data. Re-bound when the layout (and thus the root
374
+ // element) changes so it always listens on the live root.
330
375
  useEffect(() => {
331
- if (typeof window === "undefined") {
376
+ const root = containerRef.current;
377
+ if (!root || !onEditEnd) {
332
378
  return;
333
379
  }
334
- const handleStorage = (event) => {
335
- if (event.key === VIEW_MODE_KEY) {
336
- setViewMode(readStepViewMode());
337
- }
338
- };
339
- const handleLocal = () => {
340
- setViewMode(readStepViewMode());
380
+ const handleFocusOut = () => {
381
+ // Defer to the next frame so focus moving *within* the step (or to a
382
+ // popover that portals to <body>, e.g. the link editor) has settled
383
+ // before we decide whether editing has really ended.
384
+ requestAnimationFrame(() => {
385
+ const active = document.activeElement;
386
+ if (active &&
387
+ (root.contains(active) ||
388
+ active.closest(".bn-popover-content, .bn-form-popover, [role='dialog']"))) {
389
+ return;
390
+ }
391
+ onEditEnd();
392
+ });
341
393
  };
342
- window.addEventListener("storage", handleStorage);
343
- window.addEventListener("bn-step-view-mode", handleLocal);
344
- return () => {
345
- window.removeEventListener("storage", handleStorage);
346
- window.removeEventListener("bn-step-view-mode", handleLocal);
347
- };
348
- }, []);
394
+ root.addEventListener("focusout", handleFocusOut);
395
+ return () => root.removeEventListener("focusout", handleFocusOut);
396
+ }, [onEditEnd, effectiveHorizontal]);
349
397
  const combinedStepValue = useMemo(() => {
350
398
  if (!stepData) {
351
399
  return stepTitle;
@@ -455,28 +503,13 @@ function TestStepContent({ block, editor, stepNumber, autoFocusEnabled = false,
455
503
  next = "vertical";
456
504
  }
457
505
  writeStepViewMode(next);
458
- setViewMode(next);
506
+ // The shared useStepViewMode hook (in every step, including this one)
507
+ // listens for this event and re-reads the mode, so we don't track it
508
+ // locally here.
459
509
  if (typeof window !== "undefined") {
460
510
  window.dispatchEvent(new Event("bn-step-view-mode"));
461
511
  }
462
512
  }, [viewMode, forceVertical]);
463
- const handleContentFocusCapture = useCallback(() => {
464
- if (viewMode === "compact") {
465
- setExpanded(true);
466
- }
467
- }, [viewMode]);
468
- const handleContentBlurCapture = useCallback((event) => {
469
- if (viewMode !== "compact") {
470
- return;
471
- }
472
- // Keep the step expanded while focus stays inside it (e.g. moving to a
473
- // toolbar or action button); collapse only when focus leaves entirely.
474
- const nextTarget = event.relatedTarget;
475
- if (nextTarget && event.currentTarget.contains(nextTarget)) {
476
- return;
477
- }
478
- setExpanded(false);
479
- }, [viewMode]);
480
513
  const [dataFocusSignal] = useState(0);
481
514
  const [expectedFocusSignal, setExpectedFocusSignal] = useState(0);
482
515
  const handleShowExpected = useCallback(() => {
@@ -500,7 +533,7 @@ function TestStepContent({ block, editor, stepNumber, autoFocusEnabled = false,
500
533
  if (effectiveHorizontal) {
501
534
  return (_jsx(StepHorizontalView, { ref: containerRef, blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: viewToggleButton, focusSignal: mountFocusSignal }));
502
535
  }
503
- return (_jsxs("div", { className: `bn-teststep${compactMode ? " bn-teststep--compact" : ""}${compactCollapsed ? " bn-teststep--collapsed" : ""}`, "data-block-id": block.id, ref: containerRef, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", onFocus: handleContentFocusCapture, onBlur: handleContentBlurCapture, children: [_jsxs("div", { className: "bn-teststep__header", children: [!compactMode && _jsx("span", { className: "bn-teststep__title", children: "Step" }), viewToggleButton] }), _jsx(StepField, { label: "Step", showLabel: false, value: stepTitle, placeholder: STEP_TITLE_PLACEHOLDER, onChange: handleStepTitleChange, autoFocus: autoFocusEnabled && stepTitle.length === 0, focusSignal: mountFocusSignal, multiline: true, disableNewlines: true, enableAutocomplete: true, fieldName: "title", compact: compactCollapsed, compactMode: compactMode, suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: handleFieldFocus, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
536
+ return (_jsxs("div", { className: `bn-teststep${compactMode ? " bn-teststep--compact" : ""}${compactCollapsed ? " bn-teststep--collapsed" : ""}`, "data-block-id": block.id, ref: containerRef, children: [_jsxs("div", { className: "bn-teststep__timeline", children: [_jsx("span", { className: "bn-teststep__number", children: stepNumber }), _jsx("div", { className: "bn-teststep__line" })] }), _jsxs("div", { className: "bn-teststep__content", children: [_jsxs("div", { className: "bn-teststep__header", children: [!compactMode && _jsx("span", { className: "bn-teststep__title", children: "Step" }), viewToggleButton] }), _jsx(StepField, { label: "Step", showLabel: false, value: stepTitle, placeholder: STEP_TITLE_PLACEHOLDER, onChange: handleStepTitleChange, autoFocus: autoFocusEnabled && stepTitle.length === 0, focusSignal: mountFocusSignal, multiline: true, disableNewlines: true, enableAutocomplete: true, fieldName: "title", compact: compactCollapsed, compactMode: compactMode, suggestionFilter: (suggestion) => suggestion.isSnippet !== true, onFieldFocus: handleFieldFocus, enableImageUpload: false, showFormattingButtons: true, onImageFile: async (file) => {
504
537
  if (!uploadImage) {
505
538
  return;
506
539
  }
@@ -67,5 +67,16 @@ export declare function applyInlineExclusion(formatting: FormattingMeta[], links
67
67
  links: LinkMeta[];
68
68
  };
69
69
  export declare function buildFullMarkdown(plainText: string, links: LinkMeta[], formatting: FormattingMeta[]): string;
70
+ /**
71
+ * Lightweight, non-interactive stand-in for {@link StepField}. Mounts no
72
+ * OverType editor, observers, or event handlers — just a styled
73
+ * `.bn-step-editor--preview` box whose markdown is rendered declaratively. Used
74
+ * for every step that isn't currently being edited.
75
+ */
76
+ export declare function StepFieldPreview({ value, fieldName, multiline, }: {
77
+ value: string;
78
+ fieldName?: string;
79
+ multiline?: boolean;
80
+ }): import("react/jsx-runtime").JSX.Element;
70
81
  export declare function StepField({ label, showLabel, labelToggle, labelAction, placeholder, value, onChange, autoFocus, focusSignal, multiline, disableNewlines, enableAutocomplete, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly, showSuggestionsOnFocus, enableImageUpload, onImageFile, rightAction, showFormattingButtons, showImageButton, onFieldFocus, compact, compactMode, }: StepFieldProps): import("react/jsx-runtime").JSX.Element;
71
82
  export {};
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import OverType from "overtype";
3
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { useComponentsContext } from "@blocknote/react";
5
5
  import { EditLinkMenuItems } from "@blocknote/react";
6
6
  import { useStepAutocomplete } from "../stepAutocomplete";
@@ -15,6 +15,12 @@ const READ_ONLY_ALLOWED_KEYS = new Set([
15
15
  ]);
16
16
  const AUTOCOMPLETE_TRIGGER_KEYS = new Set([" ", "Space"]);
17
17
  const markdownParser = OverType.MarkdownParser;
18
+ /**
19
+ * `useLayoutEffect` that degrades to `useEffect` outside the browser so SSR
20
+ * doesn't warn. Static previews and the OverType mount run pre-paint to avoid a
21
+ * blank/jumping frame on the click→edit swap.
22
+ */
23
+ const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
18
24
  function ImageUploadIcon() {
19
25
  return (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", "aria-hidden": "true", focusable: "false", children: _jsx("path", { d: "M12.667 2C13.0335 2.00008 13.3474 2.13057 13.6084 2.3916C13.8694 2.65264 13.9999 2.96648 14 3.33301V12.667C13.9999 13.0335 13.8694 13.3474 13.6084 13.6084C13.3474 13.8694 13.0335 13.9999 12.667 14H3.33301C2.96648 13.9999 2.65264 13.8694 2.3916 13.6084C2.13057 13.3474 2.00008 13.0335 2 12.667V3.33301C2.00008 2.96648 2.13057 2.65264 2.3916 2.3916C2.65264 2.13057 2.96648 2.00008 3.33301 2H12.667ZM3.33301 12.667H12.667V3.33301H3.33301V12.667ZM12 11.333H4L6 8.66699L7.5 10.667L9.5 8L12 11.333ZM5.66699 4.66699C5.94455 4.66707 6.18066 4.76375 6.375 4.95801C6.56944 5.15245 6.66699 5.38921 6.66699 5.66699C6.66692 5.94463 6.56937 6.18063 6.375 6.375C6.18063 6.56937 5.94463 6.66692 5.66699 6.66699C5.38921 6.66699 5.15245 6.56944 4.95801 6.375C4.76375 6.18066 4.66707 5.94455 4.66699 5.66699C4.66699 5.38921 4.76356 5.15245 4.95801 4.95801C5.15245 4.76356 5.38921 4.66699 5.66699 4.66699Z", fill: "currentColor" }) }));
20
26
  }
@@ -532,6 +538,107 @@ function markdownToPlainText(markdown) {
532
538
  return markdown.replace(/!\[[^\]]*]\([^)]+\)/g, "").replace(/\[[^\]]*]\([^)]+\)/g, "").replace(/[*_`~]/g, "").replace(/\s+/g, " ").trim();
533
539
  }
534
540
  }
541
+ const IMAGE_SYNTAX = /!\[([^\]]*)\]\(([^)]+)\)/g;
542
+ /**
543
+ * Render a run of plain text, turning any `![alt](url)` markdown into real
544
+ * `<img>` elements. Returns a string when there are no images (so simple text
545
+ * stays a plain text node), otherwise a keyed array of strings and images.
546
+ */
547
+ function renderTextWithImages(text, key) {
548
+ if (!text.includes("![")) {
549
+ return text;
550
+ }
551
+ const nodes = [];
552
+ IMAGE_SYNTAX.lastIndex = 0;
553
+ let last = 0;
554
+ let part = 0;
555
+ let match;
556
+ while ((match = IMAGE_SYNTAX.exec(text)) !== null) {
557
+ if (match.index > last) {
558
+ nodes.push(_jsx(Fragment, { children: text.slice(last, match.index) }, `${key}-t${part++}`));
559
+ }
560
+ nodes.push(_jsx("img", { src: match[2], alt: match[1] || "Step image" }, `${key}-i${part++}`));
561
+ last = match.index + match[0].length;
562
+ }
563
+ if (last < text.length) {
564
+ nodes.push(_jsx(Fragment, { children: text.slice(last) }, `${key}-t${part++}`));
565
+ }
566
+ return nodes;
567
+ }
568
+ /**
569
+ * Render a step field's markdown as a faithful, read-only reading view: the same
570
+ * clean text + bold/italic/code/link decorations + inline images the live
571
+ * OverType preview shows, but as plain React children (no editor, no refs, no
572
+ * imperative DOM — BlockNote's node-view renderer doesn't attach refs the way a
573
+ * normal React commit does, so the content must be declarative).
574
+ *
575
+ * Decorations are applied by slicing the plain text at every formatting/link
576
+ * boundary and wrapping each segment in the same `step-preview-*` elements the
577
+ * live editor uses, so all existing CSS applies unchanged.
578
+ */
579
+ function renderStepFieldContent(value) {
580
+ const { plainText, links, formatting } = stripInlineMarkdown(value);
581
+ if (!plainText) {
582
+ return null;
583
+ }
584
+ if (formatting.length === 0 && links.length === 0) {
585
+ return renderTextWithImages(plainText, "p");
586
+ }
587
+ const len = plainText.length;
588
+ const points = new Set([0, len]);
589
+ for (const f of formatting) {
590
+ points.add(Math.max(0, f.start));
591
+ points.add(Math.min(len, f.end));
592
+ }
593
+ for (const l of links) {
594
+ points.add(Math.max(0, l.start));
595
+ points.add(Math.min(len, l.end));
596
+ }
597
+ const sorted = [...points].sort((a, b) => a - b);
598
+ const out = [];
599
+ for (let i = 0; i < sorted.length - 1; i++) {
600
+ const a = sorted[i];
601
+ const b = sorted[i + 1];
602
+ if (a >= b) {
603
+ continue;
604
+ }
605
+ const text = plainText.slice(a, b);
606
+ const fmts = new Set(formatting.filter((f) => f.start <= a && f.end >= b).map((f) => f.type));
607
+ const link = links.find((l) => l.start <= a && l.end >= b);
608
+ let node = renderTextWithImages(text, `s${i}`);
609
+ if (fmts.has("code")) {
610
+ node = _jsx("code", { className: "step-preview-code", children: node });
611
+ }
612
+ if (fmts.has("italic")) {
613
+ node = _jsx("em", { className: "step-preview-italic", children: node });
614
+ }
615
+ if (fmts.has("bold")) {
616
+ node = _jsx("strong", { className: "step-preview-bold", children: node });
617
+ }
618
+ if (link) {
619
+ node = (_jsx("a", { className: "step-preview-link", href: link.url, children: node }));
620
+ }
621
+ out.push(_jsx(Fragment, { children: node }, i));
622
+ }
623
+ return out;
624
+ }
625
+ /**
626
+ * Lightweight, non-interactive stand-in for {@link StepField}. Mounts no
627
+ * OverType editor, observers, or event handlers — just a styled
628
+ * `.bn-step-editor--preview` box whose markdown is rendered declaratively. Used
629
+ * for every step that isn't currently being edited.
630
+ */
631
+ export function StepFieldPreview({ value, fieldName, multiline = true, }) {
632
+ const content = useMemo(() => renderStepFieldContent(value), [value]);
633
+ const editorClassName = [
634
+ "bn-step-editor",
635
+ multiline ? "bn-step-editor--multiline" : "",
636
+ "bn-step-editor--preview",
637
+ ]
638
+ .filter(Boolean)
639
+ .join(" ");
640
+ return (_jsx("div", { className: "bn-step-field", children: _jsx("div", { className: editorClassName, "data-step-field": fieldName, children: content }) }));
641
+ }
535
642
  export function StepField({ label, showLabel = true, labelToggle, labelAction, placeholder, value, onChange, autoFocus, focusSignal, multiline = false, disableNewlines = false, enableAutocomplete = false, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly = false, showSuggestionsOnFocus = false, enableImageUpload = false, onImageFile, rightAction, showFormattingButtons = false, showImageButton = false, onFieldFocus, compact = false, compactMode = false, }) {
536
643
  var _a, _b;
537
644
  const stepSuggestions = useStepAutocomplete();
@@ -544,6 +651,10 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
544
651
  const autoFocusRef = useRef(false);
545
652
  const pendingFocusRef = useRef(false);
546
653
  const initialValueRef = useRef(value);
654
+ // Read at OverType init so the editor mounts already-collapsed in compact
655
+ // mode (no tall first frame before the compact layout effect runs).
656
+ const compactModeRef = useRef(compactMode);
657
+ compactModeRef.current = compactMode;
547
658
  const onChangeRef = useRef(onChange);
548
659
  const [plainTextValue, setPlainTextValue] = useState(() => markdownToPlainText(value));
549
660
  const [isFocused, setIsFocused] = useState(false);
@@ -611,7 +722,7 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
611
722
  });
612
723
  (_a = onChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onChangeRef, markdown);
613
724
  }, [pushUndoSnapshot]);
614
- useEffect(() => {
725
+ useIsomorphicLayoutEffect(() => {
615
726
  const container = editorContainerRef.current;
616
727
  if (!container) {
617
728
  return;
@@ -629,11 +740,16 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
629
740
  value: plainText,
630
741
  placeholder: resolvedPlaceholder,
631
742
  autoResize: multiline,
632
- minHeight: multiline ? "4rem" : "2.5rem",
743
+ // Seed the compact floor at init so a clicked step paints already
744
+ // collapsed — the compact layout effect below keeps it in sync after.
745
+ minHeight: compactModeRef.current ? "0px" : multiline ? "4rem" : "2.5rem",
633
746
  padding: "0.5rem 0.75rem",
634
747
  fontSize: "0.95rem",
635
748
  onChange: handleEditorChange,
636
749
  });
750
+ if (compactModeRef.current && instance.textarea) {
751
+ instance.textarea.rows = 1;
752
+ }
637
753
  // Monkey-patch updatePreview to add link highlights
638
754
  const originalUpdatePreview = instance.updatePreview.bind(instance);
639
755
  instance.updatePreview = function () {
@@ -767,7 +883,7 @@ export function StepField({ label, showLabel = true, labelToggle, labelAction, p
767
883
  // so caret and value survive. Driven by the stable compactMode flag (not
768
884
  // `compact`) so collapsed and expanded share one height — focusing never
769
885
  // shifts the layout.
770
- useEffect(() => {
886
+ useIsomorphicLayoutEffect(() => {
771
887
  var _a;
772
888
  const instance = editorInstanceRef.current;
773
889
  if (!(instance === null || instance === void 0 ? void 0 : instance.options)) {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.74",
3
+ "version": "0.4.75",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",