Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 33 additions & 13 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { BlockNoteView } from "@blocknote/mantine";
import {
useCreateBlockNote,
Expand Down Expand Up @@ -411,22 +411,42 @@ function App() {
document.documentElement.classList.toggle("dark", darkMode);
}, [darkMode]);

// Re-serializing the whole document (Markdown + pretty JSON) on every change
// is wasteful during bursts like a large paste, where the document mutates
// many times in quick succession (chunked streaming). Debounce so the preview
// panels update once the document settles instead of on every intermediate
// edit, keeping the editor responsive.
const serializeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEditorChange((editorInstance) => {
try {
const documentBlocks = editorInstance.document as CustomEditorBlock[];
const md = blocksToMarkdown(documentBlocks);
setMarkdown(md);
setBlocksJson(JSON.stringify(documentBlocks, null, 2));
setConversionError(null);
setCopyStatus("idle");
setCopyBlocksStatus("idle");
} catch (error) {
setConversionError(error instanceof Error ? error.message : String(error));
setCopyStatus("idle");
setCopyBlocksStatus("idle");
if (serializeTimerRef.current !== null) {
clearTimeout(serializeTimerRef.current);
}
serializeTimerRef.current = setTimeout(() => {
serializeTimerRef.current = null;
try {
const documentBlocks = editorInstance.document as CustomEditorBlock[];
const md = blocksToMarkdown(documentBlocks);
setMarkdown(md);
setBlocksJson(JSON.stringify(documentBlocks, null, 2));
setConversionError(null);
setCopyStatus("idle");
setCopyBlocksStatus("idle");
} catch (error) {
setConversionError(error instanceof Error ? error.message : String(error));
setCopyStatus("idle");
setCopyBlocksStatus("idle");
}
}, 120);
}, editor);

useEffect(() => {
return () => {
if (serializeTimerRef.current !== null) {
clearTimeout(serializeTimerRef.current);
}
};
}, []);

useEffect(() => {
if (!editor) {
return;
Expand Down
245 changes: 198 additions & 47 deletions src/editor/blocks/step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createReactBlockSpec, useEditorChange } from "@blocknote/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { StepField } from "./stepField";
import { StepHorizontalView } from "./stepHorizontalView";
import { useDeferredMount } from "./useDeferredMount";
import { useStepImageUpload } from "../stepImageUpload";
import type { StepSuggestion } from "../stepAutocomplete";

Expand Down Expand Up @@ -227,27 +228,176 @@ export function addSnippetBlock(editor: {
return inserted?.[1]?.id ?? null;
}

export const stepBlock = createReactBlockSpec(
{
type: "testStep",
content: "none",
propSchema: {
stepTitle: {
default: "",
},
stepData: {
default: "",
},
expectedResult: {
default: "",
},
listStyle: {
default: "bullet",
},
},
},
{
render: ({ block, editor }) => {
/**
* A test step's 1-based position within its group: count back over preceding
* steps (blank lines don't break the run) until a non-step block.
*/
export function computeStepNumber(allBlocks: any[], blockId: string): number {
const blockIndex = allBlocks.findIndex((b) => b.id === blockId);
if (blockIndex < 0) return 1;

let count = 1;
for (let i = blockIndex - 1; i >= 0; i--) {
const b = allBlocks[i];
if (b.type === "testStep") {
count++;
} else if (isEmptyParagraph(b)) {
continue;
} else {
break;
}
}
return count;
}

/** Strip the most common inline markdown markers for a readable static preview. */
function stripMarkdownForPreview(text: string): string {
return text
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/(\*\*|__|\*|_|~~|`)/g, "")
.replace(/<\/?[^>]+>/g, "")
.trim();
}

/**
* Cheap static stand-in shown before a step's interactive editor is mounted.
* Mirrors the real step's structure/typography so the document height stays
* stable and so it reads correctly during the brief window before upgrade.
*/
function TestStepPreview({
blockId,
stepNumber,
stepTitle,
stepData,
expectedResult,
}: {
blockId: string;
stepNumber: number;
stepTitle: string;
stepData: string;
expectedResult: string;
}) {
const titleText = stripMarkdownForPreview(stepTitle);
const dataText = stripMarkdownForPreview(stepData);
const expectedText = stripMarkdownForPreview(expectedResult);

return (
<div className="bn-teststep" data-block-id={blockId}>
<div className="bn-teststep__timeline">
<span className="bn-teststep__number">{stepNumber}</span>
<div className="bn-teststep__line" />
</div>
<div className="bn-teststep__content">
<div className="bn-teststep__header">
<span className="bn-teststep__title">Step</span>
</div>
<div className="bn-step-field">
<div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
{titleText || " "}
</div>
</div>
{dataText ? (
<div className="bn-step-field">
<div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
{dataText}
</div>
</div>
) : null}
{expectedText ? (
<div className="bn-step-field">
<div className="bn-step-editor bn-step-editor--multiline bn-step-editor--preview">
{expectedText}
</div>
</div>
) : null}
</div>
</div>
);
}

/**
* Wrapper that defers mounting the (expensive) interactive step editor until
* the block scrolls into view. Off-screen steps render {@link TestStepPreview}
* instead, which is what keeps pasting/loading a large test document fast.
*
* The step number is tracked here and pushed down as a prop. We subscribe to
* editor changes but bail out of re-rendering when the number is unchanged, so
* ordinary text edits don't re-render every step in the document.
*/
function TestStepBlock({ block, editor }: { block: any; editor: any }) {
// An empty step is almost always a freshly-inserted one that needs to focus
// its title immediately, so mount its real editor eagerly. Steps with content
// (e.g. from a large paste) can safely start as a cheap preview.
const isEmptyStep =
!((block.props.stepTitle as string) || "") &&
!((block.props.stepData as string) || "") &&
!((block.props.expectedResult as string) || "");
const { ref, active, activate, shouldFocusOnActivate } = useDeferredMount<HTMLDivElement>({
initiallyActive: isEmptyStep,
});
const [stepNumber, setStepNumber] = useState(() =>
computeStepNumber(editor.document, block.id),
);

useEditorChange(() => {
// Recompute on change, but bail out of the state update (and therefore the
// re-render) when the number is unchanged. This is the key win: ordinary
// text edits leave every step's number untouched, so they don't re-render
// the whole step list.
const next = computeStepNumber(editor.document, block.id);
setStepNumber((prev) => (prev === next ? prev : next));
}, editor);

if (active) {
// Empty steps mounted eagerly (freshly inserted) auto-focus their title.
// A preview upgraded by a click focuses its field too, so a single click
// starts editing. Steps upgraded passively (scroll-into-view, hover
// pre-warm) must never steal focus.
return (
<TestStepContent
block={block}
editor={editor}
stepNumber={stepNumber}
autoFocusEnabled={isEmptyStep}
focusOnMount={shouldFocusOnActivate}
/>
);
}

return (
<div
ref={ref}
onMouseDownCapture={() => activate(true)}
onFocusCapture={() => activate(true)}
>
<TestStepPreview
blockId={block.id}
stepNumber={stepNumber}
stepTitle={(block.props.stepTitle as string) || ""}
stepData={(block.props.stepData as string) || ""}
expectedResult={(block.props.expectedResult as string) || ""}
/>
</div>
);
}

function TestStepContent({
block,
editor,
stepNumber,
autoFocusEnabled = false,
focusOnMount = false,
}: {
block: any;
editor: any;
stepNumber: number;
autoFocusEnabled?: boolean;
focusOnMount?: boolean;
}) {
// When a preview is upgraded by a click, focus its primary field once on
// mount so a single click starts editing (caret at end).
const mountFocusSignal = focusOnMount ? 1 : 0;
const stepTitle = (block.props.stepTitle as string) || "";
const stepData = (block.props.stepData as string) || "";
const expectedResult = (block.props.expectedResult as string) || "";
Expand All @@ -259,7 +409,6 @@ export const stepBlock = createReactBlockSpec(
);
const [isDataVisible, setIsDataVisible] = useState(dataHasContent);
const [shouldFocusDataField, setShouldFocusDataField] = useState(false);
const [documentVersion, setDocumentVersion] = useState(0);
const uploadImage = useStepImageUpload();
const [viewMode, setViewMode] = useState<StepViewMode>(() => readStepViewMode());
const containerRef = useRef<HTMLDivElement>(null);
Expand All @@ -279,30 +428,6 @@ export const stepBlock = createReactBlockSpec(

const effectiveVertical = forceVertical || viewMode === "vertical";

// Calculate step number based on position in document
const stepNumber = useMemo(() => {
const allBlocks = editor.document;
const blockIndex = allBlocks.findIndex((b) => b.id === block.id);
if (blockIndex < 0) return 1;

let count = 1;
for (let i = blockIndex - 1; i >= 0; i--) {
const b = allBlocks[i];
if (b.type === "testStep") {
count++;
} else if (isEmptyParagraph(b)) {
continue;
} else {
break;
}
}
return count;
}, [block.id, documentVersion, editor.document]);

useEditorChange(() => {
setDocumentVersion((version) => version + 1);
}, editor);

useEffect(() => {
if (typeof window === "undefined") {
return;
Expand Down Expand Up @@ -501,6 +626,7 @@ export const stepBlock = createReactBlockSpec(
onInsertNextStep={handleInsertNextStep}
onFieldFocus={handleFieldFocus}
viewToggle={viewToggleButton}
focusSignal={mountFocusSignal}
/>
);
}
Expand All @@ -522,7 +648,8 @@ export const stepBlock = createReactBlockSpec(
value={stepTitle}
placeholder={STEP_TITLE_PLACEHOLDER}
onChange={handleStepTitleChange}
autoFocus={stepTitle.length === 0}
autoFocus={autoFocusEnabled && stepTitle.length === 0}
focusSignal={mountFocusSignal}
multiline
disableNewlines
enableAutocomplete
Expand Down Expand Up @@ -634,6 +761,30 @@ export const stepBlock = createReactBlockSpec(
</div>
</div>
);
}

export const stepBlock = createReactBlockSpec(
{
type: "testStep",
content: "none",
propSchema: {
stepTitle: {
default: "",
},
stepData: {
default: "",
},
expectedResult: {
default: "",
},
listStyle: {
default: "bullet",
},
},
},
{
render: ({ block, editor }) => (
<TestStepBlock block={block} editor={editor} />
),
},
);
3 changes: 3 additions & 0 deletions src/editor/blocks/stepHorizontalView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type StepHorizontalViewProps = {
onInsertNextStep: () => void;
onFieldFocus: () => void;
viewToggle?: ReactNode;
focusSignal?: number;
};

export const StepHorizontalView = forwardRef<HTMLDivElement, StepHorizontalViewProps>(function StepHorizontalView({
Expand All @@ -27,6 +28,7 @@ export const StepHorizontalView = forwardRef<HTMLDivElement, StepHorizontalViewP
onInsertNextStep,
onFieldFocus,
viewToggle,
focusSignal,
}, ref) {
return (
<div className="bn-teststep bn-teststep--horizontal" data-block-id={blockId} ref={ref}>
Expand All @@ -42,6 +44,7 @@ export const StepHorizontalView = forwardRef<HTMLDivElement, StepHorizontalViewP
value={stepValue}
onChange={onStepChange}
placeholder={STEP_PLACEHOLDER}
focusSignal={focusSignal}
enableAutocomplete
fieldName="title"
suggestionFilter={(suggestion) => (suggestion as StepSuggestion).isSnippet !== true}
Expand Down
Loading
Loading