diff --git a/src/editor/blocks/step.tsx b/src/editor/blocks/step.tsx index ddb05ea..e517db0 100644 --- a/src/editor/blocks/step.tsx +++ b/src/editor/blocks/step.tsx @@ -1,5 +1,5 @@ import { createReactBlockSpec, useEditorChange } from "@blocknote/react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type FocusEvent } from "react"; import { StepField } from "./stepField"; import { StepHorizontalView } from "./stepHorizontalView"; import { useDeferredMount } from "./useDeferredMount"; @@ -11,7 +11,7 @@ const VIEW_MODE_KEY = "bn-step-view-mode"; const STEP_TITLE_PLACEHOLDER = "Enter step title..."; const STEP_DATA_PLACEHOLDER = "Enter step data..."; const EXPECTED_RESULT_PLACEHOLDER = "Enter expected result..."; -type StepViewMode = "vertical" | "horizontal"; +type StepViewMode = "vertical" | "horizontal" | "compact"; const FORCE_VERTICAL_WIDTH = 550; /* readExpectedCollapsedPreference removed — currently unused */ @@ -32,7 +32,8 @@ const readStepViewMode = (): StepViewMode => { return "vertical"; } try { - return window.localStorage.getItem(VIEW_MODE_KEY) === "horizontal" ? "horizontal" : "vertical"; + const stored = window.localStorage.getItem(VIEW_MODE_KEY); + return stored === "horizontal" || stored === "compact" ? stored : "vertical"; } catch { return "vertical"; } @@ -413,6 +414,9 @@ function TestStepContent({ const [viewMode, setViewMode] = useState(() => readStepViewMode()); const containerRef = useRef(null); const [forceVertical, setForceVertical] = useState(false); + // In compact mode each step collapses to a reading-focused row and only + // expands to the full editing layout while one of its fields has focus. + const [expanded, setExpanded] = useState(false); useEffect(() => { const el = containerRef.current?.parentElement; @@ -426,7 +430,11 @@ function TestStepContent({ return () => observer.disconnect(); }, []); - const effectiveVertical = forceVertical || viewMode === "vertical"; + const compactMode = viewMode === "compact"; + const effectiveHorizontal = viewMode === "horizontal" && !forceVertical; + // Compact steps render the vertical layout but collapse their chrome until + // a field gains focus, at which point the step expands to "normal" editing. + const compactCollapsed = compactMode && !expanded; useEffect(() => { if (typeof window === "undefined") { @@ -569,14 +577,45 @@ function TestStepContent({ }, [editor, block.id]); const handleToggleView = useCallback(() => { - const next = viewMode === "horizontal" ? "vertical" : "horizontal"; + // Cycle vertical → horizontal → compact → vertical. Skip horizontal when + // the container is too narrow to fit its two columns. + let next: StepViewMode; + if (viewMode === "vertical") { + next = forceVertical ? "compact" : "horizontal"; + } else if (viewMode === "horizontal") { + next = "compact"; + } else { + next = "vertical"; + } writeStepViewMode(next); setViewMode(next); if (typeof window !== "undefined") { window.dispatchEvent(new Event("bn-step-view-mode")); } + }, [viewMode, forceVertical]); + + const handleContentFocusCapture = useCallback(() => { + if (viewMode === "compact") { + setExpanded(true); + } }, [viewMode]); + const handleContentBlurCapture = useCallback( + (event: FocusEvent) => { + if (viewMode !== "compact") { + return; + } + // Keep the step expanded while focus stays inside it (e.g. moving to a + // toolbar or action button); collapse only when focus leaves entirely. + const nextTarget = event.relatedTarget as Node | null; + if (nextTarget && event.currentTarget.contains(nextTarget)) { + return; + } + setExpanded(false); + }, + [viewMode], + ); + const [dataFocusSignal] = useState(0); const [expectedFocusSignal, setExpectedFocusSignal] = useState(0); @@ -592,28 +631,42 @@ function TestStepContent({ editor.updateBlock(block.id, { props: { expectedResult: "" } }); }, [editor, block.id]); + const nextViewLabel = + viewMode === "compact" + ? "Switch to vertical view" + : viewMode === "horizontal" + ? "Switch to compact view" + : forceVertical + ? "Switch to compact view" + : "Switch to horizontal view"; + const viewToggleButton = ( ); - if (!effectiveVertical) { + if (effectiveHorizontal) { return ( +
{stepNumber}
-
+
- Step + {!compactMode && Step} {viewToggleButton}
(suggestion as StepSuggestion).isSnippet !== true} onFieldFocus={handleFieldFocus} enableImageUpload={false} @@ -683,6 +746,9 @@ function TestStepContent({ {isDataVisible ? ( void; + /** + * Reading-focused presentation: suppresses the toolbar and tightens the + * field so it reads like plain text. The OverType editor stays mounted so + * focusing the field (which expands the step) preserves the caret. + */ + compact?: boolean; + /** + * True whenever the step's view is compact, regardless of whether this field + * is currently collapsed or expanded. Used to drop the editor's tall + * min-height floor so reading rows hug their content — kept separate from + * `compact` so it stays stable across focus and never re-lays-out on expand. + */ + compactMode?: boolean; }; const READ_ONLY_ALLOWED_KEYS = new Set([ @@ -686,6 +699,8 @@ export function StepField({ showFormattingButtons = false, showImageButton = false, onFieldFocus, + compact = false, + compactMode = false, }: StepFieldProps) { const stepSuggestions = useStepAutocomplete(); const suggestions = suggestionsOverride ?? stepSuggestions; @@ -956,6 +971,32 @@ export function StepField({ }, []), }); + // In compact mode, drop OverType's tall min-height floor so reading rows hug + // their content. Mutating options.minHeight + recomputing avoids a re-init, + // so caret and value survive. Driven by the stable compactMode flag (not + // `compact`) so collapsed and expanded share one height — focusing never + // shifts the layout. + useEffect(() => { + const instance = editorInstanceRef.current as + | (OverTypeInstance & { + options?: { minHeight?: string }; + textarea?: HTMLTextAreaElement; + _updateAutoHeight?: () => void; + }) + | null; + if (!instance?.options) { + return; + } + instance.options.minHeight = compactMode ? "0px" : multiline ? "4rem" : "2.5rem"; + // A textarea's default rows=2 floors its scrollHeight at two lines, which + // autoResize then locks the box into; rows=1 lets compact rows hug a single + // line (autoResize still grows it for multi-line content). + if (instance.textarea) { + instance.textarea.rows = compactMode ? 1 : 2; + } + instance._updateAutoHeight?.(); + }, [compactMode, multiline, textareaNode]); + useEffect(() => { const instance = editorInstanceRef.current; if (!instance) { @@ -1752,10 +1793,11 @@ export function StepField({ .join(" "); const showToolbar = - showFormattingButtons || (enableImageUpload && uploadImage && showImageButton) || Boolean(rightAction) || enableAutocomplete; + !compact && + (showFormattingButtons || (enableImageUpload && uploadImage && showImageButton) || Boolean(rightAction) || enableAutocomplete); return ( -
+
{showLabel && (
diff --git a/src/editor/styles.css b/src/editor/styles.css index a614fa4..945aa6b 100644 --- a/src/editor/styles.css +++ b/src/editor/styles.css @@ -546,6 +546,99 @@ html.dark .bn-step-editor .overtype-wrapper .overtype-preview a.step-preview-lin min-height: 28px; } +/* Compact (reading-focused) view: tight rows with no chrome until a field is + focused, at which point the step expands to the normal editing layout. + + Structural rules below apply to the whole compact mode (both collapsed and + expanded) so that expanding a step never shifts the field's position — the + view-toggle floats out of flow and the editor keeps its default padding, so + focusing only reveals chrome *below* the field instead of pushing it down. */ +.bn-teststep--compact .bn-teststep__header { + position: absolute; + top: 0; + right: 0; + min-height: 0; + z-index: 2; +} + +/* Tighten the gap between the step title and its data/expected fields while + reading. Applied to the whole compact mode (not just collapsed) so the + spacing is identical when a field expands — no jump on focus. */ +.bn-teststep--compact .bn-teststep__content { + gap: 6px; +} + +.bn-teststep--compact .bn-teststep__content > .bn-teststep__header + .bn-step-field { + margin-top: 0; +} + +/* Collapsed-only rules below hide the remaining chrome for reading. None of + them change the field's top position, so toggling them on focus is jump-free + (the border only changes colour, min-height only grows the box downward). */ +.bn-teststep--collapsed .bn-step-field__input { + border-color: transparent; +} + +.bn-teststep--collapsed .bn-step-field__input:hover { + border-color: var(--step-input-border); +} + +/* Zero the whole min-height chain in compact mode so reading rows hug their + content. OverType floors the textarea height at its CSS min-height (which the + wrapper inherits as 100% of the editor's old 4rem box), so dropping only the + OverType minHeight option isn't enough — the textarea's own min-height must + go too. Applied to all of compact (not just collapsed) so the height is + identical when a field expands: no jump on focus. Vertical padding is also + tightened consistently for the same reason. */ +/* OverType injects its own stylesheet (after ours) that hard-codes + `.overtype-container.overtype-auto-resize .overtype-wrapper { min-height: 60px }` + with the same specificity we'd normally use, so it wins on source order. The + extra `.overtype-container` qualifier below raises our specificity above it. */ +.bn-teststep--compact .bn-step-field__input--multiline, +.bn-teststep--compact .bn-step-editor, +.bn-teststep--compact .bn-step-editor--multiline, +.bn-teststep--compact .bn-step-editor .overtype-container .overtype-wrapper, +.bn-teststep--compact .bn-step-editor .overtype-container .overtype-wrapper .overtype-input, +.bn-teststep--compact .bn-step-editor .overtype-container .overtype-wrapper .overtype-preview { + min-height: 0 !important; +} + +.bn-teststep--compact .bn-step-editor .overtype-wrapper .overtype-input, +.bn-teststep--compact .bn-step-editor .overtype-wrapper .overtype-preview { + padding: 4px 12px !important; +} + +.bn-teststep--collapsed .bn-step-actions { + display: none; +} + +/* Prefix the expected-result text with a small "Expected" label while reading, + so the line reads e.g. "Expected Login form is shown". OverType wraps each + line in its own block element, so the badge goes on the first line element to + sit inline before the text. It's a pseudo-element (not editable text), so it + never enters the serialized value. */ +.bn-teststep--collapsed [data-step-field="expected"] .overtype-wrapper .overtype-preview > :first-child::before { + content: "Expected"; + display: inline-block; + margin-right: 8px; + padding: 0 6px; + border-radius: 4px; + background: var(--step-bg-light); + color: var(--step-muted); + font-size: 11px; + font-weight: 600; + line-height: 18px; + vertical-align: 1px; +} + +.bn-teststep__view-toggle--compact svg { + color: var(--step-muted); +} + +html.dark .bn-teststep__view-toggle--compact svg { + color: var(--step-muted); +} + .bn-snippet .bn-step-field__input { border-color: var(--snippet-border-light);