import { useEffect, useMemo, useRef, useState } from "react"; import { BlockNoteView } from "@blocknote/mantine"; import { useCreateBlockNote, useEditorChange, SuggestionMenuController, getDefaultReactSlashMenuItems, useBlockNoteEditor, } from "@blocknote/react"; import { filterSuggestionItems, insertOrUpdateBlock, } from "@blocknote/core"; import { flip, offset, shift, size } from "@floating-ui/react"; import { blocksToMarkdown, markdownToBlocks, type CustomEditorBlock, } from "./editor/customMarkdownConverter"; import { createMarkdownPasteHandler } from "./editor/createMarkdownPasteHandler"; import { customSchema, type CustomEditor } from "./editor/customSchema"; import { tagBadgeExtension } from "./editor/tagBadge"; import { autoLinkExtension } from "./editor/autoLink"; import { exampleTableHighlightExtension } from "./editor/exampleTableHighlight"; import { setStepsFetcher, type StepJsonApiDocument } from "./editor/stepAutocomplete"; import { setSnippetFetcher, type SnippetJsonApiDocument } from "./editor/snippetAutocomplete"; import { setMentionSources, type MentionItem, type MentionSearchResult, type MentionSource, } from "./editor/mentionAutocomplete"; import { MentionMenu } from "./editor/MentionMenu"; import { setImageUploadHandler } from "./editor/stepImageUpload"; import { canInsertStepOrSnippet, addStepsBlock, addSnippetBlock } from "./editor/blocks/step"; import { addTestBlock } from "./editor/blocks/testMeta"; import "./App.css"; const focusStepField = ( editor: CustomEditor | null | undefined, blockId?: string, fieldName = "title", ) => { if (!editor || !blockId) { return; } const focus = () => { const stepTitle = document.querySelector( `[data-block-id="${blockId}"] [data-step-field="${fieldName}"]`, ); if (stepTitle) { stepTitle.focus(); return; } editor.setSelection(blockId, blockId); editor.setTextCursorPosition(blockId, "end"); editor.focus(); }; if (typeof requestAnimationFrame === "function") { requestAnimationFrame(focus); return; } setTimeout(focus, 0); }; type Schema = typeof customSchema; const DEMO_STEP_FIXTURES: StepJsonApiDocument = { data: [ { id: "145", type: "step", attributes: { labels: [], title: "Donec placerat, dui vitae", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 23, "comments-count": 0, }, }, { id: "146", type: "step", attributes: { labels: [], title: "Ut auctor mi erat ac dolor.", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 23, "comments-count": 0, }, }, { id: "147", type: "step", attributes: { labels: [], title: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit.", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 23, "comments-count": 0, }, }, { id: "148", type: "step", attributes: { labels: [], title: "Felis libero varius orci, in vulputate", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 19, "comments-count": 0, }, }, { id: "149", type: "step", attributes: { labels: [], title: "Massa turpis scelerisque diam.", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 19, "comments-count": 0, }, }, { id: "150", type: "step", attributes: { labels: [], title: "Nunc et felis est. Phasellus laoreet nibh vel augue", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 19, "comments-count": 0, }, }, { id: "151", type: "step", attributes: { labels: [], title: "Suspendisse interdum sem non sem cursus consequat.", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 17, "comments-count": 0, }, }, { id: "152", type: "step", attributes: { labels: [], title: "Aliquam tempor, nibh sed facilisis lacinia, nisl velit aliquet nunc.", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 16, "comments-count": 0, }, }, { id: "153", type: "step", attributes: { labels: [], title: "Praesent tellus neque, efficitur vel hendrerit sed, porta id sapien.", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 14, "comments-count": 0, }, }, { id: "154", type: "step", attributes: { labels: [], title: "Maecenas suscipit lacus vitae viverra fermentum.", kind: "manual", description: null, keywords: [], "is-snippet": null, "usage-count": 12, "comments-count": 0, }, }, { id: "301", type: "step", attributes: { labels: ["snippet"], title: "Open login page and wait for ready state", kind: "snippet", description: "Reusable login navigation snippet", keywords: ["login", "auth"], "is-snippet": true, "usage-count": 41, "comments-count": 2, }, }, { id: "302", type: "step", attributes: { labels: ["snippet"], title: "Fill credentials with provided user object", kind: "snippet", description: "Populate form fields from test data", keywords: ["form", "user"], "is-snippet": true, "usage-count": 35, "comments-count": 1, }, }, { id: "303", type: "step", attributes: { labels: ["snippet"], title: "Verify toast message disappears", kind: "snippet", description: "Shared assertion for ephemeral UI", keywords: ["toast", "assertion"], "is-snippet": true, "usage-count": 18, "comments-count": 0, }, }, ], }; const DEMO_SNIPPET_FIXTURES: SnippetJsonApiDocument = { data: [ { id: "501", type: "snippet", attributes: { title: "Login setup", body: "Open /login\nWait for form to render\nEnsure no console errors", description: "Navigate to login and wait for readiness", "usage-count": 12, "is-snippet": true, }, }, { id: "502", type: "snippet", attributes: { title: "Fill credentials", body: "Type email\nType password\nClick Sign In", description: "Reusable credentials filler", "usage-count": 9, "is-snippet": true, }, }, { id: "503", type: "snippet", attributes: { title: "Verify toast disappears", body: "Assert toast visible\nWait 3s\nAssert toast removed", description: "Shared assertion for ephemeral notifications", "usage-count": 7, "is-snippet": true, }, }, ], }; const DEMO_USERS: MentionItem[] = [ { id: "u-davert", label: "davert", detail: "Michael Bodnarchuk" }, { id: "u-anna", label: "anna", detail: "Anna QA" }, { id: "u-taras", label: "taras", detail: "Taras Manager" }, { id: "u-siren", label: "siern", detail: "Sirena QA" }, { id: "u-bob", label: "bob", detail: "Bob Tester" }, { id: "u-carol", label: "carol", detail: "Carol Dev" }, { id: "u-dan", label: "dan", detail: "Dan Ops" }, { id: "u-eve", label: "eve", detail: "Eve Security" }, ]; // Simulated "tests" API dataset — insertion uses the numeric id -> "@T{id}". const DEMO_TESTS: MentionItem[] = [ { id: "10231", label: "Login redirects to dashboard", detail: "Given a registered user with valid credentials, when they submit the login form, the app authenticates against the API, persists the session token, and redirects to the dashboard within 2 seconds. Verifies the welcome banner shows the user's name and that the navigation reflects an authenticated state.", }, { id: "10232", label: "Login rejects wrong password", detail: "Given a registered user, when they submit the login form with an incorrect password, the app keeps them on the login page, shows an inline 'Invalid email or password' error, does not create a session, and increments the failed-attempt counter used for rate limiting after five consecutive failures.", }, { id: "10245", label: "Logout clears the session", detail: "Given an authenticated user on any page, when they trigger logout, the app revokes the session token server-side, clears local storage and cookies, redirects to the login screen, and ensures that pressing the browser back button does not restore access to protected routes.", }, { id: "10310", label: "Checkout applies discount code", detail: "#10310" }, { id: "10311", label: "Checkout blocks empty cart", detail: "#10311" }, { id: "10420", label: "Profile avatar upload", detail: "#10420" }, { id: "10421", label: "Profile email change requires confirm", detail: "#10421" }, { id: "10530", label: "Password reset sends email", detail: "#10530" }, ]; const DEMO_SUITES: MentionItem[] = [ { id: "1001", label: "Authentication", detail: "End-to-end coverage of the authentication surface: login with valid and invalid credentials, session persistence and expiry, logout and token revocation, password reset via email, rate limiting after repeated failures, and redirect behaviour for protected routes when unauthenticated.", }, { id: "1002", label: "Checkout flow", detail: "Covers the full purchase journey from cart to confirmation: applying and rejecting discount codes, blocking checkout on an empty cart, tax and shipping calculation, payment authorization and failure handling, and the order confirmation screen with a valid receipt number.", }, { id: "1003", label: "User profile", detail: "#1003" }, { id: "1004", label: "Notifications", detail: "#1004" }, { id: "1005", label: "Admin panel", detail: "#1005" }, ]; const demoDelay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); /** * Demo @-mention config. Prefixes are fully data-driven: @T / @S hit a * (simulated) API and insert an id token, while a bare @ filters the in-memory * user list and inserts the handle. */ const DEMO_MENTION_SOURCES: MentionSource[] = [ { prefix: "T", label: "Tests", search: async (query): Promise => { await demoDelay(120); // simulate an API round-trip const q = query.toLowerCase(); return DEMO_TESTS.filter((test) => test.label.toLowerCase().includes(q)); }, // default insert -> "@T{id}", e.g. "@T10231" }, { prefix: "S", label: "Suites", search: async (query): Promise => { await demoDelay(120); const q = query.toLowerCase(); return DEMO_SUITES.filter((suite) => suite.label.toLowerCase().includes(q)); }, // default insert -> "@S{id}", e.g. "@S1001" }, { prefix: "", label: "Users", items: DEMO_USERS, insert: (item) => `@${item.label}`, // insert -> "@davert" }, ]; function CustomSlashMenu() { const editor = useBlockNoteEditor(); if (!editor) { return null; } const getItems = async (query: string) => { const isMac = typeof navigator !== "undefined" && (/Mac/.test(navigator.platform) || (/AppleWebKit/.test(navigator.userAgent) && /Mobile\/\w+/.test(navigator.userAgent))); const defaultItems = getDefaultReactSlashMenuItems(editor).map((item) => { if (item.badge && isMac) { return { ...item, badge: item.badge.replace("Alt", "Option") }; } return item; }); const stepItem = { key: "test_step" as any, title: "Test Step", subtext: "Capture an action with its expected result", group: "Test documentation", icon: TS, aliases: ["step", "test step", "expected"], onItemClick: () => { const inserted = insertOrUpdateBlock(editor, { type: "testStep", props: { stepTitle: "", stepData: "", expectedResult: "", }, }); focusStepField(editor, inserted.id, "title"); }, }; const snippetItem = { key: "snippet" as any, title: "Snippet", subtext: "Insert a reusable snippet with data and an expected result", group: "Test documentation", icon: SN, aliases: ["snippet", "reusable step"], onItemClick: () => { const inserted = insertOrUpdateBlock(editor, { type: "snippet", props: { snippetId: "", snippetTitle: "", snippetData: "", snippetExpectedResult: "", }, }); focusStepField(editor, inserted.id, "snippet-title"); }, }; const addTestItem = { key: "add_test" as any, title: "Add Test", subtext: "Insert test metadata (id, priority, tags…)", group: "Test documentation", icon: @T, aliases: ["test", "metadata", "meta", "test id"], onItemClick: () => { const headingId = addTestBlock(editor); focusStepField(editor, headingId ?? undefined); }, }; const currentBlock = editor.getTextCursorPosition().block; const canInsert = canInsertStepOrSnippet(editor, currentBlock.id); const items = canInsert ? [...defaultItems, stepItem, snippetItem, addTestItem] : [...defaultItems, addTestItem]; return filterSuggestionItems(items, query); }; return ( ); } function App() { const editor = useCreateBlockNote({ schema: customSchema, extensions: [tagBadgeExtension(), autoLinkExtension(), exampleTableHighlightExtension()], pasteHandler: createMarkdownPasteHandler(markdownToBlocks), uploadFile: async (file: File) => { const url = `https://placehold.co/600x400?text=${encodeURIComponent(file.name)}`; return { props: { url, name: file.name, }, }; }, }); const [markdown, setMarkdown] = useState(""); const [conversionError, setConversionError] = useState(null); const [blocksJson, setBlocksJson] = useState("[]"); const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">( "idle", ); const [copyBlocksStatus, setCopyBlocksStatus] = useState<"idle" | "copied" | "failed">( "idle", ); const [darkMode, setDarkMode] = useState(false); const [customTheme, setCustomTheme] = useState(false); const [suggestWhileTyping, setSuggestWhileTyping] = useState(true); const [markdownInput, setMarkdownInput] = useState(""); const [inputError, setInputError] = useState(null); useEffect(() => { 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 | null>(null); useEditorChange((editorInstance) => { 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; } const unsubscribe = editor.onChange((instance, context) => { const changes = context.getChanges(); const newlyInsertedAction = changes.find(({ type, block, source }) => { if (type !== "insert") { return false; } if (source?.type === "yjs-remote") { return false; } if (block.type === "testStep") { return ((block.props as any)?.stepTitle ?? "") === ""; } if (block.type === "snippet") { return ((block.props as any)?.snippetTitle ?? "") === ""; } return false; }); if (newlyInsertedAction) { const fieldName = newlyInsertedAction.block.type === "snippet" ? "snippet-title" : "title"; focusStepField(instance, newlyInsertedAction.block.id, fieldName); } }); return () => { if (typeof unsubscribe === "function") { unsubscribe(); } }; }, [editor]); const uploadStepImage = useMemo( () => async (_image: Blob) => ({ url: `https://placehold.co/600x400?text=Uploaded+${Date.now()}` }), [], ); useEffect(() => { // Demo defaults: configure global handlers so the editor works without manual providers. setStepsFetcher(() => DEMO_STEP_FIXTURES); setSnippetFetcher(() => DEMO_SNIPPET_FIXTURES); setMentionSources(DEMO_MENTION_SOURCES); const handler = editor?.uploadFile ? async (file: Blob) => { const result = await editor.uploadFile!(file as File); if (typeof result === "string") { return { url: result }; } if (result && typeof result === "object") { if ("url" in result && typeof (result as any).url === "string") { return { url: (result as any).url as string }; } const propsUrl = (result as any).props?.url; if (typeof propsUrl === "string") { return { url: propsUrl }; } } throw new Error("uploadFile did not return a URL"); } : uploadStepImage; setImageUploadHandler(handler); return () => { setStepsFetcher(null); setSnippetFetcher(null); setImageUploadHandler(null); setMentionSources(null); }; }, [editor, uploadStepImage]); const insertTestStep = () => { const id = addStepsBlock(editor); if (id) focusStepField(editor, id, "title"); }; const insertSnippet = () => { const id = addSnippetBlock(editor); if (id) focusStepField(editor, id, "snippet-title"); }; const insertTest = () => { const id = addTestBlock(editor); if (id) focusStepField(editor, id); }; // Load the pasted Markdown as the whole document, the way a host app does on // initial load (unlike pasting into the editor, which goes through the paste handler). const handleApplyMarkdown = () => { try { const blocks = markdownToBlocks(markdownInput); editor.replaceBlocks( editor.document, blocks.length > 0 ? blocks : [{ type: "paragraph" }], ); setInputError(null); } catch (error) { setInputError(error instanceof Error ? error.message : String(error)); } }; const handleCopyMarkdown = async () => { if (conversionError) { return; } try { if ( typeof navigator !== "undefined" && navigator?.clipboard?.writeText ) { await navigator.clipboard.writeText(markdown); } else { const textArea = document.createElement("textarea"); textArea.value = markdown; textArea.style.position = "fixed"; textArea.style.opacity = "0"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); document.execCommand("copy"); document.body.removeChild(textArea); } setCopyStatus("copied"); if (typeof window !== "undefined") { window.setTimeout(() => setCopyStatus("idle"), 2000); } } catch (error) { console.error("Failed to copy markdown", error); setCopyStatus("failed"); if (typeof window !== "undefined") { window.setTimeout(() => setCopyStatus("idle"), 2000); } } }; const handleCopyBlocks = async () => { if (conversionError) { return; } try { if ( typeof navigator !== "undefined" && navigator?.clipboard?.writeText ) { await navigator.clipboard.writeText(blocksJson); } else { const textArea = document.createElement("textarea"); textArea.value = blocksJson; textArea.style.position = "fixed"; textArea.style.opacity = "0"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); document.execCommand("copy"); document.body.removeChild(textArea); } setCopyBlocksStatus("copied"); if (typeof window !== "undefined") { window.setTimeout(() => setCopyBlocksStatus("idle"), 2000); } } catch (error) { console.error("Failed to copy block JSON", error); setCopyBlocksStatus("failed"); if (typeof window !== "undefined") { window.setTimeout(() => setCopyBlocksStatus("idle"), 2000); } } }; return (

BlockNote Custom Block Playground

Explore custom blocks for documenting test cases and actionable steps, then convert the document to Markdown using a bespoke serializer.